mirror of
https://github.com/n30nex/CartoLite-Server.git
synced 2026-10-23 19:22:42 +00:00
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:
1 parent
abfcebb5e8
commit
d5bd53a29c
41 files changed
+1225
-181
No files matched your search
+1
-1
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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));
|
||||
})));
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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', '');
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cartolite-server-web",
|
||||
"version": "0.5.1",
|
||||
"version": "0.6.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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:'©'; }
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
@@ -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();}};
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
@@ -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&¤t)clearActivity();card.querySelector('output')!.value=remaining?`${remaining}s`:'';card.querySelector('progress')!.value=remaining;
|
||||
card.querySelector('[data-state]')!.textContent=remaining?'Next activity in':'Waiting for activity';
|
||||
}
|
||||
const dismiss=(event:KeyboardEvent)=>{
|
||||
if(event.key!=='Escape'||!enabled||document.querySelector('.controls button[aria-expanded="true"]'))return;
|
||||
event.preventDefault();event.stopImmediatePropagation();setEnabled(false);button.focus();
|
||||
};
|
||||
document.addEventListener('keydown',dismiss);
|
||||
const timer=window.setInterval(tick,250);
|
||||
return {offer(packet:PacketView){if(!enabled)return;const points=followEndpoints(packet);if(matches && (scope.value==='node'?!points.some(p=>matches!(p.id)):!points.every(p=>matches!(p.id))))return;const nearby=points.some(point=>{const p=graph.projectEndpoint(point);return p.x>=0&&p.x<=stage.clientWidth&&p.y>=0&&p.y<=stage.clientHeight;});queue.offer(packet,nearby?2:0,Date.now());tick();},destroy(){clearInterval(timer);document.removeEventListener('keydown',dismiss);stage.removeEventListener('pointerdown',pause);stage.removeEventListener('wheel',pause);stage.removeEventListener('keydown',pause);card.remove();button.remove();}};
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user