From 0d4cd377237adcaede8c29ab008cd35312ea235a Mon Sep 17 00:00:00 2001 From: n30nex Date: Sun, 27 Sep 2026 00:11:37 -0400 Subject: [PATCH] Restore worldwide grouping and adapt GPU effects to frame cost --- web/e2e/cinematic.spec.ts | 9 +++++++ web/e2e/customization.spec.ts | 2 +- web/src/cinematic.css | 6 +++++ web/src/cinematicChrome.ts | 8 +++++- web/src/gpuEffects.ts | 21 +++++++++++++-- web/src/main.ts | 6 +++-- web/src/netgraph/main.ts | 8 +++--- web/src/netgraph/renderer.ts | 49 ++++++++++++----------------------- web/src/packetAnimator.ts | 28 ++------------------ 9 files changed, 69 insertions(+), 68 deletions(-) diff --git a/web/e2e/cinematic.spec.ts b/web/e2e/cinematic.spec.ts index f0dd944..18cf7f5 100644 --- a/web/e2e/cinematic.spec.ts +++ b/web/e2e/cinematic.spec.ts @@ -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'); } +}); diff --git a/web/e2e/customization.spec.ts b/web/e2e/customization.spec.ts index 50a3af5..ba7eb1a 100644 --- a/web/e2e/customization.spec.ts +++ b/web/e2e/customization.spec.ts @@ -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); diff --git a/web/src/cinematic.css b/web/src/cinematic.css index 1d25c15..9302027 100644 --- a/web/src/cinematic.css +++ b/web/src/cinematic.css @@ -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); } } diff --git a/web/src/cinematicChrome.ts b/web/src/cinematicChrome.ts index 291a95c..cd5c344 100644 --- a/web/src/cinematicChrome.ts +++ b/web/src/cinematicChrome.ts @@ -20,6 +20,10 @@ export function mountCinematicDock(): void { const labs = topbar.querySelector('#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('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('#follow-card:not([hidden]) #follow-close')?.click(); if(!target) document.querySelector('#node-inspector-sheet:not([hidden]) .node-inspector-close')?.click(); }); - window.addEventListener('pagehide',()=>observer.disconnect(),{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 => { diff --git a/web/src/gpuEffects.ts b/web/src/gpuEffects.ts index 296f69a..a355431 100644 --- a/web/src/gpuEffects.ts +++ b/web/src/gpuEffects.ts @@ -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) { diff --git a/web/src/main.ts b/web/src/main.ts index 6bc8427..86df828 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -121,6 +121,7 @@ function mobileScreenAwakeWanted(): boolean { } function requestScreenAwake(): Promise { + if (/CartoLiteAndroid\//.test(navigator.userAgent)) { appElement.dataset.screenAwake = 'native'; return Promise.resolve(); } if (!mobileScreenAwakeWanted()) { appElement.dataset.screenAwake = 'desktop'; return Promise.resolve(); @@ -435,7 +436,8 @@ async function start(): Promise { void requestScreenAwake(); void feed?.resume(); }); - window.addEventListener('beforeunload', () => { + window.addEventListener('pagehide', (event) => { + if (event.persisted) return; if (trafficWakeTimer !== undefined) window.clearTimeout(trafficWakeTimer); if (followTimer !== undefined) window.clearInterval(followTimer); feed?.stop(); @@ -444,7 +446,7 @@ async function start(): Promise { sonifier?.destroy(); mapView?.destroy(); releaseScreenAwake(); - }, { once: true }); + }); const initial = await fetchState(); const requestedSelection = requestedNode(); diff --git a/web/src/netgraph/main.ts b/web/src/netgraph/main.ts index 4164545..fcb6326 100644 --- a/web/src/netgraph/main.ts +++ b/web/src/netgraph/main.ts @@ -198,7 +198,7 @@ async function start(): Promise { }); 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 { 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 { 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 { + if (/CartoLiteAndroid\//.test(navigator.userAgent)) { app.dataset.screenAwake = 'native'; return Promise.resolve(); } screenAwakeWanted = true; if (!matchMedia('(pointer: coarse)').matches) { app.dataset.screenAwake = 'desktop'; diff --git a/web/src/netgraph/renderer.ts b/web/src/netgraph/renderer.ts index 2770315..ce70a96 100644 --- a/web/src/netgraph/renderer.ts +++ b/web/src/netgraph/renderer.ts @@ -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): 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(); diff --git a/web/src/packetAnimator.ts b/web/src/packetAnimator.ts index d3f4a81..aa4781e 100644 --- a/web/src/packetAnimator.ts +++ b/web/src/packetAnimator.ts @@ -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;