mirror of
https://github.com/n30nex/CartoLite-Server.git
synced 2026-10-16 00:49:55 +00:00
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:
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Copy this file to .env and configure your MQTT feed. No map key or build is needed.
|
||||
# Keep .env and any optional .secrets files private.
|
||||
CARTOLITE_IMAGE=ghcr.io/n30nex/cartolite-server:0.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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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. |
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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
@@ -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 & 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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cartolite-server-web",
|
||||
"version": "0.5.0",
|
||||
"version": "0.5.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
+2
-1
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user