mirror of
https://github.com/n30nex/CartoLite-Server.git
synced 2026-10-16 09:29:51 +00:00
Keep region handles and filters accessible during Follow
This commit is contained in:
@@ -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;
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
@@ -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' }] } })));
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
</p>
|
||||
</main>
|
||||
<template id="graph-camera-ui"><div class="graph-camera" role="group" aria-label="3D camera controls" hidden><strong>3D camera</strong><div><button type="button" data-left aria-label="Rotate topology left" title="Rotate left">↶</button><button type="button" data-right aria-label="Rotate topology right" title="Rotate right">↷</button><button type="button" data-flat>Top-down</button></div><label>Tilt<input type="range" min="8" max="65" step="1" value="45" aria-label="Topology tilt"><output>45°</output></label><small>Drag to pan. Shift-drag or right-drag to rotate. Pinch to zoom and twist to rotate. Height is schematic.</small></div></template>
|
||||
<template id="graph-regions-ui"><details id="region-filter" class="region-filter"><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></details></template>
|
||||
<template id="graph-regions-ui"><details id="region-filter" class="region-filter"><summary aria-label="Filter regions">Regions · All</summary><div id="region-options" class="region-options" hidden><input type="search" aria-label="Search regions" placeholder="Region code / name"><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></details></template>
|
||||
<template id="graph-layout-ui"><details class="graph-layout"><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. <span data-save-state>Saved on this device.</span></small></details></template>
|
||||
<script type="module" src="/src/netgraph/main.ts"></script>
|
||||
</body>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<HTMLDetailsElement>('graph-regions-ui');
|
||||
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';
|
||||
@@ -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<HTMLElement>('.region-options')!.hidden = !menu.open; if (menu.open) closePanels(); });
|
||||
menu.addEventListener('toggle', () => { menu.querySelector<HTMLElement>('.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(); } };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user