diff --git a/.env.example b/.env.example index 8d6de32..bb304d1 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,6 @@ # Copy this file to .env and configure your MQTT feed. No map key or build is needed. # Keep .env and any optional .secrets files private. -CARTOLITE_IMAGE=ghcr.io/n30nex/cartolite-server:0.8.2 +CARTOLITE_IMAGE=ghcr.io/n30nex/cartolite-server:0.8.3 CARTOLITE_BIND_ADDR=127.0.0.1 CARTOLITE_PORT=8080 # Optional CARTO override: see compose.carto.yml and docs/deployment.md. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 18e8e0f..f93a8f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,7 +63,7 @@ jobs: run: npm test - working-directory: web run: npm run build - - run: node scripts/check-frontend-budget.mjs web/dist + - run: node scripts/report-frontend-size.mjs web/dist container: name: Container integration, browser, privacy, and scan (${{ matrix.arch }}) @@ -122,7 +122,7 @@ jobs: cache-from: type=gha,scope=world-${{ matrix.arch }} cache-to: type=gha,scope=world-${{ matrix.arch }},mode=max - run: | - bash scripts/check-image-size.sh cartolite-server:ci + bash scripts/report-image-size.sh cartolite-server:ci test "$(docker image inspect cartolite-server:ci --format '{{.Config.User}}')" = "65532:65532" test "$(docker image inspect cartolite-server:ci --format '{{.Architecture}}')" = "${{ matrix.arch }}" test "$(docker image inspect cartolite-server:ci --format '{{index .Config.Labels "org.opencontainers.image.revision"}}')" = "$GITHUB_SHA" diff --git a/AGENTS.md b/AGENTS.md index 266934c..805a9de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,3 +21,10 @@ CartoLite Server is a compact worldwide MeshCore map and Netgraph: a Go MQTT/HTT - Keep `REGION_ALLOWLIST` optional and exact when configured. - Run frontend tests/build, Go tests/vet/race, integration/privacy smoke tests, and `git diff --check` before release. - Preserve unrelated work and never force-push shared branches. + +## Build artifact sizes + +By owner request (17 October 2026), frontend, worker, geography and container +artifact sizes are reported without hard release-blocking caps. Keep the runtime +performance gates, privacy/security checks, lazy-view isolation, resource limits +and exact tested-image promotion requirements. diff --git a/VERSION b/VERSION index 100435b..ee94dd8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.8.2 +0.8.3 diff --git a/compose.yml b/compose.yml index 4f52860..e1b316b 100644 --- a/compose.yml +++ b/compose.yml @@ -2,7 +2,7 @@ name: cartolite-server services: cartolite: - image: ${CARTOLITE_IMAGE:-ghcr.io/n30nex/cartolite-server:0.8.2} + image: ${CARTOLITE_IMAGE:-ghcr.io/n30nex/cartolite-server:0.8.3} restart: unless-stopped ports: - "${CARTOLITE_BIND_ADDR:-127.0.0.1}:${CARTOLITE_PORT:-8080}:8080" diff --git a/docs/releases/0.8.3.md b/docs/releases/0.8.3.md new file mode 100644 index 0000000..aa5429e --- /dev/null +++ b/docs/releases/0.8.3.md @@ -0,0 +1,17 @@ +# 0.8.3 — Clearer, brighter Terminal activity + +- Dim background water, land textures and roads so the network stands out. +- Preserve strong route colours at dense zooms and give node markers a clear, + bold silhouette. Labels sit above live effects. +- Add bounded, coloured glow and white-hot character cores to live activity. + Preserve colour in faint trails and fade using the original packet frame. +- Respect Spectacle, Calm, Minimal, graphics quality and reduced motion. +- Remove hard frontend, worker, geography and container build-size ceilings by + owner request. Report sizes; retain asset integrity/isolation, runtime timing, + 7,000-route, privacy, security and installation checks. + +Live packet timing, audio, route meaning and saved preferences are retained. + +The installer files select 0.8.3. Existing installs can set +`CARTOLITE_IMAGE=ghcr.io/n30nex/cartolite-server:0.8.3` and run +`docker compose pull` followed by `docker compose up -d`. Keep the data volume. diff --git a/scripts/check-image-size.sh b/scripts/check-image-size.sh deleted file mode 100644 index 129dc88..0000000 --- a/scripts/check-image-size.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -image="${1:?usage: check-image-size.sh IMAGE}" -limit="${CARTOLITE_IMAGE_SIZE_BUDGET:-52428800}" -size="$(docker image inspect "$image" --format '{{.Size}}')" -[[ "$size" =~ ^[0-9]+$ ]] -echo "$size image bytes (budget $limit)" -test "$size" -le "$limit" diff --git a/scripts/check-frontend-budget.mjs b/scripts/report-frontend-size.mjs similarity index 50% rename from scripts/check-frontend-budget.mjs rename to scripts/report-frontend-size.mjs index 64c1ec6..394d41b 100644 --- a/scripts/check-frontend-budget.mjs +++ b/scripts/report-frontend-size.mjs @@ -3,14 +3,7 @@ import { readdir, readFile } from "node:fs/promises"; import { basename, join } from "node:path"; const root = process.argv[2] ?? "web/dist"; -// MapLibre 6 requires a separate Vite worker (129,701 gzip bytes in Actions). -// Keep the previous cap for all other JS/CSS; account for the worker explicitly. -// Combined measured cost: 446,084 bytes. Runtime timing/route gates are unchanged. -const uiLimit = 350 * 1024; -const workerLimit = 128 * 1024; -// 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)); +// Report artifact sizes without release-blocking caps. Runtime gates remain. async function assets(dir, pattern) { const found = []; @@ -40,11 +33,9 @@ for (const file of files) { console.log(`${bytes.toString().padStart(8)} ${file}`); } -console.log(`${total} gzip bytes total (budget ${limit})`); -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; +console.log(`${total} gzip bytes total`); +console.log(`${total - workerBytes - terminalBytes} gzip bytes for main UI`); +console.log(`${workerBytes} gzip bytes for map worker`); +console.log(terminalBytes + ' gzip bytes for optional Terminal'); +if (terminalBytes <= 0) throw new Error('missing optional Terminal assets'); 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 - terminalBytes > uiLimit || workerBytes > workerLimit) process.exitCode = 1; diff --git a/scripts/report-image-size.sh b/scripts/report-image-size.sh new file mode 100644 index 0000000..6d0fa4e --- /dev/null +++ b/scripts/report-image-size.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +image="${1:?usage: report-image-size.sh IMAGE}" +size="$(docker image inspect "$image" --format '{{.Size}}')" +[[ "$size" =~ ^[0-9]+$ ]] +echo "$size image bytes" diff --git a/web/e2e/terminal.spec.ts b/web/e2e/terminal.spec.ts index 2d9c42f..c673bb1 100644 --- a/web/e2e/terminal.spec.ts +++ b/web/e2e/terminal.spec.ts @@ -3,6 +3,56 @@ import { visualFixture, visualPacket } from './visualFixtures'; import { openMapOptions } from './mapControls'; import { openViewMenu } from './dockControls'; +test('Terminal gives live packets a bounded glow while keeping labels and quiet modes clear', async ({page},info) => { + test.slow(); + await visualFixture(page); + // Capture the presentation sink, not the packet clock. A known frame makes + // glow comparisons reproducible while the normal live-feed tests stay intact. + await page.addInitScript(() => { + window.addEventListener('cartolite-map-ready', event => { + const animator=(event as CustomEvent).detail.animator; + const original=animator.setFrameSink.bind(animator); + animator.setFrameSink=(sink: (source?:HTMLCanvasElement)=>void) => { Object.assign(window,{terminalFrame:sink}); original(sink); }; + },{once:true}); + }); + await page.goto('/terminal/'); + await expect(page.locator('#map')).toHaveAttribute('data-render-state','idle',{timeout:10000}); + await expect(page.locator('#map')).toHaveAttribute('data-terminal-renderer','webgl2'); + const render=async () => page.evaluate(() => { + const host=document.querySelector('#map')!, source=document.createElement('canvas'); + source.width=host.clientWidth; source.height=host.clientHeight; + Object.assign(source.dataset,{qualityMode:'full',activeCount:'1',residueCount:'0'}); + const ctx=source.getContext('2d')!, x=source.width*.5, y=source.height*.5; + ctx.strokeStyle='#ff75b5'; ctx.lineWidth=5; ctx.beginPath(); ctx.moveTo(x-60,y); ctx.lineTo(x,y); ctx.stroke(); + ctx.beginPath(); ctx.arc(x-60,y,16,0,Math.PI*2); ctx.stroke(); + ctx.fillStyle='#fff'; ctx.fillRect(x-4,y-4,9,9); + (window as unknown as {terminalFrame:(source:HTMLCanvasElement)=>void}).terminalFrame(source); + const canvas=document.querySelector('#terminal-packets')!; + const pixels=canvas.getContext('2d')!.getImageData(0,0,canvas.width,canvas.height).data; + let halo=0,core=0; + for(let i=0;i=8 && pixels[i+3]!<96) halo++; + if(pixels[i+3]!>192 && Math.max(pixels[i]!,pixels[i+1]!,pixels[i+2]!)>230) core++; + } + return {halo,core,glow:Number(canvas.dataset.glow)}; + }); + await page.waitForTimeout(60); + const spectacle=await render(); + expect(spectacle.glow).toBeGreaterThan(0); expect(spectacle.core).toBeGreaterThan(10); + await page.screenshot({path:info.outputPath('terminal-live-spectacle.png')}); + const z=await page.evaluate(() => ['terminal-map','terminal-packets'].map(id=>Number(getComputedStyle(document.getElementById(id)!).zIndex))); + expect(z[0]).toBeGreaterThan(z[1]!); + await openMapOptions(page); await page.getByLabel('Effects intensity').selectOption('minimal'); await page.keyboard.press('Escape'); + await page.waitForTimeout(60); + const minimal=await render(); + expect(minimal.glow).toBe(0); expect(minimal.core).toBeGreaterThan(10); + expect(spectacle.halo).toBeGreaterThan(minimal.halo*1.25); + await page.screenshot({path:info.outputPath('terminal-live-minimal.png')}); + await openMapOptions(page); await page.getByLabel('Effects intensity').selectOption('spectacle'); await page.keyboard.press('Escape'); + await page.emulateMedia({reducedMotion:'reduce'}); await page.waitForTimeout(60); + const reduced=await render(); expect(reduced.glow).toBe(0); expect(reduced.core).toBeGreaterThan(10); +}); + test('Terminal preserves roads, landscape detail and measured label spacing', async ({page}, info) => { test.slow(); await visualFixture(page,10.2,true); @@ -12,7 +62,7 @@ test('Terminal preserves roads, landscape detail and measured label spacing', as Object.assign(window,{terminalLabels:labels}); canvas.clearRect = function(...args:Parameters) { if(this.canvas.id==='terminal-map') labels.length=0; return clear.apply(this,args); }; canvas.fillText = function(text,x,y,...rest) { - if(this.canvas.id==='terminal-map') labels.push({text,x,y,width:this.measureText(text).width,height:parseFloat(this.font)*1.25}); + if(this.canvas.id==='terminal-map') labels.push({text,x,y,width:this.measureText(text).width,height:parseFloat(this.font.match(/[\d.]+px/)![0])*1.25}); return original.call(this,text,x,y,...rest); }; }); @@ -30,7 +80,7 @@ test('Terminal preserves roads, landscape detail and measured label spacing', as const canvas=document.createElement('canvas'); canvas.width=image.width; canvas.height=image.height; const ctx=canvas.getContext('2d')!; ctx.drawImage(image,0,0); const pixels=ctx.getImageData(0,Math.floor(image.height*.2),image.width,Math.floor(image.height*.6)).data; - const colors=[[53,95,73],[53,64,76],[114,147,170]], count=[0,0,0]; + const colors=[[37,62,49],[40,50,63],[83,107,128]], count=[0,0,0]; for(let i=0;iMath.abs(pixels[i+c]!-v)<3)) count[k]!++; return Math.min(...count); },png.toString('base64')); diff --git a/web/package-lock.json b/web/package-lock.json index f566c74..c8a6a8e 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "cartolite-server-web", - "version": "0.8.2", + "version": "0.8.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cartolite-server-web", - "version": "0.8.2", + "version": "0.8.3", "dependencies": { "maplibre-gl": "6.4.1" }, diff --git a/web/package.json b/web/package.json index e61a2fa..0327c45 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "cartolite-server-web", - "version": "0.8.2", + "version": "0.8.3", "private": true, "type": "module", "scripts": { diff --git a/web/src/routeLayer.ts b/web/src/routeLayer.ts index a153b5d..588afbe 100644 --- a/web/src/routeLayer.ts +++ b/web/src/routeLayer.ts @@ -347,6 +347,7 @@ export class HistoricalRouteLayer implements CustomLayerInterface { if (!this.#vertexCount) return; const { program, uniforms, attributes } = this.#resources; const settings = displayPreferences(); + const terminal = document.documentElement.hasAttribute('data-terminal'); // Preserve every core stroke and pattern; omit decorative passes when a // dense close view carries as much geometry as a regional overview. const denseOverview = this.#routes.length > 2000 && (this.#map.getZoom() < 11 || this.#vertexCount > 12000); @@ -366,11 +367,11 @@ export class HistoricalRouteLayer implements CustomLayerInterface { this.#hitMatrix = new Float32Array(matrix); gl.uniformMatrix4fv(uniforms.matrix!, false, this.#hitMatrix); gl.uniform2f(uniforms.viewport!, Math.max(1, this.#map.getCanvas().clientWidth), Math.max(1, this.#map.getCanvas().clientHeight)); - gl.uniform1f(uniforms.width!, settings.width); + gl.uniform1f(uniforms.width!, settings.width * (terminal ? 1.2 : 1)); gl.uniform1f(uniforms.glow!, denseOverview ? 0 : settings.glow * clamp((this.#map.getZoom() - 3) / 7, 0.15, 1)); gl.uniform1f(uniforms.outline!, denseOverview ? 0 : 1); gl.uniform1f(uniforms.simple!, denseOverview ? 1 : 0); - gl.uniform1f(uniforms.opacity!, this.#opacity * (settings.detail === 'auto' ? clamp(1 - Math.log2(1 + this.#routes.length / 150) * .1, .38, .9) : 1)); + gl.uniform1f(uniforms.opacity!, Math.min(1,this.#opacity * (terminal ? 1.35 : settings.detail === 'auto' ? clamp(1 - Math.log2(1 + this.#routes.length / 150) * .1, .38, .9) : 1))); gl.uniform1f(uniforms.maximum_band!, this.#maximumBand); gl.uniform1f(uniforms.pattern!, settings.pattern === 'dashed' ? 1 : settings.pattern === 'dotted' ? 2 : 0); if (this.#lightBackground) gl.uniform3f(uniforms.casing!, 0.97, 0.99, 0.95); diff --git a/web/src/terminal.css b/web/src/terminal.css index 0320dbb..329aec5 100644 --- a/web/src/terminal.css +++ b/web/src/terminal.css @@ -18,8 +18,9 @@ [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; } +#terminal-map { z-index: 4; } +#terminal-packets { z-index: 2; } +[data-terminal] .maplibregl-control-container > div { z-index: 6; } [data-terminal-fallback] .terminal-canvas { display: none; } #terminal-status { position: absolute; top: 60px; left: 14px; right: 14px; z-index: 5; display: flex; flex-wrap: wrap; gap: 4px 18px; width: fit-content; max-width: calc(100% - 28px); padding: 5px 8px; border: 1px solid #334b57; background: #000d; color: var(--terminal-accent); font-size: 11px; pointer-events: none; } .terminal-help { color: #a3b4bd; } diff --git a/web/src/terminal.ts b/web/src/terminal.ts index 7994aa3..8bf30cb 100644 --- a/web/src/terminal.ts +++ b/web/src/terminal.ts @@ -1,7 +1,7 @@ import type { LiveMap } from './map'; import type { PacketAnimator } from './packetAnimator'; import { browserStorage } from './browserStorage'; -import { applyDisplayChrome } from './displayPreferences'; +import { applyDisplayChrome, effectStrength, prefersReducedMotion } from './displayPreferences'; import { terminalGraphics } from './terminalGraphics'; import { characterCells, terminalColor, terminalPreferences, TERMINAL_DEFAULTS, TERMINAL_COLORS } from './terminalGrid'; import './terminal.css'; @@ -80,16 +80,29 @@ export function mountTerminal(view: LiveMap, animator: PacketAnimator): () => vo read.imageSmoothingEnabled = false; read.drawImage(source, 0, 0, sample.width, sample.height); const cells = characterCells(read.getImageData(0, 0, sample.width, sample.height), preferences.glyphs === 'ascii'); - ctx.font = `${size}px ui-monospace, "DejaVu Sans Mono", "Cascadia Mono", monospace`; + ctx.font = `bold ${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; + const glow = prefersReducedMotion() ? 0 : effectStrength() * (source.dataset.qualityMode === 'low' ? 2 : source.dataset.qualityMode === 'balanced' ? 4 : 6); + ctx.lineWidth=.5; + // Batch colour/brightness runs. Bound decorative work before reducing cores. + let visible = 0, blooms=0; 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]!; + const start = x, color = cells.colors[y * cells.columns + x]!, energy=cells.energy[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; } + do { text += cells.glyphs[y * cells.columns + x]!; x++; } while (x < cells.columns && cells.colors[y * cells.columns + x] === color && cells.energy[y*cells.columns+x] === energy); + if (!color) continue; + ctx.globalAlpha=Math.min(1,energy/255*1.35); + ctx.fillStyle=ctx.strokeStyle=ctx.shadowColor=terminalColor(color,preferences.palette); + ctx.shadowBlur=blooms++<120 ? glow : 0; + ctx.strokeText(text,start*cw,y*ch); ctx.fillText(text,start*cw,y*ch); visible+=x-start; } + ctx.shadowBlur=0; ctx.fillStyle=terminalColor(2,preferences.palette); + for(let i=0;i vo for (const feature of features) { if (feature.geometry.type !== 'Point') continue; const p = feature.properties, node = feature.layer.id === 'nodes', cluster = feature.layer.id === 'clusters'; + context.font = `${node || cluster ? 'bold ' : ''}${size}px ui-monospace, "DejaVu Sans Mono", "Cascadia Mono", monospace`; 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; @@ -128,7 +142,9 @@ export function mountTerminal(view: LiveMap, animator: PacketAnimator): () => vo cells.forEach(cell => occupied.add(cell)); context.fillStyle = '#000'; context.fillRect(x-1,y,w+2,ch); context.fillStyle = terminalColor(node ? (p.id === selected ? 2 : p.role === 'companion' ? 7 : p.role === 'room_server' ? 8 : 4) : cluster ? 4 : nodeLabel ? 2 : 5, preferences.palette); - context.fillText(text,x,y); drawn++; break; + context.shadowColor=String(context.fillStyle); context.shadowBlur=node || cluster ? 3*effectStrength() : 0; + context.globalAlpha=node || cluster || nodeLabel ? 1 : .65; + context.fillText(text,x,y); context.shadowBlur=0; context.globalAlpha=1; drawn++; break; } } canvas.dataset.labels = String(drawn); @@ -181,7 +197,7 @@ export function mountTerminal(view: LiveMap, animator: PacketAnimator): () => vo 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.id === 'basemap-landcover' ? '#124b24' : layer.id === 'basemap-landuse' ? '#383838' : layer.type === 'line' ? '#526270' : '#030609'; + const color = layer.id.includes('water') ? '#08145a' : layer.id.includes('boundary') ? '#526270' : layer.id === 'basemap-landcover' ? '#124b24' : layer.id === 'basemap-landuse' ? '#383838' : layer.type === 'line' ? '#526270' : '#030609'; if (property) setPaint(layer.id, property, layer.id === 'basemap-landcover' ? ['match', ['get','class'], ['wood','grass'], '#124b24', '#383838'] : color); } } diff --git a/web/src/terminalGraphics.ts b/web/src/terminalGraphics.ts index f145a0c..70c8936 100644 --- a/web/src/terminalGraphics.ts +++ b/web/src/terminalGraphics.ts @@ -13,6 +13,7 @@ int ink(vec3 c){ if(max(c.r,max(c.g,c.b))<100./255.&&c.g>c.r*1.5&&c.g>c.b*1.2)return 9; if(max(c.r,max(c.g,c.b))<65./255.&&max(c.r,max(c.g,c.b))-min(c.r,min(c.g,c.b))<5./255.)return 10; if(max(c.r,max(c.g,c.b))<100./255.)return 3; + if(min(c.r,min(c.g,c.b))>max(c.r,max(c.g,c.b))*.7)return 2; 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; @@ -20,7 +21,7 @@ int ink(vec3 c){ 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);} -int rank(int k){return k==0?-1:k==1||k>8?0:k==3?1:k==2?2:3;} +int rank(int k){return k==0?-1:k==1||k==9||k==10?0:k==3?1:k==11?2:3;} void main(){ vec2 cell=floor(gl_FragCoord.xy),stepSize=1./(grid*vec2(2,4)); int mask=0,color=0,priority=-1,count=0; @@ -33,12 +34,12 @@ void main(){ int sampleInk=at(p+vec2(i,j)*stepSize/3.); if(rank(sampleInk)>rank(k))k=sampleInk; } - if((k==0||k>8)&&(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||k==9||k==10)&&(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=11; int priorityNow=rank(k); if(k==0||priorityNowpriority){mask=0;count=0;color=k;priority=priorityNow;} - if(k>8&&(x+y)%2==0)continue; + if((k==9||k==10)&&(x+y)%2==0)continue; mask|=bits[y*2+x];count++; } int glyph=ascii==1&&mask!=0?(color==1?260:count<3?257:count<6?258:259):mask; @@ -46,7 +47,7 @@ void main(){ }`; const GLYPHS = `#version 300 es precision highp float;precision highp int; -uniform sampler2D cells;uniform sampler2D glyphs;uniform vec2 grid;uniform vec2 viewport;uniform vec3 colors[11]; +uniform sampler2D cells;uniform sampler2D glyphs;uniform vec2 grid;uniform vec2 viewport;uniform vec3 colors[12]; out vec4 result; void main(){ vec2 p=gl_FragCoord.xy/viewport*grid; diff --git a/web/src/terminalGrid.test.ts b/web/src/terminalGrid.test.ts index f6000b6..00e8feb 100644 --- a/web/src/terminalGrid.test.ts +++ b/web/src/terminalGrid.test.ts @@ -15,9 +15,10 @@ describe('Terminal presentation', () => { data.fill(0); data.set([255,255,255,255],7*4); expect(characterCells({data,width:2,height:4}, 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); + it('keeps packet categories coloured even when their light is faint', () => { + expect(terminalInk(3,6,9)).toBe(0); expect(terminalInk(8,20,90)).toBe(7); 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(19,57,49),terminalInk(64,52,22),terminalInk(64,29,45),terminalInk(30,52,64),terminalInk(42,35,63)]).toEqual([4,5,6,7,8]); expect(terminalInk(255,255,255,0)).toBe(0); }); it('keeps bright packet cores separate from faint trails in the same cell', () => { @@ -27,4 +28,16 @@ describe('Terminal presentation', () => { const cell=characterCells({data,width:2,height:4},false); expect(cell.glyphs).toEqual(['⠁']); expect([...cell.colors]).toEqual([6]); }); + it('keeps a white-hot core and its coloured trail, with the original fade', () => { + const data=new Uint8ClampedArray(32); + for(let i=0;i<8;i++) data.set([77,231,196,180],i*4); + data.set([255,255,255,255],28); + const bright=characterCells({data,width:2,height:4},false); + expect(bright.glyphs).toEqual(['⣿']); expect(bright.hot).toEqual(['⢀']); + expect([...bright.colors]).toEqual([4]); expect([...bright.energy]).toEqual([255]); + for(let i=3;i<32;i+=4) data[i]=Math.round(data[i]!*0.4); + const faded=characterCells({data,width:2,height:4},false); + expect(faded.glyphs).toEqual(bright.glyphs); expect(faded.colors).toEqual(bright.colors); + expect(faded.energy[0]).toBeGreaterThan(0); expect(faded.energy[0]).toBeLessThan(bright.energy[0]!); + }); }); diff --git a/web/src/terminalGrid.ts b/web/src/terminalGrid.ts index e83bbfe..c34128e 100644 --- a/web/src/terminalGrid.ts +++ b/web/src/terminalGrid.ts @@ -1,6 +1,6 @@ /** 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', '#7293aa', '#71f5c7', '#ffcf63', '#ff83bd', '#78cfff', '#b39aff', '#355f49', '#35404c']; +export const TERMINAL_COLORS = ['#000000', '#213c78', '#f2fff1', '#536b80', '#72ffcf', '#ffdb72', '#ff85ce', '#89e5ff', '#c6a4ff', '#253e31', '#28323f', '#788a98']; 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 }; @@ -18,14 +18,16 @@ export function terminalPreferences(raw: string | null): TerminalPreferences { export function terminalColor(index: number, palette: TerminalPalette): string { if (palette === 'ansi') return TERMINAL_COLORS[index] ?? TERMINAL_COLORS[2]!; - return palette === 'amber' ? (index === 1 || index > 8 ? '#765025' : '#ffd18a') - : (index === 1 || index > 8 ? '#215d40' : '#8dffb0'); + const background = index === 1 || index === 3 || index > 8; + return palette === 'amber' ? (background ? '#634522' : '#ffe5ac') + : (background ? '#214631' : '#afffc7'); } 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; + // This is the live-only canvas, not geography. Keep dim packet hues instead + // of classifying them as roads or water; opacity is carried separately. + if (alpha < 16 || Math.max(r, g, b) < 16) return 0; + if (Math.min(r,g,b) > Math.max(r,g,b) * .7) return 2; 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; @@ -34,23 +36,32 @@ export function terminalInk(r: number, g: number, b: number, alpha = 255): numbe return 2; } -export function characterCells(image: Pick, ascii: boolean): { glyphs: string[]; colors: Uint8Array; columns: number; rows: number } { +export function characterCells(image: Pick, ascii: boolean) { 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 glyphs: string[] = new Array(columns * rows).fill(' '), hot = [...glyphs]; + const colors = new Uint8Array(glyphs.length), energy = new Uint8Array(glyphs.length); + const strengths = new Float32Array(8), kinds = new Uint8Array(8); for (let y = 0; y < rows; y++) for (let x = 0; x < columns; x++) { - let mask = 0, color = 0, priority = -1, count = 0; + let mask = 0, white = 0, color = 2, peak = 0, colourPeak = 0, count = 0; for (let dy = 0; dy < 4; dy++) for (let dx = 0; dx < 2; dx++) { const p = ((y*4+dy)*width+x*2+dx)*4; const k = terminalInk(data[p]!, data[p+1]!, data[p+2]!, data[p+3]!); - const rank = k === 1 ? 0 : k === 3 ? 1 : k === 2 ? 2 : 3; - if (!k || rank < priority) continue; - if (rank > priority) { mask = 0; count = 0; color = k; priority = rank; } - mask |= DOTS[dy * 2 + dx]!; count++; + const strength = k ? data[p+3]! * Math.max(data[p]!,data[p+1]!,data[p+2]!) / 255 : 0; + const dot=dy*2+dx; strengths[dot]=strength; kinds[dot]=k; + peak=Math.max(peak,strength); + if(k!==2 && strength>colourPeak) { color=k; colourPeak=strength; } + } + for(let dot=0;dot<8;dot++) { + if(strengths[dot]! < Math.max(16,peak*.28)) continue; + mask |= DOTS[dot]!; count++; + if(kinds[dot]===2 && strengths[dot]! >= peak*.7) white |= DOTS[dot]!; } const cell = y * columns + x; - colors[cell] = color; - glyphs[cell] = mask ? (ascii ? (color === 1 ? ':' : count < 3 ? '.' : count < 6 ? '+' : '#') : String.fromCharCode(0x2800 + mask)) : ' '; + if(!mask) continue; + colors[cell] = color; energy[cell]=Math.min(255,Math.ceil(peak/16)*16); + glyphs[cell] = ascii ? (count<3 ? '*' : count<6 ? '+' : '#') : String.fromCharCode(0x2800+mask); + if(white) hot[cell] = ascii ? '*' : String.fromCharCode(0x2800+white); } - return { glyphs, colors, columns, rows }; + return { glyphs, hot, colors, energy, columns, rows }; }