From d8e3499e55f7b80f5d8712fb2a45417f02fe39db Mon Sep 17 00:00:00 2001 From: n30nex Date: Sun, 18 Oct 2026 01:15:48 -0400 Subject: [PATCH] Customize Terminal colours, typography and retro drawing modes (#36) * Add worldwide Terminal colour and font options with crisp network overlays * Resolve Terminal entry and CSS from the build manifest * Apply Terminal-specific defaults and preserve other view preferences * Keep smooth traffic on its canvas and diagnose hidden-route repaint * Synchronize route visibility and finish the still-frame handoff * Recover Terminal terrain graphics and retain clear scope and geography * Hold style writes during graphics recovery and rebuild retained overlays * Initialize map indexes before load consumers * Compare Terminal terrain pixels before and after context recovery * Release terrain resources before graphics context replacement * Map road paint to its independent Terminal colour * Keep terrain stacks cached across route visibility changes * Normalize bundled font licence whitespace --- .env.example | 2 +- .github/workflows/ci.yml | 21 +- README.md | 17 ++ VERSION | 2 +- compose.yml | 2 +- docs/releases/0.9.0.md | 27 +++ docs/roadmap.md | 46 +++- docs/visual-audit-followup-2026-10-18.md | 52 ++++ scripts/write-terminal-page.mjs | 21 +- web/e2e/scale-smoke.spec.ts | 9 +- web/e2e/terminal.spec.ts | 229 +++++++++++++++++- web/e2e/terrain-scale.spec.ts | 4 +- web/e2e/ui-audit.spec.ts | 5 +- web/e2e/visualFixtures.ts | 2 +- web/package-lock.json | 4 +- web/package.json | 2 +- web/public/fonts/terminal/README.txt | 10 + web/public/fonts/terminal/Silkscreen-Bold.ttf | Bin 0 -> 30632 bytes .../fonts/terminal/Silkscreen-Regular.ttf | Bin 0 -> 32220 bytes web/public/fonts/terminal/VT323-Regular.ttf | Bin 0 -> 153116 bytes web/public/fonts/terminal/silkscreen-OFL.txt | 93 +++++++ web/public/fonts/terminal/vt323-OFL.txt | 93 +++++++ web/src/displayControls.ts | 5 +- web/src/displayPreferences.ts | 43 +++- web/src/main.ts | 8 +- web/src/map.ts | 48 +++- web/src/packetAnimator.ts | 18 +- web/src/preferences.ts | 12 +- web/src/routeLayer.ts | 19 +- web/src/terminal.css | 82 +++++-- web/src/terminal.ts | 145 ++++++++--- web/src/terminalControls.ts | 97 ++++++++ web/src/terminalGraphics.ts | 60 +++-- web/src/terminalGrid.ts | 16 +- web/src/terminalProfile.test.ts | 54 +++++ web/src/terminalProfile.ts | 120 +++++++++ web/vite.config.ts | 1 + 37 files changed, 1230 insertions(+), 139 deletions(-) create mode 100644 docs/releases/0.9.0.md create mode 100644 docs/visual-audit-followup-2026-10-18.md create mode 100644 web/public/fonts/terminal/README.txt create mode 100644 web/public/fonts/terminal/Silkscreen-Bold.ttf create mode 100644 web/public/fonts/terminal/Silkscreen-Regular.ttf create mode 100644 web/public/fonts/terminal/VT323-Regular.ttf create mode 100644 web/public/fonts/terminal/silkscreen-OFL.txt create mode 100644 web/public/fonts/terminal/vt323-OFL.txt create mode 100644 web/src/terminalControls.ts create mode 100644 web/src/terminalProfile.test.ts create mode 100644 web/src/terminalProfile.ts diff --git a/.env.example b/.env.example index 98d01ad..f24c17f 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,6 @@ # Copy this file to .env and configure your MQTT feed. No map key or build is needed. # Keep .env and any optional .secrets files private. -CARTOLITE_IMAGE=ghcr.io/n30nex/cartolite-server:0.8.5 +CARTOLITE_IMAGE=ghcr.io/n30nex/cartolite-server:0.9.0 CARTOLITE_BIND_ADDR=127.0.0.1 CARTOLITE_PORT=8080 # Optional CARTO override: see compose.carto.yml and docs/deployment.md. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70caf06..0f5b959 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -150,6 +150,21 @@ jobs: bash scripts/publish-fixture.sh cartolite-server-mqtt testdata/synthetic-live.ndjson bash scripts/integration-smoke.sh http://127.0.0.1:39476 artifacts/integration cartolite-server-mqtt bash scripts/performance-smoke.sh http://127.0.0.1:39476 cartolite-server-mqtt + - name: Terminal presentation and isolation + if: matrix.arch == 'amd64' + working-directory: web + env: + PLAYWRIGHT_HTML_OUTPUT_DIR: terminal-playwright-report + run: npm run test:e2e -- terminal.spec.ts --output terminal-test-results + - name: Preserve Terminal review screenshots + if: always() && matrix.arch == 'amd64' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: cartolite-server-terminal-review + retention-days: 30 + path: | + web/terminal-test-results/ + web/terminal-playwright-report/ - name: UI audit and recovery regressions if: matrix.arch == 'amd64' working-directory: web @@ -165,12 +180,6 @@ jobs: path: | web/ux-audit-test-results/ web/ux-audit-playwright-report/ - - name: Terminal presentation and isolation - if: matrix.arch == 'amd64' - working-directory: web - env: - PLAYWRIGHT_HTML_OUTPUT_DIR: terminal-playwright-report - run: npm run test:e2e -- terminal.spec.ts --output terminal-test-results - name: Worldwide and terrain regressions if: matrix.arch == 'amd64' working-directory: web diff --git a/README.md b/README.md index 8f46898..2c842b7 100644 --- a/README.md +++ b/README.md @@ -192,3 +192,20 @@ Layout, region choices and route visibility are remembered in this browser. Open /terminal/ or choose **Terminal** beside Map and Netgraph (in the View picker on phones). This BBS-style geographic view uses live Braille/ASCII graphics with ANSI, amber or green phosphor. Find, inspection, layer toggles, route windows, Follow, optional 3D and opt-in sounds use the same live data and controls as Map. Set characters and cell size under **Display → Terminal display**. The character renderer is downloaded only when you open Terminal; no extra map key or service is required. See [implementation and validation plan](docs/terminal-view-plan-2026-10-17.md). + +### Terminal appearance + +Open **Terminal → Display → Terminal options** for six retro presets, per-element +colours, HSL sliders, console/pixel fonts and independent map brightness/live glow. +Choose Braille, ASCII, pixel-block or cel-shaded geography; confirmed routes stay +sharp above it. Live packets can use characters or smooth strokes. Settings are +local, migrate older Terminal choices and synchronize across tabs without changing +Map/Netgraph preferences or sound. The colour editor always keeps a readable reset. +Fonts and licences are bundled in `web/public/fonts/terminal/`. + +Terminal defaults match the owner-selected screenshots: routes, live packets, +heatmap, node/place labels, roads and buildings on; clusters, regions, Topo and +3D off; 15-minute routes and 15% Topo shading. Fine ANSI/BBS Braille, Spectacle, +Complete detail, High graphics, system motion, standard text and Neon routes. +Layer/display settings are independent from Map/Netgraph, and saved Terminal +customizations remain. Reset all display settings restores these Terminal defaults. diff --git a/VERSION b/VERSION index 7ada0d3..ac39a10 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.8.5 +0.9.0 diff --git a/compose.yml b/compose.yml index 6a0b5a8..9643e52 100644 --- a/compose.yml +++ b/compose.yml @@ -2,7 +2,7 @@ name: cartolite-server services: cartolite: - image: ${CARTOLITE_IMAGE:-ghcr.io/n30nex/cartolite-server:0.8.5} + image: ${CARTOLITE_IMAGE:-ghcr.io/n30nex/cartolite-server:0.9.0} restart: unless-stopped ports: - "${CARTOLITE_BIND_ADDR:-127.0.0.1}:${CARTOLITE_PORT:-8080}:8080" diff --git a/docs/releases/0.9.0.md b/docs/releases/0.9.0.md new file mode 100644 index 0000000..9e7b4d1 --- /dev/null +++ b/docs/releases/0.9.0.md @@ -0,0 +1,27 @@ +# 0.9.0 — Make Terminal your own + +- Add Terminal options inside Display, with BBS, Amber, Green, CGA, EGA and Cel-shaded presets. +- Adjust geography, node, packet, route, label and interface colours with a picker, hex value and hue/saturation/lightness sliders. +- Choose system mono, Typewriter, VT323 console or Silkscreen pixel fonts, with map size, weight and interface scale controls. +- Draw Braille, ASCII, pixel-block or cel-shaded maps. Keep routes sharp above the map and choose glowing characters or smooth live packets. +- Adjust background brightness, place labels and live glow independently; keep the existing six route styles and advanced controls. +- Preserve existing preferences and synchronize Terminal profiles across tabs. The editor retains readable colours and reset controls. + +Fonts are bundled locally under SIL OFL 1.1; no remote font service is used. No +backend endpoints, traffic fields, dependencies, packet/audio timing or native APK +changes. Builds, tests and screenshots run only in Actions. Release publication +and deployment evidence will be recorded in the published release. + +Terminal defaults match the owner-selected screenshots: routes, live packets, +heatmap, node/place labels, roads and buildings on; clusters, regions, Topo and +3D off; 15-minute routes and 15% Topo shading. Fine ANSI/BBS Braille, Spectacle, +Complete detail, High graphics, system motion, standard text and Neon routes. +Layer/display settings are independent from Map/Netgraph, and saved Terminal +customizations remain. Reset all display settings restores these Terminal defaults. + +Route visibility now updates the map scene and finishes its compositor handoff, +preventing stale lines after hiding routes in Safari. Terrain graphics recovery +uses stylesheet readiness rather than waiting for a tile reload to emit a new +style event. Terminal keeps its active time window visible and honours Large +text for map labels. The supplemental visual audit is tracked separately in the +roadmap; broader inspector/overview changes are not claimed in this release. diff --git a/docs/roadmap.md b/docs/roadmap.md index 59abd71..ff9eb21 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,12 +1,12 @@ # CartoLite delivery roadmap -Updated 17 October 2026. Current audit: [end-to-end UI/UX review](ui-ux-audit-2026-10-17.md). +Updated 18 October 2026. Current audit: [end-to-end UI/UX review](ui-ux-audit-2026-10-17.md). The [older roadmap](roadmap-archive-2026-09.md) is preserved as a dated record; its Planned labels and build-size caps are not current instructions. ## Previously verified release baseline -Canada **0.17.3**, Worldwide **0.8.4**, Canada Android **1.1.2**. Already shipped: +Canada **0.17.4**, Worldwide **0.8.5**, Canada Android **1.1.2**. Already shipped: shared appearance and six route presets; cinematic effects and quality/motion controls; optional Map and Netgraph 3D; buildings/topography; scoped ten-second Follow; region layout/dragging/filtering and live-only routes; compact phone @@ -23,8 +23,8 @@ It must be reviewed against current main before integration, preserving the new | Phase | Status | Deliverable | Exit condition | | --- | --- | --- | --- | | 1. Everyday navigation and recovery | Delivered | UI01–UI06/UI15: live search refresh, dialog Back/Escape, quiet dismissible map notices, actionable empty Netgraph, reachable Follow actions, stable Labs feedback and this reconciled roadmap. Released in Canada 0.17.3 / Worldwide 0.8.4. | Baseline reproduction; focused tests pass without weakening assertions; full Actions gates; reviewed paired PRs; exact images, qualified Canada deployment and Worldwide installs. | -| 2. Controls, discovery and explanations | Implemented; [delivery status](https://github.com/n30nex/CartoLite-Server/releases/tag/v0.8.5) | UI07/UI08: relevant per-view controls (including a true High-quality override in Labs), consistent visible help, clearer active filters and route/live/observer-only explanations. Silent voice browsing and reliable immediate mute. | Keyboard/Back/focus, 320px/short landscape, enlarged text, sound opt-in and no duplicate feed/layout reset. | -| 3. Animation and visual clarity | Planned | UI09/UI10: semantic Terminal network ink, dense-label priority, purposeful live emphasis, consistent ground/perspective behavior and measured sustained quality. Review each Labs scene independently. | Fixed-time synthetic GPU/Canvas/audio comparisons; all themes/presets; terrain/buildings/date-line/context loss; unchanged 4k-node/7k-route and responsiveness gates. | +| 2. Controls, discovery and explanations | Delivered; [delivery status](https://github.com/n30nex/CartoLite-Server/releases/tag/v0.8.5) | UI07/UI08: relevant per-view controls (including a true High-quality override in Labs), consistent visible help, clearer active filters and route/live/observer-only explanations. Silent voice browsing and reliable immediate mute. | Keyboard/Back/focus, 320px/short landscape, enlarged text, sound opt-in and no duplicate feed/layout reset. | +| 3. Animation and visual clarity | Active: Terminal customization | UI09/UI10: semantic Terminal network ink, dense-label priority, purposeful live emphasis, consistent ground/perspective behavior and measured sustained quality. Review each Labs scene independently. | Fixed-time synthetic GPU/Canvas/audio comparisons; all themes/presets; terrain/buildings/date-line/context loss; unchanged 4k-node/7k-route and responsiveness gates. | | 4. Recovery and release hardening | Planned integration | UI11/UI12: reconcile the separate retained/freshness/clock/region-loading work; clear development-tool advisories through existing dependency PRs; extend browser/accessibility coverage. | Conservative topology/privacy; real reconnect/failure scenarios; exact native images and checkpoint-preserving anonymous installs. No unrelated branch resets or local builds. | | 5. Optional additions | Planned | UI13/UI14: named local views, public-only share links, bounded role/recent/in-view Find filters, EN/FR Canada chrome and worldwide locale/units. Approved extra tile providers remain a separate configuration decision. | Demonstrate a useful task, reversible saved settings and explicit data/coverage limitations before broadening the UI. | @@ -63,7 +63,7 @@ passed 224 frontend and 321 browser cases; its [161 deployed browser checks](https://github.com/n30nex/CartoLite/actions/runs/39066754097) passed without retries. Three live observations over 369.4 seconds confirmed advancing traffic/checkpoints and zero drops/restarts after an encrypted backup -and isolated restore. The exact tested image remains deployed. +and isolated restore. That exact tested image was deployed and qualified. Worldwide's [exact-main CI](https://github.com/n30nex/CartoLite-Server/actions/runs/39064086634) passed 198 frontend and 273 browser cases, including native anonymous AMD64/ARM64 @@ -86,3 +86,39 @@ The new Find filters remain an optional addition; existing Find recovery is reta The linked release record carries final validation and delivery status. This versioned roadmap records the source implementation; publication alone is not proof of a healthy deployment. The release notes distinguish these stages. + +## Next package: Terminal customization + +Target Canada 0.18.0 / Worldwide 0.9.0. Add a compact Terminal options editor +inside Display with BBS, Amber, Green, CGA, EGA and Cel-shaded presets. Provide +separate semantic colours for geography, traffic, nodes, labels and interface, +colour-picker/hex/HSL sliders, independent map brightness, live glow and typography. +Keep a readable reset/editor surface even for low-contrast custom palettes. + +Convert geography before drawing the existing network layers. Preserve solid +route cores, source topology, hit geometry and packet/audio timing. Offer Braille, +ASCII, pixel blocks or flat cel-shaded geography, and character or smooth packets. +Keep bounded sampling, quality/reduced-motion behavior, fallback and context recovery. +Fonts are bundled locally with their licences and only loaded by Terminal. + +Migrate legacy phosphor/glyph/grid preferences; save the new profile separately +and synchronize Terminal tabs. Existing Map/Netgraph styles, layers and sound remain. +Actions must cover all presets, arbitrary colour separation, mobile bounds, font +loading, keyboard input, migration/reset, cross-tab behavior, unchanged live timing, +context recovery and the existing scale/privacy/installation gates. Exact-image +release and Canada qualification follow passing paired reviews. The remaining +phase 3 Labs/dense-label/terrain audit and phases 4-5 remain planned. + +Owner-selected Terminal defaults (18 October): routes/live/heatmap/labels/roads/ +buildings on; clusters/regions/Topo/3D off; 15-minute routes, 15% Topo shading; +BBS/ANSI fine Braille, Spectacle, Complete, High, system motion, standard text +and Neon routes. Terminal layer/display settings now use separate storage from +Map/Netgraph, while existing Terminal custom profiles and sound choices persist. + +## Supplemental visual audit + +The owner-supplied 15-page visual audit adds A01–A15, separate from this audit's +UI identifiers. [The follow-up](visual-audit-followup-2026-10-18.md) prioritizes a +measured selected-node/inspector layout and complete duration labels after the +Terminal package, then scope handoff, overview/selection clarity and control copy. +These findings remain planned until reproduced and verified on current code. diff --git a/docs/visual-audit-followup-2026-10-18.md b/docs/visual-audit-followup-2026-10-18.md new file mode 100644 index 0000000..0b70149 --- /dev/null +++ b/docs/visual-audit-followup-2026-10-18.md @@ -0,0 +1,52 @@ +# Supplemental visual-audit follow-up + +The owner supplied **CartoLite UI/UX Audit, 17 October 2026** (15-page PDF) on +18 October. Its A01–A15 findings come from rendered Canada browser observations; +they are distinct from UI01–UI15 in the repository's source audit. They do not +establish backend/RF defects, clean-session defaults, hardware performance or +full accessibility conformance. Reproduce the relevant states against current +code with synthetic fixtures before marking a finding fixed. Do not commit the +live examples or captures as test data. + +## Current Terminal package + +Canada 0.18.0 / Worldwide 0.9.0 address the visual-hierarchy portion of A11: +geography is drawn separately from exact network strokes; colours, map brightness, +fonts and live treatment are adjustable; labels retain an opaque backing and +Large text support. The selected marker remains `@`, and stale markers can be +quieter without dimming their names. A visible Terminal status names the route +window or live-only/hidden state. This is not a claim that all A11 inspection, +naming and handoff work is finished. + +The owner's later screenshots explicitly choose independent Terminal defaults: +fine ANSI/BBS Braille, Spectacle, Complete, High, system motion, Standard and Neon; +15-minute routes with the requested layers. These take precedence over the PDF's +suggestion to share every preference. Map and Netgraph keep their saved settings. + +## Next focused packages + +1. **Keep the subject visible — A01, A02, A07a.** Measure the actual mobile + inspector, desktop summary/inspector rail and dock. Frame the selected node + inside the remaining canvas on selection or explicit recovery; preserve manual + panning. Add readable sheet states and a clear return to the map. Show complete + duration values at 320px and with Large text. Validate Map and Netgraph in both + editions; reuse the same framing for Terminal. +2. **Explain investigation scope — A03, A08, A15.** Keep selected identity across + views and announce the destination's time/filter scope. Offer an explicit + same-window action where appropriate, preserving Terminal's chosen defaults. + Verify timestamp and retained-count meanings before changing labels. Keep the + query during no-result recovery; distinguish quiet, filtered, paused and + disconnected states. +3. **Readable overview and primary selection — A04, A05.** Collision-safe region + names, stronger primary selection, quieter neighbours and a reachable node-type + key. Retain every eligible connection and honest drill-down. Any optional + aggregated summary must be labelled and reversible; never present representative + region-centre lines as confirmed RF links. +4. **Consistent controls and outreach — A06, A07b, A09–A14.** Align panel/trigger + names and reset scopes, make camera recovery and Follow scope easier to reach, + use consistent Terminal naming, and clarify Labs keys, pause, sound and scene + rotation. Labs remains Canada-only. Review each change with before/after scenes. + +Use the existing Actions suites and synthetic scenes at desktop, 390px, 320px, +short landscape and Large text. Preserve keyboard/Back behavior, opt-in sound, +reduced motion, frame/route budgets, privacy and exact-image release checks. diff --git a/scripts/write-terminal-page.mjs b/scripts/write-terminal-page.mjs index 65abc9c..b8158b3 100644 --- a/scripts/write-terminal-page.mjs +++ b/scripts/write-terminal-page.mjs @@ -1,12 +1,20 @@ -import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; +import { mkdir, readFile, writeFile, unlink, rmdir } from 'node:fs/promises'; // Reuse the Map document and its exact asset graph. A second bundled Map entry // would move MapLibre into shared chunks also downloaded by Netgraph. const html = await readFile('dist/index.html', 'utf8'); -const assets = (await readdir('dist/assets')).filter(name => /^terminal-.*\.(?:js|css)$/.test(name)); -if (assets.filter(name => name.endsWith('.js')).length !== 1 || assets.filter(name => name.endsWith('.css')).length !== 1) throw new Error('Terminal entry assets missing or ambiguous'); -const script = ``; -const style = ``; +const manifest = JSON.parse(await readFile('dist/.vite/manifest.json','utf8')); +const entry = manifest['src/terminal.ts']; +if(!entry?.isEntry || !/^assets\/[\w.-]+\.js$/.test(entry.file))throw new Error('Terminal entry missing from build manifest'); +const visited=new Set(), css=new Set(); +function collect(chunk) { + for(const id of chunk.imports??[]) { if(visited.has(id))continue;visited.add(id);if(!manifest[id])throw new Error('Missing Terminal import');collect(manifest[id]); } + for(const path of chunk.css??[]) {if(!/^assets\/[\w.-]+\.css$/.test(path))throw new Error('Invalid Terminal CSS');if(!html.includes(`href="/${path}"`))css.add(path);} +} +collect(entry); +if(!css.size)throw new Error('Terminal stylesheet missing'); +const script = ``; +const style = [...css].map(path=>``).join('\n'); const terminal = html.replace('', '') .replace('', `\n${script}`) .replace('', `${style}\n`) @@ -16,3 +24,6 @@ const terminal = html.replace('', '({...((element as HTMLElement).dataset)}))); throw error; } await expect(map).toHaveAttribute('data-rendered-route-segments', '7000'); + if(viewPath === '/terminal/') { + // First-view timing above exercises the new High/Neon/unclustered defaults. + // Stage the same off-to-on route and clustered-to-individual interactions + // below, preserving every existing count and performance assertion. + await openMapOptions(page);await page.locator('#routes-button').click();await page.locator('#clusters-button').click(); + await page.keyboard.press('Escape');await expect(map).toHaveAttribute('data-render-state','idle'); + } await installLongTaskObserver(page); const heatmapButton = page.locator('#heatmap-button'); diff --git a/web/e2e/terminal.spec.ts b/web/e2e/terminal.spec.ts index c673bb1..5c57415 100644 --- a/web/e2e/terminal.spec.ts +++ b/web/e2e/terminal.spec.ts @@ -2,6 +2,7 @@ import { expect, test, type Page } from '@playwright/test'; import { visualFixture, visualPacket } from './visualFixtures'; import { openMapOptions } from './mapControls'; import { openViewMenu } from './dockControls'; +import type { LiveMap } from '../src/map'; test('Terminal gives live packets a bounded glow while keeping labels and quiet modes clear', async ({page},info) => { test.slow(); @@ -184,7 +185,7 @@ test('unavailable Terminal graphics falls back without stopping the feed', async test('Map and Netgraph never download the optional Terminal renderer', async ({page}) => { const terminalAssets: string[] = []; - page.on('request', request => { if (/\/terminal-[^/]+\.(?:js|css)(?:\?|$)/.test(request.url())) terminalAssets.push(request.url()); }); + page.on('request', request => { if (/\/terminal-[^/]+\.(?:js|css)(?:\?|$)|\/fonts\/terminal\//.test(request.url())) terminalAssets.push(request.url()); }); await visualFixture(page); for (const path of ['/', '/netgraph/']) { await page.goto(path); @@ -193,6 +194,232 @@ test('Map and Netgraph never download the optional Terminal renderer', async ({p expect(terminalAssets).toEqual([]); }); +test('Terminal presets keep real routes above every drawing mode and load the selected fonts',async({page},info)=>{ + test.slow();await visualFixture(page,10.2,true);await page.goto('/terminal/'); + await expect(page.locator('#map')).toHaveAttribute('data-render-state','idle'); + const errors:string[]=[];page.on('pageerror',error=>errors.push(error.message)); + let sequence=0; + for(const [preset,mode,font] of [['ansi','0','mono'],['amber','0','mono'],['green','0','mono'],['cga','2','pixel'],['ega','1','vt323'],['cell','3','mono']]) { + await openMapOptions(page);await page.getByLabel('Terminal palette').selectOption(preset!); + await page.keyboard.press('Escape'); + await expect(page.locator('#map')).toHaveAttribute('data-terminal-drawing',mode!); + await expect(page.locator('html')).toHaveAttribute('data-terminal-font',font!); + await expect(page.locator('#map')).toHaveAttribute('data-eligible-routes','1'); + await expect(page.locator('.terminal-notice')).toBeHidden(); + await expect.poll(()=>page.evaluate(()=>document.fonts.status)).toBe('loaded'); + if(font==='pixel')expect(await page.evaluate(()=>document.fonts.check('16px Silkscreen'))).toBe(true); + if(font==='vt323')expect(await page.evaluate(()=>document.fonts.check('16px VT323'))).toBe(true); + await visualPacket(page,++sequence, 'Text'); + await page.screenshot({path:info.outputPath(`preset-${preset}.png`)}); + } + expect(errors).toEqual([]); +}); + +test('Terminal starts with the requested layers and Neon appearance without overwriting Map defaults',async({page},info)=>{ + test.slow();await visualFixture(page,10.2,true); + await page.addInitScript(()=>{ + if(sessionStorage.getItem('terminal-defaults-seeded'))return; + sessionStorage.setItem('terminal-defaults-seeded','true'); + for(const prefix of ['cartolite','cartolite-server']) { + localStorage.removeItem(`${prefix}:terminal-ui:v1`); + localStorage.setItem(`${prefix}:display:v1`,JSON.stringify({preset:'dotted',quality:'economy'})); + } + }); + await page.goto('/terminal/');await expect(page.locator('#map')).toHaveAttribute('data-render-state','idle'); + await openMapOptions(page); + for(const id of ['routes','live-packets','heatmap','node-labels','map-labels','roads','buildings'])await expect(page.locator(`#${id}-button`)).toHaveAttribute('aria-pressed','true'); + for(const id of ['clusters','regions','hillshade','terrain'])if(await page.locator(`#${id}-button`).count())await expect(page.locator(`#${id}-button`)).toHaveAttribute('aria-pressed','false'); + await expect(page.locator('#route-window')).toHaveValue('15m');await expect(page.locator('#terrain-relief')).toHaveValue('15'); + for(const [label,value] of [['Terminal palette','ansi'],['Terminal characters','braille'],['Terminal cell size','10'],['Effects intensity','spectacle'],['Detail level','complete'],['Graphics quality','high'],['Motion preference','system'],['Text size','standard'],['Route style','neon']])await expect(page.getByLabel(label!,{exact:true})).toHaveValue(value!); + await page.screenshot({path:info.outputPath('terminal-defaults.png')}); + await page.locator('#route-window').selectOption('1h');await page.getByLabel('Route style',{exact:true}).selectOption('dashed'); + await page.keyboard.press('Escape');await page.goto('/');await openMapOptions(page); + await expect(page.locator('#route-window')).toHaveValue('24h');await expect(page.getByLabel('Graphics quality')).toHaveValue('economy');await expect(page.getByLabel('Route style',{exact:true})).toHaveValue('dotted'); + await page.goto('/terminal/');await openMapOptions(page); + await expect(page.locator('#route-window')).toHaveValue('1h');await expect(page.getByLabel('Route style',{exact:true})).toHaveValue('dashed'); + await page.getByRole('button',{name:'Reset all display settings',exact:true}).click(); + await expect(page.locator('#route-window')).toHaveValue('15m');await expect(page.getByLabel('Route style',{exact:true})).toHaveValue('neon'); +}); + +test('Terminal colour and typography editor preserves other views, survives refresh and synchronizes tabs',async({page,context},info)=>{ + test.slow();await visualFixture(page,10.2,true); + await page.addInitScript(()=>{ + if(!localStorage.getItem('cartolite-terminal-v2'))localStorage.setItem('cartolite-terminal-v1',JSON.stringify({palette:'amber',glyphs:'ascii',size:16})); + }); + await page.goto('/terminal/?node=visual-a'); + await expect(page.locator('#map')).toHaveAttribute('data-selected-node-id','visual-a'); + await expect(page.locator('html')).toHaveAttribute('data-terminal-palette','amber'); + await page.keyboard.press('Escape'); + const saved=await page.evaluate(()=>Object.fromEntries(Object.entries(localStorage).filter(([key])=>!key.startsWith('cartolite-terminal')&&!key.includes(':view:')))); + await openMapOptions(page); + await page.getByText('Typography',{exact:true}).click(); + await expect(page.getByLabel('Terminal map font size')).toHaveValue('16'); + await page.getByLabel('Terminal map font',{exact:true}).selectOption('vt323'); + await page.getByLabel('Terminal interface font').selectOption('pixel'); + await page.getByLabel('Terminal map font size').fill('18'); + await page.getByLabel('Terminal interface scale').fill('1.35'); + await page.getByText('Element colours',{exact:true}).click(); + await page.getByLabel('Terminal colour element').selectOption('water'); + await page.getByLabel('Terminal hue',{exact:true}).focus();await page.keyboard.press('ArrowRight'); + await page.getByLabel('Terminal colour hex').fill('#403a77'); + await page.getByLabel('Terminal colour element').selectOption('ink'); + await page.getByLabel('Terminal colour hex').fill('#050b10'); + // Even a deliberately invisible custom UI must leave the recovery editor legible. + expect(await page.locator('.terminal-settings').evaluate(el=>getComputedStyle(el).color)).toBe('rgb(213, 230, 223)'); + await page.screenshot({path:info.outputPath('terminal-editor.png')}); + const panel=(await page.locator('#layers-panel').boundingBox())!; + expect(panel.x).toBeGreaterThanOrEqual(0);expect(panel.x+panel.width).toBeLessThanOrEqual(page.viewportSize()!.width+1); + expect(await page.locator('.terminal-settings').evaluate(el=>el.scrollWidth<=el.clientWidth+1)).toBe(true); + await page.keyboard.press('Escape');await page.reload(); + await expect(page.locator('html')).toHaveAttribute('data-terminal-font','vt323'); + await expect.poll(()=>page.evaluate(()=>getComputedStyle(document.documentElement).getPropertyValue('--terminal-water').trim())).toBe('#403a77'); + expect(await page.evaluate(()=>Object.fromEntries(Object.entries(localStorage).filter(([key])=>!key.startsWith('cartolite-terminal')&&!key.includes(':view:'))))).toEqual(saved); + const other=await context.newPage();await visualFixture(other,10.2,true);await other.goto('/terminal/'); + await expect(other.locator('html')).toHaveAttribute('data-terminal-font','vt323'); + await openMapOptions(page);await page.getByRole('button',{name:'Reset Terminal display',exact:true}).click(); + await expect(other.locator('html')).toHaveAttribute('data-terminal-palette','ansi'); + await expect(other.locator('html')).toHaveAttribute('data-terminal-font','mono'); + await other.close(); +}); + +test('Terminal custom traffic ink stays visible over geography without restarting packets or camera',async({page},info)=>{ + test.slow();await visualFixture(page,10.2,true); + await page.addInitScript(()=>window.addEventListener('cartolite-map-ready',event=>Object.assign(window,{terminalHarness:(event as CustomEvent).detail}),{once:true})); + await page.goto('/terminal/');await expect(page.locator('#map')).toHaveAttribute('data-render-state','idle'); + await openMapOptions(page);await page.getByText('Element colours',{exact:true}).click(); + await page.getByLabel('Terminal colour element').selectOption('text');await page.getByLabel('Terminal colour hex').fill('#67ff23'); + await page.getByLabel('Terminal colour element').selectOption('water');await page.getByLabel('Terminal colour hex').fill('#5b183a'); + await page.getByLabel('Terminal colour element').selectOption('roads');await page.getByLabel('Terminal colour hex').fill('#ff3600'); + await page.keyboard.press('Escape'); + await expect.poll(()=>greenRoutePixels(page)).toBeGreaterThan(20); + await expect.poll(async()=>{ + const screenshot=await page.screenshot(); + return page.evaluate(async image=>{ + const img=new Image();img.src='data:image/png;base64,'+image;await img.decode(); + const canvas=document.createElement('canvas');canvas.width=img.width;canvas.height=img.height; + const ctx=canvas.getContext('2d')!;ctx.drawImage(img,0,0); + const pixels=ctx.getImageData(0,0,img.width,img.height).data;let roads=0; + for(let i=0;i110&&pixels[i+1]!<70&&pixels[i+2]!<40)roads++; + return roads; + },screenshot.toString('base64')); + }).toBeGreaterThan(30); + const order=await page.evaluate(()=>{ + const {view}=(window as unknown as {terminalHarness:{view:LiveMap}}).terminalHarness; + const ids=view.map.getLayersOrder();return [ids.indexOf('terminal-graphics'),ids.indexOf('route-exact-webgl')]; + });expect(order[0]).toBeLessThan(order[1]!); + await page.screenshot({path:info.outputPath('terminal-custom-ink.png')}); + await openMapOptions(page);await page.locator('#routes-button').click();await page.keyboard.press('Escape'); + await expect(page.locator('#map')).toHaveAttribute('data-routes-visible','false'); + await expect(page.locator('#map')).toHaveAttribute('data-render-state','idle'); + try { await expect.poll(()=>greenRoutePixels(page)).toBeLessThan(10); } + catch(error) { + const diagnostic=await page.evaluate(async()=>{ + const {view}=(window as unknown as {terminalHarness:{view:LiveMap}}).terminalHarness; + const map=view.map,gl=map.getCanvas().getContext('webgl2')!; + const before={...map.getContainer().dataset}; + const layers=map.getLayersOrder().filter(id=>id.startsWith('route')).map(id=>({id,visibility:map.getLayoutProperty(id,'visibility')})); + const gpuError=gl.getError(); + await new Promise(resolve=>{map.once('render',()=>{map.once('render',()=>resolve());map.triggerRepaint();});map.triggerRepaint();}); + return {before,after:{...map.getContainer().dataset},layers,gpuError}; + }); + await info.attach('hidden-route-diagnostic',{body:JSON.stringify({...diagnostic,pixelsAfterTwoFrames:await greenRoutePixels(page)}),contentType:'application/json'}); + await page.screenshot({path:info.outputPath('hidden-route-after-two-frames.png')}); + throw error; + } + await visualPacket(page,1,'Text'); + const continuity=await page.evaluate(()=>{ + const {view,animator}=(window as unknown as {terminalHarness:{view:LiveMap;animator:{activeRoutes:{started:number;duration:number}[]}}}).terminalHarness; + const before=animator.activeRoutes.map(route=>[route.started,route.duration]);const camera=[view.map.getCenter().toArray(),view.map.getZoom(),view.map.getPitch()]; + const input=document.querySelector('[data-terminal="mapBrightness"]')!; + input.value='.3';input.dispatchEvent(new Event('input',{bubbles:true})); + return {before,after:animator.activeRoutes.map(route=>[route.started,route.duration]),camera,afterCamera:[view.map.getCenter().toArray(),view.map.getZoom(),view.map.getPitch()]}; + }); + expect(continuity.before.length).toBe(1);expect(continuity.after).toEqual(continuity.before);expect(continuity.afterCamera).toEqual(continuity.camera); + await expect.poll(async()=>Number(await page.locator('#terminal-packets').getAttribute('data-cells'))).toBeGreaterThan(0); + await page.screenshot({path:info.outputPath('terminal-custom-live.png')}); +}); + +test('Terminal cel-shaded 3D retains its profile and routes through graphics-context recovery',async({page},info)=>{ + test.skip(info.project.name!=='desktop','Actual context-loss injection is exercised on desktop Chromium; fallback/retry runs on every browser.'); + test.slow();await visualFixture(page,14,true); + const errors:string[]=[];page.on('pageerror',error=>errors.push(error.message)); + const warnings:string[]=[];page.on('console',message=>{if(['warning','error'].includes(message.type()))warnings.push(message.text());}); + await page.addInitScript(()=>window.addEventListener('cartolite-map-ready',event=>Object.assign(window,{terminalHarness:(event as CustomEvent).detail}),{once:true})); + await page.goto('/terminal/');await expect(page.locator('#map')).toHaveAttribute('data-render-state','idle'); + await openMapOptions(page);await page.getByLabel('Terminal palette').selectOption('cell');await page.keyboard.press('Escape'); + await page.locator('#terrain-shortcut').click(); + await expect(page.locator('#map')).toHaveAttribute('data-terrain3d','true'); + await expect(page.locator('#map')).toHaveAttribute('data-camera-moving','false'); + await page.evaluate(()=>{ + const {view}=(window as unknown as {terminalHarness:{view:LiveMap}}).terminalHarness; + view.map.jumpTo({bearing:25}); + }); + await terminalMapIdle(page); + const before=await page.screenshot({path:info.outputPath('terminal-3d-before-recovery.png')}); + await page.evaluate(()=>{ + const {view}=(window as unknown as {terminalHarness:{view:LiveMap}}).terminalHarness; + const gl=view.map.getCanvas().getContext('webgl2')!,extension=gl.getExtension('WEBGL_lose_context'); + if(!extension)throw Error('Context-loss fixture unavailable'); + extension.loseContext();setTimeout(()=>extension.restoreContext(),120); + }); + await expect(page.locator('#map')).toHaveAttribute('data-route-context-restores','1'); + await expect(page.locator('#map')).toHaveAttribute('data-terminal-drawing','3'); + await expect(page.locator('html')).toHaveAttribute('data-terminal-palette','cell'); + await expect(page.locator('.terminal-notice')).toBeHidden(); + await expect.poll(()=>page.evaluate(()=>{ + const {view}=(window as unknown as {terminalHarness:{view:LiveMap}}).terminalHarness; + const ids=view.map.getLayersOrder();return ids.indexOf('terminal-graphics')message.includes('INVALID_OPERATION'))).toEqual([]); + const difference=await page.evaluate(async images=>{ + const samples:Uint8ClampedArray[]=[]; + for(const image of images){ + const img=new Image();img.src='data:image/png;base64,'+image;await img.decode(); + const canvas=document.createElement('canvas');canvas.width=img.width;canvas.height=img.height; + const ctx=canvas.getContext('2d')!;ctx.drawImage(img,0,0); + samples.push(ctx.getImageData(0,0,img.width,img.height).data); + } + let total=0,count=0; + // Same stationary scene: ignore alpha and sample sparsely, allowing small + // label/antialias differences while rejecting a broken terrain framebuffer. + for(let i=0;i{ + await page.evaluate(async()=>{ + const {view}=(window as unknown as {terminalHarness:{view:LiveMap}}).terminalHarness; + if(!view.map.loaded())await new Promise(resolve=>view.map.once('idle',()=>resolve())); + }); +} + +async function greenRoutePixels(page: Page):Promise { + const png=await page.screenshot(); + return page.evaluate(async base64=>{ + const {view}=(window as unknown as {terminalHarness:{view:LiveMap}}).terminalHarness; + const a=view.map.project([-80.35,43.45]),b=view.map.project([-80.23,43.51]); + const image=new Image();image.src='data:image/png;base64,'+base64;await image.decode(); + const canvas=document.createElement('canvas');canvas.width=image.width;canvas.height=image.height; + const ctx=canvas.getContext('2d')!;ctx.drawImage(image,0,0); + const pixels=ctx.getImageData(0,0,image.width,image.height).data; + const dx=b.x-a.x,dy=b.y-a.y,length=dx*dx+dy*dy,sx=image.width/innerWidth,sy=image.height/innerHeight; + // Sample the middle of the actual route. Endpoint labels may contain green + // emoji and must not be counted as retained network ink. + let count=0;for(let y=0;y.75||(px-t*dx)**2+(py-t*dy)**2>36)continue; + const i=(y*image.width+x)*4;if(pixels[i]!<140&&pixels[i+1]!>140&&pixels[i+2]!<100)count++; + }return count; + },png.toString('base64')); +} + // Inspect actual pixels, not a renderer's own readiness flag. Only the ANSI // character water has this bright blue in the synthetic scene's centre. async function blueGlyphPixels(page: Page): Promise { diff --git a/web/e2e/terrain-scale.spec.ts b/web/e2e/terrain-scale.spec.ts index 1dd5714..fcf383c 100644 --- a/web/e2e/terrain-scale.spec.ts +++ b/web/e2e/terrain-scale.spec.ts @@ -5,7 +5,7 @@ import type { NodeV2, RouteV2, StateV2 } from '../src/types'; test.use({ trace: 'off', screenshot: 'off' }); -test('keeps close 3D inspection responsive with 7000 retained routes', async ({ page }, info) => { +test('@terrain keeps close 3D inspection responsive with 7000 retained routes', async ({ page }, info) => { test.skip(info.project.name !== 'desktop', 'desktop terrain scale gate'); await visualFixture(page, 15); const now = Date.now(); @@ -43,7 +43,7 @@ test('keeps close 3D inspection responsive with 7000 retained routes', async ({ await page.screenshot({ path: info.outputPath('terrain-scale-close.png') }); }); -test('keeps controls responsive while preparing a dense 7000-route terrain mesh', async ({ page }, info) => { +test('@terrain keeps controls responsive while preparing a dense 7000-route terrain mesh', async ({ page }, info) => { test.skip(info.project.name !== 'desktop', 'desktop dense terrain gate'); await visualFixture(page, 13); const now = Date.now(); diff --git a/web/e2e/ui-audit.spec.ts b/web/e2e/ui-audit.spec.ts index 9cd56cd..8fee779 100644 --- a/web/e2e/ui-audit.spec.ts +++ b/web/e2e/ui-audit.spec.ts @@ -195,9 +195,10 @@ test.describe('UI audit controls', () => { await page.goto('/terminal/'); await expect(page.locator('#terminal-map')).toBeVisible(); await openDisplay(page); await expect(page.locator('#basemap-style')).toBeHidden(); await expect(page.locator('#interface-theme')).toBeHidden(); - for (const [palette, colour] of [['ansi', 'rgb(121, 248, 196)'], ['green', 'rgb(141, 255, 176)'], ['amber', 'rgb(255, 209, 138)']]) { + for (const [palette, colour] of [['ansi', 'rgb(114, 255, 207)'], ['green', 'rgb(141, 255, 176)'], ['amber', 'rgb(255, 209, 138)']]) { await page.getByLabel('Terminal palette').selectOption(palette!); - await expect(page.locator('.terminal-settings legend')).toHaveCSS('color', colour!); + // The recovery editor keeps fixed readable colours; the route preview follows traffic ink. + await expect(page.locator('.terminal-settings legend')).toHaveCSS('color', 'rgb(121, 248, 196)'); await expect(page.locator('.route-preview')).toHaveCSS('color', colour!); await expect(page.locator('.route-preview')).toHaveCSS('background-color', 'rgb(0, 0, 0)'); } diff --git a/web/e2e/visualFixtures.ts b/web/e2e/visualFixtures.ts index 94b5631..f6c17b6 100644 --- a/web/e2e/visualFixtures.ts +++ b/web/e2e/visualFixtures.ts @@ -24,7 +24,7 @@ export async function visualFixture(page: Page, zoom = 10.2, detail = false): Pr await page.route('**/api/config', (route) => route.fulfill({ json: { schemaVersion: 1, basemap: { provider: 'openfreemap' } } })); await page.addInitScript(({ center, zoom, detail }) => { const ui = { routes: true, heatmap: false, clusters: false, hillshade: false, terrain3D: false, buildings: false, mapLabels: detail, nodeLabels: detail, livePackets: true, routeWindow: '24h' }; - for (const key of ['cartolite:ui:v1', 'cartolite-server:ui:v1']) if (!localStorage.getItem(key)) localStorage.setItem(key, JSON.stringify(ui)); + for (const key of ['cartolite:ui:v1', 'cartolite-server:ui:v1','cartolite:terminal-ui:v1','cartolite-server:terminal-ui:v1']) if (!localStorage.getItem(key)) localStorage.setItem(key, JSON.stringify(ui)); for (const prefix of ['cartolite:view:v3', 'cartolite-server:view:v1']) for (const kind of ['desktop', 'mobile']) { if (!localStorage.getItem(`${prefix}:${kind}`)) localStorage.setItem(`${prefix}:${kind}`, JSON.stringify({ center, zoom })); } diff --git a/web/package-lock.json b/web/package-lock.json index 61b408c..398f972 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "cartolite-server-web", - "version": "0.8.5", + "version": "0.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cartolite-server-web", - "version": "0.8.5", + "version": "0.9.0", "dependencies": { "maplibre-gl": "6.4.1" }, diff --git a/web/package.json b/web/package.json index cd1ed7f..9513c44 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "cartolite-server-web", - "version": "0.8.5", + "version": "0.9.0", "private": true, "type": "module", "scripts": { diff --git a/web/public/fonts/terminal/README.txt b/web/public/fonts/terminal/README.txt new file mode 100644 index 0000000..6544e44 --- /dev/null +++ b/web/public/fonts/terminal/README.txt @@ -0,0 +1,10 @@ +CartoLite Terminal fonts + +VT323 - Copyright 2011 The VT323 Project Authors (peter.hull@oikoi.com). +Silkscreen - Copyright 2001 The Silkscreen Project Authors (https://github.com/googlefonts/silkscreen). + +Unmodified fonts under SIL Open Font License 1.1, bundled with their complete licences: +vt323-OFL.txt and silkscreen-OFL.txt in this directory. +Source: https://github.com/google/fonts/tree/47d54ca1ebb14fd864695f1d39dc734f39017c33/ofl +Only Terminal requests these fonts. No remote font service or tracking is used. +System mono and Typewriter use installed browser fonts with a monospace fallback. diff --git a/web/public/fonts/terminal/Silkscreen-Bold.ttf b/web/public/fonts/terminal/Silkscreen-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..6771252683388362e64aaea6e0d5d991e417c9fb GIT binary patch literal 30632 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 literal 0 HcmV?d00001 diff --git a/web/public/fonts/terminal/silkscreen-OFL.txt b/web/public/fonts/terminal/silkscreen-OFL.txt new file mode 100644 index 0000000..d26b0ed --- /dev/null +++ b/web/public/fonts/terminal/silkscreen-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2001 The Silkscreen Project Authors (https://github.com/googlefonts/silkscreen) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/web/public/fonts/terminal/vt323-OFL.txt b/web/public/fonts/terminal/vt323-OFL.txt new file mode 100644 index 0000000..bcb092f --- /dev/null +++ b/web/public/fonts/terminal/vt323-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2011, The VT323 Project Authors (peter.hull@oikoi.com) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/web/src/displayControls.ts b/web/src/displayControls.ts index 466809c..62d43dd 100644 --- a/web/src/displayControls.ts +++ b/web/src/displayControls.ts @@ -1,4 +1,5 @@ import './display.css'; +import { TERMINAL_PAGE } from './preferences'; import './cinematic.css'; import { DEFAULT_DISPLAY, DISPLAY_EVENT, ROUTE_PRESETS, displayPreferences, initializeDisplay, updateDisplay, type DisplayPreferences } from './displayPreferences'; @@ -25,7 +26,7 @@ export function mountDisplayControls(parent: HTMLElement, surface: 'map' | 'netg ] as const).map(([key, label, min, max, step, unit]) => ``).join('')} ` : '

'} -

${surface === 'labs' ? 'Effects, motion, graphics, text and interface settings are shared. Each experiment keeps its own artistic setting; route styling stays in Map, Netgraph and Terminal.' : 'Shared across Map, Netgraph and Terminal in this browser. Route styling changes appearance, not connections or packet timing.'}

`; +

${surface === 'labs' ? 'Effects, motion, graphics, text and interface settings are shared. Each experiment keeps its own artistic setting; route styling stays in Map, Netgraph and Terminal.' : (TERMINAL_PAGE ? 'Terminal saves its own display settings. Map and Netgraph keep their choices. Styling never changes connections or packet timing.' : 'Shared across Map and Netgraph in this browser. Terminal saves its own display settings. Styling never changes connections or packet timing.')}

`; const oldOpacity = parent.querySelector('#route-opacity'); const oldOpacityLabel = oldOpacity?.closest('label'); const duplicateOpacity = section.querySelector('[data-display="opacity"]')?.closest('label'); @@ -61,7 +62,7 @@ export function mountDisplayControls(parent: HTMLElement, surface: 'map' | 'netg if (input.value !== 'custom') updateDisplay({ preset: input.value as DisplayPreferences['preset'], ...ROUTE_PRESETS[input.value as keyof typeof ROUTE_PRESETS] }); } else updateDisplay({ [key]: input.value, ...(key === 'pattern' ? { preset: 'custom' as const } : {}) }); }); - section.querySelector('.display-reset')?.addEventListener('click', () => updateDisplay({ preset: DEFAULT_DISPLAY.preset, ...ROUTE_PRESETS.crisp })); + section.querySelector('.display-reset')?.addEventListener('click', () => updateDisplay({ preset: DEFAULT_DISPLAY.preset, ...ROUTE_PRESETS[DEFAULT_DISPLAY.preset === 'neon' ? 'neon' : 'crisp'] })); window.addEventListener(DISPLAY_EVENT, sync); sync(); } diff --git a/web/src/displayPreferences.ts b/web/src/displayPreferences.ts index 8b11110..8801728 100644 --- a/web/src/displayPreferences.ts +++ b/web/src/displayPreferences.ts @@ -1,4 +1,4 @@ -import { UI_STORAGE_KEY, type BasemapStyle, type InterfaceTheme } from './preferences'; +import { TERMINAL_PAGE, UI_STORAGE_KEY, type BasemapStyle, type InterfaceTheme } from './preferences'; import { PACKET_KIND_COLORS as DARK_KINDS } from './trafficVisuals'; export type LinePattern = 'solid' | 'dashed' | 'dotted'; @@ -21,7 +21,7 @@ export interface DisplayPreferences { residueSeconds: number; } -export const DISPLAY_STORAGE_KEY = UI_STORAGE_KEY.replace(':ui:', ':display:'); +export const DISPLAY_STORAGE_KEY = UI_STORAGE_KEY.replace(':ui:', ':display:').replace(':terminal-ui:', ':terminal-display:'); export const DISPLAY_EVENT = 'cartolite:display-change'; export const ROUTE_PRESETS = { crisp: { pattern: 'solid', width: 1.6, opacity: 0.8, glow: 0.15, packetSize: 1, trailLength: 1, residueSeconds: 15 }, @@ -31,7 +31,7 @@ export const ROUTE_PRESETS = { ribbon: { pattern: 'solid', width: 3.5, opacity: 0.75, glow: 0.1, packetSize: 1.35, trailLength: 1, residueSeconds: 12 }, comet: { pattern: 'solid', width: 1.4, opacity: 0.8, glow: 0.5, packetSize: 1.4, trailLength: 2, residueSeconds: 30 }, } as const; -export const DEFAULT_DISPLAY: DisplayPreferences = { effects: 'spectacle', detail: 'auto', quality: 'auto', motion: 'system', textSize: 'standard', basemap: 'dark', theme: 'map', preset: 'crisp', ...ROUTE_PRESETS.crisp }; +export const DEFAULT_DISPLAY: DisplayPreferences = { effects: 'spectacle', detail: TERMINAL_PAGE ? 'complete' : 'auto', quality: TERMINAL_PAGE ? 'high' : 'auto', motion: 'system', textSize: 'standard', basemap: 'dark', theme: 'map', preset: TERMINAL_PAGE ? 'neon' : 'crisp', ...(TERMINAL_PAGE ? ROUTE_PRESETS.neon : ROUTE_PRESETS.crisp) }; const LIGHT_KINDS = { Advert: '#006957', Trace: '#855000', Text: '#a21b58', ACK: '#075b98', Control: '#6740a0', Other: '#445760' }; const LIGHT_COLORS: Record = { @@ -51,16 +51,29 @@ const LIGHT_COLORS: Record = { let current: DisplayPreferences = { ...DEFAULT_DISPLAY }; let initialized = false; +// Optional per-view colour presentation. Terminal installs this after its lazy +// entry loads; shared preferences and other pages never import its renderer. +let viewColors: Record = {}; +let viewLightColors: Record = LIGHT_COLORS; +let hasViewColors = false; +let colorRevision = 0; +export function displayColorRevision(): number { return colorRevision; } +export function setDisplayColors(colors: Record = {}): void { + const safe = Object.fromEntries(Object.entries(colors).filter(([from,to]) => /^#[0-9a-f]{6}$/i.test(from) && /^#[0-9a-f]{6}$/i.test(to)).map(([from,to])=>[from.toLowerCase(),to.toLowerCase()])); + if(JSON.stringify(safe)===JSON.stringify(viewColors))return; + viewColors=safe; hasViewColors=Object.keys(safe).length>0; viewLightColors={...LIGHT_COLORS,...safe}; colorRevision++; +} export function displayPreferences(): Readonly { return current; } export function lightScene(): boolean { return !document.documentElement.hasAttribute('data-terminal') && current.basemap !== 'dark'; } export function displayColor(color: string, light = lightScene()): string { - if (!light) return color; - const direct = LIGHT_COLORS[color.toLowerCase()]; + if(!light && !hasViewColors)return color; + const colors = light ? viewLightColors : viewColors; + const direct = colors[color.toLowerCase()]; if (direct) return direct; const rgba = /^rgba?\(\s*(\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\s*\)$/i.exec(color); if (!rgba) return color; const hex = '#' + rgba.slice(1, 4).map((channel) => Number(channel).toString(16).padStart(2, '0')).join(''); - const mapped = LIGHT_COLORS[hex]; + const mapped = colors[hex]; if (!mapped) return color; const value = Number.parseInt(mapped.slice(1), 16); return `rgba(${(value >> 16) & 255}, ${(value >> 8) & 255}, ${value & 255}, ${rgba[4] ?? 1})`; @@ -68,12 +81,16 @@ export function displayColor(color: string, light = lightScene()): string { export function recolorPaint(value: unknown, light: boolean): unknown { if (typeof value === 'string') return displayColor(value, light); if (!Array.isArray(value)) return value; - if (light && value[0] === 'get' && typeof value[1] === 'string' && /^color(?:\d|$)/.test(value[1])) { - return ['match', value, ...Object.entries(LIGHT_COLORS).flat(), value]; + const colors = light ? viewLightColors : viewColors; + if ((light || hasViewColors) && value[0] === 'get' && typeof value[1] === 'string' && /^color(?:\d|$)/.test(value[1])) { + return ['match', value, ...Object.entries(colors).flat(), value]; } return value.map((item) => recolorPaint(item, light)); } -export function packetPalette(light = lightScene()): typeof DARK_KINDS { return light ? LIGHT_KINDS : DARK_KINDS; } +export function packetPalette(light = lightScene()): typeof DARK_KINDS { + if(!hasViewColors)return light ? LIGHT_KINDS : DARK_KINDS; + return Object.fromEntries(Object.entries(DARK_KINDS).map(([kind,color])=>[kind,displayColor(color,light)])) as typeof DARK_KINDS; +} export function canvasColorWithAlpha(color: string, alpha: number): string { const resolved = displayColor(color); const value = Number.parseInt(resolved.startsWith('#') ? resolved.slice(1) : 'ffffff', 16); @@ -93,13 +110,13 @@ export function normalizeDisplay(value: unknown): DisplayPreferences { }; return { effects: v.effects === 'calm' || v.effects === 'minimal' ? v.effects : 'spectacle', - detail: v.detail === 'complete' ? 'complete' : 'auto', - quality: v.quality === 'high' || v.quality === 'economy' ? v.quality : 'auto', - motion: v.motion === 'full' || v.motion === 'reduced' ? v.motion : 'system', + detail: v.detail === 'complete' || v.detail === 'auto' ? v.detail : DEFAULT_DISPLAY.detail, + quality: v.quality === 'high' || v.quality === 'economy' || v.quality === 'auto' ? v.quality : DEFAULT_DISPLAY.quality, + motion: v.motion === 'full' || v.motion === 'reduced' || v.motion === 'system' ? v.motion : DEFAULT_DISPLAY.motion, textSize: v.textSize === 'large' ? 'large' : 'standard', basemap: v.basemap === 'light' || v.basemap === 'streets' ? v.basemap : 'dark', theme: v.theme === 'light' || v.theme === 'dark' ? v.theme : 'map', - preset: v.preset === 'custom' || (typeof v.preset === 'string' && Object.hasOwn(ROUTE_PRESETS, v.preset)) ? v.preset as RoutePreset : 'crisp', + preset: v.preset === 'custom' || (typeof v.preset === 'string' && Object.hasOwn(ROUTE_PRESETS, v.preset)) ? v.preset as RoutePreset : DEFAULT_DISPLAY.preset, pattern: v.pattern === 'dashed' || v.pattern === 'dotted' ? v.pattern : 'solid', width: amount('width', 1, 5), opacity: amount('opacity', 0.2, 1), glow: amount('glow', 0, 1), packetSize: amount('packetSize', 0.75, 2), trailLength: amount('trailLength', 0.5, 2), residueSeconds: amount('residueSeconds', 0, 45), diff --git a/web/src/main.ts b/web/src/main.ts index 7874a87..bea1c9c 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -311,16 +311,16 @@ async function start(): Promise { const button = document.getElementById(id); if (button && uiPreferences[key] !== DEFAULT_UI_PREFERENCES[key]) button.click(); } - persistUiPreference({ relief: DEFAULT_UI_PREFERENCES.relief, routeWindow: 'auto' }); - routeWindow.value = 'auto'; - liveMap.setRouteWindow('auto'); + persistUiPreference({ relief: DEFAULT_UI_PREFERENCES.relief, routeWindow: DEFAULT_UI_PREFERENCES.routeWindow }); + routeWindow.value = DEFAULT_UI_PREFERENCES.routeWindow; + liveMap.setRouteWindow(DEFAULT_UI_PREFERENCES.routeWindow); applyAppearance(); }; required('reset-layers').addEventListener('click', resetLayers); required('reset-appearance').addEventListener('click', () => { resetLayers(); if (legendExpanded) legendToggle.click(); - persistUiPreference({ basemap: 'dark', theme: 'map', routeOpacity: 0.8, terrainExaggeration: 1, terrainPitch: 50, terrainBearing: 0 }); + persistUiPreference({ basemap: 'dark', theme: 'map', routeOpacity: DEFAULT_UI_PREFERENCES.routeOpacity, terrainExaggeration: 1, terrainPitch: 50, terrainBearing: 0 }); updateDisplay({ ...DEFAULT_DISPLAY }); applyAppearance(); }); diff --git a/web/src/map.ts b/web/src/map.ts index 028b700..3b1c601 100644 --- a/web/src/map.ts +++ b/web/src/map.ts @@ -281,7 +281,13 @@ export class LiveMap { const creditSize = new ResizeObserver(() => document.documentElement.style.setProperty('--map-credits-height', `${credits.offsetHeight}px`)); creditSize.observe(credits); this.map.once('remove', () => creditSize.disconnect()); - this.map.on('load', () => this.#installLayers()); + // Evented runs on listeners before once listeners. Keep initial indexing + // ahead of load consumers (Find), then unregister before graphics recovery. + const installInitialLayers = (): void => { + this.map.off('load', installInitialLayers); + this.#installLayers(); + }; + this.map.on('load', installInitialLayers); this.map.on('movestart', () => { this.container.dataset.cameraMoving = 'true'; }); this.map.on('sourcedata', (event) => { if (this.#buildingSourceID && event.sourceId === this.#buildingSourceID) { @@ -300,6 +306,8 @@ export class LiveMap { this.map.on('zoom', this.#updateRouteRepresentation); this.map.on('zoomend', this.#handleZoomEnd); this.map.on('resize', this.#handleInspectorResize); + this.map.getCanvas().addEventListener('webglcontextlost', this.#prepareWebGLContextLoss, true); + this.map.on('webglcontextlost', this.#handleWebGLContextLost); this.map.on('webglcontextrestored', this.#handleWebGLContextRestored); document.addEventListener('keydown', this.#handleKeyDown); this.#freshnessTimer = window.setInterval(() => this.#refreshRouteClock(), 60_000); @@ -936,6 +944,8 @@ export class LiveMap { this.map.off('zoom', this.#updateRouteRepresentation); this.map.off('zoomend', this.#handleZoomEnd); this.map.off('resize', this.#handleInspectorResize); + this.map.getCanvas().removeEventListener('webglcontextlost', this.#prepareWebGLContextLoss, true); + this.map.off('webglcontextlost', this.#handleWebGLContextLost); this.map.off('webglcontextrestored', this.#handleWebGLContextRestored); this.map.remove(); } @@ -1816,17 +1826,41 @@ export class LiveMap { } #handleInspectorResize = (): void => { + if(!this.#layersReady)return; this.#updateBuildingLayer(); if (this.#selectedNodeID) this.#renderNodeInspector(true); }; + #prepareWebGLContextLoss = (): void => { + // Release terrain's tile textures into the OLD painter before MapLibre + // replaces it. Otherwise its terrain teardown recycles invalid GPU handles + // into the restored painter. Keep our 3D preference and camera unchanged. + if (this.map.getTerrain()) this.map.setTerrain(null); + }; + + #handleWebGLContextLost = (): void => { + // MapLibre discards its stylesheet during context loss. Resize/data events + // can run before the replacement stylesheet exists; retain state, not writes. + this.#layersReady=false; + this.#routeHydrationEpoch++; + this.#routeHydrating=false; + if(this.#routeHydrationTimer!==undefined)window.clearTimeout(this.#routeHydrationTimer); + this.#routeHydrationTimer=undefined; + this.renderEpoch++;this.renderingScheduled=false;this.renderingSources.clear(); + this.container.dataset.graphicsRecovering='true'; + }; + #handleWebGLContextRestored = (): void => { const restore = (): void => { - if (!this.#layersReady) return; - if (!this.map.isStyleLoaded()) { + // Restored terrain tiles may still be loading, without another style.load + // event. Layer presence establishes the stylesheet, not global tile idle. + if (!this.map.getLayer(ROUTE_FOCUS_LAYER_IDS[0])) { + // Loss before first load is completed by the ordinary initial installer. + if(this.map.getLayer('basemap-water')&&!this.map.getSource(NODE_SOURCE_ID))return; this.map.once('style.load', restore); return; } + this.#layersReady=true; if (this.map.getLayer(ROUTE_WEBGL_LAYER_ID)) this.map.removeLayer(ROUTE_WEBGL_LAYER_ID); const layer = new HistoricalRouteLayer(); layer.setOpacity(this.#appearance.routeOpacity); @@ -1837,6 +1871,14 @@ export class LiveMap { this.#historicalRouteLayer = layer; const before = this.map.getLayer(ROUTE_FOCUS_LAYER_IDS[0]) ? ROUTE_FOCUS_LAYER_IDS[0] : undefined; this.map.addLayer(layer, before); + this.setAppearance(this.#appearance,true); + if (this.#terrain3D) { + this.#ensureTerrainLayers(); + this.map.setTerrain({ source: TERRAIN_SOURCE_ID, exaggeration: this.#appearance.terrainExaggeration }); + } + this.render(this.#lastState,{reset:true},true); + this.showFollowPacket(this.#followedPacket); + this.container.dataset.graphicsRecovering='false'; this.container.dataset.routeContextRestores = String(Number(this.container.dataset.routeContextRestores ?? 0) + 1); }; window.requestAnimationFrame(restore); diff --git a/web/src/packetAnimator.ts b/web/src/packetAnimator.ts index 706e082..7aafeae 100644 --- a/web/src/packetAnimator.ts +++ b/web/src/packetAnimator.ts @@ -305,6 +305,7 @@ export class PacketAnimator { #residueCacheUpdatedAt = Number.NEGATIVE_INFINITY; #dpr = 1; #scheduledWakeCount = 0; + #presentationGlow = 1; #frameSink?: (canvas?: HTMLCanvasElement) => void; #appliedQuality?: VisualQuality; @@ -384,7 +385,10 @@ export class PacketAnimator { this.#requestFrame(); } - /** Optional presentation sink; never owns packet progression or audio. */ + /** Presentation only; never changes packet progression or audio. */ + setPresentationGlow(scale: number): void { this.#presentationGlow = Math.max(0, Math.min(2, Number.isFinite(scale) ? scale : 1)); this.#residueContentDirty = true; this.#requestFrame(); } + #glow(): number { return Math.min(1,displayPreferences().glow * this.#presentationGlow); } + setFrameSink(sink?: (canvas?: HTMLCanvasElement) => void): void { this.#frameSink = sink; } setPaused(paused: boolean): void { @@ -608,7 +612,7 @@ export class PacketAnimator { const coreWidth = this.#reducedMotion ? 1.8 : style.coreWidth; const coreColor = this.#reducedMotion ? item.color : blendWithWhite(item.color, style.hot * 0.16); traceSurfacePath(context, this.projection.projectSegment(item.segment)); - context.strokeStyle = withAlpha(item.color, Math.min(0.7, bloomOpacity * rangeBoost * detail) * displayPreferences().glow); + context.strokeStyle = withAlpha(item.color, Math.min(0.7, bloomOpacity * rangeBoost * detail) * this.#glow()); context.lineWidth = bloomWidth * rangeBoost * detail; context.stroke(); context.setLineDash(lineDash()); @@ -755,7 +759,7 @@ export class PacketAnimator { const width = quality === 'full' ? 7.2 : quality === 'balanced' ? 5.8 : 3.8; this.#context.lineWidth = width * (longHaul ? 1.42 : 1) * (trail.head.scale ?? 1); traceSurfacePath(this.#context, trail.points ?? [trail.tail, trail.head]); - this.#context.globalAlpha = displayPreferences().glow * effectStrength(); + this.#context.globalAlpha = this.#glow() * effectStrength(); this.#context.stroke(); this.#context.globalAlpha = 1; const core = this.#context.createLinearGradient(trail.tail.x, trail.tail.y, trail.head.x, trail.head.y); @@ -818,7 +822,7 @@ export class PacketAnimator { } #drawStaticSegment(points: readonly ScreenPoint[], color: string, opacity: number, _signature: PacketSignature): void { - this.#context.strokeStyle = withAlpha(color, opacity * 0.2 * displayPreferences().glow); + this.#context.strokeStyle = withAlpha(color, opacity * 0.2 * this.#glow()); this.#context.lineWidth = 7; traceSurfacePath(this.#context, points); this.#context.stroke(); @@ -859,7 +863,7 @@ export class PacketAnimator { this.#context.fillStyle = glow; this.#context.beginPath(); this.#context.arc(point.x, point.y, radius, 0, Math.PI * 2); - this.#context.globalAlpha = displayPreferences().glow * effectStrength(); + this.#context.globalAlpha = this.#glow() * effectStrength(); this.#context.fill(); this.#context.globalAlpha = 1; this.#context.fillStyle = displayColor(color); @@ -922,8 +926,8 @@ export class PacketAnimator { return; } const gradient = this.#context.createRadialGradient(point.x, point.y, 0, point.x, point.y, radius); - gradient.addColorStop(0, withAlpha(blendWithWhite(color, 0.3), timing.opacity * 0.62 * displayPreferences().glow)); - gradient.addColorStop(0.2, withAlpha(color, timing.opacity * 0.5 * displayPreferences().glow)); + gradient.addColorStop(0, withAlpha(blendWithWhite(color, 0.3), timing.opacity * 0.62 * this.#glow())); + gradient.addColorStop(0.2, withAlpha(color, timing.opacity * 0.5 * this.#glow())); gradient.addColorStop(1, withAlpha(color, 0)); this.#context.fillStyle = gradient; this.#context.beginPath(); diff --git a/web/src/preferences.ts b/web/src/preferences.ts index f41df69..0928370 100644 --- a/web/src/preferences.ts +++ b/web/src/preferences.ts @@ -31,7 +31,8 @@ export interface UiPreferences { } const VIEW_STORAGE_PREFIX = 'cartolite-server:view:v1'; -export const UI_STORAGE_KEY = 'cartolite-server:ui:v1'; +export const TERMINAL_PAGE = typeof document !== 'undefined' && document.documentElement.hasAttribute('data-terminal'); +export const UI_STORAGE_KEY = TERMINAL_PAGE ? 'cartolite-server:terminal-ui:v1' : 'cartolite-server:ui:v1'; export const DEFAULT_UI_PREFERENCES: UiPreferences = { routes: false, heatmap: true, @@ -51,7 +52,8 @@ export const DEFAULT_UI_PREFERENCES: UiPreferences = { roads: true, livePackets: true, routeOpacity: 0.8, - relief: 0.75 + relief: 0.75, + ...(TERMINAL_PAGE ? { routes:true,heatmap:true,clusters:false,hillshade:false,terrain3D:false,buildings:true,routeWindow:'15m' as const,mapLabels:true,nodeLabels:true,roads:true,livePackets:true,relief:.15,routeOpacity:.9 } : {}), }; export function viewClass( @@ -102,7 +104,7 @@ export function loadUiPreferences(storage: Storage): UiPreferences { clusters: typeof value.clusters === 'boolean' ? value.clusters : DEFAULT_UI_PREFERENCES.clusters, hillshade: typeof value.hillshade === 'boolean' ? value.hillshade : DEFAULT_UI_PREFERENCES.hillshade, terrain3D: typeof value.terrain3D === 'boolean' ? value.terrain3D : DEFAULT_UI_PREFERENCES.terrain3D, - buildings: typeof value.buildings === 'boolean' ? value.buildings : Boolean(value.terrain3D), + buildings: typeof value.buildings === 'boolean' ? value.buildings : Boolean(value.terrain3D) || DEFAULT_UI_PREFERENCES.buildings, terrainExaggeration: savedRange(value.terrainExaggeration, 1, 0.5, 2), terrainPitch: savedRange(value.terrainPitch, 50, 0, 65), terrainBearing: savedRange(value.terrainBearing, 0, -180, 180), @@ -116,8 +118,8 @@ export function loadUiPreferences(storage: Storage): UiPreferences { nodeLabels: typeof value.nodeLabels === 'boolean' ? value.nodeLabels : true, roads: typeof value.roads === 'boolean' ? value.roads : true, livePackets: typeof value.livePackets === 'boolean' ? value.livePackets : true, - routeOpacity: savedAmount(value.routeOpacity, 0.8, 0.2), - relief: savedAmount(value.relief, 0.75, 0) + routeOpacity: savedAmount(value.routeOpacity, DEFAULT_UI_PREFERENCES.routeOpacity, 0.2), + relief: savedAmount(value.relief, DEFAULT_UI_PREFERENCES.relief, 0) }; } catch { return { ...DEFAULT_UI_PREFERENCES }; diff --git a/web/src/routeLayer.ts b/web/src/routeLayer.ts index 588afbe..105b45a 100644 --- a/web/src/routeLayer.ts +++ b/web/src/routeLayer.ts @@ -2,7 +2,7 @@ import { LngLat, MercatorCoordinate, type CustomLayerInterface, type CustomRende import type { Feature, LineString } from 'geojson'; import { splitWorldRoute, routeCoordinate, longitudeDelta, type Coordinate } from './worldGeometry'; import { adaptiveSurfacePath, type SurfacePoint } from './terrainProjection'; -import { displayColor, displayPreferences } from './displayPreferences'; +import { displayColor, displayColorRevision, displayPreferences } from './displayPreferences'; export const ROUTE_WEBGL_LAYER_ID = 'route-exact-webgl'; // From xyz, to xyz, corner (along/side), rgb, alpha, age band. @@ -44,6 +44,7 @@ export class HistoricalRouteLayer implements CustomLayerInterface { #cameraKey = ''; #buffers: GPUChunk[] = []; #bufferTerrain = false; + #colorRevision = -1; onAdd(map: MapLibreMap, gl: GL): void { this.#map = map; @@ -72,6 +73,7 @@ export class HistoricalRouteLayer implements CustomLayerInterface { map.off('moveend', this.#cameraChanged); map.off('terrain', this.#terrainChanged); map.off('sourcedata', this.#sourceChanged); + map.off('render',this.#settleVisibility); this.#clearBuffers(); if (this.#resources) gl.deleteProgram(this.#resources.program); this.#resources = undefined; @@ -104,6 +106,7 @@ export class HistoricalRouteLayer implements CustomLayerInterface { this.#refreshTimer = setTimeout(() => { this.#refreshTimer = undefined; this.#invalidate(); }, delay); } #invalidate = (): void => { this.#dirty = true; if (this.#visible) this.#map?.triggerRepaint(); }; + #settleVisibility = (): void => { this.#map?.triggerRepaint(); }; #terrainChanged = (): void => { this.#cancelBuild(); // A flat mesh has neither terrain heights nor viewport culling. Do not draw @@ -127,6 +130,12 @@ export class HistoricalRouteLayer implements CustomLayerInterface { if (this.#visible === visible) return; this.#visible = visible; if (!visible) this.#cancelBuild(); + // Keep this custom layer's slot stable: native visibility changes rebuild + // terrain's render-to-texture stacks. The draw guard hides the strokes. + // Complete the compositor handoff even when the scene becomes entirely + // still. This is one final frame per toggle, not a permanent repaint loop. + this.#map?.off('render',this.#settleVisibility); + this.#map?.once('render',this.#settleVisibility); this.#map?.triggerRepaint(); } setMaximumBand(band: number): void { @@ -292,6 +301,9 @@ export class HistoricalRouteLayer implements CustomLayerInterface { } render(gl: GL, options: CustomRenderMethodInput): void { if (!this.#resources || !this.#map || !this.#visible) return; + if(this.#colorRevision !== displayColorRevision()) { + this.#colorRevision=displayColorRevision(); this.#cancelBuild(); + } if (this.#dirty && !this.#building && !this.#prepared) void this.#upload(Array.from(options.defaultProjectionData.mainMatrix)).catch(() => { this.#building = false; if (this.#map) this.#map.getContainer().dataset.routeMeshBusy = 'false'; @@ -371,11 +383,12 @@ export class HistoricalRouteLayer implements CustomLayerInterface { gl.uniform1f(uniforms.glow!, denseOverview ? 0 : settings.glow * clamp((this.#map.getZoom() - 3) / 7, 0.15, 1)); gl.uniform1f(uniforms.outline!, denseOverview ? 0 : 1); gl.uniform1f(uniforms.simple!, denseOverview ? 1 : 0); + // Character conversion needs a clear foreground signal; keep the same + // routes/patterns and the user's opacity control, without density dimming. gl.uniform1f(uniforms.opacity!, Math.min(1,this.#opacity * (terminal ? 1.35 : settings.detail === 'auto' ? clamp(1 - Math.log2(1 + this.#routes.length / 150) * .1, .38, .9) : 1))); gl.uniform1f(uniforms.maximum_band!, this.#maximumBand); gl.uniform1f(uniforms.pattern!, settings.pattern === 'dashed' ? 1 : settings.pattern === 'dotted' ? 2 : 0); - if (this.#lightBackground) gl.uniform3f(uniforms.casing!, 0.97, 0.99, 0.95); - else gl.uniform3f(uniforms.casing!, 0.025, 0.055, 0.075); + gl.uniform3fv(uniforms.casing!, parseColor(displayColor('#061216',this.#lightBackground))); for (const chunk of this.#buffers) { gl.bindBuffer(gl.ARRAY_BUFFER, chunk.buffer); let offset = 0; diff --git a/web/src/terminal.css b/web/src/terminal.css index 0722df6..a4621fc 100644 --- a/web/src/terminal.css +++ b/web/src/terminal.css @@ -1,40 +1,86 @@ +@font-face { font-family:VT323; src:url('/fonts/terminal/VT323-Regular.ttf') format('truetype'); font-style:normal; font-weight:400; font-display:swap; } +@font-face { font-family:Silkscreen; src:url('/fonts/terminal/Silkscreen-Regular.ttf') format('truetype'); font-style:normal; font-weight:400; font-display:swap; } +@font-face { font-family:Silkscreen; src:url('/fonts/terminal/Silkscreen-Bold.ttf') format('truetype'); font-style:normal; font-weight:700; font-display:swap; } :root[data-terminal][data-theme] { - --panel-solid: #050b10; --panel-border: #527885; --ink-strong: #e9e9bf; - --ink: #d5e6df; --muted: #a5bdbb; --field-bg: #08121a; --selected: #193c3e; - --focus-ring: #79f8c4; --terminal-accent: #79f8c4; color-scheme: dark; - font-family: ui-monospace, "Cascadia Mono", "DejaVu Sans Mono", monospace; + --panel-solid:var(--terminal-panel,#050b10); --panel-border:var(--terminal-border,#527885); --ink-strong:var(--terminal-strong,#e9e9bf); + --ink:var(--terminal-ink,#d5e6df); --muted:var(--terminal-muted,#a5bdbb); --field-bg:var(--terminal-field,#08121a); --selected:var(--terminal-active,#193c3e); + --focus-ring:var(--terminal-accent,#79f8c4); color-scheme:dark; + --terminal-text-factor:1; + font-family:var(--terminal-font,ui-monospace,monospace); font-synthesis:weight; } -:root[data-terminal][data-terminal-palette="amber"] { --terminal-accent: #ffd18a; } -:root[data-terminal][data-terminal-palette="green"] { --terminal-accent: #8dffb0; } -[data-terminal] .route-preview { background:#000; color:var(--terminal-accent); } -[data-terminal] body, [data-terminal] #map { background: #000; } -[data-terminal] .glass, [data-terminal] dialog { border-radius: 2px; background: #050b10; border-color: #527885; box-shadow: none; backdrop-filter: none; color: #d5e6df; } +:root[data-terminal][data-text-size="large"] { --terminal-text-factor:1.15; } +[data-terminal] .route-preview { background:var(--terminal-background,#000); color:var(--terminal-advert,var(--terminal-accent)); } +[data-terminal] body, [data-terminal] #map { background:var(--terminal-background,#000); } +html:root[data-terminal] :is(.glass,dialog,.maplibregl-popup-content,.maplibregl-ctrl-attrib) { border-radius:2px; background:var(--panel-solid); border-color:var(--panel-border); box-shadow:none; backdrop-filter:none; color:var(--ink); font-family:inherit; } [data-terminal] button, [data-terminal] select, [data-terminal] input { font-family: inherit; } +[data-terminal] :is(.control-button,.dock-views a,.node-inspector,.sound-panel,.map-appearance,.layer-toggles,.follow-card) { font-size:calc(11px * var(--terminal-scale,1) * var(--terminal-text-factor,1)); } +[data-terminal] :is(.display-settings,.sound-panel,.camera-settings) label { font-size:calc(12px * var(--terminal-scale,1) * var(--terminal-text-factor,1)); } +[data-terminal] :is(.node-name,.node-search-name,.neighbor-label) { color:var(--ink-strong); } +[data-terminal] :is(.node-meta,.node-search-meta,.neighbor-meta,.sound-hint) { color:var(--muted); } +[data-terminal] :is(.sound-unlock-hint,.map-notice) { color:var(--terminal-warning); } +[data-terminal] :is(button,input,select):disabled { color:var(--muted); opacity:.7; } +[data-terminal] :is(input,select) { background:var(--field-bg); color:var(--ink); border-color:var(--panel-border); } +[data-terminal] :is(button,a):focus-visible { outline:2px solid var(--focus-ring); outline-offset:2px; } +html:root[data-terminal] :is(.control-button,.node-search-result,.neighbor-row,.follow-card,.node-inspector,.tooltip,.route-legend,.legend,.sound-panel,.maplibregl-ctrl-attrib) { color:var(--ink); } +html:root[data-terminal] :is(.tooltip strong,.node-inspector strong,.node-inspector h3,.node-search-result strong,.follow-subject,.follow-title) { color:var(--ink-strong); } +html:root[data-terminal] :is(.tooltip span,.node-inspector dt,.node-role,.node-search-result span,.follow-detail,.follow-meta,.sound-description,.display-note,.layer-hint) { color:var(--muted); } +html:root[data-terminal] :is(.control-button.selected,.control-button.active,.control-button[aria-pressed="true"],.node-search-result[aria-selected="true"],.node-search-result:hover,.neighbor-row:hover) { color:var(--ink-strong); background:var(--selected); } +html:root[data-terminal] :is(.control-button,.node-search-result,.neighbor-row,.maplibregl-popup-content,.sound-panel button) { border-color:var(--panel-border); } +html:root[data-terminal] :is(.node-search-result:focus-visible,.neighbor-row:focus-visible) { box-shadow:inset 0 0 0 2px var(--focus-ring); } +html:root[data-terminal] .focus-chip i { background:var(--terminal-selection); border-color:var(--terminal-selection); box-shadow:none; } +html:root[data-terminal] .maplibregl-popup-tip { border-top-color:var(--panel-solid); } +html:root[data-terminal] input[type=range] { accent-color:var(--terminal-accent); } +html:root[data-terminal] #app { --traffic-rgb:var(--terminal-advert-rgb); } +html:root[data-terminal] #app[data-traffic-kind="trace"] { --traffic-rgb:var(--terminal-trace-rgb); } +html:root[data-terminal] #app[data-traffic-kind="text"] { --traffic-rgb:var(--terminal-text-rgb); } +html:root[data-terminal] #app[data-traffic-kind="ack"] { --traffic-rgb:var(--terminal-ack-rgb); } +html:root[data-terminal] #app[data-traffic-kind="control"] { --traffic-rgb:var(--terminal-control-rgb); } +html:root[data-terminal] #app[data-traffic-kind="other"] { --traffic-rgb:var(--terminal-other-rgb); } [data-terminal] .control-button { border-radius: 2px; } [data-terminal] .brand { color: var(--terminal-accent); } [data-terminal] #legend .node { width: auto; height: auto; border: 0; border-radius: 0; background: none; box-shadow: none; font-style: normal; } -[data-terminal] #legend .repeater::after { content: 'R'; color: #71f5c7; } -[data-terminal] #legend .companion::after { content: 'C'; color: #78cfff; } -[data-terminal] #legend .room::after { content: 'H'; color: #b39aff; } +[data-terminal] #legend .repeater::after { content: 'R'; color:var(--terminal-repeater); } +[data-terminal] #legend .companion::after { content: 'C'; color:var(--terminal-companion); } +[data-terminal] #legend .room::after { content: 'H'; color:var(--terminal-room); } [data-terminal] #map-grade { display: none; } [data-terminal]:not([data-terminal-fallback]) #packet-canvas { opacity: 0; } +[data-terminal][data-terminal-traffic="smooth"] #packet-canvas { opacity:1; z-index:2; } +[data-terminal][data-terminal-traffic="smooth"] #terminal-packets { display:none; } .terminal-canvas { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; } #terminal-map { z-index: 4; } #terminal-packets { z-index: 2; } [data-terminal] .maplibregl-control-container > div { z-index: 6; } [data-terminal-fallback] .terminal-canvas { display: none; } -#terminal-status { position: absolute; top: 60px; left: 14px; right: 14px; z-index: 5; display: flex; flex-wrap: wrap; gap: 4px 18px; width: fit-content; max-width: calc(100% - 28px); padding: 5px 8px; border: 1px solid #334b57; background: #000d; color: var(--terminal-accent); font-size: 11px; pointer-events: none; } -.terminal-help { color: #a3b4bd; } -.terminal-settings { margin: 8px 0; padding: 8px; border: 1px solid #527885; display: grid; gap: 8px; } -.terminal-settings legend { color: var(--terminal-accent); font-weight: bold; } +#terminal-status { position: absolute; top: 60px; left: 14px; right: 14px; z-index: 5; display: flex; flex-wrap: wrap; gap: 4px 18px; width: fit-content; max-width: calc(100% - 28px); padding: 5px 8px; border: 1px solid var(--panel-border); background:var(--panel-solid); color:var(--terminal-accent); font-size:calc(11px * var(--terminal-scale,1)); pointer-events:none; } +.terminal-help { color:var(--muted); } +/* The editor is a recovery surface even when the chosen UI colours are illegible. */ +html:root .terminal-settings { margin:8px 0; padding:10px; border:1px solid #527885; display:grid; gap:10px; min-width:0; color:#d5e6df; background:#050b10; font:12px/1.5 ui-monospace,monospace; color-scheme:dark; } +.terminal-settings legend { color:#79f8c4; font-weight:bold; } .terminal-settings label { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; } -.terminal-settings select { min-height: 36px; max-width: 100%; background: #08121a; color: #efffdf; border: 1px solid #527885; } +html:root .terminal-settings :is(input,select,button) { min-height:36px; max-width:100%; background:#08121a; color:#efffdf; border:1px solid #527885; border-radius:3px; font:inherit; } +html:root .terminal-settings :is(input,select,button):focus-visible { outline:2px solid #79f8c4; outline-offset:2px; } +.terminal-settings select { min-width:0; max-width:65%; } +.terminal-settings details { border-top:1px solid #334b57; padding-top:6px; } +.terminal-settings summary { cursor:pointer; padding:7px 0; min-height:36px; } +.terminal-settings details[open] > :not(summary) { margin-top:10px; } +.terminal-settings a { color:#89e5ff; } +.terminal-slider input { width:100%; margin:0; accent-color:#79f8c4; } +html:root[data-terminal] .terminal-settings input[type=range] { accent-color:#79f8c4; } +.terminal-slider output { color:#efffdf; } +.terminal-colour-inputs { display:flex; gap:10px; } +.terminal-colour-inputs input[type=color] { width:52px; flex-shrink:0; padding:3px; } +.terminal-colour-inputs input[type=text] { min-width:0; width:100%; padding:6px; } +.terminal-colour-inputs input:invalid { outline:2px solid #ff85ce; } +.terminal-preview { display:grid; gap:4px; padding:10px; border:1px solid #527885; background:var(--sample-background); font-size:16px; overflow:hidden; } +.terminal-preview span { color:var(--sample-water); } +.terminal-preview i { color:var(--sample-advert); font-style:normal; } +.terminal-preview b { color:var(--sample-labels); font-weight:normal; } .terminal-settings p { color: #a5bdbb; font-size: 11px; line-height: 1.5; margin: 0; } .terminal-notice { position: absolute; z-index: 8; top: 105px; left: 14px; max-width: min(340px, calc(100% - 28px)); padding: 8px; font-size: 12px; } .terminal-notice[hidden] { display: none; } @media(max-width:900px), (pointer:coarse) { #terminal-status { top: max(52px, calc(env(safe-area-inset-top) + 45px)); font-size: 10px; gap: 4px 10px; } .terminal-help { display: none; } - .terminal-settings select { min-height: 44px; } + html:root .terminal-settings :is(input,select,button,summary) { min-height:44px; } } @media(max-height:480px) { #terminal-status { top: 8px; left: 205px; right: 8px; max-width: calc(100% - 220px); } #terminal-status strong, .terminal-help { display: none; } } diff --git a/web/src/terminal.ts b/web/src/terminal.ts index 8bf30cb..d125a16 100644 --- a/web/src/terminal.ts +++ b/web/src/terminal.ts @@ -1,12 +1,13 @@ import type { LiveMap } from './map'; import type { PacketAnimator } from './packetAnimator'; import { browserStorage } from './browserStorage'; -import { applyDisplayChrome, effectStrength, prefersReducedMotion } from './displayPreferences'; +import { applyDisplayChrome, displayPreferences, DISPLAY_EVENT, effectStrength, prefersReducedMotion, setDisplayColors } from './displayPreferences'; import { terminalGraphics } from './terminalGraphics'; -import { characterCells, terminalColor, terminalPreferences, TERMINAL_DEFAULTS, TERMINAL_COLORS } from './terminalGrid'; +import { characterCells } from './terminalGrid'; +import { loadTerminal, TERMINAL_FONTS, TERMINAL_KEY, TERMINAL_LEGACY_KEY, shade } from './terminalProfile'; +import { terminalControls } from './terminalControls'; import './terminal.css'; -const KEY = 'cartolite-terminal-v1'; const ROLES: Record = { repeater: 'R', companion: 'C', room_server: 'H', sensor: 'S' }; // This page samples the existing 2D packet canvas. Choose CPU backing before @@ -25,8 +26,7 @@ window.addEventListener('cartolite-map-ready', event => { /** Presentation only: the existing Map and packet clock still own the scene. */ export function mountTerminal(view: LiveMap, animator: PacketAnimator): () => void { const map = view.map, host = map.getContainer(), root = document.documentElement; - let preferences = { ...TERMINAL_DEFAULTS }; - try { preferences = terminalPreferences(browserStorage().getItem(KEY)); } catch { /* Storage may be disabled. */ } + let preferences = loadTerminal(browserStorage()); const originalPaint = new Map[2] }>(); let labelLayers: string[] = []; let disposed = false, failed = false, lastPacket = 0; @@ -37,32 +37,73 @@ export function mountTerminal(view: LiveMap, animator: PacketAnimator): () => vo const sample = document.createElement('canvas'); const read = sample.getContext('2d', { willReadFrequently: true })!; const status = document.createElement('div'); status.id = 'terminal-status'; - status.innerHTML = 'CARTOLITE BBSDrag to pan · Scroll to zoom · Find to inspect'; + status.innerHTML = 'CARTOLITE BBSDrag to pan · Scroll to zoom · Find to inspect'; host.after(status); const notice = document.createElement('div'); notice.className = 'terminal-notice glass'; notice.hidden = true; notice.innerHTML = 'Terminal graphics paused. The map and live traffic are still available.'; host.after(notice); - const settings = document.createElement('fieldset'); settings.className = 'terminal-settings'; - settings.innerHTML = 'Terminal display

R repeater · C companion · H room · S sensor · @ selected. ANSI textures: green vegetation, grey built-up land. Live timing stays the same.

'; + const controls = terminalControls(preferences, value => { + preferences=value; + try { browserStorage().setItem(TERMINAL_KEY,JSON.stringify(preferences)); } catch { /* Session settings still work. */ } + sync(); + }); + const { settings }=controls; document.querySelector('#map-appearance > summary')!.after(settings); - const selects = settings.querySelectorAll('select'); + const allReset=document.querySelector('#reset-appearance')!; + const resetTerminal=()=>settings.querySelector('[data-reset-terminal]')!.click(); + allReset.addEventListener('click',resetTerminal); + if(allReset.nextElementSibling)allReset.nextElementSibling.textContent='Resets Terminal layers and display. Map, Netgraph and sound settings stay unchanged.'; + const font = (size: number) => `${preferences.weight} ${size}px ${TERMINAL_FONTS[preferences.font].family}`; + const packetColours = () => ({2:preferences.colours.core,3:preferences.colours.other,4:preferences.colours.advert,5:preferences.colours.trace,6:preferences.colours.text,7:preferences.colours.ack,8:preferences.colours.control}); + let loadedFont = ''; const sync = () => { - selects[0]!.value = preferences.palette; selects[1]!.value = preferences.glyphs; selects[2]!.value = String(preferences.size); + controls.sync(preferences); root.dataset.terminalPalette = preferences.palette; + root.dataset.terminalTraffic = preferences.traffic; + root.dataset.terminalFont = preferences.font; + for(const [key,value] of Object.entries(preferences.colours))root.style.setProperty(`--terminal-${key}`,value); + root.style.setProperty('--terminal-font',TERMINAL_FONTS[preferences.uiFont].family); + root.style.setProperty('--terminal-scale',String(preferences.uiScale)); + applyColours(); + view.setAppearance(undefined,true); animator.setPresentationGlow(preferences.liveGlow);animator.refreshAppearance(); style(); + const nextFont=[preferences.font,preferences.uiFont,preferences.weight].join(':'); + if(loadedFont!==nextFont) { + loadedFont=nextFont; + void Promise.all([document.fonts.load(font(32)),document.fonts.load(`16px ${TERMINAL_FONTS[preferences.uiFont].family}`)]) + .then(()=>{if(!disposed && !failed && nextFont===loadedFont)capture.refreshFont();}).catch(()=>{ /* System fallback remains usable. */ }); + } + fx.clearRect(0,0,effects.width,effects.height); + lastPacket=0; schedule(); }; - const save = () => { try { browserStorage().setItem(KEY, JSON.stringify(preferences)); } catch { /* Session settings still work. */ } sync(); }; - settings.addEventListener('change', () => { preferences = terminalPreferences(JSON.stringify({ palette: selects[0]!.value, glyphs: selects[1]!.value, size: Number(selects[2]!.value) })); save(); }); - settings.querySelector('button')!.addEventListener('click', () => { preferences = { ...TERMINAL_DEFAULTS }; save(); }); - const storage = (event: StorageEvent) => { if (event.key === KEY || event.key === null) { preferences = terminalPreferences(event.newValue); sync(); } }; + const storage = (event: StorageEvent) => { if (event.key === TERMINAL_KEY || event.key === TERMINAL_LEGACY_KEY || event.key === null) { preferences=loadTerminal(browserStorage()); sync(); } }; window.addEventListener('storage', storage); + function applyColours() { + if(failed)return; + const c=preferences.colours; + for(const kind of ['advert','trace','text','ack','control','other'] as const) { + root.style.setProperty(`--terminal-${kind}-rgb`,[1,3,5].map(i=>parseInt(c[kind].slice(i,i+2),16)).join(',')); + } + setDisplayColors({ + '#4de7c4':c.advert,'#ffd15a':c.trace,'#ff75b5':c.text,'#78cfff':c.ack,'#a78bfa':c.control,'#9caebd':c.other, + '#73d9cf':c.advert,'#45c27f':c.repeater,'#53a7e8':c.companion,'#ab76dc':c.room,'#a2ad57':c.sensor,'#8794a6':c.sensor, + '#ffffff':c.core,'#eaffff':c.core,'#f2ffff':c.core,'#edfffd':c.core,'#e5fffc':c.core, + '#f3b844':c.neighbours,'#f2bd55':c.selection,'#fff0b8':c.selection,'#f5cf76':c.selection, + '#69d1ca':c.boundary,'#8ec5c1':c.places,'#f6d77f':c.labels,'#d2e0ef':c.labels, + '#02070b':c.background,'#061216':c.background,'#08272c':c.background,'#bce9e5':c.labels,'#48d5c7':c.repeater,'#32c8bb':c.repeater, + }); + applyDisplayChrome(); + } + const displayChanged=()=>{ applyColours(); style(); schedule(); }; + window.addEventListener(DISPLAY_EVENT,displayChanged); + function dimensions() { const width = host.clientWidth, height = host.clientHeight; // Bound raster work on very large screens without dropping any route data. let size = Math.max(preferences.size, Math.floor(Math.sqrt(width*height/16000)/.83)), cw = 0, ch = 0; do { - context.font = `${size}px ui-monospace, "DejaVu Sans Mono", "Cascadia Mono", monospace`; + context.font = font(size); cw = context.measureText('M').width; ch = size * 1.15; if (Math.ceil(width/cw)*Math.ceil(height/ch) <= 16000) break; size++; @@ -79,10 +120,11 @@ export function mountTerminal(view: LiveMap, animator: PacketAnimator): () => vo sample.width = Math.ceil(width / cw) * 2; sample.height = Math.ceil(height / ch) * 4; read.imageSmoothingEnabled = false; read.drawImage(source, 0, 0, sample.width, sample.height); - const cells = characterCells(read.getImageData(0, 0, sample.width, sample.height), preferences.glyphs === 'ascii'); - ctx.font = `bold ${size}px ui-monospace, "DejaVu Sans Mono", "Cascadia Mono", monospace`; + const colours=packetColours(); + const cells = characterCells(read.getImageData(0, 0, sample.width, sample.height), preferences.glyphs === 'ascii', colours); + ctx.font = font(size); ctx.textBaseline = 'top'; - const glow = prefersReducedMotion() ? 0 : effectStrength() * (source.dataset.qualityMode === 'low' ? 2 : source.dataset.qualityMode === 'balanced' ? 4 : 6); + const glow = prefersReducedMotion() ? 0 : preferences.liveGlow * effectStrength() * (source.dataset.qualityMode === 'low' ? 2 : source.dataset.qualityMode === 'balanced' ? 4 : 6); ctx.lineWidth=.5; // Batch colour/brightness runs. Bound decorative work before reducing cores. let visible = 0, blooms=0; @@ -92,14 +134,16 @@ export function mountTerminal(view: LiveMap, animator: PacketAnimator): () => vo do { text += cells.glyphs[y * cells.columns + x]!; x++; } while (x < cells.columns && cells.colors[y * cells.columns + x] === color && cells.energy[y*cells.columns+x] === energy); if (!color) continue; ctx.globalAlpha=Math.min(1,energy/255*1.35); - ctx.fillStyle=ctx.strokeStyle=ctx.shadowColor=terminalColor(color,preferences.palette); + ctx.fillStyle=ctx.strokeStyle=ctx.shadowColor=colours[color as keyof typeof colours]??preferences.colours.core; ctx.shadowBlur=blooms++<120 ? glow : 0; - ctx.strokeText(text,start*cw,y*ch); ctx.fillText(text,start*cw,y*ch); visible+=x-start; + // A console font may fall back for Braille. Keep its glyphs inside the + // same geographic cells instead of letting the fallback widen the trail. + ctx.strokeText(text,start*cw,y*ch,(x-start)*cw); ctx.fillText(text,start*cw,y*ch,(x-start)*cw); visible+=x-start; } - ctx.shadowBlur=0; ctx.fillStyle=terminalColor(2,preferences.palette); + ctx.shadowBlur=0; ctx.fillStyle=preferences.colours.core; for(let i=0;i vo } function labels() { - const { width, height } = dimensions(), size = preferences.size; - context.font = `${size}px ui-monospace, "DejaVu Sans Mono", "Cascadia Mono", monospace`; + const { width, height } = dimensions(), size = preferences.fontSize * (displayPreferences().textSize==='large'?1.25:1); + context.font = font(size); const cw = context.measureText('M').width, ch = size * 1.25; const occupied = new Set(); const features = map.queryRenderedFeatures(undefined, { layers: labelLayers.filter(id => map.getLayer(id)) }); @@ -121,7 +165,7 @@ export function mountTerminal(view: LiveMap, animator: PacketAnimator): () => vo for (const feature of features) { if (feature.geometry.type !== 'Point') continue; const p = feature.properties, node = feature.layer.id === 'nodes', cluster = feature.layer.id === 'clusters'; - context.font = `${node || cluster ? 'bold ' : ''}${size}px ui-monospace, "DejaVu Sans Mono", "Cascadia Mono", monospace`; + context.font = font(size); const text = cluster ? `[${p.point_count_abbreviated ?? p.point_count}]` : node ? (p.id === selected ? '@' : (ROLES[String(p.role)] ?? '?')) : String(p.label ?? p.name_en ?? p['name:en'] ?? p.name ?? ''); if (!text) continue; const coordinate = feature.geometry.coordinates; @@ -140,10 +184,11 @@ export function mountTerminal(view: LiveMap, animator: PacketAnimator): () => vo for (let row=Math.floor((y-2)/ch);row<=Math.floor((y+ch+2)/ch);row++) for (let col=Math.floor((x-2)/cw);col<=Math.floor((x+w+2)/cw);col++) cells.push(`${col}:${row}`); if (cells.some(cell => occupied.has(cell))) continue; cells.forEach(cell => occupied.add(cell)); - context.fillStyle = '#000'; context.fillRect(x-1,y,w+2,ch); - context.fillStyle = terminalColor(node ? (p.id === selected ? 2 : p.role === 'companion' ? 7 : p.role === 'room_server' ? 8 : 4) : cluster ? 4 : nodeLabel ? 2 : 5, preferences.palette); + context.fillStyle = preferences.colours.background; context.fillRect(x-1,y,w+2,ch); + const c=preferences.colours; + context.fillStyle = selected && p.id===selected ? c.selection : node ? (p.role==='companion'?c.companion:p.role==='room_server'?c.room:p.role==='repeater'?c.repeater:c.sensor) : cluster?c.repeater:nodeLabel?c.labels:shade(c.places,preferences.labelBrightness); context.shadowColor=String(context.fillStyle); context.shadowBlur=node || cluster ? 3*effectStrength() : 0; - context.globalAlpha=node || cluster || nodeLabel ? 1 : .65; + context.globalAlpha=node && p.id!==selected && typeof p.opacity==='number' ? Math.max(.65,Math.min(1,p.opacity)) : 1; context.fillText(text,x,y); context.shadowBlur=0; context.globalAlpha=1; drawn++; break; } } @@ -155,6 +200,8 @@ export function mountTerminal(view: LiveMap, animator: PacketAnimator): () => vo delete root.dataset.terminal; for (const item of originalPaint.values()) if (map.getLayer(item.layer)) map.setPaintProperty(item.layer, item.property as Parameters[1], item.value); originalPaint.clear(); + setDisplayColors(); + animator.setPresentationGlow(1); applyDisplayChrome(); view.setAppearance(undefined, true); animator.refreshAppearance(); fx.clearRect(0, 0, effects.width, effects.height); } @@ -170,6 +217,10 @@ export function mountTerminal(view: LiveMap, animator: PacketAnimator): () => vo canvas.dataset.workMs = String(Math.round(performance.now() - started)); const center = map.getCenter(); status.querySelector('.terminal-coordinates')!.textContent = `${center.lat.toFixed(2)}, ${center.lng.toFixed(2)} Z:${map.getZoom().toFixed(1)}`; + const routes=document.querySelector('#routes-button')?.getAttribute('aria-pressed')==='true'; + const live=document.querySelector('#live-packets-button')?.getAttribute('aria-pressed')==='true'; + const window=document.querySelector('#route-window')?.selectedOptions[0]?.textContent??''; + status.querySelector('.terminal-scope')!.textContent=routes?`Links ${window}${live?'':' · live off'}`:live?'Live only':'Traffic hidden'; } catch { fail(); } } function schedule() { @@ -185,7 +236,7 @@ export function mountTerminal(view: LiveMap, animator: PacketAnimator): () => vo const setPaint = (layer: string, property: string, value: Parameters[2]) => { const current = map.getPaintProperty(layer, property as Parameters[1]); if (JSON.stringify(current) === JSON.stringify(value)) return; - originalPaint.set(`${layer}:${property}`, { layer, property, value: current }); + if(!originalPaint.has(`${layer}:${property}`))originalPaint.set(`${layer}:${property}`, { layer, property, value: current }); map.setPaintProperty(layer, property as Parameters[1], value); }; // Public layer ordering includes custom layers and avoids serializing all @@ -195,9 +246,19 @@ export function mountTerminal(view: LiveMap, animator: PacketAnimator): () => vo for (const layer of layers) { if (layer.type === 'symbol') { setPaint(layer.id, 'text-opacity', 0); + } else if (['nodes','nodes-glow','node-core'].includes(layer.id)) { + setPaint(layer.id,'circle-opacity',0); + if(layer.id==='nodes')setPaint(layer.id,'circle-stroke-opacity',0); + } else if(layer.id==='building-footprints') { + setPaint(layer.id,'fill-color','#ffd15a');setPaint(layer.id,'fill-opacity',1); + } else if(layer.id==='building-extrusions') { + setPaint(layer.id,'fill-extrusion-color','#ffd15a');setPaint(layer.id,'fill-extrusion-opacity',1); + } + else if(layer.id==='terrain-hillshade') { + setPaint(layer.id,'hillshade-shadow-color','#030609');setPaint(layer.id,'hillshade-highlight-color','#72b89b'); } else if (layer.id.startsWith('basemap-')) { const property = layer.type === 'background' ? 'background-color' : layer.type === 'fill' ? 'fill-color' : layer.type === 'line' ? 'line-color' : undefined; - const color = layer.id.includes('water') ? '#08145a' : layer.id.includes('boundary') ? '#526270' : layer.id === 'basemap-landcover' ? '#124b24' : layer.id === 'basemap-landuse' ? '#383838' : layer.type === 'line' ? '#526270' : '#030609'; + const color = layer.id.includes('water') ? '#08145a' : layer.id.includes('boundary') ? '#c184f6' : layer.id === 'basemap-landcover' ? '#124b24' : layer.id === 'basemap-landuse' ? '#383838' : layer.type === 'line' ? '#405060' : '#030609'; if (property) setPaint(layer.id, property, layer.id === 'basemap-landcover' ? ['match', ['get','class'], ['wood','grass'], '#124b24', '#383838'] : color); } } @@ -206,21 +267,25 @@ export function mountTerminal(view: LiveMap, animator: PacketAnimator): () => vo } const capture = terminalGraphics(map, () => { const { width, height, cw, ch } = dimensions(); - return { columns: Math.max(1,Math.ceil(width/cw)), rows: Math.max(1,Math.ceil(height/ch)), ascii: preferences.glyphs === 'ascii', colors: TERMINAL_COLORS.map((_,i) => terminalColor(i,preferences.palette)) }; + const c=preferences.colours; + return { columns: Math.max(1,Math.ceil(width/cw)), rows: Math.max(1,Math.ceil(height/ch)), mode:['braille','ascii','blocks','cell'].indexOf(preferences.glyphs), + colors:[c.background,c.water,c.terrain,c.roads,c.terrain,c.buildings,c.boundary,c.shore,c.boundary,c.vegetation,c.urban,c.shore],brightness:preferences.mapBrightness,font:font(32) }; }, paint, fail, () => !disposed && !failed && !document.hidden); const visibility = () => { if (document.hidden) capture.suspend(); else { style(); schedule(); } }; // Appearance/terrain can finish loading while the camera or feed keeps the // map busy. Do not wait for idle to reapply the encoded cartographic colours. const ensureStyle = () => { if (map.getLayer('basemap-water') && (map.getPaintProperty('basemap-water','fill-color') !== '#08145a' - || (map.getLayer('node-labels') && map.getPaintProperty('node-labels','text-opacity') !== 0))) style(); + || (map.getLayer('node-labels') && map.getPaintProperty('node-labels','text-opacity') !== 0) + || (map.getLayer('nodes') && map.getPaintProperty('nodes','circle-opacity') !== 0) + || (map.getLayer('building-footprints') && map.getPaintProperty('building-footprints','fill-color') !== '#ffd15a'))) style(); }; const restored = () => { failed = false; root.dataset.terminal = 'true'; delete root.dataset.terminalFallback; notice.hidden = true; - applyDisplayChrome(); view.setAppearance(undefined, true); animator.refreshAppearance(); style(); schedule(); + sync(); }; notice.querySelector('button')!.addEventListener('click', restored); - map.on('load', style); map.on('idle', style); + map.on('load', style); map.on('style.load',style);map.on('idle', style); map.on('render', ensureStyle); map.on('webglcontextrestored', restored); document.addEventListener('visibilitychange', visibility); @@ -229,19 +294,27 @@ export function mountTerminal(view: LiveMap, animator: PacketAnimator): () => vo if (failed || document.hidden) return; const now = performance.now(); if (now - lastPacket < 40 && (source.dataset.activeCount !== '0' || source.dataset.residueCount !== '0')) return; - try { draw(source, effects, fx); lastPacket = now; } catch { fail(); } + try { + if(preferences.traffic==='smooth') { + // Show the original canvas directly: no full-screen copy or second clock. + effects.dataset.cells=source.dataset.activeCount??'0';effects.dataset.glow='0'; + } else draw(source, effects, fx); + lastPacket = now; + } catch { fail(); } }); sync(); style(); const destroy = () => { if (disposed) return; disposed = true; capture.destroy(); animator.setFrameSink(undefined); - map.off('load', style); map.off('idle', style); + map.off('load', style); map.off('style.load',style);map.off('idle', style); map.off('render', ensureStyle); map.off('webglcontextrestored', restored); map.off('remove', destroy); document.removeEventListener('visibilitychange', visibility); window.removeEventListener('storage', storage); + window.removeEventListener(DISPLAY_EVENT,displayChanged);allReset.removeEventListener('click',resetTerminal);setDisplayColors();animator.setPresentationGlow(1); for (const element of [canvas, effects, status, notice, settings]) element.remove(); delete root.dataset.terminal; delete root.dataset.terminalFallback; delete root.dataset.terminalPalette; + delete root.dataset.terminalTraffic;delete root.dataset.terminalFont; }; map.on('remove', destroy); return destroy; diff --git a/web/src/terminalControls.ts b/web/src/terminalControls.ts new file mode 100644 index 0000000..89fcb53 --- /dev/null +++ b/web/src/terminalControls.ts @@ -0,0 +1,97 @@ +import { colourHSL, hslColour, normalizeTerminal, TERMINAL_COLOURS, TERMINAL_FONTS, TERMINAL_PRESETS, terminalPreset, type TerminalColour, type TerminalPreset, type TerminalProfile } from './terminalProfile'; + +/** One editor inside Display: no extra floating panel or live-feed owner. */ +export function terminalControls(initial: TerminalProfile, change: (value: TerminalProfile) => void) { + const settings = document.createElement('fieldset'); settings.className = 'terminal-settings'; + const fonts = Object.entries(TERMINAL_FONTS).map(([id,font])=>``).join(''); + const slider = (key:string,label:string,min:number,max:number,step:number) => ``; + settings.innerHTML = `Terminal options + +
░░░ ≋≋≋R ══════◆── CCartoLite BBS
+ + + +
Map & traffic visibility + ${slider('mapBrightness','Map brightness',.15,1.3,.05)} + ${slider('labelBrightness','Place label brightness',.4,1.3,.05)} + ${slider('liveGlow','Live glow',0,2,.1)} +

Routes stay sharp above the map. Use Route style and Advanced route styling below for solid, dashed or dotted lines, width, opacity, packet size and trails. Glow respects Effects, Motion and Graphics settings.

+
+
Typography + + ${slider('fontSize','Map font size',10,24,1)}${slider('weight','Font weight',400,700,100)} + + ${slider('uiScale','Interface scale',.9,1.35,.05)} +

Fonts affect labels, ASCII and controls. Braille and pixel map shapes keep their fixed grid. Missing characters use the system font. Font credits

+
+
Element colours + +
+ ${slider('hue','Hue',0,360,1)}${slider('saturation','Saturation',0,100,1)}${slider('lightness','Lightness',0,100,1)} + +

Traffic colours are shared by routes, packets and their legend. This editor keeps readable colours so you can always undo a change.

+
+ +

Saved on this device; updates other Terminal tabs. Map, Netgraph, layers, sound and volume keep their saved settings. R repeater · C companion · H room · S sensor · @ selected.

`; + let current = initial; + const element = settings.querySelector('[aria-label="Terminal colour element"]')!; + const colour = settings.querySelector('[type=color]')!; + const hex = settings.querySelector('[aria-label="Terminal colour hex"]')!; + const channels = ['hue','saturation','lightness'] as const; + let hsl: number[] = [0,0,0]; + function syncColour() { + const key=element.value as TerminalColour; + colour.value=hex.value=current.colours[key]; hex.setCustomValidity(''); + hsl=colourHSL(colour.value); + channels.forEach((key,i)=>{ + settings.querySelector(`[data-terminal="${key}"]`)!.value=String(hsl[i]); + settings.querySelector(`[data-value="${key}"]`)!.value=String(hsl[i])+(i===0?'°':'%'); + }); + } + function sync(profile: TerminalProfile) { + current=profile; + for(const input of settings.querySelectorAll('[data-terminal]')) { + const key=input.dataset.terminal as keyof TerminalProfile; + if(key in current) input.value=String(current[key]); + } + for(const output of settings.querySelectorAll('[data-value]')) { + const key=output.dataset.value as keyof TerminalProfile; + if(!(key in current))continue; + const value=Number(current[key]); + output.value=['mapBrightness','labelBrightness','liveGlow','uiScale'].includes(key)?`${Math.round(value*100)}%`:key==='fontSize'?`${value}px`:String(value); + } + const preview=settings.querySelector('.terminal-preview')!; + for(const key of ['background','water','advert','labels'] as const)preview.style.setProperty(`--sample-${key}`,current.colours[key]); + preview.style.fontFamily=TERMINAL_FONTS[current.font].family; + syncColour(); + } + function update(value: TerminalProfile, keepHsl=false) { + const held=[...hsl]; current=normalizeTerminal(value); change(current); sync(current); + // Keep hue/saturation adjustable through grey and black: RGB cannot retain them. + if(keepHsl){hsl=held;channels.forEach((key,i)=>{settings.querySelector(`[data-terminal="${key}"]`)!.value=String(hsl[i]); settings.querySelector(`[data-value="${key}"]`)!.value=String(hsl[i])+(i===0?'°':'%');});} + } + settings.addEventListener('input',event=>{ + const input=event.target; + if(!(input instanceof HTMLInputElement))return; + const key=input.dataset.terminal; + if(channels.includes(key as typeof channels[number])) { + hsl[channels.indexOf(key as typeof channels[number])]=Number(input.value); + update({...current,colours:{...current.colours,[element.value]:hslColour(hsl[0]!,hsl[1]!,hsl[2]!)}},true); + } else if(key) update({...current,[key]:Number(input.value)}); + else if(input===colour || input===hex) { + if(/^#[0-9a-f]{6}$/i.test(input.value))update({...current,colours:{...current.colours,[element.value]:input.value}}); + else hex.setCustomValidity('Use six hexadecimal digits, for example #55ffff.'); + } + }); + settings.addEventListener('change',event=>{ + const input=event.target; + if(!(input instanceof HTMLSelectElement))return; + if(input===element){syncColour();return;} + const key=input.dataset.terminal!; + update(key==='palette'?terminalPreset(input.value as TerminalPreset):{...current,[key]:key==='size'?Number(input.value):input.value}); + }); + settings.querySelector('[data-reset-colour]')!.addEventListener('click',()=>update({...current,colours:{...current.colours,[element.value]:terminalPreset(current.palette).colours[element.value as TerminalColour]}})); + settings.querySelector('[data-reset-terminal]')!.addEventListener('click',()=>update(terminalPreset('ansi'))); + sync(initial); + return {settings,sync}; +} diff --git a/web/src/terminalGraphics.ts b/web/src/terminalGraphics.ts index 70c8936..973a191 100644 --- a/web/src/terminalGraphics.ts +++ b/web/src/terminalGraphics.ts @@ -3,11 +3,7 @@ import type { CustomLayerInterface, Map as LibreMap } from 'maplibre-gl'; const VERTEX = `#version 300 es void main(){vec2 p=vec2((gl_VertexID<<1)&2,gl_VertexID&2);gl_Position=vec4(p*2.-1.,0,1);}`; // One pixel per character in the first pass; screen pixels only sample the font. -const CELLS = `#version 300 es -precision highp float;precision highp int; -uniform sampler2D terminal_scene;uniform vec2 grid;uniform int ascii; -out vec4 result; -int ink(vec3 c){ +const INK = `int ink(vec3 c){ if(max(c.r,max(c.g,c.b))<26./255.)return 0; if(c.b>c.r*1.8&&c.b>c.g*1.6)return 1; if(max(c.r,max(c.g,c.b))<100./255.&&c.g>c.r*1.5&&c.g>c.b*1.2)return 9; @@ -19,7 +15,12 @@ int ink(vec3 c){ if(c.g>c.r*1.2&&c.g>c.b*.9)return 4; if(c.b>c.r*1.1&&c.r>c.g*.9)return 8; if(c.b>c.r*1.1)return 7;return 2; -} +}`; +const CELLS = `#version 300 es +precision highp float;precision highp int; +uniform sampler2D terminal_scene;uniform vec2 grid;uniform int ascii; +out vec4 result; +${INK} int at(vec2 p){return ink(texture(terminal_scene,clamp(p,vec2(0),vec2(1))).rgb);} int rank(int k){return k==0?-1:k==1||k==9||k==10?0:k==3?1:k==11?2:3;} void main(){ @@ -47,21 +48,31 @@ void main(){ }`; const GLYPHS = `#version 300 es precision highp float;precision highp int; -uniform sampler2D cells;uniform sampler2D glyphs;uniform vec2 grid;uniform vec2 viewport;uniform vec3 colors[12]; +uniform sampler2D cells;uniform sampler2D glyphs;uniform sampler2D terminal_scene;uniform vec2 grid;uniform vec2 viewport;uniform vec3 colors[12];uniform int mode;uniform float brightness; out vec4 result; +${INK} void main(){ + if(mode==3){ + vec2 uv=gl_FragCoord.xy/viewport;int k=ink(texture(terminal_scene,uv).rgb); + float edge=0.,shore=0.;vec2 stepSize=vec2(1.5)/viewport; + for(int i=0;i<4;i++) {vec2 p=uv+vec2(i<2?(i==0?stepSize.x:-stepSize.x):0.,i>1?(i==2?stepSize.y:-stepSize.y):0.);int next=ink(texture(terminal_scene,clamp(p,vec2(0),vec2(1))).rgb);if(next!=k)edge=1.;if(k!=1&&next==1)shore=1.;} + result=vec4(mix(mix(colors[0],colors[k]*brightness,1.-edge*.4),colors[11]*brightness,shore),1);return; + } vec2 p=gl_FragCoord.xy/viewport*grid; vec3 cell=floor(texture(cells,(floor(p)+.5)/grid).rgb*255.+.5); vec2 local=clamp(vec2(fract(p.x),1.-fract(p.y)),vec2(.5/24.,.5/40.),vec2(1.-.5/24.,1.-.5/40.)); vec2 uv=(cell.xy+local)/vec2(16,17); - result=vec4(colors[int(cell.z)]*texture(glyphs,uv).a,1); + float coverage=texture(glyphs,uv).a; + if(mode==2){int bits[8]=int[8](1,8,2,16,4,32,64,128);int x=int(local.x*2.);int y=int(local.y*4.);coverage=(int(cell.x+cell.y*16.)&bits[y*2+x])!=0?1.:0.;} + result=vec4(mix(colors[0],colors[int(cell.z)]*brightness,coverage),1); }`; -export function terminalGraphics(map: LibreMap, config: () => { columns: number; rows: number; ascii: boolean; colors: string[] }, frame: () => void, failure: () => void, enabled: () => boolean) { +export function terminalGraphics(map: LibreMap, config: () => { columns: number; rows: number; mode: number; colors: string[]; brightness: number; font: string }, frame: () => void, failure: () => void, enabled: () => boolean) { let gl: WebGL2RenderingContext | undefined, programs: WebGLProgram[] = [], textures: WebGLTexture[] = [], shaders: WebGLShader[] = []; let framebuffer: WebGLFramebuffer | null = null, vao: WebGLVertexArrayObject | null = null; let locations: Record[] = [], width = 0, height = 0, columns = 0, rows = 0, disposed = false, lastLabels = 0; const metrics = map.getContainer().dataset; + let atlasFont = ''; function release() { if (gl) { for (const p of programs) gl.deleteProgram(p); @@ -70,7 +81,7 @@ export function terminalGraphics(map: LibreMap, config: () => { columns: number; gl.deleteFramebuffer(framebuffer); gl.deleteVertexArray(vao); } programs = []; textures = []; shaders = []; locations = []; framebuffer = vao = null; - width = height = columns = rows = 0; + width = height = columns = rows = 0; atlasFont=''; } function initialize(g: WebGL2RenderingContext) { for (const fragment of [CELLS, GLYPHS]) { @@ -82,7 +93,7 @@ export function terminalGraphics(map: LibreMap, config: () => { columns: number; g.attachShader(p, s); } g.linkProgram(p); if (!g.getProgramParameter(p, g.LINK_STATUS)) throw new Error('Terminal program unavailable'); - locations.push(Object.fromEntries(['terminal_scene','grid','ascii','cells','glyphs','viewport','colors[0]'].map(name => [name,g.getUniformLocation(p,name)]))); + locations.push(Object.fromEntries(['terminal_scene','grid','ascii','cells','glyphs','viewport','colors[0]','mode','brightness'].map(name => [name,g.getUniformLocation(p,name)]))); } for (const s of shaders) g.deleteShader(s); shaders = []; framebuffer = g.createFramebuffer(); vao = g.createVertexArray(); @@ -93,14 +104,19 @@ export function terminalGraphics(map: LibreMap, config: () => { columns: number; g.texParameteri(g.TEXTURE_2D, g.TEXTURE_MIN_FILTER, g.NEAREST); g.texParameteri(g.TEXTURE_2D, g.TEXTURE_MAG_FILTER, g.NEAREST); g.texParameteri(g.TEXTURE_2D, g.TEXTURE_WRAP_S, g.CLAMP_TO_EDGE); g.texParameteri(g.TEXTURE_2D, g.TEXTURE_WRAP_T, g.CLAMP_TO_EDGE); } + } + function updateFont(g: WebGL2RenderingContext, font: string) { + if(atlasFont===font)return; const atlas = document.createElement('canvas'); atlas.width = 384; atlas.height = 680; const ctx = atlas.getContext('2d'); if (!ctx) throw new Error('Terminal font unavailable'); - ctx.font = '32px ui-monospace, "DejaVu Sans Mono", monospace'; ctx.textBaseline = 'top'; ctx.fillStyle = '#fff'; + ctx.font = font; ctx.textBaseline = 'top'; ctx.fillStyle = '#fff'; ctx.textAlign='center'; for (let i = 1; i < 256; i++) for (let dot=0;dot<8;dot++) { if (i & [1,8,2,16,4,32,64,128][dot]!) ctx.fillRect((i%16)*24+(dot%2)*12+3,Math.floor(i/16)*40+Math.floor(dot/2)*10+2,6,6); } - for (let i=257;i<=260;i++) ctx.fillText(['.','+','#',':'][i-257]!, (i%16)*24,Math.floor(i/16)*40); + for (let i=257;i<=260;i++) ctx.fillText(['.','+','#',':'][i-257]!, (i%16)*24+12,Math.floor(i/16)*40,24); + g.activeTexture(g.TEXTURE2);g.bindTexture(g.TEXTURE_2D,textures[2]!); g.texImage2D(g.TEXTURE_2D,0,g.RGBA,g.RGBA,g.UNSIGNED_BYTE,atlas); + atlasFont=font; } const layer: CustomLayerInterface = { id: 'terminal-graphics', type: 'custom', renderingMode: '2d', @@ -111,16 +127,18 @@ export function terminalGraphics(map: LibreMap, config: () => { columns: number; const target = g.getParameter(g.FRAMEBUFFER_BINDING), readTarget = g.getParameter(g.READ_FRAMEBUFFER_BINDING), viewport = g.getParameter(g.VIEWPORT); const unpack = g.getParameter(g.PIXEL_UNPACK_BUFFER_BINDING), flip = g.getParameter(g.UNPACK_FLIP_Y_WEBGL); const dither = g.isEnabled(g.DITHER); + const depthMask=g.getParameter(g.DEPTH_WRITEMASK), activeTexture=g.getParameter(g.ACTIVE_TEXTURE); try { g.bindBuffer(g.PIXEL_UNPACK_BUFFER,null); g.pixelStorei(g.UNPACK_FLIP_Y_WEBGL,false); g.activeTexture(g.TEXTURE0); if (!programs.length) initialize(g); const options = config(), w = g.drawingBufferWidth, h = g.drawingBufferHeight; + updateFont(g,options.font);g.activeTexture(g.TEXTURE0); g.bindTexture(g.TEXTURE_2D,textures[0]!); if (width !== w || height !== h) { width=w; height=h; g.texImage2D(g.TEXTURE_2D,0,g.RGBA8,w,h,0,g.RGBA,g.UNSIGNED_BYTE,null); } // No readPixels, preserved drawing buffer, or CPU geographic copy. g.bindFramebuffer(g.READ_FRAMEBUFFER,target); g.copyTexSubImage2D(g.TEXTURE_2D,0,0,0,0,0,w,h); - g.disable(g.BLEND); g.disable(g.DEPTH_TEST); g.disable(g.STENCIL_TEST); g.disable(g.SCISSOR_TEST); g.disable(g.CULL_FACE); g.disable(g.DITHER); g.colorMask(true,true,true,true); + g.disable(g.BLEND); g.disable(g.DEPTH_TEST); g.depthMask(false);g.disable(g.STENCIL_TEST); g.disable(g.SCISSOR_TEST); g.disable(g.CULL_FACE); g.disable(g.DITHER); g.colorMask(true,true,true,true); g.bindVertexArray(vao); g.bindFramebuffer(g.FRAMEBUFFER,framebuffer); g.activeTexture(g.TEXTURE1); g.bindTexture(g.TEXTURE_2D,textures[1]!); if (columns !== options.columns || rows !== options.rows) { @@ -131,22 +149,30 @@ export function terminalGraphics(map: LibreMap, config: () => { columns: number; } g.viewport(0,0,columns,rows); g.useProgram(programs[0]!); const a=locations[0]!, b=locations[1]!; - g.uniform1i(a.terminal_scene!,0); g.uniform2f(a.grid!,columns,rows); g.uniform1i(a.ascii!,Number(options.ascii)); g.drawArrays(g.TRIANGLES,0,3); + g.uniform1i(a.terminal_scene!,0); g.uniform2f(a.grid!,columns,rows); g.uniform1i(a.ascii!,Number(options.mode===1)); g.drawArrays(g.TRIANGLES,0,3); g.bindFramebuffer(g.FRAMEBUFFER,target); g.viewport(0,0,w,h); g.useProgram(programs[1]!); g.activeTexture(g.TEXTURE2); g.bindTexture(g.TEXTURE_2D,textures[2]!); g.uniform1i(b.cells!,1); g.uniform1i(b.glyphs!,2); g.uniform2f(b.grid!,columns,rows); g.uniform2f(b.viewport!,w,h); + g.uniform1i(b.terminal_scene!,0);g.uniform1i(b.mode!,options.mode);g.uniform1f(b.brightness!,options.brightness); g.uniform3fv(b['colors[0]']!, options.colors.flatMap(color => { const n=parseInt(color.slice(1),16); return [(n>>16&255)/255,(n>>8&255)/255,(n&255)/255]; })); g.drawArrays(g.TRIANGLES,0,3); metrics.terminalRenderer='webgl2'; metrics.terminalGridCells=String(columns*rows); + metrics.terminalDrawing=String(options.mode);metrics.terminalBrightness=String(options.brightness); metrics.terminalSamples=String(Number(metrics.terminalSamples??0)+1); if (performance.now()-lastLabels>100) { frame(); lastLabels=performance.now(); } } catch { release(); failure(); } - finally { g.bindFramebuffer(g.FRAMEBUFFER,target); g.bindFramebuffer(g.READ_FRAMEBUFFER,readTarget); g.viewport(...viewport as [number,number,number,number]); g.bindBuffer(g.PIXEL_UNPACK_BUFFER,unpack); g.pixelStorei(g.UNPACK_FLIP_Y_WEBGL,flip); if (dither) g.enable(g.DITHER); } + finally { g.bindFramebuffer(g.FRAMEBUFFER,target); g.bindFramebuffer(g.READ_FRAMEBUFFER,readTarget); g.viewport(...viewport as [number,number,number,number]); g.bindBuffer(g.PIXEL_UNPACK_BUFFER,unpack); g.pixelStorei(g.UNPACK_FLIP_Y_WEBGL,flip);g.depthMask(depthMask);g.activeTexture(activeTexture); if (dither) g.enable(g.DITHER); } }, }; map.on('webglcontextlost',release); return { - attach() { if (disposed) return; if (!map.getLayer(layer.id)) map.addLayer(layer); else if (map.getLayersOrder().at(-1)!==layer.id) map.moveLayer(layer.id); }, + // Only geography is converted. Network ink, heat, selection and hit targets + // retain their real layers and cannot be mistaken for a cartographic colour. + attach() { if (disposed) return; const before=map.getLayersOrder().find(id=>id.startsWith('activity-heat-')); + if (!map.getLayer(layer.id)) map.addLayer(layer,before); + else if (before && map.getLayersOrder().indexOf(layer.id)!==map.getLayersOrder().indexOf(before)-1) map.moveLayer(layer.id,before); + }, + refreshFont() { atlasFont='';lastLabels=0;if(!disposed && enabled())map.triggerRepaint(); }, request() { lastLabels=0; if (!disposed && enabled()) map.triggerRepaint(); }, suspend: release, destroy() { disposed=true; map.off('webglcontextlost',release); if (map.getLayersOrder().includes(layer.id)) map.removeLayer(layer.id); release(); }, diff --git a/web/src/terminalGrid.ts b/web/src/terminalGrid.ts index c34128e..958dbd6 100644 --- a/web/src/terminalGrid.ts +++ b/web/src/terminalGrid.ts @@ -36,17 +36,29 @@ export function terminalInk(r: number, g: number, b: number, alpha = 255): numbe return 2; } -export function characterCells(image: Pick, ascii: boolean) { +export function characterCells(image: Pick, ascii: boolean, palette?: Record) { const { data, width, height } = image; const columns = Math.floor(width / 2), rows = Math.floor(height / 4); const glyphs: string[] = new Array(columns * rows).fill(' '), hot = [...glyphs]; const colors = new Uint8Array(glyphs.length), energy = new Uint8Array(glyphs.length); const strengths = new Float32Array(8), kinds = new Uint8Array(8); + const inks=palette?Object.entries(palette).map(([id,hex])=>{ + const rgb=[1,3,5].map(i=>parseInt(hex.slice(i,i+2),16));const peak=Math.max(1,...rgb); + return {id:Number(id),rgb:rgb.map(channel=>channel/peak)}; + }):undefined; for (let y = 0; y < rows; y++) for (let x = 0; x < columns; x++) { let mask = 0, white = 0, color = 2, peak = 0, colourPeak = 0, count = 0; for (let dy = 0; dy < 4; dy++) for (let dx = 0; dx < 2; dx++) { const p = ((y*4+dy)*width+x*2+dx)*4; - const k = terminalInk(data[p]!, data[p+1]!, data[p+2]!, data[p+3]!); + let k = terminalInk(data[p]!, data[p+1]!, data[p+2]!, data[p+3]!); + if(k && inks) { + const peak=Math.max(1,data[p]!,data[p+1]!,data[p+2]!); + let distance=Infinity; + for(const ink of inks) { + const d=ink.rgb.reduce((sum,v,i)=>sum+(data[p+i]!/peak-v)**2,0); + if(dsetDisplayColors()); +describe('Terminal profiles',()=>{ + it('migrates the three legacy choices without touching shared settings',()=>{ + const store={getItem:(key:string)=>key===TERMINAL_KEY?null:'{"palette":"amber","glyphs":"ascii","size":16}'}; + expect(loadTerminal(store)).toMatchObject({palette:'amber',glyphs:'ascii',size:16,fontSize:16}); + expect(loadTerminal({getItem:()=>{throw Error('blocked');}})).toEqual(terminalPreset('ansi')); + }); + it('bounds arbitrary saved data and rejects CSS, fonts and unknown keys',()=>{ + for(const raw of [null,[],{},false])expect(normalizeTerminal(raw)).toEqual(terminalPreset('ansi')); + const value=normalizeTerminal({palette:'__proto__',font:'url(https://evil)',uiFont:'bogus',glyphs:'html',weight:Infinity,fontSize:999,mapBrightness:-3,liveGlow:20,colours:{water:'red;display:none',ink:'#AaCcFF',unknown:'#ffffff'}}); + expect(value).toMatchObject({palette:'ansi',font:'mono',uiFont:'mono',glyphs:'braille',weight:600,fontSize:24,mapBrightness:.15,liveGlow:2}); + expect(value.colours.ink).toBe('#aaccff');expect(value.colours.water).toBe('#213c78'); + expect(Object.keys(value.colours)).toEqual(Object.keys(TERMINAL_COLOURS)); + }); + it('round-trips every preset and edits without sharing mutable defaults',()=>{ + for(const preset of Object.keys(TERMINAL_PRESETS) as (keyof typeof TERMINAL_PRESETS)[]){ + const profile=terminalPreset(preset); + expect(normalizeTerminal(JSON.parse(JSON.stringify(profile)))).toEqual(profile); + profile.colours.water='#ffffff';expect(terminalPreset(preset).colours.water).not.toBe('#ffffff'); + } + expect(hslColour(...colourHSL('#ff0000'))).toBe('#ff0000'); + expect(hslColour(180,100,50)).toBe('#00ffff'); + expect(colourHSL('#000000')).toEqual([0,0,0]); + }); + it('keeps arbitrary network colours and legend consistent, then removes the override',()=>{ + const old=displayColorRevision();setDisplayColors({'#4de7c4':'#5329ff','#ffffff':'#ff5500','bad':'url(no)'}); + expect(displayColorRevision()).toBe(old+1); + expect(displayColor('#4de7c4',false)).toBe('#5329ff');expect(packetPalette(false).Advert).toBe('#5329ff'); + expect(displayColor('rgba(77,231,196,0.4)',false)).toBe('rgba(83, 41, 255, 0.4)'); + expect(recolorPaint(['get','color'],false)).toEqual(['match',['get','color'],'#4de7c4','#5329ff','#ffffff','#ff5500',['get','color']]); + setDisplayColors();expect(displayColor('#4de7c4',false)).toBe('#4de7c4'); + }); + it('preserves glyph packet colours even when users choose unconventional hues',()=>{ + const data=new Uint8ClampedArray(32);data.set([83,41,255,220],0);data.set([255,85,0,255],28); + const cell=characterCells({data,width:2,height:4},false,{2:'#ff5500',4:'#5329ff',6:'#00ff00'}); + expect([...cell.colors]).toEqual([4]);expect(cell.hot).toEqual(['⢀']); + }); + it('starts Terminal with the owner-selected defaults in its own storage scope',async()=>{ + vi.resetModules();document.documentElement.setAttribute('data-terminal','true'); + try { + const ui=await import('./preferences'),display=await import('./displayPreferences'); + expect(ui.UI_STORAGE_KEY).toContain(':terminal-ui:');expect(display.DISPLAY_STORAGE_KEY).toContain(':terminal-display:'); + expect(ui.loadUiPreferences(localStorage)).toMatchObject({routes:true,livePackets:true,heatmap:true,clusters:false,hillshade:false,terrain3D:false,buildings:true,routeWindow:'15m',nodeLabels:true,mapLabels:true,roads:true,relief:.15}); + if('regions' in ui.DEFAULT_UI_PREFERENCES)expect(ui.DEFAULT_UI_PREFERENCES.regions).toBe(false); + expect(display.loadDisplayPreferences({getItem:()=>null})).toMatchObject({effects:'spectacle',detail:'complete',quality:'high',motion:'system',textSize:'standard',preset:'neon',...display.ROUTE_PRESETS.neon}); + expect(loadTerminal({getItem:()=>null})).toMatchObject({palette:'ansi',glyphs:'braille',size:10}); + } finally {document.documentElement.removeAttribute('data-terminal');vi.resetModules();} + }); +}); diff --git a/web/src/terminalProfile.ts b/web/src/terminalProfile.ts new file mode 100644 index 0000000..6ac6129 --- /dev/null +++ b/web/src/terminalProfile.ts @@ -0,0 +1,120 @@ +import { terminalPreferences as legacyPreferences } from './terminalGrid'; + +export const TERMINAL_KEY = 'cartolite-terminal-v2'; +export const TERMINAL_LEGACY_KEY = 'cartolite-terminal-v1'; +export const TERMINAL_FONTS = { + mono: { label: 'System mono', family: 'ui-monospace, "Cascadia Mono", "DejaVu Sans Mono", monospace' }, + vt323: { label: 'VT323 console', family: '"VT323", monospace' }, + pixel: { label: 'Silkscreen pixel', family: '"Silkscreen", monospace' }, + typewriter: { label: 'Typewriter', family: '"Courier New", Courier, monospace' }, +} as const; + +// Semantic slots stay independent: a green river can never become an Advert. +export const TERMINAL_COLOURS = { + background: ['Map', 'Background', '#000000'], water: ['Map', 'Water', '#213c78'], + vegetation: ['Map', 'Vegetation', '#253e31'], urban: ['Map', 'Built-up land', '#28323f'], + roads: ['Map', 'Roads', '#536b80'], boundary: ['Map', 'Borders', '#657d91'], + shore: ['Map', 'Shorelines', '#788a98'], buildings: ['Map', 'Buildings', '#667b89'], + terrain: ['Map', 'Terrain highlights', '#516455'], places: ['Labels', 'Place names', '#a5b8c8'], + labels: ['Labels', 'Node names', '#f2fff1'], selection: ['Nodes', 'Selected node', '#ffffff'], + neighbours: ['Nodes', 'Neighbours', '#a5b8c8'], + repeater: ['Nodes', 'Repeaters', '#72ffcf'], companion: ['Nodes', 'Companions', '#89e5ff'], + room: ['Nodes', 'Rooms', '#c6a4ff'], sensor: ['Nodes', 'Sensors / unknown', '#d7e393'], + advert: ['Traffic', 'Advert', '#72ffcf'], trace: ['Traffic', 'Trace', '#ffdb72'], + text: ['Traffic', 'Text', '#ff85ce'], ack: ['Traffic', 'ACK', '#89e5ff'], + control: ['Traffic', 'Control', '#c6a4ff'], other: ['Traffic', 'Other', '#b7cbd9'], + core: ['Traffic', 'Packet core', '#f2fff1'], + panel: ['Interface', 'Panel background', '#050b10'], field: ['Interface', 'Input background', '#08121a'], + border: ['Interface', 'Panel borders', '#527885'], ink: ['Interface', 'Text', '#d5e6df'], + strong: ['Interface', 'Headings', '#e9e9bf'], muted: ['Interface', 'Secondary text', '#a5bdbb'], + accent: ['Interface', 'Accent / focus', '#79f8c4'], active: ['Interface', 'Active controls', '#193c3e'], + warning: ['Interface', 'Warnings', '#ffd18a'], +} as const; +export type TerminalColour = keyof typeof TERMINAL_COLOURS; +export type TerminalFont = keyof typeof TERMINAL_FONTS; +export type TerminalPreset = 'ansi' | 'amber' | 'green' | 'cga' | 'ega' | 'cell'; +export interface TerminalProfile { + palette: TerminalPreset; glyphs: 'braille' | 'ascii' | 'blocks' | 'cell'; size: number; + font: TerminalFont; uiFont: TerminalFont; fontSize: number; weight: number; uiScale: number; + mapBrightness: number; labelBrightness: number; liveGlow: number; traffic: 'characters' | 'smooth'; + colours: Record; +} +const baseColours = Object.fromEntries(Object.entries(TERMINAL_COLOURS).map(([key, value]) => [key, value[2]])) as TerminalProfile['colours']; +const BASE: Omit = { + glyphs: 'braille', size: 10, font: 'mono', uiFont: 'mono', fontSize: 10, weight: 600, uiScale: 1, + mapBrightness: 1, labelBrightness: 1, liveGlow: 1, traffic: 'characters', +}; +export const TERMINAL_PRESETS: Record; options?: Partial> }> = { + ansi: { label: 'BBS', colours: {} }, + amber: { label: 'Amber', colours: { + water: '#332414', vegetation: '#30271b', urban: '#382d1c', roads: '#78552a', boundary: '#876c3c', shore: '#a5814c', buildings: '#806947', terrain: '#655235', places: '#bf9e70', + labels: '#ffe5ac', neighbours:'#bf9e70', repeater: '#ffd18a', companion: '#ffe8ba', room: '#eaba6c', sensor: '#dac19a', advert: '#ffd18a', trace: '#ffffc7', text: '#eaba6c', ack: '#ffe8ba', control: '#d1a66b', other: '#c7ad83', core: '#fff5dc', selection: '#ffffff', + panel: '#100b05', field: '#1c1308', border: '#806538', ink: '#f3d8a4', strong: '#fff0cc', muted: '#c5ab80', accent: '#ffd18a', active: '#40301b', warning: '#fff3ba', + } }, + green: { label: 'Green', colours: { + water: '#113b32', vegetation: '#122c1d', urban: '#203b29', roads: '#42684d', boundary: '#5d7e60', shore: '#72997f', buildings: '#446b51', terrain: '#345440', places: '#94bb9a', + labels: '#afffc7', neighbours:'#94bb9a', repeater: '#8dffb0', companion: '#ceffd9', room: '#61db86', sensor: '#b1d5ac', advert: '#8dffb0', trace: '#dfffab', text: '#6be79c', ack: '#ccffe4', control: '#51c98e', other: '#a3ceb1', core: '#effff2', selection: '#ffffff', + panel: '#050e08', field: '#0a1b12', border: '#497b5b', ink: '#c3f2cd', strong: '#e5ffdf', muted: '#9dbea5', accent: '#8dffb0', active: '#173c26', warning: '#dfffab', + } }, + cga: { label: 'CGA', options: { glyphs: 'blocks', font: 'pixel', fontSize: 11, weight: 400, mapBrightness: .7, traffic: 'smooth' }, colours: { + water: '#005555', vegetation: '#002a2a', urban: '#2b152b', roads: '#aa55aa', boundary: '#ff55ff', shore: '#55ffff', buildings: '#aa55aa', places: '#55ffff', labels: '#ffffff', + advert: '#55ffff', trace: '#ffffff', text: '#ff55ff', ack: '#55aaff', control: '#aa55ff', other: '#aaaaaa', repeater: '#55ffff', companion: '#ffffff', room: '#ff55ff', + panel: '#080008', field: '#180b18', border: '#aa55aa', ink: '#ffffff', strong: '#55ffff', muted: '#bbaabb', accent: '#ff55ff', active: '#3f153f', + } }, + ega: { label: 'EGA', options: { glyphs: 'ascii', font: 'vt323', fontSize: 16, weight: 400, mapBrightness: .65, traffic: 'smooth' }, colours: { + water: '#0000aa', vegetation: '#005500', urban: '#555555', roads: '#aaaaaa', boundary: '#aa5500', shore: '#5555ff', buildings: '#aaaaaa', places: '#ffff55', + advert: '#55ff55', trace: '#ffff55', text: '#ff55ff', ack: '#55ffff', control: '#5555ff', other: '#aaaaaa', repeater: '#55ff55', companion: '#55ffff', room: '#ff55ff', + border: '#5555ff', accent: '#55ffff', active: '#000055', + } }, + cell: { label: 'Cel-shaded', options: { glyphs: 'cell', font: 'mono', fontSize: 12, mapBrightness: .65, traffic: 'smooth' }, colours: { + background: '#07141b', water: '#183d59', vegetation: '#1e3d32', urban: '#35434b', roads: '#657582', boundary: '#81949c', shore: '#7b9ba9', buildings: '#647c88', terrain: '#476b53', places: '#b9cdd4', + } }, +}; +export function terminalPreset(palette: TerminalPreset): TerminalProfile { + const preset = TERMINAL_PRESETS[palette]; + return { ...BASE, ...preset.options, palette, colours: { ...baseColours, ...preset.colours } }; +} +export function normalizeTerminal(value: unknown): TerminalProfile { + const v = value && typeof value === 'object' ? value as Partial : {}; + const palette = typeof v.palette === 'string' && Object.hasOwn(TERMINAL_PRESETS, v.palette) ? v.palette : 'ansi'; + const defaults = terminalPreset(palette); + const number = (key: keyof TerminalProfile, min: number, max: number, step: number) => typeof v[key] === 'number' && Number.isFinite(v[key]) + ? Number((Math.round(Math.max(min, Math.min(max, v[key] as number)) / step) * step).toFixed(3)) : defaults[key] as number; + return { + palette, glyphs: ['braille','ascii','blocks','cell'].includes(String(v.glyphs)) ? v.glyphs! : defaults.glyphs, + size: [10,12,16].includes(v.size!) ? v.size! : defaults.size, + font: typeof v.font === 'string' && Object.hasOwn(TERMINAL_FONTS,v.font) ? v.font : defaults.font, + uiFont: typeof v.uiFont === 'string' && Object.hasOwn(TERMINAL_FONTS,v.uiFont) ? v.uiFont : defaults.uiFont, + fontSize: number('fontSize',10,24,1), weight: number('weight',400,700,100), uiScale: number('uiScale',.9,1.35,.05), + mapBrightness: number('mapBrightness',.15,1.3,.05), labelBrightness: number('labelBrightness',.4,1.3,.05), liveGlow: number('liveGlow',0,2,.1), + traffic: v.traffic === 'smooth' || v.traffic === 'characters' ? v.traffic : defaults.traffic, + colours: Object.fromEntries(Object.keys(baseColours).map(key => { + const colour = v.colours?.[key as TerminalColour]; + return [key, typeof colour === 'string' && /^#[0-9a-f]{6}$/i.test(colour) ? colour.toLowerCase() : defaults.colours[key as TerminalColour]]; + })) as TerminalProfile['colours'], + }; +} +export function loadTerminal(storage: Pick): TerminalProfile { + try { + const value = storage.getItem(TERMINAL_KEY); + if (value) return normalizeTerminal(JSON.parse(value)); + const raw=storage.getItem(TERMINAL_LEGACY_KEY); + if(!raw)return terminalPreset('ansi'); + const legacy=legacyPreferences(raw); + return normalizeTerminal({...legacy,fontSize:legacy.size}); + } catch { return terminalPreset('ansi'); } +} +export function shade(colour: string, amount: number): string { + return '#' + [1,3,5].map(start => Math.min(255, Math.round(parseInt(colour.slice(start,start+2),16)*amount)).toString(16).padStart(2,'0')).join(''); +} +export function colourHSL(hex: string): [number,number,number] { + const [r=0,g=0,b=0] = [1,3,5].map(i=>parseInt(hex.slice(i,i+2),16)/255); + const min=Math.min(r,g,b), max=Math.max(r,g,b), d=max-min, l=(max+min)/2; + const h=d===0?0:(max===r?(g-b)/d+(g{const k=(n+h/30)%12;return Math.round(255*(l-a*Math.max(-1,Math.min(k-3,9-k,1)))).toString(16).padStart(2,'0');}).join(''); +} diff --git a/web/vite.config.ts b/web/vite.config.ts index dd3c91a..1e86d40 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -7,6 +7,7 @@ function inputPath(relative: string): string { export default defineConfig({ build: { + manifest: true, target: 'es2022', assetsInlineLimit: 0, chunkSizeWarningLimit: 500,