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).
*/
const BODIES = {
// ── Bus: short, blunt, square-shouldered. The shape everything else reads
// against, and the one most people will see most often.
bus: `
`,
// ── Tram / light rail: longer and slimmer than a bus, with the two
// articulation joints that make a modern low-floor tram unmistakable.
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.
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 rounded marker. Deliberately featureless, so an
// unclassified vehicle never pretends to be a mode it is not.
unknown: `
`,
};
/** The modes this module can draw. */
export const TRANSIT_ICON_KINDS = Object.freeze(Object.keys(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
* and halo.
*
* 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}} [options]
* @returns {string} `data:image/svg+xml;base64,...`
*/
export function transitIcon(kind, px = FLEET_RASTER_PX, options = {}) {
const k = 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 key = `${k}:${selected ? 1 : 0}:${profile}`;
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;
}