Refine map layers and cross-view UI usability (#16)

* Refine layer controls and cross-view UI accessibility

* Complete cumulative labels and landscape layer usability checks

* Keep inactive layer labels readable on light scenes

* Check cumulative observation wording in route inspection
This commit is contained in:
n30nex
2026-09-13 10:09:11 -04:00
committed by GitHub
parent ab5a58367b
commit abfcebb5e8
30 changed files with 508 additions and 81 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Copy this file to .env and configure your MQTT feed. No map key or build is needed.
# Keep .env and any optional .secrets files private.
CARTOLITE_IMAGE=ghcr.io/n30nex/cartolite-server:0.5.0
CARTOLITE_IMAGE=ghcr.io/n30nex/cartolite-server:0.5.1
CARTOLITE_BIND_ADDR=127.0.0.1
CARTOLITE_PORT=8080
# Optional CARTO override: see compose.carto.yml and docs/deployment.md.
+1 -1
View File
@@ -1 +1 @@
0.5.0
0.5.1
+1 -1
View File
@@ -6,6 +6,6 @@ services:
build:
context: .
args:
APP_VERSION: ${CARTOLITE_VERSION:-0.5.0}
APP_VERSION: ${CARTOLITE_VERSION:-0.5.1}
GIT_SHA: ${CARTOLITE_GIT_SHA:-local}
BUILD_TIME: ${CARTOLITE_BUILD_TIME:-unknown}
+1 -1
View File
@@ -2,7 +2,7 @@ name: cartolite-server
services:
cartolite:
image: ${CARTOLITE_IMAGE:-ghcr.io/n30nex/cartolite-server:0.5.0}
image: ${CARTOLITE_IMAGE:-ghcr.io/n30nex/cartolite-server:0.5.1}
restart: unless-stopped
ports:
- "${CARTOLITE_BIND_ADDR:-127.0.0.1}:${CARTOLITE_PORT:-8080}:8080"
+11 -1
View File
@@ -1,6 +1,6 @@
# Map, Netgraph, and display settings
Map keeps its everyday controls in the toolbar. Open **Map** for layers, appearance and camera settings. Netgraph has a matching **Display** menu. Menus scroll within the viewport; Escape closes them and returns keyboard focus to their button.
Map keeps its everyday controls in the toolbar. Open **Layers** for grouped Traffic and Map details controls. Expand **Terrain & buildings** or **Appearance** for their additional settings. Netgraph has a matching **Display** menu. Menus scroll within the viewport; Escape closes them and returns keyboard focus to their button.
## Shared appearance
@@ -31,3 +31,13 @@ A map notice explains unavailable tile details and offers Retry; traffic data an
Synthetic Actions checks cover themes, presets, storage migration and cross-tab updates, keyboard/menu behavior, actual building pixels, terrain/date-line projection, route/packet visibility, bounded performance and the public privacy contract.
OpenFreeMap supplies the default geography and building layer. Optional CARTO configuration is described in [Deployment](deployment.md). Netgraph areas remain coordinate-derived global grid squares.
## Layer guidance and reset
Toggles show On or Off, and the menu reports how many layers are enabled. Buildings explain when neighborhood zoom is required. Camera and terrain-height controls require 3D; the shading slider requires Topo.
**Reset layers** restores layer visibility, route window, and shading defaults while keeping the scene, route preset, and sound settings. **Reset all display settings**, under Appearance, also resets the shared Map/Netgraph appearance. Sound remains separate.
Netgraph supports arrow-key pan, plus/minus zoom, and Home to fit when its canvas has keyboard focus. Its menus remain mutually exclusive for keyboard and pointer use, and phone summaries keep visible labels.
If browser storage is blocked, the views continue running; preferences are then temporary for the current page.
+5 -3
View File
@@ -1,5 +1,7 @@
# Audit roadmap
Latest UI review: [2026-09-13 audit and refinement](ui-ux-audit-2026-09-13.md). The paired refinement implements A07, A08, A10 and part of A09, with layer organization and scoped reset; release evidence is separate.
Updated: 12 September 2026. This roadmap turns the end-to-end audit into tracked delivery work. Audit IDs A01–A12 are retained; F IDs track features and R IDs track validation risks.
This is the worldwide source-release roadmap. Shared work is paired with [CartoLite Canada](https://github.com/n30nex/CartoLite); A04/O1 is tracked upstream and is not an operator rollout for this repository.
@@ -36,10 +38,10 @@ Each item needs its acceptance check, edition parity, and an exact CI result bef
| A04 | P1 | Canada | Needs setup | Configure the documented encrypted off-host backup and restore path. Prove a checksummed isolated restore with the matching image/version and independent monitoring; preserve production throughout validation. |
| A05 | P2 | Both | Implemented | Reject non-finite RF/coordinate values and impose a bounded MQTT envelope before normalization. Synthetic invalid numbers and over-limit JSON/hex are rejected; valid RSSI-only and SNR-only packets still work. |
| A06 | P2 | Both | Implemented | Run retention maintenance during quiet periods and avoid unnecessary checkpoint writes. With no new messages, expired routes/nodes leave the API, checkpoint and connected client; quiet connected feeds remain ready. |
| A07 | P2 | Both | Planned | Label cumulative observation totals honestly and describe windows as “links heard within this period.” Never imply current totals are unique packets counted within the chosen interval. |
| A08 | P2 | Both map pages | Planned | Guard access to browser storage itself and fall back to in-memory preferences. A throwing storage getter cannot prevent startup or control use. |
| A07 | P2 | Both | Implemented | Label cumulative observation totals honestly and describe windows as “links heard within this period.” Never imply current totals are unique packets counted within the chosen interval. |
| A08 | P2 | Both map pages | Implemented | Guard access to browser storage itself and fall back to in-memory preferences. A throwing storage getter cannot prevent startup or control use. |
| A09 | P2 | Both | Planned | Transfer keyboard selection focus to details, restore it on close, add Netgraph keyboard pan/zoom/reset, and bounded node/area browsing. Find → details → neighbour → close works without a pointer. |
| A10 | P2 | Both Netgraphs | Planned | Give every phone counter a visible or accessible name; prefer a concise nodes/links summary with optional area/component detail. Validate at 320–480 px without overlap. |
| A10 | P2 | Both Netgraphs | Implemented | Give every phone counter a visible or accessible name; prefer a concise nodes/links summary with optional area/component detail. Validate at 320–480 px without overlap. |
| A11 | P2 | Both delivery processes | Planned | Make dependency contributions testable without production credentials, retain trusted provider verification, enforce worldwide exact-revision merge/release checks and enable dependency alerting. M1 covers the runtime lockfile scan and automatic publication of the verified manual-run status on the exact commit; repository enforcement and alerts remain planned. |
| A12 | P2 | Worldwide | Planned | Improve setup identity, require an instance-specific MQTT client ID, and refresh upgrade/rollback instructions. A documented install reports its actual revision/version; independent instances have distinct identities. |
+67
View File
@@ -0,0 +1,67 @@
# UI/UX audit — Canada and Worldwide
Audited baselines: Canada 0.14.1 (`c85ff956`), Worldwide 0.5.0 (`ab5a5836`).
## Scope and evidence
Reviewed the Map toolbar, layers, appearance, terrain/buildings, Find, node details,
Live Follow, sound, legends, loading/recovery, and responsive rules; Netgraph's
equivalent controls, counters, selection, and camera interactions; Canada Labs'
experiment, pause, sound, exhibit, and information controls; the Android wrapper's
navigation/recovery source; and Worldwide's installation/provider configuration.
The Canada public Map and Netgraph were inspected in a browser. In particular,
keyboard activation reproduced Find and Display remaining open together.
Worldwide findings were checked against its corresponding source and are covered
by the paired synthetic Actions suite. No separate Worldwide public instance or
physical Android handset was used for this audit.
## Findings included in this refinement
| Priority | Finding and evidence | Change | Acceptance check |
|---|---|---|---|
| P1 | Access to `localStorage` itself can throw before Map startup, and the audio constructor can prevent all three views starting. Existing catches around storage methods do not cover the getter. | A small in-memory fallback protects Map preferences and the audio constructor. | A blocked storage getter still permits Map, Netgraph, and Canada Labs startup. |
| P2 | Appearance, preview, and advanced controls precede the layer grid. At a normal desktop height, common layers extend below the menu's visible area. | Put Traffic and Map details first. Move Terrain/buildings and Appearance into native disclosures; keep the close control at the top while scrolling. | Common traffic toggles fit in the initial panel; expanded controls remain bounded on phones and landscape views. |
| P2 | Layer state relies on a small colored dot. An enabled Buildings layer may show nothing below neighborhood zoom. Camera/shading controls remain active when their layer is off. | Explicit On/Off labels, an enabled-layer count, zoom-aware building guidance, and disabled inactive camera/shading inputs. | Guidance changes at the building zoom threshold; 3D enables Topo/Buildings and their controls correctly. |
| P2 | The existing reset combines layer visibility, scene, style, and camera preferences. | Add Reset layers while retaining a clearly named full reset under Appearance. | Reset layers keeps scene, route preset, and sound preferences; the full reset still restores display defaults. |
| P2 | Netgraph's separate Display controller only coordinates with pointer dismissal. Enter can open Display over Find; Escape does not consistently restore menu focus. | Coordinate menu opening for pointer and keyboard activation and restore focus on dismissal. | Only one menu remains open through Find → Display → Sound; Escape returns to the initiating control. |
| P2 | Netgraph is keyboard-focusable but has no camera keyboard handler. | Arrow-key pan, plus/minus zoom, Home to fit, and an accessible keyboard hint. | Keyboard navigation changes the view without changing topology or intercepting form inputs. |
| P2 | At phone widths, Netgraph hides all counter labels and can crowd the controls row. | Show concise labels and place the summary below the controls. Preserve full labels for assistive technology. | Phone counters are named, visible, and do not overlap controls. |
| P2 | Node details call cumulative retained-link counts “packets,” which can suggest counts within the selected window. Replacing popup contents also recreates its close button without the original accessible name. | Label totals as observations, explain how the window filters links, and retain a named close button after updates. | Live updates keep a usable close control and cumulative totals are identified correctly. |
| P2 | Pausing Follow through its card or opening another control does not always stop an already-running camera ease. | Stop the current follow camera transition for those explicit pause actions. | Pause immediately stops camera motion; the existing ten-second activity dwell remains unchanged. |
## Further work
These remain separate, concrete follow-ups; this patch does not claim to complete
every older roadmap item.
1. **Inspector keyboard continuity:** move focus from search results into details,
preserve the focused neighbour through live reordering, and restore focus to
the originating control on close. The close-button naming defect is addressed
here; the complete focus journey still needs a dedicated regression.
2. **Follow scope and actions:** offer an explicit area lock, Next, and Inspect,
with a clear distinction between heard-at-node activity and confirmed routes.
3. **Motion and text preferences:** evaluate shared reduced-motion and text-size
controls across Map and Netgraph. Labs has a separate artistic presentation;
preserve its Pause and explanation affordances while considering consistency.
4. **Legends and dense selections:** continue testing the combined inspector,
region legend, follow card, and provider notice in short landscape viewports.
Avoid adding permanently visible panels to solve isolated overlaps.
5. **Worldwide first use:** distinguish an empty configured feed, a disconnected
broker, and no routes in the selected window with actionable setup guidance.
6. **Native Android verification:** check system back, TalkBack, large system text,
resume, and offline recovery on hardware. The web improvements reach the
existing wrapper; this patch changes no native navigation policy.
## Design and validation rules
Keep one source of truth for appearance and retain existing preferences. Use
native disclosures, stable toggle labels, explicit pressed states, and keyboard
focus restoration, following the [WAI disclosure pattern](https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/)
and [button pattern](https://www.w3.org/WAI/ARIA/apg/patterns/button/).
Build and test only in GitHub Actions with synthetic traffic. Preserve the existing
privacy, topology, rendering, size, and image checks. Inspect the resulting
desktop/mobile screenshots and verify the exact deployed Canada revision after
the final candidate passes. Runtime evidence is recorded separately from this
source audit.
+1
View File
@@ -23,6 +23,7 @@ test('map options are compact, persistent, and preserve live layers through styl
await page.locator('.display-advanced > summary').click();
await page.locator('#route-opacity').press('Home');
for (let step = 0; step < 6; step++) await page.locator('#route-opacity').press('ArrowRight');
if (await page.locator('#hillshade-button').getAttribute('aria-pressed') !== 'true') await page.locator('#hillshade-button').click();
await page.locator('#terrain-relief').press('Home');
for (let step = 0; step < 8; step++) await page.locator('#terrain-relief').press('ArrowRight');
await page.locator('#basemap-style').selectOption('streets');
+5
View File
@@ -3,4 +3,9 @@ import type { Page } from '@playwright/test';
export async function openMapOptions(page: Page): Promise<void> {
const summary = page.locator('#layers-summary');
if (await summary.count() && await summary.getAttribute('aria-expanded') !== 'true') await summary.click();
// Interaction tests need the complete options; the layer UX test checks the collapsed default.
for (const id of ['terrain-options', 'map-appearance']) {
const group = page.locator('#' + id);
if (await group.count() && await group.getAttribute('open') === null) await group.locator('summary').first().click();
}
}
+1
View File
@@ -109,6 +109,7 @@ test('map navigation and controls remain separate across laptop and narrow phone
await expect(page.locator('#status-text')).toBeVisible();
}
await page.locator('#layers-summary').click();
await page.locator('#terrain-options > summary').click();
for (const id of ['routes', 'heatmap', 'clusters', 'hillshade', 'terrain']) {
await expect(page.locator(`#${id}-button > span`).last()).toBeVisible();
}
+1
View File
@@ -41,6 +41,7 @@ test('keeps tablet portrait controls compact, separated, and touch sized', async
await summary.click();
await expect(disclosure).toHaveAttribute('open', '');
await page.locator('#terrain-options > summary').click();
const layerButtons = ['routes', 'heatmap', 'clusters', 'hillshade', 'terrain'];
for (const id of layerButtons) await expect(page.locator(`#${id}-button`)).toBeVisible();
await expect(page.locator('#route-window')).toBeVisible();
+174
View File
@@ -0,0 +1,174 @@
import { expect, test } from '@playwright/test';
import { openMapOptions } from './mapControls';
import { visualFixture, visualPacket } from './visualFixtures';
test('puts everyday layers first and keeps close reachable while scrolling options', async ({ page }, info) => {
await visualFixture(page);
await page.goto('/');
await expect(page.locator('#map')).toHaveAttribute('data-render-state', 'idle');
await page.locator('#layers-summary').click();
await expect(page.locator('#map-appearance')).not.toHaveAttribute('open');
await expect(page.locator('#terrain-options')).not.toHaveAttribute('open');
await expect(page.locator('#basemap-style')).toBeHidden();
const panel = await page.locator('#layers-panel').boundingBox();
for (const id of ['routes-button', 'live-packets-button', 'heatmap-button', 'clusters-button']) {
const box = await page.locator('#' + id).boundingBox();
expect(box!.y).toBeGreaterThanOrEqual(panel!.y);
expect(box!.y + box!.height).toBeLessThanOrEqual(panel!.y + panel!.height);
}
await expect(page.locator('#layer-summary')).toContainText('layers on');
await page.screenshot({ path: info.outputPath('layers-first.png') });
await openMapOptions(page);
await page.locator('.display-advanced > summary').click();
await page.getByLabel('After-trails', { exact: true }).press('End');
const close = await page.locator('#layers-close').boundingBox();
expect(close!.y).toBeGreaterThanOrEqual(panel!.y);
expect(close!.y + close!.height).toBeLessThan(panel!.y + 80);
await page.locator('#layers-close').click();
await expect(page.locator('#layers-summary')).toBeFocused();
});
test('resets layers without discarding appearance or sound preferences', async ({ page }) => {
await visualFixture(page);
await page.goto('/');
await expect(page.locator('#map')).toHaveAttribute('data-render-state', 'idle');
await page.locator('#sound-button').click();
await page.locator('#sound-volume').press('Home');
await page.locator('#sound-volume').press('ArrowRight');
await openMapOptions(page);
await page.locator('#basemap-style').selectOption('streets');
await page.getByLabel('Route style', { exact: true }).selectOption('neon');
await page.locator('#terrain-button').click();
await page.locator('#reset-layers').click();
await expect(page.locator('#map')).toHaveAttribute('data-basemap-style', 'streets');
await expect(page.locator('#map')).toHaveAttribute('data-route-preset', 'neon');
await expect(page.locator('#terrain-button')).toHaveAttribute('aria-pressed', 'false');
await expect(page.locator('#routes-button')).toHaveAttribute('aria-pressed', 'false');
await expect(page.locator('#heatmap-button')).toHaveAttribute('aria-pressed', 'true');
await expect(page.locator('#sound-volume')).toHaveValue('1');
await expect(page.locator('#sound-button')).toHaveAttribute('aria-pressed', 'false');
await page.locator('#reset-appearance').click();
await expect(page.locator('#map')).toHaveAttribute('data-basemap-style', 'dark');
await expect(page.locator('#map')).toHaveAttribute('data-route-preset', 'crisp');
await expect(page.locator('#sound-volume')).toHaveValue('1');
});
test('explains zoom-dependent buildings and enables camera controls only in 3D', async ({ page }, info) => {
await visualFixture(page);
await page.goto('/');
const map = page.locator('#map');
await expect(map).toHaveAttribute('data-render-state', 'idle');
await openMapOptions(page);
await page.locator('#buildings-button').click();
await expect(page.locator('#building-status')).toContainText('Zoom in');
await expect(page.locator('#camera-pitch')).toBeDisabled();
await expect(page.locator('#terrain-height')).toBeDisabled();
await expect(page.locator('#terrain-relief')).toBeDisabled();
// Exercise the zoom hint without repeatedly loading cold terrain tiles.
// Dedicated terrain tests retain the rendering and responsiveness gates.
await page.keyboard.press('Escape');
for (let step = 0; step < 3; step++) {
await page.locator('.maplibregl-canvas').press('Equal');
await expect(map).toHaveAttribute('data-camera-moving', 'false');
}
await openMapOptions(page);
await expect(page.locator('#building-status')).toHaveText('Building footprints are enabled.');
await page.locator('#terrain-button').click();
await expect(page.locator('#hillshade-button')).toHaveAttribute('aria-pressed', 'true');
await expect(page.locator('#camera-pitch')).toBeEnabled();
await expect(page.locator('#terrain-height')).toBeEnabled();
await expect(page.locator('#terrain-relief')).toBeEnabled();
await expect(page.locator('#building-status')).toHaveText(info.project.name === 'desktop'
? 'Mapped building heights are shown where available.' : 'Building footprints are enabled.');
});
test('Netgraph menus are exclusive for keyboard users and its camera supports keys', async ({ page }, info) => {
await visualFixture(page);
await page.goto('/netgraph/');
await expect(page.locator('#connected-count')).toHaveText('2');
await page.locator('#find-button').press('Enter');
await page.locator('#display-button').press('Enter');
await expect(page.locator('#find-panel')).toBeHidden();
await expect(page.locator('#display-panel')).toBeVisible();
await page.locator('#sound-button').press('Enter');
await expect(page.locator('#display-panel')).toBeHidden();
await expect(page.locator('#sound-panel')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.locator('#sound-button')).toBeFocused();
if (info.project.name === 'desktop') {
const stage = page.locator('#netgraph-stage');
const center = await stage.getAttribute('data-view-center');
const scale = Number(await stage.getAttribute('data-view-scale'));
await stage.press('ArrowRight');
await expect(stage).not.toHaveAttribute('data-view-center', center!);
await stage.press('Equal');
await expect.poll(async () => Number(await stage.getAttribute('data-view-scale'))).toBeGreaterThan(scale);
await stage.press('Home');
await expect(page.locator('#connected-count')).toHaveText('2');
await page.setViewportSize({ width: 568, height: 320 });
}
const size = page.viewportSize()!;
if (size.width <= 480 || size.height <= 520) {
for (const label of ['nodes', 'links', 'areas', 'groups']) await expect(page.locator('.summary-compact').filter({ hasText: label })).toBeVisible();
const summary = await page.locator('#graph-summary').boundingBox();
const controls = await page.locator('.controls').boundingBox();
expect(summary!.y).toBeGreaterThanOrEqual(controls!.y + controls!.height);
expect(summary!.y + summary!.height).toBeLessThanOrEqual(size.height);
if (size.width > size.height && size.height <= 520) {
const legend = await page.locator('#legend').boundingBox();
expect(summary!.x + summary!.width).toBeLessThanOrEqual(legend!.x);
}
}
});
test('node details keep a named close button after live updates and explain totals', async ({ page }) => {
await visualFixture(page);
await page.goto('/');
await expect(page.locator('#map')).toHaveAttribute('data-render-state', 'idle');
await page.locator('#find-button').click();
await page.locator('#node-search').fill('Visual Alpha');
await page.getByRole('option', { name: /Visual Alpha/ }).click();
await visualPacket(page, 1);
await expect(page.locator('.neighbor-row')).toContainText('total observations');
await expect(page.locator('.inspector-window-note')).toContainText('earlier observations');
await page.getByRole('button', { name: 'Close node details', exact: true }).click();
await expect(page.locator('.node-inspector')).toBeHidden();
});
test('pausing Follow stops a camera transition immediately', async ({ page }, info) => {
test.skip(info.project.name !== 'desktop', 'camera timing is checked once');
await visualFixture(page, 15);
await page.goto('/');
const map = page.locator('#map');
await expect(map).toHaveAttribute('data-render-state', 'idle');
await page.locator('#follow-button').click();
await visualPacket(page, 1);
await expect(map).toHaveAttribute('data-camera-moving', 'true');
await page.locator('#follow-pause').click();
await expect(map).toHaveAttribute('data-camera-moving', 'false');
await expect(page.locator('#follow-card')).toHaveAttribute('data-state', 'paused');
});
test('blocked browser storage does not prevent Map, Netgraph, or Labs startup', async ({ page }, info) => {
test.skip(info.project.name !== 'desktop', 'storage behavior does not depend on viewport');
const errors: string[] = [];
page.on('pageerror', (error) => errors.push(error.message));
await page.addInitScript(() => Object.defineProperty(window, 'localStorage', {
configurable: true, get() { throw new DOMException('Storage disabled', 'SecurityError'); },
}));
await page.goto('/');
await expect(page.locator('#status-text')).not.toHaveText('Starting…');
await expect(page.locator('#fatal')).toBeHidden();
const hasLabs = await page.locator('#labs-link').count() > 0;
await page.locator('#layers-summary').click();
await expect(page.locator('#layers-panel')).toBeVisible();
await page.goto('/netgraph/');
await expect(page.locator('#status-text')).not.toHaveText('Starting…');
await expect(page.locator('#fatal')).toBeHidden();
if (hasLabs) {
await page.goto('/labs/?demo=1');
await expect(page.locator('#labs-app')).toHaveAttribute('data-loading', 'false');
await expect(page.locator('#labs-fatal')).toBeHidden();
}
expect(errors).toEqual([]);
});
+1 -1
View File
@@ -526,7 +526,7 @@ test('focuses recent route neighbors and clears selection on the map', async ({
await expect(map).toHaveAttribute('data-hovered-route-id', 'a-b');
await expect(tooltip).toHaveAttribute('data-kind', 'route');
await expect(tooltip).toContainText('Alpha ↔ Bravo');
await expect(tooltip).toContainText('12 packets');
await expect(tooltip).toContainText('12 total observations');
await expectTooltipInsideViewport(tooltip, page);
await page.screenshot({ path: testInfo.outputPath(`cartolite-neighbors-focus-${testInfo.project.name}.png`) });
+60 -35
View File
@@ -55,15 +55,14 @@
<svg class="control-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="m4 7 8-4 8 4-8 4z"></path><path d="m4 12 8 4 8-4M4 17l8 4 8-4"></path>
</svg>
<span>Map</span>
<span>Layers</span>
</button>
<div id="layers-panel" class="layers-panel glass" hidden>
<header class="map-options-header"><strong>Map options</strong><button id="layers-close" class="control-button icon-button" type="button" aria-label="Close map options">×</button></header>
<label class="map-option" for="basemap-style"><span>Map style</span><select id="basemap-style"><option value="dark">Night</option><option value="light">Daylight</option><option value="streets">Streets</option></select></label>
<label class="map-option" for="interface-theme"><span>Interface</span><select id="interface-theme"><option value="map">Match map</option><option value="dark">Dark</option><option value="light">Light</option></select></label>
<div class="map-options-label">Layers</div>
<div class="layer-toggles">
<button id="routes-button" class="control-button" type="button" aria-label="Routes" aria-pressed="false" title="Show routes">
<div id="layers-panel" class="layers-panel glass" aria-label="Map layers and appearance" hidden>
<header class="map-options-header"><div><strong>Map layers</strong><p id="layer-summary" class="map-options-subtitle" aria-live="polite"></p></div><button id="layers-close" class="control-button icon-button" type="button" aria-label="Close map options">×</button></header>
<section class="map-layer-group" aria-labelledby="traffic-layer-title" aria-describedby="traffic-layer-help">
<h2 id="traffic-layer-title">Traffic</h2>
<div class="layer-toggles">
<button id="routes-button" class="control-button" type="button" aria-label="Routes" aria-pressed="false" title="Show routes">
<svg class="control-icon" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="6" cy="19" r="3"></circle>
<path d="M9 19h8.5a3.5 3.5 0 0 0 0-7h-11a3.5 3.5 0 0 1 0-7H15"></path>
@@ -71,7 +70,8 @@
</svg>
<span>Routes</span>
</button>
<button id="heatmap-button" class="control-button selected" type="button" aria-label="Heatmap" aria-pressed="true" title="Hide heatmap">
<button id="live-packets-button" class="control-button" type="button" aria-label="Live packets" aria-pressed="true"><span>Live packets</span></button>
<button id="heatmap-button" class="control-button selected" type="button" aria-label="Heatmap" aria-pressed="true" title="Hide heatmap">
<svg class="control-icon" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="2.5"></circle>
<circle cx="12" cy="12" r="6"></circle>
@@ -79,32 +79,15 @@
</svg>
<span>Heatmap</span>
</button>
<button id="clusters-button" class="control-button selected" type="button" aria-label="Clusters" aria-pressed="true" title="Hide clusters">
<button id="clusters-button" class="control-button selected" type="button" aria-label="Clusters" aria-pressed="true" title="Hide clusters">
<svg class="control-icon" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="7" cy="8" r="3"></circle><circle cx="16.5" cy="7" r="2.5"></circle><circle cx="12" cy="16" r="3.5"></circle>
</svg>
<span>Clusters</span>
</button>
<button id="hillshade-button" class="control-button" type="button" aria-label="Topography" aria-pressed="false" title="Show topography">
<svg class="control-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="m3 18 6-9 3 4 3-7 6 12z"></path><path d="M6 18h12"></path>
</svg>
<span>Topo</span>
</button>
<button id="terrain-button" class="control-button" type="button" aria-label="3D terrain" aria-pressed="false" title="Enable 3D terrain">
<svg class="control-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="m4 15 5-7 3 4 3-6 5 9"></path><path d="m4 15 8 5 8-5"></path>
</svg>
<span>3D</span>
</button>
<button id="map-labels-button" class="control-button" type="button" aria-label="Place labels" aria-pressed="true"><span>Place labels</span></button>
<button id="buildings-button" class="control-button" type="button" aria-label="Buildings" aria-pressed="false"><span>Buildings</span></button>
<button id="node-labels-button" class="control-button" type="button" aria-label="Node labels" aria-pressed="true"><span>Node labels</span></button>
<button id="roads-button" class="control-button" type="button" aria-label="Roads" aria-pressed="true"><span>Roads</span></button>
<button id="live-packets-button" class="control-button" type="button" aria-label="Live packets" aria-pressed="true"><span>Live packets</span></button>
</div>
<label class="route-window" for="route-window">
<span>Route age</span>
</div>
<label class="route-window" for="route-window">
<span>Links heard within</span>
<select id="route-window" aria-label="Route age window">
<option value="auto">Auto · 15m</option>
<option value="15m">15m</option>
@@ -113,15 +96,57 @@
<option value="24h">24h</option>
</select>
</label>
<label class="map-slider" for="route-opacity"><span>Route strength</span><output id="route-opacity-output" for="route-opacity">80%</output><input id="route-opacity" type="range" min="20" max="100" step="5" value="80" /></label>
<label class="map-slider" for="terrain-relief"><span>Topo shading</span><output id="terrain-relief-output" for="terrain-relief">75%</output><input id="terrain-relief" type="range" min="0" max="100" step="5" value="75" /></label>
<details class="camera-settings"><summary>Desktop 3D camera</summary>
<button id="north-button" class="control-button reset-appearance" type="button" aria-label="Reset north"><span class="compass-needle" aria-hidden="true">↑</span> Reset north <output id="camera-bearing" aria-label="Camera bearing">0°</output></button>
<p id="traffic-layer-help" class="layer-hint">Routes show previously heard links. Live packets control new motion and sound.</p>
</section>
<section class="map-layer-group" aria-labelledby="detail-layer-title">
<h2 id="detail-layer-title">Map details</h2>
<div class="layer-toggles">
<button id="node-labels-button" class="control-button" type="button" aria-label="Node labels" aria-pressed="true"><span>Node labels</span></button>
<button id="map-labels-button" class="control-button" type="button" aria-label="Place labels" aria-pressed="true"><span>Place labels</span></button>
<button id="roads-button" class="control-button" type="button" aria-label="Roads" aria-pressed="true"><span>Roads</span></button>
</div>
</section>
<details id="terrain-options" class="map-layer-group">
<summary>Terrain &amp; buildings <span id="terrain-summary">2D</span></summary>
<div class="layer-group-body">
<div class="layer-toggles">
<button id="hillshade-button" class="control-button" type="button" aria-label="Topography" aria-pressed="false" title="Show topography">
<svg class="control-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="m3 18 6-9 3 4 3-7 6 12z"></path><path d="M6 18h12"></path>
</svg>
<span>Topo</span>
</button>
<button id="terrain-button" class="control-button" type="button" aria-label="3D terrain" aria-pressed="false" title="Enable 3D terrain">
<svg class="control-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="m4 15 5-7 3 4 3-6 5 9"></path><path d="m4 15 8 5 8-5"></path>
</svg>
<span>3D</span>
</button>
<button id="buildings-button" class="control-button" type="button" aria-label="Buildings" aria-pressed="false"><span>Buildings</span></button>
</div>
<p id="building-status" class="layer-hint" aria-live="polite">Buildings appear at neighborhood zoom.</p>
<label class="map-slider" for="terrain-relief"><span>Topo shading</span><output id="terrain-relief-output" for="terrain-relief">75%</output><input id="terrain-relief" type="range" min="0" max="100" step="5" value="75" /></label>
<p class="layer-hint">Topo adds shading. 3D adds height and enables Topo and Buildings.</p>
<details class="camera-settings"><summary>Desktop 3D camera</summary>
<p id="camera-status" class="layer-hint">Turn on 3D to adjust the camera and terrain height.</p><button id="north-button" class="control-button reset-appearance" type="button" aria-label="Reset north"><span class="compass-needle" aria-hidden="true">↑</span> Reset north <output id="camera-bearing" aria-label="Camera bearing">0°</output></button>
<label class="map-slider" for="camera-pitch"><span>Camera pitch</span><output id="camera-pitch-output" for="camera-pitch">50°</output><input id="camera-pitch" type="range" min="0" max="65" step="1" value="50" /></label>
<label class="map-slider" for="terrain-height"><span>Terrain height</span><output id="terrain-height-output" for="terrain-height">1×</output><input id="terrain-height" type="range" min="0.5" max="2" step="0.1" value="1" /></label>
<p class="display-note">Height changes the terrain’s visual scale. Buildings use approximate mapped heights, not antenna heights.</p>
</details>
<button id="reset-appearance" class="control-button reset-appearance" type="button">Restore display defaults</button>
</div>
</details>
<button id="reset-layers" class="control-button reset-appearance" type="button">Reset layers</button>
<details id="map-appearance" class="map-appearance">
<summary>Appearance <span id="appearance-summary">Night · Crisp</span></summary>
<div id="map-appearance-content" class="map-appearance-content">
<label class="map-option" for="basemap-style"><span>Map style</span><select id="basemap-style"><option value="dark">Night</option><option value="light">Daylight</option><option value="streets">Streets</option></select></label>
<label class="map-option" for="interface-theme"><span>Interface</span><select id="interface-theme"><option value="map">Match map</option><option value="dark">Dark</option><option value="light">Light</option></select></label>
<label class="map-slider" for="route-opacity"><span>Route strength</span><output id="route-opacity-output" for="route-opacity">80%</output><input id="route-opacity" type="range" min="20" max="100" step="5" value="80" /></label>
</div>
<button id="reset-appearance" class="control-button reset-appearance" type="button">Reset all display settings</button>
<p class="layer-hint">Resets layers and shared Map/Netgraph appearance. Sound settings stay unchanged.</p>
</details>
</div>
</div>
<div class="sound-control">
+6 -5
View File
@@ -13,7 +13,7 @@
</head>
<body>
<main id="netgraph-app" class="netgraph-app" aria-label="CartoLite live topology" data-loading="true">
<section id="netgraph-stage" class="netgraph-stage" aria-label="Interactive live MeshCore topology" tabindex="0" data-render-state="loading">
<section id="netgraph-stage" class="netgraph-stage" aria-label="Interactive live MeshCore topology" aria-describedby="graph-keyboard-help" tabindex="0" data-render-state="loading">
<canvas id="graph-canvas" aria-hidden="true"></canvas>
<canvas id="packet-canvas" aria-hidden="true"></canvas>
<div class="graph-grade" aria-hidden="true"></div>
@@ -67,16 +67,17 @@
<button id="reset-button" class="control-button reset-button" type="button" title="Fit the whole topology"><span aria-hidden="true">⌂</span><span>Reset</span></button>
</section>
<p id="graph-keyboard-help" class="visually-hidden">Use arrow keys to pan, plus and minus to zoom, and Home to fit the topology.</p>
<section class="zoom-controls glass" aria-label="Zoom controls">
<button id="zoom-in-button" class="control-button" type="button" aria-label="Zoom in" title="Zoom in">+</button>
<button id="zoom-out-button" class="control-button" type="button" aria-label="Zoom out" title="Zoom out">−</button>
</section>
<aside id="graph-summary" class="graph-summary glass" aria-label="Topology summary">
<div><strong id="connected-count">—</strong><span>connected nodes</span></div>
<div><strong id="route-count">—</strong><span>visible links</span></div>
<div><strong id="area-count">—</strong><span>geo areas</span></div>
<div><strong id="component-count">—</strong><span>components</span></div>
<div><strong id="connected-count">—</strong><span class="summary-full">connected nodes</span><span class="summary-compact" aria-hidden="true">nodes</span></div>
<div><strong id="route-count">—</strong><span class="summary-full">visible links</span><span class="summary-compact" aria-hidden="true">links</span></div>
<div><strong id="area-count">—</strong><span class="summary-full">geo areas</span><span class="summary-compact" aria-hidden="true">areas</span></div>
<div><strong id="component-count">—</strong><span class="summary-full">components</span><span class="summary-compact" aria-hidden="true">groups</span></div>
</aside>
<aside id="legend" class="legend glass" aria-label="Netgraph legend">
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "cartolite-server-web",
"version": "0.5.0",
"version": "0.5.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cartolite-server-web",
"version": "0.5.0",
"version": "0.5.1",
"dependencies": {
"maplibre-gl": "6.4.1"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "cartolite-server-web",
"version": "0.5.0",
"version": "0.5.1",
"private": true,
"type": "module",
"scripts": {
+2 -1
View File
@@ -1,3 +1,4 @@
import { browserStorage } from './browserStorage';
import { stableHash } from './trafficVisuals';
import { isSoundScene, SOUND_SCENES as SCENES, type SoundScene } from './soundScenes';
import { surfacePathPoint, type SurfacePoint } from './terrainProjection';
@@ -217,7 +218,7 @@ export class RouteSonifier {
constructor(
private readonly map: ViewportProjector,
private readonly viewport: HTMLElement,
private readonly storage: Storage = window.localStorage,
private readonly storage: Storage = browserStorage(),
) {
const preference = loadSoundPreference(storage);
this.preferredEnabled = preference.enabled;
+16
View File
@@ -0,0 +1,16 @@
import { afterEach, expect, it, vi } from 'vitest';
import { browserStorage } from './browserStorage';
afterEach(() => vi.restoreAllMocks());
it('keeps preferences usable when the browser rejects access to storage itself', () => {
vi.spyOn(window, 'localStorage', 'get').mockImplementation(() => { throw new DOMException('Blocked', 'SecurityError'); });
const storage = browserStorage();
storage.clear();
storage.setItem('display', 'night');
expect(browserStorage().getItem('display')).toBe('night');
expect(storage.length).toBe(1);
expect(storage.key(0)).toBe('display');
storage.removeItem('display');
expect(storage.getItem('display')).toBeNull();
});
+14
View File
@@ -0,0 +1,14 @@
const values = new Map<string, string>();
const memoryStorage: Storage = {
get length() { return values.size; },
clear: () => values.clear(),
getItem: (key) => values.get(key) ?? null,
key: (index) => [...values.keys()][index] ?? null,
removeItem: (key) => { values.delete(key); },
setItem: (key, value) => { values.set(key, value); },
};
/** Some privacy modes reject access to the storage object itself. */
export function browserStorage(): Storage {
try { return window.localStorage; } catch { return memoryStorage; }
}
+2
View File
@@ -79,3 +79,5 @@
}
#map[data-terrain3d="true"] + .map-grade { background: none; box-shadow: none; }
#map[data-terrain3d="true"] + .map-grade::after { display: none; }
.inspector-window-note { display: block; margin-top: 6px; color: var(--muted); font-size: 10px; line-height: 1.45; font-weight: 400; letter-spacing: 0; text-transform: none; }
+4 -5
View File
@@ -25,9 +25,7 @@ export function mountDisplayControls(parent: HTMLElement, sceneControls = false)
oldOpacityLabel.querySelector('span')!.textContent = 'Line opacity';
duplicateOpacity.replaceWith(oldOpacityLabel);
}
const layersHeading = parent.querySelector('.map-options-label');
if (layersHeading?.parentElement === parent) parent.insertBefore(section, layersHeading);
else parent.append(section);
parent.append(section);
const sync = (): void => {
const settings = displayPreferences();
for (const input of section.querySelectorAll<HTMLInputElement | HTMLSelectElement>('[data-display]')) input.value = String(settings[input.dataset.display as keyof DisplayPreferences]);
@@ -60,7 +58,7 @@ export function mountDisplayControls(parent: HTMLElement, sceneControls = false)
sync();
}
export function mountNetgraphDisplay(): void {
export function mountNetgraphDisplay(onOpen: () => void = () => {}): () => void {
const controls = document.querySelector<HTMLElement>('.controls')!;
const host = document.createElement('div');
host.className = 'display-disclosure popover-control';
@@ -69,7 +67,7 @@ export function mountNetgraphDisplay(): void {
const button = host.querySelector<HTMLButtonElement>('#display-button')!;
const panel = host.querySelector<HTMLElement>('#display-panel')!;
const close = (restore = false): void => { panel.hidden = true; button.setAttribute('aria-expanded', 'false'); if (restore) button.focus(); };
button.addEventListener('click', () => { panel.hidden = !panel.hidden; button.setAttribute('aria-expanded', String(!panel.hidden)); });
button.addEventListener('click', () => { panel.hidden = !panel.hidden; button.setAttribute('aria-expanded', String(!panel.hidden)); if (!panel.hidden) onOpen(); });
panel.querySelector('button')!.addEventListener('click', () => close(true));
document.addEventListener('pointerdown', (event) => { if (event.target instanceof Node && !host.contains(event.target)) close(); });
document.addEventListener('keydown', (event) => {
@@ -77,4 +75,5 @@ export function mountNetgraphDisplay(): void {
event.preventDefault(); event.stopImmediatePropagation(); close(true);
});
mountDisplayControls(panel, true);
return () => close();
}
+51 -13
View File
@@ -1,3 +1,4 @@
import { browserStorage } from './browserStorage';
import { populateSoundScenes, syncSoundScene, SOUND_SCENES } from './soundScenes';
import { mountDisplayControls } from './displayControls';
import { loadBasemapConfiguration } from './basemap';
@@ -91,8 +92,8 @@ const followProgress = required<HTMLProgressElement>('follow-progress');
const followPause = required<HTMLButtonElement>('follow-pause');
initializeDisplay();
let uiPreferences: UiPreferences = { ...loadUiPreferences(localStorage), basemap: displayPreferences().basemap, theme: displayPreferences().theme, routeOpacity: displayPreferences().opacity };
mountDisplayControls(layersPanel);
let uiPreferences: UiPreferences = { ...loadUiPreferences(browserStorage()), basemap: displayPreferences().basemap, theme: displayPreferences().theme, routeOpacity: displayPreferences().opacity };
mountDisplayControls(required<HTMLElement>('map-appearance-content'));
let legendExpanded = uiPreferences.legendExpanded;
let lastTrafficPulseAt = -Infinity;
let soundPulseTimer: number | undefined;
@@ -173,7 +174,7 @@ applyAppearanceChrome();
legendToggle.addEventListener('click', () => {
legendExpanded = !legendExpanded;
uiPreferences = { ...uiPreferences, legendExpanded };
saveUiPreferences(localStorage, uiPreferences);
saveUiPreferences(browserStorage(), uiPreferences);
legend.dataset.collapsed = String(!legendExpanded);
legendToggle.setAttribute('aria-expanded', String(legendExpanded));
legendToggle.setAttribute('aria-label', legendExpanded ? 'Hide map legend' : 'Show map legend');
@@ -344,7 +345,7 @@ async function start(): Promise<void> {
liveMap.resetNorth();
persistUiPreference({ terrainBearing: 0 });
});
required<HTMLButtonElement>('reset-appearance').addEventListener('click', () => {
const resetLayers = (): void => {
const toggles = [
['terrain-button', 'terrain3D'], ['hillshade-button', 'hillshade'], ['buildings-button', 'buildings'], ['routes-button', 'routes'],
['heatmap-button', 'heatmap'], ['clusters-button', 'clusters'],
@@ -354,12 +355,18 @@ async function start(): Promise<void> {
const button = document.getElementById(id);
if (button && uiPreferences[key] !== DEFAULT_UI_PREFERENCES[key]) button.click();
}
if (legendExpanded) legendToggle.click();
persistUiPreference({ basemap: 'dark', theme: 'map', routeOpacity: 0.8, relief: 0.75, routeWindow: 'auto', terrainExaggeration: 1, terrainPitch: 50, terrainBearing: 0 });
updateDisplay({ ...DEFAULT_DISPLAY });
persistUiPreference({ relief: DEFAULT_UI_PREFERENCES.relief, routeWindow: 'auto' });
routeWindow.value = 'auto';
liveMap.setRouteWindow('auto');
applyAppearance();
};
required<HTMLButtonElement>('reset-layers').addEventListener('click', resetLayers);
required<HTMLButtonElement>('reset-appearance').addEventListener('click', () => {
resetLayers();
if (legendExpanded) legendToggle.click();
persistUiPreference({ basemap: 'dark', theme: 'map', routeOpacity: 0.8, terrainExaggeration: 1, terrainPitch: 50, terrainBearing: 0 });
updateDisplay({ ...DEFAULT_DISPLAY });
applyAppearance();
});
applyAppearance();
routeWindow.value = uiPreferences.routeWindow;
@@ -504,8 +511,13 @@ async function start(): Promise<void> {
}
};
setLiveFollow(false);
pauseLiveFollow = () => { if (liveFollow) setLiveFollow(false, true); };
followPause.addEventListener('click', () => setLiveFollow(followPaused, !followPaused));
pauseLiveFollow = () => {
if (liveFollow) { liveMap.map.stop(); setLiveFollow(false, true); }
};
followPause.addEventListener('click', () => {
if (liveFollow) liveMap.map.stop();
setLiveFollow(followPaused, !followPaused);
});
required<HTMLButtonElement>('follow-close').addEventListener('click', () => setLiveFollow(false));
for (const gesture of ['dragstart', 'click'] as const) liveMap.map.on(gesture, () => { if (liveFollow) setLiveFollow(false, true); });
@@ -526,7 +538,7 @@ async function start(): Promise<void> {
if (changes) liveMap.render(state, changes);
updateStatus();
});
const savedView = loadSavedView(localStorage, activeViewClass);
const savedView = loadSavedView(browserStorage(), activeViewClass);
if (savedView) {
mapElement.dataset.viewSource = liveMap.restore(savedView.center, savedView.zoom, initial.nodes)
? 'saved'
@@ -537,7 +549,8 @@ async function start(): Promise<void> {
}
liveMap.map.on('moveend', () => {
if (!liveFollow) saveView(localStorage, activeViewClass, liveMap.view());
updateLayerChrome();
if (!liveFollow) saveView(browserStorage(), activeViewClass, liveMap.view());
if (uiPreferences.terrain3D && activeViewClass === 'desktop') {
persistUiPreference({ terrainPitch: liveMap.map.getPitch(), terrainBearing: liveMap.map.getBearing() });
applyAppearanceChrome();
@@ -554,7 +567,7 @@ async function start(): Promise<void> {
document.documentElement.dataset.viewClass = next;
setLayersOpen(false);
if (liveFollow) setLiveFollow(false, true);
const restored = loadSavedView(localStorage, next);
const restored = loadSavedView(browserStorage(), next);
if (restored) {
mapElement.dataset.viewSource = liveMap.restore(restored.center, restored.zoom, liveStore.snapshot.nodes)
? 'saved'
@@ -739,6 +752,7 @@ function required<T extends HTMLElement>(id: string): T {
function applyAppearanceChrome(): void {
applyDisplayChrome();
updateLayerChrome();
basemapStyle.value = uiPreferences.basemap;
interfaceTheme.value = uiPreferences.theme;
routeOpacity.value = String(Math.round(uiPreferences.routeOpacity * 100));
@@ -754,7 +768,30 @@ function applyAppearanceChrome(): void {
function persistUiPreference(update: Partial<UiPreferences>): void {
uiPreferences = { ...uiPreferences, ...update };
saveUiPreferences(localStorage, uiPreferences);
saveUiPreferences(browserStorage(), uiPreferences);
}
function updateLayerChrome(): void {
const enabled = layersPanel.querySelectorAll('.layer-toggles button[aria-pressed="true"]').length;
const layerSummary = required<HTMLElement>('layer-summary');
const summaryText = `${enabled} layers on`;
if (layerSummary.textContent !== summaryText) layerSummary.textContent = summaryText;
const preset = displayPreferences().preset;
required<HTMLElement>('appearance-summary').textContent = `${{ dark: 'Night', light: 'Daylight', streets: 'Streets' }[uiPreferences.basemap]} · ${preset[0]!.toUpperCase()}${preset.slice(1)}`;
required<HTMLElement>('terrain-summary').textContent = `${uiPreferences.terrain3D ? '3D' : '2D'}${uiPreferences.buildings ? ' · Buildings' : ''}`;
const zoom = Number(mapElement.dataset.cameraZoom ?? 0);
const buildingText = !uiPreferences.buildings
? 'Buildings appear at neighborhood zoom.'
: zoom < 13 ? 'Zoom in to neighborhood level to see buildings.'
: uiPreferences.terrain3D && activeViewClass === 'desktop'
? 'Mapped building heights are shown where available.' : 'Building footprints are enabled.';
const buildingStatus = required<HTMLElement>('building-status');
if (buildingStatus.textContent !== buildingText) buildingStatus.textContent = buildingText;
terrainRelief.disabled = !uiPreferences.hillshade;
cameraPitch.disabled = !uiPreferences.terrain3D;
terrainHeight.disabled = !uiPreferences.terrain3D;
required<HTMLButtonElement>('north-button').disabled = !uiPreferences.terrain3D;
required<HTMLElement>('camera-status').hidden = uiPreferences.terrain3D;
}
function updateCompass(bearing: number): void {
@@ -775,6 +812,7 @@ function wireLayerToggle(
button.setAttribute('aria-pressed', String(visible));
button.classList.toggle('selected', visible);
button.title = `${visible ? 'Hide' : 'Show'} ${layerName}`;
updateLayerChrome();
};
setVisible(visible);
update();
+3 -2
View File
@@ -21,6 +21,7 @@ import {
buildNodeInspectorModel,
createNodeInspectorContent,
relativeTime,
observationTotal,
searchNodes,
type NodeSearchResult,
} from './nodeInspector';
@@ -1556,11 +1557,10 @@ export class LiveMap {
if (!from || !to) return false;
this.routeInspectionPinned = pin;
this.setHoveredRoute(route.id);
const packetCount = Math.max(0, route.packetCount);
this.presentTooltip(
event,
`${from.label} ↔ ${to.label}`,
`${route.lastKind} · ${packetCount.toLocaleString()} ${packetCount === 1 ? 'packet' : 'packets'} · heard ${relativeTime(route.lastHeard)}`,
`${route.lastKind} · ${observationTotal(route.packetCount)} · heard ${relativeTime(route.lastHeard)}`,
'route'
);
return true;
@@ -1737,6 +1737,7 @@ export class LiveMap {
.setLngLat([model.node.lng, model.node.lat])
.setDOMContent(content);
if (!popup.isOpen()) popup.addTo(this.map);
popup.getElement().querySelector('.maplibregl-popup-close-button')?.setAttribute('aria-label', 'Close node details');
}
private closeInspector(clearSelection: boolean): void {
+21 -3
View File
@@ -14,7 +14,7 @@ import { NetgraphRenderer } from './renderer';
const SETTINGS_KEY = 'cartolite-server:netgraph:v1';
initializeDisplay();
mountNetgraphDisplay();
const closeDisplay = mountNetgraphDisplay(() => { closeFindPanel(); closeSoundPanel(); });
const app = required<HTMLElement>('netgraph-app');
const stage = required<HTMLElement>('netgraph-stage');
const graphCanvas = required<HTMLCanvasElement>('graph-canvas');
@@ -220,6 +220,21 @@ async function start(): Promise<void> {
resetButton.addEventListener('click', () => graph.home());
zoomInButton.addEventListener('click', () => graph.zoomBy(1.5));
zoomOutButton.addEventListener('click', () => graph.zoomBy(1 / 1.5));
stage.addEventListener('keydown', (event) => {
if (event.target !== stage || event.altKey || event.ctrlKey || event.metaKey) return;
const step = event.shiftKey ? 160 : 80;
switch (event.key) {
case 'ArrowLeft': graph.panBy(-step, 0); break;
case 'ArrowRight': graph.panBy(step, 0); break;
case 'ArrowUp': graph.panBy(0, -step); break;
case 'ArrowDown': graph.panBy(0, step); break;
case '+': case '=': graph.zoomBy(1.5); break;
case '-': case '_': graph.zoomBy(1 / 1.5); break;
case 'Home': graph.home(); break;
default: return;
}
event.preventDefault();
});
wireSearch(graph, (nodeID) => selectNode(nodeID, true));
let wasHidden = document.hidden;
@@ -251,9 +266,11 @@ async function start(): Promise<void> {
});
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape') return;
const activeMenu = !soundPanel.hidden ? soundButton : !findPanel.hidden ? findButton : undefined;
closeSoundPanel();
closeFindPanel();
if (selectedNodeID) selectNode(null);
if (activeMenu) { event.preventDefault(); activeMenu.focus(); }
else if (selectedNodeID) { selectNode(null); stage.focus(); }
});
minuteTimer = window.setInterval(() => {
graph.refreshRouteWindow();
@@ -306,6 +323,7 @@ function wireSearch(renderer: NetgraphRenderer, select: (nodeID: string) => void
findButton.setAttribute('aria-expanded', String(opening));
if (!opening) return;
closeSoundPanel();
closeDisplay();
renderResults();
requestAnimationFrame(() => nodeSearch.focus());
});
@@ -328,7 +346,7 @@ function configureSound(sonifier: RouteSonifier): void {
const opening = soundPanel.hidden;
soundPanel.hidden = !opening;
soundButton.setAttribute('aria-expanded', String(opening));
if (opening) closeFindPanel();
if (opening) { closeFindPanel(); closeDisplay(); }
});
soundToggle.addEventListener('click', async () => {
await sonifier.setEnabled(sonifier.status() !== 'on');
+7
View File
@@ -1528,6 +1528,13 @@ export class NetgraphRenderer implements ViewportProjector {
for (const id of ids) if (this.stage.hasPointerCapture(id)) this.stage.releasePointerCapture(id);
}
panBy(x: number, y: number): void {
this.cancelViewAnimation();
this.centerX += x / this.scale;
this.centerY += y / this.scale;
this.viewChanged();
}
zoomBy(factor: number): void {
this.animateView(this.centerX, this.centerY, clamp(this.scale * factor, 0.005, 8));
}
+18
View File
@@ -322,3 +322,21 @@ button:focus-visible, select:focus-visible, input:focus-visible, a:focus-visible
.scene-control { grid-template-columns: auto minmax(0, 1fr); }
.scene-control select { width: 100%; min-width: 0; }
.sound-panel .sound-description { display: block; min-height: 2.8em; line-height: 1.4; }
.visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
#netgraph-stage:focus-visible { outline: 2px solid var(--focus-ring, #69d1ca); outline-offset: -4px; }
.graph-summary .summary-compact { display: none; }
@media (max-width: 480px) {
.graph-summary { top: max(116px, calc(env(safe-area-inset-top) + 116px)); left: auto; right: 8px; }
.graph-summary .summary-full { display: block; position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.graph-summary .summary-compact { display: block; }
}
@media (orientation: landscape) and (max-height: 520px) {
.graph-summary { display: flex; top: auto; bottom: max(8px, env(safe-area-inset-bottom)); left: 8px; right: auto; }
.graph-summary .summary-full { display: block; position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.graph-summary .summary-compact { display: block; }
}
@media (orientation: landscape) and (max-height: 520px) and (max-width: 600px) {
.graph-summary { display: grid; grid-template-columns: repeat(2, max-content); gap: 3px 12px; }
.graph-summary div + div { border: 0; padding-left: 0; }
}
+7 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import { buildNodeInspectorModel, createNodeInspectorContent, searchNodes } from './nodeInspector';
import { buildNodeInspectorModel, createNodeInspectorContent, observationTotal, searchNodes } from './nodeInspector';
import type { NodeV2, RouteV2 } from './types';
const now = 1_700_000_000_000;
@@ -56,3 +56,9 @@ describe('node finder', () => {
expect(results.map(({ node: result }) => result.id)).toEqual(['new-exact', 'old-exact', 'prefix', 'contains']);
});
});
it('identifies cumulative observations in both detail and route labels', () => {
expect(observationTotal(1)).toBe('1 total observation');
expect(observationTotal(7)).toBe('7 total observations');
expect(observationTotal(0)).toBe('0 total observations');
});
+10 -1
View File
@@ -85,6 +85,11 @@ export function searchNodes(nodes: Iterable<NodeV2>, rawQuery: string, limit = 8
)).slice(0, Math.floor(limit));
}
export function observationTotal(count: number): string {
const total = Math.max(0, count);
return `${total.toLocaleString()} total ${total === 1 ? 'observation' : 'observations'}`;
}
export function relativeTime(timestamp: number, now = Date.now()): string {
const seconds = Math.max(0, Math.floor((now - timestamp) / 1_000));
if (seconds < 60) return 'now';
@@ -141,6 +146,10 @@ export function createNodeInspectorContent(
heading.textContent = model.neighbors.length === 0 ? 'No neighbours in this route window' : 'Neighbours · newest first';
root.append(heading);
if (model.neighbors.length === 0) return root;
const totalsNote = ownerDocument.createElement('small');
totalsNote.className = 'inspector-window-note';
totalsNote.textContent = 'Totals include earlier observations; the window selects which links appear.';
heading.append(totalsNote);
const list = ownerDocument.createElement('div');
list.className = 'neighbor-list';
@@ -154,7 +163,7 @@ export function createNodeInspectorContent(
const role = ownerDocument.createElement('span');
role.textContent = roleLabel(neighbor.role);
const traffic = ownerDocument.createElement('span');
const packets = neighbor.packetCount === 1 ? '1 packet' : `${neighbor.packetCount.toLocaleString()} packets`;
const packets = observationTotal(neighbor.packetCount);
traffic.textContent = `${neighbor.lastKind} · ${packets} · ${relativeTime(neighbor.lastHeard, now)}`;
button.append(label, role, traffic);
button.addEventListener('click', () => options.onSelectNeighbor(neighbor.id));
+14 -3
View File
@@ -644,7 +644,18 @@ button { font: inherit; }
border-radius: 12px;
}
#layers-panel[hidden], .follow-card[hidden] { display: none; }
.map-options-header { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--ink-strong); font-size: 13px; }
.map-options-header { position: sticky; top: -10px; z-index: 1; display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: -10px -12px 0; padding: 8px 12px; background: var(--panel-solid); border-bottom: 1px solid var(--panel-border); color: var(--ink-strong); font-size: 13px; }
.map-options-subtitle { margin: 3px 0 0; color: var(--muted); font-size: 11px; font-weight: 400; }
.map-layer-group { min-width: 0; margin: 0; }
section.map-layer-group, .layer-group-body, .map-appearance-content { display: grid; gap: 8px; }
.map-layer-group h2 { margin: 2px 0; color: var(--ink-strong); font-size: 12px; font-weight: 650; }
.map-layer-group > summary, .map-appearance > summary { min-height: 44px; padding: 12px 0; color: var(--ink-strong); font-size: 12px; font-weight: 650; cursor: pointer; }
.map-layer-group > summary > span, .map-appearance > summary > span { float: right; margin-left: 8px; color: var(--muted); font-size: 10px; font-weight: 400; }
details.map-layer-group, .map-appearance { border-top: 1px solid var(--panel-border); }
.layer-hint { margin: 0; color: var(--muted); font-size: 11px; line-height: 1.5; }
#map-appearance .reset-appearance { width: 100%; margin-block: 10px 8px; }
#layers-panel :is(button, input, select, summary) { scroll-margin-block: 70px 12px; }
#layers-panel input:disabled { cursor: not-allowed; opacity: 0.5; }
.map-options-header .control-button { border: 0; border-radius: 7px; }
.map-options-label { padding-top: 4px; color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; }
.layer-toggles { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 5px; }
@@ -654,8 +665,8 @@ button { font: inherit; }
}
#layers-panel .control-button > span:last-child { display: inline; }
.layer-toggles .control-icon { width: 14px; height: 14px; }
.layer-toggles .control-button::after { content: ''; flex: none; width: 7px; height: 7px; margin-left: auto; border: 1px solid currentColor; border-radius: 50%; opacity: 0.5; }
.layer-toggles .control-button[aria-pressed="true"]::after { background: #45baa6; border-color: #45baa6; opacity: 1; }
.layer-toggles .control-button::after { content: 'Off'; flex: none; margin-left: auto; font-size: 9px; font-weight: 400; opacity: 1; }
.layer-toggles .control-button[aria-pressed="true"]::after { content: 'On'; font-weight: 750; opacity: 1; }
.map-option, #layers-panel .route-window { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 0; color: var(--muted); font-size: 12px; }
.map-option select, #layers-panel .route-window select { width: 146px; min-width: 0; min-height: 44px; border: 1px solid var(--panel-border); border-radius: 8px; padding: 0 8px; color: var(--ink-strong); background: var(--field-bg); font: inherit; }
.map-slider { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 0 10px; color: var(--muted); font-size: 12px; }