mirror of
https://github.com/hanzo-apps/world.git
synced 2026-10-30 14:53:09 +00:00
The native deck GlobeView now honors the 2D basemap switcher (dark | satellite | terrain), reading the SAME localStorage key (hanzo-world-basemap-style) and reacting live to a basemap-style-changed event DeckGLMap fires — no reload. - dark: the existing monochrome vercel-black sphere. - satellite: keyless ESRI World Imagery draped via a deck TileLayer/BitmapLayer, back-faces culled so the far hemisphere never bleeds through; the near-black ocean sphere underneath supplies depth + darkens the imagery so data dots stay legible. Thin light country borders overlaid for intel orientation. - terrain: keyless ESRI World Physical Map (natural relief). Data layers (dots/arcs/borders) render ON TOP and keep picking; ocean sphere is tinted dark slate-blue on bright styles so the Web-Mercator polar gap blends. Since native uses keyless ESRI, the switcher's sat/terrain buttons no longer need a Mapbox token when native is the 3D renderer (default), and setBasemapStyle notifies the globe before the tokenless mapbox setStyle so a missing token can't block the switch. Adds a style-switch e2e (single-context preserved, data still picks) + harness hooks. Co-authored-by: Hanzo Dev <[email protected]>
137 lines
5.5 KiB
TypeScript
137 lines
5.5 KiB
TypeScript
import { expect, test } from '@playwright/test';
|
|
|
|
// Native deck.gl GlobeView (GlobeNative) — the pure-deck 3D globe that replaces the
|
|
// mapbox globe behind the ?globe=native flag.
|
|
//
|
|
// This guards the structural perf + correctness contract that a headless GPU CAN
|
|
// verify:
|
|
// - the mode activates from the ?globe=native flag,
|
|
// - the whole page holds exactly ONE WebGL context (no mapbox, no overlay) — the
|
|
// single-context perf target,
|
|
// - deck's active viewport is a GlobeViewport (proves it's reprojecting onto the
|
|
// sphere, not a flat map),
|
|
// - a seeded hotspot picks at its globe-projected screen point (layers render AND
|
|
// reproject on the sphere).
|
|
|
|
type PickResult = { found: boolean; layerId: string | null };
|
|
|
|
type GlobeHarness = {
|
|
ready: boolean;
|
|
nativeEnabled: boolean;
|
|
getCanvasCount: () => number;
|
|
getViewportType: () => string | null;
|
|
getFirstHotspotLngLat: () => { lon: number; lat: number } | null;
|
|
setCamera: (lon: number, lat: number, zoom: number) => void;
|
|
stopSpin: () => void;
|
|
pickAtLonLat: (lon: number, lat: number, radius?: number) => PickResult;
|
|
setBasemapStyle: (style: 'dark' | 'satellite' | 'terrain') => void;
|
|
getBasemapStyle: () => string;
|
|
destroy: () => void;
|
|
};
|
|
|
|
type HarnessWindow = Window & { __globeHarness?: GlobeHarness };
|
|
type Page = import('@playwright/test').Page;
|
|
|
|
const ready = async (page: Page, query: string): Promise<void> => {
|
|
await page.goto(`/tests/globe-native-harness.html${query}`);
|
|
await expect(page.locator('.globe-native-wrapper')).toBeVisible();
|
|
await expect
|
|
.poll(() => page.evaluate(() => Boolean((window as HarnessWindow).__globeHarness?.ready)), {
|
|
timeout: 45000,
|
|
})
|
|
.toBe(true);
|
|
};
|
|
|
|
const pickHotspot = async (page: Page): Promise<PickResult> =>
|
|
page.evaluate(() => {
|
|
const w = (window as HarnessWindow).__globeHarness!;
|
|
const hs = w.getFirstHotspotLngLat();
|
|
if (!hs) return { found: false, layerId: null };
|
|
w.setCamera(hs.lon, hs.lat, 3);
|
|
w.stopSpin();
|
|
return w.pickAtLonLat(hs.lon, hs.lat, 12);
|
|
});
|
|
|
|
test.describe('native deck.gl GlobeView', () => {
|
|
test.describe.configure({ retries: 1 });
|
|
test.use({ reducedMotion: 'reduce' });
|
|
|
|
test('flag activates; single WebGL context; GlobeViewport; hotspot picks on the sphere', async ({
|
|
page,
|
|
}, testInfo) => {
|
|
const pageErrors: string[] = [];
|
|
const ignorable = [/could not compile fragment shader/i, /image.*could not be decoded/i];
|
|
page.on('pageerror', (e) => pageErrors.push(e.message));
|
|
|
|
await ready(page, '?globe=native');
|
|
|
|
// The flag helper the app routes on reads native from ?globe=native.
|
|
expect(
|
|
await page.evaluate(() => (window as HarnessWindow).__globeHarness!.nativeEnabled),
|
|
).toBe(true);
|
|
|
|
// Single WebGL context: exactly one <canvas> on the whole page (no mapbox basemap
|
|
// canvas, no deck overlay canvas — just GlobeNative's).
|
|
expect(await page.evaluate(() => document.querySelectorAll('canvas').length)).toBe(1);
|
|
expect(
|
|
await page.evaluate(() => (window as HarnessWindow).__globeHarness!.getCanvasCount()),
|
|
).toBe(1);
|
|
|
|
// Active viewport is a GlobeViewport (deck is reprojecting onto the sphere).
|
|
await expect.poll(() =>
|
|
page.evaluate(() => (window as HarnessWindow).__globeHarness!.getViewportType()),
|
|
).toMatch(/Globe/i);
|
|
|
|
// A seeded hotspot picks at its globe-projected screen point.
|
|
await expect.poll(async () => (await pickHotspot(page)).found, { timeout: 20000 }).toBe(true);
|
|
|
|
// CTO-facing proof: dots + monochrome basemap on the sphere.
|
|
await page.evaluate(() => {
|
|
const w = (window as HarnessWindow).__globeHarness!;
|
|
const hs = w.getFirstHotspotLngLat();
|
|
if (hs) w.setCamera(hs.lon, hs.lat, 2.2);
|
|
w.stopSpin();
|
|
});
|
|
await page.waitForTimeout(600);
|
|
const shot = await page.locator('.globe-native-wrapper').screenshot();
|
|
await testInfo.attach('globe-native-dots', { body: shot, contentType: 'image/png' });
|
|
|
|
expect(pageErrors.filter((e) => !ignorable.some((p) => p.test(e)))).toEqual([]);
|
|
});
|
|
|
|
test('native is the default; ?globe=mapbox is the escape hatch', async ({ page }) => {
|
|
// Default (no query param) → native is on.
|
|
await ready(page, '');
|
|
expect(
|
|
await page.evaluate(() => (window as HarnessWindow).__globeHarness!.nativeEnabled),
|
|
).toBe(true);
|
|
expect(await page.evaluate(() => document.querySelectorAll('canvas').length)).toBe(1);
|
|
|
|
// Escape hatch → native off (the app would render the mapbox globe instead).
|
|
await ready(page, '?globe=mapbox');
|
|
expect(
|
|
await page.evaluate(() => (window as HarnessWindow).__globeHarness!.nativeEnabled),
|
|
).toBe(false);
|
|
});
|
|
|
|
test('basemap style switches dark/satellite/terrain, stays single-context, data still picks', async ({
|
|
page,
|
|
}) => {
|
|
await ready(page, '?globe=native');
|
|
|
|
const setStyle = (s: 'dark' | 'satellite' | 'terrain') =>
|
|
page.evaluate((style) => (window as HarnessWindow).__globeHarness!.setBasemapStyle(style), s);
|
|
const getStyle = () =>
|
|
page.evaluate(() => (window as HarnessWindow).__globeHarness!.getBasemapStyle());
|
|
|
|
for (const style of ['satellite', 'terrain', 'dark'] as const) {
|
|
await setStyle(style);
|
|
expect(await getStyle()).toBe(style);
|
|
// Draping imagery must not spawn a second WebGL context...
|
|
expect(await page.evaluate(() => document.querySelectorAll('canvas').length)).toBe(1);
|
|
// ...and the data layers keep rendering ON TOP (still pickable on the sphere).
|
|
await expect.poll(async () => (await pickHotspot(page)).found, { timeout: 15000 }).toBe(true);
|
|
}
|
|
});
|
|
});
|