import {
presetSpriteScale,
presetSpriteOutlinePx,
transitStyleProfile,
} from './transitPresetStyle.js';
// src/data/transitIcons.js
/**
* Front-up transit-vehicle silhouettes, one per mode, as SVG data URIs for
* Cesium billboards.
*
* Same house language as `aircraftIcons.js`: a 96-unit viewBox with the glyph
* centred at (48,48), drawn nose toward -Y so the screen-projected rotation in
* `iconOrientation.js` can spin the whole glyph, filled WHITE with a dark
* hairline edge so the tint pipeline (billboard.color = the mode colour, plus
* `.withAlpha` fades) keeps working. No glyph carries a colour of its own; a
* hardcoded fill would fight the tint that tells a person what they are looking
* at.
*
* These are plan views, the way a vehicle reads from a camera above a street —
* not side elevations. Each mode has a DISTINCT outline so it is recognisable
* at the ~18 px the fleet renders at, by shape and not only by colour: a bus is
* short and blunt, a tram is long and slender with articulation joints, a metro
* car is squared off, a mainline train has a tapered nose, and a ferry is a
* hull with a bow.
*
* Interior detail is drawn with the same dark hairline as the edge — windscreen
* lines, articulation joints, a deck line — because a solid dark panel would
* darken the tint and a white-on-white panel would vanish.
*/
const VIEW = 96;
const C = VIEW / 2; // 48 — glyph centre
/** Hairline dark edge, matching the aircraft glyph weight at this box size. */
const STROKE =
'stroke="rgba(0,0,0,0.34)" stroke-width="1.5" stroke-linejoin="round"';
/** Interior panel lines: the same ink, thinner, capped so joints read cleanly. */
const DETAIL =
'stroke="rgba(0,0,0,0.30)" stroke-width="1.5" stroke-linecap="round" fill="none"';
/**
* 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 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, a short pointed cab
// and the two articulation joints of a modern low-floor tram.
tram: `
`,
// ── Metro / subway: squared off and wider than a tram, the cab end
// chamfered to a narrow flat face; the rear stays square.
subway: `
`,
// ── Mainline / commuter rail: the tapered nose of an intercity set, longer
// than anything else in the set.
rail: `
`,
// ── Ferry: a hull. Pointed bow, square stern, superstructure amidships —
// the only glyph in the set that is not a box on rails or rubber.
ferry: `
`,
// ── 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: `
`,
};
/** The modes this module can draw. */
export const TRANSIT_ICON_KINDS = Object.freeze(Object.keys(HEADING_BODIES));
const _iconCache = new Map();
const _b64 = (s) =>
typeof btoa === 'function'
? btoa(s)
: Buffer.from(s, 'utf8').toString('base64');
/**
* Fleet raster size. Same reasoning as the aircraft fleet glyphs: Cesium's
* billboard atlas has no mipmaps, so a texture far larger than its on-screen
* footprint is GPU-minified into mush. Transit billboards render around 14–20
* CSS px, so 48 px of source covers the band on a Retina display with very
* little minification.
*/
const FLEET_RASTER_PX = 48;
/** The selected vehicle draws bigger and deserves a crisper source. */
export const SELECTED_ICON_PX = 96;
/**
* On-screen size the fleet glyph is authored for. A halo width asked for in
* screen pixels is converted to viewBox units against this, so "two pixels of
* ring" means two pixels on an eighteen-pixel billboard.
*/
export const FLEET_SCREEN_PX = 20;
/**
* How a halo changes the glyph's frame. A thick outer stroke would clip at
* the 96-unit box (the tram already reaches 42.5 of 48), so the viewBox is
* padded by the stroke and the billboard has to grow by the same ratio to
* keep the body its shipped size on screen.
*
* @param {number} haloScreenPx Ring width in final screen pixels (0 = none).
* @param {number} [screenPx] Billboard size the ring is specified against.
* @returns {{units: number, pad: number, ratio: number}} Stroke width in
* viewBox units (the visible ring is half of it, outside the body), the
* padding added on every side, and the frame growth factor to apply to the
* billboard's width and height.
*/
export function haloFrame(haloScreenPx, screenPx = FLEET_SCREEN_PX) {
const ring = Number.isFinite(haloScreenPx) ? Math.max(0, haloScreenPx) : 0;
if (ring === 0) return { units: 0, pad: 0, ratio: 1 };
// Half the stroke lies outside the body, so a ring of R px needs 2R of
// stroke. One screen pixel is VIEW/screenPx units.
const units = (2 * ring * VIEW) / Math.max(1, screenPx);
const pad = Math.ceil(units / 2) + 1;
return { units, pad, ratio: (VIEW + 2 * pad) / VIEW };
}
/** Number of raster variants ever built — a bound a test can hold the cache to. */
export function transitIconCacheSize() {
return _iconCache.size;
}
/**
* Data URI for one mode's silhouette, lazily built and cached per kind, size,
* 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
* the shipped white fill with its hairline edge on top, so the ring sits
* OUTSIDE the silhouette and the interior detail is untouched. The dark ring
* is what survives NVG's auto-gain and FLIR's luma mapping when the bright
* core alone would merge with a bright road.
*
* @param {string} kind One of TRANSIT_ICON_KINDS; anything else draws `unknown`.
* @param {number} [px] Raster size.
* @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,...`
*/
export function transitIcon(kind, px = FLEET_RASTER_PX, options = {}) {
const k = HEADING_BODIES[kind] ? kind : 'unknown';
const selected = px > FLEET_RASTER_PX;
px = selected ? SELECTED_ICON_PX : FLEET_RASTER_PX;
const profile = transitStyleProfile(
options.style ||
(options.haloScreenPx >= 2
? 'thermal'
: options.haloScreenPx === 1.25
? 'retro'
: 'normal'),
);
const style =
profile === 'mono' ? 'thermal' : profile === 'crt' ? 'retro' : 'normal';
const displayPx = (selected ? 30 : 20) * presetSpriteScale(style, selected);
const frame = haloFrame(presetSpriteOutlinePx(style, selected), displayPx);
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];
const halo =
frame.units > 0
? profile === 'mono'
? ``
: body.replace(
//,
(_m, d) =>
``,
)
: '';
const svg =
``;
uri = 'data:image/svg+xml;base64,' + _b64(svg);
_iconCache.set(key, uri);
}
return uri;
}