diff --git a/changelog.d/1574-transit-icon-heading.md b/changelog.d/1574-transit-icon-heading.md new file mode 100644 index 000000000..9639e8aa0 --- /dev/null +++ b/changelog.d/1574-transit-icon-heading.md @@ -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). diff --git a/changelog.d/1656-stalled-feed-dims-tracked.md b/changelog.d/1656-stalled-feed-dims-tracked.md new file mode 100644 index 000000000..dcb595981 --- /dev/null +++ b/changelog.d/1656-stalled-feed-dims-tracked.md @@ -0,0 +1,3 @@ +- A tracked aircraft is dimmed as STALE while the whole flight feed is + stalled, as it is after a missed update, instead of staying at full + brightness (Sameh Khamis, @samehkhamis, #1656). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 5a248bc85..198ef0901 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5314,8 +5314,11 @@ visual inspection. **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. 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 -raster cache at 36 variants. Normal mode colours are bus `#5EF08A`, tram +final unpadded display size. Each mode has an outline with a front (a nose, a +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 `#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 @@ -5792,7 +5795,7 @@ silently demoting every later lookup for the session. ### Motion & Symbology Correctness (June 10, 2026) -- **Flights (commercial + military)** render one poll interval behind real time (30s/15s) and interpolate between two known feed-stamped fixes (OpenSky `time_position`; adsb.lol `receipt − seen_pos`). The display latency is an intentional product decision — do not "fix" it away. The fleet dead-reckons at ~12Hz (1m² write gating); a contact already hidden behind the horizon whose last position still is re-reckons on a staggered one-in-13-ticks rotation (about once a second) instead, so the worldwide feed's ~13k contacts no longer cost a full pass when well under 1% are on screen. Anything that position puts in view, and any contact with a fleet model, takes the full pass immediately, and hidden-inclusive proximity queries read positions at most about a second old; aircraft get a 3-poll grace period (faded icon) before removal, except the tracked aircraft, which is kept until 10 minutes after its newest fix. When a position epoch pauses, both layers coast for five minutes on the last speed and heading (at most 1,500 kt), then stop dimmed as SIGNAL LOST; contacts on or near known ground hold instead. Source backoff marks each contact and the cockpit `STALE`; the cockpit then holds the exact layer position instead of continuing inertial flight. Repeated-position kinematic changes create a forward-only synthetic fix rather than mutating history, and grounded history is lifted only when no owned 3D model already controls its datum. A nominally successful worldwide OpenSky response whose own snapshot epoch is more than two minutes old prefers the existing 250-nm viewport-scoped adsb.lol fallback and labels the source/coverage accordingly when that upstream is available. If the fallback is also unavailable, the layer's freshness/error fields use the source epoch—never the cache receipt time—so the UI reports an old snapshot rather than “just now.” +- **Flights (commercial + military)** render one poll interval behind real time (30s/15s) and interpolate between two known feed-stamped fixes (OpenSky `time_position`; adsb.lol `receipt − seen_pos`). The display latency is an intentional product decision — do not "fix" it away. The fleet dead-reckons at ~12Hz (1m² write gating); a contact already hidden behind the horizon whose last position still is re-reckons on a staggered one-in-13-ticks rotation (about once a second) instead, so the worldwide feed's ~13k contacts no longer cost a full pass when well under 1% are on screen. Anything that position puts in view, and any contact with a fleet model, takes the full pass immediately, and hidden-inclusive proximity queries read positions at most about a second old; aircraft get a 3-poll grace period (faded icon) before removal, except the tracked aircraft, which is kept until 10 minutes after its newest fix. When a position epoch pauses, both layers coast for five minutes on the last speed and heading (at most 1,500 kt), then stop dimmed as SIGNAL LOST; contacts on or near known ground hold instead. Source backoff marks each contact and the cockpit `STALE` and dims the tracked aircraft, as a missed update does; the cockpit then holds the exact layer position instead of continuing inertial flight. Repeated-position kinematic changes create a forward-only synthetic fix rather than mutating history, and grounded history is lifted only when no owned 3D model already controls its datum. A nominally successful worldwide OpenSky response whose own snapshot epoch is more than two minutes old prefers the existing 250-nm viewport-scoped adsb.lol fallback and labels the source/coverage accordingly when that upstream is available. If the fallback is also unavailable, the layer's freshness/error fields use the source epoch—never the cache receipt time—so the UI reports an old snapshot rather than “just now.” - **World-space headings at every angle** (`src/data/iconOrientation.js`): aircraft/vessel icon rotation uses the camera right/up basis per tick (alignedAxis always ZERO), which is exact at screen center and for orthographic/nadir views and remains stable through >180° tracked orbits. Perspective rays vary across the viewport, so off-center contacts at oblique pitch can diverge from an exact finite-difference window projection; a regression test pins that known regime, and field evidence decides whether to adopt exact projection with the basis method as fallback. Fleet rotations refresh on camera-pose change; tracked entities per frame. Billboards are horizon-culled via a shared EllipsoidalOccluder. - **Military/OpenSky reconciliation** (`src/data/militaryRegistry.js`): known-military ICAOs render amber in the flights layer (60s self-poll of the cached mil endpoint when the military layer is off) and are suppressed there while the military layer renders them. - **Satellites**: core catalog from CelesTrak's stations, visual, GPS, GLONASS, Galileo, GEO, resource, weather, planet, SAR and science groups, tracking lands ~726km out via `viewFrom` (tracked entity owns a point graphic so the tracking camera engages), rings realign via primitive modelMatrix (no per-second rebuild flicker), optional `setParams({catalog:'dense'})` Starlink mode. diff --git a/scripts/qa-transit.mjs b/scripts/qa-transit.mjs index 49a25fd18..32bfb596a 100644 --- a/scripts/qa-transit.mjs +++ b/scripts/qa-transit.mjs @@ -801,7 +801,7 @@ try { ); check( `${preset.name}: the icon raster cache stays bounded`, - styling.iconCacheSize <= 6 * 2 * 3, + styling.iconCacheSize <= 6 * 2 * 3 * 2, `${styling.iconCacheSize} rasters`, ); } diff --git a/src/data/transitIcons.js b/src/data/transitIcons.js index ec480530d..c6ad963e1 100644 --- a/src/data/transitIcons.js +++ b/src/data/transitIcons.js @@ -41,33 +41,46 @@ const DETAIL = /** * 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 BODIES = { - // ── Bus: short, blunt, square-shouldered. The shape everything else reads - // against, and the one most people will see most often. +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, with the two - // articulation joints that make a modern low-floor tram unmistakable. + // ── 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 at both ends, wider than a tram, a single - // cab line at the front. Reads as rolling stock, not a road vehicle. + // ── Metro / subway: squared off and wider than a tram, the cab end + // chamfered to a narrow flat face; the rear stays square. subway: ` - - + `, @@ -90,8 +103,49 @@ const BODIES = { fill="white" ${STROKE}/> `, - // ── 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. + unknown: ` + `, +}; + +const NEUTRAL_BODIES = { + bus: ` + + + `, + + tram: ` + + + `, + + subway: ` + + + + `, + + rail: ` + + + `, + + ferry: ` + + `, + unknown: ` FLEET_RASTER_PX; px = selected ? SELECTED_ICON_PX : FLEET_RASTER_PX; const profile = transitStyleProfile( @@ -185,16 +242,18 @@ export function transitIcon(kind, px = FLEET_RASTER_PX, options = {}) { 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}`; + 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]; + : bodies[k]; const halo = frame.units > 0 ? profile === 'mono' diff --git a/src/data/transitIcons.test.mjs b/src/data/transitIcons.test.mjs index 8f2571017..758b1d04b 100644 --- a/src/data/transitIcons.test.mjs +++ b/src/data/transitIcons.test.mjs @@ -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); }); -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 } = await import('./transitPresetStyle.js'); for (const kind of TRANSIT_ICON_KINDS) { for (const selected of [false, true]) { - for (const style of ['normal', 'thermal', 'retro']) { - const display = - (selected ? 30 : 20) * presetSpriteScale(style, selected); - const svg = decode(transitIcon(kind, selected ? 96 : 48, { style })); - const units = Number( - svg.match(/stroke-opacity="(?:0.95|1)" stroke-width="([\d.]+)"/)[1], - ); - assert.ok( - Math.abs( - (units * display) / 192 - presetSpriteOutlinePx(style, selected), - ) < 0.001, - ); - const frame = haloFrame( - presetSpriteOutlinePx(style, selected), - display, - ); - assert.match( - svg, - new RegExp( - `width="${Math.round((selected ? 96 : 48) * frame.ratio)}"`, - ), - ); - } + for (const style of ['normal', 'thermal', 'retro']) + for (const heading of [true, false]) { + const display = + (selected ? 30 : 20) * presetSpriteScale(style, selected); + const svg = decode( + transitIcon(kind, selected ? 96 : 48, { style, heading }), + ); + const units = Number( + svg.match(/stroke-opacity="(?:0.95|1)" stroke-width="([\d.]+)"/)[1], + ); + assert.ok( + Math.abs( + (units * display) / 192 - presetSpriteOutlinePx(style, selected), + ) < 0.001, + ); + const frame = haloFrame( + presetSpriteOutlinePx(style, selected), + display, + ); + assert.match( + svg, + new RegExp( + `width="${Math.round((selected ? 96 : 48) * frame.ratio)}"`, + ), + ); + } } } for (let i = 0; i < 100; 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 () => { @@ -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) }; }; for (const selected of [false, true]) - for (const kind of TRANSIT_ICON_KINDS) { - const normal = await rasterBody( - decode(transitIcon(kind, selected ? 96 : 48, { style: 'normal' })), - ); - for (const style of ['surveillance', 'thermal', 'noir', 'nvg']) { - const svg = decode(transitIcon(kind, selected ? 96 : 48, { style })); - const mono = await rasterBody(svg); - assert.deepEqual( - mono.mask, - normal.mask, - `${kind}/${style}: no envelope`, + for (const heading of [true, false]) + for (const kind of TRANSIT_ICON_KINDS) { + const normal = await rasterBody( + decode( + transitIcon(kind, selected ? 96 : 48, { style: 'normal', heading }), + ), ); - assert.ok( - Math.abs(mono.aspect / normal.aspect - 1) <= 0.05, - `${kind}/${style}: aspect`, - ); - const display = - (selected ? 30 : 20) * presetSpriteScale(style, selected); - const crt = (selected ? 30 : 20) * presetSpriteScale('retro', selected); - assert.equal(display, crt); - const frame = haloFrame( - presetSpriteOutlinePx(style, selected), - display, - ); - const crtFrame = haloFrame( - presetSpriteOutlinePx('retro', selected), - crt, - ); - 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(//); + for (const style of ['surveillance', 'thermal', 'noir', 'nvg']) { + const svg = decode( + transitIcon(kind, selected ? 96 : 48, { style, heading }), + ); + const mono = await rasterBody(svg); + assert.deepEqual( + mono.mask, + normal.mask, + `${kind}/${style}: no envelope`, + ); + assert.ok( + Math.abs(mono.aspect / normal.aspect - 1) <= 0.05, + `${kind}/${style}: aspect`, + ); + const display = + (selected ? 30 : 20) * presetSpriteScale(style, selected); + const crt = + (selected ? 30 : 20) * presetSpriteScale('retro', selected); + assert.equal(display, crt); + const frame = haloFrame( + presetSpriteOutlinePx(style, selected), + display, + ); + const crtFrame = haloFrame( + presetSpriteOutlinePx('retro', selected), + crt, + ); + 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(//); + } } - } }); 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 })); } }); + +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(/ { + const { data, info } = await sharp( + Buffer.from( + ``, + ), + ) + .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')); +}); diff --git a/src/layers/flights/tracking.js b/src/layers/flights/tracking.js index cbc1b66ad..a4faa8329 100644 --- a/src/layers/flights/tracking.js +++ b/src/layers/flights/tracking.js @@ -859,8 +859,8 @@ export function createTracking({ * - SIGNAL LOST ·