diff --git a/.env.example b/.env.example index d1e5026..a8b867b 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,6 @@ # Copy this file to .env and configure your MQTT feed. No map key or build is needed. # Keep .env and any optional .secrets files private. -CARTOLITE_IMAGE=ghcr.io/n30nex/cartolite-server:0.5.1 +CARTOLITE_IMAGE=ghcr.io/n30nex/cartolite-server:0.6.0 CARTOLITE_BIND_ADDR=127.0.0.1 CARTOLITE_PORT=8080 # Optional CARTO override: see compose.carto.yml and docs/deployment.md. diff --git a/CHANGELOG.md b/CHANGELOG.md index d07da55..2e4a478 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 0.6.0 - Cinematic overhaul + +- Add Spectacle, Calm and Minimal effects, automatic detail/graphics, shared motion and text-size controls while retaining saved styles and sound settings. +- Draw live packet cores, directional trails and terrain-aligned arrival effects with shared WebGL2 batches and Canvas fallback. Historical links stay still and packet/audio timing is unchanged. +- Introduce a compact dock, fixed node inspector, selection continuity between views, reversible layer combinations and native/browser Back handling for menus. +- Add scoped ten-second Follow with Hold, Next and Inspect; bring live directing and area/component focus to Netgraph. +- Reduce crowded labels and animated area badges, preserve every retained route, and improve light/dark contrast and keyboard focus. +- Add explicit voice preview and mute controls for the existing 30 sound voices. +- Preserve key-free OpenFreeMap, worldwide geographic grouping and tested AMD64/ARM64 Docker installation. + ## 0.5.0 - 2026-09-12 - Add shared Map/Netgraph themes, six route presets, advanced styling controls and readable light/dark palettes. diff --git a/README.md b/README.md index a07a189..75bb664 100644 --- a/README.md +++ b/README.md @@ -130,3 +130,22 @@ This repository contains the standalone server, browser map, and worldwide Netgr ## License [MIT](LICENSE). CartoLite Server is derived from the CartoLite map system and keeps its privacy-first public-data boundary. + +## Cinematic display + +Map and Netgraph share effects intensity, six route presets, detail level, +graphics quality, motion and text size. New settings default to Spectacle with +automatic detail/quality and the system motion preference. Existing appearance, +layer and audio choices are preserved. Sound stays opt-in; Preview voice plays +one explicit sample without enabling live audio. + +The dock holds navigation, Find, Layers, Follow, Sound and Display. Network, +Activity and Landscape combinations change layers only and include Undo. +Follow holds each live subject for ten seconds and supports fixed-area or +selected-node scopes, Hold, Next and Inspect. It never replays old traffic. +Netgraph adds area/component focus without rearranging the network. + +GPU packet effects use the same geographic projections and hop clock as the +Canvas fallback. Automatic detail reduces background decoration, not retained +connections. Terrain and buildings are visual context, not RF coverage or +measured antenna heights. A missing basemap does not prevent using Netgraph. diff --git a/VERSION b/VERSION index 4b9fcbe..a918a2a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.1 +0.6.0 diff --git a/compose.yml b/compose.yml index b4a9c9d..2cb8723 100644 --- a/compose.yml +++ b/compose.yml @@ -2,7 +2,7 @@ name: cartolite-server services: cartolite: - image: ${CARTOLITE_IMAGE:-ghcr.io/n30nex/cartolite-server:0.5.1} + image: ${CARTOLITE_IMAGE:-ghcr.io/n30nex/cartolite-server:0.6.0} restart: unless-stopped ports: - "${CARTOLITE_BIND_ADDR:-127.0.0.1}:${CARTOLITE_PORT:-8080}:8080" diff --git a/docs/cinematic-overhaul.md b/docs/cinematic-overhaul.md new file mode 100644 index 0000000..d0f2e65 --- /dev/null +++ b/docs/cinematic-overhaul.md @@ -0,0 +1,16 @@ +# Cinematic overhaul delivery + +Target Canada 0.15.0, Worldwide 0.6.0 and Canada Android 1.1.0. + +- [x] Shared preferences, compact dock, layer combinations and accessibility +- [x] Shared GPU live effects with Canvas fallback; terrain and density refinement +- [x] Scoped Follow, Netgraph focus, selection continuity and Canada Labs +- [ ] Synthetic visual, privacy, performance and image gates in Actions +- [ ] Exact-artifact releases, Canada backup/restore/deployment, physical APK acceptance + +No public API changes. Existing topology and packet/audio clocks remain authoritative. +Builds, tests and browser artifacts run in GitHub Actions only. + +The app supplies compact MapLibre canvas/gesture/attribution styling rather than shipping unused provider-control icons. The existing total byte limits remain unchanged. + +Implementation checkmarks indicate code completion. Release verification remains open until the final commit passes Actions and runtime acceptance. diff --git a/web/e2e/cinematic.spec.ts b/web/e2e/cinematic.spec.ts new file mode 100644 index 0000000..5c6d07a --- /dev/null +++ b/web/e2e/cinematic.spec.ts @@ -0,0 +1,126 @@ +import { expect, test } from '@playwright/test'; +import { visualFixture, visualPacket } from './visualFixtures'; +import { openMapOptions } from './mapControls'; + +test('cinematic preferences migrate and layer combinations are reversible', async ({page},info) => { + await visualFixture(page); await page.goto('/'); + await expect(page.locator('#map')).toHaveAttribute('data-render-state','idle'); + await openMapOptions(page); + await expect(page.getByLabel('Effects intensity')).toHaveValue('spectacle'); + const windowBefore=await page.getByLabel('Route age window').inputValue(); + const before=await page.locator('#routes-button').getAttribute('aria-pressed'); + await page.getByRole('button',{name:'Activity',exact:true}).click(); + await expect(page.locator('#routes-button')).toHaveAttribute('aria-pressed','false'); + await expect(page.locator('#live-packets-button')).toHaveAttribute('aria-pressed','true'); + await expect(page.getByLabel('Route age window')).toHaveValue(windowBefore); + await page.getByRole('button',{name:'Undo',exact:true}).click(); + await expect(page.locator('#routes-button')).toHaveAttribute('aria-pressed',before!); + await page.getByLabel('Text size').selectOption('large'); + await page.getByLabel('Motion preference').selectOption('reduced'); + await page.getByLabel('Effects intensity').selectOption('calm'); + await page.keyboard.press('Escape'); + await expect(page.locator('html')).toHaveAttribute('data-motion','reduced'); + await page.reload(); await openMapOptions(page); + await expect(page.getByLabel('Text size')).toHaveValue('large'); + await expect(page.getByLabel('Effects intensity')).toHaveValue('calm'); + const box=await page.locator('#layers-panel').boundingBox(); + expect(box!.x).toBeGreaterThanOrEqual(0); expect(box!.y).toBeGreaterThanOrEqual(0); + expect(box!.x+box!.width).toBeLessThanOrEqual(page.viewportSize()!.width); + expect(box!.y+box!.height).toBeLessThanOrEqual(page.viewportSize()!.height); + await page.screenshot({path:info.outputPath('cinematic-accessible-controls.png')}); + await page.keyboard.press('Escape'); + const credits=page.locator('.maplibregl-ctrl-attrib-button'); + if(await page.locator('.maplibregl-ctrl-attrib-inner').isVisible())await credits.click(); + await credits.click(); + await expect(page.locator('.maplibregl-ctrl-attrib-inner')).toBeVisible(); + await credits.click(); +}); + +test('GPU live effects survive context loss with Canvas fallback', async ({page},info) => { + const errors:string[]=[];page.on('pageerror',error=>errors.push(error.message)); + await visualFixture(page);await page.goto('/netgraph/'); + await expect(page.locator('html')).toHaveAttribute('data-fixture-stream','ready'); + await visualPacket(page,1,'Text'); + await expect(page.locator('#netgraph-stage')).toHaveAttribute('data-effects-renderer','webgl2'); + await page.screenshot({path:info.outputPath('netgraph-gpu-packet.png')}); + await page.locator('.gpu-packet-canvas').evaluate(element => (element as HTMLCanvasElement).getContext('webgl2')!.getExtension('WEBGL_lose_context')!.loseContext()); + await visualPacket(page,2,'Advert'); + await expect(page.locator('#netgraph-stage')).toHaveAttribute('data-effects-renderer','canvas2d'); + await expect.poll(()=>page.locator('#packet-canvas').evaluate(element=>{const c=element as HTMLCanvasElement;return c.getContext('2d')!.getImageData(0,0,c.width,c.height).data.some((v,i)=>i%4===3&&v>30);})).toBe(true); + await page.screenshot({path:info.outputPath('netgraph-canvas-fallback.png')}); + expect(errors).toEqual([]); +}); + +test('selected public node stays selected across views and browser Back closes menus', async ({page}) => { + await visualFixture(page);await page.goto('/'); + await expect(page.locator('#map')).toHaveAttribute('data-render-state','idle'); + await page.locator('#find-button').click(); await page.locator('#node-search').fill('Visual Alpha'); + await page.getByRole('option',{name:/Visual Alpha/}).click(); + await expect(page.locator('#map')).toHaveAttribute('data-selected-node-id','visual-a'); + await page.getByRole('link',{name:'Open CartoLite Netgraph',exact:true}).click(); + await expect(page.locator('.node-inspector')).toHaveAttribute('data-node-id','visual-a'); + await page.locator('#display-button').click();await expect(page.locator('#display-panel')).toBeVisible(); + await page.goBack();await expect(page.locator('#display-panel')).toBeHidden(); + await expect(page).toHaveURL(/netgraph/); + await expect(page.locator('.node-inspector')).toBeVisible(); + await page.goBack(); + await expect(page.locator('#node-inspector-sheet')).toBeHidden(); + await page.goBack(); + await expect(page.locator('#map .maplibregl-canvas')).toHaveCount(1); + await expect(page.locator('#map')).toHaveAttribute('data-render-state','idle'); +}); + +test('rapidly reopening Finder after selection keeps its input available', async ({page}) => { + await visualFixture(page);await page.goto('/'); + await expect(page.locator('#map')).toHaveAttribute('data-render-state','idle'); + for (const name of ['Visual Alpha','Visual Bravo','Visual Alpha']) { + await page.locator('#find-button').click(); await page.locator('#node-search').fill(name); + await page.getByRole('option',{name:new RegExp(name)}).click(); + } + await expect(page.locator('#map')).toHaveAttribute('data-selected-node-id','visual-a'); +}); + +test('the Android shell owns keep-awake instead of a second browser lock', async ({page}) => { + await visualFixture(page); + await page.addInitScript(()=>Object.defineProperty(navigator,'userAgent',{get:()=> 'Mozilla/5.0 CartoLiteAndroid/1.1.0'})); + for(const path of ['/','/netgraph/']) { await page.goto(path); await expect(page.locator(path==='/'?'#app':'#netgraph-app')).toHaveAttribute('data-screen-awake','native'); } +}); + +test('Netgraph Follow holds ten seconds, filters a selected node and waits honestly', async ({page},info) => { + await page.emulateMedia({reducedMotion:'reduce'}); + await visualFixture(page);await page.goto('/netgraph/?node=visual-a'); + await expect(page.locator('#netgraph-stage')).toHaveAttribute('data-selected-node-id','visual-a'); + await expect(page.locator('html')).toHaveAttribute('data-fixture-stream','ready'); + await page.clock.install();await page.clock.pauseAt(new Date(Date.now()+1000)); + await page.getByRole('button',{name:/Follow$/,exact:false}).click(); + const card=page.locator('#follow-card'); + await expect(card).toBeVisible(); + await card.getByLabel('Follow scope').selectOption('node'); + await visualPacket(page,1,'Text'); + await expect(card.locator('[data-detail]')).toHaveText('Text · 1 confirmed hop'); + await expect(card.locator('output')).toHaveText('10s'); + await page.clock.fastForward(4000);await visualPacket(page,2,'Advert'); + await expect(card.locator('output')).toHaveText('6s'); + await expect(card.locator('[data-detail]')).toContainText('Text'); + await page.clock.fastForward(6000); + await expect(card.locator('[data-detail]')).toContainText('Advert'); + await card.getByRole('button',{name:'Hold',exact:true}).click(); + await page.clock.fastForward(11000); + await expect(card.locator('[data-state]')).toContainText('Held'); + await visualPacket(page,3,'Trace'); + await card.getByRole('button',{name:'Next',exact:true}).click(); + await expect(card.locator('[data-detail]')).toContainText('Trace'); + await page.screenshot({path:info.outputPath('netgraph-follow.png')}); + await card.getByRole('button',{name:'Inspect',exact:true}).click(); + await expect(page.locator('.node-inspector')).toBeVisible(); + await page.getByRole('button',{name:'Close node details',exact:true}).click(); + await expect(card).toBeVisible(); + await card.getByRole('button',{name:'Next',exact:true}).click(); + await expect(card.locator('[data-title]')).toHaveText('Waiting for activity'); + await expect(card.getByRole('button',{name:'Inspect',exact:true})).toBeDisabled(); + // A confirmed link that does not touch the captured node must not be selected. + await page.evaluate(()=>window.dispatchEvent(new CustomEvent('visual-packet',{detail:{seq:4,id:'unrelated',at:Date.now(),payloadType:'Text',mode:'route',segments:[{routeId:'other',fromId:'visual-b',toId:'visual-c'}]}}))); + await expect(card.locator('[data-title]')).toHaveText('Waiting for activity'); + await card.getByLabel('Follow scope').focus();await page.keyboard.press('Escape'); + await expect(card).toBeHidden(); +}); diff --git a/web/e2e/customization.spec.ts b/web/e2e/customization.spec.ts index c1a54c7..ba7eb1a 100644 --- a/web/e2e/customization.spec.ts +++ b/web/e2e/customization.spec.ts @@ -84,12 +84,13 @@ test('Live Follow holds its activity card for ten seconds and pauses when the us await page.clock.runFor(80); await page.screenshot({ path: testInfo.outputPath('live-follow-card.png') }); await page.locator('#follow-pause').click(); - await expect(page.locator('#follow-card')).toHaveAttribute('data-state', 'paused'); + await expect(page.locator('#follow-card')).toHaveAttribute('data-state', 'held'); await emit(page, 3, 'Trace'); await page.clock.fastForward(11_000); await expect(page.locator('#follow-detail')).toHaveText('Advert · 2 confirmed hops'); await page.locator('#follow-pause').click(); await emit(page, 4, 'Trace'); + await page.locator('#follow-next').click(); await expect(page.locator('#follow-detail')).toHaveText('Trace · 2 confirmed hops'); // Native map gestures need their animation callbacks after the timed assertions. await page.clock.resume(); @@ -103,6 +104,8 @@ test('Live Follow holds its activity card for ten seconds and pauses when the us await page.locator('#find-button').click(); await page.locator('#node-search').fill('Summit'); await page.locator('.node-search-result').first().click(); + // Pause before fresh events, with headroom for the software renderer's command round trip. + await page.clock.pauseAt(new Date(await page.evaluate(() => Date.now() + 10_000))); await page.locator('#follow-button').click(); await emit(page, 5, 'Text'); await expect(page.locator('#follow-card')).toHaveAttribute('data-state', 'following'); @@ -112,9 +115,9 @@ test('Live Follow holds its activity card for ten seconds and pauses when the us seq: 6, id: 'fixture-new-neighbour', at: Date.now(), mode: 'route', payloadType: 'Advert', segments: [{ routeId: 'new-neighbour', fromId: 'summit', toId: 'meadow' }], } }))); + await page.clock.runFor(1000); // Flush frame batching and coalesced route hydration under virtual time. await expect(page.locator('#map')).toHaveAttribute('data-neighbor-route-count', '3'); await expect(page.locator('#follow-card')).toHaveAttribute('data-state', 'following'); - await page.clock.pauseAt(new Date(await page.evaluate(() => Date.now() + 1000))); await page.clock.fastForward(10_000); await page.clock.fastForward(10_000); await expect(page.locator('#follow-card')).toHaveAttribute('data-state', 'following'); diff --git a/web/e2e/display-upgrade.spec.ts b/web/e2e/display-upgrade.spec.ts index 5dbea01..4c2967f 100644 --- a/web/e2e/display-upgrade.spec.ts +++ b/web/e2e/display-upgrade.spec.ts @@ -50,23 +50,19 @@ test('shares presets and custom styling across Map and Netgraph without losing c await expect(page.locator('#map-notice')).toBeHidden(); }); -test('uses bright packet ink at night and dark ink on both light scenes', async ({ page }, info) => { - await visualFixture(page); - let sequence = 0; - for (const path of ['/', '/netgraph/']) { - sequence = 0; - await page.goto(path); - await expect(page.locator('html')).toHaveAttribute('data-fixture-stream', 'ready'); - for (const scene of ['dark', 'light', 'streets']) { - if (path === '/') { await openMapOptions(page); await page.locator('#basemap-style').selectOption(scene); } - else { await page.locator('#display-button').click(); await page.getByLabel('Scene style', { exact: true }).selectOption(scene); } - await page.keyboard.press('Escape'); - await visualPacket(page, ++sequence); - await expect.poll(() => packetInk(page, scene !== 'dark'), { timeout: 3000 }).toBeGreaterThan(2); - await page.screenshot({ path: info.outputPath(`${path === '/' ? 'map' : 'netgraph'}-${scene}.png`) }); - } - } -}); +for (const path of ['/', '/netgraph/']) for (const scene of ['dark', 'light', 'streets']) { + test(`composited live packet ink is readable in ${path} ${scene}`, async ({page},info) => { + await visualFixture(page); await page.goto(path); + await expect(page.locator('html')).toHaveAttribute('data-fixture-stream','ready'); + if(path==='/') { await openMapOptions(page); await page.locator('#basemap-style').selectOption(scene); } + else { await page.locator('#display-button').click(); await page.getByLabel('Scene style',{exact:true}).selectOption(scene); } + await page.keyboard.press('Escape'); + await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(()=>resolve())))); + const before=await sceneFrame(page); await visualPacket(page,1); + await expect.poll(()=>packetInk(page,scene!=='dark',before),{timeout:3000}).toBeGreaterThan(2); + await page.screenshot({path:info.outputPath(`${path==='/'?'map':'netgraph'}-${scene}.png`)}); + }); +} test('reports missing tiles while keeping node data and Netgraph available', async ({ page }) => { await visualFixture(page); @@ -195,10 +191,14 @@ test('can disable lingering trails without disabling live packets', async ({ pag await page.getByLabel('After-trails', { exact: true }).press('Home'); await expect(page.getByLabel('After-trails', { exact: true })).toHaveValue('0'); await page.keyboard.press('Escape'); + await expect(page.locator('#map')).toHaveAttribute('data-render-state','idle'); + await page.clock.install();await page.clock.pauseAt(new Date(Date.now()+1000)); + const beforePacket = await sceneFrame(page); await visualPacket(page, 1); - await expect.poll(() => packetInk(page, false)).toBeGreaterThan(2); + await page.clock.runFor(600); + await expect.poll(() => packetInk(page, false, beforePacket)).toBeGreaterThan(2); // Includes the existing six-second node wake, independently of route residue. - await page.waitForTimeout(9500); + await page.clock.fastForward(9500);await page.clock.runFor(32); const painted = await page.locator('#packet-canvas').evaluate((element) => { const canvas = element as HTMLCanvasElement; return canvas.getContext('2d')!.getImageData(0, 0, canvas.width, canvas.height).data.some((value, index) => index % 4 === 3 && value > 0); @@ -207,18 +207,25 @@ test('can disable lingering trails without disabling live packets', async ({ pag await expect(page.locator('#packet-canvas')).toHaveAttribute('data-enabled', 'true'); }); -async function packetInk(page: Page, light: boolean): Promise { - return page.locator('#packet-canvas').evaluate((element, light) => { - const canvas = element as HTMLCanvasElement; - const pixels = canvas.getContext('2d')!.getImageData(0, 0, canvas.width, canvas.height).data; - let found = 0; - for (let i = 0; i < pixels.length; i += 4) { - if (pixels[i + 3]! < 180) continue; - if (light ? pixels[i]! < 40 && pixels[i + 1]! > 45 && pixels[i + 1]! < 145 && pixels[i + 2]! < 140 - : pixels[i]! > 40 && pixels[i]! < 110 && pixels[i + 1]! > 180 && pixels[i + 2]! > 150) found++; +async function sceneFrame(page: Page): Promise { + return page.screenshot({style: '.topbar,.controls,.legend,.route-legend,.graph-summary,.graph-focus,.zoom-controls,.node-inspector-sheet,.follow-card,.maplibregl-ctrl-attrib{visibility:hidden!important}'}); +} + +async function packetInk(page: Page, light: boolean, before: Buffer): Promise { + const after = await sceneFrame(page); + return page.evaluate(async ({before,after,light}) => { + const decode=async (data:string) => { + const image=new Image();image.src=`data:image/png;base64,${data}`;await image.decode(); + const canvas=document.createElement('canvas');canvas.width=image.width;canvas.height=image.height; + const context=canvas.getContext('2d')!;context.drawImage(image,0,0);return context.getImageData(0,0,canvas.width,canvas.height).data; + }; + const [base,pixels]=await Promise.all([decode(before),decode(after)]);let found=0; + for(let i=0;i45&&pixels![i+1]!<145&&pixels![i+2]!<140:pixels![i]!>40&&pixels![i]!<110&&pixels![i+1]!>180&&pixels![i+2]!>150)found++; } return found; - }, light); + },{before:before.toString('base64'),after:after.toString('base64'),light}); } async function changedPixels(page: Page, before: Buffer, after: Buffer): Promise { diff --git a/web/e2e/netgraph.spec.ts b/web/e2e/netgraph.spec.ts index 0e301f9..7024921 100644 --- a/web/e2e/netgraph.spec.ts +++ b/web/e2e/netgraph.spec.ts @@ -55,7 +55,7 @@ test('Netgraph renders stable topology, inspection, and synchronized musical hop await expect(stage).toHaveAttribute('data-last-region-traffic', 'long-haul'); await expect.poll(() => stage.getAttribute('data-active-region-labels').then(Number), { timeout: 8_000 }).toBeGreaterThan(0); await expect(stage).toHaveAttribute('data-active-region-roles', /OUT/); - await expect.poll(() => canvasHasPixels(page, '#packet-canvas'), { timeout: 5_000 }).toBe(true); + await expect.poll(async () => await canvasHasPixels(page, '#packet-canvas') || await canvasHasPixels(page, '#netgraph-residue-canvas') || await gpuHasPixels(page), { timeout: 5_000 }).toBe(true); await page.locator('#find-button').click(); await page.locator('#node-search').fill('Alpha'); @@ -399,3 +399,14 @@ async function instrumentAudioContext(page: Page): Promise { Object.defineProperty(window, 'AudioContext', { configurable: true, value: TestAudioContext }); }); } + +async function gpuHasPixels(page: Page): Promise { + return page.locator('.gpu-packet-canvas').evaluate(element => new Promise(resolve => requestAnimationFrame(() => { + const canvas = element as HTMLCanvasElement; + const gl = canvas.getContext('webgl2'); + if (!gl || gl.isContextLost()) { resolve(false); return; } + const pixels = new Uint8Array(canvas.width * canvas.height * 4); + gl.readPixels(0,0,canvas.width,canvas.height,gl.RGBA,gl.UNSIGNED_BYTE,pixels); + resolve(pixels.some((value,index)=>index%4===3&&value>0)); + }))); +} diff --git a/web/e2e/refinements.spec.ts b/web/e2e/refinements.spec.ts index 8715217..9f190aa 100644 --- a/web/e2e/refinements.spec.ts +++ b/web/e2e/refinements.spec.ts @@ -48,7 +48,7 @@ for (const path of ['/', '/netgraph/']) { if (path === '/netgraph/' && page.viewportSize()!.height <= 520) { const inspector = await page.locator('#node-inspector-sheet').boundingBox(); const controls = await page.locator('.controls').boundingBox(); - expect(inspector!.y).toBeGreaterThanOrEqual(controls!.y + controls!.height); + expect(inspector!.y + inspector!.height).toBeLessThanOrEqual(controls!.y); } if (await layers.isVisible()) await layers.click(); diff --git a/web/e2e/scale-smoke.spec.ts b/web/e2e/scale-smoke.spec.ts index e1f4961..bef0de0 100644 --- a/web/e2e/scale-smoke.spec.ts +++ b/web/e2e/scale-smoke.spec.ts @@ -11,6 +11,7 @@ test('keeps a 4k-node / 7k-route first view responsive', async ({ page }, testIn // Allow the complete interaction journey to finish; the explicit startup, // frame, long-task, and application-work budgets below still gate performance. test.slow(); + page.on('console', message => { if (message.type() === 'warn' || message.type() === 'error') console.log('[scale]', message.text().replace(/https?:\/\/\S+/g, '[resource]').slice(0, 300)); }); const state = scaleState(); const firstRoute = state.routes[0]; if (!firstRoute) throw new Error('scale fixture has no routes'); @@ -41,7 +42,8 @@ test('keeps a 4k-node / 7k-route first view responsive', async ({ page }, testIn const map = page.locator('#map'); await expect(page.locator('#route-canvas')).toHaveCount(0); await expect(map).toHaveAttribute('data-route-renderer', 'maplibre-webgl'); - await expect(map).toHaveAttribute('data-exact-routes-ready', 'true', { timeout: 15_000 }); + try { await expect(map).toHaveAttribute('data-exact-routes-ready', 'true', { timeout: 15_000 }); } + catch(error) { console.log('Route readiness diagnostic',await map.evaluate(element=>({...((element as HTMLElement).dataset)}))); throw error; } await expect(map).toHaveAttribute('data-rendered-route-segments', '7000'); await installLongTaskObserver(page); @@ -109,9 +111,7 @@ test('keeps a 4k-node / 7k-route first view responsive', async ({ page }, testIn await resetLongTasks(page); await page.locator('.node-search-result').first().click(); await expect(map).toHaveAttribute('data-selected-node-id', 'node-0'); - const inspector = testInfo.project.name.startsWith('mobile') - ? page.locator('#node-inspector-sheet') - : page.locator('.node-inspector-popup'); + const inspector = page.locator('#node-inspector-sheet'); await expect(inspector).toBeVisible(); await expect(inspector.locator('.neighbor-row').first()).toBeVisible(); expect(Number(await map.getAttribute('data-node-selection-apply-ms')), 'opening an indexed node inspector must finish within 100 ms').toBeLessThan(100); diff --git a/web/e2e/tablet-portrait.spec.ts b/web/e2e/tablet-portrait.spec.ts index cd7a148..84bc43f 100644 --- a/web/e2e/tablet-portrait.spec.ts +++ b/web/e2e/tablet-portrait.spec.ts @@ -26,7 +26,7 @@ test('keeps tablet portrait controls compact, separated, and touch sized', async return { left: bounds.left, right: bounds.right, top: bounds.top, bottom: bounds.bottom }; })); expect(chrome).toHaveLength(2); - expect(chrome[0].right).toBeLessThanOrEqual(chrome[1].left); + expect(chrome[0].bottom <= chrome[1].top || chrome[0].right <= chrome[1].left).toBe(true); const primarySizes = await page.locator('#follow-button, #sound-button, #reset-button, #layers-summary').evaluateAll( (elements) => elements.map((element) => { diff --git a/web/e2e/ui-ux.spec.ts b/web/e2e/ui-ux.spec.ts index 2c79e7c..58e7dc7 100644 --- a/web/e2e/ui-ux.spec.ts +++ b/web/e2e/ui-ux.spec.ts @@ -112,11 +112,11 @@ test('Netgraph menus are exclusive for keyboard users and its camera supports ke for (const label of ['nodes', 'links', 'areas', 'groups']) await expect(page.locator('.summary-compact').filter({ hasText: label })).toBeVisible(); const summary = await page.locator('#graph-summary').boundingBox(); const controls = await page.locator('.controls').boundingBox(); - expect(summary!.y).toBeGreaterThanOrEqual(controls!.y + controls!.height); + expect(summary!.y + summary!.height <= controls!.y || summary!.x + summary!.width <= controls!.x || summary!.x >= controls!.x + controls!.width).toBe(true); expect(summary!.y + summary!.height).toBeLessThanOrEqual(size.height); if (size.width > size.height && size.height <= 520) { const legend = await page.locator('#legend').boundingBox(); - expect(summary!.x + summary!.width).toBeLessThanOrEqual(legend!.x); + expect(summary!.y + summary!.height <= legend!.y || summary!.x + summary!.width <= legend!.x).toBe(true); } } }); @@ -146,7 +146,7 @@ test('pausing Follow stops a camera transition immediately', async ({ page }, in await expect(map).toHaveAttribute('data-camera-moving', 'true'); await page.locator('#follow-pause').click(); await expect(map).toHaveAttribute('data-camera-moving', 'false'); - await expect(page.locator('#follow-card')).toHaveAttribute('data-state', 'paused'); + await expect(page.locator('#follow-card')).toHaveAttribute('data-state', 'held'); }); test('blocked browser storage does not prevent Map, Netgraph, or Labs startup', async ({ page }, info) => { diff --git a/web/e2e/visual-smoke.spec.ts b/web/e2e/visual-smoke.spec.ts index 7f06de3..bbea661 100644 --- a/web/e2e/visual-smoke.spec.ts +++ b/web/e2e/visual-smoke.spec.ts @@ -411,6 +411,7 @@ test('keeps a recent packet trail after stable routes are hidden', async ({ page await expect(page.locator('#app')).toHaveAttribute('data-traffic-kind', 'text'); const afterglowWindow = routeDuration([{ routeId: 'route-a-b', from, to }]) + DESTINATION_BLOOM_MS + 600; await page.waitForTimeout(afterglowWindow); + await testInfo.attach('trail-state.json', { contentType: 'application/json', body: JSON.stringify(await packetCanvas.evaluate(c => ({...((c as HTMLCanvasElement).dataset),display:localStorage.getItem('cartolite:display:v1'),width:(c as HTMLCanvasElement).width,height:(c as HTMLCanvasElement).height}))) }); await expect.poll(() => canvasHasPixels(packetCanvas), { message: '45-second trail should outlive the moving comet and afterglow', timeout: 2_000 }).toBe(true); await page.waitForTimeout(Math.max(0, 15_500 - afterglowWindow)); await expect.poll(() => canvasHasPixels(packetCanvas), { message: 'recent packet trail should remain visible beyond the former 15-second lifetime', timeout: 2_000 }).toBe(true); @@ -469,7 +470,7 @@ test('focuses recent route neighbors and clears selection on the map', async ({ await openMapOptions(page); await routesButton.click(); await expect(map).toHaveAttribute('data-routes-visible', 'true'); - if (mobile) await closeLayers(page); + await closeLayers(page); const box = await canvas.boundingBox(); expect(box).not.toBeNull(); if (!box) return; @@ -480,7 +481,7 @@ test('focuses recent route neighbors and clears selection on the map', async ({ await clickPoint(page, { x: alphaPoint.x + (mobile ? 12 : 0), y: alphaPoint.y }, mobile); await expect(map).toHaveAttribute('data-selected-node-id', 'a'); - const inspector = mobile ? page.locator('#node-inspector-sheet') : page.locator('.node-inspector-popup'); + const inspector = page.locator('#node-inspector-sheet'); await expect(inspector).toBeVisible(); await expect(inspector).toContainText('Alpha'); // The traffic canvas normally ignores pointer hits, which can hide a paint-order bug. @@ -511,7 +512,7 @@ test('focuses recent route neighbors and clears selection on the map', async ({ if (mobile) await openLayers(page); await openMapOptions(page); await page.locator('#route-window').selectOption('24h'); - if (mobile) await closeLayers(page); + await closeLayers(page); await expect(map).toHaveAttribute('data-neighbor-route-count', '2'); await expect(map).toHaveAttribute('data-focused-route-count', '2'); await expect(inspector.locator('.neighbor-row')).toHaveCount(2); @@ -522,6 +523,7 @@ test('focuses recent route neighbors and clears selection on the map', async ({ await expect(page.locator('#legend-items')).toBeHidden(); await expect(page.locator('#legend-toggle')).toBeHidden(); + await closeLayers(page); await inspectRoute(page, alphaPoint, bravoPoint, mobile); await expect(map).toHaveAttribute('data-hovered-route-id', 'a-b'); await expect(tooltip).toHaveAttribute('data-kind', 'route'); @@ -554,7 +556,7 @@ test('focuses recent route neighbors and clears selection on the map', async ({ await routesButton.click(); await expect(map).toHaveAttribute('data-routes-visible', 'true'); await expect(map).toHaveAttribute('data-render-state', 'idle'); - if (mobile) await closeLayers(page); + await closeLayers(page); await inspectRoute(page, alphaPoint, charliePoint, mobile); await expect(map).toHaveAttribute('data-hovered-route-id', 'a-c'); await expect(tooltip).toHaveAttribute('data-kind', 'route'); @@ -566,8 +568,11 @@ test('focuses recent route neighbors and clears selection on the map', async ({ await expect(tooltip).toBeHidden(); await openMapOptions(page); await page.locator('#route-window').selectOption('24h'); - if (mobile) await closeLayers(page); + await closeLayers(page); + // In a short landscape view the right-hand inspector covers this fixture node. + // Dismiss it before testing the real map hit target. + if (page.viewportSize()!.height <= 520) await page.getByRole('button', {name:'Close node details',exact:true}).click(); await clickPoint(page, bravoPoint, mobile); await expect(map).toHaveAttribute('data-selected-node-id', 'b'); await expect(inspector).toContainText('Bravo'); @@ -584,14 +589,17 @@ test('focuses recent route neighbors and clears selection on the map', async ({ await expect(tooltip).toBeHidden(); } + const beforeWheel=Number(await map.getAttribute('data-camera-zoom')); await page.mouse.move(box.x + 30, box.y + 100); await page.mouse.wheel(0, -120); + await expect.poll(()=>map.getAttribute('data-camera-zoom').then(Number)).toBeGreaterThan(beforeWheel); + await expect(map).toHaveAttribute('data-camera-moving','false'); await expect(map).toHaveAttribute('data-selected-node-id', 'b'); await expect(inspector).toBeVisible(); await expect(inspector).toContainText('Bravo'); await clickPoint(page, { - x: box.x + box.width * 0.84, + x: box.x + box.width * (page.viewportSize()!.height<=520 ? 0.22 : 0.84), y: box.y + box.height * (mobile ? 0.30 : 0.82), }, mobile); await expect(map).toHaveAttribute('data-selected-node-id', ''); diff --git a/web/package-lock.json b/web/package-lock.json index 657f74b..20fb511 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "cartolite-server-web", - "version": "0.5.1", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cartolite-server-web", - "version": "0.5.1", + "version": "0.6.0", "dependencies": { "maplibre-gl": "6.4.1" }, diff --git a/web/package.json b/web/package.json index 425e170..16d4f07 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "cartolite-server-web", - "version": "0.5.1", + "version": "0.6.0", "private": true, "type": "module", "scripts": { diff --git a/web/playwright.config.ts b/web/playwright.config.ts index 36df1b7..2d7c46c 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -4,6 +4,8 @@ export default defineConfig({ testDir: './e2e', outputDir: './test-results', retries: process.env.CI ? 1 : 0, + // Stop broken candidates early; a passing candidate still runs every scenario. + maxFailures: process.env.CI ? 3 : 0, // Renderer timing is meaningful only when one Chromium instance owns the // shared Actions CPU; concurrent software-rendered maps distort the gate. workers: process.env.CI ? 1 : undefined, @@ -11,7 +13,7 @@ export default defineConfig({ use: { baseURL: process.env.CARTOLITE_BASE_URL ?? 'http://127.0.0.1:39476', screenshot: 'only-on-failure', - trace: 'retain-on-failure', + trace: 'on-first-retry', launchOptions: { args: ['--enable-unsafe-swiftshader'] } }, projects: [ diff --git a/web/src/audio.test.ts b/web/src/audio.test.ts index 554fbce..6690064 100644 --- a/web/src/audio.test.ts +++ b/web/src/audio.test.ts @@ -197,6 +197,18 @@ describe('route hop sonification', () => { }); describe('sound preference and autoplay state', () => { + it('previews one voice without opting the listener into live sound', async () => { + const original=window.AudioContext; + FakeAudioContext.initialState='running'; FakeAudioContext.oscillators=0; + Object.defineProperty(window,'AudioContext',{configurable:true,value:FakeAudioContext}); + const sonifier=new RouteSonifier(projector as never,document.createElement('div')); + try { + await sonifier.preview(); + expect(FakeAudioContext.oscillators).toBe(1); + expect(sonifier.isEnabled()).toBe(false); + expect(loadSoundPreference(localStorage).enabled).toBe(false); + } finally { sonifier.destroy(); Object.defineProperty(window,'AudioContext',{configurable:true,value:original}); } + }); beforeEach(() => localStorage.clear()); it('defaults to Aurora at 80 percent and stores only enabled, volume, and scene', () => { diff --git a/web/src/audio.ts b/web/src/audio.ts index 2fa3016..8d52383 100644 --- a/web/src/audio.ts +++ b/web/src/audio.ts @@ -322,6 +322,18 @@ export class RouteSonifier { return notes.length; } + /** Explicit preview gesture; does not enable or save live sound. */ + async preview(): Promise { + if (!this.supported() || this.paused) return; + const context = !this.context || this.context.state === 'closed' ? this.createContext() : this.context; + this.unlockMobileOutput(context); + if (context.state !== 'running') await context.resume(); + if (context.state !== 'running' || this.paused || document.hidden) return; + this.setMasterLevel(MASTER_LEVEL * this.volume); + const scene = SCENES[this.scene]; + this.schedule({ frequency: 440, startMS: 0, durationMS: Math.min(420,scene.maxDurationMS), brightness: Math.max(scene.minBrightness,1800*scene.brightnessScale), pan: 0, variation: 0, scene: this.scene, character: 'map' }, .8); + } + destroy(): void { this.enabled = false; this.stopActive(); diff --git a/web/src/cinematic.css b/web/src/cinematic.css new file mode 100644 index 0000000..ac6b8b4 --- /dev/null +++ b/web/src/cinematic.css @@ -0,0 +1,112 @@ +/* Cinematic chrome is shared by Map and Netgraph; the scene keeps the space. */ +html:root[data-cinematic] { --dock-bottom: max(22px, calc(env(safe-area-inset-bottom) + 12px)); --dock-height: 52px; } +html:root[data-cinematic] .glass { background: var(--panel-solid); border-color: var(--panel-border); box-shadow: 0 8px 28px #0003; } +html:root[data-cinematic] .controls { position: fixed; top: auto; right: auto; bottom: var(--dock-bottom); left: 50%; transform: translateX(-50%); display: flex; flex-wrap: nowrap; gap: 2px; max-width: calc(100vw - 24px); border-radius: 18px; padding: 4px; z-index: 20; } +html:root[data-cinematic] .toolbar { position: static; } +html:root[data-cinematic] .topbar { position: fixed; top: max(12px, env(safe-area-inset-top)); left: 12px; right: auto; width: auto; border-radius: 14px; z-index: 12; } +html:root[data-cinematic] .control-button { min-height: 44px; border-radius: 10px; white-space: nowrap; } +.dock-views { display: flex; align-items: center; gap: 2px; border-right: 1px solid var(--panel-border); padding-right: 4px; } +.dock-views a { text-decoration: none; display: inline-flex; align-items: center; justify-content: center; gap: 5px; padding: 0 12px; font-size: 12px; color: var(--ink); } +.dock-views a[aria-current] { color: var(--ink-strong); background: var(--selected); box-shadow: inset 0 -2px var(--focus-ring); } +html:root[data-cinematic] :is(.layers-panel,.find-panel,.sound-panel,.display-panel) { position: fixed; top: auto; bottom: calc(var(--dock-bottom) + 62px); right: 12px; left: auto; transform: none; max-height: calc(100dvh - 150px); width: min(370px, calc(100vw - 24px)); border-radius: 16px; } +/* Fixed descendants of the transformed dock use its own coordinate space. */ +html:root[data-cinematic] .controls :is(.layers-panel,.find-panel,.sound-panel,.display-panel) { position: absolute; bottom: calc(100% + 10px); right: 0; top: auto; } +html:root[data-cinematic] :is(.node-inspector-sheet,.follow-card) { top: max(72px, calc(env(safe-area-inset-top) + 62px)); bottom: auto; right: 12px; left: auto; width: min(370px, calc(100vw - 24px)); max-height: calc(100dvh - 190px); border-radius: 16px; overflow: auto; } +html:root[data-cinematic] .follow-card { left: 12px; right: auto; } +html:root[data-cinematic] :is(.legend,.route-legend,.graph-summary) { bottom: calc(var(--dock-bottom) + 64px); } +html:root[data-cinematic] .graph-summary { top: 12px; right: 12px; bottom: auto; } +html:root[data-cinematic] .zoom-controls { bottom: 22px; top: auto; } +html:root[data-cinematic] .map-notice { bottom: calc(var(--dock-bottom) + 64px); } +.layer-combinations { padding: 8px 0 14px; border-bottom: 1px solid var(--panel-border); font-size: 12px; } +.layer-combinations > div { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; } +.layer-combinations button, .follow-actions button, .follow-scope select { min-height: 44px; padding: 6px 10px; background: var(--field-bg); color: var(--ink-strong); border: 1px solid var(--panel-border); border-radius: 9px; cursor: pointer; } +.layer-combinations p { font-size: 11px; color: var(--muted); line-height: 1.5; margin-bottom: 0; } +.follow-actions { display: flex; gap: 6px; } +.follow-scope { display: flex; align-items: center; justify-content: space-between; gap: 8px; font-size: 12px; margin-block: 8px; } +:root[data-motion="reduced"] *, :root[data-effects="minimal"] .traffic-meter { animation: none !important; transition-duration: 0s !important; } +:root[data-text-size="large"] :is(.display-settings label,.control-button,.node-inspector,.node-search-result,.follow-card,.layer-hint,.map-option,.map-slider,.scene-control,.volume-control) { font-size: 15px; } +:root[data-text-size="large"] :is(.display-note,.sound-description,.neighbor-meta,.follow-detail,.node-role) { font-size: 13px; } +html:root[data-cinematic] .control-button:disabled { opacity: .65; cursor: not-allowed; } +@media (max-width: 900px), (pointer: coarse) { + html:root[data-cinematic] { --dock-bottom: max(8px, env(safe-area-inset-bottom)); --dock-height: 96px; } + html:root[data-cinematic] .controls { width: calc(100vw - 16px); max-width: 650px; display: flex; flex-wrap: wrap; justify-content: center; border-radius: 16px; } + html:root[data-cinematic] .controls > *, html:root[data-cinematic] .dock-views { flex: 0 1 auto; } + html:root[data-cinematic] .controls .control-button { padding-inline: 9px; font-size: 11px; } + html:root[data-cinematic] .dock-views { border: 0; } + html:root[data-cinematic] .controls :is(.layers-panel,.find-panel,.sound-panel,.display-panel) { bottom: calc(100% + 8px); right: 0; width: min(100%, 650px); max-height: calc(100dvh - 185px); } + html:root[data-cinematic] :is(.node-inspector-sheet,.follow-card) { position: fixed; top: auto; bottom: calc(var(--dock-bottom) + 112px); left: 8px; right: 8px; width: auto; max-height: min(50dvh, 420px); } + html:root[data-cinematic] :is(.legend,.route-legend) { bottom: calc(var(--dock-bottom) + 116px); } + html:root[data-cinematic] .graph-summary { top: 65px; left: 8px; right: auto; font-size: 10px; } + html:root[data-cinematic] .sound-state, html:root[data-cinematic] #sound-state { display: none; } + html:root[data-cinematic]:has(.layers-disclosure[open],.find-panel:not([hidden]),.sound-panel:not([hidden]),.display-panel:not([hidden])) :is(.node-inspector-sheet,.follow-card) { visibility: hidden; } +} +@media (max-height: 520px) and (orientation: landscape) { + html:root[data-cinematic] .controls { flex-wrap: wrap; max-width: calc(100vw - 72px); left: calc(50% + 24px); } + html:root[data-cinematic] .controls :is(.layers-panel,.find-panel,.sound-panel,.display-panel) { max-height: calc(100dvh - 95px); } + html:root[data-cinematic] :is(.node-inspector-sheet,.follow-card) { bottom: 72px; max-height: calc(100dvh - 90px); width: min(350px, 48vw); left: auto; right: 8px; } +} + +.graph-focus { position: fixed; top: 68px; left: 12px; display: flex; align-items: center; gap: 8px; padding: 5px 10px; border: 1px solid var(--panel-border); border-radius: 10px; color: var(--ink); background: var(--panel-solid); font-size: 12px; z-index: 8; } +.graph-focus select { min-height: 36px; background: var(--field-bg); color: var(--ink-strong); border: 0; } +.graph-focus small:empty { display: none; } +@media(max-width:900px) { .graph-focus { top: 106px; left: 8px; } } + +html:root[data-cinematic] .controls > :is(.layers-disclosure,.find-control,.sound-control,.popover-control), html:root[data-cinematic] .display-disclosure { position:static; } +html:root[data-cinematic] .controls #layers-panel { position:absolute; top:auto; right:0; bottom:calc(100% + 10px); width:min(370px,calc(100vw - 24px)); max-height:calc(100dvh - 150px); } +html:root[data-cinematic] .maplibregl-ctrl-bottom-right { opacity:1; } +.gpu-packet-canvas { z-index:2; } +.sound-preview-actions { display:flex; gap:8px; } +html:root[data-cinematic] .director-enabled[data-follow-inspect="true"] .node-inspector-sheet { visibility:visible; pointer-events:auto; } +@media(max-width:900px),(pointer:coarse) { html:root[data-cinematic] .controls #layers-panel { width:100%; max-height:calc(100dvh - 185px); } } +@media(max-height:520px) and (orientation:landscape) { html:root[data-cinematic] .controls #layers-panel { max-height:calc(100dvh - 95px); } } + +:root[data-native-app] .graph-summary { right:68px; } + +html:root[data-cinematic] .graph-summary { left:auto; width:auto; } +html:root[data-cinematic] .follow-card header { gap:6px; } +html:root[data-cinematic] .follow-card header button { min-height:44px; min-width:44px; border:0; background:transparent; color:var(--ink); cursor:pointer; } +html:root[data-cinematic] .follow-card .follow-scope, html:root[data-cinematic] .follow-card .follow-actions { margin:8px 12px; } +@media(max-width:900px),(pointer:coarse) { html:root[data-cinematic] .graph-summary { left:8px; right:auto; } } + +html:root[data-cinematic] .controls #layers-panel { height:min(750px,calc(100dvh - 150px)); } + +@media(max-width:900px),(pointer:coarse) { html:root[data-cinematic] :is(#app,#netgraph-app)[data-follow-inspect="true"]:has(#node-inspector-sheet:not([hidden])) .follow-card { visibility:hidden; } } + +html:root[data-cinematic][data-text-size="large"] .control-button { font-size:15px; } +:root[data-text-size="large"] #layers-panel .control-button { font-size:15px; } +html:root[data-cinematic] .controls #layers-panel { height:min(750px,calc(100dvh - var(--dock-height) - var(--dock-bottom) - 80px)); max-height:calc(100dvh - var(--dock-height) - var(--dock-bottom) - 80px); } +html:root[data-cinematic] .controls :is(.find-panel,.sound-panel,.display-panel) { max-height:calc(100dvh - var(--dock-height) - var(--dock-bottom) - 80px); } +@media(max-width:900px),(pointer:coarse) { html:root[data-cinematic] :is(.node-inspector-sheet,.follow-card,.legend,.route-legend) { bottom:calc(var(--dock-height) + var(--dock-bottom) + 12px); } } + +/* Opaque panels need no backdrop readback over animated geography. */ +html:root[data-cinematic] .glass { backdrop-filter:none; -webkit-backdrop-filter:none; } +html:root[data-cinematic] .controls .zoom-controls { position:static; display:flex; flex:none; width:88px; padding:0; border:0; background:transparent; box-shadow:none; border-radius:10px; } +html:root[data-cinematic] .controls .zoom-controls .control-button { width:44px; min-width:44px; height:44px; padding:0; font-size:20px; } + +html:root[data-cinematic] .control-button { min-width:44px; } +@media(max-width:900px),(pointer:coarse) { + html:root[data-cinematic] #app #legend-toggle { position:fixed; top:max(12px,env(safe-area-inset-top)); right:12px; left:auto; bottom:auto; display:grid; width:44px; height:44px; border:1px solid var(--panel-border); border-radius:12px; background:var(--panel-solid); } + html:root[data-cinematic] #app #legend[data-focused="true"] #legend-toggle { display:none; } + html:root[data-cinematic] #app #legend[data-collapsed="true"][data-focused="false"]:not(:has(.region-legend:not([hidden]))) { background:transparent; border-color:transparent; box-shadow:none; } +} + +html:root[data-cinematic] .follow-card { position:fixed; z-index:8; color:var(--ink); } +html:root[data-cinematic] .follow-card[hidden] { display:none; } +html:root[data-cinematic] .follow-card header { display:flex; align-items:center; padding-left:12px; } +html:root[data-cinematic] .follow-card header strong { flex:1; font-size:12px; } +html:root[data-cinematic] .follow-copy { display:grid; gap:6px; padding:8px 12px; overflow-wrap:anywhere; } +html:root[data-cinematic] .follow-card footer { display:flex; justify-content:space-between; padding:8px 12px; } +html:root[data-cinematic] .follow-card :is(footer,.follow-copy span) { color:var(--muted); font-size:12px; } +html:root[data-cinematic] .follow-card progress { display:block; width:100%; height:3px; accent-color:var(--focus-ring); } +html:root[data-cinematic] .director-enabled:not([data-follow-inspect="true"]) .node-inspector-sheet { visibility:hidden; pointer-events:none; } + +@media(max-height:520px) and (orientation:landscape) { + html:root[data-cinematic] .node-inspector { display:flex; flex-direction:column; min-width:0; max-height:none; } + html:root[data-cinematic] .node-inspector > header { position:sticky; top:0; z-index:1; flex:none; background:var(--panel-solid); } + html:root[data-cinematic] .node-inspector .neighbor-list { flex:none; overflow:visible; } +} + +@media(max-width:900px),(pointer:coarse) { + html:root[data-cinematic] .maplibregl-ctrl-bottom-right { top:max(68px,calc(env(safe-area-inset-top) + 64px)); bottom:auto; } +} +html:root[data-cinematic] .maplibregl-ctrl-attrib-button::before { content:'©'; } diff --git a/web/src/cinematicChrome.test.ts b/web/src/cinematicChrome.test.ts new file mode 100644 index 0000000..3d085db --- /dev/null +++ b/web/src/cinematicChrome.test.ts @@ -0,0 +1,21 @@ +// @vitest-environment jsdom +import {afterEach,expect,it} from 'vitest'; +import {followViewport} from './cinematicChrome'; +afterEach(()=>{document.body.replaceChildren();}); +function element(id:string,x:number,y:number,width:number,height:number){ + const node=document.createElement('div');node.id=id;node.className=id; + Object.defineProperties(node,{clientWidth:{value:width},clientHeight:{value:height}}); + node.getBoundingClientRect=()=>({x,y,left:x,top:y,right:x+width,bottom:y+height,width,height,toJSON:()=>({})}); + node.getClientRects=()=>[node.getBoundingClientRect()] as unknown as DOMRectList; + document.body.append(node);return node; +} +it('keeps portrait activity above the Follow card and dock',()=>{ + const stage=element('stage',0,0,390,844);element('controls',8,730,374,106);element('follow-card',8,470,374,248); + const rect=followViewport(stage); + expect(rect.bottom).toBeLessThan(470);expect(rect.right-rect.left).toBeGreaterThan(250);expect(rect.bottom-rect.top).toBeGreaterThan(250); +}); +it('uses the space beside a landscape card without covering the dock',()=>{ + const stage=element('stage',0,0,844,390);element('controls',40,330,764,52);element('follow-card',486,100,350,215); + const rect=followViewport(stage); + expect(rect.right).toBeLessThan(486);expect(rect.bottom).toBeLessThan(330);expect(rect.right-rect.left).toBeGreaterThan(350); +}); diff --git a/web/src/cinematicChrome.ts b/web/src/cinematicChrome.ts new file mode 100644 index 0000000..17ba8c3 --- /dev/null +++ b/web/src/cinematicChrome.ts @@ -0,0 +1,139 @@ +/** A single dock, with existing control handlers and accessible names preserved. */ +export function mountCinematicDock(): void { + const controls = document.querySelector('.controls'); + const topbar = document.querySelector('#topbar'); + if (!controls || !topbar) return; + document.documentElement.dataset.cinematic = 'true'; + const navigation = document.createElement('nav'); + navigation.className = 'dock-views'; navigation.setAttribute('aria-label', 'Views'); + const graph = location.pathname.startsWith('/netgraph'); + const mapLink = document.createElement('a'); + mapLink.href = '/'; mapLink.className = 'control-button'; mapLink.textContent = 'Map'; + mapLink.setAttribute('aria-label', 'Open CartoLite map'); + if (!graph) mapLink.setAttribute('aria-current', 'page'); + navigation.append(mapLink); + const graphLink = topbar.querySelector('#netgraph-link') ?? document.createElement('a'); + graphLink.href = '/netgraph/'; graphLink.classList.add('control-button', 'netgraph-link'); graphLink.textContent = 'Netgraph'; + graphLink.setAttribute('aria-label', 'Open CartoLite Netgraph'); + if (graph) graphLink.setAttribute('aria-current', 'page'); + navigation.append(graphLink); + const labs = topbar.querySelector('#labs-link'); + if (labs) { labs.classList.add('control-button'); navigation.append(labs); } + controls.prepend(navigation); + const zoomControls = document.querySelector('.zoom-controls'); + if (zoomControls) controls.append(zoomControls); + const sizeDock=()=>document.documentElement.style.setProperty('--dock-height',`${controls.offsetHeight}px`); + const dockResize=new ResizeObserver(sizeDock);dockResize.observe(controls);sizeDock(); + window.addEventListener('pagehide',()=>dockResize.disconnect()); + window.addEventListener('pageshow',()=>dockResize.observe(controls)); + if (!graph) { + const display = document.createElement('button'); + display.type = 'button'; display.className = 'control-button'; display.textContent = '◐ Display'; + display.setAttribute('aria-label', 'Display settings'); + display.addEventListener('click', () => { + const disclosure = document.querySelector('#map-appearance'); + const layers = document.querySelector('#layers-disclosure'); + if (!layers?.hasAttribute('open')) document.querySelector('#layers-summary')?.click(); + if (disclosure) { disclosure.open = true; disclosure.scrollIntoView({ block: 'nearest' }); disclosure.querySelector('summary')?.focus(); } + }); + controls.insertBefore(display, controls.querySelector('.sound-control')); + } + if (/CartoLiteAndroid\//.test(navigator.userAgent)) document.documentElement.dataset.nativeApp = 'true'; + const menuIDs = ['layers-panel', 'find-panel', 'sound-panel', 'display-panel', 'follow-card', 'node-inspector-sheet']; + const openMenu = () => menuIDs.find(id => { const panel=document.getElementById(id); return panel && !panel.hidden; }); + let closingHistory = false; + let closingURL = location.href; + let pendingNavigation: string | undefined; + const syncHistory = () => { + if (closingHistory) { closingURL = location.href; return; } + const open = openMenu(); + const current = history.state?.cartolitePanel; + if (open && current !== open) { + if (open === history.state?.cartoliteParent) { closingHistory = true; closingURL = location.href; history.back(); return; } + const replacing = current && current !== 'node-inspector-sheet' && current !== 'follow-card'; + const state = { ...history.state, cartolitePanel: open, cartoliteParent: replacing ? history.state?.cartoliteParent : current }; + if(replacing) history.replaceState(state,''); else history.pushState(state,''); + } else if(!open && current) { closingHistory = true; closingURL = location.href; history.back(); } + }; + const observer = new MutationObserver(syncHistory); + observer.observe(document.body,{subtree:true,childList:true,attributes:true,attributeFilter:['hidden']}); + controls.addEventListener('click', event => { + const link = event.target instanceof Element ? event.target.closest('a') : null; + if (event instanceof MouseEvent && (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)) return; + if (closingHistory && link) { event.preventDefault(); pendingNavigation = link.href; } + }); + window.addEventListener('popstate', () => { + if (closingHistory) { + closingHistory = false; + history.replaceState(history.state, '', closingURL); + if (pendingNavigation) { location.assign(pendingNavigation); pendingNavigation = undefined; } + else syncHistory(); + return; + } + const target = history.state?.cartolitePanel; + for(const button of controls.querySelectorAll('button[aria-expanded="true"][aria-controls]')) { + if(button.getAttribute('aria-controls') !== target) button.click(); + } + if(target !== 'follow-card') document.querySelector('#follow-card:not([hidden]) #follow-close')?.click(); + if(!target) document.querySelector('#node-inspector-sheet:not([hidden]) .node-inspector-close')?.click(); + }); + window.addEventListener('pagehide',()=>observer.disconnect()); + window.addEventListener('pageshow',()=>observer.observe(document.body,{subtree:true,childList:true,attributes:true,attributeFilter:['hidden']})); + // One menu at a time, including the independently mounted display control. + let closing = false; + controls.addEventListener('click', event => { + if (closing || !(event.target instanceof Element)) return; + const owner = event.target.closest('button[aria-controls]'); + if (!owner || owner.getAttribute('aria-expanded') !== 'true') return; + closing = true; + for (const button of controls.querySelectorAll('button[aria-expanded="true"][aria-controls]')) { + if (button !== owner) button.click(); + } + closing = false; + }); +} + +export function mountLayerCombinations(): void { + const header = document.querySelector('#layers-panel .map-options-header'); + if (!header) return; + const bar = document.createElement('section'); bar.className = 'layer-combinations'; bar.setAttribute('aria-label', 'Layer combinations'); + bar.innerHTML = 'Quick views

Changes layers only. Your camera and sound stay as they are.

'; + (document.querySelector('#layers-panel section.map-layer-group') ?? header).after(bar); + const ids = ['routes-button', 'heatmap-button', 'node-labels-button', 'hillshade-button', 'buildings-button', 'live-packets-button']; + let previous: boolean[] | undefined; + const apply = (values: boolean[]) => ids.forEach((id, i) => { + const button = document.getElementById(id) as HTMLButtonElement | null; + if (button && (button.getAttribute('aria-pressed') === 'true') !== values[i]) button.click(); + }); + bar.addEventListener('click', event => { + const button = event.target instanceof Element ? event.target.closest('button') : null; + if (!button) return; + if (button.hasAttribute('data-undo') && previous) { apply(previous); previous = undefined; button.hidden = true; return; } + const choice = button.dataset.combination; + if (!choice) return; + previous = ids.map(id => document.getElementById(id)?.getAttribute('aria-pressed') === 'true'); + apply([choice !== 'activity', choice === 'activity', true, choice === 'landscape', choice === 'landscape', true]); + bar.querySelector('[data-undo]')!.hidden = false; + }); +} + +/** The director frames activity in the space actually left by its card and dock. */ +export function followViewport(container: HTMLElement) { + const box=container.getBoundingClientRect(), width=container.clientWidth, height=container.clientHeight; + let left=24, right=width-24, top=72, bottom=height-24; + for(const selector of ['#topbar','.graph-focus']) { + const chrome=document.querySelector(selector); + if(chrome&&chrome.getClientRects().length)top=Math.max(top,chrome.getBoundingClientRect().bottom-box.top+16); + } + const dock=document.querySelector('.controls'); + if(dock)bottom=Math.min(bottom,dock.getBoundingClientRect().top-box.top-16); + const card=document.getElementById('follow-card'); + if(card&&!card.hidden){ + const bounds=card.getBoundingClientRect(); + if(bounds.width>=width*.6)bottom=Math.min(bottom,bounds.top-box.top-20); + else if(bounds.left+bounds.width/2Scene` : ''} + + + + +
Preview · silent
Advanced route styling diff --git a/web/src/displayPreferences.test.ts b/web/src/displayPreferences.test.ts index 020ecc2..f69fd7c 100644 --- a/web/src/displayPreferences.test.ts +++ b/web/src/displayPreferences.test.ts @@ -2,6 +2,12 @@ import { describe, expect, it } from 'vitest'; import { DEFAULT_DISPLAY, DISPLAY_STORAGE_KEY, ROUTE_PRESETS, displayColor, loadDisplayPreferences, normalizeDisplay, packetPalette } from './displayPreferences'; describe('shared display preferences', () => { + it('preserves existing route styling while enabling the cinematic defaults', () => { + const previous = { basemap: 'light', preset: 'dotted', pattern: 'dotted', width: 3, glow: 0, opacity: .7, packetSize: 1.5, trailLength: .7, residueSeconds: 8 }; + expect(normalizeDisplay(previous)).toMatchObject({ ...previous, effects: 'spectacle', detail: 'auto', quality: 'auto', motion: 'system', textSize: 'standard' }); + expect(normalizeDisplay({ effects: 'calm', detail: 'complete', quality: 'economy', motion: 'reduced', textSize: 'large' })).toMatchObject({ effects: 'calm', detail: 'complete', quality: 'economy', motion: 'reduced', textSize: 'large' }); + expect(normalizeDisplay({ effects: 'invalid', detail: false, quality: 2, motion: 'invalid', textSize: 200 })).toMatchObject({ effects: 'spectacle', detail: 'auto', quality: 'auto', motion: 'system', textSize: 'standard' }); + }); it('migrates the map appearance without importing layers or audio settings', () => { const storage = { getItem: (key: string) => key === DISPLAY_STORAGE_KEY ? null : JSON.stringify({ basemap: 'streets', theme: 'dark', routeOpacity: 0.55, routes: true, scene: 'choir' }) }; expect(loadDisplayPreferences(storage)).toEqual({ ...DEFAULT_DISPLAY, basemap: 'streets', theme: 'dark', opacity: 0.55 }); diff --git a/web/src/displayPreferences.ts b/web/src/displayPreferences.ts index fd653eb..3dd1f62 100644 --- a/web/src/displayPreferences.ts +++ b/web/src/displayPreferences.ts @@ -4,6 +4,11 @@ import { PACKET_KIND_COLORS as DARK_KINDS } from './trafficVisuals'; export type LinePattern = 'solid' | 'dashed' | 'dotted'; export type RoutePreset = 'crisp' | 'neon' | 'dashed' | 'dotted' | 'ribbon' | 'comet' | 'custom'; export interface DisplayPreferences { + effects: 'spectacle' | 'calm' | 'minimal'; + detail: 'auto' | 'complete'; + quality: 'auto' | 'high' | 'economy'; + motion: 'system' | 'full' | 'reduced'; + textSize: 'standard' | 'large'; basemap: BasemapStyle; theme: InterfaceTheme; preset: RoutePreset; @@ -26,7 +31,7 @@ export const ROUTE_PRESETS = { ribbon: { pattern: 'solid', width: 3.5, opacity: 0.75, glow: 0.1, packetSize: 1.35, trailLength: 1, residueSeconds: 12 }, comet: { pattern: 'solid', width: 1.4, opacity: 0.8, glow: 0.5, packetSize: 1.4, trailLength: 2, residueSeconds: 30 }, } as const; -export const DEFAULT_DISPLAY: DisplayPreferences = { basemap: 'dark', theme: 'map', preset: 'crisp', ...ROUTE_PRESETS.crisp }; +export const DEFAULT_DISPLAY: DisplayPreferences = { effects: 'spectacle', detail: 'auto', quality: 'auto', motion: 'system', textSize: 'standard', basemap: 'dark', theme: 'map', preset: 'crisp', ...ROUTE_PRESETS.crisp }; const LIGHT_KINDS = { Advert: '#006957', Trace: '#855000', Text: '#a21b58', ACK: '#075b98', Control: '#6740a0', Other: '#445760' }; const LIGHT_COLORS: Record = { @@ -87,6 +92,11 @@ export function normalizeDisplay(value: unknown): DisplayPreferences { return typeof raw === 'number' && Number.isFinite(raw) ? Math.max(min, Math.min(max, raw)) : Number(DEFAULT_DISPLAY[key]); }; return { + effects: v.effects === 'calm' || v.effects === 'minimal' ? v.effects : 'spectacle', + detail: v.detail === 'complete' ? 'complete' : 'auto', + quality: v.quality === 'high' || v.quality === 'economy' ? v.quality : 'auto', + motion: v.motion === 'full' || v.motion === 'reduced' ? v.motion : 'system', + textSize: v.textSize === 'large' ? 'large' : 'standard', basemap: v.basemap === 'light' || v.basemap === 'streets' ? v.basemap : 'dark', theme: v.theme === 'light' || v.theme === 'dark' ? v.theme : 'map', preset: v.preset === 'custom' || (typeof v.preset === 'string' && Object.hasOwn(ROUTE_PRESETS, v.preset)) ? v.preset as RoutePreset : 'crisp', @@ -108,6 +118,9 @@ export function initializeDisplay(): void { initialized = true; try { current = loadDisplayPreferences(localStorage); } catch { /* Browser storage is optional. */ } applyDisplayChrome(); + window.matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change', () => { + applyDisplayChrome(); window.dispatchEvent(new Event(DISPLAY_EVENT)); + }); window.addEventListener('storage', (event) => { if (event.key !== DISPLAY_STORAGE_KEY && event.key !== null) return; try { current = loadDisplayPreferences(localStorage); } catch { current = { ...DEFAULT_DISPLAY }; } @@ -126,6 +139,10 @@ export function applyDisplayChrome(): void { root.dataset.theme = current.theme === 'map' ? (lightScene() ? 'light' : 'dark') : current.theme; root.dataset.basemap = current.basemap; root.dataset.routePreset = current.preset; + root.dataset.effects = current.effects; + root.dataset.detail = current.detail; + root.dataset.motion = prefersReducedMotion() ? 'reduced' : 'full'; + root.dataset.textSize = current.textSize; for (const [kind, color] of Object.entries(packetPalette())) root.style.setProperty(`--kind-${kind.toLowerCase()}`, color); for (const [kind, color] of Object.entries(packetPalette())) root.style.setProperty(`--ui-kind-${kind.toLowerCase()}`, color); root.style.setProperty('--scene-background', current.basemap === 'dark' ? '#071319' : current.basemap === 'streets' ? '#f0eadb' : '#eef1ee'); @@ -134,3 +151,13 @@ export function applyDisplayChrome(): void { if (color) el.style.setProperty('--route-color', color); } } + +/** Motion preference changes presentation only, never the packet clock. */ +export function prefersReducedMotion(): boolean { + return current.motion === 'reduced' || (current.motion === 'system' && typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches); +} +export function effectStrength(): number { return current.effects === 'minimal' ? 0 : current.effects === 'calm' ? 0.35 : 1; } +export function scenePalette() { + return lightScene() ? { core: '#18383f', outline: '#f8faf4', muted: '#49656a', grid: '#c9d5d0', selection: '#005c52' } + : { core: '#efffff', outline: '#071319', muted: '#a8bcc7', grid: '#183039', selection: '#72ffe1' }; +} diff --git a/web/src/gpuEffects.ts b/web/src/gpuEffects.ts new file mode 100644 index 0000000..f6aa72c --- /dev/null +++ b/web/src/gpuEffects.ts @@ -0,0 +1,194 @@ +import type { Map as LibreMap, CustomLayerInterface } from 'maplibre-gl'; +import { displayColor, displayPreferences, effectStrength, scenePalette } from './displayPreferences'; +import type { SurfacePoint } from './terrainProjection'; +import type { PacketSignature } from './trafficVisuals'; + +const STRIDE = 9; +const CAPACITY = 216_000; +const VERTEX = `#version 300 es +precision highp float; +layout(location=0) in vec2 position; +layout(location=1) in vec2 uv; +layout(location=2) in vec4 color; +layout(location=3) in float shape; +uniform vec2 resolution; +out vec2 vUV; out vec4 vColor; flat out int vShape; +void main() { gl_Position=vec4(position/resolution*vec2(2.,-2.)+vec2(-1.,1.),0.,1.); vUV=uv; vColor=color; vShape=int(shape); }`; +const FRAGMENT = `#version 300 es +precision highp float; +in vec2 vUV; in vec4 vColor; flat in int vShape; +out vec4 result; +void main() { + float d=length(vUV); + if(vShape==3) d=abs(vUV.y); + if(vShape==4) d=abs(vUV.x)+abs(vUV.y); + if(vShape==5) d=max(abs(vUV.x)*.55,abs(vUV.y)); + float aa=max(fwidth(d),.015); + float alpha=1.-smoothstep(1.-aa,1.,d); + if(vShape==1) alpha=exp(-4.*d*d)*(1.-smoothstep(.75,1.,d)); + if(vShape==2) alpha=(1.-smoothstep(.12,.12+aa,abs(d-.78))); + result=vec4(vColor.rgb, vColor.a*alpha); +}`; + +/** Screen geometry comes from the same projector as hit testing and audio. */ +export class GPUEffects { + private gl?: WebGL2RenderingContext; + private program?: WebGLProgram; + private buffer?: WebGLBuffer; + private vao?: WebGLVertexArrayObject; + private resolution?: WebGLUniformLocation | null; + private readonly vertices = new Float32Array(CAPACITY); + private used = 0; + private enabled = true; + get hasInk(): boolean { return this.used > 0; } + setEnabled(enabled: boolean): void { this.enabled = enabled; } + private width = 1; + private height = 1; + get ready(): boolean { return this.enabled && !!this.program && !!this.gl && !this.gl.isContextLost(); } + + initialize(gl: WebGL2RenderingContext): void { + this.dispose(); this.gl = gl; + const shaders: WebGLShader[] = []; + const oldVAO = gl.getParameter(gl.VERTEX_ARRAY_BINDING) as WebGLVertexArrayObject | null; + const oldBuffer = gl.getParameter(gl.ARRAY_BUFFER_BINDING) as WebGLBuffer | null; + try { + const program = gl.createProgram(); if (!program) throw new Error('GPU program unavailable'); + this.program = program; + for (const [kind, source] of [[gl.VERTEX_SHADER, VERTEX], [gl.FRAGMENT_SHADER, FRAGMENT]] as const) { + const shader = gl.createShader(kind); if (!shader) throw new Error('GPU shader unavailable'); + shaders.push(shader); gl.shaderSource(shader, source); gl.compileShader(shader); + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) throw new Error('GPU effect shader failed'); + gl.attachShader(program, shader); + } + gl.linkProgram(program); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw new Error('GPU effects link failed'); + this.buffer = gl.createBuffer() ?? undefined; this.vao = gl.createVertexArray() ?? undefined; + if (!this.buffer || !this.vao) throw new Error('GPU effect buffers unavailable'); + gl.bindVertexArray(this.vao); gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer); + gl.bufferData(gl.ARRAY_BUFFER, this.vertices.byteLength, gl.DYNAMIC_DRAW); + for (const [index, size, offset] of [[0,2,0],[1,2,2],[2,4,4],[3,1,8]]) { + gl.enableVertexAttribArray(index!); gl.vertexAttribPointer(index!, size!, gl.FLOAT, false, STRIDE*4, offset!*4); + } + this.resolution = gl.getUniformLocation(program, 'resolution'); + } catch { this.dispose(); } + finally { shaders.forEach(shader => gl.deleteShader(shader)); gl.bindVertexArray(oldVAO); gl.bindBuffer(gl.ARRAY_BUFFER, oldBuffer); } + } + + begin(width: number, height: number): void { this.used = 0; this.width = Math.max(1,width); this.height = Math.max(1,height); } + clear(): void { this.used = 0; } + + private quad(points: readonly SurfacePoint[], color: string, opacity: number, shape: number, tail = 1): void { + const hex = Number.parseInt(displayColor(color).slice(1),16); + const rgb = [(hex>>16&255)/255,(hex>>8&255)/255,(hex&255)/255]; + const coords = [[-1,-1],[1,-1],[1,1],[-1,1]]; + for (const i of [0,1,2,0,2,3]) { + const point = points[i]!; const uv = coords[i]!; + this.vertices.set([point.x,point.y,uv[0]!,uv[1]!,...rgb,Math.max(0,Math.min(1,opacity*(i===0||i===3?tail:1))),shape],this.used); + this.used += STRIDE; + } + } + + disc(point: SurfacePoint, radius: number, color: string, opacity: number, shape = 0, grounded = false): boolean { + if (!this.ready || this.used+54>CAPACITY || !Number.isFinite(point.x+point.y+radius)) return false; + const g = grounded && point.ground ? point.ground : [1,0,0,1]; + this.quad([[-1,-1],[1,-1],[1,1],[-1,1]].map(([x,y]) => ({x:point.x+radius*(x!*g[0]!+y!*g[2]!),y:point.y+radius*(x!*g[1]!+y!*g[3]!)})),color,opacity,shape); + return true; + } + + packet(points: readonly SurfacePoint[], head: SurfacePoint, color: string, signature: PacketSignature, emphasis = 1): boolean { + if (!this.ready || this.used+(points.length+7)*54>CAPACITY) return false; // Canvas draws overflow; no live hop is dropped. + const settings = displayPreferences(); const strength = effectStrength(); + const radius = 3.1 * settings.packetSize * emphasis * Math.max(.65,Math.min(1.35,head.scale ?? 1)); + const glow = strength * (settings.quality === 'economy' ? .12 : .6); + for (let i=1;i1) this.disc(points[Math.max(0,points.length-2)]!,radius*.6,color,.5); + return true; + } + + draw(): void { + const gl=this.gl; if(!gl || !this.ready || !this.used) return; + const program=gl.getParameter(gl.CURRENT_PROGRAM) as WebGLProgram|null; + const vao=gl.getParameter(gl.VERTEX_ARRAY_BINDING) as WebGLVertexArrayObject|null; + const buffer=gl.getParameter(gl.ARRAY_BUFFER_BINDING) as WebGLBuffer|null; + const depth=gl.isEnabled(gl.DEPTH_TEST), cull=gl.isEnabled(gl.CULL_FACE), blend=gl.isEnabled(gl.BLEND); + const depthMask=gl.getParameter(gl.DEPTH_WRITEMASK) as boolean; + const blendState=[gl.BLEND_SRC_RGB,gl.BLEND_DST_RGB,gl.BLEND_SRC_ALPHA,gl.BLEND_DST_ALPHA].map(p=>gl.getParameter(p) as number); + try { + gl.useProgram(this.program!); gl.bindVertexArray(this.vao!); gl.bindBuffer(gl.ARRAY_BUFFER,this.buffer!); + gl.bufferSubData(gl.ARRAY_BUFFER,0,this.vertices.subarray(0,this.used)); + gl.uniform2f(this.resolution!,this.width,this.height); + gl.disable(gl.DEPTH_TEST); gl.disable(gl.CULL_FACE); gl.depthMask(false); gl.enable(gl.BLEND); + gl.blendFuncSeparate(gl.SRC_ALPHA,gl.ONE_MINUS_SRC_ALPHA,gl.ONE,gl.ONE_MINUS_SRC_ALPHA); + gl.drawArrays(gl.TRIANGLES,0,this.used/STRIDE); + } finally { + gl.depthMask(depthMask); (depth?gl.enable:gl.disable).call(gl,gl.DEPTH_TEST); (cull?gl.enable:gl.disable).call(gl,gl.CULL_FACE); (blend?gl.enable:gl.disable).call(gl,gl.BLEND); + gl.blendFuncSeparate(blendState[0]!,blendState[1]!,blendState[2]!,blendState[3]!); + gl.bindVertexArray(vao); gl.bindBuffer(gl.ARRAY_BUFFER,buffer); gl.useProgram(program); + } + } + dispose(): void { + if(this.gl && !this.gl.isContextLost()) { if(this.program)this.gl.deleteProgram(this.program); if(this.buffer)this.gl.deleteBuffer(this.buffer); if(this.vao)this.gl.deleteVertexArray(this.vao); } + this.program=undefined; this.buffer=undefined; this.vao=undefined; this.used=0; + } +} + +export function mapEffects(map: LibreMap) { + const batch=new GPUEffects(); let activeGL: WebGL2RenderingContext|undefined; + let lastInk=false, requestedAt=0, slowFrames=0, lastFrameAt=0, slowPresentationFrames=0, budgetFallback=false; + const measure=()=>{ + if(!requestedAt)return; + const elapsed=performance.now()-requestedAt;requestedAt=0; + slowFrames=elapsed>80?slowFrames+1:Math.max(0,slowFrames-1); + if(slowFrames>=3)budgetFallback=true; + }; + const retry=()=>{budgetFallback=false;slowFrames=0;slowPresentationFrames=0;lastFrameAt=0;requestedAt=0;}; + map.on('render',measure);map.on('moveend',retry); + const layer: CustomLayerInterface={id:'live-packet-effects',type:'custom',renderingMode:'2d',onAdd(_map,gl){activeGL=gl as WebGL2RenderingContext;batch.initialize(activeGL);},render(){batch.draw();},onRemove(){batch.dispose();}}; + const wanted=()=>displayPreferences().quality!=='economy'&&!budgetFallback + && !(displayPreferences().quality==='auto'&&Math.max(Number(map.getContainer().dataset.eligibleRoutes)||0,Number(map.getContainer().dataset.renderedRouteSegments)||0)>2000); + const attach=()=>{ + if(!wanted()){batch.setEnabled(false);if(map.getLayer?.(layer.id))map.removeLayer(layer.id);} + else if(map.isStyleLoaded?.() && !map.getLayer(layer.id))map.addLayer(layer); + map.getContainer().dataset.gpuEffectLayer=String(Boolean(map.getLayer?.(layer.id))); + }; + const lost=()=>batch.dispose(); + const restored=()=>{if(activeGL&&wanted())batch.initialize(activeGL);attach();map.triggerRepaint();}; + map.on('load',attach); map.on('idle',attach); map.on('styledata',attach); map.on('webglcontextlost',lost); map.on('webglcontextrestored',restored); attach(); + return {batch,prepare:()=>{ + const now=performance.now(); + if(lastInk&&lastFrameAt)slowPresentationFrames=now-lastFrameAt>80?slowPresentationFrames+1:Math.max(0,slowPresentationFrames-1); + if(slowPresentationFrames>=3)budgetFallback=true; + lastFrameAt=now; + const enabled=wanted();batch.setEnabled(enabled); + // Even an empty custom layer resets MapLibre's graphics state. Remove it + // while the independent Canvas fallback owns packet presentation. + if(enabled)attach();else if(map.getLayer?.(layer.id))map.removeLayer(layer.id); + },flush:()=>{ + const ink=batch.ready&&batch.hasInk; + // A stationary faded trail does not require rerasterizing the whole basemap. + if(ink||lastInk){if(!requestedAt)requestedAt=performance.now();map.triggerRepaint();} + lastInk=ink; + },destroy:()=>{map.off('render',measure);map.off('moveend',retry);map.off('load',attach);map.off('idle',attach);map.off('styledata',attach);map.off('webglcontextlost',lost);map.off('webglcontextrestored',restored);if(map.getLayer?.(layer.id))map.removeLayer(layer.id);batch.dispose();}}; +} + +export function canvasEffects(parent: HTMLElement, before: HTMLCanvasElement) { + const canvas=document.createElement('canvas'); canvas.className='gpu-packet-canvas'; canvas.setAttribute('aria-hidden','true'); + Object.assign(canvas.style,{position:'absolute',inset:'0',width:'100%',height:'100%',pointerEvents:'none'}); parent.insertBefore(canvas,before); + const batch=new GPUEffects(); const gl=canvas.getContext('webgl2',{alpha:true,antialias:true,premultipliedAlpha:true}); + if(gl)batch.initialize(gl); + const lost=(event: Event)=>{event.preventDefault();batch.dispose();}; const restored=()=>{if(gl)batch.initialize(gl);}; + canvas.addEventListener('webglcontextlost',lost); canvas.addEventListener('webglcontextrestored',restored); + return {batch,flush:(width:number,height:number,dpr:number)=>{if(!gl)return;const w=Math.round(width*dpr),h=Math.round(height*dpr);if(canvas.width!==w||canvas.height!==h){canvas.width=w;canvas.height=h;}gl.viewport(0,0,w,h);gl.clearColor(0,0,0,0);gl.clear(gl.COLOR_BUFFER_BIT);batch.draw();},destroy:()=>{canvas.removeEventListener('webglcontextlost',lost);canvas.removeEventListener('webglcontextrestored',restored);batch.dispose();gl?.getExtension('WEBGL_lose_context')?.loseContext();canvas.remove();}}; +} diff --git a/web/src/liveFollow.test.ts b/web/src/liveFollow.test.ts index 3397b47..a6df9e6 100644 --- a/web/src/liveFollow.test.ts +++ b/web/src/liveFollow.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { FOLLOW_DWELL_MS, FollowQueue, followSummary } from './liveFollow'; +import { FOLLOW_DWELL_MS, FollowQueue, followSummary, followsScope } from './liveFollow'; import type { PacketView } from './types'; const packet = (id: string): PacketView => ({ @@ -8,6 +8,21 @@ const packet = (id: string): PacketView => ({ }); describe('Live Follow pacing', () => { + it('holds the countdown without replaying a packet and resumes the remaining dwell', () => { + const queue = new FollowQueue(); queue.offer(packet('a'), 1, 1000); queue.take(1000); + queue.setHeld(true, 4000); expect(queue.remaining(20_000)).toBe(7); + queue.offer(packet('b'), 1, 20_000); expect(queue.take(20_000)).toBeUndefined(); + queue.setHeld(false, 20_000); expect(queue.remaining(20_000)).toBe(7); + expect(queue.take(27_000)?.id).toBe('b'); + queue.next(); expect(queue.take(27_001)).toBeUndefined(); + }); + it('locks an area and handles bounds that cross the date line', () => { + const p = packet('a'); expect(followsScope(p, { kind:'node',id:'b' })).toBe(false); + expect(followsScope(p, { kind:'area',bounds:[-81,43,-79,45] })).toBe(true); + expect(followsScope(p, { kind:'area',bounds:[170,43,-170,45] })).toBe(false); + if(p.mode==='observer')p.observer.lng=179; + expect(followsScope(p, { kind:'area',bounds:[170,43,-170,45] })).toBe(true); + }); it('holds a card for ten seconds even when the camera did not move', () => { const queue = new FollowQueue(); queue.offer(packet('a'), 1, 1000); diff --git a/web/src/liveFollow.ts b/web/src/liveFollow.ts index 8c0f946..b5f2610 100644 --- a/web/src/liveFollow.ts +++ b/web/src/liveFollow.ts @@ -8,6 +8,7 @@ export const FOLLOW_DWELL_MS = 10_000; export class FollowQueue { private pending?: { packet: PacketView; priority: number; receivedAt: number }; private nextAt = 0; + private heldAt?: number; offer(packet: PacketView, priority: number, now: number): void { if (!this.pending || now - this.pending.receivedAt > FOLLOW_DWELL_MS || priority >= this.pending.priority) { @@ -16,7 +17,7 @@ export class FollowQueue { } take(now: number): PacketView | undefined { - if (now < this.nextAt || !this.pending) return undefined; + if (this.heldAt !== undefined || now < this.nextAt || !this.pending) return undefined; const pending = this.pending; this.pending = undefined; if (now - pending.receivedAt > FOLLOW_DWELL_MS * 2) return undefined; @@ -25,10 +26,18 @@ export class FollowQueue { } remaining(now: number): number { - return Math.ceil(Math.max(0, this.nextAt - now) / 1000); + return Math.ceil(Math.max(0, this.nextAt - (this.heldAt ?? now)) / 1000); } + setHeld(held: boolean, now: number): void { + if (held && this.heldAt === undefined) this.heldAt = now; + else if (!held && this.heldAt !== undefined) { this.nextAt += now - this.heldAt; this.heldAt = undefined; } + } + + next(): void { this.nextAt = 0; this.heldAt = undefined; } + clear(): void { + this.heldAt = undefined; this.pending = undefined; this.nextAt = 0; } @@ -49,3 +58,17 @@ export function followSummary(packet: PacketView): { title: string; detail: stri const connected = packet.segments.every((hop, index) => index === 0 || packet.segments[index - 1]!.to.id === hop.from.id); return { title: connected ? `${first} → ${last}` : `${first} · ${hops} observed links`, detail: `${kind} · ${hops} confirmed ${hops === 1 ? 'hop' : 'hops'}` }; } + +export type FollowScope = { kind: 'everywhere' } | { kind: 'node'; id: string } | { kind: 'area'; bounds: readonly [number, number, number, number] }; +export function followsScope(packet: PacketView, scope: FollowScope): boolean { + const points = followEndpoints(packet); + if (!points.length) return false; + if (scope.kind === 'everywhere') return true; + if (scope.kind === 'node') return points.some(point => point.id === scope.id); + const [west, south, east, north] = scope.bounds; + return points.every(point => { + const lng = ((point.lng + 180) % 360 + 360) % 360 - 180; + const insideLongitude = east - west >= 360 || (west <= east ? lng >= west && lng <= east : lng >= west || lng <= east); + return point.lat >= south && point.lat <= north && insideLongitude; + }); +} diff --git a/web/src/main.ts b/web/src/main.ts index ff2d97a..726cc5a 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -1,10 +1,13 @@ +import { mountSoundPreview } from './soundPreview'; +import { requestedNode } from './selection'; +import { mountCinematicDock, mountLayerCombinations } from './cinematicChrome'; import { browserStorage } from './browserStorage'; import { populateSoundScenes, syncSoundScene, SOUND_SCENES } from './soundScenes'; import { mountDisplayControls } from './displayControls'; import { loadBasemapConfiguration } from './basemap'; import { attachMapNotice } from './mapNotice'; import { DEFAULT_DISPLAY, DISPLAY_EVENT, displayPreferences, initializeDisplay, updateDisplay, applyDisplayChrome } from './displayPreferences'; -import 'maplibre-gl/dist/maplibre-gl.css'; +import './maplibre-base.css'; import './styles.css'; import { fetchState, LiveFeed } from './api'; import { RouteSonifier, type SoundScene, type SoundStatus } from './audio'; @@ -15,7 +18,7 @@ import { type RouteWindow } from './map'; import { PacketAnimator, potentialLongHaulPacket } from './packetAnimator'; -import { FollowQueue, followSummary } from './liveFollow'; +import { FollowQueue, followSummary, followEndpoints, followsScope, type FollowScope } from './liveFollow'; import { DEFAULT_UI_PREFERENCES, type BasemapStyle, @@ -118,6 +121,7 @@ function mobileScreenAwakeWanted(): boolean { } function requestScreenAwake(): Promise { + if (/CartoLiteAndroid\//.test(navigator.userAgent)) { appElement.dataset.screenAwake = 'native'; return Promise.resolve(); } if (!mobileScreenAwakeWanted()) { appElement.dataset.screenAwake = 'desktop'; return Promise.resolve(); @@ -172,6 +176,7 @@ setLayersOpen(false); applyAppearanceChrome(); legendToggle.addEventListener('click', () => { + closeSoundPanel(); closeFindPanel(); setLayersOpen(false); legendExpanded = !legendExpanded; uiPreferences = { ...uiPreferences, legendExpanded }; saveUiPreferences(browserStorage(), uiPreferences); @@ -213,6 +218,7 @@ document.addEventListener('keydown', (event) => { required('layers-close').addEventListener('click', () => { setLayersOpen(false); layersSummary.focus(); }); void requestScreenAwake(); +mountCinematicDock(); void start(); async function start(): Promise { @@ -249,6 +255,7 @@ async function start(): Promise { animator = liveAnimator; const routeSonifier = new RouteSonifier(liveAnimator.projection, packetCanvas); sonifier = routeSonifier; + mountSoundPreview(routeSonifier, soundPanel); soundVolume.value = String(Math.round(routeSonifier.getVolume() * 100)); soundVolumeOutput.value = `${soundVolume.value}%`; syncSoundScene(soundScene, routeSonifier.getScene()); @@ -368,6 +375,7 @@ async function start(): Promise { updateDisplay({ ...DEFAULT_DISPLAY }); applyAppearance(); }); + mountLayerCombinations(); applyAppearance(); routeWindow.value = uiPreferences.routeWindow; liveMap.setRouteWindow(uiPreferences.routeWindow); @@ -385,6 +393,7 @@ async function start(): Promise { pauseLiveFollow(); liveMap.selectNodeByID(nodeID, true); closeFindPanel(); + document.querySelector('#node-inspector-sheet .node-inspector-close')?.focus({preventScroll:true}); if (activeViewClass === 'mobile') setLayersOpen(false); }, dismiss() { @@ -401,7 +410,7 @@ async function start(): Promise { closeSoundPanel(); setLayersOpen(false); renderNodeSearch(); - window.requestAnimationFrame(() => nodeSearch.focus()); + nodeSearch.focus(); }); let wasHidden = document.hidden; document.addEventListener('visibilitychange', () => { @@ -428,7 +437,8 @@ async function start(): Promise { void requestScreenAwake(); void feed?.resume(); }); - window.addEventListener('beforeunload', () => { + window.addEventListener('pagehide', (event) => { + if (event.persisted) return; if (trafficWakeTimer !== undefined) window.clearTimeout(trafficWakeTimer); if (followTimer !== undefined) window.clearInterval(followTimer); feed?.stop(); @@ -437,18 +447,54 @@ async function start(): Promise { sonifier?.destroy(); mapView?.destroy(); releaseScreenAwake(); - }, { once: true }); + }); const initial = await fetchState(); + const requestedSelection = requestedNode(); + if (requestedSelection) liveMap.map.once('idle', () => liveMap.selectNodeByID(requestedSelection, false)); const liveStore = new LiveStore(initial); store = liveStore; let streamConnected = false; let liveFollow = false; let followPaused = false; + let held = false; + let currentFollowPacket: PacketView | undefined; + let followScope: FollowScope = { kind: 'everywhere' }; + const followScopeControl = document.createElement('label'); + followScopeControl.className = 'follow-scope'; + followScopeControl.innerHTML = 'Follow area'; + followCard.querySelector('header')!.after(followScopeControl); + const scopeSelect = followScopeControl.querySelector('select')!; + const actions = document.createElement('div'); actions.className = 'follow-actions'; + actions.innerHTML = ''; + followCard.append(actions); + const inspectFollow = actions.querySelector('#follow-inspect')!; + scopeSelect.addEventListener('change', () => { + if (scopeSelect.value === 'area') { + const bounds = liveMap.map.getBounds(); + const wrap = (lng: number) => ((lng + 180) % 360 + 360) % 360 - 180; + followScope = { kind: 'area', bounds: bounds.getEast()-bounds.getWest()>=360 ? [-180,bounds.getSouth(),180,bounds.getNorth()] : [wrap(bounds.getWest()),bounds.getSouth(),wrap(bounds.getEast()),bounds.getNorth()] }; + } else if (scopeSelect.value === 'node') { + const id = liveMap.getSelectedNodeID(); + if (!id) { scopeSelect.value = 'everywhere'; followDetail.textContent = 'Select a node to follow its activity'; followScope = { kind: 'everywhere' }; } + else followScope = { kind: 'node', id }; + } else followScope = { kind: 'everywhere' }; + followQueue.clear(); held = false; followPause.textContent = 'Hold'; clearFollowActivity(); + }); + actions.querySelector('#follow-next')!.addEventListener('click', () => { + if (!liveFollow) return; + held = false; followQueue.next(); followPause.textContent = 'Hold'; liveMap.beginFollow(); tickFollow(); + }); + inspectFollow.addEventListener('click', () => { + const endpoint = currentFollowPacket && followEndpoints(currentFollowPacket)[0]; + if (!endpoint) return; + held = true; followQueue.setHeld(true, Date.now()); followPause.textContent = 'Continue'; appElement.dataset.followInspect = 'true'; liveMap.selectNodeByID(endpoint.id, false); + }); const followQueue = new FollowQueue(); mapElement.dataset.followDwellMs = String(LIVE_FOLLOW_MIN_INTERVAL_MS); const clearFollowActivity = (): void => { + currentFollowPacket = undefined; inspectFollow.disabled = true; followTitle.textContent = 'Waiting for activity'; followDetail.textContent = 'Nearby activity is shown first'; delete followCard.dataset.packetAt; @@ -458,9 +504,12 @@ async function start(): Promise { const tickFollow = (): void => { if (!liveFollow || document.hidden) return; + if (held) { followCard.dataset.state = 'held'; followState.textContent = 'Held · continue when ready'; return; } + followCard.dataset.state = 'following'; const now = Date.now(); const packet = followQueue.take(now); - if (packet && liveMap.shouldFollow(packet)) { + if (packet && followsScope(packet, followScope)) { + currentFollowPacket = packet; inspectFollow.disabled = false; const summary = followSummary(packet); followTitle.textContent = summary.title; followDetail.textContent = summary.detail; @@ -477,7 +526,7 @@ async function start(): Promise { }; const queueLiveFollow = (packet: PacketView): void => { - if (!liveFollow || !liveMap.shouldFollow(packet)) return; + if (!liveFollow || !followsScope(packet, followScope)) return; followQueue.offer(packet, liveMap.followPriority(packet), Date.now()); tickFollow(); }; @@ -488,14 +537,14 @@ async function start(): Promise { if (followTimer !== undefined) window.clearInterval(followTimer); followTimer = undefined; liveFollow = enabled; - followPaused = paused; + followPaused = paused; held = false; delete appElement.dataset.followInspect; followButton.setAttribute('aria-pressed', String(enabled)); followButton.classList.toggle('selected', enabled); followButton.dataset.mode = enabled ? 'director' : 'manual'; appElement.classList.toggle('director-enabled', enabled); followButton.title = enabled ? 'Stop following live packets' : 'Follow live packets'; followCard.hidden = !enabled && !paused; - followPause.textContent = paused ? 'Resume' : 'Pause'; + followPause.textContent = paused ? 'Resume' : 'Hold'; followCard.dataset.state = enabled ? 'following' : paused ? 'paused' : 'off'; followCountdown.value = ''; followProgress.value = 0; @@ -515,8 +564,11 @@ async function start(): Promise { if (liveFollow) { liveMap.map.stop(); setLiveFollow(false, true); } }; followPause.addEventListener('click', () => { - if (liveFollow) liveMap.map.stop(); - setLiveFollow(followPaused, !followPaused); + if (followPaused) { setLiveFollow(true); return; } + held = !held; delete appElement.dataset.followInspect; followQueue.setHeld(held, Date.now()); + followPause.textContent = held ? 'Continue' : 'Hold'; + if (held) liveMap.map.stop(); + tickFollow(); }); required('follow-close').addEventListener('click', () => setLiveFollow(false)); @@ -545,7 +597,7 @@ async function start(): Promise { : 'home-no-activity'; } else { mapElement.dataset.viewSource = 'home'; - liveMap.home(initial.nodes); + liveMap.home(initial.nodes, false); } liveMap.map.on('moveend', () => { diff --git a/web/src/map.ts b/web/src/map.ts index a4397dd..6fbc43e 100644 --- a/web/src/map.ts +++ b/web/src/map.ts @@ -1,3 +1,6 @@ +import { followViewport } from './cinematicChrome'; +import { prefersReducedMotion as displayReducedMotion } from './displayPreferences'; +import { rememberNode, replaceInspector } from './selection'; import * as maplibregl from 'maplibre-gl'; import workerURL from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url'; import { @@ -207,7 +210,7 @@ export class LiveMap { private readonly originalOverlayColors = new Map(); private buildingSourceID?: string; private directorTimer?: number; - private readonly reducedMotion = prefersReducedMotion(); + private reducedMotion = prefersReducedMotion(); private freshnessTimer: number; private renderEpoch = 0; private renderingScheduled = false; @@ -391,6 +394,7 @@ export class LiveMap { this.rebuildAllRoutes = false; this.dirtyRouteIDs.clear(); this.routeHydrating = true; + this.container.dataset.routeHydrating = 'true'; this.routeDataDirty = false; this.container.dataset.exactRoutesLoaded = 'false'; this.container.dataset.exactRoutesReady = 'false'; @@ -401,6 +405,7 @@ export class LiveMap { const fail = (error: unknown): void => { if (!active()) return; this.routeHydrating = false; + this.container.dataset.routeHydrating = 'false'; this.routeDataDirty = true; this.rebuildAllRoutes = true; this.container.dataset.renderState = 'idle'; @@ -409,6 +414,7 @@ export class LiveMap { const finish = (): void => { if (!active()) return; this.routeHydrating = false; + this.container.dataset.routeHydrating = 'false'; this.trackExactRouteReadiness(hydrationEpoch); if (this.routeDataDirty) { this.emitRouteWindowChange(); @@ -455,8 +461,11 @@ export class LiveMap { let settledFrames = 0; const settle = (): void => { if (hydrationEpoch !== this.routeHydrationEpoch) return; - const sourceReady = Boolean(this.map.getSource(ROUTE_DETAIL_SOURCE_ID)) - && this.map.isSourceLoaded(ROUTE_DETAIL_SOURCE_ID); + // Historical geometry lives in the batched renderer. The GeoJSON source + // serves selected-neighbour hit targets only and may stay unloaded while hidden. + const detailNeeded = this.routesVisible && this.selectedNodeID !== null; + const sourceReady = this.container.dataset.exactRoutesLoaded === 'true' + && (!detailNeeded || (Boolean(this.map.getSource(ROUTE_DETAIL_SOURCE_ID)) && this.map.isSourceLoaded(ROUTE_DETAIL_SOURCE_ID))); if (!sourceReady || this.routeHydrating) { settledFrames = 0; window.requestAnimationFrame(settle); @@ -586,7 +595,7 @@ export class LiveMap { this.map.easeTo({ center, zoom, ...orientation, duration: 520, essential: false }); } - home(nodes: readonly NodeV2[]): void { + home(nodes: readonly NodeV2[], animate = true): void { this.lastFollowMoveAt = 0; const now = Date.now(); const active = nodes.filter((node) => validEndpoint(node) && Math.max(0, now - node.lastSeen) <= ACTIVE_NODE_WINDOW_MS); @@ -601,7 +610,7 @@ export class LiveMap { } const bounds = new maplibregl.LngLatBounds(); for (const node of visible) bounds.extend([node.lng, node.lat]); - const options = { padding: this.container.clientWidth <= 620 ? 48 : 72, maxZoom: 6, duration: this.reducedMotion ? 0 : 620 }; + const options = { padding: this.container.clientWidth <= 620 ? 48 : 72, maxZoom: 6, duration: this.reducedMotion || !animate ? 0 : 620 }; this.map.fitBounds(bounds, options); } @@ -630,10 +639,11 @@ export class LiveMap { if (endpoints.length === 0) return false; const container = this.map.getContainer(); const viewport = { width: container.clientWidth, height: container.clientHeight }; - const inside = endpoints.every((endpoint) => isPointInSafeArea( - this.map.project([endpoint.lng, endpoint.lat]), - viewport, - )); + const visible=followViewport(container); + const inside = endpoints.every(endpoint => { + const p=this.map.project([endpoint.lng,endpoint.lat]); + return p.x>=visible.left&&p.x<=visible.right&&p.y>=visible.top&&p.y<=visible.bottom; + }); if (inside) return false; const now = Date.now(); if (!canMoveLiveFollow(this.lastFollowMoveAt, now)) return false; @@ -644,20 +654,13 @@ export class LiveMap { this.directorTimer = undefined; this.container.dataset.cameraMode = 'idle'; }, 1500); - if (endpoints.length === 1) { - const center: [number, number] = [endpoints[0]!.lng, endpoints[0]!.lat]; - if (this.reducedMotion) this.map.jumpTo({ center }); - else this.map.easeTo({ center, duration: 1200, essential: false, easeId: 'cartolite-live-follow' }); - return true; - } const bounds = new maplibregl.LngLatBounds(); const anchor = endpoints[0]!.lng; for (const endpoint of endpoints) bounds.extend([anchor + longitudeDelta(anchor, endpoint.lng), endpoint.lat]); - const horizontal = container.clientWidth <= 620 ? 56 : 104; const camera = this.map.cameraForBounds(bounds, { - padding: { top: 96, right: horizontal, bottom: Math.min(230, viewport.height * 0.32), left: horizontal }, + padding: { top: visible.top, right: viewport.width-visible.right, bottom: viewport.height-visible.bottom, left: visible.left }, bearing: this.map.getBearing(), - maxZoom: this.followZoom, + maxZoom: endpoints.length===1 ? this.map.getZoom() : Math.min(this.followZoom,this.map.getZoom()), }); if (!camera) return false; this.map.easeTo({ ...camera, duration: this.reducedMotion ? 0 : 1400, essential: false, easeId: 'cartolite-live-follow' }); @@ -695,6 +698,7 @@ export class LiveMap { } setAppearance(preferences: UiPreferences, force = false): void { + this.reducedMotion = displayReducedMotion(); const previous = this.appearance; this.appearance = { ...preferences }; this.container.dataset.basemapStyle = preferences.basemap; @@ -722,6 +726,10 @@ export class LiveMap { } } this.map.setLayoutProperty(NODE_LABEL_LAYER_ID, 'visibility', preferences.nodeLabels ? 'visible' : 'none'); + const display = displayPreferences(); + this.map.setLayoutProperty(NODE_LABEL_LAYER_ID, 'text-padding', display.detail === 'complete' ? 2 : 7); + this.map.setLayoutProperty(NODE_LABEL_LAYER_ID, 'text-size', ['interpolate', ['linear'], ['zoom'], DETAIL_ZOOM, display.textSize === 'large' ? 12 : 10, 12, display.textSize === 'large' ? 15 : 12, 16, display.textSize === 'large' ? 17 : 13]); + this.map.setPaintProperty(NODE_LABEL_LAYER_ID, 'text-opacity', .96); this.historicalRouteLayer.setOpacity(preferences.routeOpacity); this.historicalRouteLayer.refreshAppearance(); this.container.dataset.routePreset = displayPreferences().preset; @@ -738,6 +746,8 @@ export class LiveMap { this.markRendering(); } + getSelectedNodeID(): string | null { return this.selectedNodeID; } + shouldFollow(packet: PacketView): boolean { return packetMatchesFollow(packet, this.selectedNodeID); } @@ -1486,6 +1496,7 @@ export class LiveMap { this.selectedNodeID = nodeID; this.selectedNodeLabel = nodeID ? label : ''; this.container.dataset.selectedNodeId = nodeID ?? ''; + rememberNode(nodeID); this.updateFocusData(); this.applyFocusState(); if (nodeID === null && this.tooltip.dataset.kind === 'route') this.hideTooltip(); @@ -1708,7 +1719,7 @@ export class LiveMap { }); if (mobile) { this.closePopup(false); - this.inspectorSheet.replaceChildren(content); + replaceInspector(this.inspectorSheet, content); this.inspectorSheet.hidden = false; return; } @@ -1741,9 +1752,11 @@ export class LiveMap { } private closeInspector(clearSelection: boolean): void { + const restoreFocus = this.inspectorSheet.contains(document.activeElement); this.closePopup(clearSelection); this.inspectorSheet.hidden = true; this.inspectorSheet.replaceChildren(); + if (restoreFocus) document.getElementById('find-button')?.focus(); } private closePopup(clearSelection: boolean): void { @@ -1754,7 +1767,7 @@ export class LiveMap { } private isMobileInspector(): boolean { - return this.container.clientWidth <= 620 || window.matchMedia('(pointer: coarse)').matches; + return document.documentElement.dataset.cinematic === 'true' || this.container.clientWidth <= 620 || window.matchMedia('(pointer: coarse)').matches; } private inspectorPopupAnchor(node: NodeV2): 'left' | 'right' { @@ -1771,11 +1784,11 @@ export class LiveMap { private centerNodeIfNeeded(node: NodeV2): void { const point = this.map.project([node.lng, node.lat]); - const mobile = this.isMobileInspector(); + const mobile = this.container.clientWidth <= 900 || window.matchMedia('(pointer: coarse)').matches; const margin = 72; const safeBottom = this.container.clientHeight - (mobile ? Math.min(360, this.container.clientHeight * 0.48) : margin); const inSafeView = point.x >= margin - && point.x <= this.container.clientWidth - margin + && point.x <= this.container.clientWidth - (mobile ? margin : 390) && point.y >= margin && point.y <= safeBottom; if (inSafeView && this.map.getZoom() >= DETAIL_ZOOM) return; diff --git a/web/src/maplibre-base.css b/web/src/maplibre-base.css new file mode 100644 index 0000000..913aaa9 --- /dev/null +++ b/web/src/maplibre-base.css @@ -0,0 +1,45 @@ +/* CartoLite supplies its own controls and inspector. Only MapLibre's canvas, + gestures and attribution need library chrome (MapLibre GL JS 6.4.1 DOM). */ +.maplibregl-map { position:relative; overflow:hidden; font:12px/20px system-ui,sans-serif; -webkit-tap-highlight-color:transparent; } +.maplibregl-canvas { position:absolute; left:0; top:0; } +.maplibregl-map:fullscreen { width:100%; height:100%; } +.maplibregl-interactive { cursor:grab; user-select:none; } +.maplibregl-interactive:active { cursor:grabbing; } +.maplibregl-track-pointer { cursor:pointer; } +.maplibregl-touch-zoom-rotate, .maplibregl-touch-zoom-rotate .maplibregl-canvas { touch-action:pan-x pan-y; } +.maplibregl-touch-drag-pan, .maplibregl-touch-drag-pan .maplibregl-canvas { touch-action:pinch-zoom; } +.maplibregl-touch-zoom-rotate.maplibregl-touch-drag-pan, .maplibregl-touch-zoom-rotate.maplibregl-touch-drag-pan .maplibregl-canvas { touch-action:none; } +.maplibregl-canvas:focus-visible { outline:2px solid var(--focus-ring); outline-offset:-2px; } +.maplibregl-ctrl-bottom-right,.maplibregl-ctrl-bottom-left,.maplibregl-ctrl-top-right,.maplibregl-ctrl-top-left { position:absolute; pointer-events:none; z-index:3; } +.maplibregl-ctrl-bottom-right { right:0; bottom:0; } +.maplibregl-ctrl-bottom-left { left:0; bottom:0; } +.maplibregl-ctrl-top-right { right:0; top:0; } +.maplibregl-ctrl-top-left { left:0; top:0; } +.maplibregl-ctrl { clear:both; pointer-events:auto; transform:translate(0,0); } +.maplibregl-ctrl-attrib { margin:0 6px 4px; padding:3px 8px; border-radius:6px; color:var(--ink); background:var(--panel-solid); font-size:10px; line-height:18px; max-width:calc(100vw - 12px); } +.maplibregl-ctrl-attrib a { color:inherit; text-decoration:none; } +.maplibregl-ctrl-attrib a:hover { text-decoration:underline; } +.maplibregl-ctrl-attrib-button { display:none; border:1px solid currentColor; border-radius:50%; background:var(--panel-solid); color:var(--ink); cursor:pointer; width:44px; height:44px; filter:none !important; } +.maplibregl-ctrl-attrib-button::before { content:'i'; font:bold 16px Georgia,serif; } +.maplibregl-compact { padding:0; min-height:44px; } +.maplibregl-compact .maplibregl-ctrl-attrib-button { display:block; float:right; } +.maplibregl-compact .maplibregl-ctrl-attrib-inner { display:none; } +.maplibregl-compact-show .maplibregl-ctrl-attrib-inner { display:block; padding:8px 48px 8px 10px; } +.maplibregl-ctrl-attrib.maplibregl-attrib-empty { display:none; } +.maplibregl-boxzoom { position:absolute; top:0; left:0; width:0; height:0; border:2px dashed var(--focus-ring); background:#72ffe118; } +.maplibregl-crosshair,.maplibregl-crosshair .maplibregl-interactive { cursor:crosshair; } +.maplibregl-popup { position:absolute; display:flex; top:0; left:0; pointer-events:none; } +.maplibregl-popup-content { position:relative; background:var(--panel-solid); border:1px solid var(--panel-border); border-radius:12px; pointer-events:auto; } +.maplibregl-popup-close-button { position:absolute; right:0; top:0; width:44px; height:44px; border:0; background:transparent; color:var(--ink); } +.maplibregl-popup-anchor-top,.maplibregl-popup-anchor-top-left,.maplibregl-popup-anchor-top-right { flex-direction:column; } +.maplibregl-popup-anchor-bottom,.maplibregl-popup-anchor-bottom-left,.maplibregl-popup-anchor-bottom-right { flex-direction:column-reverse; } +.maplibregl-popup-anchor-left { flex-direction:row; } +.maplibregl-popup-anchor-right { flex-direction:row-reverse; } +.maplibregl-popup-tip { display:none; } +@media(forced-colors:active) { .maplibregl-ctrl-attrib { border:1px solid CanvasText; } } + +summary.maplibregl-ctrl-attrib-button { list-style:none; display:grid; place-items:center; } +summary.maplibregl-ctrl-attrib-button::-webkit-details-marker { display:none; } +.maplibregl-ctrl-attrib.maplibregl-compact { position:relative; width:44px; } +.maplibregl-ctrl-attrib.maplibregl-compact-show { width:auto; max-width:min(480px,calc(100vw - 12px)); } +.maplibregl-compact-show .maplibregl-ctrl-attrib-button { position:absolute; right:0; top:0; } diff --git a/web/src/netgraph/follow.ts b/web/src/netgraph/follow.ts new file mode 100644 index 0000000..3e55e06 --- /dev/null +++ b/web/src/netgraph/follow.ts @@ -0,0 +1,47 @@ +import { FollowQueue, followEndpoints, followSummary } from '../liveFollow'; +import type { PacketView } from '../types'; +import type { NetgraphRenderer } from './renderer'; + +/** Same live-only queue and ten-second dwell as Map. No animation is replayed. */ +export function mountGraphFollow(graph: NetgraphRenderer, select: (id: string) => void) { + const button=document.createElement('button');button.type='button';button.className='control-button';button.textContent='◎ Follow';button.setAttribute('aria-pressed','false'); + document.querySelector('.controls')!.append(button); + const card=document.createElement('section');card.id='follow-card';card.className='follow-card glass';card.hidden=true;card.setAttribute('aria-label','Live Follow activity'); + card.innerHTML='
Live Follow
Waiting for activity
'; + const app=document.querySelector('.netgraph-app')!;app.append(card); + const queue=new FollowQueue();let enabled=false,held=false;let current:PacketView|undefined;let matches:((id:string)=>boolean)|undefined; + const hold=card.querySelector('[data-hold]')!; + const inspect=card.querySelector('[data-inspect]')!; + const scope=card.querySelector('select')!; + const clearActivity=()=>{current=undefined;inspect.disabled=true;card.querySelector('[data-title]')!.textContent='Waiting for activity';card.querySelector('[data-detail]')!.textContent='New live packets only';card.querySelector('output')!.value='';card.querySelector('progress')!.value=0;}; + const setEnabled=(value:boolean)=>{enabled=value;held=false;queue.clear();clearActivity();graph.holdView();app.classList.toggle('director-enabled',value);delete app.dataset.followInspect;button.setAttribute('aria-pressed',String(value));card.hidden=!value;hold.textContent='Hold';}; + button.addEventListener('click',()=>setEnabled(!enabled)); + card.querySelector('[data-close]')!.addEventListener('click',()=>setEnabled(false)); + scope.addEventListener('change',()=>{ + const selected=graph.selectedNode(); + matches=scope.value==='area'?graph.captureFollowArea():scope.value==='node'?(id=>id===selected):undefined; + queue.clear();held=false;hold.textContent='Hold';clearActivity();graph.holdView(); + if(scope.value==='node'&&!selected)card.querySelector('[data-detail]')!.textContent='Select a node, then choose Selected node'; + }); + hold.addEventListener('click',()=>{held=!held;if(held)graph.holdView();queue.setHeld(held,Date.now());hold.textContent=held?'Continue':'Hold';}); + card.querySelector('[data-next]')!.addEventListener('click',()=>{held=false;delete app.dataset.followInspect;queue.next();hold.textContent='Hold';tick();}); + inspect.addEventListener('click',()=>{const id=current&&followEndpoints(current)[0]?.id;if(id){held=true;app.dataset.followInspect='true';graph.holdView();queue.setHeld(true,Date.now());hold.textContent='Continue';select(id);}}); + const pause=()=>{if(enabled&&!held){held=true;queue.setHeld(true,Date.now());hold.textContent='Continue';}}; + const stage=document.getElementById('netgraph-stage')!; + stage.addEventListener('pointerdown',pause);stage.addEventListener('wheel',pause,{passive:true});stage.addEventListener('keydown',pause); + function tick():void { + if(!enabled||document.hidden)return; + if(held){card.querySelector('[data-state]')!.textContent='Held · explore freely';return;} + const packet=queue.take(Date.now()); + if(packet){current=packet;inspect.disabled=false;const summary=followSummary(packet);card.querySelector('[data-title]')!.textContent=summary.title;card.querySelector('[data-detail]')!.textContent=summary.detail;graph.followPacket(packet);} + const remaining=queue.remaining(Date.now());if(!remaining&¤t)clearActivity();card.querySelector('output')!.value=remaining?`${remaining}s`:'';card.querySelector('progress')!.value=remaining; + card.querySelector('[data-state]')!.textContent=remaining?'Next activity in':'Waiting for activity'; + } + const dismiss=(event:KeyboardEvent)=>{ + if(event.key!=='Escape'||!enabled||document.querySelector('.controls button[aria-expanded="true"]'))return; + event.preventDefault();event.stopImmediatePropagation();setEnabled(false);button.focus(); + }; + document.addEventListener('keydown',dismiss); + const timer=window.setInterval(tick,250); + return {offer(packet:PacketView){if(!enabled)return;const points=followEndpoints(packet);if(matches && (scope.value==='node'?!points.some(p=>matches!(p.id)):!points.every(p=>matches!(p.id))))return;const nearby=points.some(point=>{const p=graph.projectEndpoint(point);return p.x>=0&&p.x<=stage.clientWidth&&p.y>=0&&p.y<=stage.clientHeight;});queue.offer(packet,nearby?2:0,Date.now());tick();},destroy(){clearInterval(timer);document.removeEventListener('keydown',dismiss);stage.removeEventListener('pointerdown',pause);stage.removeEventListener('wheel',pause);stage.removeEventListener('keydown',pause);card.remove();button.remove();}}; +} diff --git a/web/src/netgraph/main.ts b/web/src/netgraph/main.ts index dc5280e..e2015a5 100644 --- a/web/src/netgraph/main.ts +++ b/web/src/netgraph/main.ts @@ -1,3 +1,7 @@ +import { mountGraphFollow } from './follow'; +import { mountSoundPreview } from '../soundPreview'; +import { requestedNode, rememberNode, replaceInspector } from '../selection'; +import { mountCinematicDock } from '../cinematicChrome'; import { populateSoundScenes, syncSoundScene, SOUND_SCENES } from '../soundScenes'; import { initializeDisplay } from '../displayPreferences'; import { mountNetgraphDisplay } from '../displayControls'; @@ -105,6 +109,7 @@ async function start(): Promise { renderer = graph; const routeSonifier = new RouteSonifier(graph, stage); sonifier = routeSonifier; + mountSoundPreview(routeSonifier, soundPanel); configureSound(routeSonifier); graph.setRouteWindow(settings.routeWindow); @@ -114,9 +119,11 @@ async function start(): Promise { const renderInspector = (): void => { const started = performance.now(); - inspectorSheet.replaceChildren(); + // Preserve keyboard focus when neighbour ordering changes. + if (!selectedNodeID) { - inspectorSheet.hidden = true; + if (inspectorSheet.contains(document.activeElement)) findButton.focus(); + inspectorSheet.replaceChildren(); inspectorSheet.hidden = true; stage.dataset.inspectorApplyMs = (performance.now() - started).toFixed(1); return; } @@ -133,7 +140,7 @@ async function start(): Promise { inspectorSheet.hidden = true; return; } - inspectorSheet.append(createNodeInspectorContent(document, model, { + replaceInspector(inspectorSheet, createNodeInspectorContent(document, model, { mobile: true, onClose: () => selectNode(null), onSelectNeighbor: (nodeID) => selectNode(nodeID, true), @@ -145,6 +152,7 @@ async function start(): Promise { selectNode = (nodeID: string | null, focus = false): void => { const started = performance.now(); selectedNodeID = nodeID; + rememberNode(nodeID); graph.setSelectedNode(nodeID); renderInspector(); if (nodeID && focus) graph.focusNode(nodeID); @@ -177,7 +185,20 @@ async function start(): Promise { if (changes.reset || selectedNodeChanged || adjacentRouteChanged) renderInspector(); }); app.dataset.loading = 'false'; + const requestedSelection = requestedNode(); + if (requestedSelection && graph.getNodes().has(requestedSelection)) selectNode(requestedSelection); + const focusControl = document.createElement('label'); focusControl.className = 'graph-focus'; + focusControl.innerHTML = 'Focus'; + inspectorSheet.before(focusControl); + focusControl.querySelector('select')!.addEventListener('change', event => { + const select = event.target as HTMLSelectElement; + const applied = graph.setFocus(select.value as 'all' | 'area' | 'component'); + focusControl.querySelector('small')!.textContent = applied ? '' : 'Select a node first'; + if (!applied) select.value = 'all'; + }); + const director = mountGraphFollow(graph, id => selectNode(id)); + window.addEventListener('pagehide',event=>{if(!event.persisted)director.destroy();}); const liveFeed = new LiveFeed(initial, { onConnection(connected) { streamConnected = connected; @@ -194,6 +215,7 @@ async function start(): Promise { const noteCount = routeSonifier.play(packet); if (noteCount > 0) pulseSound(noteCount); pulseTraffic(packet); + director.offer(packet); }, onStatus(event) { liveStore.updateStatus(event.status, event.seq); @@ -277,7 +299,8 @@ async function start(): Promise { updateSummary(); if (selectedNodeID) renderInspector(); }, 60_000); - window.addEventListener('beforeunload', () => { + window.addEventListener('pagehide', (event) => { + if (event.persisted) return; if (minuteTimer !== undefined) window.clearInterval(minuteTimer); if (trafficTimer !== undefined) window.clearTimeout(trafficTimer); if (soundPulseTimer !== undefined) window.clearTimeout(soundPulseTimer); @@ -286,7 +309,7 @@ async function start(): Promise { routeSonifier.destroy(); graph.destroy(); releaseScreenAwake(); - }, { once: true }); + }); } catch (error) { if (minuteTimer !== undefined) window.clearInterval(minuteTimer); feed?.stop(); @@ -310,6 +333,7 @@ function wireSearch(renderer: NetgraphRenderer, select: (nodeID: string) => void select(nodeID) { select(nodeID); closeFindPanel(); + inspectorSheet.querySelector('.node-inspector-close')?.focus({preventScroll:true}); }, dismiss() { closeFindPanel(); @@ -325,7 +349,7 @@ function wireSearch(renderer: NetgraphRenderer, select: (nodeID: string) => void closeSoundPanel(); closeDisplay(); renderResults(); - requestAnimationFrame(() => nodeSearch.focus()); + nodeSearch.focus(); }); } @@ -459,6 +483,7 @@ function isNetgraphWindow(value: unknown): value is NetgraphWindow { } function requestScreenAwake(): Promise { + if (/CartoLiteAndroid\//.test(navigator.userAgent)) { app.dataset.screenAwake = 'native'; return Promise.resolve(); } screenAwakeWanted = true; if (!matchMedia('(pointer: coarse)').matches) { app.dataset.screenAwake = 'desktop'; @@ -512,3 +537,5 @@ function required(id: string): T { function clamp(value: number, minimum: number, maximum: number): number { return Math.max(minimum, Math.min(maximum, value)); } + +mountCinematicDock(); diff --git a/web/src/netgraph/renderer.ts b/web/src/netgraph/renderer.ts index 71f4203..994a23e 100644 --- a/web/src/netgraph/renderer.ts +++ b/web/src/netgraph/renderer.ts @@ -1,4 +1,6 @@ -import { canvasColorWithAlpha as colorWithAlpha, DISPLAY_EVENT, displayColor, displayPreferences, displayResidueAge, lightScene, lineDash, residueLifetime } from '../displayPreferences'; +import { followViewport } from '../cinematicChrome'; +import { canvasEffects } from '../gpuEffects'; +import { canvasColorWithAlpha as colorWithAlpha, DISPLAY_EVENT, displayColor, displayPreferences, displayResidueAge, lightScene, lineDash, residueLifetime, prefersReducedMotion, effectStrength } from '../displayPreferences'; import type { ViewportProjector } from '../audio'; import { DESTINATION_BLOOM_MS, @@ -8,7 +10,6 @@ import { interpolateScreenPoint, nodeWakeRadius, packetTrail, - residueSparkleProgress, residueStyle, routeDuration, routeMotion, @@ -108,6 +109,7 @@ export interface NetgraphRendererCallbacks { } export class NetgraphRenderer implements ViewportProjector { + private readonly gpu: ReturnType; private readonly graphContext: CanvasRenderingContext2D; private readonly packetContext: CanvasRenderingContext2D; private readonly residueCanvas: HTMLCanvasElement; @@ -141,6 +143,7 @@ export class NetgraphRenderer implements ViewportProjector { private screenNodes: ScreenNode[] = []; private routeWindow: NetgraphWindow = '15m'; private selectedNodeID: string | null = null; + private focusIDs: Set | undefined; private hoveredNodeID: string | null = null; private activeRoutes: ActiveRoute[] = []; private observerWakes: ObserverWake[] = []; @@ -173,6 +176,7 @@ export class NetgraphRenderer implements ViewportProjector { const graphContext = graphCanvas.getContext('2d'); const packetContext = packetCanvas.getContext('2d'); if (!graphContext || !packetContext) throw new Error('Canvas2D is unavailable'); + this.gpu = canvasEffects(stage, packetCanvas); this.graphContext = graphContext; this.packetContext = packetContext; this.residueCanvas = document.createElement('canvas'); @@ -184,7 +188,7 @@ export class NetgraphRenderer implements ViewportProjector { this.nodesContext = this.nodesCanvas.getContext('2d')!; this.reducedMotionQuery = matchMedia('(prefers-reduced-motion: reduce)'); this.lowPowerQuery = matchMedia('(max-width: 700px), (pointer: coarse)'); - this.stage.dataset.motionMode = this.reducedMotionQuery.matches ? 'static' : 'animated'; + this.stage.dataset.motionMode = prefersReducedMotion() ? 'static' : 'animated'; this.stage.dataset.qualityMode = this.quality.mode; this.handleResize = this.handleResize.bind(this); this.drawMotion = this.drawMotion.bind(this); @@ -348,6 +352,41 @@ export class NetgraphRenderer implements ViewportProjector { this.requestStaticDraw(); } + followPacket(packet: PacketView): void { + const ids = packet.mode === 'observer' ? [packet.observer.id] : packet.segments.flatMap(hop => [hop.from.id,hop.to.id]); + const points = ids.map(id => this.layout.positions.get(id)).filter((p): p is NonNullable => !!p); + if (!points.length) return; + const view=followViewport(this.stage); + if (points.every(p => { const q=this.screenPoint(p.id); return q.x>=view.left&&q.x<=view.right&&q.y>=view.top&&q.y<=view.bottom; })) return; + const xs=points.map(p=>p.x),ys=points.map(p=>p.y); + const left=Math.min(...xs),right=Math.max(...xs),top=Math.min(...ys),bottom=Math.max(...ys); + const scale=Math.max(.015,Math.min(this.scale,(view.right-view.left)/Math.max(1,right-left),(view.bottom-view.top)/Math.max(1,bottom-top))); + this.animateView((left+right)/2-((view.left+view.right)/2-this.width/2)/scale,(top+bottom)/2-((view.top+view.bottom)/2-this.height/2)/scale,scale); + } + + captureFollowArea(): (id: string) => boolean { + const left=this.centerX-this.width/(2*this.scale), right=this.centerX+this.width/(2*this.scale); + const top=this.centerY-this.height/(2*this.scale), bottom=this.centerY+this.height/(2*this.scale); + return id => { + const point=this.layout.positions.get(id); + return !!point && point.x>=left && point.x<=right && point.y>=top && point.y<=bottom; + }; + } + + holdView(): void { this.cancelViewAnimation(); } + + selectedNode(): string | null { return this.selectedNodeID; } + + setFocus(mode: 'all' | 'area' | 'component'): boolean { + const anchor = this.selectedNodeID ? this.layout.positions.get(this.selectedNodeID) : undefined; + if (mode !== 'all' && !anchor) return false; + this.focusIDs = mode === 'all' ? undefined : new Set([...this.layout.positions.values()].filter(point => mode === 'area' ? point.areaCode === anchor!.areaCode : point.component === anchor!.component).map(point => point.id)); + this.screenPoints.clear(); this.nodesDirty = true; this.residueProjectionDirty = true; + this.stage.dataset.focusMode = mode; + this.requestStaticDraw(); this.requestMotionFrame(); + return true; + } + focusNode(nodeID: string): void { const position = this.layout.positions.get(nodeID); if (!position) return; @@ -358,11 +397,11 @@ export class NetgraphRenderer implements ViewportProjector { const bounds = this.layout.bounds; const boundsWidth = Math.max(1, bounds.maxX - bounds.minX); const boundsHeight = Math.max(1, bounds.maxY - bounds.minY); - const nextScale = clamp(Math.min((this.width - 52) / boundsWidth, (this.height - 80) / boundsHeight), 0.005, 3); + const nextScale = clamp(Math.min((this.width - 52) / boundsWidth, (this.height - 80) / boundsHeight), 0.04, 3); const nextX = (bounds.minX + bounds.maxX) / 2; const nextY = (bounds.minY + bounds.maxY) / 2; this.initializedView = true; - if (animate && !this.reducedMotionQuery.matches) this.animateView(nextX, nextY, nextScale); + if (animate && !prefersReducedMotion()) this.animateView(nextX, nextY, nextScale); else { this.cancelViewAnimation(); this.centerX = nextX; @@ -395,7 +434,7 @@ export class NetgraphRenderer implements ViewportProjector { crossRegion: regionTraffic?.crossRegion ?? false, longHaul: regionTraffic?.longHaul ?? false, }; - if (this.reducedMotionQuery.matches) { + if (prefersReducedMotion()) { for (const segment of packet.segments) this.addResidue(segment.routeId, segment.from.id, segment.to.id, color, signature, now); } else { this.activeRoutes.push(active); @@ -447,6 +486,7 @@ export class NetgraphRenderer implements ViewportProjector { setPaused(paused: boolean): void { this.paused = paused; if (paused) { + this.gpu.batch.clear(); this.gpu.flush(this.width,this.height,this.dpr); this.lastMotionAt = 0; this.quality.resetSamples(); this.clearPointers(); @@ -475,6 +515,7 @@ export class NetgraphRenderer implements ViewportProjector { } destroy(): void { + this.gpu.destroy(); window.removeEventListener(DISPLAY_EVENT, this.refreshAppearance); this.clearPointers(); this.stage.removeEventListener('pointerdown', this.handlePointerDown); @@ -736,6 +777,7 @@ export class NetgraphRenderer implements ViewportProjector { } private appendRoute(context: CanvasRenderingContext2D, route: RouteV2): void { + if (this.focusIDs && (!this.focusIDs.has(route.fromId) || !this.focusIDs.has(route.toId))) return; const from = this.screenPoint(route.fromId); const to = this.screenPoint(route.toId); if (!segmentNearViewport(from, to, this.width, this.height, 8)) return; @@ -817,17 +859,22 @@ export class NetgraphRenderer implements ViewportProjector { .filter(({ node, degree }) => ( node.id === this.selectedNodeID || node.id === this.hoveredNodeID + || displayPreferences().detail === 'complete' || this.scale >= 0.42 && degree >= (this.scale < 0.7 ? 18 : this.scale < 1.3 ? 8 : 3) )) .sort((left, right) => Number(right.node.id === this.selectedNodeID) - Number(left.node.id === this.selectedNodeID) || right.degree - left.degree) - .slice(0, this.scale < 0.7 ? 22 : this.scale < 1.3 ? 54 : 120); - context.font = '600 10px Inter, ui-sans-serif, system-ui, sans-serif'; + .slice(0, displayPreferences().detail === 'complete' ? 500 : this.scale < 0.7 ? 22 : this.scale < 1.3 ? 54 : 120); + const labelBoxes: LabelRect[] = []; + context.font = `600 ${displayPreferences().textSize === 'large' ? 13 : 11}px Inter, ui-sans-serif, system-ui, sans-serif`; context.textBaseline = 'middle'; for (const { node, point, radius } of labelCandidates) { const label = truncateLabel(node.label, 28); const width = context.measureText(label).width; const x = point.x + radius + 6; const y = point.y; + const box = {x:x-3,y:y-9,width:width+6,height:18}; + if (node.id !== this.selectedNodeID && labelBoxes.some(other => rectanglesOverlap(box,other,3))) continue; + labelBoxes.push(box); context.fillStyle = lightScene() ? 'rgba(247, 249, 241, 0.9)' : 'rgba(4, 14, 19, 0.78)'; roundRect(context, x - 3, y - 8, width + 6, 16, 5); context.fill(); @@ -878,6 +925,8 @@ export class NetgraphRenderer implements ViewportProjector { this.residueProjectionDirty = true; } context.clearRect(0, 0, this.width, this.height); + this.gpu.batch.begin(this.width, this.height); + this.stage.dataset.effectsRenderer = this.gpu.batch.ready ? 'webgl2' : 'canvas2d'; this.activeRoutes = this.activeRoutes.filter((route) => now - route.started <= route.duration + DESTINATION_BLOOM_MS); this.observerWakes = this.observerWakes.filter((wake) => now - wake.started <= 6_000); const residueCount = this.residue.length; @@ -888,7 +937,8 @@ export class NetgraphRenderer implements ViewportProjector { this.stage.dataset.focusedPacketEmphasis = '1'; for (const route of this.activeRoutes) this.drawActiveRoute(context, route, now); for (const wake of this.observerWakes) this.drawObserverWake(context, wake, now); - const regionFrames = activeRegionFrames(this.regionActivityCues, now, this.reducedMotionQuery.matches); + this.gpu.flush(this.width, this.height, ratio); + const regionFrames = activeRegionFrames(this.regionActivityCues, now, prefersReducedMotion()); const drawnRegionRoles = this.drawRegionActivity(context, regionFrames); this.stage.dataset.activePackets = String(this.activeRoutes.length + this.observerWakes.length); this.stage.dataset.residueCount = String(this.residue.length); @@ -896,9 +946,8 @@ export class NetgraphRenderer implements ViewportProjector { this.stage.dataset.activeRegionRoles = drawnRegionRoles.join(','); const visibleMotion = drawnRegionRoles.length > 0 || this.activeRoutes.some(({ packet }) => packet.segments.some((segment) => segmentNearViewport(this.screenPoint(segment.from.id), this.screenPoint(segment.to.id), this.width, this.height, 28))) - || this.observerWakes.some((wake) => this.pointVisible(this.screenPoint(wake.endpoint.id), 48)) - || this.residue.some((item) => segmentNearViewport(this.screenPoint(item.fromId), this.screenPoint(item.toId), this.width, this.height, 12)); - if (!this.reducedMotionQuery.matches && visibleMotion) { + || this.observerWakes.some((wake) => this.pointVisible(this.screenPoint(wake.endpoint.id), 48)); + if (!prefersReducedMotion() && visibleMotion) { if (this.lastMotionAt && this.quality.sample(now - this.lastMotionAt, performance.now() - started)) { this.stage.dataset.qualityMode = this.quality.mode; } @@ -941,6 +990,13 @@ export class NetgraphRenderer implements ViewportProjector { ) continue; const color = PACKET_KIND_COLORS[frame.kind]; + if (displayPreferences().detail === 'auto') { + const direction = frame.role === 'send' ? 'OUT' : frame.role === 'receive' ? 'IN' : 'LOCAL'; + context.beginPath(); context.arc(point.x,point.y-radius-7,3.5,0,Math.PI*2); + context.fillStyle = colorWithAlpha(color, .55 + frame.intensity*.4); context.fill(); + drawnRoles.push(`${regionTag.toUpperCase()}:${direction}`); + continue; + } const intensity = clamp(frame.intensity, 0, 1); const ringRadius = radius + 8 + frame.spread * (frame.longHaul ? 28 : 18); context.beginPath(); @@ -980,9 +1036,9 @@ export class NetgraphRenderer implements ViewportProjector { return drawnRoles; } - private drawResidue(context: CanvasRenderingContext2D, now: number): void { + private drawResidue(_context: CanvasRenderingContext2D, now: number): void { const interval = this.quality.mode === 'low' ? 500 : this.quality.mode === 'balanced' ? 250 : 125; - if (shouldRefreshResidueCache(this.residueCacheAt, now, this.residueProjectionDirty, this.reducedMotionQuery.matches && this.residueDirty, interval)) { + if (shouldRefreshResidueCache(this.residueCacheAt, now, this.residueProjectionDirty, prefersReducedMotion() && this.residueDirty, interval)) { this.residueContext.clearRect(0, 0, this.width, this.height); this.drawResidueLines(this.residueContext, now); this.residueCacheAt = now; @@ -991,27 +1047,8 @@ export class NetgraphRenderer implements ViewportProjector { } // The compositor retains this separate layer; do not copy a full-screen // canvas into the moving packet layer on every frame in Android WebView. - if (this.reducedMotionQuery.matches) return; - // Slow fading ink is cached; travelling sparkles still move every frame. - const sparkleCount = this.quality.mode === 'full' && !this.lowPowerQuery.matches ? 2 : 1; - const limit = this.quality.mode === 'low' ? 16 : this.quality.mode === 'balanced' ? 40 : this.lowPowerQuery.matches ? 96 : 160; - for (let itemIndex = Math.max(0, this.residue.length - limit); itemIndex < this.residue.length; itemIndex += 1) { - const residue = this.residue[itemIndex]!; - const from = this.screenPoint(residue.fromId); - const to = this.screenPoint(residue.toId); - if (!segmentNearViewport(from, to, this.width, this.height, 12)) continue; - const age = now - residue.addedAt; - const style = residueStyle(displayResidueAge(age)); - for (let index = 0; index < sparkleCount; index += 1) { - const progress = residueSparkleProgress(residue.routeId, age, index); - const spark = interpolateScreenPoint(from, to, progress); - const twinkle = 0.5 + 0.5 * Math.sin(age / 350 + index * 2.1); - context.beginPath(); - context.arc(spark.x, spark.y, 0.9 + style.hot * 0.8, 0, Math.PI * 2); - context.fillStyle = colorWithAlpha(residue.color, style.life * (0.24 + style.hot * 0.42) * (0.55 + twinkle * 0.45)); - context.fill(); - } - } + if (prefersReducedMotion()) return; + // Residue fades in place; only new packets travel along links. } private drawResidueLines(context: CanvasRenderingContext2D, now: number): void { @@ -1074,7 +1111,8 @@ export class NetgraphRenderer implements ViewportProjector { const emphasis = this.selectedNodeID && active.packet.segments.some((hop) => hop.from.id === this.selectedNodeID || hop.to.id === this.selectedNodeID) ? 1.3 : 1; if (emphasis > 1) this.stage.dataset.focusedPacketEmphasis = String(emphasis); const trail = packetTrail(from, head, clamp(Math.hypot(to.x - from.x, to.y - from.y) * 0.28, 18, 68) * displayPreferences().trailLength); - if (this.quality.mode === 'low') { + if (this.gpu.batch.packet([trail.tail, head], head, active.color, active.signature, emphasis)) return; + if (this.quality.mode === 'low' || displayPreferences().quality === 'economy' || effectStrength() === 0) { context.beginPath(); context.moveTo(trail.tail.x, trail.tail.y); context.lineTo(head.x, head.y); @@ -1125,7 +1163,7 @@ export class NetgraphRenderer implements ViewportProjector { context.fill(); } const glowRadius = (active.longHaul ? 15 : active.crossRegion ? 12 : 10) * emphasis; - context.globalAlpha = displayPreferences().glow; + context.globalAlpha = displayPreferences().glow * effectStrength(); context.drawImage(this.glowSprite(active.color), head.x - glowRadius, head.y - glowRadius, glowRadius * 2, glowRadius * 2); context.globalAlpha = 1; context.beginPath(); @@ -1189,6 +1227,7 @@ export class NetgraphRenderer implements ViewportProjector { private drawHandoff(context: CanvasRenderingContext2D, point: ScreenPoint, color: string, progress: number): void { if (!this.pointVisible(point, 28)) return; + if (this.gpu.batch.disc(point, 5 + progress * 10, color, (1-progress)*.8, 2)) return; context.beginPath(); context.arc(point.x, point.y, 5 + progress * 10, 0, Math.PI * 2); context.strokeStyle = colorWithAlpha(color, (1 - progress) * 0.8); @@ -1199,6 +1238,7 @@ export class NetgraphRenderer implements ViewportProjector { private drawDestination(context: CanvasRenderingContext2D, point: ScreenPoint, color: string, progress: number): void { if (!this.pointVisible(point, 28)) return; const opacity = Math.sin(Math.PI * clamp(progress, 0, 1)); + if (this.gpu.batch.disc(point, 7 + progress * 14, color, opacity*.8, 2)) return; context.beginPath(); context.arc(point.x, point.y, 7 + progress * 14, 0, Math.PI * 2); context.fillStyle = colorWithAlpha(color, opacity * 0.18); @@ -1213,7 +1253,7 @@ export class NetgraphRenderer implements ViewportProjector { const age = now - wake.started; const point = this.screenPoint(wake.endpoint.id); if (!this.pointVisible(point, 48)) return; - const radius = nodeWakeRadius(age, wake.signature, this.reducedMotionQuery.matches); + const radius = nodeWakeRadius(age, wake.signature, prefersReducedMotion()); const life = Math.pow(1 - clamp(age / 6_000, 0, 1), 2); context.beginPath(); context.arc(point.x, point.y, radius, 0, Math.PI * 2); @@ -1260,7 +1300,7 @@ export class NetgraphRenderer implements ViewportProjector { ...this.regionActivityCues.filter((cue) => cue.startedAt > now).map((cue) => cue.startedAt), ]; if (expiries.length === 0) return; - const nextExpiry = Math.min(...expiries); + const nextExpiry = Math.min(...expiries, this.residue.length ? now + 250 : Infinity); this.residueCleanupTimer = window.setTimeout(() => { this.residueCleanupTimer = 0; this.requestMotionFrame(); @@ -1274,6 +1314,7 @@ export class NetgraphRenderer implements ViewportProjector { } private screenPoint(nodeID: string): ScreenPoint { + if (this.focusIDs && !this.focusIDs.has(nodeID)) return {x:-1_000_000,y:-1_000_000}; const cached = this.screenPoints.get(nodeID); if (cached) return cached; const position = this.layout.positions.get(nodeID); @@ -1389,7 +1430,7 @@ export class NetgraphRenderer implements ViewportProjector { private animateView(centerX: number, centerY: number, scale: number): void { this.cancelViewAnimation(); - if (this.reducedMotionQuery.matches || this.quality.mode === 'low') { + if (prefersReducedMotion() || this.quality.mode === 'low') { this.centerX = centerX; this.centerY = centerY; this.scale = scale; @@ -1449,7 +1490,7 @@ export class NetgraphRenderer implements ViewportProjector { const [first, second] = this.pointers.values(); if (first && second && this.pinchStart) { const { distance, scale, anchor, offset } = this.pinchStart; - this.scale = clamp(scale * Math.hypot(second.x - first.x, second.y - first.y) / distance, 0.005, 8); + this.scale = clamp(scale * Math.hypot(second.x - first.x, second.y - first.y) / distance, 0.035, 8); this.centerX = anchor.x - ((first.x + second.x) / 2 - offset.x - this.width / 2) / this.scale; this.centerY = anchor.y - ((first.y + second.y) / 2 - offset.y - this.height / 2) / this.scale; this.dragMoved = true; @@ -1536,7 +1577,7 @@ export class NetgraphRenderer implements ViewportProjector { } zoomBy(factor: number): void { - this.animateView(this.centerX, this.centerY, clamp(this.scale * factor, 0.005, 8)); + this.animateView(this.centerX, this.centerY, clamp(this.scale * factor, 0.035, 8)); } private handleWheel = (event: WheelEvent): void => { @@ -1546,7 +1587,7 @@ export class NetgraphRenderer implements ViewportProjector { const x = event.clientX - rect.left; const y = event.clientY - rect.top; const anchor = this.screenToWorld(x, y); - const nextScale = clamp(this.scale * Math.exp(-event.deltaY * 0.0012), 0.005, 8); + const nextScale = clamp(this.scale * Math.exp(-event.deltaY * 0.0012), 0.035, 8); this.scale = nextScale; this.centerX = anchor.x - (x - this.width / 2) / nextScale; this.centerY = anchor.y - (y - this.height / 2) / nextScale; @@ -1556,12 +1597,12 @@ export class NetgraphRenderer implements ViewportProjector { private handleDoubleClick = (event: MouseEvent): void => { const rect = this.stage.getBoundingClientRect(); const anchor = this.screenToWorld(event.clientX - rect.left, event.clientY - rect.top); - this.animateView(anchor.x, anchor.y, clamp(this.scale * 1.65, 0.005, 8)); + this.animateView(anchor.x, anchor.y, clamp(this.scale * 1.65, 0.035, 8)); }; private handleMotionPreference = (): void => { - this.stage.dataset.motionMode = this.reducedMotionQuery.matches ? 'static' : 'animated'; - if (this.reducedMotionQuery.matches) this.activeRoutes = []; + this.stage.dataset.motionMode = prefersReducedMotion() ? 'static' : 'animated'; + if (prefersReducedMotion()) this.activeRoutes = []; else this.clearResidueCleanup(); this.requestMotionFrame(); }; diff --git a/web/src/packetAnimator.test.ts b/web/src/packetAnimator.test.ts index b7a7950..ffa04b0 100644 --- a/web/src/packetAnimator.test.ts +++ b/web/src/packetAnimator.test.ts @@ -235,6 +235,7 @@ describe('PacketAnimator motion preference lifecycle', () => { const map = { on: vi.fn(), off: vi.fn(), + getContainer: () => document.createElement('div'), project: vi.fn((coordinates: [number, number]) => ({ x: coordinates[0], y: coordinates[1] })), } as unknown as maplibregl.Map; const canvas = document.createElement('canvas'); @@ -305,6 +306,7 @@ describe('PacketAnimator motion preference lifecycle', () => { const map = { on: vi.fn(), off: vi.fn(), + getContainer: () => document.createElement('div'), project: vi.fn((coordinates: [number, number]) => ({ x: coordinates[0], y: coordinates[1] })), } as unknown as maplibregl.Map; const canvas = document.createElement('canvas'); @@ -349,6 +351,7 @@ describe('PacketAnimator motion preference lifecycle', () => { const map = { on: vi.fn(), off: vi.fn(), + getContainer: () => document.createElement('div'), project: vi.fn((coordinates: [number, number]) => ({ x: coordinates[0], y: coordinates[1] })), } as unknown as maplibregl.Map; const canvas = document.createElement('canvas'); diff --git a/web/src/packetAnimator.ts b/web/src/packetAnimator.ts index 7aaddfa..a194afd 100644 --- a/web/src/packetAnimator.ts +++ b/web/src/packetAnimator.ts @@ -1,5 +1,6 @@ +import { mapEffects } from './gpuEffects'; import { stableHash as stableVisualHash } from './trafficVisuals'; -import { canvasColorWithAlpha as withAlpha, displayColor, displayPreferences, displayResidueAge, lightScene, lineDash, residueLifetime } from './displayPreferences'; +import { canvasColorWithAlpha as withAlpha, displayColor, displayPreferences, displayResidueAge, lightScene, lineDash, residueLifetime, prefersReducedMotion, effectStrength } from './displayPreferences'; import type * as maplibregl from 'maplibre-gl'; import type { EndpointV2, ObserverPacketEventV2, PacketView, RoutePacketView, RouteSegmentView } from './types'; import { TerrainProjector, surfaceArc, surfacePathPoint, surfaceTrail, traceSurfacePath, type SurfacePoint } from './terrainProjection'; @@ -282,6 +283,7 @@ export function capNewest(items: readonly T[], limit: number): T[] { export class PacketAnimator { readonly projection: TerrainProjector; + private readonly gpu: ReturnType; private readonly context: CanvasRenderingContext2D; private readonly residueCanvas: HTMLCanvasElement; private readonly residueContext: CanvasRenderingContext2D; @@ -306,6 +308,7 @@ export class PacketAnimator { constructor(private readonly map: maplibregl.Map, private readonly canvas: HTMLCanvasElement) { this.projection = new TerrainProjector(map); + this.gpu = mapEffects(map); const context = canvas.getContext('2d'); if (!context) throw new Error('Canvas2D is unavailable'); this.context = context; @@ -315,7 +318,7 @@ export class PacketAnimator { this.residueContext = residueContext; this.reducedMotionQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); this.lowPowerQuery = window.matchMedia('(max-width: 620px), (pointer: coarse)'); - this.reducedMotion = this.reducedMotionQuery.matches; + this.reducedMotion = prefersReducedMotion(); this.lowPower = this.lowPowerQuery.matches; if (this.reducedMotion) this.reducedModeStartedAt = performance.now(); this.updateMotionMode(); @@ -393,6 +396,7 @@ export class PacketAnimator { this.frameId = 0; this.residueTimer = undefined; this.clearCanvas(); + this.gpu.batch.clear(); this.gpu.flush(); this.clearResidueCanvas(); } else { this.requestFrame(); @@ -400,6 +404,7 @@ export class PacketAnimator { } refreshAppearance(): void { + this.handleReducedMotionChange({ matches: prefersReducedMotion() } as MediaQueryListEvent); this.residueContentDirty = true; this.residueProjectionDirty = true; this.canvas.dataset.routePreset = displayPreferences().preset; @@ -415,6 +420,7 @@ export class PacketAnimator { this.map.off('terrain', this.handleMapMove); this.map.off('sourcedata', this.handleTerrainData); this.projection.reset(); + this.gpu.destroy(); } private resize(): void { @@ -438,9 +444,10 @@ export class PacketAnimator { } private handleReducedMotionChange = (event: MediaQueryListEvent): void => { - if (this.reducedMotion === event.matches) return; + const reduced = displayPreferences().motion === 'system' ? event.matches : prefersReducedMotion(); + if (this.reducedMotion === reduced) return; const now = performance.now(); - this.reducedMotion = event.matches; + this.reducedMotion = reduced; if (this.reducedMotion) { this.reducedModeStartedAt = now; for (const route of this.activeRoutes) { @@ -521,6 +528,9 @@ export class PacketAnimator { this.frameId = 0; if (this.paused) return; this.clearCanvas(); + this.gpu.prepare(); + this.gpu.batch.begin(this.canvas.width / this.dpr, this.canvas.height / this.dpr); + this.canvas.dataset.effectsRenderer = this.gpu.batch.ready ? 'webgl2' : 'canvas2d'; if (!this.reducedMotion) { for (const route of this.activeRoutes) this.completeRoute(route, now); } @@ -542,7 +552,6 @@ export class PacketAnimator { this.context.globalCompositeOperation = 'source-over'; this.context.lineCap = 'round'; if (!this.reducedMotion) { - this.drawResidueSparkles(now); for (const item of this.nodeWakes) this.drawNodeWake(this.context, item, now); } this.activeRoutes = this.activeRoutes.filter( @@ -552,10 +561,13 @@ export class PacketAnimator { (item) => now - item.started < OBSERVER_PING_MS, ); this.updateMotionMode(); + this.canvas.dataset.residueCount = String(this.residue.length); + this.canvas.dataset.activeCount = String(this.activeRoutes.length + this.activeObservers.length); for (const route of this.activeRoutes) this.drawRoute(route, now); for (const observer of this.activeObservers) this.drawObserver(observer, now); this.context.restore(); - if (!this.reducedMotion && this.hasVisibleEffects()) this.requestFrame(); + this.gpu.flush(); + if (!this.reducedMotion && (this.activeRoutes.length || this.activeObservers.length || this.nodeWakes.length)) this.requestFrame(); else this.requestTimedFrame(now); } @@ -599,30 +611,6 @@ export class PacketAnimator { context.setLineDash([]); } - private drawResidueSparkles(now: number): void { - if (this.map.getZoom() < 5 || displayPreferences().glow < 0.25) return; - const quality = this.qualityMode(); - const count = quality === 'full' ? 3 : quality === 'balanced' ? 2 : 1; - const limit = quality === 'full' ? 160 : quality === 'balanced' ? 120 : 96; - for (const item of this.residue.slice(-limit)) { - const path = this.projection.projectSegment(item.segment); - const style = residueStyle(displayResidueAge(now - item.addedAt)); - if (style.life <= 0.025) continue; - const age = Math.max(0, now - item.addedAt); - const sparkleCount = Math.min(4, count + (item.longHaul ? 1 : 0)); - for (let index = 0; index < sparkleCount; index += 1) { - const progress = residueSparkleProgress(item.segment.routeId, age, index); - const point = surfacePathPoint(path, progress); - const twinkle = 0.32 + 0.68 * Math.abs(Math.sin(age / 240 + index * 2.1)); - const radius = quality === 'low' ? 0.85 : 0.9 + index * 0.12; - this.context.fillStyle = withAlpha(item.color, style.life * twinkle * 0.82); - this.context.beginPath(); - this.context.arc(point.x, point.y, radius * (point.scale ?? 1), 0, Math.PI * 2); - this.context.fill(); - } - } - } - private drawNodeWake(context: CanvasRenderingContext2D, item: NodeWake, now: number): void { const life = nodeWakeLife(now - item.addedAt); if (life <= 0) return; @@ -704,6 +692,8 @@ export class PacketAnimator { } } this.canvas.dataset.projectionSamples = String(path.length); + const gpuDrawn = this.gpu.batch.packet(points, head, item.color, item.signature, item.longHaul ? 1.15 : 1); + if (!gpuDrawn) { this.drawProgressiveTrail(trail, item.color, quality, item.longHaul); if (quality !== 'low') { this.drawTrailSparks(trail, item.color, item.packet.id, elapsed, quality === 'full' ? 3 : 2); @@ -714,6 +704,7 @@ export class PacketAnimator { if (item.longHaul) this.drawLongHaulMarker(head, tangent, item.color, elapsed); } } + } const first = item.packet.segments[0]; if (first) this.drawBloom( this.point(first.from), @@ -757,7 +748,7 @@ export class PacketAnimator { const width = quality === 'full' ? 7.2 : quality === 'balanced' ? 5.8 : 3.8; this.context.lineWidth = width * (longHaul ? 1.42 : 1) * (trail.head.scale ?? 1); traceSurfacePath(this.context, trail.points ?? [trail.tail, trail.head]); - this.context.globalAlpha = displayPreferences().glow; + this.context.globalAlpha = displayPreferences().glow * effectStrength(); this.context.stroke(); this.context.globalAlpha = 1; const core = this.context.createLinearGradient(trail.tail.x, trail.tail.y, trail.head.x, trail.head.y); @@ -861,7 +852,7 @@ export class PacketAnimator { this.context.fillStyle = glow; this.context.beginPath(); this.context.arc(point.x, point.y, radius, 0, Math.PI * 2); - this.context.globalAlpha = displayPreferences().glow; + this.context.globalAlpha = displayPreferences().glow * effectStrength(); this.context.fill(); this.context.globalAlpha = 1; this.context.fillStyle = displayColor(color); @@ -961,6 +952,7 @@ export class PacketAnimator { ): void { if (timing.opacity <= 0) return; const radius = startRadius + (endRadius - startRadius) * easeOutCubic(timing.progress); + if (this.gpu.batch.disc(point, radius * .75, color, timing.opacity * .8, 2, true)) return; if (simple) { this.context.strokeStyle = withAlpha(color, timing.opacity * 0.58); this.context.lineWidth = 1.4; @@ -986,6 +978,7 @@ export class PacketAnimator { timing: { progress: number; opacity: number }, ): void { if (timing.opacity <= 0) return; + if (this.gpu.batch.disc(point, 5 + timing.progress * 8, color, timing.opacity * .8, 2, true)) return; const angle = Math.atan2(toward.y - point.y, toward.x - point.x); const radius = 3 + easeOutCubic(timing.progress) * 5; this.context.strokeStyle = withAlpha(color, timing.opacity * 0.78); @@ -1008,6 +1001,7 @@ export class PacketAnimator { ): void { if (timing.opacity <= 0) return; const radius = 5 + easeOutCubic(timing.progress) * (simple ? (longHaul ? 12 : 8) : longHaul ? 22 : 14); + if (this.gpu.batch.disc(point, radius, color, timing.opacity * .85, 2, true)) return; this.context.strokeStyle = withAlpha(color, timing.opacity * 0.72); this.context.lineWidth = simple ? (longHaul ? 1.55 : 1.2) : longHaul ? 1.9 : 1.5; this.context.beginPath(); @@ -1075,7 +1069,8 @@ export class PacketAnimator { } private qualityMode(): VisualQuality { - return visualQuality(this.lowPower, this.activeRoutes.length, this.activeObservers.length); + if (displayPreferences().quality === 'economy' || displayPreferences().effects === 'minimal') return 'low'; + return visualQuality(displayPreferences().quality === 'high' ? false : this.lowPower, this.activeRoutes.length, this.activeObservers.length); } private packetNearViewport(packet: PacketView): boolean { diff --git a/web/src/routeLayer.test.ts b/web/src/routeLayer.test.ts index b98ddc9..dec09c0 100644 --- a/web/src/routeLayer.test.ts +++ b/web/src/routeLayer.test.ts @@ -53,6 +53,11 @@ describe('historical route WebGL geometry', () => { const vertices = historicalRouteVertices(routes); expect(vertices).toHaveLength(routes.length * 6 * STROKE_VERTEX_FLOATS); + expect(Array.from({length:6},(_,i)=>[vertices[i*STROKE_VERTEX_FLOATS+6],vertices[i*STROKE_VERTEX_FLOATS+7]])).toEqual([[0,-1],[1,-1],[1,1],[0,-1],[1,1],[0,1]]); + for(let i=1;i<6;i++) { + expect(vertices.slice(i*STROKE_VERTEX_FLOATS,i*STROKE_VERTEX_FLOATS+6)).toEqual(vertices.slice(0,6)); + expect(vertices.slice(i*STROKE_VERTEX_FLOATS+8,(i+1)*STROKE_VERTEX_FLOATS)).toEqual(vertices.slice(8,STROKE_VERTEX_FLOATS)); + } expect(vertices[12]).toBe(0); expect(vertices[6 * STROKE_VERTEX_FLOATS + 12]).toBe(3); expect([...vertices].every(Number.isFinite)).toBe(true); diff --git a/web/src/routeLayer.ts b/web/src/routeLayer.ts index ed727ae..d1f8111 100644 --- a/web/src/routeLayer.ts +++ b/web/src/routeLayer.ts @@ -239,6 +239,10 @@ export class HistoricalRouteLayer implements CustomLayerInterface { const opacity = Math.max(this.lightBackground ? 0.95 : 0.55, rawOpacity); const pieces = splitWorldRoute([rawFrom[0]!, rawFrom[1]!], [rawTo[0]!, rawTo[1]!]); for (const pair of pieces) { + if (!terrain) { + segments.push({from:position(pair[0]),to:position(pair[1]),color,opacity,band}); + continue; + } let fractions = [0, 1]; if (terrain) { // Reject distant routes before MapLibre's terrain-aware projection, @@ -366,7 +370,7 @@ export class HistoricalRouteLayer implements CustomLayerInterface { gl.uniform1f(uniforms.glow!, denseOverview ? 0 : settings.glow * clamp((this.map.getZoom() - 3) / 7, 0.15, 1)); gl.uniform1f(uniforms.outline!, denseOverview ? 0 : 1); gl.uniform1f(uniforms.simple!, denseOverview ? 1 : 0); - gl.uniform1f(uniforms.opacity!, this.opacity); + gl.uniform1f(uniforms.opacity!, this.opacity * (settings.detail === 'auto' ? clamp(1 - Math.log2(1 + this.routes.length / 150) * .1, .38, .9) : 1)); gl.uniform1f(uniforms.maximum_band!, this.maximumBand); gl.uniform1f(uniforms.pattern!, settings.pattern === 'dashed' ? 1 : settings.pattern === 'dotted' ? 2 : 0); if (this.lightBackground) gl.uniform3f(uniforms.casing!, 0.97, 0.99, 0.95); @@ -460,9 +464,16 @@ function strokeVertices(segments: readonly StrokeSegment[], origin: [number, num let offset = 0; for (const segment of segments) { const color = parseColor(segment.color); - for (const [along, side] of [[0, -1], [1, -1], [1, 1], [0, -1], [1, 1], [0, 1]]) { - data.set([...segment.from.map((v, i) => v - origin[i]!), ...segment.to.map((v, i) => v - origin[i]!), along!, side!, ...color, segment.opacity, segment.band], offset); - offset += STROKE_VERTEX_FLOATS; + // A stroke's six vertices share endpoints, colour and age. Pack those once; + // copy the typed values and change only each triangle corner. + const first=offset; + for(let axis=0;axis<3;axis++) { data[first+axis]=segment.from[axis]!-origin[axis]!;data[first+3+axis]=segment.to[axis]!-origin[axis]!;data[first+8+axis]=color[axis]!; } + data[first+11]=segment.opacity;data[first+12]=segment.band; + for(let vertex=0;vertex<6;vertex++) { + if(vertex)data.copyWithin(offset,first,first+STROKE_VERTEX_FLOATS); + data[offset+6]=vertex===1||vertex===2||vertex===4?1:0; + data[offset+7]=vertex===2||vertex===4||vertex===5?1:-1; + offset+=STROKE_VERTEX_FLOATS; } } return data; diff --git a/web/src/selection.ts b/web/src/selection.ts new file mode 100644 index 0000000..6c6b486 --- /dev/null +++ b/web/src/selection.ts @@ -0,0 +1,26 @@ +/** Selection contains only the already-public node ID, never packet identifiers. */ +export function requestedNode(): string | null { + const value = new URL(location.href).searchParams.get('node'); + return value && value.length <= 128 && !/[\u0000-\u001f]/.test(value) ? value : null; +} +export function rememberNode(id: string | null): void { + const url = new URL(location.href); + if (id) url.searchParams.set('node', id); else url.searchParams.delete('node'); + history.replaceState(history.state, '', url); + for (const link of document.querySelectorAll('.dock-views a')) { + const target = new URL(link.href); + if (target.pathname !== '/' && !target.pathname.startsWith('/netgraph')) continue; + if (id) target.searchParams.set('node', id); else target.searchParams.delete('node'); + link.href = target.pathname + target.search; + } +} +export function replaceInspector(host: HTMLElement, content: HTMLElement): void { + const active = document.activeElement instanceof HTMLElement && host.contains(document.activeElement) ? document.activeElement : null; + const id = active?.closest('button')?.dataset.nodeId; + const label = active?.getAttribute('aria-label'); + host.replaceChildren(content); + if (!active) return; + const target = id ? [...host.querySelectorAll('button[data-node-id]')].find(button => button.dataset.nodeId === id) + : [...host.querySelectorAll('[aria-label]')].find(element => element.getAttribute('aria-label') === label); + (target ?? host.querySelector('button'))?.focus({preventScroll:true}); +} diff --git a/web/src/soundPreview.ts b/web/src/soundPreview.ts new file mode 100644 index 0000000..55da959 --- /dev/null +++ b/web/src/soundPreview.ts @@ -0,0 +1,10 @@ +import type { RouteSonifier } from './audio'; +export function mountSoundPreview(sonifier: RouteSonifier, parent: HTMLElement): void { + const controls = document.createElement('div'); controls.className = 'sound-preview-actions'; + const preview = document.createElement('button'); preview.type='button'; preview.textContent='Preview voice'; preview.className='control-button'; + preview.disabled=!sonifier.supported(); + preview.addEventListener('click', async () => { preview.disabled=true; try { await sonifier.preview(); } catch { preview.textContent='Preview unavailable'; } finally { window.setTimeout(()=>{preview.disabled=!sonifier.supported();},700); } }); + const mute=document.createElement('button'); mute.type='button';mute.textContent='Mute';mute.className='control-button'; + mute.addEventListener('click',()=>{void sonifier.setEnabled(false);}); + controls.append(preview,mute);parent.append(controls); +}