CartoLite Server 0.6.0: cinematic Map and Netgraph (#17)

* Build cinematic display controls and shared GPU packet effects

* Add scoped Follow and adaptive scene hierarchy

* Add explicit sound preview and streamline map chrome

* Add Netgraph live director and cinematic acceptance

* Refine cinematic focus and renderer lifecycle

* Fix menu history races and Follow inspection

* Prepare Worldwide 0.6.0 cinematic release candidate

* Fix mobile layer positioning and verify composited packet colors

* Validate each visual scene independently and retain bounded CI evidence

* Restore worldwide grouping and adapt GPU effects to frame cost

* Bound GPU presentation cost without dropping packet motion

* Keep dock controls reachable and avoid redundant backdrop rendering

* Keep phone legends and touch targets clear of the dock

* Keep Netgraph Follow scoped, readable and steady

* Retain mobile reset and bound dense-scene presentation cost

* Keep traffic controls and node details legible in landscape

* Focus Finder within its opening gesture to prevent menu races

* Frame Follow above its controls and release owned graphics contexts

* Verify map dismissal after the camera gesture settles

* Pack shared route attributes once per segment

* Keep map attribution accessible above the mobile dock

* Remove inactive map effect layers and sample packet lifetimes deterministically

* Synchronize dense-scene fallback even when packet animation is idle

* Verify effect-layer ownership with complete map test doubles
This commit is contained in:
n30nex authored and GitHub committed 2026-09-27 02:55:30 -04:00
1 parent abfcebb5e8
commit d5bd53a29c
41 files changed
+1225 -181

No files matched your search

+1 -1
View File
@@ -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.
+10
View File
@@ -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.
+19
View File
@@ -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.
+1 -1
View File
@@ -1 +1 @@
0.5.1
0.6.0
+1 -1
View File
@@ -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"
+16
View File
@@ -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.
+126
View File
@@ -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();
});
+5 -2
View File
@@ -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');
+36 -29
View File
@@ -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<void>(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<number> {
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<Buffer> {
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<number> {
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;i<pixels!.length;i+=4) {
if(Math.abs(pixels![i]!-base![i]!)+Math.abs(pixels![i+1]!-base![i+1]!)+Math.abs(pixels![i+2]!-base![i+2]!)<20)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++;
}
return found;
}, light);
},{before:before.toString('base64'),after:after.toString('base64'),light});
}
async function changedPixels(page: Page, before: Buffer, after: Buffer): Promise<number> {
+12 -1
View File
@@ -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<void> {
Object.defineProperty(window, 'AudioContext', { configurable: true, value: TestAudioContext });
});
}
async function gpuHasPixels(page: Page): Promise<boolean> {
return page.locator('.gpu-packet-canvas').evaluate(element => new Promise<boolean>(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));
})));
}
+1 -1
View File
@@ -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();
+4 -4
View File
@@ -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);
+1 -1
View File
@@ -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) => {
+3 -3
View File
@@ -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) => {
+14 -6
View File
@@ -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', '');
+2 -2
View File
@@ -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"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "cartolite-server-web",
"version": "0.5.1",
"version": "0.6.0",
"private": true,
"type": "module",
"scripts": {
+3 -1
View File
@@ -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: [
+12
View File
@@ -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', () => {
+12
View File
@@ -322,6 +322,18 @@ export class RouteSonifier {
return notes.length;
}
/** Explicit preview gesture; does not enable or save live sound. */
async preview(): Promise<void> {
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();
+112
View File
@@ -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:'©'; }
+21
View File
@@ -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);
});
+139
View File
@@ -0,0 +1,139 @@
/** A single dock, with existing control handlers and accessible names preserved. */
export function mountCinematicDock(): void {
const controls = document.querySelector<HTMLElement>('.controls');
const topbar = document.querySelector<HTMLElement>('#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<HTMLAnchorElement>('#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<HTMLAnchorElement>('#labs-link');
if (labs) { labs.classList.add('control-button'); navigation.append(labs); }
controls.prepend(navigation);
const zoomControls = document.querySelector<HTMLElement>('.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<HTMLDetailsElement>('#map-appearance');
const layers = document.querySelector<HTMLElement>('#layers-disclosure');
if (!layers?.hasAttribute('open')) document.querySelector<HTMLButtonElement>('#layers-summary')?.click();
if (disclosure) { disclosure.open = true; disclosure.scrollIntoView({ block: 'nearest' }); disclosure.querySelector<HTMLElement>('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<HTMLAnchorElement>('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<HTMLButtonElement>('button[aria-expanded="true"][aria-controls]')) {
if(button.getAttribute('aria-controls') !== target) button.click();
}
if(target !== 'follow-card') document.querySelector<HTMLButtonElement>('#follow-card:not([hidden]) #follow-close')?.click();
if(!target) document.querySelector<HTMLButtonElement>('#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<HTMLButtonElement>('button[aria-controls]');
if (!owner || owner.getAttribute('aria-expanded') !== 'true') return;
closing = true;
for (const button of controls.querySelectorAll<HTMLButtonElement>('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 = '<span>Quick views</span><div><button type="button" data-combination="network">Network</button><button type="button" data-combination="activity">Activity</button><button type="button" data-combination="landscape">Landscape</button><button type="button" data-undo hidden>Undo</button></div><p>Changes layers only. Your camera and sound stay as they are.</p>';
(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<HTMLButtonElement>('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<HTMLButtonElement>('[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<HTMLElement>(selector);
if(chrome&&chrome.getClientRects().length)top=Math.max(top,chrome.getBoundingClientRect().bottom-box.top+16);
}
const dock=document.querySelector<HTMLElement>('.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/2<box.left+width/2)left=Math.max(left,bounds.right-box.left+24);
else right=Math.min(right,bounds.left-box.left-24);
}
top=Math.min(top,height-48);bottom=Math.max(top+24,bottom);
return {left,right:Math.max(left+48,right),top,bottom};
}
+6
View File
@@ -1,4 +1,5 @@
import './display.css';
import './cinematic.css';
import { DEFAULT_DISPLAY, DISPLAY_EVENT, ROUTE_PRESETS, displayPreferences, initializeDisplay, updateDisplay, type DisplayPreferences } from './displayPreferences';
export function mountDisplayControls(parent: HTMLElement, sceneControls = false): void {
@@ -7,6 +8,11 @@ export function mountDisplayControls(parent: HTMLElement, sceneControls = false)
section.className = 'display-settings';
section.setAttribute('aria-label', 'Shared display settings');
section.innerHTML = `${sceneControls ? `<label>Scene<select data-display="basemap" aria-label="Scene style"><option value="dark">Night</option><option value="light">Daylight</option><option value="streets">Paper</option></select></label><label>Interface<select data-display="theme" aria-label="Interface theme"><option value="map">Match scene</option><option value="dark">Dark</option><option value="light">Light</option></select></label>` : ''}
<label>Effects<select data-display="effects" aria-label="Effects intensity"><option value="spectacle">Spectacle</option><option value="calm">Calm</option><option value="minimal">Minimal</option></select></label>
<label>Detail<select data-display="detail" aria-label="Detail level"><option value="auto">Automatic</option><option value="complete">Complete</option></select></label>
<label>Graphics<select data-display="quality" aria-label="Graphics quality"><option value="auto">Automatic</option><option value="high">High</option><option value="economy">Economy</option></select></label>
<label>Motion<select data-display="motion" aria-label="Motion preference"><option value="system">Use system setting</option><option value="full">Full</option><option value="reduced">Reduced</option></select></label>
<label>Text<select data-display="textSize" aria-label="Text size"><option value="standard">Standard</option><option value="large">Large</option></select></label>
<label>Route style<select data-display="preset" aria-label="Route style">${Object.keys(ROUTE_PRESETS).map((id) => `<option value="${id}">${id[0]!.toUpperCase()}${id.slice(1)}</option>`).join('')}<option value="custom">Custom</option></select></label>
<div class="route-preview" aria-label="Silent route style preview"><i></i><b></b><span>Preview · silent</span></div>
<details class="display-advanced"><summary>Advanced route styling</summary>
+6
View File
@@ -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 });
+28 -1
View File
@@ -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<string, string> = {
@@ -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' };
}
+194
View File
@@ -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;i<points.length;i++) {
const a=points[i-1]!, b=points[i]!; if(b.breakBefore) continue;
const length=Math.hypot(b.x-a.x,b.y-a.y); if(length<.01) continue;
const width=radius*.55, nx=-(b.y-a.y)/length*width, ny=(b.x-a.x)/length*width;
this.quad([{x:a.x+nx,y:a.y+ny},{x:b.x+nx,y:b.y+ny},{x:b.x-nx,y:b.y-ny},{x:a.x-nx,y:a.y-ny}],color,.95,3,Math.max(.06,(i-1)/Math.max(1,points.length-1)));
}
if(glow) this.disc(head,radius*3.2,color,glow,1);
const shape=signature==='orbit'?4:signature==='tick'?5:0;
this.disc(head,radius+1.25,scenePalette().outline,.9,shape);
this.disc(head,radius,color,1,shape);
this.disc(head,Math.max(.8,radius*.3),scenePalette().core,.95);
if(signature==='ripple') this.disc(head,radius*2,color,.65,2);
if(signature==='double') this.disc({x:head.x+radius*1.7,y:head.y},radius*.48,color,.9,4);
if(signature==='echo' && points.length>1) 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();}};
}
+16 -1
View File
@@ -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);
+25 -2
View File
@@ -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;
});
}
+64 -12
View File
@@ -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<void> {
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<HTMLButtonElement>('layers-close').addEventListener('click', () => { setLayersOpen(false); layersSummary.focus(); });
void requestScreenAwake();
mountCinematicDock();
void start();
async function start(): Promise<void> {
@@ -249,6 +255,7 @@ async function start(): Promise<void> {
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<void> {
updateDisplay({ ...DEFAULT_DISPLAY });
applyAppearance();
});
mountLayerCombinations();
applyAppearance();
routeWindow.value = uiPreferences.routeWindow;
liveMap.setRouteWindow(uiPreferences.routeWindow);
@@ -385,6 +393,7 @@ async function start(): Promise<void> {
pauseLiveFollow();
liveMap.selectNodeByID(nodeID, true);
closeFindPanel();
document.querySelector<HTMLElement>('#node-inspector-sheet .node-inspector-close')?.focus({preventScroll:true});
if (activeViewClass === 'mobile') setLayersOpen(false);
},
dismiss() {
@@ -401,7 +410,7 @@ async function start(): Promise<void> {
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> {
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<void> {
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<select aria-label="Follow scope"><option value="everywhere">Everywhere</option><option value="area">This area</option><option value="node">Selected node</option></select>';
followCard.querySelector('header')!.after(followScopeControl);
const scopeSelect = followScopeControl.querySelector('select')!;
const actions = document.createElement('div'); actions.className = 'follow-actions';
actions.innerHTML = '<button id="follow-next" type="button">Next</button><button id="follow-inspect" type="button" disabled>Inspect</button>';
followCard.append(actions);
const inspectFollow = actions.querySelector<HTMLButtonElement>('#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<void> {
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<void> {
};
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<void> {
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<void> {
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<HTMLButtonElement>('follow-close').addEventListener('click', () => setLiveFollow(false));
@@ -545,7 +597,7 @@ async function start(): Promise<void> {
: 'home-no-activity';
} else {
mapElement.dataset.viewSource = 'home';
liveMap.home(initial.nodes);
liveMap.home(initial.nodes, false);
}
liveMap.map.on('moveend', () => {
+35 -22
View File
@@ -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<string, unknown>();
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;
+45
View File
@@ -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; }
+47
View File
@@ -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='<header><strong>Live Follow</strong><button type="button" data-hold>Hold</button><button type="button" id="follow-close" data-close aria-label="Close Live Follow">×</button></header><label class="follow-scope">Follow area<select aria-label="Follow scope"><option value="all">Everywhere</option><option value="area">This area</option><option value="node">Selected node</option></select></label><div class="follow-copy"><strong data-title>Waiting for activity</strong><span data-detail>New live packets only</span></div><footer><span data-state>Waiting for activity</span><output aria-label="Seconds until next activity"></output></footer><progress max="10" value="0" aria-label="Time remaining on this activity"></progress><div class="follow-actions"><button type="button" data-next>Next</button><button type="button" data-inspect disabled>Inspect</button></div>';
const app=document.querySelector<HTMLElement>('.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<HTMLButtonElement>('[data-hold]')!;
const inspect=card.querySelector<HTMLButtonElement>('[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&&current)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();}};
}
+33 -6
View File
@@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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 = '<span>Focus</span><select aria-label="Topology focus"><option value="all">Show all</option><option value="area">Selected area</option><option value="component">Selected component</option></select><small role="status"></small>';
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<void> {
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<void> {
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<void> {
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<HTMLElement>('.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<void> {
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<T extends HTMLElement>(id: string): T {
function clamp(value: number, minimum: number, maximum: number): number {
return Math.max(minimum, Math.min(maximum, value));
}
mountCinematicDock();
+87 -46
View File
@@ -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<typeof canvasEffects>;
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<string> | 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<typeof p> => !!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();
};
+3
View File
@@ -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');
+28 -33
View File
@@ -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<T>(items: readonly T[], limit: number): T[] {
export class PacketAnimator {
readonly projection: TerrainProjector;
private readonly gpu: ReturnType<typeof mapEffects>;
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 {
+5
View File
@@ -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);
+15 -4
View File
@@ -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;
+26
View File
@@ -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<HTMLAnchorElement>('.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<HTMLButtonElement>('button')?.dataset.nodeId;
const label = active?.getAttribute('aria-label');
host.replaceChildren(content);
if (!active) return;
const target = id ? [...host.querySelectorAll<HTMLButtonElement>('button[data-node-id]')].find(button => button.dataset.nodeId === id)
: [...host.querySelectorAll<HTMLElement>('[aria-label]')].find(element => element.getAttribute('aria-label') === label);
(target ?? host.querySelector<HTMLElement>('button'))?.focus({preventScroll:true});
}
+10
View File
@@ -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);
}