diff --git a/changelog.d/1574-transit-icon-heading.md b/changelog.d/1574-transit-icon-heading.md
new file mode 100644
index 000000000..9639e8aa0
--- /dev/null
+++ b/changelog.d/1574-transit-icon-heading.md
@@ -0,0 +1,5 @@
+- Give transit vehicle icons a readable front: the bus, tram, metro and unknown
+ glyphs now carry a nose or cab in their outline, so the direction of travel
+ shows at fleet size and survives the sensor profiles. A vehicle with no known
+ course gets a symmetric outline instead of one that points north, and
+ switches to the pointed glyph once a course arrives (IMBrandao, #1574).
diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md
index 1f92e1cf7..9df7cf081 100644
--- a/docs/CURRENT-STATE.md
+++ b/docs/CURRENT-STATE.md
@@ -5312,8 +5312,11 @@ visual inspection.
**Appearance.** Normal fleet frames are 20 px, growing to 30 px on selection;
CRT, NVG, thermal and noir frames are 26/39 px. Source rasters are 48/96 px before padding.
External halos are 1/1.5 px in normal, 1.25/1.25 px in CRT and 2/2 px in sensors, calculated from the
-final unpadded display size. Six modes, two sizes and three profiles cap the
-raster cache at 36 variants. Normal mode colours are bus `#5EF08A`, tram
+final unpadded display size. Each mode has an outline with a front (a nose, a
+bow or a chamfered cab) for a vehicle with a known course and a symmetric
+outline for one without, which would otherwise sit at rotation 0 and point
+north; a vehicle switches to the pointed glyph when a course arrives. Six modes, two sizes, three
+profiles and the two outlines cap the raster cache at 72 variants. Normal mode colours are bus `#5EF08A`, tram
`#FFC24A`, subway `#FF4538`, rail `#D9A6FF`, ferry `#5FD6FF`, and unknown
`#D8DDE5`. Selection keeps the mode colour. Sensors reuse each normal silhouette's exact
outer path, filled opaque white without interior panel lines, with an opaque
diff --git a/scripts/qa-transit.mjs b/scripts/qa-transit.mjs
index 49a25fd18..32bfb596a 100644
--- a/scripts/qa-transit.mjs
+++ b/scripts/qa-transit.mjs
@@ -801,7 +801,7 @@ try {
);
check(
`${preset.name}: the icon raster cache stays bounded`,
- styling.iconCacheSize <= 6 * 2 * 3,
+ styling.iconCacheSize <= 6 * 2 * 3 * 2,
`${styling.iconCacheSize} rasters`,
);
}
diff --git a/src/data/transitIcons.js b/src/data/transitIcons.js
index ec480530d..c6ad963e1 100644
--- a/src/data/transitIcons.js
+++ b/src/data/transitIcons.js
@@ -41,33 +41,46 @@ const DETAIL =
/**
* 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).
+ *
+ * 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 = {
- // ── Bus: short, blunt, square-shouldered. The shape everything else reads
- // against, and the one most people will see most often.
+const HEADING_BODIES = {
+ // ── Bus: short, broad, square-shouldered behind an arrow-like nose. The
+ // shape everything else reads against, and the one most people will see
+ // most often, so it carries the boldest front.
bus: `
-
-
+ `,
- // ── Tram / light rail: longer and slimmer than a bus, with the two
- // articulation joints that make a modern low-floor tram unmistakable.
+ // ── Tram / light rail: longer and slimmer than a bus, a short pointed cab
+ // and the two articulation joints of a modern low-floor tram.
tram: `
-
- `,
+ `,
- // ── Metro / subway: squared off at both ends, wider than a tram, a single
- // cab line at the front. Reads as rolling stock, not a road vehicle.
+ // ── Metro / subway: squared off and wider than a tram, the cab end
+ // chamfered to a narrow flat face; the rear stays square.
subway: `
-
-
+ `,
@@ -90,8 +103,49 @@ const BODIES = {
fill="white" ${STROKE}/>
`,
- // ── 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.
+ unknown: `
+ `,
+};
+
+const NEUTRAL_BODIES = {
+ bus: `
+
+
+ `,
+
+ tram: `
+
+
+ `,
+
+ subway: `
+
+
+
+ `,
+
+ rail: `
+
+
+ `,
+
+ ferry: `
+
+ `,
+
unknown: `
FLEET_RASTER_PX;
px = selected ? SELECTED_ICON_PX : FLEET_RASTER_PX;
const profile = transitStyleProfile(
@@ -185,16 +242,18 @@ export function transitIcon(kind, px = FLEET_RASTER_PX, options = {}) {
profile === 'mono' ? 'thermal' : profile === 'crt' ? 'retro' : 'normal';
const displayPx = (selected ? 30 : 20) * presetSpriteScale(style, selected);
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);
if (!uri) {
const size = VIEW + 2 * frame.pad;
// Reuse the exact outer path. Sensors fill its interior without panel ink.
- const silhouette = BODIES[k].match(/`
- : BODIES[k];
+ : bodies[k];
const halo =
frame.units > 0
? profile === 'mono'
diff --git a/src/data/transitIcons.test.mjs b/src/data/transitIcons.test.mjs
index 8f2571017..758b1d04b 100644
--- a/src/data/transitIcons.test.mjs
+++ b/src/data/transitIcons.test.mjs
@@ -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);
});
-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 } =
await import('./transitPresetStyle.js');
for (const kind of TRANSIT_ICON_KINDS) {
for (const selected of [false, true]) {
- for (const style of ['normal', 'thermal', 'retro']) {
- const display =
- (selected ? 30 : 20) * presetSpriteScale(style, selected);
- const svg = decode(transitIcon(kind, selected ? 96 : 48, { style }));
- const units = Number(
- svg.match(/stroke-opacity="(?:0.95|1)" stroke-width="([\d.]+)"/)[1],
- );
- assert.ok(
- Math.abs(
- (units * display) / 192 - presetSpriteOutlinePx(style, selected),
- ) < 0.001,
- );
- const frame = haloFrame(
- presetSpriteOutlinePx(style, selected),
- display,
- );
- assert.match(
- svg,
- new RegExp(
- `width="${Math.round((selected ? 96 : 48) * frame.ratio)}"`,
- ),
- );
- }
+ for (const style of ['normal', 'thermal', 'retro'])
+ for (const heading of [true, false]) {
+ const display =
+ (selected ? 30 : 20) * presetSpriteScale(style, selected);
+ const svg = decode(
+ transitIcon(kind, selected ? 96 : 48, { style, heading }),
+ );
+ const units = Number(
+ svg.match(/stroke-opacity="(?:0.95|1)" stroke-width="([\d.]+)"/)[1],
+ );
+ assert.ok(
+ Math.abs(
+ (units * display) / 192 - presetSpriteOutlinePx(style, selected),
+ ) < 0.001,
+ );
+ const frame = haloFrame(
+ presetSpriteOutlinePx(style, selected),
+ display,
+ );
+ assert.match(
+ svg,
+ new RegExp(
+ `width="${Math.round((selected ? 96 : 48) * frame.ratio)}"`,
+ ),
+ );
+ }
}
}
for (let i = 0; i < 100; 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 () => {
@@ -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) };
};
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`,
+ for (const heading of [true, false])
+ for (const kind of TRANSIT_ICON_KINDS) {
+ const normal = await rasterBody(
+ decode(
+ transitIcon(kind, selected ? 96 : 48, { style: 'normal', heading }),
+ ),
);
- 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(//);
+ for (const style of ['surveillance', 'thermal', 'noir', 'nvg']) {
+ const svg = decode(
+ transitIcon(kind, selected ? 96 : 48, { style, heading }),
+ );
+ 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(//);
+ }
}
- }
});
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 }));
}
});
+
+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(/ {
+ const { data, info } = await sharp(
+ Buffer.from(
+ ``,
+ ),
+ )
+ .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'));
+});
diff --git a/src/layers/transit/rendering.js b/src/layers/transit/rendering.js
index f3868444a..664b1688d 100644
--- a/src/layers/transit/rendering.js
+++ b/src/layers/transit/rendering.js
@@ -232,11 +232,16 @@ export function createRendering({ state, services, parts }) {
const displayPx = basePx * presetSpriteScale(style, selected);
const frame = haloFrame(halo, displayPx);
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(
entry.mode,
selected ? SELECTED_ICON_PX : undefined,
{
style,
+ heading,
},
);
marker.width = px;
@@ -252,6 +257,12 @@ export function createRendering({ state, services, parts }) {
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) {
const marker = state._markers.add({
id: key,
@@ -324,6 +335,7 @@ export function createRendering({ state, services, parts }) {
for (const entry of state._visible) {
// Idle contacts still need a course when their first usable bearing arrives.
if (!state._moving.has(entry)) applyDisplayCourse(entry, monoNow);
+ syncHeadingIcon(entry);
if (entry.key === state._selectedKey) continue;
const course = entry.courseDeg;
if (!Number.isFinite(course)) {
@@ -427,6 +439,7 @@ export function createRendering({ state, services, parts }) {
refreshRotations(Date.now());
const selected = state._vehicles.get(state._selectedKey);
if (selected && state._visible.has(selected)) {
+ syncHeadingIcon(selected);
selected.marker.rotation = Number.isFinite(selected.courseDeg)
? perspectiveProjectedRotation(
state._viewer.scene,