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 = `` + `${halo}${body}`; uri = 'data:image/svg+xml;base64,' + _b64(svg); _iconCache.set(key, uri); } return uri; }