mirror of
https://github.com/n30nex/CartoLite-Server.git
synced 2026-10-22 10:22:25 +00:00
Make Terminal traffic shine and report build sizes without caps (#32)
This commit is contained in:
1 parent
ca6b955c05
commit
8e1ef6d3f1
18 files changed
+175
-69
No files matched your search
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Copy this file to .env and configure your MQTT feed. No map key or build is needed.
|
||||
# Keep .env and any optional .secrets files private.
|
||||
CARTOLITE_IMAGE=ghcr.io/n30nex/cartolite-server:0.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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
@@ -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<HTMLElement>('#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<HTMLCanvasElement>('#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<pixels.length;i+=4) {
|
||||
if(pixels[i+3]!>=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<typeof clear>) { 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;i<pixels.length;i+=4) for(let k=0;k<3;k++) if(colors[k]!.every((v,c)=>Math.abs(pixels[i+c]!-v)<3)) count[k]!++;
|
||||
return Math.min(...count);
|
||||
},png.toString('base64'));
|
||||
|
||||
Generated
+2
-2
@@ -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"
|
||||
},
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cartolite-server-web",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
|
||||
+25
-9
@@ -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<cells.hot.length;i++) if(cells.hot[i]!==' ') {
|
||||
ctx.globalAlpha=cells.energy[i]!/255;
|
||||
ctx.fillText(cells.hot[i]!,i%cells.columns*cw,Math.floor(i/cells.columns)*ch);
|
||||
}
|
||||
ctx.globalAlpha=1;
|
||||
target.dataset.glow=String(glow);
|
||||
target.dataset.cells = String(visible); target.dataset.columns = String(cells.columns);
|
||||
target.dataset.glyphs = preferences.glyphs;
|
||||
}
|
||||
@@ -108,6 +121,7 @@ export function mountTerminal(view: LiveMap, animator: PacketAnimator): () => vo
|
||||
for (const feature of features) {
|
||||
if (feature.geometry.type !== 'Point') continue;
|
||||
const p = feature.properties, node = feature.layer.id === 'nodes', cluster = feature.layer.id === 'clusters';
|
||||
context.font = `${node || cluster ? 'bold ' : ''}${size}px ui-monospace, "DejaVu Sans Mono", "Cascadia Mono", monospace`;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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||priorityNow<priority)continue;
|
||||
// Do not recolour every water/land dot when a bright route crosses one dot.
|
||||
if(priorityNow>priority){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;
|
||||
|
||||
@@ -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]!);
|
||||
});
|
||||
});
|
||||
+27
-16
@@ -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<ImageData, 'data' | 'width' | 'height'>, ascii: boolean): { glyphs: string[]; colors: Uint8Array; columns: number; rows: number } {
|
||||
export function characterCells(image: Pick<ImageData, 'data' | 'width' | 'height'>, 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 };
|
||||
}
|
||||
Reference in new issue
Block a user