From 7da51fd7f060d4dd09da08f1970bb2ed271cccac Mon Sep 17 00:00:00 2001 From: n30nex Date: Sun, 11 Oct 2026 23:34:03 -0400 Subject: [PATCH] Keep region handles and filters accessible during Follow --- README.md | 2 +- docs/netgraph.md | 2 +- web/e2e/netgraph-regions.spec.ts | 2 ++ web/netgraph/index.html | 2 +- web/src/netgraph/areas.ts | 1 + web/src/netgraph/layout.ts | 4 +++- web/src/netgraph/regionControls.ts | 21 +++++++++++++-------- web/src/netgraph/regionLayout.test.ts | 9 ++++++--- web/src/netgraph/renderer.ts | 1 - web/src/netgraph/styles.css | 8 ++++++-- 10 files changed, 34 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 93753cc..64d5dd2 100644 --- a/README.md +++ b/README.md @@ -180,7 +180,7 @@ 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. +Use **Regions** to search geographic 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; diff --git a/docs/netgraph.md b/docs/netgraph.md index b04203c..0ab047d 100644 --- a/docs/netgraph.md +++ b/docs/netgraph.md @@ -16,7 +16,7 @@ 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. +Use **Regions** to search geographic 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; diff --git a/web/e2e/netgraph-regions.spec.ts b/web/e2e/netgraph-regions.spec.ts index dc9d267..b840644 100644 --- a/web/e2e/netgraph-regions.spec.ts +++ b/web/e2e/netgraph-regions.spec.ts @@ -59,9 +59,11 @@ test('spacing and dragging survive refresh while live packets keep their routes 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 expect(stage).toHaveAttribute('data-moving-region', code); 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 expect(stage).toHaveAttribute('data-moving-region', code); await page.mouse.move(point.x + 30, point.y + 24, { steps: 4 }); } await page.evaluate(() => window.dispatchEvent(new CustomEvent('visual-packet', { detail: { seq: 1, id: 'new-connection', at: Date.now(), payloadType: 'Text', mode: 'route', segments: [{ routeId: 'reverse-hop', fromId: 'visual-b', toId: 'visual-a' }] } }))); diff --git a/web/netgraph/index.html b/web/netgraph/index.html index 422f941..78ef59f 100644 --- a/web/netgraph/index.html +++ b/web/netgraph/index.html @@ -94,7 +94,7 @@

- + diff --git a/web/src/netgraph/areas.ts b/web/src/netgraph/areas.ts index 48c4637..5d0d948 100644 --- a/web/src/netgraph/areas.ts +++ b/web/src/netgraph/areas.ts @@ -3,6 +3,7 @@ export interface NetgraphAreaAnchor { name: string; lat: number; lng: number; + aliases?: readonly string[]; } // Four-character Maidenhead squares: 2 degrees longitude by 1 degree latitude. diff --git a/web/src/netgraph/layout.ts b/web/src/netgraph/layout.ts index eab86ad..f6d077a 100644 --- a/web/src/netgraph/layout.ts +++ b/web/src/netgraph/layout.ts @@ -19,6 +19,7 @@ export interface NetgraphPosition { } export interface NetgraphArea { + aliases?: readonly string[]; code: string; name: string; x: number; @@ -99,6 +100,7 @@ export function buildNetgraphLayout( area = { code: anchor.code, name: anchor.name, + aliases: anchor.aliases, x: anchor.lng * AREA_GEO_SCALE_X, y: -anchor.lat * AREA_GEO_SCALE_Y, desiredX: anchor.lng * AREA_GEO_SCALE_X, @@ -154,7 +156,7 @@ export function buildNetgraphLayout( }); } - const areas = areaDrafts.map(({ code, name, x, y, radius, nodeCount }) => ({ code, name, x, y, radius, nodeCount })); + const areas = areaDrafts.map(({ code, name, x, y, radius, nodeCount, aliases }) => ({ code, name, x, y, radius, nodeCount, aliases })); return { positions, diff --git a/web/src/netgraph/regionControls.ts b/web/src/netgraph/regionControls.ts index 70d23e5..a367145 100644 --- a/web/src/netgraph/regionControls.ts +++ b/web/src/netgraph/regionControls.ts @@ -4,6 +4,9 @@ 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 measure = () => document.documentElement.style.setProperty('--graph-controls-bottom', `${Math.ceil(host.getBoundingClientRect().bottom)}px`); + const resize = new ResizeObserver(measure); resize.observe(host); + window.addEventListener('resize', measure); measure(); const menu = graphTemplate('graph-regions-ui'); 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'; @@ -22,10 +25,11 @@ export function mountRegionControls(graph: NetgraphRenderer, host: HTMLElement, 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); }; + const fold = (text: string) => text.normalize('NFD').replace(/\p{M}/gu, '').toLocaleLowerCase(); + const filterList = () => { const query = fold(search.value.trim()); + for (const label of list.children) (label as HTMLElement).hidden = !fold((label as HTMLElement).dataset.search ?? '').includes(query); }; search.addEventListener('input', filterList); - menu.addEventListener('toggle', () => { menu.querySelector('.region-options')!.hidden = !menu.open; if (menu.open) closePanels(); }); + menu.addEventListener('toggle', () => { menu.querySelector('.region-options')!.hidden = !menu.open; if (menu.open) { navigate(); 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); @@ -44,16 +48,17 @@ export function mountRegionControls(graph: NetgraphRenderer, host: HTMLElement, ? 'Kept for this visit; storage unavailable.' : '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 entries = new Map(areas.map(a => [a.code, [a.name, ...(a.aliases ?? [])]])); + 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))) { + for (const [code, names] 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}`; + input.type = 'checkbox'; input.dataset.region = code; text.textContent = `${code} · ${names[0]}`; + label.dataset.search = [code, ...names].join(' '); 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 }); @@ -65,5 +70,5 @@ export function mountRegionControls(graph: NetgraphRenderer, host: HTMLElement, } } sync(); - return { sync, destroy() { document.removeEventListener('pointerdown', dismiss); menu.remove(); routes.remove(); layout.remove(); } }; + return { sync, destroy() { resize.disconnect(); window.removeEventListener('resize', measure); 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 index ba20446..d91d2a1 100644 --- a/web/src/netgraph/regionLayout.test.ts +++ b/web/src/netgraph/regionLayout.test.ts @@ -26,17 +26,20 @@ describe('saved region arrangements', () => { 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 close = new Map([...assignments].map(([id, area]) => [id, { ...area, lng: area.code === 'WEST' ? -75.1 : -75 }])); + const base = buildNetgraphLayout(nodes, routes, close); const westArea = base.areas.find(a => a.code === 'WEST')!; const settings = normalizeRegionLayout({ spacing: 2, centres: { WEST: { x: westArea.x + 200, y: westArea.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 extended = extendNetgraphLayout(base, [...nodes, newNode], [...routes, newRoute], new Map([...close, ['new', close.get('a')!]])); 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'); - const reloaded = arrangeRegions(buildNetgraphLayout([...nodes, newNode], [...routes, newRoute], new Map([...assignments, ['new', west]])), loadRegionLayout()); + const fresh = buildNetgraphLayout([...nodes, newNode], [...routes, newRoute], new Map([...close, ['new', close.get('a')!]])); + expect(fresh.areas.find(a => a.code === 'WEST')!.x).not.toBe(westArea.x); + const reloaded = arrangeRegions(fresh, loadRegionLayout()); expect(reloaded.areas.find(a => a.code === 'WEST')!.x).toBe(before.areas.find(a => a.code === 'WEST')!.x); expect(reloaded.areas.find(a => a.code === 'WEST')!.y).toBe(before.areas.find(a => a.code === 'WEST')!.y); expect(loadRegionLayout().regions).toEqual(['WEST']); expect(loadRegionLayout().routes).toBe(false); diff --git a/web/src/netgraph/renderer.ts b/web/src/netgraph/renderer.ts index 43f771c..14ed576 100644 --- a/web/src/netgraph/renderer.ts +++ b/web/src/netgraph/renderer.ts @@ -852,7 +852,6 @@ export class NetgraphRenderer implements ViewportProjector { 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 · ↔`; context.font = '750 11px Inter, ui-sans-serif, system-ui, sans-serif'; diff --git a/web/src/netgraph/styles.css b/web/src/netgraph/styles.css index 0192bd9..e8c64b9 100644 --- a/web/src/netgraph/styles.css +++ b/web/src/netgraph/styles.css @@ -279,13 +279,13 @@ button:focus-visible, select:focus-visible, input:focus-visible, a:focus-visible } /* Region controls use the existing focus row and Display sheet. */ -.graph-focus { max-width:calc(100vw - 16px); gap:6px; padding-inline:6px; } +.graph-focus { flex-wrap:wrap; z-index:10; 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 { max-height:min(420px,calc(100dvh - var(--dock-height,54px) - var(--dock-bottom,12px) - 176px)); overflow:auto; overscroll-behavior:contain; 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 { max-height:min(420px,calc(100dvh - var(--dock-height,54px) - var(--dock-bottom,12px) - var(--graph-controls-bottom,160px) - 16px)); overflow:auto; overscroll-behavior:contain; 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 label { display:flex; align-items:center; gap:8px; min-height:44px; cursor:pointer; } @@ -302,3 +302,7 @@ button:focus-visible, select:focus-visible, input:focus-visible, a:focus-visible #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; } } + +html:root[data-cinematic] #netgraph-app #follow-card { max-height:calc(100dvh - var(--dock-height) - var(--dock-bottom) - var(--graph-controls-bottom,116px) - 24px); } +@media(min-width:901px) and (pointer:fine) { html:root[data-cinematic] #netgraph-app #follow-card { top:calc(var(--graph-controls-bottom,116px) + 8px); } } +html:root[data-cinematic]:has(#region-filter[open]) :is(.node-inspector-sheet,.follow-card) { visibility:hidden; }