From 973952cf99dc380580b9c8369defffd214469a9b Mon Sep 17 00:00:00 2001 From: n30nex Date: Sat, 17 Oct 2026 04:44:19 -0400 Subject: [PATCH] Add the live BBS Terminal view (0.8.0) (#28) * Add live BBS Terminal view * Type terminal paint restoration and cover deployed browser view * Keep Terminal boot assets off other views and include page in images * Isolate Terminal assets and sample map graphics asynchronously * Preserve appearance through Terminal fallback and type sampled pixels * Use public layer ordering and suspend idle Terminal capture * Render Terminal geography on the GPU without pixel readback * Keep label painting separate from map paint settings * Harden glyph state and avoid packet-canvas readback stalls --- .github/workflows/ci.yml | 10 +- Dockerfile | 1 + README.md | 6 + VERSION | 2 +- backend/internal/httpapi/server.go | 8 + backend/internal/httpapi/terminal_test.go | 31 ++++ docs/terminal-view-plan-2026-10-17.md | 55 ++++++ scripts/check-frontend-budget.mjs | 12 +- scripts/write-terminal-page.mjs | 18 ++ web/e2e/scale-smoke.spec.ts | 8 +- web/e2e/terminal.spec.ts | 116 ++++++++++++ web/package-lock.json | 4 +- web/package.json | 4 +- web/playwright.config.ts | 2 +- web/src/cinematicChrome.ts | 20 +-- web/src/displayPreferences.ts | 2 +- web/src/gpuEffects.ts | 2 +- web/src/main.ts | 1 + web/src/map.ts | 18 +- web/src/mobileDock.ts | 4 +- web/src/packetAnimator.ts | 6 + web/src/selection.ts | 2 +- web/src/terminal.css | 38 ++++ web/src/terminal.ts | 205 ++++++++++++++++++++++ web/src/terminalGraphics.ts | 139 +++++++++++++++ web/src/terminalGrid.test.ts | 26 +++ web/src/terminalGrid.ts | 60 +++++++ web/vite.config.ts | 6 +- 28 files changed, 767 insertions(+), 39 deletions(-) create mode 100644 backend/internal/httpapi/terminal_test.go create mode 100644 docs/terminal-view-plan-2026-10-17.md create mode 100644 scripts/write-terminal-page.mjs create mode 100644 web/e2e/terminal.spec.ts create mode 100644 web/src/terminal.css create mode 100644 web/src/terminal.ts create mode 100644 web/src/terminalGraphics.ts create mode 100644 web/src/terminalGrid.test.ts create mode 100644 web/src/terminalGrid.ts 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>/, '<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 = ``; + host.innerHTML = ``; 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 = 'Terminal display

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') } }