Merge branch 'main' into denis/gev-news-layer

This commit is contained in:
Mazeyar Moeini Feizabadi authored and GitHub committed 2026-11-10 22:38:56 +01:00
commit 8f489d203f
11 files changed
+287 -100

No files matched your search

+5
View File
@@ -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).
@@ -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).
+6 -3
View File
@@ -5314,8 +5314,11 @@ visual inspection.
**Appearance.** Normal fleet frames are 20 px, growing to 30 px on selection; **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. 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 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 final unpadded display size. Each mode has an outline with a front (a nose, a
raster cache at 36 variants. Normal mode colours are bus `#5EF08A`, tram 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 `#FFC24A`, subway `#FF4538`, rail `#D9A6FF`, ferry `#5FD6FF`, and unknown
`#D8DDE5`. Selection keeps the mode colour. Sensors reuse each normal silhouette's exact `#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 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) ### 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. - **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. - **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. - **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.
+1 -1
View File
@@ -801,7 +801,7 @@ try {
); );
check( check(
`${preset.name}: the icon raster cache stays bounded`, `${preset.name}: the icon raster cache stays bounded`,
styling.iconCacheSize <= 6 * 2 * 3, styling.iconCacheSize <= 6 * 2 * 3 * 2,
`${styling.iconCacheSize} rasters`, `${styling.iconCacheSize} rasters`,
); );
} }
+83 -24
View File
@@ -41,33 +41,46 @@ const DETAIL =
/** /**
* Each body is drawn in a centred frame (origin 0,0 = glyph centre), front * 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). * 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 = { const HEADING_BODIES = {
// ── Bus: short, blunt, square-shouldered. The shape everything else reads // ── Bus: short, broad, square-shouldered behind an arrow-like nose. The
// against, and the one most people will see most often. // shape everything else reads against, and the one most people will see
// most often, so it carries the boldest front.
bus: ` bus: `
<path d="M-17,-28 C-17,-32 -14,-34 0,-34 C14,-34 17,-32 17,-28 <path d="M0,-41 L17,-25 L17,28 C17,32 14,34 0,34 C-14,34 -17,32 -17,28
L17,28 C17,32 14,34 0,34 C-14,34 -17,32 -17,28 Z" L-17,-25 Z"
fill="white" ${STROKE}/> fill="white" ${STROKE}/>
<path d="M-11,-24 L11,-24" ${DETAIL}/> <path d="M-11,-21 L11,-21" ${DETAIL}/>
<path d="M-11,20 L11,20" ${DETAIL}/>`, <path d="M-11,20 L11,20" ${DETAIL}/>`,
// ── Tram / light rail: longer and slimmer than a bus, with the two // ── Tram / light rail: longer and slimmer than a bus, a short pointed cab
// articulation joints that make a modern low-floor tram unmistakable. // and the two articulation joints of a modern low-floor tram.
tram: ` tram: `
<path d="M-10,-38 C-10,-41 -8,-42.5 0,-42.5 C8,-42.5 10,-41 10,-38 <path d="M0,-44 L10,-33 L10,38 C10,41 8,42.5 0,42.5 C-8,42.5 -10,41 -10,38
L10,38 C10,41 8,42.5 0,42.5 C-8,42.5 -10,41 -10,38 Z" L-10,-33 Z"
fill="white" ${STROKE}/> fill="white" ${STROKE}/>
<path d="M-10,-13 L10,-13" ${DETAIL}/> <path d="M-10,-13 L10,-13" ${DETAIL}/>
<path d="M-10,13 L10,13" ${DETAIL}/> <path d="M-10,13 L10,13" ${DETAIL}/>
<path d="M-8,-34 L8,-34" ${DETAIL}/>`, <path d="M-8,-29 L8,-29" ${DETAIL}/>`,
// ── Metro / subway: squared off at both ends, wider than a tram, a single // ── Metro / subway: squared off and wider than a tram, the cab end
// cab line at the front. Reads as rolling stock, not a road vehicle. // chamfered to a narrow flat face; the rear stays square.
subway: ` subway: `
<path d="M-11.5,-40 L11.5,-40 L11.5,40 L-11.5,40 Z" <path d="M-4,-40 L4,-40 L11.5,-30 L11.5,40 L-11.5,40 L-11.5,-30 Z"
fill="white" ${STROKE}/> fill="white" ${STROKE}/>
<path d="M-11.5,-32 L11.5,-32" ${DETAIL}/> <path d="M-11.5,-26 L11.5,-26" ${DETAIL}/>
<path d="M-11.5,0 L11.5,0" ${DETAIL}/> <path d="M-11.5,0 L11.5,0" ${DETAIL}/>
<path d="M-11.5,32 L11.5,32" ${DETAIL}/>`, <path d="M-11.5,32 L11.5,32" ${DETAIL}/>`,
@@ -90,8 +103,49 @@ const BODIES = {
fill="white" ${STROKE}/> fill="white" ${STROKE}/>
<path d="M-7.5,-6 L7.5,-6 L7.5,20 L-7.5,20 Z" ${DETAIL}/>`, <path d="M-7.5,-6 L7.5,-6 L7.5,20 L-7.5,20 Z" ${DETAIL}/>`,
// ── 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. // unclassified vehicle never pretends to be a mode it is not.
unknown: `
<path d="M0,-38 L10,-24 L10,26 C10,30 7,32 0,32 C-7,32 -10,30 -10,26
L-10,-24 Z"
fill="white" ${STROKE}/>`,
};
const NEUTRAL_BODIES = {
bus: `
<path d="M-17,-28 C-17,-32 -14,-34 0,-34 C14,-34 17,-32 17,-28
L17,28 C17,32 14,34 0,34 C-14,34 -17,32 -17,28 Z"
fill="white" ${STROKE}/>
<path d="M-11,-20 L11,-20" ${DETAIL}/>
<path d="M-11,20 L11,20" ${DETAIL}/>`,
tram: `
<path d="M-10,-38 C-10,-41 -8,-42.5 0,-42.5 C8,-42.5 10,-41 10,-38
L10,38 C10,41 8,42.5 0,42.5 C-8,42.5 -10,41 -10,38 Z"
fill="white" ${STROKE}/>
<path d="M-10,-13 L10,-13" ${DETAIL}/>
<path d="M-10,13 L10,13" ${DETAIL}/>`,
subway: `
<path d="M-11.5,-40 L11.5,-40 L11.5,40 L-11.5,40 Z"
fill="white" ${STROKE}/>
<path d="M-11.5,-32 L11.5,-32" ${DETAIL}/>
<path d="M-11.5,0 L11.5,0" ${DETAIL}/>
<path d="M-11.5,32 L11.5,32" ${DETAIL}/>`,
rail: `
<path d="M-10.5,-40 C-10.5,-43 -8.5,-44.5 0,-44.5 C8.5,-44.5 10.5,-43 10.5,-40
L10.5,40 C10.5,43 8.5,44.5 0,44.5 C-8.5,44.5 -10.5,43 -10.5,40 Z"
fill="white" ${STROKE}/>
<path d="M-10.5,-6 L10.5,-6" ${DETAIL}/>
<path d="M-10.5,6 L10.5,6" ${DETAIL}/>`,
ferry: `
<path d="M-12.5,-30 C-12.5,-36 -8,-38 0,-38 C8,-38 12.5,-36 12.5,-30
L12.5,30 C12.5,36 8,38 0,38 C-8,38 -12.5,36 -12.5,30 Z"
fill="white" ${STROKE}/>
<path d="M-7.5,-13 L7.5,-13 L7.5,13 L-7.5,13 Z" ${DETAIL}/>`,
unknown: ` unknown: `
<path d="M-10,-26 C-10,-30 -7,-32 0,-32 C7,-32 10,-30 10,-26 <path d="M-10,-26 C-10,-30 -7,-32 0,-32 C7,-32 10,-30 10,-26
L10,26 C10,30 7,32 0,32 C-7,32 -10,30 -10,26 Z" L10,26 C10,30 7,32 0,32 C-7,32 -10,30 -10,26 Z"
@@ -99,7 +153,7 @@ const BODIES = {
}; };
/** The modes this module can draw. */ /** The modes this module can draw. */
export const TRANSIT_ICON_KINDS = Object.freeze(Object.keys(BODIES)); export const TRANSIT_ICON_KINDS = Object.freeze(Object.keys(HEADING_BODIES));
const _iconCache = new Map(); const _iconCache = new Map();
@@ -155,8 +209,8 @@ export function transitIconCacheSize() {
} }
/** /**
* Data URI for one mode's silhouette, lazily built and cached per kind, size * Data URI for one mode's silhouette, lazily built and cached per kind, size,
* and halo. * 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 * 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 * the shipped white fill with its hairline edge on top, so the ring sits
@@ -166,11 +220,14 @@ export function transitIconCacheSize() {
* *
* @param {string} kind One of TRANSIT_ICON_KINDS; anything else draws `unknown`. * @param {string} kind One of TRANSIT_ICON_KINDS; anything else draws `unknown`.
* @param {number} [px] Raster size. * @param {number} [px] Raster size.
* @param {{haloScreenPx?: number, screenPx?: number}} [options] * @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,...` * @returns {string} `data:image/svg+xml;base64,...`
*/ */
export function transitIcon(kind, px = FLEET_RASTER_PX, options = {}) { export function transitIcon(kind, px = FLEET_RASTER_PX, options = {}) {
const k = BODIES[kind] ? kind : 'unknown'; const k = HEADING_BODIES[kind] ? kind : 'unknown';
const selected = px > FLEET_RASTER_PX; const selected = px > FLEET_RASTER_PX;
px = selected ? SELECTED_ICON_PX : FLEET_RASTER_PX; px = selected ? SELECTED_ICON_PX : FLEET_RASTER_PX;
const profile = transitStyleProfile( const profile = transitStyleProfile(
@@ -185,16 +242,18 @@ export function transitIcon(kind, px = FLEET_RASTER_PX, options = {}) {
profile === 'mono' ? 'thermal' : profile === 'crt' ? 'retro' : 'normal'; profile === 'mono' ? 'thermal' : profile === 'crt' ? 'retro' : 'normal';
const displayPx = (selected ? 30 : 20) * presetSpriteScale(style, selected); const displayPx = (selected ? 30 : 20) * presetSpriteScale(style, selected);
const frame = haloFrame(presetSpriteOutlinePx(style, selected), displayPx); 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); let uri = _iconCache.get(key);
if (!uri) { if (!uri) {
const size = VIEW + 2 * frame.pad; const size = VIEW + 2 * frame.pad;
// Reuse the exact outer path. Sensors fill its interior without panel ink. // Reuse the exact outer path. Sensors fill its interior without panel ink.
const silhouette = BODIES[k].match(/<path d="([^"]+)"/)[1]; const silhouette = bodies[k].match(/<path d="([^"]+)"/)[1];
const body = const body =
profile === 'mono' profile === 'mono'
? `<path d="${silhouette}" fill="white" />` ? `<path d="${silhouette}" fill="white" />`
: BODIES[k]; : bodies[k];
const halo = const halo =
frame.units > 0 frame.units > 0
? profile === 'mono' ? profile === 'mono'
+128 -64
View File
@@ -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); 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 } = const { presetSpriteScale, presetSpriteOutlinePx } =
await import('./transitPresetStyle.js'); await import('./transitPresetStyle.js');
for (const kind of TRANSIT_ICON_KINDS) { for (const kind of TRANSIT_ICON_KINDS) {
for (const selected of [false, true]) { for (const selected of [false, true]) {
for (const style of ['normal', 'thermal', 'retro']) { for (const style of ['normal', 'thermal', 'retro'])
const display = for (const heading of [true, false]) {
(selected ? 30 : 20) * presetSpriteScale(style, selected); const display =
const svg = decode(transitIcon(kind, selected ? 96 : 48, { style })); (selected ? 30 : 20) * presetSpriteScale(style, selected);
const units = Number( const svg = decode(
svg.match(/stroke-opacity="(?:0.95|1)" stroke-width="([\d.]+)"/)[1], transitIcon(kind, selected ? 96 : 48, { style, heading }),
); );
assert.ok( const units = Number(
Math.abs( svg.match(/stroke-opacity="(?:0.95|1)" stroke-width="([\d.]+)"/)[1],
(units * display) / 192 - presetSpriteOutlinePx(style, selected), );
) < 0.001, assert.ok(
); Math.abs(
const frame = haloFrame( (units * display) / 192 - presetSpriteOutlinePx(style, selected),
presetSpriteOutlinePx(style, selected), ) < 0.001,
display, );
); const frame = haloFrame(
assert.match( presetSpriteOutlinePx(style, selected),
svg, display,
new RegExp( );
`width="${Math.round((selected ? 96 : 48) * frame.ratio)}"`, assert.match(
), svg,
); new RegExp(
} `width="${Math.round((selected ? 96 : 48) * frame.ratio)}"`,
),
);
}
} }
} }
for (let i = 0; i < 100; i++) for (let i = 0; i < 100; i++)
transitIcon('invalid', i, { haloScreenPx: i, screenPx: 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 () => { 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) }; return { mask, aspect: (maxX - minX + 1) / (maxY - minY + 1) };
}; };
for (const selected of [false, true]) for (const selected of [false, true])
for (const kind of TRANSIT_ICON_KINDS) { for (const heading of [true, false])
const normal = await rasterBody( for (const kind of TRANSIT_ICON_KINDS) {
decode(transitIcon(kind, selected ? 96 : 48, { style: 'normal' })), const normal = await rasterBody(
); decode(
for (const style of ['surveillance', 'thermal', 'noir', 'nvg']) { transitIcon(kind, selected ? 96 : 48, { style: 'normal', heading }),
const svg = decode(transitIcon(kind, selected ? 96 : 48, { style })); ),
const mono = await rasterBody(svg);
assert.deepEqual(
mono.mask,
normal.mask,
`${kind}/${style}: no envelope`,
); );
assert.ok( for (const style of ['surveillance', 'thermal', 'noir', 'nvg']) {
Math.abs(mono.aspect / normal.aspect - 1) <= 0.05, const svg = decode(
`${kind}/${style}: aspect`, transitIcon(kind, selected ? 96 : 48, { style, heading }),
); );
const display = const mono = await rasterBody(svg);
(selected ? 30 : 20) * presetSpriteScale(style, selected); assert.deepEqual(
const crt = (selected ? 30 : 20) * presetSpriteScale('retro', selected); mono.mask,
assert.equal(display, crt); normal.mask,
const frame = haloFrame( `${kind}/${style}: no envelope`,
presetSpriteOutlinePx(style, selected), );
display, assert.ok(
); Math.abs(mono.aspect / normal.aspect - 1) <= 0.05,
const crtFrame = haloFrame( `${kind}/${style}: aspect`,
presetSpriteOutlinePx('retro', selected), );
crt, const display =
); (selected ? 30 : 20) * presetSpriteScale(style, selected);
assert.equal( const crt =
(display * frame.ratio) / frame.ratio, (selected ? 30 : 20) * presetSpriteScale('retro', selected);
(crt * crtFrame.ratio) / crtFrame.ratio, assert.equal(display, crt);
'unpadded silhouette matches CRT; only external halo padding grows', const frame = haloFrame(
); presetSpriteOutlinePx(style, selected),
assert.equal( display,
(svg.match(/<path /g) || []).length, );
2, const crtFrame = haloFrame(
'only outer halo and opaque silhouette', presetSpriteOutlinePx('retro', selected),
); crt,
assert.match(svg, /fill="white" \/>/); );
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(/<path /g) || []).length,
2,
'only outer halo and opaque silhouette',
);
assert.match(svg, /fill="white" \/>/);
}
} }
}
}); });
test('sensor raster halo stays dark across its screen-space band on a white roof', async () => { 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 })); 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(/<path d="([^"]+)"\s+fill="white"/)[1];
// Fleet-size raster of the outline alone: row widths from front to back.
const rows = async (d) => {
const { data, info } = await sharp(
Buffer.from(
`<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="-48 -48 96 96"><path d="${d}" fill="white"/></svg>`,
),
)
.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'));
});
+4 -4
View File
@@ -859,8 +859,8 @@ export function createTracking({
* - SIGNAL LOST · <time since its last report>, dimmed: its coast has ended * - SIGNAL LOST · <time since its last report>, dimmed: its coast has ended
* (or it holds at its last report after a missed update) and no newer * (or it holds at its last report after a missed update) and no newer
* report has come. * report has come.
* - STALE, dimmed: it has missed an update and still coasts. * - STALE, dimmed: it has missed an update and still coasts, or the whole
* - STALE, not dimmed: the whole feed is backing off. * feed is backing off.
* @param {string} icao24 * @param {string} icao24
* @returns {{text: string, dimmed: boolean}} * @returns {{text: string, dimmed: boolean}}
*/ */
@@ -902,8 +902,8 @@ export function createTracking({
: `${formatNumber(Math.floor(sec / 60), { useGrouping: false })} ${unitLabel('min')}`; : `${formatNumber(Math.floor(sec / 60), { useGrouping: false })} ${unitLabel('min')}`;
return { text: t('SIGNAL LOST · {time}', { time }), dimmed: true }; return { text: t('SIGNAL LOST · {time}', { time }), dimmed: true };
} }
if (missed) return { text: t('STALE'), dimmed: true }; // A missed update or a stalled feed: no newer position is coming in.
if (backoff) return { text: t('STALE'), dimmed: false }; if (missed || backoff) return { text: t('STALE'), dimmed: true };
return { text: '', dimmed: false }; return { text: '', dimmed: false };
} }
+20
View File
@@ -791,6 +791,26 @@ test('a repeated old fix counts as a missed update for the tracked plane', () =>
} }
}); });
test('a stalled feed dims the tracked plane as STALE', () => {
const { fixes, info } = outOfCoverage();
const h = harness({ fixes, info, nowMs: pastReport(-5) });
try {
h.tracking._startTrail(ICAO);
h.run(pastReport(-2));
// The whole feed is backing off: no poll counts a miss, and no newer
// position is coming in.
h.flightState.feed._backoff = true;
assert.deepEqual(h.tracking._trackedStaleCue(ICAO), {
text: 'STALE',
dimmed: true,
});
assert.equal(h.tracking._trackedIconColor().alpha, STALE_ALPHA);
assert.equal(h.flightState.records.missingPolls.has(ICAO), false);
} finally {
h.restore();
}
});
test('a report arriving after the plane has stopped: it eases in with no spur, and the trail keeps everything up to the stop', () => { test('a report arriving after the plane has stopped: it eases in with no spur, and the trail keeps everything up to the stop', () => {
const { fixes, info } = outOfCoverage(); const { fixes, info } = outOfCoverage();
info.lastContactEpochMs = fixes.at(-1).epochMs; info.lastContactEpochMs = fixes.at(-1).epochMs;
+4 -4
View File
@@ -230,8 +230,8 @@ export function createTracking({
* - SIGNAL LOST · <time since its last report>, dimmed: its coast has ended * - SIGNAL LOST · <time since its last report>, dimmed: its coast has ended
* (or it holds at its last report after a missed update) and no newer * (or it holds at its last report after a missed update) and no newer
* report has come. * report has come.
* - STALE, dimmed: it has missed an update and still coasts. * - STALE, dimmed: it has missed an update and still coasts, or the whole
* - STALE, not dimmed: the whole feed is backing off. * feed is backing off.
* @param {string} icao24 * @param {string} icao24
* @returns {{text: string, dimmed: boolean}} * @returns {{text: string, dimmed: boolean}}
*/ */
@@ -273,8 +273,8 @@ export function createTracking({
: `${formatNumber(Math.floor(sec / 60), { useGrouping: false })} ${unitLabel('min')}`; : `${formatNumber(Math.floor(sec / 60), { useGrouping: false })} ${unitLabel('min')}`;
return { text: t('SIGNAL LOST · {time}', { time }), dimmed: true }; return { text: t('SIGNAL LOST · {time}', { time }), dimmed: true };
} }
if (missed) return { text: t('STALE'), dimmed: true }; // A missed update or a stalled feed: no newer position is coming in.
if (backoff) return { text: t('STALE'), dimmed: false }; if (missed || backoff) return { text: t('STALE'), dimmed: true };
return { text: '', dimmed: false }; return { text: '', dimmed: false };
} }
+20
View File
@@ -642,6 +642,26 @@ test('military: a repeated old fix counts as a missed update for the tracked pla
} }
}); });
test('military: a stalled feed dims the tracked plane as STALE', () => {
const { fixes, info } = outOfCoverage();
const h = harness({ fixes, info, nowMs: pastReport(-5) });
try {
h.tracking._startTrail(ICAO);
h.run(pastReport(-2));
// The whole feed is backing off: no poll counts a miss, and no newer
// position is coming in.
h.flightState.feed._backoff = true;
assert.deepEqual(h.tracking._trackedStaleCue(ICAO), {
text: 'STALE',
dimmed: true,
});
assert.equal(h.tracking._trackedIconColor().alpha, STALE_ALPHA);
assert.equal(h.flightState.records.missingPolls.has(ICAO), false);
} finally {
h.restore();
}
});
test('military: a report arriving after the plane has stopped: it eases in with no spur, and the trail keeps everything up to the stop', () => { test('military: a report arriving after the plane has stopped: it eases in with no spur, and the trail keeps everything up to the stop', () => {
const { fixes, info } = outOfCoverage(); const { fixes, info } = outOfCoverage();
info.lastContactEpochMs = fixes.at(-1).epochMs; info.lastContactEpochMs = fixes.at(-1).epochMs;
+13
View File
@@ -232,11 +232,16 @@ export function createRendering({ state, services, parts }) {
const displayPx = basePx * presetSpriteScale(style, selected); const displayPx = basePx * presetSpriteScale(style, selected);
const frame = haloFrame(halo, displayPx); const frame = haloFrame(halo, displayPx);
const px = displayPx * frame.ratio; const px = displayPx * frame.ratio;
// A glyph with a front only when there is a course to point it along:
// without one the billboard sits at rotation 0 and a nose would say north.
const heading = Number.isFinite(entry.courseDeg);
entry.iconHeading = heading;
marker.image = transitIcon( marker.image = transitIcon(
entry.mode, entry.mode,
selected ? SELECTED_ICON_PX : undefined, selected ? SELECTED_ICON_PX : undefined,
{ {
style, style,
heading,
}, },
); );
marker.width = px; marker.width = px;
@@ -252,6 +257,12 @@ export function createRendering({ state, services, parts }) {
MODE_CESIUM_COLORS[entry.mode] || MODE_CESIUM_COLORS.unknown; MODE_CESIUM_COLORS[entry.mode] || MODE_CESIUM_COLORS.unknown;
} }
/** Swap between the pointed and the neutral glyph when a course appears or is lost. */
function syncHeadingIcon(entry) {
if (entry.marker && entry.iconHeading !== Number.isFinite(entry.courseDeg))
applySpriteStyle(entry);
}
function addMarker(key, entry) { function addMarker(key, entry) {
const marker = state._markers.add({ const marker = state._markers.add({
id: key, id: key,
@@ -324,6 +335,7 @@ export function createRendering({ state, services, parts }) {
for (const entry of state._visible) { for (const entry of state._visible) {
// Idle contacts still need a course when their first usable bearing arrives. // Idle contacts still need a course when their first usable bearing arrives.
if (!state._moving.has(entry)) applyDisplayCourse(entry, monoNow); if (!state._moving.has(entry)) applyDisplayCourse(entry, monoNow);
syncHeadingIcon(entry);
if (entry.key === state._selectedKey) continue; if (entry.key === state._selectedKey) continue;
const course = entry.courseDeg; const course = entry.courseDeg;
if (!Number.isFinite(course)) { if (!Number.isFinite(course)) {
@@ -427,6 +439,7 @@ export function createRendering({ state, services, parts }) {
refreshRotations(Date.now()); refreshRotations(Date.now());
const selected = state._vehicles.get(state._selectedKey); const selected = state._vehicles.get(state._selectedKey);
if (selected && state._visible.has(selected)) { if (selected && state._visible.has(selected)) {
syncHeadingIcon(selected);
selected.marker.rotation = Number.isFinite(selected.courseDeg) selected.marker.rotation = Number.isFinite(selected.courseDeg)
? perspectiveProjectedRotation( ? perspectiveProjectedRotation(
state._viewer.scene, state._viewer.scene,