Restore worldwide grouping and adapt GPU effects to frame cost

This commit is contained in:
n30nex committed 2026-09-27 00:11:37 -04:00
1 parent 2c3981b4c2
commit 0d4cd37723
9 files changed
+69 -68

No files matched your search

+9
View File
@@ -59,6 +59,9 @@ test('selected public node stays selected across views and browser Back closes m
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}) => {
@@ -70,3 +73,9 @@ test('rapidly reopening Finder after selection keeps its input available', async
}
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'); }
});
+1 -1
View File
@@ -115,7 +115,7 @@ 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(80); // Flush the store's batched update while virtual time is paused.
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.fastForward(10_000);
+6
View File
@@ -71,3 +71,9 @@
:root[data-cinematic] .controls #layers-panel { height:min(750px,calc(100dvh - 150px)); }
@media(max-width:900px),(pointer:coarse) { :root[data-cinematic] #app[data-follow-inspect="true"]:has(#node-inspector-sheet:not([hidden])) .follow-card { visibility:hidden; } }
:root[data-cinematic][data-text-size="large"] .control-button { font-size:15px; }
:root[data-text-size="large"] #layers-panel .control-button { font-size:15px; }
: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); }
: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) { :root[data-cinematic] :is(.node-inspector-sheet,.follow-card,.legend,.route-legend) { bottom:calc(var(--dock-height) + var(--dock-bottom) + 12px); } }
+7 -1
View File
@@ -20,6 +20,10 @@ export function mountCinematicDock(): void {
const labs = topbar.querySelector<HTMLAnchorElement>('#labs-link');
if (labs) { labs.classList.add('control-button'); navigation.append(labs); }
controls.prepend(navigation);
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';
@@ -53,6 +57,7 @@ export function mountCinematicDock(): void {
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', () => {
@@ -70,7 +75,8 @@ export function mountCinematicDock(): void {
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(),{once:true});
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 => {
+19 -2
View File
@@ -39,9 +39,12 @@ export class GPUEffects {
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.program && !!this.gl && !this.gl.isContextLost(); }
get ready(): boolean { return this.enabled && !!this.program && !!this.gl && !this.gl.isContextLost(); }
initialize(gl: WebGL2RenderingContext): void {
this.dispose(); this.gl = gl;
@@ -143,12 +146,26 @@ export class GPUEffects {
export function mapEffects(map: LibreMap) {
const batch=new GPUEffects(); let activeGL: WebGL2RenderingContext|undefined;
let lastInk=false, requestedAt=0, slowFrames=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;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 attach=()=>{if(map.isStyleLoaded?.() && !map.getLayer(layer.id)) map.addLayer(layer);};
const lost=()=>batch.dispose();
const restored=()=>{if(activeGL)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,flush:()=>{if(batch.ready)map.triggerRepaint();},destroy:()=>{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();}};
return {batch,prepare:()=>batch.setEnabled(displayPreferences().quality !== 'economy' && (!budgetFallback || displayPreferences().quality === 'high')),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) {
+4 -2
View File
@@ -121,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();
@@ -435,7 +436,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();
@@ -444,7 +446,7 @@ async function start(): Promise<void> {
sonifier?.destroy();
mapView?.destroy();
releaseScreenAwake();
}, { once: true });
});
const initial = await fetchState();
const requestedSelection = requestedNode();
+5 -3
View File
@@ -198,7 +198,7 @@ async function start(): Promise<void> {
});
const director = mountGraphFollow(graph, id => selectNode(id));
window.addEventListener('pagehide',()=>director.destroy(),{once:true});
window.addEventListener('pagehide',event=>{if(!event.persisted)director.destroy();});
const liveFeed = new LiveFeed(initial, {
onConnection(connected) {
streamConnected = connected;
@@ -299,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);
@@ -308,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();
@@ -482,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';
+16 -33
View File
@@ -9,7 +9,6 @@ import {
interpolateScreenPoint,
nodeWakeRadius,
packetTrail,
residueSparkleProgress,
residueStyle,
routeDuration,
routeMotion,
@@ -36,7 +35,7 @@ import {
type PacketSignature,
} from '../trafficVisuals';
import type { EndpointV2, NodeRole, NodeV2, PacketView, RoutePacketView, RouteV2, StateV2 } from '../types';
import type { NetgraphAreaAnchor } from './areas';
import { nearestNetgraphArea, type NetgraphAreaAnchor } from './areas';
import { NetgraphQuality } from './quality';
import {
buildNetgraphLayout,
@@ -226,6 +225,15 @@ export class NetgraphRenderer implements ViewportProjector {
const started = performance.now();
const firstLayout = this.layout.positions.size === 0;
let visualChange = Boolean(changes.reset);
let movedArea = false;
if (changes.reset) this.assignedAreas.clear();
for (const node of changes.reset ? state.nodes : changes.nodes ?? []) {
const area = nearestNetgraphArea(node.lat, node.lng);
const previous = this.assignedAreas.get(node.id);
movedArea ||= Boolean(previous && previous.code !== area.code);
this.assignedAreas.set(node.id, area);
}
this.stage.dataset.regionAssignments = String(this.assignedAreas.size);
if (changes.reset) {
this.nodesByID = new Map(state.nodes.map((node) => [node.id, node]));
this.coordinateNodeIDs = new Map(state.nodes.map((node) => [coordinateKey(node.lng, node.lat), node.id]));
@@ -259,8 +267,8 @@ export class NetgraphRenderer implements ViewportProjector {
}
}
if (changes.reset || graphTopologyChanged(this.topology, changes.routes ?? [], state.routes.length)) {
if (changes.reset || firstLayout) {
if (changes.reset || movedArea || graphTopologyChanged(this.topology, changes.routes ?? [], state.routes.length)) {
if (changes.reset || movedArea || firstLayout) {
this.layout = buildNetgraphLayout(state.nodes, state.routes, this.assignedAreas);
} else {
this.layout = extendNetgraphLayout(this.layout, state.nodes, state.routes, this.assignedAreas);
@@ -323,11 +331,6 @@ export class NetgraphRenderer implements ViewportProjector {
return this.nodesByID;
}
setAreaAssignments(assignments: ReadonlyMap<string, NetgraphAreaAnchor>): void {
this.assignedAreas = new Map(assignments);
this.stage.dataset.regionAssignments = String(this.assignedAreas.size);
}
connectedNodes(): NodeV2[] {
return [...this.layout.connectedNodeIDs]
.map((nodeID) => this.nodesByID.get(nodeID))
@@ -934,8 +937,7 @@ 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));
|| 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;
@@ -1025,7 +1027,7 @@ 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, prefersReducedMotion() && this.residueDirty, interval)) {
this.residueContext.clearRect(0, 0, this.width, this.height);
@@ -1037,26 +1039,7 @@ 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 (prefersReducedMotion()) 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();
}
}
// Residue fades in place; only new packets travel along links.
}
private drawResidueLines(context: CanvasRenderingContext2D, now: number): void {
@@ -1308,7 +1291,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();
+2 -26
View File
@@ -527,6 +527,7 @@ 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) {
@@ -550,7 +551,6 @@ export class PacketAnimator {
this.context.globalCompositeOperation = 'source-over';
this.context.lineCap = 'round';
if (!this.reducedMotion) {
if (effectStrength() > 0.5) this.drawResidueSparkles(now);
for (const item of this.nodeWakes) this.drawNodeWake(this.context, item, now);
}
this.activeRoutes = this.activeRoutes.filter(
@@ -566,7 +566,7 @@ export class PacketAnimator {
for (const observer of this.activeObservers) this.drawObserver(observer, now);
this.context.restore();
this.gpu.flush();
if (!this.reducedMotion && this.hasVisibleEffects()) this.requestFrame();
if (!this.reducedMotion && (this.activeRoutes.length || this.activeObservers.length || this.nodeWakes.length)) this.requestFrame();
else this.requestTimedFrame(now);
}
@@ -610,30 +610,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;