fix(transit): keep the real silhouettes under sensor presets, make the selected trail visible, put Transit in Movement (#596)

## Changes

Follow-up to #587 after the owner's first look at the merged transit layer on Google 3D tiles.

- **Sensor presets keep the real silhouettes.** Under NVG, thermal (white-hot, black-hot, Ironbow) and noir, vehicles are the same silhouettes as in normal view, filled opaque white with a 2 px dark halo, at the CRT display sizes (26 px fleet, 39 px selected). The solid rounded "signature" bodies are gone.
- **The selected vehicle's trail is visible.** The retained history is now draped onto 3D tiles and terrain (`GroundPolylinePrimitive`, `ClassificationType.BOTH`, the way the traffic heat-lines are drawn), 3 px core over a 5 px dark backing, per-segment age colour, a short draped head clipped to the sprite. Two defects kept it off screen before: Cesium's per-instance attribute getters return copies, so the renderer's show/colour edits were discarded, and a marker did not recover after the camera jumped to it, which hid the trail with it. Trail geometry is now prepared on selection independent of floor resolution and marker visibility; sparse long intervals (rail) keep a bounded marker corridor instead of dropping the interval; classification survives map-source changes.
- **Transit sits in the Movement panel group** between Street Traffic and Bike Share (the panel orders by `PANEL_GROUPS`, not by catalog order; the catalog is moved too).

## How it was verified

- `npm run format:check` (854 files), `npm run check:boundaries`, `npm test` (4,062 passing), `npm run build` clean; tracking regression 109/109 in a real browser on Google 3D tiles.
- Browser harness (`scripts/qa-transit.mjs`), keyed on Google 3D tiles and keyless: keyed 194 pass, with the trail pixel check passing at 300, 600 and 900 m; the remaining keyed failures are the per-frame allocation gate already recorded as a follow-up from #587 (CPU and frame time within budget), four readability lines at threshold edges, and the card-anchor check now counting frames where the selected marker is legitimately off screen; keyless 193 pass with the remaining misses being threshold edges on the thin tram silhouette (centre 0.83–0.88 against 0.85) and one bus ring 0.28 against 0.25 on a pure-white roof.
- New harness checks: `Google 3D selected trail visible at 6 of 8 projected samples` (repeated off/on framebuffer captures at 300/600/900 m, samples outside the sprite footprint, a background-change negative control; fails on main, passes here), a camera-arrival check (select a moving vehicle, `setView` onto it, marker shown and trail body drawn within 2 s), `PANEL_ORDER` pinned, raster tests comparing every sensor raster's mask and aspect ratio to the normal silhouette.
- Direct look on a keyed build: a live CapMetro bus at 30 m/s selected through the layer API draws its draped trail along the highway lane (harness capture `keyed-600m-trail-visible.jpg`).
- Adversarial review by Codex Astra before the last two fix passes (stack-switch classification, marker corridor under the vertex cap, pixel-gate loophole), all three fixed with regressions.

Docs: `docs/CURRENT-STATE.md` and `CHANGELOG.md` updated in this PR. No data-source changes.
This commit is contained in:
Bilawal Sidhu authored and GitHub committed 2026-09-15 13:26:41 -05:00
1 parent cd22ca3559
commit 0bb61f53c6
24 files changed
+1945 -205

No files matched your search

+132 -52
View File
@@ -21,23 +21,70 @@ export function trailAlpha(ageMs) {
/** One static history batch and one frequently updated head. Never entities. */
export function createContactTrailRenderer(scene) {
const heads = scene.primitives.add(new Cesium.PolylineCollection());
const backing = heads.add({
positions: [],
width: 4,
show: false,
material: Cesium.Material.fromType('Color', {
color: new Cesium.Color(0.02, 0.03, 0.05, 0.8),
}),
});
const head = heads.add({
positions: [],
width: 2,
show: false,
material: Cesium.Material.fromType('Color', {
color: Cesium.Color.WHITE.clone(),
}),
});
const ground =
!!scene.frameState?.context &&
Cesium.GroundPolylinePrimitive.isSupported(scene);
const collection = ground ? scene.groundPrimitives : scene.primitives;
// One 25 m corridor is clipped in material coordinates. Only subdivision
// crossings build geometry; frame updates write a scalar, never vertex buffers.
const headMaterial = (alpha) =>
new Cesium.Material({
fabric: {
type: 'TransitTrailHead',
uniforms: {
color: new Cesium.Color(0.37, 0.94, 0.54, alpha),
fraction: 0,
backing: true,
},
source: `czm_material czm_getMaterial(czm_materialInput materialInput) {
if (materialInput.st.s > fraction) discard;
czm_material m = czm_getDefaultMaterial(materialInput);
bool edge = abs(materialInput.st.t - 0.5) > 0.3;
m.diffuse = czm_gammaCorrect(vec4(edge && backing ? vec3(0.02, 0.03, 0.05) : color.rgb, 1.0)).rgb;
m.alpha = color.a;
return m;
}`,
},
translucent: true,
});
const material = headMaterial(0.8),
depthMaterial = headMaterial(0.55);
depthMaterial.uniforms.backing = false;
const head = { positions: [], show: false, width: 3, material };
const backing = { positions: [], show: false, width: 5 };
let headPrimitive = null,
headRebuilds = 0;
function geometry(positions, width, material = false) {
return ground
? new Cesium.GroundPolylineGeometry({ positions, width, granularity: 0 })
: new Cesium.PolylineGeometry({
positions,
width,
arcType: Cesium.ArcType.NONE,
vertexFormat: material
? Cesium.PolylineMaterialAppearance.VERTEX_FORMAT
: Cesium.PolylineColorAppearance.VERTEX_FORMAT,
});
}
function primitive(instances, appearance, depthFailAppearance) {
return ground
? new Cesium.GroundPolylinePrimitive({
geometryInstances: instances,
appearance,
classificationType: Cesium.ClassificationType.BOTH,
allowPicking: false,
asynchronous: true,
show: visible,
})
: new Cesium.Primitive({
geometryInstances: instances,
appearance,
depthFailAppearance,
allowPicking: false,
asynchronous: false,
show: visible,
});
}
let body = null,
segments = [],
revision = -1,
@@ -68,7 +115,10 @@ export function createContactTrailRenderer(scene) {
completed = -1;
lastSecond = -1;
active = null;
if (body) scene.primitives.remove(body);
if (headPrimitive) collection.remove(headPrimitive);
headPrimitive = null;
head.show = backing.show = false;
if (body) collection.remove(body);
body = null;
const instances = [];
attributeIds.length = 0;
@@ -80,18 +130,13 @@ export function createContactTrailRenderer(scene) {
instances.push(
new Cesium.GeometryInstance({
id: `${i}:${lane}`,
geometry: new Cesium.PolylineGeometry({
positions: segment.positions,
width: lane ? 2 : 4,
arcType: Cesium.ArcType.NONE,
vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT,
}),
geometry: geometry(segment.positions, lane ? 3 : 5),
attributes: {
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
tint(0.8, !lane),
),
depthFailColor: Cesium.ColorGeometryInstanceAttribute.fromColor(
tint(0.8, !lane),
tint(0.55),
),
show: new Cesium.ShowGeometryInstanceAttribute(false),
},
@@ -100,20 +145,12 @@ export function createContactTrailRenderer(scene) {
}
}
if (instances.length) {
const depthFailAppearance = new Cesium.PolylineColorAppearance({
translucent: true,
fragmentShaderSource:
'in vec4 v_color; void main() { out_FragColor = czm_gammaCorrect(vec4(v_color.rgb, v_color.a * 0.2)); }',
});
body = scene.primitives.add(
new Cesium.Primitive({
geometryInstances: instances,
appearance: new Cesium.PolylineColorAppearance({ translucent: true }),
depthFailAppearance,
allowPicking: false,
asynchronous: false,
show: visible,
}),
body = collection.add(
primitive(
instances,
new Cesium.PolylineColorAppearance({ translucent: true }),
new Cesium.PolylineColorAppearance({ translucent: true }),
),
);
rebuilds++;
}
@@ -122,6 +159,7 @@ export function createContactTrailRenderer(scene) {
if (!sample || !renderedPosition) {
head.show = false;
backing.show = false;
if (headPrimitive) headPrimitive.show = false;
return;
}
const second = Math.floor(sample.displayT / 1000);
@@ -152,8 +190,10 @@ export function createContactTrailRenderer(scene) {
attributeIds[i][lane],
);
if (!attributes) continue;
attributes.show[0] = i <= done ? 1 : 0;
attributes.show = attributes.show;
// Cesium getters return copies. Write back the array we changed.
const show = attributes.show;
show[0] = i <= done ? 1 : 0;
attributes.show = show;
}
}
completed = done;
@@ -166,16 +206,20 @@ export function createContactTrailRenderer(scene) {
attributeIds[i][lane],
);
if (!attributes) continue;
const rgba = attributes.color;
Cesium.ColorGeometryInstanceAttribute.toValue(
tint(alpha, !lane),
attributes.color,
rgba,
);
attributes.color = attributes.color;
Cesium.ColorGeometryInstanceAttribute.toValue(
tint(alpha, !lane),
attributes.depthFailColor,
);
attributes.depthFailColor = attributes.depthFailColor;
attributes.color = rgba;
if (!ground) {
const depthRgba = attributes.depthFailColor;
Cesium.ColorGeometryInstanceAttribute.toValue(
tint(0.55),
depthRgba,
);
attributes.depthFailColor = depthRgba;
}
}
}
lastSecond = second;
@@ -183,16 +227,43 @@ export function createContactTrailRenderer(scene) {
const show = visible && !!segment && sample.fraction < 1;
head.show = show;
backing.show = show;
if (headPrimitive) headPrimitive.show = show;
if (!show) return;
if (active !== segment) {
active = segment;
if (headPrimitive) collection.remove(headPrimitive);
headPrimitive = collection.add(
primitive(
new Cesium.GeometryInstance({
geometry: geometry(segment.positions, 5, true),
}),
new Cesium.PolylineMaterialAppearance({
material,
translucent: true,
}),
new Cesium.PolylineMaterialAppearance({
material: depthMaterial,
translucent: true,
}),
),
);
headRebuilds++;
headPositions[0] = segment.positions[0];
headPositions[1] = endpoint;
}
Cesium.Cartesian3.clone(renderedPosition, endpoint);
head.positions = headPositions;
backing.positions = headPositions;
Cesium.Color.clone(tint(0.8), head.material.uniforms.color);
material.uniforms.fraction = Math.max(
0,
Math.min(
1,
(sample.displayT - segment.fromT) / (segment.toT - segment.fromT),
),
);
depthMaterial.uniforms.fraction = material.uniforms.fraction;
Cesium.Color.clone(tint(0.8), material.uniforms.color);
Cesium.Color.clone(tint(0.55), depthMaterial.uniforms.color);
}
return {
replaceHistory,
@@ -205,12 +276,15 @@ export function createContactTrailRenderer(scene) {
},
setVisible(next) {
visible = next;
heads.show = next;
if (headPrimitive) headPrimitive.show = next && head.show;
if (body) body.show = next;
},
destroy() {
if (body) scene.primitives.remove(body);
scene.primitives.remove(heads);
if (body) collection.remove(body);
if (headPrimitive) collection.remove(headPrimitive);
headPrimitive = null;
material.destroy();
depthMaterial.destroy();
body = null;
segments = [];
},
@@ -222,6 +296,12 @@ export function createContactTrailRenderer(scene) {
body,
head,
backing,
headPrimitive,
headStrategy: ground
? 'draped corridor with material clip'
: 'depth-fail corridor with material clip',
ground,
headRebuilds,
entitiesAdded: 0,
};
},
+169 -22
View File
@@ -1,12 +1,14 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { readFileSync } from 'node:fs';
import { TRAIL_VERTEX_LIMIT } from '../layers/transit/trails.js';
import {
createContactTrailRenderer,
trailAlpha,
} from './contactTrailRenderer.js';
function fixture(t) {
function fixture(t, ground = false) {
for (const key of [
'HTMLCanvasElement',
'HTMLImageElement',
@@ -20,9 +22,21 @@ function fixture(t) {
else globalThis[key] = prior;
});
}
const width = Cesium.ContextLimits._maximumAliasedLineWidth;
Cesium.ContextLimits._maximumAliasedLineWidth = 1;
t.after(() => {
Cesium.ContextLimits._maximumAliasedLineWidth = width;
});
const primitives = new Cesium.PrimitiveCollection();
t.after(() => primitives.destroy());
return { primitives };
const groundPrimitives = new Cesium.PrimitiveCollection();
t.after(() => groundPrimitives.destroy());
return {
primitives,
groundPrimitives,
frameState: { context: { depthTexture: ground } },
globe: { show: false },
};
}
test('body clips future subdivisions, head ends at exact marker, and frames retain geometry', (t) => {
const scene = fixture(t),
@@ -62,7 +76,9 @@ test('body clips future subdivisions, head ends at exact marker, and frames reta
for (const instance of body.geometryInstances) {
assert.deepEqual(
instance.attributes.depthFailColor.value,
instance.attributes.color.value,
Cesium.ColorGeometryInstanceAttribute.toValue(
Cesium.Color.fromCssColorString('#5EF08A').withAlpha(0.55),
),
);
}
body._batchTableAttributeIndices = { color: 0, depthFailColor: 1 };
@@ -81,7 +97,18 @@ test('body clips future subdivisions, head ends at exact marker, and frames reta
);
// Simulate Cesium's completed upload; exercise public attribute writes.
body._ready = true;
body.getGeometryInstanceAttributes = (id) => attributes.get(id);
body.getGeometryInstanceAttributes = (id) => {
const stored = attributes.get(id),
accessor = {};
for (const name of ['show', 'color', 'depthFailColor'])
Object.defineProperty(accessor, name, {
get: () => stored[name].slice(),
set: (value) => {
stored[name] = value.slice();
},
});
return accessor;
};
const marker = Cesium.Cartesian3.lerp(
positions[1],
positions[2],
@@ -95,10 +122,19 @@ test('body clips future subdivisions, head ends at exact marker, and frames reta
[1, 1, 0, 0],
);
for (const a of attributes.values())
assert.deepEqual(a.depthFailColor, a.color);
assert.deepEqual(
a.depthFailColor,
Cesium.ColorGeometryInstanceAttribute.toValue(
Cesium.Color.fromCssColorString('#5EF08A').withAlpha(0.55),
),
);
const result = renderer.diagnostics();
assert.equal(result.body, body);
assert.equal(result.rebuilds, 1);
assert.equal(result.headRebuilds, 1, '120 frames retain head geometry');
assert.equal(result.head.material.uniforms.fraction, 0.5);
assert.equal(result.head.width, 3);
assert.equal(result.backing.width, 5);
assert.ok(Cesium.Cartesian3.equals(result.head.positions.at(-1), marker));
assert.equal(result.head.positions.length, 2);
renderer.setDisplaySample(
@@ -120,24 +156,130 @@ test('trail age curve uses the specified four alpha anchors', () => {
assert.ok(Math.abs(trailAlpha(60000) - 0.625) < 1e-10);
});
test('the maximum prepared body stays below the 2 MiB geometry budget', () => {
const geometry = Cesium.PolylineGeometry.createGeometry(
new Cesium.PolylineGeometry({
positions: [
Cesium.Cartesian3.fromDegrees(-71, 42, 11.5),
Cesium.Cartesian3.fromDegrees(-71, 42.0002, 11.5),
],
width: 2,
arcType: Cesium.ArcType.NONE,
vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT,
}),
test('ground body and clipped head stay inside rebuild and frame geometry budgets', async () => {
Cesium.ApproximateTerrainHeights._terrainHeights = JSON.parse(
readFileSync(
new URL(
'../../node_modules/@cesium/engine/Source/Assets/approximateTerrainHeights.json',
import.meta.url,
),
),
);
const bytes =
Object.values(geometry.attributes).reduce(
(total, attribute) => total + (attribute?.values?.byteLength || 0),
const positions = [
Cesium.Cartesian3.fromDegrees(-97.7431, 30.267),
Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672),
];
const start = performance.now();
const instances = async (count) =>
Promise.all(
Array.from(
{ length: count },
async (_, i) =>
new Cesium.GeometryInstance({
id: i,
geometry: await Cesium.GroundPolylineGeometry.createGeometry(
new Cesium.GroundPolylineGeometry({
positions,
width: i % 2 ? 3 : 5,
granularity: 0,
}),
),
}),
),
);
const pipelineBytes = async (count) => {
// Match GroundPolylinePrimitive's internal Primitive options and the
// application's morph-capable scene, including batch IDs and encoding.
const result = Cesium.PrimitivePipeline.combineGeometry({
instances: await instances(count),
projection: new Cesium.GeographicProjection(),
ellipsoid: Cesium.Ellipsoid.WGS84,
modelMatrix: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY),
elementIndexUintSupported: true,
scene3DOnly: false,
vertexCacheOptimize: false,
compressVertices: false,
createPickOffsets: false,
});
return result.geometries.reduce(
(sum, geometry) =>
sum +
Object.values(geometry.attributes).reduce(
(n, a) => n + (a?.values?.byteLength || 0),
0,
) +
geometry.indices.byteLength,
0,
) + geometry.indices.byteLength;
assert.ok(bytes * 2 * 2047 < 2 * 1024 * 1024);
);
};
const bytes = await pipelineBytes(1);
const bodyInstances = 2 * (TRAIL_VERTEX_LIMIT - 1);
const bodyBytes = await pipelineBytes(bodyInstances);
assert.equal(bytes, 1576, 'measure final encoded and batched geometry');
assert.equal(bodyBytes, 2014128);
const instanceReserve = 64 * bodyInstances;
assert.ok(bodyBytes + instanceReserve <= 2 * 1024 * 1024);
assert.ok(
bytes <= 2048,
'subdivision crossings stay below 2 KiB of head geometry',
);
console.log(
`trail final pipeline: ${bytes} bytes/head crossing, 0 bytes/ordinary head frame, ${bodyBytes} bytes/body (${bodyInstances} instances), ${instanceReserve} bytes instance reserve; geometry CPU ${(performance.now() - start).toFixed(3)} ms`,
);
});
test('supported scenes drape body and clipped head on 3D tiles and retain head geometry', (t) => {
const scene = fixture(t, true),
renderer = createContactTrailRenderer(scene);
const positions = [
Cesium.Cartesian3.fromDegrees(-97.7431, 30.267),
Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672),
];
renderer.replaceHistory({
revision: 1,
segments: [{ fromSeq: 0, toSeq: 1, fromT: 0, toT: 20000, positions }],
});
const first = renderer.diagnostics();
assert.ok(first.body instanceof Cesium.GroundPolylinePrimitive);
assert.equal(first.body.classificationType, Cesium.ClassificationType.BOTH);
assert.ok(scene.groundPrimitives.contains(first.body));
for (const instance of first.body.geometryInstances)
assert.ok(instance.geometry instanceof Cesium.GroundPolylineGeometry);
for (let i = 1; i <= 120; i++)
renderer.setDisplaySample(
{ displayT: i * 100, fromSeq: 0, toSeq: 1, fraction: i / 200 },
positions[1],
);
const result = renderer.diagnostics();
assert.equal(result.body, first.body);
assert.equal(result.headRebuilds, 1);
assert.ok(result.headPrimitive instanceof Cesium.GroundPolylinePrimitive);
assert.equal(result.head.material.uniforms.fraction, 0.6);
assert.match(result.head.material.shaderSource, /discard/);
for (const stack of ['Google', 'OSM', 'Bing', 'Google']) {
scene.globe.show = stack !== 'Google';
renderer.setDisplaySample(
{ displayT: 12000, fromSeq: 0, toSeq: 1, fraction: 0.6 },
positions[1],
);
const retained = renderer.diagnostics();
assert.equal(retained.body, result.body, `${stack}: retained history`);
assert.equal(retained.headPrimitive, result.headPrimitive);
for (const primitive of [retained.body, retained.headPrimitive]) {
assert.equal(
primitive.classificationType,
Cesium.ClassificationType.BOTH,
stack,
);
assert.equal(primitive.show, true, stack);
}
}
renderer.setVisible(false);
assert.equal(result.body.show, false);
assert.equal(result.headPrimitive.show, false);
renderer.destroy();
assert.equal(scene.groundPrimitives.length, 0);
assert.equal(scene.primitives.length, 0);
});
test('subdivision crossings only write the changed show range and age colors once per second', (t) => {
@@ -206,5 +348,10 @@ test('subdivision crossings only write the changed show range and age colors onc
display(1200);
assert.deepEqual(counts, { show: 0, color: 200, depthFailColor: 200 });
for (const a of attributes.values())
assert.deepEqual(a.color, a.depthFailColor);
assert.deepEqual(
a.depthFailColor,
Cesium.ColorGeometryInstanceAttribute.toValue(
Cesium.Color.fromCssColorString('#FF4538').withAlpha(0.55),
),
);
});
+13 -12
View File
@@ -189,20 +189,21 @@ export function transitIcon(kind, px = FLEET_RASTER_PX, options = {}) {
let uri = _iconCache.get(key);
if (!uri) {
const size = VIEW + 2 * frame.pad;
// Sensor signatures use a broad rounded envelope, without dark interior
// joints or a second outline eating into the hot core at display resolution.
const halfW = k === 'bus' || k === 'ferry' || k === 'unknown' ? 48 : 47;
const halfH = 48;
const radius = k === 'subway' ? 8 : k === 'rail' ? 12 : 14;
const capsule = `<path d="M${-halfW + radius},${-halfH} H${halfW - radius} Q${halfW},${-halfH} ${halfW},${-halfH + radius} V${halfH - radius} Q${halfW},${halfH} ${halfW - radius},${halfH} H${-halfW + radius} Q${-halfW},${halfH} ${-halfW},${halfH - radius} V${-halfH + radius} Q${-halfW},${-halfH} ${-halfW + radius},${-halfH} Z" fill="white" />`;
const body = profile === 'mono' ? capsule : BODIES[k];
// Reuse the exact outer path. Sensors fill its interior without panel ink.
const silhouette = BODIES[k].match(/<path d="([^"]+)"/)[1];
const body =
profile === 'mono'
? `<path d="${silhouette}" fill="white" />`
: BODIES[k];
const halo =
frame.units > 0
? body.replace(
/<path d="([^"]+)"\s+fill="white" [^/]*\/>/,
(_m, d) =>
`<path d="${d}" fill="none" stroke="${profile === 'mono' ? '#000000' : '#05080C'}" stroke-opacity="${profile === 'mono' ? 1 : 0.95}" stroke-width="${frame.units.toFixed(2)}" stroke-linejoin="round"/>`,
)
? profile === 'mono'
? `<path d="${silhouette}" fill="none" stroke="#05080C" stroke-opacity="1" stroke-width="${frame.units.toFixed(2)}" stroke-linejoin="round"/>`
: body.replace(
/<path d="([^"]+)"\s+fill="white" [^/]*\/>/,
(_m, d) =>
`<path d="${d}" fill="none" stroke="#05080C" stroke-opacity="0.95" stroke-width="${frame.units.toFixed(2)}" stroke-linejoin="round"/>`,
)
: '';
const svg =
`<svg xmlns="http://www.w3.org/2000/svg" width="${Math.round(px * frame.ratio)}" height="${Math.round(px * frame.ratio)}" viewBox="${-frame.pad} ${-frame.pad} ${size} ${size}">` +
+128 -24
View File
@@ -32,10 +32,10 @@ test('a haloed glyph draws the dark ring under the shipped white body', () => {
assert.notEqual(plain, haloed);
assert.match(
haloed,
/stroke="#000000" stroke-opacity="1" stroke-width="19\.20"/,
/stroke="#05080C" stroke-opacity="1" stroke-width="14\.77"/,
);
assert.ok(
haloed.indexOf('#000000') < haloed.indexOf('fill="white"'),
haloed.indexOf('#05080C') < haloed.indexOf('fill="white"'),
'ring first, body on top',
);
assert.match(haloed, /viewBox="-\d+ -\d+ \d+ \d+"/, 'padded frame');
@@ -97,40 +97,144 @@ test('all 36 variants retain the specified final halo at both display sizes', as
assert.equal(transitIconCacheSize(), 36);
});
test('sensor bodies cover sixty percent of the padded display with opaque white', async () => {
test('every mono raster preserves the normal silhouette mask and aspect at CRT display size', async () => {
const sharp = (await import('sharp')).default;
const { presetSpriteScale, presetSpriteOutlinePx } =
await import('./transitPresetStyle.js');
const rasterBody = async (svg) => {
// Compare fills in the same frame; the separately tested external halo is excluded.
const d =
[...svg.matchAll(/<path d="([^"]+)" fill="white"/g)][0]?.[1] ??
svg.match(/<path d="([^"]+)"\s+fill="white"/)[1];
const { data, info } = await sharp(
Buffer.from(
`<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="-48 -48 96 96"><path d="${d}" fill="white"/></svg>`,
),
)
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
const mask = [];
let minX = info.width,
minY = info.height,
maxX = 0,
maxY = 0;
for (let y = 0; y < info.height; y++)
for (let x = 0; x < info.width; x++) {
const alpha = data[(y * info.width + x) * 4 + 3];
mask.push(alpha);
if (alpha > 127) {
minX = Math.min(minX, x);
maxX = Math.max(maxX, x);
minY = Math.min(minY, y);
maxY = Math.max(maxY, y);
}
}
return { mask, aspect: (maxX - minX + 1) / (maxY - minY + 1) };
};
for (const selected of [false, true])
for (const kind of TRANSIT_ICON_KINDS) {
const normal = await rasterBody(
decode(transitIcon(kind, selected ? 96 : 48, { style: 'normal' })),
);
for (const style of ['surveillance', 'thermal', 'noir', 'nvg']) {
const svg = decode(transitIcon(kind, selected ? 96 : 48, { style }));
const mono = await rasterBody(svg);
assert.deepEqual(
mono.mask,
normal.mask,
`${kind}/${style}: no envelope`,
);
assert.ok(
Math.abs(mono.aspect / normal.aspect - 1) <= 0.05,
`${kind}/${style}: aspect`,
);
const display =
(selected ? 30 : 20) * presetSpriteScale(style, selected);
const crt = (selected ? 30 : 20) * presetSpriteScale('retro', selected);
assert.equal(display, crt);
const frame = haloFrame(
presetSpriteOutlinePx(style, selected),
display,
);
const crtFrame = haloFrame(
presetSpriteOutlinePx('retro', selected),
crt,
);
assert.equal(
(display * frame.ratio) / frame.ratio,
(crt * crtFrame.ratio) / crtFrame.ratio,
'unpadded silhouette matches CRT; only external halo padding grows',
);
assert.equal(
(svg.match(/<path /g) || []).length,
2,
'only outer halo and opaque silhouette',
);
assert.match(svg, /fill="white" \/>/);
}
}
});
test('sensor raster halo stays dark across its screen-space band on a white roof', async () => {
const sharp = (await import('sharp')).default;
const { presetSpriteScale, presetSpriteOutlinePx } =
await import('./transitPresetStyle.js');
const { reduceSensorContrast } =
await import('../layers/transit/qaMetrics.js');
const sides = {
bus: 17,
tram: 10,
subway: 11.5,
rail: 10.5,
ferry: 12.5,
unknown: 10,
};
for (const selected of [false, true]) {
const pixels = [];
for (const kind of TRANSIT_ICON_KINDS) {
const display =
(selected ? 30 : 20) * presetSpriteScale('thermal', selected);
assert.equal(display, selected ? 36 : 30);
assert.equal(presetSpriteOutlinePx('thermal', selected), 3);
const frame = haloFrame(
presetSpriteOutlinePx('thermal', selected),
display,
);
const size = Math.round(display * frame.ratio * 8);
const svg = decode(
transitIcon(kind, selected ? 96 : 48, { style: 'thermal' }),
);
const frame = haloFrame(3, display);
const width = Math.round(display * frame.ratio);
const { data, info } = await sharp(Buffer.from(svg))
.resize(width, width)
.ensureAlpha()
.resize(size, size)
.flatten({ background: '#ffffff' })
.raw()
.toBuffer({ resolveWithObject: true });
let white = 0;
for (let i = 0; i < data.length; i += 4)
if (
data[i] >= 240 &&
data[i + 1] >= 240 &&
data[i + 2] >= 240 &&
data[i + 3] >= 240
)
white++;
assert.ok(
white / (info.width * info.height) >= 0.6,
`${kind}/${display}: white ${white / (info.width * info.height)}`,
);
const c = (Math.floor(width / 2) * width + Math.floor(width / 2)) * 4;
assert.deepEqual([...data.subarray(c, c + 4)], [255, 255, 255, 255]);
const sample = (offsetPx) => {
const x = Math.floor(
size / 2 + (offsetPx * size) / (display * frame.ratio),
);
const i = (Math.floor(size / 2) * info.width + x) * info.channels;
return (
(0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]) / 255
);
};
// The body stays at CRT size; the ring must cover this fixed external band.
const ringMin = sample((sides[kind] * display) / 96 + 1.25);
pixels.push({
key: kind,
verified: true,
centre: sample(0),
ringMin,
ringMax: 1 - ringMin,
background: 1,
});
}
const white = reduceSensorContrast(pixels, 'white', 'thermal');
assert.ok(white.pass, JSON.stringify({ selected, pixels, white }));
const black = reduceSensorContrast(
pixels.map((p) => ({ ...p, centre: 1 - p.centre })),
'black',
'thermal',
);
assert.ok(black.pass, JSON.stringify({ selected, black }));
}
});
+3 -5
View File
@@ -12,7 +12,7 @@
* Encoding per profile, the traffic-dot precedent applied to a glyph:
* - `mono` (surveillance/NVG, thermal/FLIR, noir, cockpit nvg/thermal): the
* sprite is WHITE — the hottest end of grayscale FLIR and of the Ironbow
* ramp, and the brightest phosphor under NVG — drawn larger, with a dark
* ramp, and the brightest phosphor under NVG — at CRT size, with a thin dark
* halo sized in SCREEN pixels so it keeps local contrast on a bright road
* through any luma mapping. Black-hot FLIR inverts it, deliberately: a
* black sprite with a light ring is what a hot object looks like there.
@@ -52,8 +52,8 @@ const PROFILE_BY_STYLE = Object.freeze({
const SPRITE_STYLE = Object.freeze({
mono: Object.freeze({
rgba: Object.freeze([255, 255, 255, 1]),
scale: 1.5,
outlinePx: 3,
scale: 1.3,
outlinePx: 2,
}),
crt: Object.freeze({
scale: 1.3,
@@ -111,8 +111,6 @@ export function presetSpriteRgba(styleName, mode) {
* @returns {number} 1 under the normal profile.
*/
export function presetSpriteScale(styleName, selected = false) {
if (transitStyleProfile(styleName) === 'mono')
return selected ? 36 / 30 : 30 / 20;
return SPRITE_STYLE[transitStyleProfile(styleName)]?.scale ?? 1;
}
+7 -7
View File
@@ -45,13 +45,13 @@ test('mono: every mode is a white core, larger, with a dark halo in screen pixel
`${style} ${mode}`,
);
}
assert.ok(presetSpriteScale(style) === 1.5, `${style} is drawn larger`);
// At least a whole screen pixel and a half of ring around a five-pixel
// body: thinner than that vanishes through blur and pixelation.
assert.ok(
presetSpriteOutlinePx(style) >= 1.5,
`${style} halo is ${presetSpriteOutlinePx(style)} px`,
);
for (const selected of [false, true]) {
assert.equal(
presetSpriteScale(style, selected),
presetSpriteScale('retro', selected),
);
assert.equal(presetSpriteOutlinePx(style, selected), 2);
}
}
});