mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-12 06:12:18 +00:00
Merge branch 'main' into denis/gev-news-layer
This commit is contained in:
11 files changed
+287
-100
No files matched your search
@@ -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).
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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'));
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in new issue
Block a user