From 24587510c7c683aa591e11a64f9db51864412ea0 Mon Sep 17 00:00:00 2001 From: Dennis Falling Date: Fri, 31 Jul 2026 10:04:16 +0100 Subject: [PATCH] Keep map pins locked to the map while panning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Android, is not part of the map at all: it's an Android View parented to the MapView and repositioned by hand once per rendered frame. Because the map renders into a GLSurfaceView, its content is composited separately from the view hierarchy holding the pins, so the pins land a frame or more behind the map they're pinned to — visible as the whole pin layer lagging behind a pan. The cost is also O(pins) per frame, which is what made it obvious once a viewport held a lot of them. Draw the pins as a symbol layer over a GeoJSON source instead. Symbols are rendered inside the map's own GL frame, so they're pixel-locked to it and their count barely matters. A symbol can only draw a registered image and our pins carry a per-element emoji, so each distinct icon is rasterised off-screen once and registered as a map image; emoji can't come from the style's glyphs, which are SDF and have no emoji ranges. Three workarounds go away with it: the manual reordering that floated the selected marker to the end of the array is now symbol-sort-key, its grow-on- select style is icon-size, and the client-side viewport cull is gone entirely since MapLibre culls per tile — which also stops pins popping in at the screen edges. Because icon offsets scale with icon size, a selected pin's tip now stays exactly on its coordinate rather than dipping just below it. Co-Authored-By: Claude Opus 5 (1M context) --- bun.lock | 13 ++ jest.config.js | 7 +- package.json | 1 + src/map/MapScreen.tsx | 249 ++++++++++++++++++++------------------- src/map/PinIcon.tsx | 88 ++++++++++++++ src/map/usePinImages.tsx | 136 +++++++++++++++++++++ 6 files changed, 370 insertions(+), 124 deletions(-) create mode 100644 src/map/PinIcon.tsx create mode 100644 src/map/usePinImages.tsx diff --git a/bun.lock b/bun.lock index 97a88ad..267fba2 100644 --- a/bun.lock +++ b/bun.lock @@ -18,6 +18,7 @@ "react-native-image-picker": "^8.2.1", "react-native-safe-area-context": "^5.5.2", "react-native-screens": "^4.25.2", + "react-native-view-shot": "^5.1.1", "rn-emoji-keyboard": "^1.7.0", }, "devDependencies": { @@ -696,6 +697,8 @@ "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "base64-arraybuffer": ["base64-arraybuffer@1.0.2", "", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="], + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.10.31", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q=="], @@ -806,6 +809,8 @@ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "css-line-break": ["css-line-break@2.1.0", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], @@ -994,6 +999,8 @@ "html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], + "html2canvas": ["html2canvas@1.4.1", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="], + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], @@ -1382,6 +1389,8 @@ "react-native-screens": ["react-native-screens@4.25.2", "", { "dependencies": { "react-freeze": "^1.0.0", "warn-once": "^0.1.0" }, "peerDependencies": { "react": "*", "react-native": ">=0.82.0" } }, "sha512-1Nj1fusFd+rIMKU/qC9yGKVG+3ofh11d3OdBQKL1iVvQfKvcB8vhvTGQf2TkfxW3bamxN+hCZIXmNuU0mRkyDg=="], + "react-native-view-shot": ["react-native-view-shot@5.1.1", "", { "dependencies": { "html2canvas": "^1.4.1" }, "peerDependencies": { "react": ">=18.0.0", "react-native": ">=0.76.0" } }, "sha512-jOTLQrMGNBgv/nRA81VR2x2AdiCGoCjc63c9a5P4A0gStCbh9oDeJ6W+GtRxxQmxXXf6LDSrvG+mmWYulIrOkg=="], + "react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="], "react-test-renderer": ["react-test-renderer@19.2.3", "", { "dependencies": { "react-is": "^19.2.3", "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-TMR1LnSFiWZMJkCgNf5ATSvAheTT2NvKIwiVwdBPHxjBI7n/JbWd4gaZ16DVd9foAXdvDz+sB5yxZTwMjPRxpw=="], @@ -1528,6 +1537,8 @@ "test-exclude": ["test-exclude@6.0.0", "", { "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^7.1.4", "minimatch": "^3.0.4" } }, "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w=="], + "text-segmentation": ["text-segmentation@1.0.3", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw=="], + "throat": ["throat@5.0.0", "", {}, "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA=="], "timeout-signal": ["timeout-signal@2.0.0", "", {}, "sha512-YBGpG4bWsHoPvofT6y/5iqulfXIiIErl5B0LdtHT1mGXDFTAhhRrbUpTvBgYbovr+3cKblya2WAOcpoy90XguA=="], @@ -1590,6 +1601,8 @@ "utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="], + "utrie": ["utrie@1.0.2", "", { "dependencies": { "base64-arraybuffer": "^1.0.2" } }, "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw=="], + "v8-to-istanbul": ["v8-to-istanbul@9.3.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.12", "@types/istanbul-lib-coverage": "^2.0.1", "convert-source-map": "^2.0.0" } }, "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA=="], "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], diff --git a/jest.config.js b/jest.config.js index 01ec325..0c9ceb5 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,9 +1,10 @@ module.exports = { preset: '@react-native/jest-preset', setupFiles: ['/jest.setup.js'], - // @react-navigation and react-native-screens ship ESM that must be - // transpiled; widen the preset's default allowlist to include them. + // @react-navigation and react-native-screens ship ESM, and + // react-native-view-shot resolves its "react-native" entry to TypeScript + // source; all must be transpiled, so widen the preset's default allowlist. transformIgnorePatterns: [ - 'node_modules/(?!((jest-)?react-native|@react-native(-community)?|@react-navigation|react-native-screens)/)', + 'node_modules/(?!((jest-)?react-native|@react-native(-community)?|@react-navigation|react-native-(screens|view-shot))/)', ], }; diff --git a/package.json b/package.json index 3f3ed2f..7fdf98d 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "react-native-image-picker": "^8.2.1", "react-native-safe-area-context": "^5.5.2", "react-native-screens": "^4.25.2", + "react-native-view-shot": "^5.1.1", "rn-emoji-keyboard": "^1.7.0" }, "devDependencies": { diff --git a/src/map/MapScreen.tsx b/src/map/MapScreen.tsx index 7a8c816..85661be 100644 --- a/src/map/MapScreen.tsx +++ b/src/map/MapScreen.tsx @@ -1,9 +1,13 @@ import { Camera, type CameraRef, + GeoJSONSource, + Images, + Layer, LocationManager, Map as MapLibreMap, - Marker, + type PressEventWithFeatures, + type SymbolLayerSpecification, UserLocation, useCurrentPosition, type ViewStateChangeEvent, @@ -32,6 +36,7 @@ import type {Theme} from '../theme/colors'; import {useTheme} from '../theme/useTheme'; import {ElementPreviewCard} from './ElementPreviewCard'; import {FilterChips} from './FilterChips'; +import {PIN_PADDING, PIN_SELECTED_SCALE} from './PinIcon'; import {zoomForPlaceTypes} from './placeZoom'; import { type SearchElement, @@ -39,9 +44,11 @@ import { type SearchPlace, type SearchTrip, } from './SearchOverlay'; +import {usePinImages} from './usePinImages'; import {type Viewport, viewportStore} from './viewportStore'; type ElementWithLocation = ElementsQuery['elements'][number]; +type PinLayout = NonNullable; const USER_ZOOM = 14; // Zoom used when flying to a searched element, and for single-element trips @@ -58,23 +65,15 @@ const OFF_CENTER_THRESHOLD = 0.2; // Margin above the device safe area for bottom-anchored overlays (recenter // button, preview card). const BOTTOM_MARGIN = 16; -// 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; -// Selected pins only grow (and draw on top); the fill stays red, as on web. -const PIN_SELECTED_SCALE = 1.25; -// Rotating the square makes it occupy a box of side × √2, and selecting scales -// that up further. The marker view is sized for that largest case, so a -// selected pin and its shadow never rely on overflowing it. -const PIN_DIAGONAL = PIN_SIZE * Math.SQRT2; -const PIN_BOX = PIN_DIAGONAL * PIN_SELECTED_SCALE; -// A resting pin therefore floats above the box's bottom edge, which is what -// the "bottom" anchor pins to; shifting the marker down by that slack puts the -// tip back on the coordinate. Selecting one grows it into the slack, nudging -// the tip just below the coordinate — as the web app's scale-on-hover does. -// One shared array, so the native prop doesn't look changed on every render. -const PIN_OFFSET: [number, number] = [0, (PIN_BOX - PIN_DIAGONAL) / 2]; +// Ids for the map style's pin source and layer. +const PIN_SOURCE_ID = 'element-pins'; +const PIN_LAYER_ID = 'element-pins-symbols'; +// The pin image is padded so its shadow has room to spill, leaving the +// teardrop's tip that far above the image's bottom edge. Anchoring the symbol +// at its bottom and then offsetting it down by the padding puts the tip back on +// the element's coordinate. Icon offsets are multiplied by the icon size, so +// this holds at both resting and selected scale. +const PIN_ICON_OFFSET: [number, number] = [0, PIN_PADDING]; export function MapScreen() { const theme = useTheme(); @@ -274,25 +273,24 @@ export function MapScreen() { navigation.setParams({removedElementId: undefined}); }, [removedElementId, navigation]); - // Only render markers whose location falls inside the last-known viewport. - // is a native View — rendering offscreen ones still costs layout - // and reprojection on every camera frame, so we cull client-side. - const visibleElements = useMemo(() => { - if (!bounds) return []; - const {left, right, bottom, top} = bounds; - return Array.from(elementsById.values()).filter(el => { - if (!el.location) return false; - const {longitude: lng, latitude: lat} = el.location; - if (lng < left || lng > right || lat < bottom || lat > top) return false; - // Apply the label filter client-side too (matching the query's ALL - // semantics) so pins accumulated before the filter was set — or that no - // longer match it — stop rendering without waiting for a refetch. - return labelFilters.every(label => el.labels.includes(label)); - }); - }, [elementsById, bounds, labelFilters]); + // Every located element we've accumulated. There's no viewport cull: pins are + // symbols in the map style now, so MapLibre culls them per tile on the render + // thread and offscreen ones cost nothing — culling here only made pins pop in + // at the screen edges. + const locatedElements = useMemo( + () => + Array.from(elementsById.values()).filter(el => { + if (!el.location) return false; + // Apply the label filter client-side too (matching the query's ALL + // semantics) so pins accumulated before the filter was set — or that no + // longer match it — stop rendering without waiting for a refetch. + return labelFilters.every(label => el.labels.includes(label)); + }), + [elementsById, labelFilters], + ); - // Elements with a location for the active trip; drives both the markers and - // the camera fit while filtering. + // Elements with a location for the active trip; drives both the pins and the + // camera fit while filtering. const tripElements = useMemo( () => tripFilter @@ -301,23 +299,92 @@ export function MapScreen() { [tripFilter, data?.elements], ); - // In trip mode render the whole trip (no viewport cull) so panning around it - // doesn't drop pins; otherwise use the bounds-culled accumulated set. - const displayedElements = tripFilter ? tripElements : visibleElements; - - // Markers stack in render order, so move the selected one to the end and it - // draws over its neighbours instead of being buried under them once it grows - // (the web app gets this from `z-index` on `.marker-highlighted`). - const orderedElements = useMemo(() => { - if (!selectedElementId) return displayedElements; - const index = displayedElements.findIndex( - el => el.id === selectedElementId, - ); - if (index < 0) return displayedElements; - const ordered = displayedElements.slice(); - ordered.push(...ordered.splice(index, 1)); - return ordered; - }, [displayedElements, selectedElementId]); + // While filtering by trip, show only that trip's elements so trip-only + // results don't mix with the accumulated viewport set. + const displayedElements = tripFilter ? tripElements : locatedElements; + + // A symbol can only draw an image the style knows about, so each distinct + // element icon gets rasterised into one. + const pinIcons = useMemo( + () => Array.from(new Set(displayedElements.map(el => el.icon))), + [displayedElements], + ); + const { + images: pinImages, + rasterizer: pinRasterizer, + imageNameFor, + } = usePinImages(theme, pinIcons); + + const pinFeatures = useMemo>( + () => ({ + type: 'FeatureCollection', + features: displayedElements.flatMap(el => + el.location + ? [ + { + type: 'Feature' as const, + geometry: { + type: 'Point' as const, + coordinates: [el.location.longitude, el.location.latitude], + }, + properties: { + elementId: el.id, + // Resolving the image name here means the data is rebuilt as + // pins finish rasterising — a handful of times on startup, + // then not again. + image: imageNameFor(el.icon), + }, + }, + ] + : [], + ), + }), + [displayedElements, imageNameFor], + ); + + // Selection is expressed as style expressions over the feature's id rather + // than baked into the source data, so selecting a pin re-sends the layout + // instead of every feature. + const pinLayout = useMemo( + () => ({ + 'icon-image': ['get', 'image'], + // The teardrop's tip marks the spot, so hang the pin above the coordinate + // rather than centring it on top of it. + 'icon-anchor': 'bottom', + 'icon-offset': PIN_ICON_OFFSET, + 'icon-size': selectedElementId + ? [ + 'case', + ['==', ['get', 'elementId'], selectedElementId], + PIN_SELECTED_SCALE, + 1, + ] + : 1, + // Every element gets a pin, so keep MapLibre's label-collision logic from + // 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`). + 'symbol-sort-key': selectedElementId + ? ['case', ['==', ['get', 'elementId'], selectedElementId], 1, 0] + : 0, + }), + [selectedElementId], + ); + + const handlePinPress = useCallback( + (event: NativeSyntheticEvent) => { + const elementId = event.nativeEvent.features[0]?.properties?.elementId; + if (typeof elementId !== 'string') return; + // The source's press event bubbles up to the Map's onPress, which would + // immediately clear the selection we just set. + event.stopPropagation(); + setSelectedElementId(elementId); + }, + [], + ); // Fit the camera to the trip's extent once its elements arrive. Guarded by a // ref so panning/zooming afterwards doesn't snap back, and reset when the @@ -422,38 +489,15 @@ export function MapScreen() { onRegionDidChange={onRegionDidChange}> - {orderedElements.map(el => - el.location ? ( - { - // The marker's "onPress" event bubbles up to the Map's - // onPress (both are codegen BubblingEventHandlers), which - // would immediately clear the selection we just set. - e.stopPropagation(); - setSelectedElementId(el.id); - }}> - - - {el.icon ? ( - {el.icon} - ) : null} - - - - ) : null, - )} + + + + + {pinRasterizer} {position && !isCenteredOnUser && !selectedElementId ? ( map: { flex: 1, }, - // Roomy enough to contain the pin at its selected size, shadow included. - pinBox: { - width: PIN_BOX, - height: PIN_BOX, - alignItems: 'center', - justifyContent: 'center', - }, - pin: { - width: PIN_SIZE, - height: PIN_SIZE, - // Three rounded corners and one square one: the teardrop's tip. - borderTopLeftRadius: PIN_SIZE / 2, - borderTopRightRadius: PIN_SIZE / 2, - borderBottomRightRadius: PIN_SIZE / 2, - borderBottomLeftRadius: 0, - backgroundColor: theme.pin, - borderWidth: 1, - borderColor: theme.pinBorder, - alignItems: 'center', - justifyContent: 'center', - boxShadow: [ - {offsetX: 0, offsetY: 2, blurRadius: 4, color: 'rgba(0,0,0,0.4)'}, - ], - transform: [{rotate: '-45deg'}], - }, - pinSelected: { - // Repeats the rotation because a later style's `transform` replaces the - // whole list rather than merging into it. - transform: [{rotate: '-45deg'}, {scale: PIN_SELECTED_SCALE}], - }, - pinIcon: { - fontSize: 16, - lineHeight: 20, - textAlign: 'center', - // Counter-rotate so the emoji stays upright inside the rotated pin. - transform: [{rotate: '45deg'}], - }, recenterButton: { position: 'absolute', right: 16, diff --git a/src/map/PinIcon.tsx b/src/map/PinIcon.tsx new file mode 100644 index 0000000..c9014e1 --- /dev/null +++ b/src/map/PinIcon.tsx @@ -0,0 +1,88 @@ +import {useMemo} from 'react'; +import {StyleSheet, Text, View} from 'react-native'; +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; +// 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; +// 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 +// its 4pt blur, with a little slack. +export const PIN_PADDING = 8; +// Full box the pin is captured in. The tip therefore sits PIN_PADDING above the +// box's bottom edge, which is what the symbol layer's icon offset cancels out +// to put the tip back on the element's coordinate. +const PIN_BOX = PIN_DIAGONAL + PIN_PADDING * 2; + +/** + * A single map pin, drawn as plain views so it can be rasterised into a map + * image. Nothing renders this on the map directly — see `usePinImages`. + * + * `scale` multiplies every dimension, letting the rasteriser capture the pin at + * a higher resolution than it rests at so growing a selected pin stays sharp. + */ +export function PinIcon({ + theme, + icon, + scale, +}: { + theme: Theme; + icon: string | null | undefined; + scale: number; +}) { + const styles = useMemo(() => makeStyles(theme, scale), [theme, scale]); + return ( + + + {icon ? {icon} : null} + + + ); +} + +const makeStyles = (theme: Theme, scale: number) => + StyleSheet.create({ + box: { + width: PIN_BOX * scale, + height: PIN_BOX * scale, + alignItems: 'center', + justifyContent: 'center', + }, + pin: { + width: PIN_SIZE * scale, + height: PIN_SIZE * scale, + // Three rounded corners and one square one: the teardrop's tip. + borderTopLeftRadius: (PIN_SIZE / 2) * scale, + borderTopRightRadius: (PIN_SIZE / 2) * scale, + borderBottomRightRadius: (PIN_SIZE / 2) * scale, + borderBottomLeftRadius: 0, + backgroundColor: theme.pin, + borderWidth: scale, + borderColor: theme.pinBorder, + alignItems: 'center', + justifyContent: 'center', + boxShadow: [ + { + offsetX: 0, + offsetY: 2 * scale, + blurRadius: 4 * scale, + color: 'rgba(0,0,0,0.4)', + }, + ], + transform: [{rotate: '-45deg'}], + }, + icon: { + fontSize: 16 * scale, + lineHeight: 20 * scale, + textAlign: 'center', + // Counter-rotate so the emoji stays upright inside the rotated pin. + transform: [{rotate: '45deg'}], + }, + }); diff --git a/src/map/usePinImages.tsx b/src/map/usePinImages.tsx new file mode 100644 index 0000000..b9aef41 --- /dev/null +++ b/src/map/usePinImages.tsx @@ -0,0 +1,136 @@ +import type {ImageEntry} from '@maplibre/maplibre-react-native'; +import {type ReactElement, useCallback, useMemo, useRef, useState} from 'react'; +import {PixelRatio, StyleSheet, View} from 'react-native'; +import {captureRef} from 'react-native-view-shot'; +import type {Theme} from '../theme/colors'; +import {PIN_SELECTED_SCALE, PinIcon} from './PinIcon'; + +// Symbol layers scale a pin up when it's selected, which resamples its image; +// capturing at the selected size means that only ever downsamples. +const RASTER_SCALE = PIN_SELECTED_SCALE; + +/** + * Rasterises map pins into images a symbol layer can draw. + * + * Symbol layers render inside the map's own GL frame — unlike ``, whose + * Android views are repositioned once per frame from outside that frame and so + * trail behind the map as it pans. The catch is that a symbol can only draw a + * registered image, and our pins carry a per-element emoji, so each distinct + * icon has to become an image first. This captures `` off-screen, once + * per icon, and registers the result under a name `imageNameFor` hands back. + * + * `icons` may grow as elements load; each new value is captured once and kept + * for the life of the screen. + */ +export function usePinImages( + theme: Theme, + icons: readonly (string | null | undefined)[], +): { + /** Images to pass to ``. */ + images: Record; + /** + * The off-screen capture host. Must be rendered *outside* the map, since + * every child of the map is treated as a map feature. + */ + rasterizer: ReactElement; + /** + * The map image name to draw for an element's icon. Falls back to the plain + * teardrop while that icon's own image is still being rasterised — + * referencing an image the style doesn't have yet would draw nothing at all. + */ + imageNameFor: (icon: string | null | undefined) => string; +} { + const [images, setImages] = useState>({}); + // Names already captured, in flight, or failed. Kept in a ref rather than + // derived from `images` so a capture in flight isn't started twice and a + // failed one isn't retried forever. + const attempted = useRef(new Set()); + const hosts = useRef(new Map()); + + // A rasterised pin bakes in the colours it was drawn with, so those are part + // of its name: should the palette ever stop being shared between light and + // dark, the new pins get new names and are captured afresh rather than + // quietly keeping the old bitmaps. + const variant = `${theme.pin}|${theme.pinBorder}`; + const nameFor = useCallback( + (icon: string | null | undefined) => `pin:${variant}:${icon ?? ''}`, + [variant], + ); + const plainPinName = nameFor(null); + + const imageNameFor = useCallback( + (icon: string | null | undefined) => { + const name = nameFor(icon); + return name in images ? name : plainPinName; + }, + [nameFor, plainPinName, images], + ); + + // Icons with no image yet, de-duplicated by name. The plain pin is always + // wanted: it stands in for icons still being captured, so it needs to be + // ready before any element arrives. + const pending = useMemo(() => { + const byName = new Map([[plainPinName, null]]); + for (const icon of icons) byName.set(nameFor(icon), icon ?? null); + return Array.from(byName, ([name, icon]) => ({name, icon})).filter( + ({name}) => !(name in images), + ); + }, [icons, images, nameFor, plainPinName]); + + const capture = useCallback((name: string) => { + if (attempted.current.has(name)) return; + const host = hosts.current.get(name); + if (!host) return; + attempted.current.add(name); + + // Let the laid-out views actually draw before asking for their pixels: + // capture reads them via `view.draw()`, which needs the emoji's text run + // resolved and the shadow's drawable in place. + requestAnimationFrame(() => { + captureRef(host, {format: 'png', result: 'data-uri'}) + .then(uri => { + hosts.current.delete(name); + setImages(prev => ({ + ...prev, + // Declaring the scale the pin was captured at is what makes the + // image's natural size on the map its unscaled point size. + [name]: {source: {uri, scale: PixelRatio.get() * RASTER_SCALE}}, + })); + }) + .catch((error: unknown) => { + hosts.current.delete(name); + // The name stays in `attempted` so we don't retry in a loop; the + // element keeps the plain pin, which is a legible fallback. + console.warn(`Failed to rasterize map pin ${name}:`, error); + }); + }); + }, []); + + const rasterizer = ( + + {pending.map(({name, icon}) => ( + capture(name)} + ref={host => { + hosts.current.set(name, host); + }}> + + + ))} + + ); + + return {images, rasterizer, imageNameFor}; +} + +const styles = StyleSheet.create({ + // Parked off-screen: capture draws the view into its own bitmap rather than + // reading the screen, so it only has to be laid out, not visible. + offscreen: { + position: 'absolute', + left: -9999, + top: -9999, + }, +});