Merge pull request #1574 from IMBrandao/feat/transit-icon-heading

feat(transit): give vehicle icons a readable front, and none without a course
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-11-10 12:36:09 -08:00
commit c87510fd2e
6 files changed
+235 -91

No files matched your search

+5
View File
@@ -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).
+5 -2
View File
@@ -5312,8 +5312,11 @@ visual inspection.
**Appearance.** Normal fleet frames are 20 px, growing to 30 px on selection; **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. 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 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 final unpadded display size. Each mode has an outline with a front (a nose, a
raster cache at 36 variants. Normal mode colours are bus `#5EF08A`, tram 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 `#FFC24A`, subway `#FF4538`, rail `#D9A6FF`, ferry `#5FD6FF`, and unknown
`#D8DDE5`. Selection keeps the mode colour. Sensors reuse each normal silhouette's exact `#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 outer path, filled opaque white without interior panel lines, with an opaque
+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(Date.now()); refreshRotations(Date.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,