Files
gods-eye-view/src/data/aircraftIcons.js
T
Sameh Khamis 2c4c66c710 Draw civil flights from an icon set moved on the GPU
Every flight is now a row in the layer's icon set. Its motion segment
follows the same regime `_deadReckon` picks (interpolate between fixes,
warm up backward from the oldest, coast forward from the newest), and
one shared `_motionRegime` makes that choice for both. The shader moves
every untracked contact between polls, so the 12 Hz fleet tick no
longer touches each billboard. It now only re-plans expired rows,
floors grounded contacts, advances focus while needed, and runs the 3D
model regime.

Grounded contacts the floor lifts are pinned to a hold at the floored
position. Their nose glides on the GPU. Contacts within 50 km re-floor
every tick, and farther ones take turns about once a second. Low
contacts are judged for the horizon at no lower than 12 m above the
ellipsoid, as before. Below hover speed a contact's nose holds. A row
whose fleet model is drawn keeps its focus updated, because the model
reads it. Untracking re-plans and re-floors the icon from the tracked
course.

On the same views with about 6,700 OpenSky flights, frame gaps at p95
drop from 28-40 ms to 17 ms, and the fleet tick from 16-37 ms to about
2 ms at most.
2026-11-03 19:40:39 -08:00

277 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// src/data/aircraftIcons.js
/**
* Nose-up white aircraft silhouettes, one per classifyAircraft() kind, as SVG
* data URIs for Cesium billboards.
*
* FIDELITY NOTE (2026-07-02): redrawn at a 96×96 viewBox (glyph centered at
* 48,48) instead of the old 32×32. The billboards themselves are still added at
* width/height 20 (24 tracked) in flights.js / militaryFlights.js, so Cesium
* DOWN-samples this larger, higher-precision source to the same on-screen
* footprint — crisp anti-aliased edges instead of an upscaled 32px bitmap. The
* on-screen size is unchanged and CLASS_SCALE_2D is untouched (no scale
* compensation needed: the billboard's explicit width/height, not the SVG's,
* sets the pixel size — the intrinsic SVG size only controls raster fidelity).
*
* All glyphs are nose-up (nose toward -Y at rotation 0; the screen-projected
* rotation pipeline in iconOrientation.js spins the whole glyph). Fill is white
* with a subtle dark hairline stroke so the tint pipeline (billboard.color =
* white / cyan-tracked / amber-military, plus .withAlpha fades) keeps working —
* no per-glyph hardcoded colors that would fight the tint.
*
* MIXED-SET UPDATE (2026-08-15, owner Hangar picks): airliner/widebody/
* turboprop/helicopter use the "refined" recognition-chart redraw;
* quadjet/glider use the "bold" chart-symbol redraw; light/fastjet keep the
* original drawings. Raster fidelity doubled (192px source, same 96 coords).
*
* Silhouette language follows skylight's type-aware glyphs (MIT,
* https://github.com/cpaczek/skylight): slender fuselages, swept wings for
* jets, long high-aspect wings for the glider, straight wings + prop discs for
* props, a rotor disc + tail boom + tail-rotor for the helicopter, and a delta
* for the fast jet. Each class has a DISTINCT planform so it reads at ~20px by
* shape, not just by footprint size.
*/
const VIEW = 96;
const C = VIEW / 2; // 48 — glyph centre
// Hairline dark edge (scaled for the 96 box: ~1.5u ≈ the old 0.5u at 32).
const STROKE =
'stroke="rgba(0,0,0,0.32)" stroke-width="1.4" stroke-linejoin="round"';
// Heavier edge for the chart-symbol ("bold") glyphs adopted 2026-08-15.
const STROKE_BOLD =
'stroke="rgba(0,0,0,0.38)" stroke-width="2" stroke-linejoin="round"';
// Softer white for translucent detail (prop discs, rotor disc) — still white so
// the tint multiplies cleanly; only the alpha differs.
const DISC = 'fill="white" fill-opacity="0.5"';
// Each body is drawn in a centred coordinate frame (origin 0,0 = glyph centre),
// nose toward -Y. Numbers are in the 96-unit space (half-extent up to ~45).
const BODIES = {
// ── Airliner: slender swept-wing narrow-body, two underwing engine pods,
// swept tailplane. The canonical jet; everything else reads against it.
airliner: `
<path d="M0,-42 C 3.8,-40 4.6,-34 4.6,-26 L 4.6,-14
L 32,4 L 34,6 L 34,10 L 31.4,9.2 L 4.6,2.4
L 4.2,20
L 14,28 L 14,32 L 0,28.6 L -14,32 L -14,28 L -4.2,20
L -4.6,2.4 L -31.4,9.2 L -34,10 L -34,6 L -32,4 L -4.6,-14
L -4.6,-26 C -4.6,-34 -3.8,-40 0,-42 Z" fill="white" ${STROKE}/>
<path d="M-15.5,-1.5 l3,7.6 4,-1.4 -1.5,-8.4 Z" fill="white"/>
<path d="M15.5,-1.5 l-3,7.6 -4,-1.4 1.5,-8.4 Z" fill="white"/>
<path d="M-1.6,33.5 L 1.6,33.5 L 1.6,40 L -1.6,40 Z" fill="white"/>`,
// ── Widebody: same swept jet but noticeably BIGGER span + fatter fuselage,
// twin engines set further out. Reads as a heavier airliner.
widebody: `
<path d="M0,-45 C 5.6,-43 6.8,-36 6.8,-27 L 6.8,-12
L 38,9 L 41.5,12.4 L 41.5,16.6 L 37.6,15 L 6.8,6
L 6.3,21
L 17,30 L 17,34.6 L 0,30.4 L -17,34.6 L -17,30 L -6.3,21
L -6.8,6 L -37.6,15 L -41.5,16.6 L -41.5,12.4 L -38,9 L -6.8,-12
L -6.8,-27 C -6.8,-36 -5.6,-43 0,-45 Z" fill="white" ${STROKE}/>
<path d="M-19,2 l3.6,9 4.8,-1.7 -1.8,-10 Z" fill="white"/>
<path d="M19,2 l-3.6,9 -4.8,-1.7 1.8,-10 Z" fill="white"/>
<path d="M-2,35.5 L 2,35.5 L 2,42.5 L -2,42.5 Z" fill="white"/>`,
// ── Quadjet: the widest swept jet, with FOUR bold underwing engine pods (two
// per wing) that hang well below the trailing edge — the wing reads as a
// 4-lumped bar even when the individual pods blur together at ~20px.
quadjet: `
<path d="M0,-45 C 7,-42 9,-34 9,-25 L 9,-11
L 46,12 L 46,21 L 9,11.5
L 8.4,21 L 20,31 L 20,37.5 L 0,32 L -20,37.5 L -20,31 L -8.4,21
L -9,11.5 L -46,21 L -46,12 L -9,-11
L -9,-25 C -9,-34 -7,-42 0,-45 Z" fill="white" ${STROKE_BOLD}/>
<rect x="-31" y="9" width="7" height="12" rx="2" fill="white"/>
<rect x="-17" y="4.5" width="7" height="12" rx="2" fill="white"/>
<rect x="10" y="4.5" width="7" height="12" rx="2" fill="white"/>
<rect x="24" y="9" width="7" height="12" rx="2" fill="white"/>`,
// ── Turboprop: bold STRAIGHT (unswept) wings — thicker chord than a jet — two
// fat nacelles that jut ahead of the leading edge, each capped by a solid
// prop disc. Reads as "straight wing with two lumps + discs" at ~20px.
turboprop: `
<path d="M0,-40 C 3.4,-38.5 4.2,-33 4.2,-26 L 4.2,-18
L 36,-15.5 L 36,-7.5 L 4.2,-8
L 3.8,22
L 13,27.5 L 13,31.5 L 0,28.6 L -13,31.5 L -13,27.5 L -3.8,22
L -4.2,-8 L -36,-7.5 L -36,-15.5 L -4.2,-18
L -4.2,-26 C -4.2,-33 -3.4,-38.5 0,-40 Z" fill="white" ${STROKE}/>
<circle cx="-17.5" cy="-16.5" r="7.5" fill="white" fill-opacity="0.5"/>
<circle cx="17.5" cy="-16.5" r="7.5" fill="white" fill-opacity="0.5"/>
<path d="M-19.5,-19 h4 v5 h-4 Z" fill="white"/>
<path d="M15.5,-19 h4 v5 h-4 Z" fill="white"/>
<path d="M-1.7,31.5 L 1.7,31.5 L 1.7,38.5 L -1.7,38.5 Z" fill="white"/>`,
// ── Light GA: small and CHUNKY — short, DEEP-chord straight wings and a fat
// stubby fuselage make it read as a solid little block (not a thin cross),
// with a bold nose prop disc. The short deep wing is the anti-glider cue.
light: `
<path d="M0,-27
C 4,-25 5.2,-20 5.2,-13
L 5.2,-9
L 27,-9 L 27,6 L 5.2,6
L 5.2,16
L 11.5,23 L 11.5,27 L 0,23.5 L -11.5,27 L -11.5,23 L -5.2,16
L -5.2,6
L -27,6 L -27,-9 L -5.2,-9
L -5.2,-13
C -5.2,-20 -4,-25 0,-27 Z" fill="white" ${STROKE}/>
<ellipse cx="0" cy="-29" rx="12" ry="3.8" ${DISC}/>`,
// ── Glider: VERY long, high-aspect TAPERED wings (fat root → thin tips) on a
// slim fuselage with a T-tail. The extreme span is the whole identity — it
// reads as "the wide skinny one" against light's short stubby wings.
glider: `
<path d="M0,-35 C 2.4,-33 3,-29 3,-25 L 3,-14
L 45,-10.5 L 45,-3.5 L 2.9,-6
L 2.2,32 L -2.2,32 L -2.9,-6
L -45,-3.5 L -45,-10.5 L -3,-14
L -3,-25 C -3,-29 -2.4,-33 0,-35 Z" fill="white" ${STROKE_BOLD}/>
<rect x="-10.5" y="32.5" width="21" height="5" rx="1.5" fill="white" ${STROKE_BOLD}/>`,
// ── Helicopter: a SOLID translucent main-rotor disc (survives downscale as an
// obvious circle, unlike a thin ring) with a bold two-blade cross, a
// teardrop cabin, a tail boom, and a tail rotor. Unmistakable "disc on top"
// silhouette that never reads as a fixed-wing.
helicopter: `
<circle cx="0" cy="-6" r="31" fill="white" fill-opacity="0.22"/>
<g transform="rotate(45 0 -6)">
<rect x="-30.5" y="-8.2" width="61" height="4.4" rx="2.2" fill="white" fill-opacity="0.9"/>
<rect x="-30.5" y="-8.2" width="61" height="4.4" rx="2.2" fill="white" fill-opacity="0.9" transform="rotate(90 0 -6)"/>
</g>
<path d="M0,-22 C 8,-20 10.5,-13 10.5,-6 C 10.5,2 7.5,7 0,8.5
C -7.5,7 -10.5,2 -10.5,-6 C -10.5,-13 -8,-20 0,-22 Z" fill="white" ${STROKE}/>
<path d="M-2.6,8 L 2.6,8 L 1.8,32 L -1.8,32 Z" fill="white" ${STROKE}/>
<path d="M-8,27 L 8,27 L 8,30.6 L -8,30.6 Z" fill="white"/>
<circle cx="5.6" cy="35" r="6" fill="white" fill-opacity="0.6"/>
<circle cx="5.6" cy="35" r="2.1" fill="white"/>`,
// ── Fast jet: sharp delta/cropped-delta with a pointed nose, LERX root
// blend, and twin tail fins. Aggressive, all-wing.
fastjet: `
<path d="M0,-43
L 3.5,-30
C 4,-24 4.6,-16 5,-8
L 27,20 L 27,26 L 6,16
L 8,30 L 8,34 L 3,31
L 3,38 L 6.5,42 L 6.5,44 L 0,41.5
L -6.5,44 L -6.5,42 L -3,38
L -3,31 L -8,34 L -8,30 L -6,16
L -27,26 L -27,20 L -5,-8
C -4.6,-16 -4,-24 -3.5,-30 Z" fill="white" ${STROKE}/>`,
// 2026-08-15 Hangar additions: two NEW classes shipped with the real-model
// fleet (CLASS_MODEL_REAL). Same contract as the set above: 96×96 nose-up,
// white fill, hairline stroke, tint-safe.
// Business jet — slender fuselage, modest swept wing, aft engines, T-tail.
bizjet: `
<path d="M0,-42
L 2.6,-36 L 3.4,-26 L 3.4,-8
L 27,8 L 27,13 L 3.6,6
L 3.6,16
L 8,18 L 8,26 L 3.8,25
L 3.2,30 L 15,34 L 15,38 L 2.4,36
L 0,40
L -2.4,36 L -15,38 L -15,34 L -3.2,30
L -3.8,25 L -8,26 L -8,18 L -3.6,16
L -3.6,6 L -27,13 L -27,8 L -3.4,-8
L -3.4,-26 L -2.6,-36 Z" fill="white" ${STROKE}/>`,
// ── TR-3B (hidden 9th kind, Easter egg — NOT a classifyAircraft() output).
// The canonical black-triangle silhouette: a dark isosceles delta with a
// light at each corner and a dimmer one at the centre. Reached only by
// explicit user conversion (see tr3bRegistry.js), never by classification.
//
// DELIBERATE BREAK from the white-fill contract above: this glyph is a
// SHADOW, so it carries its own near-black fill. The billboard tint still
// multiplies (white fleet / cyan tracked / amber military), which darkens
// rather than lightens — the triangle stays a silhouette in every layer,
// and the corner lights pick the tint up as their hue.
tr3b: `
<path d="M0,-38 L 40,30 L -40,30 Z"
fill="#0d1014" stroke="rgba(158,184,210,0.34)" stroke-width="1.6"
stroke-linejoin="round"/>
<circle cx="0" cy="-24" r="8.5" fill="#c9dcf0" fill-opacity="0.13"/>
<circle cx="-28" cy="21" r="8.5" fill="#c9dcf0" fill-opacity="0.13"/>
<circle cx="28" cy="21" r="8.5" fill="#c9dcf0" fill-opacity="0.13"/>
<circle cx="0" cy="-24" r="4.6" fill="#dceaf8" fill-opacity="0.52"/>
<circle cx="-28" cy="21" r="4.6" fill="#dceaf8" fill-opacity="0.52"/>
<circle cx="28" cy="21" r="4.6" fill="#dceaf8" fill-opacity="0.52"/>
<circle cx="0" cy="6" r="3.4" fill="#dceaf8" fill-opacity="0.26"/>`,
// ── TR-3B, thermal-reactive variant. Same cold airframe, but the three
// corner emitters + the centre one render HOT: near-white cores inside a
// baked radial glow, so the FLIR/NVG luminance mapping (and any bloom)
// lights them up while the hull stays cold. Selected by the layers
// whenever their `irBoost` style param is on (surveillance/thermal/nvg).
tr3bHot: `
<defs>
<radialGradient id="tr3bGlow">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.92"/>
<stop offset="32%" stop-color="#f2f9ff" stop-opacity="0.44"/>
<stop offset="100%" stop-color="#dcefff" stop-opacity="0"/>
</radialGradient>
</defs>
<path d="M0,-38 L 40,30 L -40,30 Z"
fill="#0b0e12" stroke="rgba(126,148,172,0.3)" stroke-width="1.6"
stroke-linejoin="round"/>
<circle cx="0" cy="-24" r="15" fill="url(#tr3bGlow)"/>
<circle cx="-28" cy="21" r="15" fill="url(#tr3bGlow)"/>
<circle cx="28" cy="21" r="15" fill="url(#tr3bGlow)"/>
<circle cx="0" cy="6" r="10" fill="url(#tr3bGlow)"/>
<circle cx="0" cy="-24" r="5.2" fill="#ffffff"/>
<circle cx="-28" cy="21" r="5.2" fill="#ffffff"/>
<circle cx="28" cy="21" r="5.2" fill="#ffffff"/>
<circle cx="0" cy="6" r="3.6" fill="#ffffff" fill-opacity="0.82"/>`,
// Large UAV (Reaper-class) — bulbous sensor nose, very long slender
// straight wings, slim tail boom, canted V-tail.
uav: `
<path d="M0,-40
C 3.6,-40 4.6,-35 4.4,-30
L 2.4,-12
L 43,-7 L 43,-2.5 L 2.3,0
L 2.1,24
L 13,32 L 13,36 L 1.6,30
L 0,38
L -1.6,30 L -13,36 L -13,32 L -2.1,24
L -2.3,0 L -43,-2.5 L -43,-7 L -2.4,-12
L -4.4,-30 C -4.6,-35 -3.6,-40 0,-40 Z" fill="white" ${STROKE}/>`,
};
const _iconCache = new Map();
const _b64 = (s) =>
typeof btoa === 'function'
? btoa(s)
: Buffer.from(s, 'utf8').toString('base64');
/** Fleet raster: billboards render at ~40–58 DEVICE px (width 20–24 CSS ×
* Retina × class scale). Cesium's billboard atlas has no mipmaps, so a big
* texture gets GPU-minified into mush (192 px ÷ 40 = 4.8× — the owner's
* "soft" glyphs; the pre-2026-08-15 96 px raster aliased instead). Rastering
* NEAR the display size lets the browser's SVG AA do the work: crisp AND
* smooth. 64 covers the 40–58 px fleet band with ≤1.6× minification. */
const FLEET_RASTER_PX = 64;
/** Raster size of the default fleet glyph. */
export const FLEET_ICON_PX = FLEET_RASTER_PX;
/** Tracked raster: the tracked billboard is the one SUSTAINED large 2D glyph
* (close-zoom fleet flybys hand off to 3D models). Keep the 192 px texture so
* it stays crisp at its biggest on-screen sizes. */
const TRACKED_RASTER_PX = 192;
/** Data URI for a class silhouette (lazily built, cached per kind+size).
* Default size serves the fleet; pass `aircraftIcon(kind, TRACKED_ICON_PX)`
* (re-exported below) for the tracked billboard. */
export const TRACKED_ICON_PX = TRACKED_RASTER_PX;
export function aircraftIcon(kind, px = FLEET_RASTER_PX) {
const k = BODIES[kind] ? kind : 'airliner';
const key = `${k}@${px}`;
let uri = _iconCache.get(key);
if (!uri) {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${px}" height="${px}" viewBox="0 0 ${VIEW} ${VIEW}"><g transform="translate(${C},${C})">${BODIES[k]}</g></svg>`;
uri = 'data:image/svg+xml;base64,' + _b64(svg);
_iconCache.set(key, uri);
}
return uri;
}