diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9e6fa7a..1aeaa83 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -148,6 +148,12 @@ 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: Worldwide and terrain regressions
if: matrix.arch == 'amd64'
working-directory: web
@@ -170,7 +176,7 @@ jobs:
) &
publisher_pid=$!
trap 'kill "$publisher_pid" >/dev/null 2>&1 || true' EXIT
- npm run test:e2e -- --grep-invert '@terrain|@world|@regions'
+ npm run test:e2e -- --grep-invert '@terrain|@world|@regions|Terminal|unavailable character readback'
- uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
scan-type: image
@@ -235,6 +241,8 @@ jobs:
web/playwright-report/
web/focused-test-results/
web/focused-playwright-report/
+ web/terminal-test-results/
+ web/terminal-playwright-report/
- if: always()
run: |
docker rm -f cartolite cartolite-server-mqtt >/dev/null 2>&1 || true
diff --git a/Dockerfile b/Dockerfile
index 4587d82..7482618 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -5,6 +5,7 @@ WORKDIR /src/web
COPY web/package.json web/package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY web/ ./
+COPY scripts/write-terminal-page.mjs /src/scripts/write-terminal-page.mjs
# Published images contain no operator or CI browser credentials.
RUN npm run build
diff --git a/README.md b/README.md
index 64d5dd2..8f46898 100644
--- a/README.md
+++ b/README.md
@@ -186,3 +186,9 @@ Only connections with both endpoints in the selected regions are shown. Search
and Follow respect this selection. Use **Routes** to hide standing connections;
live packets still illuminate their paths and leave a brief fading trace.
Layout, region choices and route visibility are remembered in this browser.
+
+### Terminal view
+
+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).
diff --git a/VERSION b/VERSION
index f38fc53..a3df0a6 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.7.3
+0.8.0
diff --git a/backend/internal/httpapi/server.go b/backend/internal/httpapi/server.go
index b2421f6..6c45f24 100644
--- a/backend/internal/httpapi/server.go
+++ b/backend/internal/httpapi/server.go
@@ -189,6 +189,14 @@ func (s *Server) frontend(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, target, http.StatusPermanentRedirect)
return
}
+ if r.URL.Path == "/terminal" {
+ target := "/terminal/"
+ if r.URL.RawQuery != "" {
+ target += "?" + r.URL.RawQuery
+ }
+ http.Redirect(w, r, target, http.StatusPermanentRedirect)
+ return
+ }
name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
if name == "." || name == "" {
name = "index.html"
diff --git a/backend/internal/httpapi/terminal_test.go b/backend/internal/httpapi/terminal_test.go
new file mode 100644
index 0000000..8bbf74a
--- /dev/null
+++ b/backend/internal/httpapi/terminal_test.go
@@ -0,0 +1,31 @@
+package httpapi
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "testing/fstest"
+)
+
+func TestTerminalPageAndCanonicalURL(t *testing.T) {
+ server := &Server{static: fstest.MapFS{
+ "terminal/index.html": {Data: []byte("
CartoLite Terminal")},
+ }}
+ handler := server.Handler()
+ for _, method := range []string{http.MethodGet, http.MethodHead} {
+ response := httptest.NewRecorder()
+ handler.ServeHTTP(response, httptest.NewRequest(method, "/terminal?node=synthetic", nil))
+ if response.Code != http.StatusPermanentRedirect || response.Header().Get("Location") != "/terminal/?node=synthetic" {
+ t.Fatalf("Terminal redirect: %d %q", response.Code, response.Header().Get("Location"))
+ }
+ response = httptest.NewRecorder()
+ handler.ServeHTTP(response, httptest.NewRequest(method, "/terminal/", nil))
+ if response.Code != http.StatusOK || response.Header().Get("Cache-Control") != "public, no-cache, no-transform" {
+ t.Fatalf("Terminal response: %d", response.Code)
+ }
+ if method == http.MethodGet && !strings.Contains(response.Body.String(), "CartoLite Terminal") {
+ t.Fatal("wrong Terminal document")
+ }
+ }
+}
diff --git a/docs/terminal-view-plan-2026-10-17.md b/docs/terminal-view-plan-2026-10-17.md
new file mode 100644
index 0000000..1b08092
--- /dev/null
+++ b/docs/terminal-view-plan-2026-10-17.md
@@ -0,0 +1,55 @@
+# Terminal view — Canada and Worldwide
+
+Add `/terminal/` as the third view after Map and Netgraph. On phones it lives in
+the existing compact view picker. The visual reference is a 1990s BBS atlas:
+black land, dotted blue water, pale coastlines, yellow place names, ANSI traffic
+and a compact coordinate/status line. Amber and green phosphor are alternatives.
+
+## Implementation
+
+Reuse the Map entry, public MapLibre custom-layer lifecycle and existing controls.
+Use a two-pass WebGL2 character effect: one metadata pixel per cell, then a local
+font atlas. Geographic pixels stay on the GPU; there is no readback or preserved
+drawing buffer. A page-specific Canvas2D character renderer translates the existing packet
+animation canvas into Braille or ASCII cells and places readable labels. It does not create another feed,
+packet clock, topology model or geographic dataset. Historical routes stay still;
+live motion and audio retain their existing scheduling. The original scene owns
+hit testing, terrain, buildings, region assignments, pan, zoom and Follow.
+
+Draw readable geographic/node labels over the character grid. Preserve Find,
+inspection, route windows, layer combinations, region legend, Follow, sound and
+optional 3D. Keep palette, glyph mode and cell size in a separate device preference.
+Do not overwrite the user's Map/Netgraph style. Keep attribution visible. If
+character graphics are unavailable, restore the ordinary map and offer a compact retry notice.
+Pause background work when hidden and release all listeners on navigation.
+
+The built Terminal document reuses the Map HTML and loads its own entry before Map;
+explicit chunk boundaries keep Terminal dependencies out of other entries. No copied control
+markup or new renderer framework. Terminal assets load only on its own URL.
+Existing UI, worker, privacy and performance caps remain. Budget the optional
+Terminal JS/CSS separately at 8 KiB gzip, with a browser assertion that other
+views never download it. No new dependencies or public API fields.
+
+## Delivery
+
+1. Implement paired Canada/Worldwide frontend changes, navigation and tests.
+2. Check synthetic geographic scenes, readable character output, live/quiet
+ timing, all layer controls, selection, missing tiles, camera changes, reduced
+ motion, storage failure, mobile bounds and the 7,000-route fixture in Actions.
+3. Update Canada Android link/start-view restoration without changing signing
+ identity. Use emulator validation; physical testing remains optional per the
+ user's release instruction. Do not operate a disconnected user phone.
+4. Review passing PRs and promote the exact tested images. Publish Canada 0.17.0,
+ Worldwide 0.8.0 and Android 1.1.2 if its signed candidate passes verification.
+5. Verify Canada's live target, encrypted off-host backup and isolated restore;
+ deploy the tested digest and verify public views, traffic, checkpoint writes
+ and at least five minutes of health. Update install/release and product links.
+
+## Deliberate limits
+
+This is a browser TUI, not a terminal emulator or remote shell. Character cells
+quantize detail; several close nodes/routes may occupy a cell. Zoom, Find and
+inspection retain access to the underlying data. Terrain and buildings are
+character shading, not new claims about radio height or propagation. No historical
+traffic is replayed as live activity. MapLibre still requires graphics support;
+Netgraph remains available when the basemap cannot initialize.
diff --git a/scripts/check-frontend-budget.mjs b/scripts/check-frontend-budget.mjs
index 8b47b40..64c1ec6 100644
--- a/scripts/check-frontend-budget.mjs
+++ b/scripts/check-frontend-budget.mjs
@@ -8,7 +8,9 @@ const root = process.argv[2] ?? "web/dist";
// Combined measured cost: 446,084 bytes. Runtime timing/route gates are unchanged.
const uiLimit = 350 * 1024;
const workerLimit = 128 * 1024;
-const limit = Number(process.env.CARTOLITE_GZIP_BUDGET ?? (uiLimit + workerLimit));
+// Optional Terminal view is lazy-loaded; original UI and worker caps stay fixed.
+const terminalLimit = 8 * 1024;
+const limit = Number(process.env.CARTOLITE_GZIP_BUDGET ?? (uiLimit + workerLimit + terminalLimit));
async function assets(dir, pattern) {
const found = [];
@@ -24,11 +26,13 @@ const files = await assets(root, /\.(?:css|m?js)$/);
if (files.length === 0) throw new Error(`no JavaScript or CSS assets found in ${root}`);
let total = 0;
+let terminalBytes = 0;
let workerBytes = 0;
let workerCount = 0;
for (const file of files) {
const bytes = gzipSync(await readFile(file), { level: 9 }).byteLength;
total += bytes;
+ if (/^terminal-.*\.(?:m?js|css)$/.test(basename(file))) terminalBytes += bytes;
if (/^maplibre-gl-worker-.*\.m?js$/.test(basename(file))) {
workerBytes += bytes;
workerCount += 1;
@@ -37,8 +41,10 @@ for (const file of files) {
}
console.log(`${total} gzip bytes total (budget ${limit})`);
-console.log(`${total - workerBytes} gzip bytes excluding map worker (budget ${uiLimit})`);
+console.log(`${total - workerBytes - terminalBytes} gzip bytes excluding map worker (budget ${uiLimit})`);
console.log(`${workerBytes} gzip bytes for map worker (budget ${workerLimit})`);
+console.log(terminalBytes + ' gzip bytes for optional Terminal (budget ' + terminalLimit + ')');
+if (terminalBytes <= 0 || terminalBytes > terminalLimit) process.exitCode = 1;
if (workerCount !== 1) throw new Error(`expected one map worker, found ${workerCount}`);
if (!Number.isFinite(limit) || limit <= 0) throw new Error('invalid total frontend budget');
-if (total > limit || total - workerBytes > uiLimit || workerBytes > workerLimit) process.exitCode = 1;
+if (total > limit || total - workerBytes - terminalBytes > uiLimit || workerBytes > workerLimit) process.exitCode = 1;
diff --git a/scripts/write-terminal-page.mjs b/scripts/write-terminal-page.mjs
new file mode 100644
index 0000000..65abc9c
--- /dev/null
+++ b/scripts/write-terminal-page.mjs
@@ -0,0 +1,18 @@
+import { mkdir, readFile, readdir, writeFile } 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 terminal = html.replace('', '')
+ .replace('', `\n${script}`)
+ .replace('', `${style}\n`)
+ .replace(/(.*?)<\/title>/, 'Terminal · $1')
+ .replace('aria-label="CartoLite live map"', 'aria-label="CartoLite Terminal live map"')
+ .replace(/(rel="canonical" href="https:\/\/[^"/]+)\//, '$1/terminal/')
+ .replace(/(property="og:url" content="https:\/\/[^"/]+)\//, '$1/terminal/');
+await mkdir('dist/terminal', { recursive: true });
+await writeFile('dist/terminal/index.html', terminal);
diff --git a/web/e2e/scale-smoke.spec.ts b/web/e2e/scale-smoke.spec.ts
index bef0de0..a80d7be 100644
--- a/web/e2e/scale-smoke.spec.ts
+++ b/web/e2e/scale-smoke.spec.ts
@@ -7,7 +7,7 @@ import type { NodeV2, RouteV2, StateV2 } from '../src/types';
// after every timing assertion has completed.
test.use({ screenshot: 'off', trace: 'off' });
-test('keeps a 4k-node / 7k-route first view responsive', async ({ page }, testInfo) => {
+for (const viewPath of ['/', '/terminal/']) test('keeps a 4k-node / 7k-route first view responsive ' + viewPath, async ({ page }, testInfo) => {
// Allow the complete interaction journey to finish; the explicit startup,
// frame, long-task, and application-work budgets below still gate performance.
test.slow();
@@ -32,7 +32,7 @@ test('keeps a 4k-node / 7k-route first view responsive', async ({ page }, testIn
}));
const started = Date.now();
- await page.goto('/');
+ await page.goto(viewPath);
await expect(page.locator('#status')).toHaveAttribute('title', '4000 nodes · 7000 routes', { timeout: 10_000 });
await expect(page.locator('#map .maplibregl-canvas')).toBeVisible();
await expect(page.locator('#packet-canvas')).toHaveAttribute('data-power-mode', testInfo.project.name.startsWith('mobile') ? 'low' : 'full');
@@ -153,7 +153,7 @@ test('keeps a 4k-node / 7k-route first view responsive', async ({ page }, testIn
await page.screenshot({ path: testInfo.outputPath('cartolite-scale.png') });
});
-test('coalesces a busy live burst into one historical-route refresh', async ({ page }) => {
+for (const viewPath of ['/', '/terminal/']) test('coalesces a busy live burst into one historical-route refresh ' + viewPath, async ({ page }) => {
const now = Date.now();
const state: StateV2 = {
schemaVersion: 2,
@@ -186,7 +186,7 @@ test('coalesces a busy live burst into one historical-route refresh', async ({ p
contentType: 'text/event-stream',
body: `retry: 60000\n\nevent: hello\ndata: ${JSON.stringify({ seq: 0, bootId: state.bootId })}\n\n${events}`
}));
- await page.goto('/');
+ await page.goto(viewPath);
const map = page.locator('#map');
await expect(map).toHaveAttribute('data-render-state', 'idle', { timeout: 15_000 });
await page.waitForTimeout(8_800);
diff --git a/web/e2e/terminal.spec.ts b/web/e2e/terminal.spec.ts
new file mode 100644
index 0000000..b7a6755
--- /dev/null
+++ b/web/e2e/terminal.spec.ts
@@ -0,0 +1,116 @@
+import { expect, test, type Page } from '@playwright/test';
+import { visualFixture, visualPacket } from './visualFixtures';
+import { openMapOptions } from './mapControls';
+import { openViewMenu } from './dockControls';
+
+test('Terminal keeps geographic controls, live packets and device display choices', async ({ page }, info) => {
+ test.slow();
+ const errors: string[] = []; page.on('pageerror', error => errors.push(error.message));
+ await visualFixture(page); await page.goto('/terminal/');
+ const map = page.locator('#map'), grid = page.locator('#terminal-map');
+ await expect(map).toHaveAttribute('data-render-state', 'idle', { timeout: 10000 });
+ await expect(map).toHaveAttribute('data-terminal-renderer','webgl2');
+ await expect.poll(() => blueGlyphPixels(page)).toBeGreaterThan(100);
+ await expect.poll(async () => Number(await grid.getAttribute('data-labels'))).toBeGreaterThan(0);
+ await expect(page.locator('.dock-views [aria-current="page"]')).toHaveText('Terminal');
+ await expect(page.locator('#fatal')).toBeHidden();
+ await page.screenshot({ path: info.outputPath('terminal-ansi.png') });
+ if (info.project.name === 'desktop') {
+ await page.locator('#terrain-shortcut').click();
+ await expect(map).toHaveAttribute('data-terrain3d','true');
+ await expect(map).toHaveAttribute('data-camera-moving','false');
+ await page.screenshot({path:info.outputPath('terminal-terrain.png')});
+ await page.locator('#terrain-shortcut').click();
+ await expect(map).toHaveAttribute('data-terrain3d','false');
+ }
+ const saved = await page.evaluate(() => [localStorage.getItem('cartolite:ui:v1'), localStorage.getItem('cartolite-server:ui:v1')]);
+ await openMapOptions(page);
+ await page.getByLabel('Terminal palette').selectOption('amber');
+ await page.getByLabel('Terminal characters').selectOption('ascii');
+ await page.getByLabel('Terminal cell size').selectOption('16');
+ expect(await page.evaluate(() => [localStorage.getItem('cartolite:ui:v1'), localStorage.getItem('cartolite-server:ui:v1')])).toEqual(saved);
+ await page.keyboard.press('Escape');
+ await expect(grid).toHaveAttribute('data-glyphs', 'ascii');
+ await page.reload();
+ await expect(page.locator('html')).toHaveAttribute('data-terminal-palette', 'amber');
+ await expect(grid).toHaveAttribute('data-glyphs', 'ascii');
+ await openMapOptions(page); await page.locator('#routes-button').click(); await page.keyboard.press('Escape');
+ await expect(map).toHaveAttribute('data-routes-visible', 'false');
+ await page.locator('#follow-button').click();
+ await visualPacket(page, 1, 'Text');
+ await expect(page.locator('#follow-card')).toBeVisible();
+ await expect(page.locator('#packet-canvas')).toHaveAttribute('data-active-count', '1');
+ await expect.poll(async () => Number(await page.locator('#terminal-packets').getAttribute('data-cells'))).toBeGreaterThan(0);
+ await page.screenshot({ path: info.outputPath('terminal-amber-packet.png') });
+ await page.locator('#follow-close').click();
+ await page.locator('#find-button').click(); await page.locator('#node-search').fill('Visual Alpha');
+ await page.getByRole('option', { name: /Visual Alpha/ }).click();
+ await expect(map).toHaveAttribute('data-selected-node-id', 'visual-a');
+ await expect(page.locator('#node-inspector-sheet')).toContainText('Visual Alpha');
+ await page.keyboard.press('Escape');
+ await openMapOptions(page); await page.getByLabel('Terminal palette').selectOption('green'); await page.keyboard.press('Escape');
+ await page.screenshot({ path: info.outputPath('terminal-green.png') });
+ expect(errors).toEqual([]);
+});
+
+test('Terminal stays compact and keeps selection when switching views', async ({ page }, info) => {
+ if (info.project.name !== 'desktop') await page.setViewportSize(info.project.name.includes('landscape') ? { width:568,height:320 } : { width:320,height:640 });
+ await visualFixture(page); await page.goto('/terminal/?node=visual-a');
+ await expect(page.locator('#map')).toHaveAttribute('data-selected-node-id', 'visual-a', {timeout:10000});
+ await page.keyboard.press('Escape');
+ await openViewMenu(page);
+ const links = page.locator('.dock-views a');
+ expect((await links.allTextContents()).slice(0,3)).toEqual(['Map','Netgraph','Terminal']);
+ const controls = (await page.locator('.controls').boundingBox())!;
+ expect(controls.x).toBeGreaterThanOrEqual(0); expect(controls.x+controls.width).toBeLessThanOrEqual(page.viewportSize()!.width);
+ await page.screenshot({path:info.outputPath('terminal-navigation.png')});
+ await page.getByRole('link', {name:'Open CartoLite Netgraph',exact:true}).click();
+ await expect(page.locator('#netgraph-stage')).toHaveAttribute('data-render-state','idle',{timeout:10000});
+ await expect(page.locator('#terminal-map')).toHaveCount(0);
+});
+
+test('unavailable Terminal graphics falls back without stopping the feed', async ({page}) => {
+ await visualFixture(page);
+ await page.addInitScript(() => {
+ const original = WebGL2RenderingContext.prototype.getShaderParameter;
+ WebGL2RenderingContext.prototype.getShaderParameter = function (...args: Parameters) {
+ if (args[1] === this.COMPILE_STATUS && this.getShaderSource(args[0])?.includes('terminal_scene') && !document.documentElement.dataset.shaderFailedOnce) {
+ document.documentElement.dataset.shaderFailedOnce = 'true'; return false;
+ }
+ return original.apply(this,args);
+ };
+ });
+ await page.goto('/terminal/');
+ await expect(page.locator('.terminal-notice')).toBeVisible();
+ await expect(page.locator('#fatal')).toBeHidden();
+ expect(await page.locator('.maplibregl-canvas').evaluate(el => getComputedStyle(el).opacity)).toBe('1');
+ await page.getByRole('button',{name:'Retry Terminal',exact:true}).click();
+ await expect(page.locator('.terminal-notice')).toBeHidden();
+ await expect.poll(() => blueGlyphPixels(page)).toBeGreaterThan(100);
+});
+
+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()); });
+ await visualFixture(page);
+ for (const path of ['/', '/netgraph/']) {
+ await page.goto(path);
+ await expect(page.locator(path === '/' ? '#map' : '#netgraph-stage')).toHaveAttribute('data-render-state','idle',{timeout:10000});
+ }
+ expect(terminalAssets).toEqual([]);
+});
+
+// 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 {
+ const png = await page.screenshot();
+ return page.evaluate(async base64 => {
+ const img = new Image(); img.src = 'data:image/png;base64,' + base64; await img.decode();
+ const canvas = document.createElement('canvas'); canvas.width = img.width; canvas.height = img.height;
+ const ctx = canvas.getContext('2d', {willReadFrequently:true})!; ctx.drawImage(img,0,0);
+ const data = ctx.getImageData(0,Math.floor(img.height*.2),img.width,Math.floor(img.height*.6)).data;
+ let count=0;
+ for(let i=0;i80 && data[i+2]!>data[i]!*1.8 && data[i+2]!>data[i+1]!*1.3) count++;
+ return count;
+ }, png.toString('base64'));
+}
diff --git a/web/package-lock.json b/web/package-lock.json
index c981981..5db9dc7 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "cartolite-server-web",
- "version": "0.7.3",
+ "version": "0.8.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cartolite-server-web",
- "version": "0.7.3",
+ "version": "0.8.0",
"dependencies": {
"maplibre-gl": "6.4.1"
},
diff --git a/web/package.json b/web/package.json
index bebeff7..df7b3d9 100644
--- a/web/package.json
+++ b/web/package.json
@@ -1,11 +1,11 @@
{
"name": "cartolite-server-web",
- "version": "0.7.3",
+ "version": "0.8.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
- "build": "tsc --noEmit && vite build",
+ "build": "tsc --noEmit && vite build && node ../scripts/write-terminal-page.mjs",
"test": "vitest run",
"test:e2e": "playwright test",
"test:watch": "vitest"
diff --git a/web/playwright.config.ts b/web/playwright.config.ts
index eb1c019..823d16b 100644
--- a/web/playwright.config.ts
+++ b/web/playwright.config.ts
@@ -19,7 +19,7 @@ export default defineConfig({
projects: [
{
name: 'mobile-webkit',
- testMatch: /(?:labs-)?mobile-ux\.spec\.ts/,
+ testMatch: /(?:(?:labs-)?mobile-ux|terminal)\.spec\.ts/,
use: { ...devices['iPhone 13'], launchOptions: { args: [] } }
},
{
diff --git a/web/src/cinematicChrome.ts b/web/src/cinematicChrome.ts
index e83faeb..1064667 100644
--- a/web/src/cinematicChrome.ts
+++ b/web/src/cinematicChrome.ts
@@ -9,16 +9,14 @@ export function mountCinematicDock(): void {
const navigation = document.createElement('nav');
navigation.className = 'dock-views'; navigation.setAttribute('aria-label', 'Views');
const graph = location.pathname.startsWith('/netgraph');
- const mapLink = document.createElement('a');
- mapLink.href = '/'; mapLink.className = 'control-button'; mapLink.textContent = 'Map';
- mapLink.setAttribute('aria-label', 'Open CartoLite map');
- if (!graph) mapLink.setAttribute('aria-current', 'page');
- navigation.append(mapLink);
- const graphLink = topbar.querySelector('#netgraph-link') ?? document.createElement('a');
- graphLink.href = '/netgraph/'; graphLink.classList.add('control-button', 'netgraph-link'); graphLink.textContent = 'Netgraph';
- graphLink.setAttribute('aria-label', 'Open CartoLite Netgraph');
- if (graph) graphLink.setAttribute('aria-current', 'page');
- navigation.append(graphLink);
+ const terminal = location.pathname.startsWith('/terminal');
+ for (const [label, path, active] of [['Map', '/', !graph && !terminal], ['Netgraph', '/netgraph/', graph], ['Terminal', '/terminal/', terminal]] as const) {
+ const link = (label === 'Netgraph' ? topbar.querySelector('#netgraph-link') : null) ?? document.createElement('a');
+ link.href = path; link.classList.add('control-button'); link.textContent = label;
+ link.setAttribute('aria-label', `Open CartoLite ${label === 'Map' ? 'map' : label}`);
+ if (active) link.setAttribute('aria-current', 'page');
+ navigation.append(link);
+ }
const labs = topbar.querySelector('#labs-link');
if (labs) { labs.classList.add('control-button'); navigation.append(labs); }
controls.prepend(navigation);
@@ -44,7 +42,7 @@ export function mountCinematicDock(): void {
close.setAttribute('aria-label', id === 'find' ? 'Close node search' : 'Close sound settings');
close.addEventListener('click', () => { if (!panel.hidden) owner.click(); owner.focus(); }); header.append(close);
}
- mountMobileDock(controls, navigation, graph ? 'Netgraph' : 'Map');
+ mountMobileDock(controls, navigation, graph ? 'Netgraph' : terminal ? 'Terminal' : 'Map');
trackPanelHistory(controls, ['view-panel', 'layers-panel', 'find-panel', 'sound-panel', 'region-options', 'display-panel', 'follow-card', 'node-inspector-sheet']);
// One menu at a time, including the independently mounted display control.
let closing = false;
diff --git a/web/src/displayPreferences.ts b/web/src/displayPreferences.ts
index 3dd1f62..8b11110 100644
--- a/web/src/displayPreferences.ts
+++ b/web/src/displayPreferences.ts
@@ -52,7 +52,7 @@ const LIGHT_COLORS: Record = {
let current: DisplayPreferences = { ...DEFAULT_DISPLAY };
let initialized = false;
export function displayPreferences(): Readonly { return current; }
-export function lightScene(): boolean { return current.basemap !== 'dark'; }
+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()];
diff --git a/web/src/gpuEffects.ts b/web/src/gpuEffects.ts
index 310efa9..8bbf722 100644
--- a/web/src/gpuEffects.ts
+++ b/web/src/gpuEffects.ts
@@ -156,7 +156,7 @@ export function mapEffects(map: LibreMap) {
const retry=()=>{budgetFallback=false;slowFrames=0;slowPresentationFrames=0;lastFrameAt=0;requestedAt=0;};
map.on('render',measure);map.on('moveend',retry);
const layer: CustomLayerInterface={id:'live-packet-effects',type:'custom',renderingMode:'2d',onAdd(_map,gl){activeGL=gl as WebGL2RenderingContext;batch.initialize(activeGL);},render(){batch.draw();},onRemove(){batch.dispose();}};
- const wanted=()=>displayPreferences().quality!=='economy'&&!budgetFallback
+ const wanted=()=>!document.documentElement.hasAttribute('data-terminal')&&displayPreferences().quality!=='economy'&&!budgetFallback
&& !(displayPreferences().quality==='auto'&&Math.max(Number(map.getContainer().dataset.eligibleRoutes)||0,Number(map.getContainer().dataset.renderedRouteSegments)||0)>2000);
const attach=()=>{
if(!wanted()){batch.setEnabled(false);if(map.getLayer?.(layer.id))map.removeLayer(layer.id);}
diff --git a/web/src/main.ts b/web/src/main.ts
index b3f07d7..d83eae8 100644
--- a/web/src/main.ts
+++ b/web/src/main.ts
@@ -197,6 +197,7 @@ async function start(): Promise {
const packetCanvas = required('packet-canvas');
const liveAnimator = new PacketAnimator(liveMap.map, packetCanvas);
animator = liveAnimator;
+ window.dispatchEvent(new CustomEvent('cartolite-map-ready', { detail: { view: liveMap, animator: liveAnimator } }));
const routeSonifier = new RouteSonifier(liveAnimator.projection, packetCanvas);
sonifier = routeSonifier;
mountSoundPreview(routeSonifier, soundPanel);
diff --git a/web/src/map.ts b/web/src/map.ts
index 6bf7dbe..ca3f470 100644
--- a/web/src/map.ts
+++ b/web/src/map.ts
@@ -713,7 +713,7 @@ export class LiveMap {
source.setData({ type: 'FeatureCollection', features });
}
- setAppearance(preferences: UiPreferences, force = false): void {
+ setAppearance(preferences: UiPreferences = this.#appearance, force = false): void {
this.#reducedMotion = displayReducedMotion();
const previous = this.#appearance;
this.#appearance = { ...preferences };
@@ -730,7 +730,7 @@ export class LiveMap {
this.map.setPaintProperty(layer.id, property as Parameters[1], value);
}
}
- const light = preferences.basemap !== 'dark';
+ const light = this.#lightScene();
this.#historicalRouteLayer.setLightBackground(light);
applyOverlayPalette(this.map, this.#originalOverlayColors, light);
if (this.#terrain3D) this.#updateSky();
@@ -755,13 +755,15 @@ export class LiveMap {
this.#updateBuildingLayer();
if (this.map.getLayer(HILLSHADE_LAYER_ID)) {
this.map.setPaintProperty(HILLSHADE_LAYER_ID, 'hillshade-exaggeration', this.#hillshadeStrength());
- const light = preferences.basemap !== 'dark';
+ const light = this.#lightScene();
this.map.setPaintProperty(HILLSHADE_LAYER_ID, 'hillshade-shadow-color', light ? 'rgba(47, 64, 61, 0.55)' : 'rgba(2, 9, 18, 0.86)');
this.map.setPaintProperty(HILLSHADE_LAYER_ID, 'hillshade-highlight-color', light ? 'rgba(255, 255, 244, 0.6)' : 'rgba(177, 211, 175, 0.64)');
}
this.markRendering();
}
+ #lightScene(): boolean { return this.#appearance.basemap !== 'dark' && !document.documentElement.hasAttribute('data-terminal'); }
+
getSelectedNodeID(): string | null { return this.#selectedNodeID; }
shouldFollow(packet: PacketView): boolean {
@@ -887,11 +889,11 @@ export class LiveMap {
this.container.dataset.buildingsVisible = String(this.#appearance.buildings);
this.container.dataset.buildingExtrusions = String(this.#appearance.buildings && this.#terrain3D && viewClass() === 'desktop');
if (!this.#layersReady) return;
- this.#buildingSourceID = updateBuildings(this.map, this.#appearance.buildings, this.#terrain3D, viewClass() === 'desktop', this.#appearance.basemap !== 'dark', basemapProvider() === 'openfreemap' ? 'carto' : undefined);
+ this.#buildingSourceID = updateBuildings(this.map, this.#appearance.buildings, this.#terrain3D, viewClass() === 'desktop', this.#lightScene(), basemapProvider() === 'openfreemap' ? 'carto' : undefined);
}
#updateSky(): void {
- const light = this.#appearance.basemap !== 'dark';
+ const light = this.#lightScene();
this.map.setSky(this.#terrain3D ? { 'sky-color': light ? '#cfe1e8' : '#14242e', 'horizon-color': light ? '#eef1ee' : '#29424a', 'fog-color': light ? '#dce7e5' : '#14242e', 'horizon-fog-blend': 0.15 } : {});
}
@@ -966,8 +968,8 @@ export class LiveMap {
'hillshade-illumination-direction': [270, 315, 0, 45],
'hillshade-illumination-altitude': [35, 35, 35, 35],
'hillshade-exaggeration': this.#hillshadeStrength(),
- 'hillshade-shadow-color': this.#appearance.basemap === 'dark' ? 'rgba(2, 9, 18, 0.86)' : 'rgba(47, 64, 61, 0.55)',
- 'hillshade-highlight-color': this.#appearance.basemap === 'dark' ? 'rgba(177, 211, 175, 0.64)' : 'rgba(255, 255, 244, 0.6)',
+ 'hillshade-shadow-color': !this.#lightScene() ? 'rgba(2, 9, 18, 0.86)' : 'rgba(47, 64, 61, 0.55)',
+ 'hillshade-highlight-color': !this.#lightScene() ? 'rgba(177, 211, 175, 0.64)' : 'rgba(255, 255, 244, 0.6)',
'hillshade-accent-color': 'rgba(47, 86, 75, 0.54)'
}
}, before);
@@ -1828,7 +1830,7 @@ export class LiveMap {
if (this.map.getLayer(ROUTE_WEBGL_LAYER_ID)) this.map.removeLayer(ROUTE_WEBGL_LAYER_ID);
const layer = new HistoricalRouteLayer();
layer.setOpacity(this.#appearance.routeOpacity);
- layer.setLightBackground(this.#appearance.basemap !== 'dark');
+ layer.setLightBackground(this.#lightScene());
layer.setRoutes([...this.#routeDetailFeatures.values()]);
layer.setVisible(this.routesVisible);
layer.setMaximumBand(routeWindowBand(this.#effectiveRouteAgeMS()));
diff --git a/web/src/mobileDock.ts b/web/src/mobileDock.ts
index 8cc4f4d..563829c 100644
--- a/web/src/mobileDock.ts
+++ b/web/src/mobileDock.ts
@@ -1,9 +1,9 @@
/** Rehome existing controls on phones; their handlers and saved state stay intact. */
-export function mountMobileDock(controls: HTMLElement, navigation: HTMLElement, view: 'Map' | 'Netgraph' | 'Labs'): void {
+export function mountMobileDock(controls: HTMLElement, navigation: HTMLElement, view: 'Map' | 'Netgraph' | 'Terminal' | 'Labs'): void {
const compact = matchMedia('(max-width: 900px), (pointer: coarse)');
const root = document.documentElement;
const host = document.createElement('div'); host.className = 'view-menu';
- host.innerHTML = `View`;
+ host.innerHTML = `View`;
controls.prepend(host);
const button = host.querySelector('#view-button')!;
const panel = host.querySelector('#view-panel')!;
diff --git a/web/src/packetAnimator.ts b/web/src/packetAnimator.ts
index 013ab16..706e082 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;
+ #frameSink?: (canvas?: HTMLCanvasElement) => void;
#appliedQuality?: VisualQuality;
constructor(private readonly map: maplibregl.Map, private readonly canvas: HTMLCanvasElement) {
@@ -383,6 +384,9 @@ export class PacketAnimator {
this.#requestFrame();
}
+ /** Optional presentation sink; never owns packet progression or audio. */
+ setFrameSink(sink?: (canvas?: HTMLCanvasElement) => void): void { this.#frameSink = sink; }
+
setPaused(paused: boolean): void {
this.#paused = paused;
if (paused) {
@@ -397,6 +401,7 @@ export class PacketAnimator {
this.#frameId = 0;
this.#residueTimer = undefined;
this.#clearCanvas();
+ this.#frameSink?.();
this.#gpu.batch.clear(); this.#gpu.flush();
this.#clearResidueCanvas();
} else {
@@ -568,6 +573,7 @@ export class PacketAnimator {
for (const observer of this.#activeObservers) this.#drawObserver(observer, now);
this.#context.restore();
this.#gpu.flush();
+ this.#frameSink?.(this.canvas);
if (!this.#reducedMotion && (this.activeRoutes.length || this.#activeObservers.length || this.#nodeWakes.length)) this.#requestFrame();
else this.#requestTimedFrame(now);
}
diff --git a/web/src/selection.ts b/web/src/selection.ts
index 5c1e1c3..89bebab 100644
--- a/web/src/selection.ts
+++ b/web/src/selection.ts
@@ -9,7 +9,7 @@ export function rememberNode(id: string | null): void {
history.replaceState(history.state, '', url);
for (const link of document.querySelectorAll('.dock-views a')) {
const target = new URL(link.href);
- if (target.pathname !== '/' && !target.pathname.startsWith('/netgraph')) continue;
+ if (target.pathname !== '/' && !target.pathname.startsWith('/netgraph') && !target.pathname.startsWith('/terminal')) continue;
if (id) target.searchParams.set('node', id); else target.searchParams.delete('node');
link.href = target.pathname + target.search;
}
diff --git a/web/src/terminal.css b/web/src/terminal.css
new file mode 100644
index 0000000..0320dbb
--- /dev/null
+++ b/web/src/terminal.css
@@ -0,0 +1,38 @@
+:root[data-terminal] {
+ --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;
+}
+:root[data-terminal-palette="amber"] { --terminal-accent: #ffd18a; }
+:root[data-terminal-palette="green"] { --terminal-accent: #8dffb0; }
+[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; }
+[data-terminal] button, [data-terminal] select, [data-terminal] input { font-family: inherit; }
+[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] #map-grade { display: none; }
+[data-terminal]:not([data-terminal-fallback]) #packet-canvas { opacity: 0; }
+.terminal-canvas { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
+#terminal-map { z-index: 1; }
+#terminal-packets { z-index: 4; }
+[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-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; }
+.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; }
+}
+@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
new file mode 100644
index 0000000..a29463a
--- /dev/null
+++ b/web/src/terminal.ts
@@ -0,0 +1,205 @@
+import type { LiveMap } from './map';
+import type { PacketAnimator } from './packetAnimator';
+import { browserStorage } from './browserStorage';
+import { applyDisplayChrome } from './displayPreferences';
+import { terminalGraphics } from './terminalGraphics';
+import { characterCells, terminalColor, terminalPreferences, TERMINAL_DEFAULTS, TERMINAL_COLORS } from './terminalGrid';
+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
+// PacketAnimator obtains the same context, avoiding GPU readback for its glyphs.
+document.querySelector('#packet-canvas')?.getContext('2d', { willReadFrequently: true });
+
+// This entry precedes Map in the Terminal document only. No renderer or CSS is
+// downloaded by the ordinary Map/Netgraph pages, and there is still one feed.
+window.addEventListener('cartolite-map-ready', event => {
+ if (!document.documentElement.hasAttribute('data-terminal')) return;
+ const { view, animator } = (event as CustomEvent<{ view: LiveMap; animator: PacketAnimator }>).detail;
+ const destroy = mountTerminal(view, animator);
+ window.addEventListener('pagehide', event => { if (!event.persisted) destroy(); });
+}, { once: true });
+
+/** 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. */ }
+ const originalPaint = new Map[2] }>();
+ let labelLayers: string[] = [];
+ let disposed = false, failed = false, lastPacket = 0;
+ const canvas = document.createElement('canvas'), effects = document.createElement('canvas');
+ canvas.id = 'terminal-map'; effects.id = 'terminal-packets';
+ for (const element of [canvas, effects]) { element.className = 'terminal-canvas'; element.setAttribute('aria-hidden', 'true'); host.after(element); }
+ const context = canvas.getContext('2d')!, fx = effects.getContext('2d')!;
+ 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';
+ 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 = '
R repeater · C companion · H room · S sensor · @ selected. ANSI keeps packet colours; amber and green use one phosphor. Character shading follows the map and terrain. Live timing stays the same.
';
+ document.querySelector('#map-appearance > summary')!.after(settings);
+ const selects = settings.querySelectorAll('select');
+ const sync = () => {
+ selects[0]!.value = preferences.palette; selects[1]!.value = preferences.glyphs; selects[2]!.value = String(preferences.size);
+ root.dataset.terminalPalette = preferences.palette;
+ 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(); } };
+ window.addEventListener('storage', storage);
+
+ function dimensions() {
+ const width = host.clientWidth, height = host.clientHeight;
+ // Bound raster work on very large screens without dropping any route data.
+ const size = Math.max(preferences.size, Math.ceil(Math.sqrt(width * height / 16000) / 0.7));
+ context.font = `${size}px ui-monospace, "DejaVu Sans Mono", "Cascadia Mono", monospace`;
+ return { width, height, size, cw: context.measureText('M').width, ch: size * 1.15 };
+ }
+
+ function draw(source: HTMLCanvasElement, target: HTMLCanvasElement, ctx: CanvasRenderingContext2D) {
+ const { width, height, size, cw, ch } = dimensions(), dpr = Math.min(devicePixelRatio || 1, 2);
+ if (!width || !height || !source.width || !source.height) return;
+ if (target.width !== Math.ceil(width * dpr) || target.height !== Math.ceil(height * dpr)) { target.width = Math.ceil(width * dpr); target.height = Math.ceil(height * dpr); }
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ ctx.clearRect(0, 0, width, height);
+ 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', false);
+ ctx.font = `${size}px ui-monospace, "DejaVu Sans Mono", "Cascadia Mono", monospace`;
+ ctx.textBaseline = 'top';
+ // Batch same-colour runs. A full ocean is a few row draws, not 16,000 calls.
+ let visible = 0;
+ for (let y = 0; y < cells.rows; y++) for (let x = 0; x < cells.columns;) {
+ const start = x, color = cells.colors[y * cells.columns + x]!;
+ let text = '';
+ do { text += cells.glyphs[y * cells.columns + x]!; x++; } while (x < cells.columns && cells.colors[y * cells.columns + x] === color);
+ if (color) { ctx.fillStyle = terminalColor(color, preferences.palette); ctx.fillText(text, start * cw, y * ch); visible += x - start; }
+ }
+ target.dataset.cells = String(visible); target.dataset.columns = String(cells.columns);
+ target.dataset.glyphs = preferences.glyphs;
+ }
+
+ function labels() {
+ const { cw, ch, size, width, height } = dimensions();
+ const occupied = new Set();
+ const features = map.queryRenderedFeatures(undefined, { layers: labelLayers.filter(id => map.getLayer(id)) });
+ const selected = view.getSelectedNodeID();
+ features.sort((a, b) => Number(b.properties.id === selected) - Number(a.properties.id === selected) || Number(b.layer.id === 'nodes' || b.layer.id === 'clusters') - Number(a.layer.id === 'nodes' || a.layer.id === 'clusters'));
+ const seen = new Set();
+ context.font = `${size}px ui-monospace, "DejaVu Sans Mono", "Cascadia Mono", monospace`;
+ 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';
+ 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;
+ const point = map.project([coordinate[0]!, coordinate[1]!]);
+ const x = Math.round(point.x / cw) * cw + (feature.layer.id === 'node-labels' ? cw * 2 : 0), y = Math.round(point.y / ch) * ch, w = text.length * cw;
+ const key = `${text}:${Math.round(x)}:${Math.round(y)}`;
+ if (seen.has(key) || x < 0 || y < 48 || x + w > width || y + ch > height - 60) continue;
+ seen.add(key);
+ const cells = [...text].map((_, i) => `${Math.round(x / cw) + i}:${Math.round(y / ch)}`);
+ 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 : 5, preferences.palette);
+ context.fillText(text.slice(0, 60), x, y);
+ }
+ canvas.dataset.labels = String(seen.size);
+ }
+
+ function fail() {
+ failed = true; root.dataset.terminalFallback = 'true'; notice.hidden = false;
+ 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();
+ applyDisplayChrome(); view.setAppearance(undefined, true); animator.refreshAppearance();
+ fx.clearRect(0, 0, effects.width, effects.height);
+ }
+
+ function paint() {
+ if (disposed || failed || document.hidden) return;
+ try {
+ const started = performance.now();
+ const { width, height } = dimensions(), dpr = Math.min(devicePixelRatio || 1, 2);
+ if (canvas.width !== Math.ceil(width*dpr) || canvas.height !== Math.ceil(height*dpr)) { canvas.width=Math.ceil(width*dpr); canvas.height=Math.ceil(height*dpr); }
+ context.setTransform(dpr,0,0,dpr,0,0); context.clearRect(0,0,width,height); context.textBaseline='top'; labels();
+ canvas.dataset.glyphs = preferences.glyphs;
+ 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)}`;
+ } catch { fail(); }
+ }
+ function schedule() {
+ capture.request();
+ }
+ // Deliberately restyle only basemap paint. Routes, terrain, regions and source
+ // visibility continue to be owned by LiveMap and the user's layer controls.
+ function style() {
+ if (disposed || failed || !map.isStyleLoaded()) return;
+ const setPaint = (layer: string, property: string, value: Parameters[2]) => {
+ const current = map.getPaintProperty(layer, property as Parameters[1]);
+ if (current === value) return;
+ 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
+ // retained GeoJSON just to inspect a few layer IDs and types.
+ const layers = map.getLayersOrder().map(id => map.getLayer(id)!);
+ labelLayers = layers.filter(layer => layer.type === 'symbol' || layer.id === 'nodes' || layer.id === 'clusters').map(layer => layer.id);
+ for (const layer of layers) {
+ if (layer.type === 'symbol') {
+ setPaint(layer.id, 'text-opacity', 0);
+ } 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') ? '#e4d9ba' : layer.type === 'line' ? '#233343' : '#030609';
+ if (property) setPaint(layer.id, property, color);
+ }
+ }
+ capture.attach();
+ paint();
+ }
+ 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)) };
+ }, paint, fail, () => !disposed && !failed && !document.hidden);
+ const visibility = () => { if (document.hidden) capture.suspend(); else { style(); schedule(); } };
+ const restored = () => {
+ failed = false; root.dataset.terminal = 'true'; delete root.dataset.terminalFallback; notice.hidden = true;
+ applyDisplayChrome(); view.setAppearance(undefined, true); animator.refreshAppearance(); style(); schedule();
+ };
+ notice.querySelector('button')!.addEventListener('click', restored);
+ map.on('load', style); map.on('idle', style);
+ map.on('webglcontextrestored', restored);
+ document.addEventListener('visibilitychange', visibility);
+ animator.setFrameSink(source => {
+ if (!source) { fx.clearRect(0, 0, effects.width, effects.height); return; }
+ 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(); }
+ });
+ sync(); style();
+ const destroy = () => {
+ if (disposed) return;
+ disposed = true; capture.destroy(); animator.setFrameSink(undefined);
+ map.off('load', style); map.off('idle', style);
+ map.off('webglcontextrestored', restored);
+ map.off('remove', destroy);
+ document.removeEventListener('visibilitychange', visibility); window.removeEventListener('storage', storage);
+ for (const element of [canvas, effects, status, notice, settings]) element.remove();
+ delete root.dataset.terminal; delete root.dataset.terminalFallback; delete root.dataset.terminalPalette;
+ };
+ map.on('remove', destroy);
+ return destroy;
+}
diff --git a/web/src/terminalGraphics.ts b/web/src/terminalGraphics.ts
new file mode 100644
index 0000000..1b426d8
--- /dev/null
+++ b/web/src/terminalGraphics.ts
@@ -0,0 +1,139 @@
+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){
+ 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.)return 3;
+ if(c.r>c.g*1.25&&c.b>c.g*1.12)return 6;
+ if(c.r>c.b*1.3&&c.g>c.b*1.15)return 5;
+ 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;
+}
+int at(vec2 p){return ink(texture(terminal_scene,clamp(p,vec2(0),vec2(1))).rgb);}
+void main(){
+ vec2 cell=floor(gl_FragCoord.xy),stepSize=1./(grid*vec2(2,4));
+ int mask=0,color=0,priority=-1,count=0;
+ int bits[8]=int[8](1,8,2,16,4,32,64,128);
+ for(int y=0;y<4;y++)for(int x=0;x<2;x++){
+ vec2 p=(cell+vec2((float(x)+.5)/2.,(3.5-float(y))/4.))/grid;
+ int k=at(p);
+ if(k==0&&(at(p+vec2(stepSize.x,0))==1||at(p-vec2(stepSize.x,0))==1||at(p+vec2(0,stepSize.y))==1||at(p-vec2(0,stepSize.y))==1))k=2;
+ if(k==0)continue;mask|=bits[y*2+x];count++;
+ int rank=k==1?0:k==3?1:k==2?2:3;
+ if(rank>priority){color=k;priority=rank;}
+ }
+ int glyph=ascii==1&&mask!=0?(color==1?260:count<3?257:count<6?258:259):mask;
+ result=vec4(float(glyph%16)/255.,float(glyph/16)/255.,float(color)/255.,1);
+}`;
+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[9];
+out vec4 result;
+void main(){
+ 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);
+}`;
+
+export function terminalGraphics(map: LibreMap, config: () => { columns: number; rows: number; ascii: boolean; colors: 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;
+ function release() {
+ if (gl) {
+ for (const p of programs) gl.deleteProgram(p);
+ for (const t of textures) gl.deleteTexture(t);
+ for (const s of shaders) gl.deleteShader(s);
+ gl.deleteFramebuffer(framebuffer); gl.deleteVertexArray(vao);
+ }
+ programs = []; textures = []; shaders = []; locations = []; framebuffer = vao = null;
+ width = height = columns = rows = 0;
+ }
+ function initialize(g: WebGL2RenderingContext) {
+ for (const fragment of [CELLS, GLYPHS]) {
+ const p = g.createProgram(); if (!p) throw new Error('Terminal program unavailable'); programs.push(p);
+ for (const [type, source] of [[g.VERTEX_SHADER, VERTEX], [g.FRAGMENT_SHADER, fragment]] as const) {
+ const s = g.createShader(type); if (!s) throw new Error('Terminal shader unavailable'); shaders.push(s);
+ g.shaderSource(s, source); g.compileShader(s);
+ if (!g.getShaderParameter(s, g.COMPILE_STATUS)) throw new Error('Terminal shader unavailable');
+ 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)])));
+ }
+ for (const s of shaders) g.deleteShader(s); shaders = [];
+ framebuffer = g.createFramebuffer(); vao = g.createVertexArray();
+ if (!framebuffer || !vao) throw new Error('Terminal surface unavailable');
+ for (let i = 0; i < 3; i++) {
+ const t = g.createTexture(); if (!t) throw new Error('Terminal texture unavailable'); textures.push(t);
+ g.bindTexture(g.TEXTURE_2D, t);
+ 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);
+ }
+ 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';
+ for (let i = 1; i <= 260; i++) ctx.fillText(i < 256 ? String.fromCharCode(0x2800+i) : [' ','.','+','#',':'][i-256]!, (i%16)*24, Math.floor(i/16)*40);
+ g.texImage2D(g.TEXTURE_2D,0,g.RGBA,g.RGBA,g.UNSIGNED_BYTE,atlas);
+ }
+ const layer: CustomLayerInterface = {
+ id: 'terminal-graphics', type: 'custom', renderingMode: '2d',
+ onAdd(_map, context) { gl = context as WebGL2RenderingContext; }, onRemove: release,
+ render(context) {
+ const g = gl = context as WebGL2RenderingContext;
+ if (disposed || !enabled() || g.isContextLost()) return;
+ 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);
+ 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;
+ 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.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) {
+ columns=options.columns; rows=options.rows;
+ g.texImage2D(g.TEXTURE_2D,0,g.RGBA8,columns,rows,0,g.RGBA,g.UNSIGNED_BYTE,null);
+ g.framebufferTexture2D(g.FRAMEBUFFER,g.COLOR_ATTACHMENT0,g.TEXTURE_2D,textures[1]!,0);
+ if (g.checkFramebufferStatus(g.FRAMEBUFFER)!==g.FRAMEBUFFER_COMPLETE) throw new Error('Terminal surface unavailable');
+ }
+ 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.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.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.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); }
+ },
+ };
+ 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); },
+ 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.test.ts b/web/src/terminalGrid.test.ts
new file mode 100644
index 0000000..48b7f07
--- /dev/null
+++ b/web/src/terminalGrid.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it } from 'vitest';
+import { characterCells, terminalInk, terminalPreferences, TERMINAL_DEFAULTS } from './terminalGrid';
+
+describe('Terminal presentation', () => {
+ it('validates saved settings without reading or overwriting other appearance preferences', () => {
+ for (const raw of [null, 'null', '{bad', '[]', '{"size":-20,"palette":"ultraviolet","glyphs":"html"}']) expect(terminalPreferences(raw)).toEqual(TERMINAL_DEFAULTS);
+ expect(terminalPreferences('{"palette":"amber","glyphs":"ascii","size":16}')).toEqual({ palette:'amber', glyphs:'ascii', size:16 });
+ });
+ it('keeps transparent cells empty and encodes all eight Braille positions', () => {
+ const data = new Uint8ClampedArray(2 * 4 * 4);
+ expect(characterCells({data,width:2,height:4}, false, false).glyphs).toEqual([' ']);
+ for (let i=0;i<8;i++) data.set([120,207,255,255],i*4);
+ expect(characterCells({data,width:2,height:4}, false, false).glyphs).toEqual(['⣿']);
+ expect(characterCells({data,width:2,height:4}, true, false).glyphs).toEqual(['#']);
+ data.fill(0); data.set([255,255,255,255],7*4);
+ expect(characterCells({data,width:2,height:4}, false, false).glyphs).toEqual(['⢀']);
+ });
+ it('distinguishes water, land and the existing packet colours', () => {
+ expect(terminalInk(3,6,9)).toBe(0); expect(terminalInk(8,20,90)).toBe(1);
+ expect([terminalInk(77,231,196),terminalInk(255,209,90),terminalInk(255,117,181),terminalInk(120,207,255),terminalInk(167,139,250)]).toEqual([4,5,6,7,8]);
+ expect(terminalInk(255,255,255,0)).toBe(0);
+ const data = new Uint8ClampedArray(4*4*4);
+ for(let y=0;y<4;y++) for(let x=0;x<4;x++) data.set(x<2 ? [8,20,90,255] : [3,6,9,255],(y*4+x)*4);
+ expect([...characterCells({data,width:4,height:4},false,true).colors]).toEqual([1,2]);
+ });
+});
diff --git a/web/src/terminalGrid.ts b/web/src/terminalGrid.ts
new file mode 100644
index 0000000..8d7c0bc
--- /dev/null
+++ b/web/src/terminalGrid.ts
@@ -0,0 +1,60 @@
+/** A fixed 2×4 dot cell. Sampling is bounded by the viewport, never by traffic. */
+const DOTS = [1, 8, 2, 16, 4, 32, 64, 128];
+export const TERMINAL_COLORS = ['#000000', '#3869df', '#e4d9ba', '#384d60', '#71f5c7', '#ffcf63', '#ff83bd', '#78cfff', '#b39aff'];
+export type TerminalPalette = 'ansi' | 'amber' | 'green';
+export interface TerminalPreferences { palette: TerminalPalette; glyphs: 'braille' | 'ascii'; size: number }
+export const TERMINAL_DEFAULTS: TerminalPreferences = { palette: 'ansi', glyphs: 'braille', size: 12 };
+
+export function terminalPreferences(raw: string | null): TerminalPreferences {
+ try {
+ const value = JSON.parse(raw ?? '{}');
+ return {
+ palette: ['ansi', 'amber', 'green'].includes(value?.palette) ? value.palette : 'ansi',
+ glyphs: value?.glyphs === 'ascii' ? 'ascii' : 'braille',
+ size: [10, 12, 16].includes(value?.size) ? value.size : 12,
+ };
+ } catch { return { ...TERMINAL_DEFAULTS }; }
+}
+
+export function terminalColor(index: number, palette: TerminalPalette): string {
+ if (palette === 'ansi') return TERMINAL_COLORS[index] ?? TERMINAL_COLORS[2]!;
+ return palette === 'amber' ? (index === 1 || index === 3 ? '#765025' : '#ffd18a')
+ : (index === 1 || index === 3 ? '#215d40' : '#8dffb0');
+}
+
+export function terminalInk(r: number, g: number, b: number, alpha = 255): number {
+ if (alpha < 24 || Math.max(r, g, b) < 26) return 0;
+ if (b > r * 1.8 && b > g * 1.6) return 1;
+ if (Math.max(r, g, b) < 100) return 3;
+ if (r > g * 1.25 && b > g * 1.12) return 6;
+ if (r > b * 1.3 && g > b * 1.15) return 5;
+ if (g > r * 1.2 && g > b * 0.9) return 4;
+ if (b > r * 1.1 && r > g * 0.9) return 8;
+ if (b > r * 1.1) return 7;
+ return 2;
+}
+
+export function characterCells(image: Pick, ascii: boolean, coast: boolean): { glyphs: string[]; colors: Uint8Array; columns: number; rows: number } {
+ const { data, width, height } = image;
+ const columns = Math.floor(width / 2), rows = Math.floor(height / 4);
+ const glyphs: string[] = new Array(columns * rows).fill(' '), colors = new Uint8Array(glyphs.length);
+ const ink = new Uint8Array(width * height);
+ for (let p = 0; p < ink.length; p++) ink[p] = terminalInk(data[p * 4]!, data[p * 4 + 1]!, data[p * 4 + 2]!, data[p * 4 + 3]!);
+ for (let y = 0; y < rows; y++) for (let x = 0; x < columns; x++) {
+ let mask = 0, color = 0, priority = -1, count = 0;
+ for (let dy = 0; dy < 4; dy++) for (let dx = 0; dx < 2; dx++) {
+ const px = x * 2 + dx, py = y * 4 + dy, p = py * width + px;
+ let k = ink[p]!;
+ // A pale coast on the land side of the provider's actual water geometry.
+ if (coast && k === 0 && ((px > 0 && ink[p - 1] === 1) || (px + 1 < width && ink[p + 1] === 1) || (py > 0 && ink[p - width] === 1) || (py + 1 < height && ink[p + width] === 1))) k = 2;
+ if (!k) continue;
+ mask |= DOTS[dy * 2 + dx]!; count++;
+ const rank = k === 1 ? 0 : k === 3 ? 1 : k === 2 ? 2 : 3;
+ if (rank > priority) { color = k; priority = rank; }
+ }
+ const cell = y * columns + x;
+ colors[cell] = color;
+ glyphs[cell] = mask ? (ascii ? (color === 1 ? ':' : count < 3 ? '.' : count < 6 ? '+' : '#') : String.fromCharCode(0x2800 + mask)) : ' ';
+ }
+ return { glyphs, colors, columns, rows };
+}
diff --git a/web/vite.config.ts b/web/vite.config.ts
index 3557910..dd3c91a 100644
--- a/web/vite.config.ts
+++ b/web/vite.config.ts
@@ -14,10 +14,14 @@ export default defineConfig({
output: {
// Keep shared controls, projections and effects together. Entry points
// and MapLibre stay separate, so Netgraph never loads the basemap.
- codeSplitting: { groups: [{ name: 'shared', test: /[\\/]src[\\/]/, minShareCount: 2 }] },
+ codeSplitting: { groups: [
+ { name: 'terminal', test: /[\\/]src[\\/]terminal(?:Grid|Graphics)?\.ts$/, priority: 20, includeDependenciesRecursively: false },
+ { name: 'shared', test: /[\\/]src[\\/]/, minShareCount: 2 },
+ ] },
},
input: {
main: inputPath('index.html'),
+ terminal: inputPath('src/terminal.ts'),
netgraph: inputPath('netgraph/index.html')
}
}