feat(transit): give vehicle icons a readable front, and none without a course

At the ~20 px the fleet renders at, the bus, tram, metro and unknown
glyphs were near-symmetric boxes: rotated along the course, nothing showed
which end was the front. Each now has a front built into its outline (an
arrow-like nose for the bus, a pointed cab for the tram, a chamfered cab
for the metro), so it survives the tint, the halo and the sensor profiles,
which keep only the outline.

A vehicle with no known course sits at rotation 0, where a pointed glyph
would claim it is heading north. Those vehicles, fleet and selected alike,
now get a symmetric outline (rail and ferry included) and switch to the
pointed one once a course arrives. The raster cache doubles to a fixed 72
variants.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Itallo BrandãoandClaude Opus 5.5 committed 2026-11-05 03:39:15 -03:00
1 parent 7d8b80db0a
commit 6172be3995
4 files changed
+225 -89

No files matched your search

+1 -1
View File
@@ -801,7 +801,7 @@ try {
); );
check( check(
`${preset.name}: the icon raster cache stays bounded`, `${preset.name}: the icon raster cache stays bounded`,
styling.iconCacheSize <= 6 * 2 * 3, styling.iconCacheSize <= 6 * 2 * 3 * 2,
`${styling.iconCacheSize} rasters`, `${styling.iconCacheSize} rasters`,
); );
} }
+83 -24
View File
@@ -41,33 +41,46 @@ const DETAIL =
/** /**
* Each body is drawn in a centred frame (origin 0,0 = glyph centre), front * Each body is drawn in a centred frame (origin 0,0 = glyph centre), front
* toward -Y, in the 96-unit space (half-extent up to ~45). * toward -Y, in the 96-unit space (half-extent up to ~45).
*
* Two sets. `HEADING_BODIES` is what a vehicle with a known course wears: every
* outline has a front that cannot be mistaken for its back at fleet size — a
* nose, a bow or a chamfered cab — because at twenty pixels a bus drawn as a
* near-symmetric box gives no hint which way it is going, and a hairline
* windscreen is far below one pixel. The front is part of the OUTLINE, not
* interior ink, so it survives the tint, the halo (which strokes the outline)
* and the sensor profiles (which keep only the outline).
*
* `NEUTRAL_BODIES` is what a vehicle with NO known course wears. Its billboard
* sits at rotation 0, so a pointed glyph would claim it is heading north; these
* outlines are the same footprint, symmetric end to end, and claim nothing.
*/ */
const BODIES = { const HEADING_BODIES = {
// ── Bus: short, blunt, square-shouldered. The shape everything else reads // ── Bus: short, broad, square-shouldered behind an arrow-like nose. The
// against, and the one most people will see most often. // shape everything else reads against, and the one most people will see
// most often, so it carries the boldest front.
bus: ` bus: `
<path d="M-17,-28 C-17,-32 -14,-34 0,-34 C14,-34 17,-32 17,-28 <path d="M0,-41 L17,-25 L17,28 C17,32 14,34 0,34 C-14,34 -17,32 -17,28
L17,28 C17,32 14,34 0,34 C-14,34 -17,32 -17,28 Z" L-17,-25 Z"
fill="white" ${STROKE}/> fill="white" ${STROKE}/>
<path d="M-11,-24 L11,-24" ${DETAIL}/> <path d="M-11,-21 L11,-21" ${DETAIL}/>
<path d="M-11,20 L11,20" ${DETAIL}/>`, <path d="M-11,20 L11,20" ${DETAIL}/>`,
// ── Tram / light rail: longer and slimmer than a bus, with the two // ── Tram / light rail: longer and slimmer than a bus, a short pointed cab
// articulation joints that make a modern low-floor tram unmistakable. // and the two articulation joints of a modern low-floor tram.
tram: ` tram: `
<path d="M-10,-38 C-10,-41 -8,-42.5 0,-42.5 C8,-42.5 10,-41 10,-38 <path d="M0,-44 L10,-33 L10,38 C10,41 8,42.5 0,42.5 C-8,42.5 -10,41 -10,38
L10,38 C10,41 8,42.5 0,42.5 C-8,42.5 -10,41 -10,38 Z" L-10,-33 Z"
fill="white" ${STROKE}/> fill="white" ${STROKE}/>
<path d="M-10,-13 L10,-13" ${DETAIL}/> <path d="M-10,-13 L10,-13" ${DETAIL}/>
<path d="M-10,13 L10,13" ${DETAIL}/> <path d="M-10,13 L10,13" ${DETAIL}/>
<path d="M-8,-34 L8,-34" ${DETAIL}/>`, <path d="M-8,-29 L8,-29" ${DETAIL}/>`,
// ── Metro / subway: squared off at both ends, wider than a tram, a single // ── Metro / subway: squared off and wider than a tram, the cab end
// cab line at the front. Reads as rolling stock, not a road vehicle. // chamfered to a narrow flat face; the rear stays square.
subway: ` subway: `
<path d="M-11.5,-40 L11.5,-40 L11.5,40 L-11.5,40 Z" <path d="M-4,-40 L4,-40 L11.5,-30 L11.5,40 L-11.5,40 L-11.5,-30 Z"
fill="white" ${STROKE}/> fill="white" ${STROKE}/>
<path d="M-11.5,-32 L11.5,-32" ${DETAIL}/> <path d="M-11.5,-26 L11.5,-26" ${DETAIL}/>
<path d="M-11.5,0 L11.5,0" ${DETAIL}/> <path d="M-11.5,0 L11.5,0" ${DETAIL}/>
<path d="M-11.5,32 L11.5,32" ${DETAIL}/>`, <path d="M-11.5,32 L11.5,32" ${DETAIL}/>`,
@@ -90,8 +103,49 @@ const BODIES = {
fill="white" ${STROKE}/> fill="white" ${STROKE}/>
<path d="M-7.5,-6 L7.5,-6 L7.5,20 L-7.5,20 Z" ${DETAIL}/>`, <path d="M-7.5,-6 L7.5,-6 L7.5,20 L-7.5,20 Z" ${DETAIL}/>`,
// ── Unknown: a plain rounded marker. Deliberately featureless, so an // ── Unknown: a plain marker with a nose and nothing else, so an
// unclassified vehicle never pretends to be a mode it is not. // unclassified vehicle never pretends to be a mode it is not.
unknown: `
<path d="M0,-38 L10,-24 L10,26 C10,30 7,32 0,32 C-7,32 -10,30 -10,26
L-10,-24 Z"
fill="white" ${STROKE}/>`,
};
const NEUTRAL_BODIES = {
bus: `
<path d="M-17,-28 C-17,-32 -14,-34 0,-34 C14,-34 17,-32 17,-28
L17,28 C17,32 14,34 0,34 C-14,34 -17,32 -17,28 Z"
fill="white" ${STROKE}/>
<path d="M-11,-20 L11,-20" ${DETAIL}/>
<path d="M-11,20 L11,20" ${DETAIL}/>`,
tram: `
<path d="M-10,-38 C-10,-41 -8,-42.5 0,-42.5 C8,-42.5 10,-41 10,-38
L10,38 C10,41 8,42.5 0,42.5 C-8,42.5 -10,41 -10,38 Z"
fill="white" ${STROKE}/>
<path d="M-10,-13 L10,-13" ${DETAIL}/>
<path d="M-10,13 L10,13" ${DETAIL}/>`,
subway: `
<path d="M-11.5,-40 L11.5,-40 L11.5,40 L-11.5,40 Z"
fill="white" ${STROKE}/>
<path d="M-11.5,-32 L11.5,-32" ${DETAIL}/>
<path d="M-11.5,0 L11.5,0" ${DETAIL}/>
<path d="M-11.5,32 L11.5,32" ${DETAIL}/>`,
rail: `
<path d="M-10.5,-40 C-10.5,-43 -8.5,-44.5 0,-44.5 C8.5,-44.5 10.5,-43 10.5,-40
L10.5,40 C10.5,43 8.5,44.5 0,44.5 C-8.5,44.5 -10.5,43 -10.5,40 Z"
fill="white" ${STROKE}/>
<path d="M-10.5,-6 L10.5,-6" ${DETAIL}/>
<path d="M-10.5,6 L10.5,6" ${DETAIL}/>`,
ferry: `
<path d="M-12.5,-30 C-12.5,-36 -8,-38 0,-38 C8,-38 12.5,-36 12.5,-30
L12.5,30 C12.5,36 8,38 0,38 C-8,38 -12.5,36 -12.5,30 Z"
fill="white" ${STROKE}/>
<path d="M-7.5,-13 L7.5,-13 L7.5,13 L-7.5,13 Z" ${DETAIL}/>`,
unknown: ` unknown: `
<path d="M-10,-26 C-10,-30 -7,-32 0,-32 C7,-32 10,-30 10,-26 <path d="M-10,-26 C-10,-30 -7,-32 0,-32 C7,-32 10,-30 10,-26
L10,26 C10,30 7,32 0,32 C-7,32 -10,30 -10,26 Z" L10,26 C10,30 7,32 0,32 C-7,32 -10,30 -10,26 Z"
@@ -99,7 +153,7 @@ const BODIES = {
}; };
/** The modes this module can draw. */ /** The modes this module can draw. */
export const TRANSIT_ICON_KINDS = Object.freeze(Object.keys(BODIES)); export const TRANSIT_ICON_KINDS = Object.freeze(Object.keys(HEADING_BODIES));
const _iconCache = new Map(); const _iconCache = new Map();
@@ -155,8 +209,8 @@ export function transitIconCacheSize() {
} }
/** /**
* Data URI for one mode's silhouette, lazily built and cached per kind, size * Data URI for one mode's silhouette, lazily built and cached per kind, size,
* and halo. * halo and whether the vehicle has a known course.
* *
* With a halo, the body is drawn twice: first as a wide dark stroke, then as * With a halo, the body is drawn twice: first as a wide dark stroke, then as
* the shipped white fill with its hairline edge on top, so the ring sits * the shipped white fill with its hairline edge on top, so the ring sits
@@ -166,11 +220,14 @@ export function transitIconCacheSize() {
* *
* @param {string} kind One of TRANSIT_ICON_KINDS; anything else draws `unknown`. * @param {string} kind One of TRANSIT_ICON_KINDS; anything else draws `unknown`.
* @param {number} [px] Raster size. * @param {number} [px] Raster size.
* @param {{haloScreenPx?: number, screenPx?: number}} [options] * @param {{haloScreenPx?: number, screenPx?: number, style?: string,
* heading?: boolean}} [options] `heading: false` draws the symmetric outline
* for a vehicle whose course is unknown (its billboard is not rotated, so a
* front would point north for no reason).
* @returns {string} `data:image/svg+xml;base64,...` * @returns {string} `data:image/svg+xml;base64,...`
*/ */
export function transitIcon(kind, px = FLEET_RASTER_PX, options = {}) { export function transitIcon(kind, px = FLEET_RASTER_PX, options = {}) {
const k = BODIES[kind] ? kind : 'unknown'; const k = HEADING_BODIES[kind] ? kind : 'unknown';
const selected = px > FLEET_RASTER_PX; const selected = px > FLEET_RASTER_PX;
px = selected ? SELECTED_ICON_PX : FLEET_RASTER_PX; px = selected ? SELECTED_ICON_PX : FLEET_RASTER_PX;
const profile = transitStyleProfile( const profile = transitStyleProfile(
@@ -185,16 +242,18 @@ export function transitIcon(kind, px = FLEET_RASTER_PX, options = {}) {
profile === 'mono' ? 'thermal' : profile === 'crt' ? 'retro' : 'normal'; profile === 'mono' ? 'thermal' : profile === 'crt' ? 'retro' : 'normal';
const displayPx = (selected ? 30 : 20) * presetSpriteScale(style, selected); const displayPx = (selected ? 30 : 20) * presetSpriteScale(style, selected);
const frame = haloFrame(presetSpriteOutlinePx(style, selected), displayPx); const frame = haloFrame(presetSpriteOutlinePx(style, selected), displayPx);
const key = `${k}:${selected ? 1 : 0}:${profile}`; const heading = options.heading !== false;
const bodies = heading ? HEADING_BODIES : NEUTRAL_BODIES;
const key = `${k}:${selected ? 1 : 0}:${profile}:${heading ? 'h' : 'n'}`;
let uri = _iconCache.get(key); let uri = _iconCache.get(key);
if (!uri) { if (!uri) {
const size = VIEW + 2 * frame.pad; const size = VIEW + 2 * frame.pad;
// Reuse the exact outer path. Sensors fill its interior without panel ink. // Reuse the exact outer path. Sensors fill its interior without panel ink.
const silhouette = BODIES[k].match(/<path d="([^"]+)"/)[1]; const silhouette = bodies[k].match(/<path d="([^"]+)"/)[1];
const body = const body =
profile === 'mono' profile === 'mono'
? `<path d="${silhouette}" fill="white" />` ? `<path d="${silhouette}" fill="white" />`
: BODIES[k]; : bodies[k];
const halo = const halo =
frame.units > 0 frame.units > 0
? profile === 'mono' ? profile === 'mono'
+128 -64
View File
@@ -62,39 +62,42 @@ test('the raster cache is bounded by kind, size and halo — never by call count
assert.ok(transitIconCacheSize() <= before + 3 + TRANSIT_ICON_KINDS.length); assert.ok(transitIconCacheSize() <= before + 3 + TRANSIT_ICON_KINDS.length);
}); });
test('all 36 variants retain the specified final halo at both display sizes', async () => { test('all 72 variants retain the specified final halo at both display sizes', async () => {
const { presetSpriteScale, presetSpriteOutlinePx } = const { presetSpriteScale, presetSpriteOutlinePx } =
await import('./transitPresetStyle.js'); await import('./transitPresetStyle.js');
for (const kind of TRANSIT_ICON_KINDS) { for (const kind of TRANSIT_ICON_KINDS) {
for (const selected of [false, true]) { for (const selected of [false, true]) {
for (const style of ['normal', 'thermal', 'retro']) { for (const style of ['normal', 'thermal', 'retro'])
const display = for (const heading of [true, false]) {
(selected ? 30 : 20) * presetSpriteScale(style, selected); const display =
const svg = decode(transitIcon(kind, selected ? 96 : 48, { style })); (selected ? 30 : 20) * presetSpriteScale(style, selected);
const units = Number( const svg = decode(
svg.match(/stroke-opacity="(?:0.95|1)" stroke-width="([\d.]+)"/)[1], transitIcon(kind, selected ? 96 : 48, { style, heading }),
); );
assert.ok( const units = Number(
Math.abs( svg.match(/stroke-opacity="(?:0.95|1)" stroke-width="([\d.]+)"/)[1],
(units * display) / 192 - presetSpriteOutlinePx(style, selected), );
) < 0.001, assert.ok(
); Math.abs(
const frame = haloFrame( (units * display) / 192 - presetSpriteOutlinePx(style, selected),
presetSpriteOutlinePx(style, selected), ) < 0.001,
display, );
); const frame = haloFrame(
assert.match( presetSpriteOutlinePx(style, selected),
svg, display,
new RegExp( );
`width="${Math.round((selected ? 96 : 48) * frame.ratio)}"`, assert.match(
), svg,
); new RegExp(
} `width="${Math.round((selected ? 96 : 48) * frame.ratio)}"`,
),
);
}
} }
} }
for (let i = 0; i < 100; i++) for (let i = 0; i < 100; i++)
transitIcon('invalid', i, { haloScreenPx: i, screenPx: i }); transitIcon('invalid', i, { haloScreenPx: i, screenPx: i });
assert.equal(transitIconCacheSize(), 36); assert.equal(transitIconCacheSize(), 72);
}); });
test('every mono raster preserves the normal silhouette mask and aspect at CRT display size', async () => { test('every mono raster preserves the normal silhouette mask and aspect at CRT display size', async () => {
@@ -133,47 +136,53 @@ test('every mono raster preserves the normal silhouette mask and aspect at CRT d
return { mask, aspect: (maxX - minX + 1) / (maxY - minY + 1) }; return { mask, aspect: (maxX - minX + 1) / (maxY - minY + 1) };
}; };
for (const selected of [false, true]) for (const selected of [false, true])
for (const kind of TRANSIT_ICON_KINDS) { for (const heading of [true, false])
const normal = await rasterBody( for (const kind of TRANSIT_ICON_KINDS) {
decode(transitIcon(kind, selected ? 96 : 48, { style: 'normal' })), const normal = await rasterBody(
); decode(
for (const style of ['surveillance', 'thermal', 'noir', 'nvg']) { transitIcon(kind, selected ? 96 : 48, { style: 'normal', heading }),
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( for (const style of ['surveillance', 'thermal', 'noir', 'nvg']) {
Math.abs(mono.aspect / normal.aspect - 1) <= 0.05, const svg = decode(
`${kind}/${style}: aspect`, transitIcon(kind, selected ? 96 : 48, { style, heading }),
); );
const display = const mono = await rasterBody(svg);
(selected ? 30 : 20) * presetSpriteScale(style, selected); assert.deepEqual(
const crt = (selected ? 30 : 20) * presetSpriteScale('retro', selected); mono.mask,
assert.equal(display, crt); normal.mask,
const frame = haloFrame( `${kind}/${style}: no envelope`,
presetSpriteOutlinePx(style, selected), );
display, assert.ok(
); Math.abs(mono.aspect / normal.aspect - 1) <= 0.05,
const crtFrame = haloFrame( `${kind}/${style}: aspect`,
presetSpriteOutlinePx('retro', selected), );
crt, const display =
); (selected ? 30 : 20) * presetSpriteScale(style, selected);
assert.equal( const crt =
(display * frame.ratio) / frame.ratio, (selected ? 30 : 20) * presetSpriteScale('retro', selected);
(crt * crtFrame.ratio) / crtFrame.ratio, assert.equal(display, crt);
'unpadded silhouette matches CRT; only external halo padding grows', const frame = haloFrame(
); presetSpriteOutlinePx(style, selected),
assert.equal( display,
(svg.match(/<path /g) || []).length, );
2, const crtFrame = haloFrame(
'only outer halo and opaque silhouette', presetSpriteOutlinePx('retro', selected),
); crt,
assert.match(svg, /fill="white" \/>/); );
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 () => { test('sensor raster halo stays dark across its screen-space band on a white roof', async () => {
@@ -238,3 +247,58 @@ test('sensor raster halo stays dark across its screen-space band on a white roof
assert.ok(black.pass, JSON.stringify({ selected, black })); assert.ok(black.pass, JSON.stringify({ selected, black }));
} }
}); });
test('a vehicle with a course shows its front; one without claims no direction', async () => {
const sharp = (await import('sharp')).default;
const outline = (svg) => svg.match(/<path d="([^"]+)"\s+fill="white"/)[1];
// Fleet-size raster of the outline alone: row widths from front to back.
const rows = async (d) => {
const { data, info } = await sharp(
Buffer.from(
`<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="-48 -48 96 96"><path d="${d}" fill="white"/></svg>`,
),
)
.resize(20, 20)
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
const out = [];
for (let y = 0; y < info.height; y++) {
let sum = 0;
for (let x = 0; x < info.width; x++)
sum += data[(y * info.width + x) * 4 + 3] / 255;
out.push(sum);
}
return out;
};
// How much the outline differs from itself turned end for end, in pixels.
const asymmetry = (r) =>
r.reduce((a, v, i) => a + Math.abs(v - r[r.length - 1 - i]), 0);
for (const kind of TRANSIT_ICON_KINDS) {
const pointed = await rows(outline(decode(transitIcon(kind))));
const neutral = await rows(
outline(decode(transitIcon(kind, 48, { heading: false }))),
);
assert.ok(
asymmetry(pointed) >= 2.5,
`${kind}: front reads at 20 px (${asymmetry(pointed).toFixed(2)})`,
);
assert.ok(
asymmetry(neutral) < 0.5,
`${kind}: no implied heading (${asymmetry(neutral).toFixed(2)})`,
);
// The front is the narrow end: the two leading rows of ink carry less
// than the two trailing rows.
const inked = pointed.flatMap((v, i) => (v > 0.05 ? [i] : []));
const first = inked[0];
const last = inked[inked.length - 1];
const lead = pointed[first] + pointed[first + 1];
const trail = pointed[last] + pointed[last - 1];
assert.ok(lead < trail, `${kind}: nose toward -Y`);
}
assert.notEqual(
transitIcon('bus'),
transitIcon('bus', 48, { heading: false }),
);
assert.equal(transitIcon('bus', 48, { heading: true }), transitIcon('bus'));
});
+13
View File
@@ -232,11 +232,16 @@ export function createRendering({ state, services, parts }) {
const displayPx = basePx * presetSpriteScale(style, selected); const displayPx = basePx * presetSpriteScale(style, selected);
const frame = haloFrame(halo, displayPx); const frame = haloFrame(halo, displayPx);
const px = displayPx * frame.ratio; const px = displayPx * frame.ratio;
// A glyph with a front only when there is a course to point it along:
// without one the billboard sits at rotation 0 and a nose would say north.
const heading = Number.isFinite(entry.courseDeg);
entry.iconHeading = heading;
marker.image = transitIcon( marker.image = transitIcon(
entry.mode, entry.mode,
selected ? SELECTED_ICON_PX : undefined, selected ? SELECTED_ICON_PX : undefined,
{ {
style, style,
heading,
}, },
); );
marker.width = px; marker.width = px;
@@ -252,6 +257,12 @@ export function createRendering({ state, services, parts }) {
MODE_CESIUM_COLORS[entry.mode] || MODE_CESIUM_COLORS.unknown; MODE_CESIUM_COLORS[entry.mode] || MODE_CESIUM_COLORS.unknown;
} }
/** Swap between the pointed and the neutral glyph when a course appears or is lost. */
function syncHeadingIcon(entry) {
if (entry.marker && entry.iconHeading !== Number.isFinite(entry.courseDeg))
applySpriteStyle(entry);
}
function addMarker(key, entry) { function addMarker(key, entry) {
const marker = state._markers.add({ const marker = state._markers.add({
id: key, id: key,
@@ -324,6 +335,7 @@ export function createRendering({ state, services, parts }) {
for (const entry of state._visible) { for (const entry of state._visible) {
// Idle contacts still need a course when their first usable bearing arrives. // Idle contacts still need a course when their first usable bearing arrives.
if (!state._moving.has(entry)) applyDisplayCourse(entry, monoNow); if (!state._moving.has(entry)) applyDisplayCourse(entry, monoNow);
syncHeadingIcon(entry);
if (entry.key === state._selectedKey) continue; if (entry.key === state._selectedKey) continue;
const course = entry.courseDeg; const course = entry.courseDeg;
if (!Number.isFinite(course)) { if (!Number.isFinite(course)) {
@@ -427,6 +439,7 @@ export function createRendering({ state, services, parts }) {
refreshRotations(now); refreshRotations(now);
const selected = state._vehicles.get(state._selectedKey); const selected = state._vehicles.get(state._selectedKey);
if (selected && state._visible.has(selected)) { if (selected && state._visible.has(selected)) {
syncHeadingIcon(selected);
selected.marker.rotation = Number.isFinite(selected.courseDeg) selected.marker.rotation = Number.isFinite(selected.courseDeg)
? perspectiveProjectedRotation( ? perspectiveProjectedRotation(
state._viewer.scene, state._viewer.scene,