diff --git a/.env.example b/.env.example index f5e32fd..3df7da0 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bac4262..76018d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/CHANGELOG.md b/CHANGELOG.md index 81c351c..019ede1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 0a4dce8..93753cc 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/VERSION b/VERSION index 39e898a..7486fdb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.7.1 +0.7.2 diff --git a/compose.yml b/compose.yml index 1d9172b..54af34c 100644 --- a/compose.yml +++ b/compose.yml @@ -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" diff --git a/docs/netgraph.md b/docs/netgraph.md index b58592c..b04203c 100644 --- a/docs/netgraph.md +++ b/docs/netgraph.md @@ -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. diff --git a/web/e2e/netgraph-regions.spec.ts b/web/e2e/netgraph-regions.spec.ts new file mode 100644 index 0000000..fea84c8 --- /dev/null +++ b/web/e2e/netgraph-regions.spec.ts @@ -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 { + 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 }; + probe.regionHandles = {}; + const fill = CanvasRenderingContext2D.prototype.fillText; + CanvasRenderingContext2D.prototype.fillText = function (...args: Parameters) { + 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 }).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'); +}); diff --git a/web/package-lock.json b/web/package-lock.json index bf2db10..11fe8fa 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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" diff --git a/web/package.json b/web/package.json index 040e0a3..2dd385c 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "cartolite-server-web", - "version": "0.7.1", + "version": "0.7.2", "private": true, "type": "module", "scripts": { diff --git a/web/src/cinematicChrome.ts b/web/src/cinematicChrome.ts index 06c8e97..6787a6a 100644 --- a/web/src/cinematicChrome.ts +++ b/web/src/cinematicChrome.ts @@ -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('button[aria-expanded="true"][aria-controls]')) { if(button.getAttribute('aria-controls') !== target) button.click(); } diff --git a/web/src/netgraph/follow.ts b/web/src/netgraph/follow.ts index 3261170..4af8204 100644 --- a/web/src/netgraph/follow.ts +++ b/web/src/netgraph/follow.ts @@ -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();}}; } diff --git a/web/src/netgraph/layout.ts b/web/src/netgraph/layout.ts index 07d46a1..eab86ad 100644 --- a/web/src/netgraph/layout.ts +++ b/web/src/netgraph/layout.ts @@ -314,7 +314,7 @@ function isDistinct(x: number, y: number, positions: ReadonlyMap, areas: readonly NetgraphArea[], ): NetgraphLayout['bounds'] { diff --git a/web/src/netgraph/main.ts b/web/src/netgraph/main.ts index 9ee6844..ef5d2d5 100644 --- a/web/src/netgraph/main.ts +++ b/web/src/netgraph/main.ts @@ -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('component-count'); const tooltip = required('tooltip'); const inspectorSheet = required('node-inspector-sheet'); const emptyState = required('empty-state'); +const emptyMessage = emptyState.textContent; const fatal = required('fatal'); let soundPulseTimer: number | undefined; @@ -101,6 +103,7 @@ async function start(): Promise { 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 { const graphControls = [findButton, resetButton, zoomInButton, zoomOutButton, routeWindow, required('graph-3d-button')]; for (const control of graphControls) control.disabled = true; syncCamera = () => { - updateCamera(); + updateCamera(); syncRegions(); const focus = document.querySelector('.graph-focus select'); if (focus && stage.dataset.focusMode) focus.value = stage.dataset.focusMode; }; @@ -216,16 +219,16 @@ async function start(): Promise { }; 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 { }); 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 = 'Focus'; inspectorSheet.before(focusControl); focusControl.querySelector('select')!.addEventListener('change', event => { @@ -246,6 +249,10 @@ async function start(): Promise { 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, { diff --git a/web/src/netgraph/regionControls.ts b/web/src/netgraph/regionControls.ts new file mode 100644 index 0000000..eecc326 --- /dev/null +++ b/web/src/netgraph/regionControls.ts @@ -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 = 'Regions · All'; + const summary = menu.querySelector('summary')!, search = menu.querySelector('input')!, list = menu.querySelector('.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 = 'Region layout
Drag a region label to move its whole bubble. Saved on this device.'; + document.getElementById('display-panel')!.append(layout); + const spacing = layout.querySelector('input')!, output = layout.querySelector('output')!, move = layout.querySelector('select')!; + let lastAreas: ReturnType | 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('.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('[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('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(); } }; +} diff --git a/web/src/netgraph/regionLayout.test.ts b/web/src/netgraph/regionLayout.test.ts new file mode 100644 index 0000000..75476d6 --- /dev/null +++ b/web/src/netgraph/regionLayout.test.ts @@ -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,"