mirror of
https://github.com/n30nex/CartoLite-Server.git
synced 2026-10-22 13:11:36 +00:00
Add saved Netgraph region arrangement and traffic filters
This commit is contained in:
1 parent
fc27d07344
commit
449e552a61
19 files changed
+531
-52
No files matched your search
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Copy this file to .env and configure your MQTT feed. No map key or build is needed.
|
||||
# Keep .env and any optional .secrets files private.
|
||||
CARTOLITE_IMAGE=ghcr.io/n30nex/cartolite-server:0.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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
@@ -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');
|
||||
});
|
||||
Generated
+5
-5
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cartolite-server-web",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();}};
|
||||
}
|
||||
@@ -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'] {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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(); } };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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; } }
|
||||
Reference in new issue
Block a user