mirror of
https://github.com/n30nex/CartoLite-Server.git
synced 2026-10-22 13:11:36 +00:00
Restore worldwide grouping and adapt GPU effects to frame cost
This commit is contained in:
1 parent
2c3981b4c2
commit
0d4cd37723
9 files changed
+69
-68
No files matched your search
@@ -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'); }
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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); } }
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user