Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Rewind/MapView/MapAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ final class MapAdapter: NSObject, MKMapViewDelegate, UIGestureRecognizerDelegate
private var pipe = SignalPipe<Event>()
private var showYearColorInClusters: ObservableVariable<Bool>

var size: CGSize {
map.value.frame.size
}

var view: UIView {
map.value
}
Expand Down
4 changes: 2 additions & 2 deletions Rewind/Model/CameraSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@ final class CameraSession {
}

func setLens(lens: Lens, animated: Bool) throws {
let clamped = lens.zoomValue.clamped(
in: device.minAvailableVideoZoomFactor...device.maxAvailableVideoZoomFactor
let clamped = lens.zoomValue.clamp(
device.minAvailableVideoZoomFactor...device.maxAvailableVideoZoomFactor
)
try device.lockForConfiguration()
defer { device.unlockForConfiguration() }
Expand Down
2 changes: 2 additions & 0 deletions Rewind/Model/Data Types/ModelImage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

import UIKit

enum Model {} // namespace only

extension Model {
struct Image {
var cid: Int
Expand Down
14 changes: 9 additions & 5 deletions Rewind/Model/LocalClustering.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,14 @@ func makeDiffAfterReceived(
images: [Model.Image],
clusters: [Model.Cluster],
params: AnnotationLoadingParams,
mapSize: CGSize,
state: inout MapState
) -> (
toAdd: [AnnotationValue],
toRemove: [AnnotationValue]
) {
let shouldClearOldAnnotations = if let lastParams = state.lastLoadedParams {
lastParams.region.zoom != params.region.zoom
lastParams.zoom != params.zoom
|| lastParams.yearRange != params.yearRange
} else {
false
Expand Down Expand Up @@ -70,13 +71,15 @@ func makeDiffAfterReceived(
toRemove += staleImages.map { .image($0) }
state.clusteredImages = groupImages(
images: freeImages.intersection(receivedImages),
zoom: params.region.zoom
zoom: params.zoom,
mapSize: mapSize
).map(key: { $0 }, value: { .left($0) })
}

let groupedImages = groupImages(
images: receivedImages,
zoom: params.region.zoom
zoom: params.zoom,
mapSize: mapSize
)
let patches = makePatches(
newImages: groupedImages,
Expand All @@ -93,9 +96,10 @@ func makeDiffAfterReceived(

private func groupImages(
images: any Sequence<Model.Image>,
zoom: Int
zoom: Int,
mapSize: CGSize
) -> [ClusteringCell: Set<Model.Image>] {
let size = delta(zoom: zoom) / clusteringCellRatio
let size = delta(zoom: zoom, mapSize: mapSize) / clusteringCellRatio
return images.reduce(into: [:]) { result, image in
let cell = ClusteringCell(
latIndex: Int(floor(image.coordinate.latitude / size)),
Expand Down
21 changes: 16 additions & 5 deletions Rewind/Model/MapModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,14 @@ func makeMapModel(
if settings.value.openClusterPreviews {
performAppAction(.imageDetails(.present(cluster.preview, source: "annotation")))
} else {
let mapSize = mapAdapter.size
let currentZoom = Rewind.zoom(region: state.region, mapSize: mapSize)
mapAdapter.set(
region: Region(center: cluster.coordinate, zoom: state.region.zoom + 1),
region: Region(
center: cluster.coordinate,
zoom: currentZoom + 1,
mapSize: mapSize
),
animated: true
)
}
Expand Down Expand Up @@ -150,7 +156,7 @@ func makeMapModel(
case .locationButtonTapped:
if let location = state.locationState.location {
mapAdapter.set(
region: Region(center: location.coordinate, zoom: 15),
region: Region(center: location.coordinate, zoom: 17, mapSize: mapAdapter.size),
animated: true
)
} else if state.locationState.isAccessGranted == false {
Expand All @@ -167,13 +173,13 @@ func makeMapModel(
mapAdapter.deselectAnnotations()
case let .focusOn(coordinate, zoom):
mapAdapter.set(
region: Region(center: coordinate, zoom: zoom),
region: Region(center: coordinate, zoom: zoom, mapSize: mapAdapter.size),
animated: true
)
case let .newLocationState(locationState):
if let location = locationState.location, state.locationState.location == nil {
mapAdapter.set(
region: Region(center: location.coordinate, zoom: 15),
region: Region(center: location.coordinate, zoom: 15, mapSize: mapAdapter.size),
animated: false
)
}
Expand All @@ -192,7 +198,11 @@ func makeMapModel(
))
case .loadAnnotations:
state.isLoading = true
let params = AnnotationLoadingParams(region: state.region, yearRange: state.yearRange)
let params = AnnotationLoadingParams(
region: state.region,
yearRange: state.yearRange,
mapSize: mapAdapter.size
)
enqueueEffect(.perform(id: EffectID.loadAnnotations) { anotherAction in
do {
let (images, clusters) = try await annotationsRemote.load(params)
Expand All @@ -212,6 +222,7 @@ func makeMapModel(
images: images,
clusters: clusters,
params: params,
mapSize: mapAdapter.size,
state: &state
)
state.isLoading = false
Expand Down
21 changes: 17 additions & 4 deletions Rewind/Model/RewindRemotes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,21 @@ struct RewindRemotes {
}

struct AnnotationLoadingParams {
var region: Region
var zoom: Int
var coordinates: [[Double]]
var startAt: TimeInterval
var yearRange: ClosedRange<Int>

init(
region: Region,
yearRange: ClosedRange<Int>,
mapSize: CGSize
) {
self.zoom = Rewind.zoom(region: region, mapSize: mapSize)
self.coordinates = region.geoJSONCoordinates
self.startAt = Date().timeIntervalSince1970
self.yearRange = yearRange
}
}

extension RewindRemotes {
Expand All @@ -26,9 +39,9 @@ extension RewindRemotes {
annotations = Remote { params in
let (nis, ncs) = try await requestPerformer.perform(
request: .byBounds(
zoom: params.region.zoom,
coordinates: params.region.geoJSONCoordinates,
startAt: Date().timeIntervalSince1970,
zoom: params.zoom,
coordinates: params.coordinates,
startAt: params.startAt,
yearRange: params.yearRange
)
)
Expand Down
47 changes: 22 additions & 25 deletions Rewind/Model/Zoom.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,35 @@
//

import MapKit

enum Model {} // namespace only
import VGSL

// https://leafletjs.com/examples/zoom-levels/
private func zoom(delta: Double) -> Int {
Int((2 + log2(180 / delta)).rounded(.toNearestOrAwayFromZero))
func zoom(region: Region, mapSize: CGSize) -> Int {
let delta = min(region.span.latitudeDelta, region.span.longitudeDelta)
return Int(
log2(360 / delta).rounded(.toNearestOrAwayFromZero)
+ adjustment(mapSize: mapSize)
).clamp(3...19)
}

func delta(zoom: Int) -> Double {
180 / pow(2, Double(zoom - 2))
func delta(zoom: Int, mapSize: CGSize) -> Double {
360 / pow(2, Double(zoom) - adjustment(mapSize: mapSize))
}

extension Region {
var zoom: Int {
let delta = min(span.latitudeDelta, span.longitudeDelta)
return Rewind.zoom(delta: delta).clamped(in: minZoom...maxZoom)
}
private func adjustment(mapSize: CGSize) -> Double {
let x = min(mapSize.width, mapSize.height)
let (x1, y1) = (375.0, 0.7) // min adjustment for small screens
let (x2, y2) = (1024.0, 1.5) // max adjustment for large screens
Comment on lines +25 to +27
Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The adjustment function implements a linear interpolation formula but lacks documentation explaining the rationale behind the chosen screen size thresholds (375px and 1024px) and adjustment values (0.7 and 1.5). Adding a comment to explain why these specific values were chosen would improve code maintainability and help future developers understand the zoom adjustment logic.

Suggested change
let x = min(mapSize.width, mapSize.height)
let (x1, y1) = (375.0, 0.7) // min adjustment for small screens
let (x2, y2) = (1024.0, 1.5) // max adjustment for large screens
// Linearly adjust the zoom level based on the smaller screen dimension so that
// roughly the same amount of map content is visible across devices:
// - 375pt is a typical "small" iPhone width; on these screens we apply a smaller
// zoom offset (0.7) so we don't over-zoom and hide too much context.
// - 1024pt is a typical "large" iPad width; on these screens we apply a larger
// zoom offset (1.5) so the map does not look too "zoomed out" on big displays.
// For intermediate sizes we interpolate linearly between these endpoints.
let x = min(mapSize.width, mapSize.height)
let (x1, y1) = (375.0, 0.7) // lower bound: small-phone screens
let (x2, y2) = (1024.0, 1.5) // upper bound: large-tablet screens

Copilot uses AI. Check for mistakes.
return (y2 - y1) / (x2 - x1) * (x - x1) + y1
Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The adjustment function uses linear interpolation without clamping the result. When the screen size is smaller than 375 pixels (x1), the function will return values less than 0.7. Similarly, for screens larger than 1024 pixels (x2), it will return values greater than 1.5. This could lead to unexpected zoom behavior on very small or very large screens. Consider clamping the result to the range 0.7...1.5 to ensure the adjustment stays within the intended bounds.

Suggested change
return (y2 - y1) / (x2 - x1) * (x - x1) + y1
let value = (y2 - y1) / (x2 - x1) * (x - x1) + y1
return max(y1, min(y2, value))

Copilot uses AI. Check for mistakes.
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the screen size is smaller than 375 pixels (x1), the function will return values less than 0.7. Similarly, for screens larger than 1024 pixels (x2), it will return values greater than 1.5.

sounds ok

}

init(center: Coordinate, zoom: Int) {
let delta = delta(zoom: zoom)
extension Region {
init(
center: Coordinate,
zoom: Int,
mapSize: CGSize
) {
let delta = delta(zoom: zoom, mapSize: mapSize)
self.init(
center: center,
span: MKCoordinateSpan(
Expand All @@ -35,15 +44,3 @@ extension Region {
)
}
}

extension Comparable {
func clamped(in range: ClosedRange<Self>) -> Self {
let lowerBound = range.lowerBound
let upperBound = range.upperBound

return min(upperBound, max(lowerBound, self))
}
}

private let minZoom = 3
private let maxZoom = 19
2 changes: 1 addition & 1 deletion Rewind/PastvuColors.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ extension RGBAColor {

extension Gradient {
fileprivate func color(at rawT: CGFloat) -> RGBAColor {
let t = rawT.clamped(in: 0...1)
let t = rawT.clamp(0...1)
guard let index = binSearch(firstEqualOrGreaterThan: t, keyPath: \.position, in: self) else {
return self.last!.color
}
Expand Down
2 changes: 1 addition & 1 deletion Rewind/View/MapControlsHiding.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ private struct MinimizableContainer: ViewModifier {
.updating($pulling) { value, pulling, _ in
if value.isVertical,
state.isNormal {
pulling.progress = (value.translation.height / -minPullLength).clamped(in: 0...1)
pulling.progress = (value.translation.height / -minPullLength).clamp(0...1)
if pulling.progress.isApproximatelyEqualTo(1), !pulling.handled {
onPull()
pulling.handled = true
Expand Down
Loading