From 71151da4815c88ee90022b50a44e525d6994f76a Mon Sep 17 00:00:00 2001 From: Dennis Falling Date: Fri, 31 Jul 2026 16:15:56 +0100 Subject: [PATCH] Give map taps to the pin they landed on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MapLibre reports a press on the pin layer by handing over every feature rendered within a 44pt box around the touch, and a pin's queryable area is its whole image, shadow padding included — so a tap near two pins comes back with both, in an order the library doesn't define. Taking the first of them gave the tap to an arbitrary pin: often a neighbour a pin's width away. Nor was there a pin "on top" to prefer instead: every unselected pin had the same sort key, leaving the stacking of two overlapping pins to the order their features happened to arrive in. A pin you couldn't see could take the tap and nothing was wrong. So stack the pins deliberately, by latitude, and measure the candidates against where they are really drawn: the pin whose outline the touch is inside wins, the front one where two overlap, the nearest otherwise. A touch with only one candidate is still taken as-is, so an isolated pin stays as forgiving to hit as MapLibre's hitbox makes it. Co-Authored-By: Claude Opus 5 (1M context) --- __tests__/pinHitTest.test.ts | 211 +++++++++++++++++++++++++++++++++ src/map/MapScreen.tsx | 52 +++++++-- src/map/PinIcon.tsx | 7 +- src/map/pinHitTest.ts | 220 +++++++++++++++++++++++++++++++++++ 4 files changed, 477 insertions(+), 13 deletions(-) create mode 100644 __tests__/pinHitTest.test.ts create mode 100644 src/map/pinHitTest.ts diff --git a/__tests__/pinHitTest.test.ts b/__tests__/pinHitTest.test.ts new file mode 100644 index 0000000..12760b7 --- /dev/null +++ b/__tests__/pinHitTest.test.ts @@ -0,0 +1,211 @@ +/** + * @format + */ + +import {PIN_DIAGONAL, PIN_SELECTED_SCALE, PIN_SIZE} from '../src/map/PinIcon'; +import { + distanceToPin, + pickPinAtPoint, + pinSortKey, + pressedPinId, + type ViewPoint, +} from '../src/map/pinHitTest'; + +// A pin's tip, with its round part above it: centre one rise up, radius half a +// side across. +const TIP: ViewPoint = [100, 200]; +const RISE = PIN_DIAGONAL / 2; +const RADIUS = PIN_SIZE / 2; +const CENTRE: ViewPoint = [TIP[0], TIP[1] - RISE]; + +const pin = (id: string, point: ViewPoint, selected = false) => ({ + id, + point, + selected, +}); + +test('the tip and the round part are inside the pin', () => { + const target = pin('a', TIP); + expect(distanceToPin(TIP, target)).toBeCloseTo(0); + expect(distanceToPin(CENTRE, target)).toBe(0); + // Just inside the round part's edge, in each direction from its centre. + expect(distanceToPin([CENTRE[0] + RADIUS - 1, CENTRE[1]], target)).toBe(0); + expect(distanceToPin([CENTRE[0] - RADIUS + 1, CENTRE[1]], target)).toBe(0); + expect(distanceToPin([CENTRE[0], CENTRE[1] - RADIUS + 1], target)).toBe(0); + // Halfway down to the tip, where the pin has narrowed to half its width. + expect( + distanceToPin([CENTRE[0] + RADIUS / 2, CENTRE[1] + RISE / 2], target), + ).toBe(0); +}); + +test('beside the pin is outside it, by the distance to its outline', () => { + const target = pin('a', TIP); + // Straight out from the round part's centre: distance from the edge. + expect( + distanceToPin([CENTRE[0] + RADIUS + 10, CENTRE[1]], target), + ).toBeCloseTo(10); + expect( + distanceToPin([CENTRE[0], CENTRE[1] - RADIUS - 5], target), + ).toBeCloseTo(5); + // Below the tip, where only the tip itself is near. + expect(distanceToPin([TIP[0], TIP[1] + 7], target)).toBeCloseTo(7); + // Level with the tip but off to the side: the pin has no width left there, so + // the nearest of it is the side above, measured square to its 45° run. + expect(distanceToPin([TIP[0] + 12, TIP[1]], target)).toBeCloseTo( + 12 * Math.SQRT1_2, + ); +}); + +test('the narrow part of the pin is narrow', () => { + const target = pin('a', TIP); + // The sides run down to the tip at 45°, so a quarter of the way above it the + // pin is a quarter of its rise wide — much narrower than its round part. + const depth = CENTRE[1] + RISE * 0.75; + expect(distanceToPin([CENTRE[0] + RISE / 4 - 1, depth], target)).toBe(0); + expect(distanceToPin([CENTRE[0] + RISE / 4 + 1, depth], target)).toBeCloseTo( + Math.SQRT1_2, + ); + // A point out at the round part's full width is well clear of it down here. + expect(distanceToPin([CENTRE[0] + RADIUS, depth], target)).toBeGreaterThan(8); +}); + +test('a selected pin is bigger, so it reaches further', () => { + const point: ViewPoint = [CENTRE[0] + RADIUS + 3, CENTRE[1]]; + expect(distanceToPin(point, pin('a', TIP))).toBeCloseTo(3); + // Grown about the tip, so both its radius and its centre move outwards. + const selected = pin('a', TIP, true); + expect(distanceToPin(point, selected)).toBe(0); + expect( + distanceToPin([TIP[0], TIP[1] - RISE * PIN_SELECTED_SCALE], selected), + ).toBe(0); +}); + +test('a tap picks the pin it is on, not a neighbouring one', () => { + // Two pins a pin's width apart, with the tap on the right-hand one's body. + const left = pin('left', [TIP[0] - PIN_SIZE, TIP[1]]); + const right = pin('right', TIP); + expect(pickPinAtPoint(CENTRE, [left, right])).toBe('right'); + expect(pickPinAtPoint(CENTRE, [right, left])).toBe('right'); + const leftCentre: ViewPoint = [CENTRE[0] - PIN_SIZE, CENTRE[1]]; + expect(pickPinAtPoint(leftCentre, [left, right])).toBe('left'); + expect(pickPinAtPoint(leftCentre, [right, left])).toBe('left'); +}); + +test('a tap on stacked pins picks the one drawn on top', () => { + // Overlapping: `front` is a few points below `back`, so it draws over it. + const back = pin('back', TIP); + const front = pin('front', [TIP[0] + 4, TIP[1] + 4]); + expect(pickPinAtPoint(CENTRE, [back, front])).toBe('front'); + // Unless the tap is on a part of the buried pin the front one doesn't cover. + const clearOfFront: ViewPoint = [CENTRE[0] - RADIUS - 2, CENTRE[1]]; + expect(pickPinAtPoint(clearOfFront, [back, front])).toBe('back'); +}); + +test('a tap between pins goes to the nearest, so it is never dropped', () => { + const left = pin('left', [TIP[0] - 90, TIP[1]]); + const right = pin('right', TIP); + const between: ViewPoint = [CENTRE[0] - 30, CENTRE[1]]; + expect(pickPinAtPoint(between, [left, right])).toBe('right'); + expect(pickPinAtPoint(between, [])).toBeNull(); +}); + +test('pins stack from north to south, so the front pin is the lower one', () => { + expect(pinSortKey(51.5)).toBeLessThan(pinSortKey(48.85)); +}); + +const feature = ( + elementId: string, + lngLat: [number, number], +): GeoJSON.Feature => ({ + type: 'Feature', + geometry: {type: 'Point', coordinates: lngLat}, + properties: {elementId, sortKey: pinSortKey(lngLat[1])}, +}); + +test('a press on one pin takes it without projecting anything', async () => { + const project = jest.fn(); + await expect( + pressedPinId( + {features: [feature('only', [13.4, 52.5])], point: [0, 0]}, + {selectedId: null, project}, + ), + ).resolves.toBe('only'); + expect(project).not.toHaveBeenCalled(); +}); + +test('a press with no pins picks nothing', async () => { + await expect( + pressedPinId( + {features: [], point: [0, 0]}, + {selectedId: null, project: jest.fn()}, + ), + ).resolves.toBeNull(); +}); + +test('a press on two pins measures where they are drawn', async () => { + // Same latitudes as the projection below: `north` draws behind `south`, and + // the tap is on the round part of `south`. + const north = feature('north', [13.4, 52.52]); + const south = feature('south', [13.4, 52.5]); + const drawnAt: Record = { + // Overlapping pins, the northern one a few points higher up the screen. + north: [100, 194], + south: TIP, + }; + const project = jest.fn(async (lngLat: [number, number]) => + lngLat[1] === 52.52 ? drawnAt.north : drawnAt.south, + ); + + await expect( + pressedPinId( + {features: [north, south], point: CENTRE}, + {selectedId: null, project}, + ), + ).resolves.toBe('south'); + expect(project).toHaveBeenCalledTimes(2); + + // A tap up beside the northern pin's body, clear of the southern one. + await expect( + pressedPinId( + {features: [north, south], point: [100, 194 - RISE - RADIUS + 1]}, + {selectedId: null, project}, + ), + ).resolves.toBe('north'); +}); + +test('a pin reported twice is still one pin', async () => { + const twice = feature('same', [13.4, 52.5]); + const project = jest.fn(); + await expect( + pressedPinId( + {features: [twice, twice], point: [0, 0]}, + {selectedId: null, project}, + ), + ).resolves.toBe('same'); + expect(project).not.toHaveBeenCalled(); +}); + +test('features without a pin on them are ignored', async () => { + const nameless: GeoJSON.Feature = { + type: 'Feature', + geometry: {type: 'Point', coordinates: [13.4, 52.5]}, + properties: null, + }; + const lineFeature: GeoJSON.Feature = { + type: 'Feature', + geometry: { + type: 'LineString', + coordinates: [ + [13.4, 52.5], + [13.5, 52.6], + ], + }, + properties: {elementId: 'a-line'}, + }; + await expect( + pressedPinId( + {features: [nameless, lineFeature], point: [0, 0]}, + {selectedId: null, project: jest.fn()}, + ), + ).resolves.toBeNull(); +}); diff --git a/src/map/MapScreen.tsx b/src/map/MapScreen.tsx index 85661be..c666d67 100644 --- a/src/map/MapScreen.tsx +++ b/src/map/MapScreen.tsx @@ -6,6 +6,7 @@ import { Layer, LocationManager, Map as MapLibreMap, + type MapRef, type PressEventWithFeatures, type SymbolLayerSpecification, UserLocation, @@ -37,6 +38,7 @@ import {useTheme} from '../theme/useTheme'; import {ElementPreviewCard} from './ElementPreviewCard'; import {FilterChips} from './FilterChips'; import {PIN_PADDING, PIN_SELECTED_SCALE} from './PinIcon'; +import {pinSortKey, pressedPinId, SELECTED_PIN_SORT_KEY} from './pinHitTest'; import {zoomForPlaceTypes} from './placeZoom'; import { type SearchElement, @@ -81,6 +83,7 @@ export function MapScreen() { const navigation = useNavigation(); const route = useRoute>(); const cameraRef = useRef(null); + const mapRef = useRef(null); // Gate the bounds fetch + viewport-save until we know the map is sitting on // a real location — either a restored viewport or a fly-to-user. Prevents // the initial zoom-1 world frame from triggering a fetch of every element. @@ -97,6 +100,10 @@ export function MapScreen() { const [selectedElementId, setSelectedElementId] = useState( null, ); + // Read by the pin press handler, which stays stable across renders so the + // native source isn't handed a new callback every time the map re-renders. + const selectedElementIdRef = useRef(selectedElementId); + selectedElementIdRef.current = selectedElementId; // When set, the map is filtered to a single trip: only that trip's elements // are shown and the bounds-based query is paused. const [tripFilter, setTripFilter] = useState<{ @@ -333,6 +340,7 @@ export function MapScreen() { // pins finish rasterising — a handful of times on startup, // then not again. image: imageNameFor(el.icon), + sortKey: pinSortKey(el.location.latitude), }, }, ] @@ -364,24 +372,47 @@ export function MapScreen() { // hiding any of them. 'icon-allow-overlap': true, 'icon-ignore-placement': true, - // Symbols with a lower sort key are drawn first, so the selected pin's - // higher key lifts it clear of its neighbours once it grows (the web app - // gets this from `z-index` on `.marker-highlighted`). + // Symbols with a lower sort key are drawn first, so this is what stacks + // the pins: by latitude, plus the selected one on top of the lot. Which + // pin is in front has to be decided rather than left to the order the + // features happen to arrive in, so that a tap landing on two pins at once + // can go to the one you can actually see — see `pinHitTest`. 'symbol-sort-key': selectedElementId - ? ['case', ['==', ['get', 'elementId'], selectedElementId], 1, 0] - : 0, + ? [ + 'case', + ['==', ['get', 'elementId'], selectedElementId], + SELECTED_PIN_SORT_KEY, + ['get', 'sortKey'], + ] + : ['get', 'sortKey'], }), [selectedElementId], ); const handlePinPress = useCallback( - (event: NativeSyntheticEvent) => { - const elementId = event.nativeEvent.features[0]?.properties?.elementId; - if (typeof elementId !== 'string') return; + async (event: NativeSyntheticEvent) => { // The source's press event bubbles up to the Map's onPress, which would - // immediately clear the selection we just set. + // immediately clear the selection we're about to set. Stopped — and the + // event read — before the first await, while the event is still ours. event.stopPropagation(); - setSelectedElementId(elementId); + const {features, point} = event.nativeEvent; + const map = mapRef.current; + if (!map) return; + // MapLibre hands over every pin near the touch, not the one under it, so + // the choice between them is ours to make. + const elementId = await pressedPinId( + {features, point}, + { + selectedId: selectedElementIdRef.current, + project: lngLat => map.project(lngLat), + }, + ).catch((error: unknown) => { + console.warn('Failed to place map pins for a tap:', error); + // Better the pin MapLibre listed first than a tap that does nothing. + const first = features[0]?.properties?.elementId; + return typeof first === 'string' ? first : null; + }); + if (elementId) setSelectedElementId(elementId); }, [], ); @@ -483,6 +514,7 @@ export function MapScreen() { return ( setSelectedElementId(null)} diff --git a/src/map/PinIcon.tsx b/src/map/PinIcon.tsx index c9014e1..1619726 100644 --- a/src/map/PinIcon.tsx +++ b/src/map/PinIcon.tsx @@ -4,13 +4,14 @@ import type {Theme} from '../theme/colors'; // Map pin geometry, mirroring the web app's `.maplibre-marker-pin`: a square // with three rounded corners, rotated -45° so the square corner points down. -// Web draws it at 30px; we size up for touch. -const PIN_SIZE = 36; +// Web draws it at 30px; we size up for touch. Exported for `pinHitTest`, which +// measures taps against the shape these describe. +export const PIN_SIZE = 36; // Selected pins only grow (and draw on top); the fill stays red, as on web. export const PIN_SELECTED_SCALE = 1.25; // Rotating the square makes it occupy a box of side × √2, with the square // corner — the teardrop's tip — landing at the bottom centre of that box. -const PIN_DIAGONAL = PIN_SIZE * Math.SQRT2; +export const PIN_DIAGONAL = PIN_SIZE * Math.SQRT2; // The drop shadow is drawn outside the pin's own bounds, so the box carries // padding on every side for it to spill into; without it the capture below // would crop the shadow at the pin's edge. Covers the shadow's 2pt drop plus diff --git a/src/map/pinHitTest.ts b/src/map/pinHitTest.ts new file mode 100644 index 0000000..628bd56 --- /dev/null +++ b/src/map/pinHitTest.ts @@ -0,0 +1,220 @@ +/** + * Which map pin a tap landed on. + * + * Pins are symbols in the map style, and MapLibre reports a press on their layer + * by handing us every feature rendered within a 44pt box around the touch + * (`MLRNMapView.onMapClick`, `MLRNPressableSource.DEFAULT_HITBOX`). That box is + * deliberately bigger than a pin, and a pin's queryable area is its whole image + * — shadow padding included — so a tap near two pins routinely comes back with + * both, in an order MapLibre doesn't define. Taking the first feature therefore + * gives the tap to an arbitrary one of them: often a neighbour a pin's width + * away, or one buried under the pin actually being aimed at. + * + * So the candidates get measured here instead, against where the pins are really + * drawn: the one whose outline the touch is inside wins, and where two overlap, + * the one drawn on top. Pins the touch only came *near* stay eligible — it has + * to land somewhere — so an isolated pin is as forgiving to hit as it ever was; + * it just can't beat a pin the touch is actually on. + * + * Coordinates are in points with y downwards: the space shared by a press + * event's `point` and `MapRef.project()`. A pin's position is its tip — the + * coordinate it marks — since that is what the symbol layer anchors. + */ + +import {PIN_DIAGONAL, PIN_SELECTED_SCALE, PIN_SIZE} from './PinIcon'; + +/** A point in view coordinates: `[x, y]`, as MapLibre reports and projects. */ +export type ViewPoint = readonly [x: number, y: number]; + +/** A pin to measure a tap against: where its tip is drawn, and how big it is. */ +export type PinTarget = { + id: string; + /** The pin's tip, in view points. */ + point: ViewPoint; + /** Selected pins are drawn larger, so they cover more ground. */ + selected?: boolean; +}; + +/** Radius of the pin's round part. */ +const BODY_RADIUS = PIN_SIZE / 2; + +/** + * How far the round part's centre sits above the tip — the distance from the + * square's centre to the corner that became the tip. + */ +const BODY_RISE = PIN_DIAGONAL / 2; + +/** + * Distance in points from `tap` to a pin's drawn outline; 0 anywhere inside it. + * + * The pin is a square with three corners rounded by half its side, rotated so + * the fourth points down, which makes it exactly the convex hull of a circle and + * that point: all three arcs share the square's centre (the rounding radius is + * also the distance from there to each side), and the two sides meeting at the + * tip are tangents to that circle. Inside the pin is therefore inside the round + * part, or inside the wedge those two tangents cut off down to the tip. + */ +export function distanceToPin(tap: ViewPoint, pin: PinTarget): number { + const scale = pin.selected ? PIN_SELECTED_SCALE : 1; + const radius = BODY_RADIUS * scale; + const rise = BODY_RISE * scale; + // Measured from the round part's centre: along the pin's axis towards the tip, + // and across it — unsigned, since the pin is symmetrical about that axis. + const along = tap[1] - (pin.point[1] - rise); + const across = Math.abs(tap[0] - pin.point[0]); + + const fromCentre = Math.hypot(along, across); + if (fromCentre <= radius) return 0; + + // Where a side leaves the round part, touching it: this far along the axis and + // that far across it. For these proportions that lands at 45°. + const tangentAlong = (radius * radius) / rise; + const tangentAcross = Math.sqrt( + radius * radius - tangentAlong * tangentAlong, + ); + + // Below that the pin tapers along a straight side to nothing at the tip. + if (along > tangentAlong && along < rise) { + const halfWidth = (tangentAcross * (rise - along)) / (rise - tangentAlong); + if (across <= halfWidth) return 0; + } + + // Outside, then, and the nearest part of the outline is the round part or the + // side. Which one is decided by the line from the centre through the touching + // point, which — the side being a tangent — is where the side's own end is. + if (across * tangentAlong >= along * tangentAcross) + return fromCentre - radius; + return distanceToSide(along, across, tangentAlong, tangentAcross, rise); +} + +/** + * Distance from an outside point to the pin's side: the straight run from where + * it leaves the round part down to the tip, in the same along/across + * coordinates. Points past the tip end up measured against the tip itself. + */ +function distanceToSide( + along: number, + across: number, + tangentAlong: number, + tangentAcross: number, + rise: number, +): number { + const runAlong = rise - tangentAlong; + const runAcross = -tangentAcross; + const runLength = runAlong * runAlong + runAcross * runAcross; + const fraction = + ((along - tangentAlong) * runAlong + (across - tangentAcross) * runAcross) / + runLength; + const clamped = Math.min(1, Math.max(0, fraction)); + return Math.hypot( + along - (tangentAlong + clamped * runAlong), + across - (tangentAcross + clamped * runAcross), + ); +} + +/** + * The pin `tap` belongs to, or null if there were no candidates at all. + * + * `pins` is ordered back-to-front, as they are drawn, so that when a touch is + * inside more than one pin the one on top takes it; otherwise the nearest pin + * does. + */ +export function pickPinAtPoint( + tap: ViewPoint, + pins: readonly PinTarget[], +): string | null { + let pickedId: string | null = null; + let pickedDistance = Number.POSITIVE_INFINITY; + for (const pin of pins) { + const distance = distanceToPin(tap, pin); + // Ties go to the later pin, which is the one drawn over the others — the + // only one of them the tap could have been aimed at. + if (distance <= pickedDistance) { + pickedId = pin.id; + pickedDistance = distance; + } + } + return pickedId; +} + +/** + * Sort key that stacks a pin at `latitude`, for the symbol layer's + * `symbol-sort-key` and for ordering tap candidates the same way. + * + * Symbols with a higher key draw later, so southern pins overlap the ones behind + * (north of) them, the way pins stack on any map. Latitude rather than screen + * position because a sort key can't depend on the camera: rotate the map far + * enough and the stacking reads upside down, but it stays *defined*, which is + * what lets a tap prefer the pin you can actually see. + */ +export function pinSortKey(latitude: number): number { + return -latitude; +} + +/** + * Sort key for the selected pin, which grows and so is drawn above every other + * pin rather than in latitude order (the web app gets this from `z-index` on + * `.marker-highlighted`). Clear of any {@link pinSortKey}, whose keys are + * latitudes. + */ +export const SELECTED_PIN_SORT_KEY = 1000; + +/** The pin press candidates from a layer press, ordered back-to-front. */ +function pinCandidates( + features: readonly GeoJSON.Feature[], + selectedId: string | null, +): {id: string; lngLat: [number, number]; selected: boolean}[] { + const byId = new Map< + string, + {id: string; lngLat: [number, number]; selected: boolean; sortKey: number} + >(); + for (const feature of features) { + const id = feature.properties?.elementId; + // A pin split across two tiles can be reported twice; it's one pin. + if (typeof id !== 'string' || byId.has(id)) continue; + if (feature.geometry?.type !== 'Point') continue; + const [longitude, latitude] = feature.geometry.coordinates; + if (typeof longitude !== 'number' || typeof latitude !== 'number') continue; + const selected = id === selectedId; + byId.set(id, { + id, + lngLat: [longitude, latitude], + selected, + sortKey: selected ? SELECTED_PIN_SORT_KEY : pinSortKey(latitude), + }); + } + return Array.from(byId.values()).sort((a, b) => a.sortKey - b.sortKey); +} + +/** + * The pin a layer press belongs to, or null if the press carried no pin at all. + * + * `project` places each candidate on screen, so the measuring happens against + * the pins as drawn under the current camera. A press with a single candidate + * skips that round trip: with nothing to choose between, MapLibre's generous + * hitbox is doing no harm. + */ +export async function pressedPinId( + press: {features: readonly GeoJSON.Feature[]; point: ViewPoint}, + options: { + selectedId: string | null; + project: (lngLat: [number, number]) => Promise; + }, +): Promise { + const candidates = pinCandidates(press.features, options.selectedId); + if (candidates.length <= 1) return candidates[0]?.id ?? null; + + // Projecting them together keeps every pin measured against the same frame, + // and resolves in candidate order. + const points = await Promise.all( + candidates.map(candidate => options.project(candidate.lngLat)), + ); + return pickPinAtPoint( + press.point, + candidates.map((candidate, index) => ({ + id: candidate.id, + point: points[index], + selected: candidate.selected, + })), + ); +}