Skip to content

Commit 567a846

Browse files
Banyel3claude
andcommitted
fix(ui): kill map flicker for real + keyless HD tiles (CARTO @2x)
Flicker: the memoised source wasn't the whole story — Leaflet fires `moveend` repeatedly with the SAME centre during the modal slide-in and tile settle, and each one flipped the label to "Locating…" and re-fired the reverse-geocode. Dedupe on the rounded centre (5dp ~1m): identical centres are ignored, so no setState, no re-render, no flicker. Reset the dedupe key when the sheet reopens. HD: added keyless CARTO Voyager tiles (free, no signup) that serve @2x retina via Leaflet's {r} token — noticeably sharper than OSM/TomTom basic. resolveTiles now defaults to CARTO and only uses TomTom when explicitly asked (provider=tomtom), so a stray EXPO_PUBLIC_TOMTOM_MAP_KEY no longer pins the map to low-res. Paid @2x keys (MapTiler/Mapbox) still win when set. 12 ui tests (was 11), type-clean. NOTE: JS-only (no native change) — just reload Metro to pick this up. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4fd91fc commit 567a846

3 files changed

Lines changed: 48 additions & 11 deletions

File tree

packages/ui/src/MapPicker.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,14 @@ export function MapPicker({
3535
const [label, setLabel] = useState<string>('Move the map to your pickup point');
3636
const [resolving, setResolving] = useState(false);
3737
const revTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
38+
const lastPosted = useRef<string>(''); // dedupe key of the last centre we acted on
3839

3940
// Decide the starting centre once the sheet opens: passed-in point → device
4041
// GPS → fallback (Zamboanga).
4142
useEffect(() => {
4243
if (!visible) {
4344
setCenter(null);
45+
lastPosted.current = '';
4446
return;
4547
}
4648
let cancelled = false;
@@ -78,6 +80,13 @@ export function MapPicker({
7880
} catch {
7981
return;
8082
}
83+
// Leaflet emits `moveend` repeatedly with the SAME centre during the
84+
// modal slide-in and tile settle. Ignore any message whose centre hasn't
85+
// actually moved (rounded to ~1m) — otherwise each one flips the label to
86+
// "Locating…" and re-fires the reverse-geocode: the flicker. Round to 5dp.
87+
const key = `${c.lat.toFixed(5)},${c.lng.toFixed(5)}`;
88+
if (key === lastPosted.current) return;
89+
lastPosted.current = key;
8190
setCoords(c);
8291
// Debounce the reverse-geocode — one call after the map settles.
8392
if (revTimer.current) clearTimeout(revTimer.current);

packages/ui/src/leaflet-map.spec.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
cartoTiles,
23
leafletHtml,
34
mapboxTiles,
45
mapTilerTiles,
@@ -33,6 +34,13 @@ describe('tile presets', () => {
3334
expect(t.detectRetina).toBe(true);
3435
});
3536

37+
it('carto is keyless and uses the {r} retina token for @2x sharpness', () => {
38+
const t = cartoTiles();
39+
expect(t.url).toContain('cartocdn.com');
40+
expect(t.url).toContain('{r}.png'); // Leaflet fills {r}=@2x on retina
41+
expect(t.url).not.toContain('key=');
42+
});
43+
3644
it('osm is keyless', () => {
3745
expect(osmTiles().url).toContain('tile.openstreetmap.org');
3846
});
@@ -42,22 +50,25 @@ describe('resolveTiles', () => {
4250
it('honours an explicit provider when its key is present', () => {
4351
expect(resolveTiles({ provider: 'mapbox', mapboxToken: 't' }).url).toContain('mapbox');
4452
expect(resolveTiles({ provider: 'maptiler', mapTilerKey: 'k' }).url).toContain('maptiler');
53+
expect(resolveTiles({ provider: 'tomtom', tomtomKey: 'tt' }).url).toContain('tomtom');
54+
expect(resolveTiles({ provider: 'carto' }).url).toContain('cartocdn');
4555
expect(resolveTiles({ provider: 'osm' }).url).toContain('openstreetmap');
4656
});
4757

48-
it('ignores an explicit provider whose key is missing, falling through', () => {
49-
// provider=mapbox but no token → falls to the best key we DO have (tomtom).
50-
expect(resolveTiles({ provider: 'mapbox', tomtomKey: 'tt' }).url).toContain('tomtom');
58+
it('ignores an explicit provider whose key is missing, falling to CARTO', () => {
59+
// provider=mapbox but no token → default path → keyless CARTO (HD).
60+
expect(resolveTiles({ provider: 'mapbox' }).url).toContain('cartocdn');
5161
});
5262

53-
it('with no provider, prefers the sharpest key available', () => {
63+
it('with no provider, prefers a paid @2x key, else keyless CARTO', () => {
5464
expect(resolveTiles({ mapTilerKey: 'k', tomtomKey: 'tt' }).url).toContain('maptiler');
5565
expect(resolveTiles({ mapboxToken: 'm', tomtomKey: 'tt' }).url).toContain('mapbox');
56-
expect(resolveTiles({ tomtomKey: 'tt' }).url).toContain('tomtom');
66+
// A stray TomTom key must NOT pin us to low-res — CARTO wins by default.
67+
expect(resolveTiles({ tomtomKey: 'tt' }).url).toContain('cartocdn');
5768
});
5869

59-
it('falls back to keyless OSM when nothing is configured', () => {
60-
expect(resolveTiles({}).url).toContain('openstreetmap');
70+
it('defaults to keyless CARTO (HD) when nothing is configured', () => {
71+
expect(resolveTiles({}).url).toContain('cartocdn');
6172
});
6273
});
6374

packages/ui/src/leaflet-map.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,20 @@ export function tomtomTiles(key: string): MapTiles {
5555
};
5656
}
5757

58-
// Keyless OpenStreetMap raster — final fallback when no token/key is set.
58+
// CARTO Voyager — keyless, free, and serves @2x retina tiles (the `{r}` token
59+
// Leaflet fills with '@2x' on retina screens). Clean modern style, noticeably
60+
// sharper than OSM/TomTom basic, with NO signup. The default when no key is set.
61+
export function cartoTiles(): MapTiles {
62+
return {
63+
url: 'https://basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png',
64+
tileSize: 256,
65+
zoomOffset: 0,
66+
detectRetina: false, // sharpness comes from the {r}=@2x token, not zoom-doubling
67+
attribution: '© CARTO © OpenStreetMap',
68+
};
69+
}
70+
71+
// Keyless OpenStreetMap raster — last resort.
5972
export function osmTiles(): MapTiles {
6073
return {
6174
url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
@@ -75,14 +88,18 @@ export function resolveTiles(cfg: {
7588
tomtomKey?: string;
7689
}): MapTiles {
7790
const p = (cfg.provider ?? '').toLowerCase();
91+
// Explicit provider wins (when its key, if any, is present).
7892
if (p === 'mapbox' && cfg.mapboxToken) return mapboxTiles(cfg.mapboxToken);
7993
if (p === 'maptiler' && cfg.mapTilerKey) return mapTilerTiles(cfg.mapTilerKey);
94+
if (p === 'tomtom' && cfg.tomtomKey) return tomtomTiles(cfg.tomtomKey);
95+
if (p === 'carto') return cartoTiles();
8096
if (p === 'osm') return osmTiles();
81-
// No explicit provider: prefer the sharpest key we actually have.
97+
// No explicit provider: prefer the sharpest source available. A paid @2x key
98+
// beats keyless CARTO; CARTO (keyless @2x) beats TomTom basic — so a stray
99+
// TomTom key doesn't pin us to low-res. TomTom is opt-in via provider=tomtom.
82100
if (cfg.mapTilerKey) return mapTilerTiles(cfg.mapTilerKey);
83101
if (cfg.mapboxToken) return mapboxTiles(cfg.mapboxToken);
84-
if (cfg.tomtomKey) return tomtomTiles(cfg.tomtomKey);
85-
return osmTiles();
102+
return cartoTiles();
86103
}
87104

88105
// Build the Leaflet page. mode 'pick' shows a fixed centre pin and posts the

0 commit comments

Comments
 (0)