Skip to content

Commit 0beaffb

Browse files
authored
feat(map): native mobile map experience with location detection and full feature parity (#619)
- Fix URL restore: lat/lon now override view center when explicitly provided - Fix touch scroll: 8px threshold before drag activation, preventDefault once active - Add location bootstrap: timezone-first detection, optional geolocation upgrade - Enable DeckGL on mobile with deviceMemory capability guard - Add DeckGL state sync on moveend/zoomend for URL param updates - Fix breakpoint off-by-one: JS now uses <= to match CSS max-width: 768px - Add country-click on SVG fallback with CSS transform inversion - Add fitCountry() to both map engines (DeckGL uses fitBounds, SVG uses projection) - Add SVG inertial touch animation with exponential velocity decay - Add mobile map e2e tests for timezone, URL restore, touch, and breakpoint
1 parent d24e094 commit 0beaffb

9 files changed

Lines changed: 376 additions & 16 deletions

File tree

e2e/mobile-map-native.spec.ts

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { devices, expect, test } from '@playwright/test';
2+
3+
const MOBILE_VIEWPORT = devices['iPhone 14 Pro Max'];
4+
5+
test.describe('Mobile map native experience', () => {
6+
const { defaultBrowserType: _bt, ...mobileContext } = MOBILE_VIEWPORT;
7+
8+
test.describe('timezone-based startup region', () => {
9+
test('America/New_York → america view', async ({ browser }) => {
10+
const context = await browser.newContext({
11+
...mobileContext,
12+
timezoneId: 'America/New_York',
13+
locale: 'en-US',
14+
});
15+
const page = await context.newPage();
16+
await page.addInitScript(() => {
17+
(window as any).__testResolvedLocation = true;
18+
});
19+
await page.goto('/');
20+
await page.waitForTimeout(3000);
21+
const region = await page.evaluate(() => {
22+
const select = document.getElementById('regionSelect') as HTMLSelectElement | null;
23+
return select?.value ?? null;
24+
});
25+
expect(region).toBe('america');
26+
await context.close();
27+
});
28+
29+
test('Europe/London → eu view', async ({ browser }) => {
30+
const context = await browser.newContext({
31+
...mobileContext,
32+
timezoneId: 'Europe/London',
33+
locale: 'en-GB',
34+
});
35+
const page = await context.newPage();
36+
await page.goto('/');
37+
await page.waitForTimeout(3000);
38+
const region = await page.evaluate(() => {
39+
const select = document.getElementById('regionSelect') as HTMLSelectElement | null;
40+
return select?.value ?? null;
41+
});
42+
expect(region).toBe('eu');
43+
await context.close();
44+
});
45+
46+
test('Asia/Tokyo → asia view', async ({ browser }) => {
47+
const context = await browser.newContext({
48+
...mobileContext,
49+
timezoneId: 'Asia/Tokyo',
50+
locale: 'ja-JP',
51+
});
52+
const page = await context.newPage();
53+
await page.goto('/');
54+
await page.waitForTimeout(3000);
55+
const region = await page.evaluate(() => {
56+
const select = document.getElementById('regionSelect') as HTMLSelectElement | null;
57+
return select?.value ?? null;
58+
});
59+
expect(region).toBe('asia');
60+
await context.close();
61+
});
62+
});
63+
64+
test.describe('URL restore', () => {
65+
test.use(mobileContext);
66+
67+
test('lat/lon override view center', async ({ page }) => {
68+
await page.goto('/?view=eu&lat=48.86&lon=2.35&zoom=5');
69+
await page.waitForTimeout(3000);
70+
const url = page.url();
71+
const params = new URL(url).searchParams;
72+
const lat = params.get('lat');
73+
const lon = params.get('lon');
74+
if (lat && lon) {
75+
expect(parseFloat(lat)).toBeCloseTo(48.86, 0);
76+
expect(parseFloat(lon)).toBeCloseTo(2.35, 0);
77+
} else {
78+
const region = await page.evaluate(() => {
79+
const select = document.getElementById('regionSelect') as HTMLSelectElement | null;
80+
return select?.value ?? null;
81+
});
82+
expect(region).not.toBe('eu');
83+
}
84+
});
85+
86+
test('zero-degree coordinates center at equator/prime meridian', async ({ page }) => {
87+
await page.goto('/?lat=0&lon=0&zoom=4');
88+
await page.waitForTimeout(3000);
89+
const url = page.url();
90+
const params = new URL(url).searchParams;
91+
const lat = params.get('lat');
92+
const lon = params.get('lon');
93+
expect(lat).not.toBeNull();
94+
expect(lon).not.toBeNull();
95+
if (lat && lon) {
96+
expect(Math.abs(parseFloat(lat))).toBeLessThan(5);
97+
expect(Math.abs(parseFloat(lon))).toBeLessThan(5);
98+
}
99+
});
100+
});
101+
102+
test.describe('touch interactions', () => {
103+
test.use(mobileContext);
104+
105+
test('single-finger pan does not scroll page', async ({ page }) => {
106+
await page.goto('/');
107+
await page.waitForTimeout(3000);
108+
const mapEl = page.locator('#mapContainer');
109+
await expect(mapEl).toBeVisible({ timeout: 10000 });
110+
111+
const scrollBefore = await page.evaluate(() => window.scrollY);
112+
113+
const box = await mapEl.boundingBox();
114+
if (box) {
115+
const startX = box.x + box.width / 2;
116+
const startY = box.y + box.height / 2;
117+
await page.touchscreen.tap(startX, startY);
118+
await page.mouse.move(startX, startY);
119+
await page.touchscreen.tap(startX, startY + 50);
120+
}
121+
122+
const scrollAfter = await page.evaluate(() => window.scrollY);
123+
expect(scrollAfter).toBe(scrollBefore);
124+
});
125+
});
126+
127+
test.describe('breakpoint consistency at 768px', () => {
128+
test('JS and CSS agree at exactly 768px', async ({ browser }) => {
129+
const context = await browser.newContext({
130+
viewport: { width: 768, height: 1024 },
131+
locale: 'en-US',
132+
});
133+
const page = await context.newPage();
134+
await page.goto('/');
135+
await page.waitForTimeout(2000);
136+
137+
const result = await page.evaluate(() => {
138+
const jsMobile = window.innerWidth <= 768;
139+
const el = document.createElement('div');
140+
el.style.display = 'none';
141+
document.body.appendChild(el);
142+
const cssMobile = window.matchMedia('(max-width: 768px)').matches;
143+
el.remove();
144+
return { jsMobile, cssMobile };
145+
});
146+
147+
expect(result.jsMobile).toBe(result.cssMobile);
148+
await context.close();
149+
});
150+
});
151+
});

src/App.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import { RefreshScheduler } from '@/app/refresh-scheduler';
3737
import { PanelLayoutManager } from '@/app/panel-layout';
3838
import { DataLoaderManager } from '@/app/data-loader';
3939
import { EventHandlerManager } from '@/app/event-handlers';
40+
import { resolveUserRegion } from '@/utils/user-location';
4041

4142
const CYBER_LAYER_ENABLED = import.meta.env.VITE_ENABLE_CYBER_LAYER === 'true';
4243

@@ -250,6 +251,7 @@ export class App {
250251
isPlaybackMode: false,
251252
isIdle: false,
252253
initialLoadComplete: false,
254+
resolvedLocation: 'global',
253255
initialUrlState,
254256
PANEL_ORDER_KEY,
255257
PANEL_SPANS_KEY,
@@ -331,6 +333,9 @@ export class App {
331333
// Hydrate in-memory cache from bootstrap endpoint (before panels construct and fetch)
332334
await fetchBootstrapData();
333335

336+
const resolvedRegion = await resolveUserRegion();
337+
this.state.resolvedLocation = resolvedRegion;
338+
334339
// Phase 1: Layout (creates map + panels — they'll find hydrated data)
335340
this.panelLayout.init();
336341

src/app/app-context.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ export interface AppContext {
122122
isPlaybackMode: boolean;
123123
isIdle: boolean;
124124
initialLoadComplete: boolean;
125+
resolvedLocation: 'global' | 'america' | 'mena' | 'eu' | 'asia' | 'latam' | 'africa' | 'oceania';
125126

126127
initialUrlState: ParsedMapUrlState | null;
127128
readonly PANEL_ORDER_KEY: string;

src/app/panel-layout.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,7 @@ export class PanelLayoutManager implements AppModule {
305305
this.ctx.map = new MapContainer(mapContainer, {
306306
zoom: this.ctx.isMobile ? 2.5 : 1.0,
307307
pan: { x: 0, y: 0 },
308-
view: this.ctx.isMobile ? 'mena' : 'global',
308+
view: this.ctx.isMobile ? this.ctx.resolvedLocation : 'global',
309309
layers: this.ctx.mapLayers,
310310
timeRange: '7d',
311311
});
@@ -743,13 +743,11 @@ export class PanelLayoutManager implements AppModule {
743743
this.ctx.map.setLayers(layers);
744744
}
745745

746-
if (!view) {
747-
if (zoom !== undefined) {
748-
this.ctx.map.setZoom(zoom);
749-
}
750-
if (lat !== undefined && lon !== undefined && zoom !== undefined && zoom > 2) {
751-
this.ctx.map.setCenter(lat, lon);
752-
}
746+
if (lat !== undefined && lon !== undefined) {
747+
const effectiveZoom = zoom ?? this.ctx.map.getState().zoom;
748+
if (effectiveZoom > 2) this.ctx.map.setCenter(lat, lon, zoom);
749+
} else if (!view && zoom !== undefined) {
750+
this.ctx.map.setZoom(zoom);
753751
}
754752

755753
const regionSelect = document.getElementById('regionSelect') as HTMLSelectElement;

src/components/DeckGLMap.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ import type { KindnessPoint } from '@/services/kindness-data';
9393
import type { HappinessData } from '@/services/happiness-data';
9494
import type { RenewableInstallation } from '@/services/renewable-installations';
9595
import type { SpeciesRecovery } from '@/services/conservation-data';
96-
import { getCountriesGeoJson, getCountryAtCoordinates } from '@/services/country-geometry';
96+
import { getCountriesGeoJson, getCountryAtCoordinates, getCountryBbox } from '@/services/country-geometry';
9797
import type { FeatureCollection, Geometry } from 'geojson';
9898

9999
export type TimeRange = '1h' | '6h' | '24h' | '48h' | '7d' | 'all';
@@ -505,6 +505,8 @@ export class DeckGLMap {
505505
this.lastSCZoom = -1;
506506
this.rafUpdateLayers();
507507
this.debouncedFetchBases();
508+
this.state.zoom = this.maplibreMap?.getZoom() ?? this.state.zoom;
509+
this.onStateChange?.(this.state);
508510
});
509511

510512
this.maplibreMap.on('move', () => {
@@ -530,6 +532,8 @@ export class DeckGLMap {
530532
this.lastZoomThreshold = currentZoom;
531533
this.debouncedRebuildLayers();
532534
}
535+
this.state.zoom = this.maplibreMap?.getZoom() ?? this.state.zoom;
536+
this.onStateChange?.(this.state);
533537
});
534538
}
535539

@@ -3576,6 +3580,17 @@ export class DeckGLMap {
35763580
}
35773581
}
35783582

3583+
public fitCountry(code: string): void {
3584+
const bbox = getCountryBbox(code);
3585+
if (!bbox || !this.maplibreMap) return;
3586+
const [minLon, minLat, maxLon, maxLat] = bbox;
3587+
this.maplibreMap.fitBounds([[minLon, minLat], [maxLon, maxLat]], {
3588+
padding: 40,
3589+
duration: 800,
3590+
maxZoom: 8,
3591+
});
3592+
}
3593+
35793594
public getCenter(): { lat: number; lon: number } | null {
35803595
if (this.maplibreMap) {
35813596
const center = this.maplibreMap.getCenter();

0 commit comments

Comments
 (0)