Add saved Netgraph region arrangement and traffic filters

This commit is contained in:
n30nex committed 2026-10-11 22:01:02 -04:00
1 parent fc27d07344
commit 449e552a61
19 files changed
+531 -52

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
# Copy this file to .env and configure your MQTT feed. No map key or build is needed.
# Keep .env and any optional .secrets files private.
CARTOLITE_IMAGE=ghcr.io/n30nex/cartolite-server:0.7.1
CARTOLITE_IMAGE=ghcr.io/n30nex/cartolite-server:0.7.2
CARTOLITE_BIND_ADDR=127.0.0.1
CARTOLITE_PORT=8080
# Optional CARTO override: see compose.carto.yml and docs/deployment.md.
+2 -2
View File
@@ -15,8 +15,8 @@ concurrency:
env:
IMAGE_NAME: ghcr.io/n30nex/cartolite-server
# Exact published 0.7.0, in addition to the legacy 0.4.1 source migration below.
UPGRADE_FROM_IMAGE: ghcr.io/n30nex/cartolite-server@sha256:33d8a85d5052f7fd220845b0f46d5873b627c6507bd8721bc83713872328b41d
# Exact published 0.7.1, in addition to the legacy 0.4.1 source migration below.
UPGRADE_FROM_IMAGE: ghcr.io/n30nex/cartolite-server@sha256:c1f97b39256e7f37af10150178203308dc68c154fb96467fc403e67cc409d167
jobs:
backend:
+8
View File
@@ -1,5 +1,13 @@
# Changelog
## 0.7.2 - Arrange and filter Netgraph regions
- Spread region bubbles with a 100–400% spacing slider; drag a region label or use movement buttons to rearrange its whole group in top-down or 3D.
- Search region codes/names and choose one or several with checkboxes. Filtering also limits search, inspection, live effects and Follow to those regions.
- Hide standing route lines with the Routes button while live packets keep moving and briefly light their connections before fading.
- Remember layout, filtering and route visibility on this device. Reset the arrangement without changing sound, display style, route window or region selection.
- Keep node membership, route meaning and packet/audio clocks unchanged during layout edits; cover persistence, gestures, filtering and dense-scene performance in Actions.
## 0.7.1 - Correct 3D Netgraph camera orientation
- Correct the reversed perspective depth so 3D looks down onto the scene: nearby groups appear larger and raised connections sit above the ground.
+15
View File
@@ -171,3 +171,18 @@ Selecting a node frames its recent confirmed neighbours once. Tap a connection
for its endpoints, latest category, last-heard time and approximate geographic
distance, or open either endpoint on Map. The height is schematic, not measured
antenna elevation. All six route presets, themes and 31 voices still apply.
### Arrange and filter regions
In Netgraph, open **Display → Region layout** to spread region centres from 100% to
400%. Drag a region label to move its whole bubble, or choose a region and use
the arrow buttons. **Reset region layout** restores the geographic arrangement.
Nodes keep their positions inside each bubble; routes and live packets follow
when the bubble moves. These controls change the diagram, not reported locations.
Use **Regions** to search IATA/region codes or names and select multiple regions.
**None** followed by one checkbox isolates a region; **All** restores the full mesh.
Only connections with both endpoints in the selected regions are shown. Search
and Follow respect this selection. Use **Routes** to hide standing connections;
live packets still illuminate their paths and leave a brief fading trace.
Layout, region choices and route visibility are remembered in this browser.
+1 -1
View File
@@ -1 +1 @@
0.7.1
0.7.2
+1 -1
View File
@@ -2,7 +2,7 @@ name: cartolite-server
services:
cartolite:
image: ${CARTOLITE_IMAGE:-ghcr.io/n30nex/cartolite-server:0.7.1}
image: ${CARTOLITE_IMAGE:-ghcr.io/n30nex/cartolite-server:0.7.2}
restart: unless-stopped
ports:
- "${CARTOLITE_BIND_ADDR:-127.0.0.1}:${CARTOLITE_PORT:-8080}:8080"
+15
View File
@@ -7,3 +7,18 @@ Nodes are grouped by four-character Maidenhead grid squares, each two degrees of
Live packets retain their hop order, protocol colours and synchronized sound. Matching packets receive bounded emphasis while a node is inspected. Area cues show LOCAL traffic or paired OUT/IN activity; DX identifies qualifying long-distance inter-area traffic. Other traffic remains visible and every visible hop keeps its musical note.
Use mouse/wheel, native touch gestures, or the zoom buttons. Reset fits the whole topology, including networks spanning continents. Finder supports Up/Down, Enter and Escape. On slower renderers, secondary decoration and compositing adapt to measured frame cost without dropping live hops or hiding topology. Reduced motion retains stable cues and fading residue.
### Arrange and filter regions
In Netgraph, open **Display → Region layout** to spread region centres from 100% to
400%. Drag a region label to move its whole bubble, or choose a region and use
the arrow buttons. **Reset region layout** restores the geographic arrangement.
Nodes keep their positions inside each bubble; routes and live packets follow
when the bubble moves. These controls change the diagram, not reported locations.
Use **Regions** to search IATA/region codes or names and select multiple regions.
**None** followed by one checkbox isolates a region; **All** restores the full mesh.
Only connections with both endpoints in the selected regions are shown. Search
and Follow respect this selection. Use **Routes** to hide standing connections;
live packets still illuminate their paths and leave a brief fading trace.
Layout, region choices and route visibility are remembered in this browser.
+125
View File
@@ -0,0 +1,125 @@
import { expect, test, type Page } from '@playwright/test';
import { visualFixture, visualPacket } from './visualFixtures';
import type { StateV2 } from '../src/types';
const key = 'cartolite:netgraph-layout:v1';
async function fixture(page: Page): Promise<void> {
await visualFixture(page);
const now = Date.now();
const nodes = [
{ id: 'visual-a', label: 'Visual Alpha', lat: 43.65, lng: -79.4 },
{ id: 'visual-b', label: 'Visual Bravo', lat: 43.651, lng: -79.401 },
{ id: 'visual-c', label: 'Visual Charlie', lat: 49.16, lng: -123.93 },
].map(n => ({ ...n, role: 'repeater' as const, observer: false, lastSeen: now }));
const state: StateV2 = { schemaVersion: 2, bootId: 'arrange-fixture', seq: 0, serverTime: now,
status: { feed: 'connected', activity: 'active', dropped: 0, version: 'test', gitSha: 'synthetic' },
map: { center: [-79.4, 43.65], zoom: 8 }, nodes,
routes: [{ id: 'visual-hop', fromId: 'visual-a', toId: 'visual-b', packetCount: 4, lastHeard: now, intensity: 2, lastKind: 'Text', traffic: 4 },
{ id: 'cross-hop', fromId: 'visual-b', toId: 'visual-c', packetCount: 2, lastHeard: now, intensity: 1, lastKind: 'Advert', traffic: 2 }] };
await page.route('**/api/state', route => route.fulfill({ json: state }));
await page.addInitScript(() => {
const probe = window as unknown as { regionHandles: Record<string, { x: number; y: number }> };
probe.regionHandles = {};
const fill = CanvasRenderingContext2D.prototype.fillText;
CanvasRenderingContext2D.prototype.fillText = function (...args: Parameters<typeof fill>) {
const match = /^([A-Z0-9.-]+) · /.exec(args[0]);
if (this.canvas.id === 'graph-canvas' && match) probe.regionHandles[match[1]!] = { x: args[1], y: args[2] };
return fill.apply(this, args);
};
});
}
async function saved(page: Page) { return page.evaluate(key => JSON.parse(localStorage.getItem(key) ?? '{}'), key); }
async function layoutPanel(page: Page) {
await page.locator('#display-button').click();
await page.locator('.graph-layout > summary').click();
}
test('spacing and dragging survive refresh while live packets keep their routes and clock', async ({ page, context }, info) => {
if (info.project.name === 'mobile') await page.setViewportSize({ width: 320, height: 640 });
await fixture(page); await page.emulateMedia({ reducedMotion: 'reduce' }); await page.goto('/netgraph/');
const stage = page.locator('#netgraph-stage');
await expect(stage).toHaveAttribute('data-render-state', 'idle');
await layoutPanel(page);
await page.getByLabel('Region spacing', { exact: true }).fill('2');
await expect(stage).toHaveAttribute('data-region-spacing', '2');
await expect(stage).toHaveAttribute('data-visible-routes', '2');
await page.getByRole('button', { name: 'Move region right', exact: true }).click();
const first = await saved(page); expect(Object.keys(first.offsets)).toHaveLength(1);
await page.getByLabel('Motion preference').selectOption('full');
await page.keyboard.press('Escape');
await page.locator('#reset-button').click();
await expect(stage).toHaveAttribute('data-render-state', 'idle');
await page.locator('#graph-3d-button').click();
await expect(stage).toHaveAttribute('data-render-state', 'idle');
await expect.poll(() => stage.getAttribute('data-view-tilt').then(Number)).toBeCloseTo(Math.PI / 4, 3);
const code = Object.keys(first.offsets)[0]!;
const point = await page.evaluate(code => (window as unknown as { regionHandles: Record<string, { x: number; y: number }> }).regionHandles[code]!, code);
const camera = await stage.getAttribute('data-view-center');
const touch = info.project.name !== 'desktop' ? await context.newCDPSession(page) : null;
if (touch) {
await touch.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ id: 1, ...point }] });
await touch.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ id: 1, x: point.x + 30, y: point.y + 24 }] });
} else {
await page.mouse.move(point.x, point.y); await page.mouse.down();
await page.mouse.move(point.x + 30, point.y + 24, { steps: 4 });
}
await visualPacket(page, 1, 'Text');
await expect(stage).toHaveAttribute('data-active-packets', '1');
if (touch) { await touch.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); await touch.detach(); }
else await page.mouse.up();
await expect(stage).toHaveAttribute('data-view-center', camera!);
await expect(stage).toHaveAttribute('data-visible-routes', '2');
const moved = await saved(page); expect(moved.offsets[code]).not.toEqual(first.offsets[code]);
await page.locator('#graph-routes-button').click();
await expect(stage).toHaveAttribute('data-history-routes', 'false');
await visualPacket(page, 2, 'Text');
await expect(stage).toHaveAttribute('data-last-packet-hops', '1');
await expect.poll(() => stage.getAttribute('data-active-packets').then(Number)).toBeGreaterThan(0);
await page.screenshot({ path: info.outputPath('netgraph-arranged-live-only.png') });
await page.reload(); await expect(stage).toHaveAttribute('data-render-state', 'idle');
expect(await saved(page)).toEqual({ ...moved, routes: false });
await expect(stage).toHaveAttribute('data-region-spacing', '2');
await expect(stage).toHaveAttribute('data-history-routes', 'false');
await layoutPanel(page); await page.getByRole('button', { name: 'Reset region layout', exact: true }).click();
expect((await saved(page)).offsets).toEqual({}); expect((await saved(page)).spacing).toBe(1);
expect((await saved(page)).routes).toBe(false);
await page.keyboard.press('Escape');
// The checkbox disclosure stays bounded and supports Back/Escape on touch screens.
await page.locator('#region-filter > summary').click();
const box = (await page.locator('.region-options').boundingBox())!;
expect(box.x).toBeGreaterThanOrEqual(0); expect(box.x + box.width).toBeLessThanOrEqual(page.viewportSize()!.width);
await page.keyboard.press('Escape'); await expect(page.locator('#region-filter')).not.toHaveAttribute('open', '');
await expect(page.locator('#region-filter > summary')).toBeFocused();
});
test('checkbox regions filter topology, search and Follow and retain their device selection', async ({ page }, info) => {
await fixture(page); await page.emulateMedia({ reducedMotion: 'reduce' }); await page.goto('/netgraph/');
const stage = page.locator('#netgraph-stage'); await expect(stage).toHaveAttribute('data-render-state', 'idle');
await page.locator('#region-filter > summary').click();
const codes = await page.locator('.region-checks input').evaluateAll(inputs => inputs.map(i => (i as HTMLInputElement).dataset.region!));
expect(codes).toHaveLength(2);
await page.locator('[data-none]').click(); await expect(stage).toHaveAttribute('data-visible-routes', '0');
await expect(page.locator('#empty-state')).toContainText('No regions selected');
// Find the two-node Toronto region without coupling either edition to a particular code system.
const labels = await page.locator('.region-checks label').allTextContents();
const index = labels.findIndex(text => text.includes('Toronto') || text.includes('43.5'));
expect(index).toBeGreaterThanOrEqual(0);
const region = codes[index]!;
await page.getByLabel('Search regions', { exact: true }).fill(region);
await page.locator('.region-checks input').nth(index).check();
await expect(stage).toHaveAttribute('data-visible-routes', '1');
await expect(stage).toHaveAttribute('data-connected-nodes', '2');
await expect(stage).toHaveAttribute('data-areas', '1');
await page.keyboard.press('Escape');
await page.locator('#find-button').click(); await page.locator('#node-search').fill('Visual Charlie');
await expect(page.locator('.node-search-result')).toHaveCount(0); await page.keyboard.press('Escape');
await page.reload(); await expect(stage).toHaveAttribute('data-region-filter', region);
await expect(stage).toHaveAttribute('data-visible-routes', '1');
await page.getByRole('button', { name: 'Follow', exact: true }).click();
await page.evaluate(() => window.dispatchEvent(new CustomEvent('visual-packet', { detail: { seq: 1, id: 'outside', at: Date.now(), payloadType: 'Text', mode: 'route', segments: [{ routeId: 'cross-hop', fromId: 'visual-b', toId: 'visual-c' }] } })));
await expect(page.locator('#follow-card')).toContainText('Waiting for activity');
await visualPacket(page, 2, 'Text'); await expect(page.locator('#follow-card [data-title]')).toContainText('Visual');
await page.screenshot({ path: info.outputPath('netgraph-region-filter.png') });
await page.locator('#region-filter > summary').click(); await page.locator('[data-all]').click();
await expect(stage).toHaveAttribute('data-visible-routes', '2'); await expect(stage).toHaveAttribute('data-areas', '2');
});
+5 -5
View File
@@ -1,12 +1,12 @@
{
"name": "cartolite-server-web",
"version": "0.7.1",
"version": "0.7.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cartolite-server-web",
"version": "0.7.1",
"version": "0.7.2",
"dependencies": {
"maplibre-gl": "6.4.1"
},
@@ -1248,7 +1248,7 @@
"is-potential-custom-element-name": "^1.0.1",
"nwsapi": "^2.2.12",
"parse5": "^7.1.2",
"rrweb-cssom": "^0.7.1",
"rrweb-cssom": "^0.7.2",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^5.0.0",
@@ -1874,8 +1874,8 @@
}
},
"node_modules/rrweb-cssom": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz",
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.2.tgz",
"integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==",
"dev": true,
"license": "MIT"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "cartolite-server-web",
"version": "0.7.1",
"version": "0.7.2",
"private": true,
"type": "module",
"scripts": {
+2 -1
View File
@@ -43,7 +43,7 @@ export function mountCinematicDock(): void {
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 menuIDs = ['layers-panel', 'find-panel', 'sound-panel', 'display-panel', 'region-options', '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;
@@ -75,6 +75,7 @@ export function mountCinematicDock(): void {
return;
}
const target = history.state?.cartolitePanel;
if (target !== 'region-options') document.querySelector('#region-filter[open]')?.removeAttribute('open');
for(const button of controls.querySelectorAll<HTMLButtonElement>('button[aria-expanded="true"][aria-controls]')) {
if(button.getAttribute('aria-controls') !== target) button.click();
}
+3 -1
View File
@@ -30,6 +30,8 @@ export function mountGraphFollow(graph: NetgraphRenderer, select: (id: string) =
const stage=document.getElementById('netgraph-stage')!;
stage.addEventListener('pointerdown',pause);stage.addEventListener('wheel',pause,{passive:true});stage.addEventListener('keydown',pause);
stage.addEventListener('graphnavigate',pause);
const filterChanged=()=>{queue.clear();clearActivity();pause();};
stage.addEventListener('graphfilter',filterChanged);
function tick():void {
if(!enabled||document.hidden)return;
if(held){card.querySelector('[data-state]')!.textContent='Held · explore freely';return;}
@@ -44,5 +46,5 @@ export function mountGraphFollow(graph: NetgraphRenderer, select: (id: string) =
};
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);stage.removeEventListener('graphnavigate',pause);card.remove();button.remove();}};
return {offer(packet:PacketView){if(!enabled)return;const points=followEndpoints(packet);if(!points.every(p=>graph.nodeInRegions(p.id)))return;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);stage.removeEventListener('graphnavigate',pause);stage.removeEventListener('graphfilter',filterChanged);card.remove();button.remove();}};
}
+1 -1
View File
@@ -314,7 +314,7 @@ function isDistinct(x: number, y: number, positions: ReadonlyMap<string, Netgrap
return true;
}
function layoutBounds(
export function layoutBounds(
positions: ReadonlyMap<string, NetgraphPosition>,
areas: readonly NetgraphArea[],
): NetgraphLayout['bounds'] {
+14 -7
View File
@@ -1,3 +1,4 @@
import { mountRegionControls } from './regionControls';
import { mountGraphFollow } from './follow';
import { mountGraphCamera } from './cameraControls';
import { createRouteInspector } from './routeInspector';
@@ -56,6 +57,7 @@ const componentCount = required<HTMLElement>('component-count');
const tooltip = required<HTMLElement>('tooltip');
const inspectorSheet = required<HTMLElement>('node-inspector-sheet');
const emptyState = required<HTMLElement>('empty-state');
const emptyMessage = emptyState.textContent;
const fatal = required<HTMLElement>('fatal');
let soundPulseTimer: number | undefined;
@@ -101,6 +103,7 @@ async function start(): Promise<void> {
let selectNode: (nodeID: string | null, focus?: boolean) => void = () => undefined;
let selectRoutes: (ids: string[], focus?: boolean) => void = () => undefined;
let syncCamera = (): void => undefined;
let syncRegions = (): void => undefined;
const navigate = () => stage.dispatchEvent(new Event('graphnavigate'));
try {
@@ -119,7 +122,7 @@ async function start(): Promise<void> {
const graphControls = [findButton, resetButton, zoomInButton, zoomOutButton, routeWindow, required<HTMLButtonElement>('graph-3d-button')];
for (const control of graphControls) control.disabled = true;
syncCamera = () => {
updateCamera();
updateCamera(); syncRegions();
const focus = document.querySelector<HTMLSelectElement>('.graph-focus select');
if (focus && stage.dataset.focusMode) focus.value = stage.dataset.focusMode;
};
@@ -216,16 +219,16 @@ async function start(): Promise<void> {
};
const updateSummary = (): void => {
connectedCount.textContent = Number(stage.dataset.connectedNodes ?? 0).toLocaleString();
routeCount.textContent = Number(stage.dataset.visibleRoutes ?? 0).toLocaleString();
areaCount.textContent = Number(stage.dataset.areas ?? 0).toLocaleString();
componentCount.textContent = Number(stage.dataset.components ?? 0).toLocaleString();
for (const [element, value] of [[connectedCount, stage.dataset.connectedNodes], [routeCount, stage.dataset.visibleRoutes], [areaCount, stage.dataset.areas], [componentCount, stage.dataset.components]] as const) {
const text = Number(value ?? 0).toLocaleString(); if (element.textContent !== text) element.textContent = text;
}
emptyState.hidden = Number(stage.dataset.visibleRoutes ?? 0) > 0;
emptyState.textContent = graph.regionLayout().regions?.length === 0 ? 'No regions selected. Choose Regions to show your mesh.' : emptyMessage;
};
liveStore.subscribe((state, changes) => {
graph.render(state, changes);
if (changes?.reset || changes?.routes?.length) updateSummary();
if (changes?.reset || changes?.routes?.length || changes?.nodes?.length) updateSummary();
updateStatus();
if (!changes) return;
if (selectedRouteIDs.length && (changes.reset || changes.routes?.length || changes.nodes?.length)) { renderInspector(); return; }
@@ -236,7 +239,7 @@ async function start(): Promise<void> {
});
const requestedSelection = requestedNode();
if (requestedSelection && graph.getNodes().has(requestedSelection)) selectNode(requestedSelection, true);
const focusControl = document.createElement('label'); focusControl.className = 'graph-focus';
const focusControl = document.createElement('div'); 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 => {
@@ -246,6 +249,10 @@ async function start(): Promise<void> {
if (!applied) select.value = stage.dataset.focusMode ?? 'all';
});
const regions = mountRegionControls(graph, focusControl, () => { updateSummary(); renderInspector(); }, () => { closeFindPanel(); closeSoundPanel(); closeDisplay(); });
syncRegions = regions.sync;
window.addEventListener('pagehide', event => { if (!event.persisted) regions.destroy(); });
const director = mountGraphFollow(graph, id => selectNode(id, true));
window.addEventListener('pagehide',event=>{if(!event.persisted)director.destroy();});
const liveFeed = new LiveFeed(initial, {
+71
View File
@@ -0,0 +1,71 @@
import type { NetgraphRenderer } from './renderer';
export function mountRegionControls(graph: NetgraphRenderer, host: HTMLElement, changed: () => void, closePanels: () => void) {
const stage = document.getElementById('netgraph-stage')!;
const navigate = () => stage.dispatchEvent(new Event('graphnavigate'));
const menu = document.createElement('details'); menu.id = 'region-filter'; menu.className = 'region-filter';
menu.innerHTML = '<summary aria-label="Filter regions">Regions · All</summary><div id="region-options" class="region-options" hidden><input type="search" aria-label="Search regions" placeholder="IATA / region"><div class="region-actions"><button type="button" data-all>All</button><button type="button" data-none>None</button></div><div class="region-checks" role="group" aria-label="Regions"></div></div>';
const summary = menu.querySelector('summary')!, search = menu.querySelector('input')!, list = menu.querySelector<HTMLElement>('.region-checks')!;
const routes = document.createElement('button'); routes.type = 'button'; routes.id = 'graph-routes-button'; routes.textContent = 'Routes';
routes.setAttribute('aria-label', 'Show historical routes'); routes.title = 'Hide standing lines; live packets still light their routes briefly';
host.append(menu, routes);
const layout = document.createElement('details'); layout.className = 'graph-layout';
layout.innerHTML = '<summary>Region layout</summary><label>Spacing<input type="range" min="1" max="4" step="0.1" aria-label="Region spacing"><output></output></label><label>Move region<select aria-label="Region to move"></select></label><div class="region-actions"><button type="button" aria-label="Move region left" data-move="-40,0">←</button><button type="button" aria-label="Move region up" data-move="0,-40">↑</button><button type="button" aria-label="Move region down" data-move="0,40">↓</button><button type="button" aria-label="Move region right" data-move="40,0">→</button></div><button type="button" data-reset>Reset region layout</button><small>Drag a region label to move its whole bubble. Saved on this device.</small>';
document.getElementById('display-panel')!.append(layout);
const spacing = layout.querySelector('input')!, output = layout.querySelector('output')!, move = layout.querySelector('select')!;
let lastAreas: ReturnType<NetgraphRenderer['regions']> | undefined, lastRegions: string[] | null | undefined, signature = '';
const applyFilter = (values: string[] | null) => { navigate(); graph.setRegions(values); sync(); changed(); };
menu.querySelector('[data-all]')!.addEventListener('click', () => applyFilter(null));
menu.querySelector('[data-none]')!.addEventListener('click', () => applyFilter([]));
list.addEventListener('change', event => {
const input = event.target as HTMLInputElement, code = input.dataset.region!;
const selected = new Set(graph.regionLayout().regions ?? graph.regions().map(a => a.code));
if (input.checked) selected.add(code); else selected.delete(code);
applyFilter([...selected]);
});
const filterList = () => { const query = search.value.trim().toLocaleLowerCase();
for (const label of list.children) (label as HTMLElement).hidden = !label.textContent!.toLocaleLowerCase().includes(query); };
search.addEventListener('input', filterList);
menu.addEventListener('toggle', () => { menu.querySelector<HTMLElement>('.region-options')!.hidden = !menu.open; if (menu.open) closePanels(); });
menu.addEventListener('keydown', event => { if (event.key === 'Escape' && menu.open) { event.preventDefault(); event.stopPropagation(); menu.open = false; summary.focus(); } });
const dismiss = (event: PointerEvent) => { if (!menu.contains(event.target as Node)) menu.open = false; };
document.addEventListener('pointerdown', dismiss);
routes.addEventListener('click', () => { graph.setRoutesVisible(!graph.regionLayout().routes); sync(); });
spacing.addEventListener('input', () => { navigate(); graph.setRegionSpacing(Number(spacing.value)); sync(); });
layout.querySelector('[data-reset]')!.addEventListener('click', () => { navigate(); graph.resetRegionLayout(); sync(); });
for (const button of layout.querySelectorAll<HTMLButtonElement>('[data-move]')) button.addEventListener('click', () => {
const [x, y] = button.dataset.move!.split(',').map(Number); navigate(); graph.moveRegion(move.value, x!, y!);
});
function sync(): void {
const prefs = graph.regionLayout(), areas = graph.regions();
spacing.value = String(prefs.spacing); output.value = `${Math.round(prefs.spacing * 100)}%`;
routes.setAttribute('aria-pressed', String(prefs.routes)); routes.classList.toggle('selected', prefs.routes);
summary.textContent = `Regions · ${prefs.regions === null ? 'All' : prefs.regions.length}`;
layout.querySelector('small')!.textContent = stage.dataset.layoutSaved === 'false'
? 'Drag region labels to arrange. Storage unavailable; changes last for this visit.'
: 'Drag a region label to move its whole bubble. Saved on this device.';
if (areas !== lastAreas || prefs.regions !== lastRegions) {
lastAreas = areas; lastRegions = prefs.regions;
const entries = new Map(areas.map(a => [a.code, a.name]));
for (const code of prefs.regions ?? []) if (!entries.has(code)) entries.set(code, 'No current activity');
const next = JSON.stringify([...entries]);
if (next !== signature) {
signature = next;
const focused = (document.activeElement as HTMLElement | null)?.dataset.region, scroll = list.scrollTop, moving = move.value;
list.replaceChildren(); move.replaceChildren();
for (const [code, name] of [...entries].sort(([a], [b]) => a.localeCompare(b))) {
const label = document.createElement('label'), input = document.createElement('input'), text = document.createElement('span');
input.type = 'checkbox'; input.dataset.region = code; text.textContent = `${code} · ${name}`;
label.append(input, text); list.append(label);
if (areas.some(a => a.code === code)) move.add(new Option(text.textContent!, code));
if (focused === code) input.focus({ preventScroll: true });
}
if ([...move.options].some(o => o.value === moving)) move.value = moving;
list.scrollTop = scroll; filterList();
}
for (const input of list.querySelectorAll<HTMLInputElement>('input')) input.checked = prefs.regions === null || prefs.regions.includes(input.dataset.region!);
}
}
sync();
return { sync, destroy() { document.removeEventListener('pointerdown', dismiss); menu.remove(); routes.remove(); layout.remove(); } };
}
+59
View File
@@ -0,0 +1,59 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { buildNetgraphLayout, extendNetgraphLayout } from './layout';
import { arrangeRegions, loadRegionLayout, normalizeRegionLayout, REGION_LAYOUT_KEY, saveRegionLayout } from './regionLayout';
import { GraphProjection, GRAPH_TILT } from './camera';
import type { NodeV2, RouteV2 } from '../types';
const nodes: NodeV2[] = ['a', 'b', 'c', 'd'].map(id => ({ id, label: id, lat: 45, lng: -75, role: 'repeater', observer: false, lastSeen: 1 }));
const routes: RouteV2[] = [['a', 'b'], ['b', 'c'], ['c', 'd']].map(([fromId, toId], i) => ({ id: String(i), fromId: fromId!, toId: toId!, packetCount: 1, lastHeard: 1, intensity: 1, lastKind: 'Text', traffic: 1 }));
const west = { code: 'WEST', name: 'West', lat: 45, lng: -120 }, east = { code: 'EAST', name: 'East', lat: 45, lng: -75 };
const assignments = new Map([['a', west], ['b', west], ['c', east], ['d', east]]);
afterEach(() => { localStorage.clear(); vi.restoreAllMocks(); });
describe('saved region arrangements', () => {
it('moves whole bubbles, preserves internal spacing and never mutates source geometry/topology', () => {
const base = buildNetgraphLayout(nodes, routes, assignments), original = structuredClone(base);
const settings = normalizeRegionLayout({ spacing: 3, offsets: { WEST: { x: 210, y: -80 } } });
const moved = arrangeRegions(base, settings);
const a = moved.positions.get('a')!, b = moved.positions.get('b')!;
expect(a.x - b.x).toBeCloseTo(base.positions.get('a')!.x - base.positions.get('b')!.x);
expect(a.y - b.y).toBeCloseTo(base.positions.get('a')!.y - base.positions.get('b')!.y);
expect(moved.areas.find(a => a.code === 'WEST')!.x).toBeCloseTo((base.areas.find(a => a.code === 'WEST')!.x + 210) * 3);
expect(moved.connectedNodeIDs).toEqual(base.connectedNodeIDs);
expect(moved.componentCount).toBe(base.componentCount);
expect(base).toEqual(original);
expect(arrangeRegions(base, normalizeRegionLayout(null))).toEqual(base);
});
it('keeps an arrangement through live extension and restores it from device storage', () => {
const base = buildNetgraphLayout(nodes, routes, assignments);
const settings = normalizeRegionLayout({ spacing: 2, offsets: { WEST: { x: 200, y: 90 } }, regions: ['WEST'], routes: false });
expect(saveRegionLayout(settings)).toBe(true);
const before = arrangeRegions(base, settings);
const newNode = { ...nodes[0]!, id: 'new' }, newRoute = { ...routes[0]!, id: 'new-route', toId: 'new' };
const extended = extendNetgraphLayout(base, [...nodes, newNode], [...routes, newRoute], new Map([...assignments, ['new', west]]));
const after = arrangeRegions(extended, loadRegionLayout());
for (const id of nodes.map(n => n.id)) expect(after.positions.get(id)!.x).toBe(before.positions.get(id)!.x);
expect(after.positions.get('new')!.areaCode).toBe('WEST');
expect(loadRegionLayout().regions).toEqual(['WEST']); expect(loadRegionLayout().routes).toBe(false);
expect(JSON.parse(localStorage.getItem(REGION_LAYOUT_KEY)!)).not.toHaveProperty('nodes');
});
it('rejects corrupt storage, unbounded offsets and prototype keys without touching other preferences', () => {
localStorage.setItem('cartolite:display:v1', 'saved appearance');
localStorage.setItem(REGION_LAYOUT_KEY, '{bad json'); expect(loadRegionLayout().spacing).toBe(1);
const value = normalizeRegionLayout(JSON.parse('{"spacing":999,"offsets":{"__proto__":{"x":1,"y":2},"WEST":{"x":1e99,"y":0}},"regions":["WEST","WEST",4,"<script>"],"routes":false}'));
expect(value.spacing).toBe(4); expect(Object.keys(value.offsets)).toEqual([]); expect(value.regions).toEqual(['WEST']);
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('blocked'); });
expect(saveRegionLayout(value)).toBe(false);
expect(localStorage.getItem('cartolite:display:v1')).toBe('saved appearance');
});
it('keeps permitted rearrangements above the 3D near plane', () => {
const base = buildNetgraphLayout(nodes, routes, assignments), b = base.bounds;
const d = Math.hypot(b.maxX - b.minX, b.maxY - b.minY);
const settings = normalizeRegionLayout({ spacing: 4, offsets: { WEST: { x: d, y: d } } });
const layout = arrangeRegions(base, settings);
for (const yaw of [0, 1.2, 2.5]) {
const projection = new GraphProjection({ x: (b.minX + b.maxX) * 2, y: (b.minY + b.maxY) * 2, zoom: 0.01, distance: d * 8, tilt: GRAPH_TILT, yaw }, 1200, 800);
expect([...layout.positions.values()].every(p => projection.project(p).visible)).toBe(true);
}
});
});
+50
View File
@@ -0,0 +1,50 @@
import { layoutBounds, type NetgraphLayout } from './layout';
export const REGION_LAYOUT_KEY = 'cartolite:netgraph-layout:v1';
export interface RegionLayoutPreferences {
spacing: number;
offsets: Record<string, { x: number; y: number }>;
regions: string[] | null;
routes: boolean;
}
const code = (value: unknown): value is string => typeof value === 'string' && /^[\w.-]{1,48}$/.test(value) && !['__proto__', 'constructor', 'prototype'].includes(value);
export function normalizeRegionLayout(value: unknown): RegionLayoutPreferences {
const v = value && typeof value === 'object' ? value as Partial<RegionLayoutPreferences> : {};
const offsets: RegionLayoutPreferences['offsets'] = Object.create(null);
if (v.offsets && typeof v.offsets === 'object') for (const [key, point] of Object.entries(v.offsets).slice(0, 4096)) {
if (code(key) && point && Number.isFinite(point.x) && Number.isFinite(point.y)
&& Math.abs(point.x) <= 1_000_000 && Math.abs(point.y) <= 1_000_000) offsets[key] = { x: point.x, y: point.y };
}
return { spacing: typeof v.spacing === 'number' && Number.isFinite(v.spacing) ? Math.max(1, Math.min(4, v.spacing)) : 1,
offsets, regions: Array.isArray(v.regions) ? [...new Set(v.regions.filter(code))].slice(0, 4096) : null, routes: v.routes !== false };
}
export function loadRegionLayout(): RegionLayoutPreferences {
try { return normalizeRegionLayout(JSON.parse(localStorage.getItem(REGION_LAYOUT_KEY) ?? 'null')); }
catch { return normalizeRegionLayout(null); }
}
export function saveRegionLayout(value: RegionLayoutPreferences): boolean {
try { localStorage.setItem(REGION_LAYOUT_KEY, JSON.stringify(value)); return true; }
catch { return false; }
}
export function regionOffsetLimit(layout: NetgraphLayout): number {
const b = layout.bounds;
return Math.max(100, Math.hypot(b.maxX - b.minX, b.maxY - b.minY));
}
/** Translate whole regions; never scale their internal nodes or modify topology. */
export function arrangeRegions(base: NetgraphLayout, preferences: RegionLayoutPreferences): NetgraphLayout {
if (preferences.spacing === 1 && !Object.keys(preferences.offsets).length) return base;
const limit = regionOffsetLimit(base);
const shifts = new Map<string, { x: number; y: number }>();
const areas = base.areas.map(area => {
const offset = preferences.offsets[area.code];
const x = (area.x + Math.max(-limit, Math.min(limit, offset?.x ?? 0))) * preferences.spacing;
const y = (area.y + Math.max(-limit, Math.min(limit, offset?.y ?? 0))) * preferences.spacing;
shifts.set(area.code, { x: x - area.x, y: y - area.y });
return { ...area, x, y };
});
const positions = new Map([...base.positions].map(([id, p]) => {
const shift = shifts.get(p.areaCode)!;
return [id, { ...p, x: p.x + shift.x, y: p.y + shift.y }];
}));
return { ...base, positions, areas, bounds: layoutBounds(positions, areas) };
}
+132 -31
View File
@@ -1,8 +1,9 @@
import { arrangeRegions, loadRegionLayout, regionOffsetLimit, saveRegionLayout } from './regionLayout';
import { followViewport } from '../cinematicChrome';
import { surfaceArc, surfaceTrail, traceSurfacePath, type SurfacePoint } from '../terrainProjection';
import { GraphProjection, GRAPH_TILT, MAX_GRAPH_TILT, fitGraphCamera, graphArcPoint, graphPathPoint, distanceToGraphPath, projectGraphPath, type GraphCamera, type GraphPoint } from './camera';
import { canvasEffects } from '../gpuEffects';
import { canvasColorWithAlpha as colorWithAlpha, DISPLAY_EVENT, displayColor, displayPreferences, displayResidueAge, lightScene, lineDash, residueLifetime, prefersReducedMotion, effectStrength } from '../displayPreferences';
import { canvasColorWithAlpha as colorWithAlpha, DISPLAY_EVENT, displayColor, displayPreferences, lightScene, lineDash, residueLifetime, prefersReducedMotion, effectStrength } from '../displayPreferences';
import type { ViewportProjector } from '../audio';
import {
DESTINATION_BLOOM_MS,
@@ -144,7 +145,12 @@ export class NetgraphRenderer implements ViewportProjector {
#regionAreasByTag = new Map<string, NetgraphAreaAnchor>();
#regionActivityCues: RegionActivityCue[] = [];
#topology = new Map<string, string>();
#layout: NetgraphLayout = buildNetgraphLayout([], []);
#baseLayout: NetgraphLayout = buildNetgraphLayout([], []);
#layout = this.#baseLayout;
#regionLayout = loadRegionLayout();
#regionMask = this.#regionLayout.regions === null ? null : new Set(this.#regionLayout.regions);
#regionLabels: Array<LabelRect & { code: string }> = [];
#regionDrag?: { code: string; offset: ScreenPoint; projection: GraphProjection; anchor: ScreenPoint };
#visibleRoutes: RouteV2[] = [];
#visibleRouteIDs = new Set<string>();
#screenNodes: ScreenNode[] = [];
@@ -288,9 +294,9 @@ export class NetgraphRenderer implements ViewportProjector {
if (changes.reset || movedArea || graphTopologyChanged(this.#topology, changes.routes ?? [], state.routes.length)) {
if (firstLayout) {
this.#layout = buildNetgraphLayout(state.nodes, state.routes, this.#assignedAreas);
this.#baseLayout = buildNetgraphLayout(state.nodes, state.routes, this.#assignedAreas);
} else {
this.#layout = extendNetgraphLayout(this.#layout, state.nodes, state.routes, this.#assignedAreas);
this.#baseLayout = extendNetgraphLayout(this.#baseLayout, state.nodes, state.routes, this.#assignedAreas);
}
this.#topology = routeTopology(state.routes);
this.#refreshFocus();
@@ -303,15 +309,13 @@ export class NetgraphRenderer implements ViewportProjector {
}
if (changes.reset || changes.routes?.length) {
this.#visibleRoutes = routesInWindow(state.routes, Date.now(), this.#routeWindow);
this.#visibleRoutes = this.#routesInRegions(routesInWindow(state.routes, Date.now(), this.#routeWindow));
this.#visibleRouteIDs = new Set(this.#visibleRoutes.map((route) => route.id));
}
this.stage.dataset.totalNodes = String(state.nodes.length);
this.stage.dataset.connectedNodes = String(this.#layout.connectedNodeIDs.size);
this.stage.dataset.totalRoutes = String(state.routes.length);
this.stage.dataset.visibleRoutes = String(this.#visibleRoutes.length);
this.stage.dataset.components = String(this.#layout.componentCount);
this.stage.dataset.areas = String(this.#layout.areas.length);
this.stage.dataset.renderApplyMs = (performance.now() - started).toFixed(1);
if (!this.#initializedView && this.#layout.positions.size > 0) this.home(false);
if (visualChange) {
@@ -329,7 +333,7 @@ export class NetgraphRenderer implements ViewportProjector {
}
refreshRouteWindow(now = Date.now()): boolean {
const nextRoutes = routesInWindow([...this.#routesByID.values()], now, this.#routeWindow);
const nextRoutes = this.#routesInRegions(routesInWindow([...this.#routesByID.values()], now, this.#routeWindow));
const nextIDs = new Set(nextRoutes.map((route) => route.id));
const changed = nextIDs.size !== this.#visibleRouteIDs.size
|| [...nextIDs].some((routeID) => !this.#visibleRouteIDs.has(routeID));
@@ -367,16 +371,72 @@ export class NetgraphRenderer implements ViewportProjector {
this.#nodesDirty = true; this.#requestStaticDraw();
}
regionLayout() { return this.#regionLayout; }
regions() { return this.#baseLayout.areas; }
nodeInRegions(id: string): boolean {
return !this.#regionMask || this.#regionMask.has(this.#layout.positions.get(id)?.areaCode ?? this.#assignedAreas.get(id)?.code ?? '');
}
#routesInRegions(routes: RouteV2[]): RouteV2[] {
return this.#regionMask ? routes.filter(r => this.nodeInRegions(r.fromId) && this.nodeInRegions(r.toId)) : routes;
}
#saveRegions(): void {
this.stage.dataset.layoutSaved = String(saveRegionLayout(this.#regionLayout));
}
setRegionSpacing(value: number): void {
if (!Number.isFinite(value)) return;
this.#clearPointers(); this.#cancelViewAnimation();
const spacing = clamp(value, 1, 4), ratio = spacing / this.#regionLayout.spacing;
this.#centerX *= ratio; this.#centerY *= ratio;
this.#regionLayout.spacing = spacing; this.#arrangementChanged(); this.#saveRegions();
}
resetRegionLayout(): void {
this.#regionLayout.offsets = Object.create(null); this.setRegionSpacing(1); this.home();
}
setRegions(regions: string[] | null): void {
this.#clearPointers(); this.#cancelViewAnimation();
this.#regionLayout.regions = regions === null ? null : [...new Set(regions)];
this.#regionMask = regions === null ? null : new Set(regions);
this.#refreshFocus(); this.refreshRouteWindow();
if (this.#selectedNodeID && !this.nodeInRegions(this.#selectedNodeID)) this.callbacks.onNodeSelect(null);
if (this.#selectedRouteID && !this.getRoute(this.#selectedRouteID)) this.callbacks.onRouteSelect?.([]);
this.#viewChanged(); this.#saveRegions(); this.home();
this.stage.dispatchEvent(new Event('graphfilter'));
}
setRoutesVisible(visible: boolean): void {
this.#regionLayout.routes = visible; this.#residueDirty = true;
this.#viewChanged(); this.#saveRegions();
}
moveRegion(code: string, dx: number, dy: number): void {
const area = this.#layout.areas.find(a => a.code === code);
if (!area) return;
this.#cancelViewAnimation();
const at = this.#projection.project(area), from = this.#projection.ground(at.x, at.y);
const to = this.#projection.ground(at.x + dx, at.y + dy), offset = this.#regionLayout.offsets[code] ?? { x: 0, y: 0 };
this.#offsetRegion(code, offset.x + (to.x - from.x) / this.#regionLayout.spacing, offset.y + (to.y - from.y) / this.#regionLayout.spacing);
this.#saveRegions();
}
#offsetRegion(code: string, x: number, y: number): void {
const limit = regionOffsetLimit(this.#baseLayout);
this.#regionLayout.offsets[code] = { x: clamp(x, -limit, limit), y: clamp(y, -limit, limit) };
this.#arrangementChanged();
}
#arrangementChanged(): void {
this.#layout = arrangeRegions(this.#baseLayout, this.#regionLayout);
this.#paintedView = undefined; this.graphCanvas.style.transform = '';
this.#viewChanged();
}
#residueMS(): number { return this.#regionLayout.routes ? residueLifetime() : 1800; }
connectedNodes(): NodeV2[] {
return [...this.#layout.connectedNodeIDs]
.map((nodeID) => this.#nodesByID.get(nodeID))
.filter((node): node is NodeV2 => Boolean(node));
.filter((node): node is NodeV2 => Boolean(node) && this.nodeInRegions(node!.id));
}
adjacentRoutes(nodeID: string): RouteV2[] {
return [...(this.#adjacentRouteIDs.get(nodeID) ?? [])]
.map((routeID) => this.#routesByID.get(routeID))
.filter((route): route is RouteV2 => Boolean(route));
.filter((route): route is RouteV2 => Boolean(route) && this.nodeInRegions(route!.fromId) && this.nodeInRegions(route!.toId));
}
setSelectedNode(nodeID: string | null): void {
@@ -423,8 +483,14 @@ export class NetgraphRenderer implements ViewportProjector {
}
#refreshFocus(): void {
this.#focusIDs = focusedNodeIDs(this.#layout, this.#focusMode, this.#focusAnchorID);
if (!this.#focusIDs) this.#focusMode = 'all';
this.#layout = arrangeRegions(this.#baseLayout, this.#regionLayout);
const focus = focusedNodeIDs(this.#layout, this.#focusMode, this.#focusAnchorID);
if (!focus) this.#focusMode = 'all';
const included = this.#regionMask ? [...this.#layout.positions.values()].filter(p => this.#regionMask!.has(p.areaCode)) : null;
this.#focusIDs = included ? new Set(included.filter(p => !focus || focus.has(p.id)).map(p => p.id)) : focus;
this.stage.dataset.connectedNodes = String(included?.length ?? this.#layout.connectedNodeIDs.size);
this.stage.dataset.areas = String(this.#layout.areas.filter(a => !this.#regionMask || this.#regionMask.has(a.code)).length);
this.refreshRouteWindow();
this.stage.dataset.focusMode = this.#focusMode;
this.callbacks.onViewChange?.();
}
@@ -432,6 +498,7 @@ export class NetgraphRenderer implements ViewportProjector {
focusNode(nodeID: string): void {
const anchor = this.#nodePoint(nodeID);
if (!anchor) return;
if (!this.nodeInRegions(nodeID)) this.setRegions([...(this.#regionLayout.regions ?? []), this.#layout.positions.get(nodeID)!.areaCode]);
if (this.#focusIDs && !this.#focusIDs.has(nodeID)) this.setFocus('all');
const points = [anchor];
for (const route of this.adjacentRoutes(nodeID)) {
@@ -475,7 +542,12 @@ export class NetgraphRenderer implements ViewportProjector {
}
home(animate = true): void {
const bounds = this.#layout.bounds;
const areas = this.#layout.areas.filter(a => !this.#regionMask || this.#regionMask.has(a.code));
if (!areas.length) return;
const bounds = this.#regionMask ? {
minX: Math.min(...areas.map(a => a.x - a.radius - 42)), maxX: Math.max(...areas.map(a => a.x + a.radius + 42)),
minY: Math.min(...areas.map(a => a.y - a.radius - 72)), maxY: Math.max(...areas.map(a => a.y + a.radius + 42)),
} : this.#layout.bounds;
const points = [{ x: bounds.minX, y: bounds.minY }, { x: bounds.minX, y: bounds.maxY },
{ x: bounds.maxX, y: bounds.minY }, { x: bounds.maxX, y: bounds.maxY },
{ x: (bounds.minX + bounds.maxX) / 2, y: (bounds.minY + bounds.maxY) / 2, z: 650 }];
@@ -547,8 +619,8 @@ export class NetgraphRenderer implements ViewportProjector {
traffic: 1,
});
}
this.#layout = extendNetgraphLayout(
this.#layout,
this.#baseLayout = extendNetgraphLayout(
this.#baseLayout,
[...this.#nodesByID.values()],
[...routes.values()],
this.#assignedAreas,
@@ -558,9 +630,7 @@ export class NetgraphRenderer implements ViewportProjector {
this.#residueProjectionDirty = true;
this.#residueDirty = true;
this.#nodesDirty = true;
this.stage.dataset.connectedNodes = String(this.#layout.connectedNodeIDs.size);
this.stage.dataset.components = String(this.#layout.componentCount);
this.stage.dataset.areas = String(this.#layout.areas.length);
this.#requestStaticDraw();
}
@@ -652,7 +722,7 @@ export class NetgraphRenderer implements ViewportProjector {
}
#requestStaticDraw(): void {
if (this.#tilt === 0 && this.#quality.mode === 'low' && this.#pointers.size && this.#paintedView) {
if (this.#tilt === 0 && !this.#regionDrag && this.#quality.mode === 'low' && this.#pointers.size && this.#paintedView) {
this.stage.dataset.renderState = 'composited';
return;
}
@@ -660,7 +730,7 @@ export class NetgraphRenderer implements ViewportProjector {
this.stage.dataset.renderState = 'scheduled';
this.#staticFrame = requestAnimationFrame(() => {
this.#staticFrame = 0;
if (this.#tilt === 0 && this.#quality.mode === 'low' && this.#pointers.size && this.#paintedView) return;
if (this.#tilt === 0 && !this.#regionDrag && this.#quality.mode === 'low' && this.#pointers.size && this.#paintedView) return;
this.#drawStatic();
});
}
@@ -671,7 +741,7 @@ export class NetgraphRenderer implements ViewportProjector {
context.clearRect(0, 0, this.#width, this.#height);
this.#drawGrid(context);
this.#drawAreaHalos(context);
this.#drawRoutes(context);
if (this.#regionLayout.routes) this.#drawRoutes(context);
if (this.#nodesDirty) {
this.#nodesContext.clearRect(0, 0, this.#width, this.#height);
this.#drawNodes(this.#nodesContext);
@@ -721,6 +791,7 @@ export class NetgraphRenderer implements ViewportProjector {
#visibleAreas(): Array<{ area: NetgraphLayout['areas'][number]; point: ScreenPoint; radius: number }> {
return this.#layout.areas
.filter(a => !this.#regionMask || this.#regionMask.has(a.code))
.map((area) => { const point = this.#worldToScreen(area.x, area.y); return { area, point, radius: area.radius * this.#scale * (point.scale ?? 1) }; })
.filter(({ point, radius }) => (
point.x + radius >= -80 && point.x - radius <= this.#width + 80
@@ -762,13 +833,14 @@ export class NetgraphRenderer implements ViewportProjector {
}
#drawAreaLabels(context: CanvasRenderingContext2D): void {
this.#regionLabels = [];
context.save();
const occupied: LabelRect[] = [];
const labels = this.#visibleAreas().sort((left, right) => right.area.nodeCount - left.area.nodeCount || left.area.code.localeCompare(right.area.code));
for (const { area, point, radius } of labels) {
if (area.nodeCount < 2 && this.#scale < 0.16) continue;
const title = `${area.code} · ${area.name}`;
const detail = `${area.nodeCount.toLocaleString()} nodes`;
const detail = `${area.nodeCount.toLocaleString()} nodes · ↔`;
context.font = '750 11px Inter, ui-sans-serif, system-ui, sans-serif';
const titleWidth = this.#textWidth(context, title);
context.font = '600 8px Inter, ui-sans-serif, system-ui, sans-serif';
@@ -784,6 +856,7 @@ export class NetgraphRenderer implements ViewportProjector {
if (x + width < 5 || x > this.#width - 5 || y + height < 5 || y > this.#height - 5) continue;
if (occupied.some((other) => rectanglesOverlap(rect, other, 5))) continue;
occupied.push(rect);
this.#regionLabels.push({ code: area.code, x: x - 6, y: y - 7, width: width + 12, height: 45 });
const connectorY = y < point.y ? y + height : y;
context.beginPath();
@@ -1061,7 +1134,7 @@ export class NetgraphRenderer implements ViewportProjector {
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;
this.#residue = this.#residue.filter((item) => now - item.addedAt < residueLifetime());
this.#residue = this.#residue.filter((item) => now - item.addedAt < this.#residueMS());
if (residueCount !== this.#residue.length) this.#residueProjectionDirty = true;
this.#regionActivityCues = this.#regionActivityCues.filter((cue) => now < cue.startedAt + cue.duration);
this.#drawResidue(context, now);
@@ -1112,7 +1185,7 @@ export class NetgraphRenderer implements ViewportProjector {
for (const [regionTag, frame] of frames) {
const area = this.#layout.areas.find((candidate) => candidate.code.toLowerCase() === regionTag);
const identity = this.#regionAreasByTag.get(regionTag);
if (!area || !identity) continue;
if (!area || !identity || (this.#regionMask && !this.#regionMask.has(area.code))) continue;
const point = this.#projection.endpoint({ x: area.x, y: area.y });
const radius = Math.max(18, area.radius * this.#scale);
if (
@@ -1189,7 +1262,7 @@ export class NetgraphRenderer implements ViewportProjector {
const path = this.#routePath(residue.fromId, residue.toId);
if (!this.#pathVisible(path, 12)) continue;
const age = now - residue.addedAt;
const style = residueStyle(displayResidueAge(age));
const style = residueStyle(age * 45_000 / Math.max(1, this.#residueMS()));
context.beginPath();
this.#appendPath(context, path);
context.strokeStyle = colorWithAlpha(residue.color, style.bloomOpacity * displayPreferences().glow);
@@ -1362,7 +1435,7 @@ export class NetgraphRenderer implements ViewportProjector {
if (this.#residueCleanupTimer) return;
const expiries = [
...this.#activeRoutes.map((route) => route.started + route.duration + DESTINATION_BLOOM_MS),
...this.#residue.map((item) => item.addedAt + residueLifetime()),
...this.#residue.map((item) => item.addedAt + this.#residueMS()),
...this.#observerWakes.map((wake) => wake.started + 6_000),
...this.#regionActivityCues.map((cue) => cue.startedAt + cue.duration),
...this.#regionActivityCues.filter((cue) => cue.startedAt > now).map((cue) => cue.startedAt),
@@ -1467,9 +1540,9 @@ export class NetgraphRenderer implements ViewportProjector {
}
#camera(): GraphCamera {
const b = this.#layout.bounds;
const b = this.#baseLayout.bounds;
return { x: this.#centerX, y: this.#centerY, zoom: this.#scale, yaw: this.#yaw, tilt: this.#tilt,
distance: Math.max(1200, Math.hypot(b.maxX - b.minX, b.maxY - b.minY) * 2) };
distance: this.#regionDrag?.projection.camera.distance ?? Math.max(1200, Math.hypot(b.maxX - b.minX, b.maxY - b.minY) * 2) * this.#regionLayout.spacing };
}
#resetProjection(): void {
@@ -1531,6 +1604,9 @@ export class NetgraphRenderer implements ViewportProjector {
this.#residueDirty = true;
this.#residueProjectionDirty = true;
this.#nodesDirty = true;
this.stage.dataset.regionSpacing = String(this.#regionLayout.spacing);
this.stage.dataset.regionFilter = this.#regionLayout.regions?.join(',') ?? 'all';
this.stage.dataset.historyRoutes = String(this.#regionLayout.routes);
this.stage.dataset.viewScale = String(this.#scale);
this.stage.dataset.viewCenter = `${this.#centerX},${this.#centerY}`;
this.stage.dataset.viewMode = this.#mode3D ? '3d' : 'top-down';
@@ -1543,7 +1619,7 @@ export class NetgraphRenderer implements ViewportProjector {
if (r) { const p = graphPathPoint(this.#routePath(r.fromId, r.toId), 0.5); this.stage.dataset.selectedRouteScreen = `${p.x.toFixed(1)},${p.y.toFixed(1)}`; }
}
this.callbacks.onViewChange?.();
if (this.#tilt === 0 && this.#quality.mode === 'low' && this.#pointers.size && this.#paintedView) {
if (this.#tilt === 0 && !this.#regionDrag && this.#quality.mode === 'low' && this.#pointers.size && this.#paintedView) {
// Reuse the actual rendered topology while fingers move. Its affine
// transform is the same projection used by live packets and hit testing.
const scale = this.#scale / this.#paintedView.scale;
@@ -1630,6 +1706,16 @@ export class NetgraphRenderer implements ViewportProjector {
this.#cancelViewAnimation();
this.#rotationGesture = this.#mode3D && (event.button === 2 || event.shiftKey);
this.#dragMoved = this.#pointers.size > 0 || this.#rotationGesture;
if (this.#pointers.size) this.#endRegionDrag();
else if (!this.#rotationGesture) {
const box = this.stage.getBoundingClientRect(), x = event.clientX - box.left, y = event.clientY - box.top;
const label = this.#regionLabels.find(r => x >= r.x && x <= r.x + r.width && y >= r.y && y <= r.y + r.height);
if (label) {
this.#regionDrag = { code: label.code, offset: this.#regionLayout.offsets[label.code] ?? { x: 0, y: 0 },
projection: this.#projection, anchor: this.#projection.ground(x, y) };
this.stage.dataset.movingRegion = label.code;
}
}
this.#pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
this.#rebaseGesture();
this.stage.setPointerCapture(event.pointerId);
@@ -1653,7 +1739,13 @@ export class NetgraphRenderer implements ViewportProjector {
const deltaX = event.clientX - this.#pointerStart.x;
const deltaY = event.clientY - this.#pointerStart.y;
if (Math.hypot(deltaX, deltaY) > 4) this.#dragMoved = true;
if (this.#rotationGesture) {
if (this.#regionDrag) {
const rect = this.stage.getBoundingClientRect(), drag = this.#regionDrag;
const point = drag.projection.ground(event.clientX - rect.left, event.clientY - rect.top);
if (this.#dragMoved) this.#offsetRegion(drag.code, drag.offset.x + (point.x - drag.anchor.x) / this.#regionLayout.spacing,
drag.offset.y + (point.y - drag.anchor.y) / this.#regionLayout.spacing);
return;
} else if (this.#rotationGesture) {
this.#yaw = this.#gestureYaw + deltaX * 0.006;
this.#tilt = clamp(this.#gestureTilt + deltaY * 0.006, 0.12, MAX_GRAPH_TILT);
} else {
@@ -1669,7 +1761,9 @@ export class NetgraphRenderer implements ViewportProjector {
const rect = this.stage.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
const hovered = this.#nearestNode(x, y);
const label = this.#regionLabels.some(r => x >= r.x && x <= r.x + r.width && y >= r.y && y <= r.y + r.height);
this.stage.classList.toggle('region-hover', label);
const hovered = label ? null : this.#nearestNode(x, y);
const nextID = hovered?.node.id ?? null;
if (nextID === this.#hoveredNodeID) return;
this.#hoveredNodeID = nextID;
@@ -1681,7 +1775,7 @@ export class NetgraphRenderer implements ViewportProjector {
#handlePointerUp = (event: PointerEvent): void => {
if (!this.#pointers.has(event.pointerId)) return;
const select = this.#pointers.size === 1 && !this.#dragMoved;
const select = this.#pointers.size === 1 && !this.#dragMoved && !this.#regionDrag;
this.#finishPointer(event.pointerId);
if (!select) return;
const rect = this.stage.getBoundingClientRect();
@@ -1701,7 +1795,13 @@ export class NetgraphRenderer implements ViewportProjector {
this.#finishPointer(event.pointerId);
};
#endRegionDrag(): void {
if (this.#regionDrag) this.#saveRegions();
this.#regionDrag = undefined; delete this.stage.dataset.movingRegion;
}
#finishPointer(pointerID: number): void {
this.#endRegionDrag();
this.#pointers.delete(pointerID);
if (this.stage.hasPointerCapture(pointerID)) this.stage.releasePointerCapture(pointerID);
// Re-anchor the remaining finger so a pinch can become a drag without a jump.
@@ -1730,6 +1830,7 @@ export class NetgraphRenderer implements ViewportProjector {
}
#clearPointers(): void {
this.#endRegionDrag();
const ids = [...this.#pointers.keys()];
this.#pointers.clear();
this.#pinchStart = undefined;
+25
View File
@@ -369,3 +369,28 @@ button:focus-visible, select:focus-visible, input:focus-visible, a:focus-visible
@media(max-width:480px) and (orientation:portrait) {
html:root[data-cinematic] #netgraph-app :is(.node-inspector-sheet,.node-inspector) { max-height:min(50dvh,calc(100dvh - var(--dock-height) - var(--dock-bottom) - 260px)); }
}
/* Region controls use the existing focus row and Display sheet. */
.graph-focus { max-width:calc(100vw - 16px); gap:6px; padding-inline:6px; }
.graph-focus > span { display:none; }
.graph-focus select { max-width:112px; }
.region-filter summary, #graph-routes-button { cursor:pointer; white-space:nowrap; padding:9px 6px; font:inherit; color:var(--ink-strong); }
#graph-routes-button { border:1px solid var(--panel-border); border-radius:7px; background:var(--field-bg); }
#graph-routes-button.selected { background:var(--selected); }
.region-options { position:absolute; top:calc(100% + 6px); left:0; width:min(300px,calc(100vw - 16px)); padding:10px; border:1px solid var(--panel-border); border-radius:10px; background:var(--panel-solid); box-shadow:0 8px 25px #0003; }
.region-options > input { width:100%; min-height:40px; }
.region-checks { max-height:min(280px,45dvh); overflow:auto; overscroll-behavior:contain; }
.region-checks label { display:flex; align-items:center; gap:8px; min-height:44px; cursor:pointer; }
.region-checks label[hidden] { display:none; }
.region-checks input { accent-color:var(--focus-ring); }
.region-actions { display:flex; gap:6px; margin-block:6px; }
.region-actions button, .graph-layout > button { min-height:44px; min-width:44px; border:1px solid var(--panel-border); border-radius:8px; color:var(--ink-strong); background:var(--field-bg); cursor:pointer; }
.graph-layout { padding-block:10px; border-top:1px solid var(--panel-border); }
.graph-layout summary { cursor:pointer; padding-block:8px; font-weight:700; }
.graph-layout label { display:flex; align-items:center; gap:8px; margin-block:8px; }
.graph-layout input { min-width:0; flex:1; accent-color:var(--focus-ring); }
.graph-layout select { min-width:0; max-width:66%; min-height:44px; color:var(--ink-strong); background:var(--field-bg); }
.graph-layout small { display:block; margin-top:8px; line-height:1.4; color:var(--muted); }
#netgraph-stage.region-hover { cursor:grab; }
#netgraph-stage.is-dragging { cursor:grabbing; }
@media(pointer:coarse) { .region-filter summary, #graph-routes-button, .graph-focus select { min-height:44px; } }