merge main 17daf897 into feat/voice-outlines

This commit is contained in:
Sameh Khamis committed 2026-11-02 09:06:21 -08:00
commit c059e12a11
107 files changed
+1598 -167

No files matched your search

+2 -3
View File
@@ -17,6 +17,7 @@ import {
phaseTiming,
recordWorkSlice,
} from '../sources/phaseTiming.js';
import { formatDistanceText } from '../data/distanceFormat.js';
// Dev convenience: expose the app's Cesium instance for console/preview probing
// (single shared module instance — avoids dual-Cesium state bugs when testing).
@@ -2177,9 +2178,7 @@ function greatCircleM(a, b) {
}
function formatDistance(m) {
if (!Number.isFinite(m)) return null;
if (m >= 1000) return `${(m / 1000).toFixed(m >= 10000 ? 0 : 1)} km`;
return `${Math.round(m / 10) * 10} m`;
return formatDistanceText(m, { metreStep: 10 });
}
function composeRouteLabel(baseLabel, distM, durS, mode, fallback) {
+15 -1
View File
@@ -1230,7 +1230,6 @@ test('only an explicit navigation request permits resolving distant annotation t
engine.destroy();
});
test('recaption inherits outline credit from borrowed geometry rather than its new anchor', async (t) => {
installAnimationFrameStubs(t);
const { renderer } = fakeRenderer();
@@ -1302,3 +1301,18 @@ test('plain recaption keeps the old mark addressable when its removal throws', a
);
assert.equal(engine.count(), 0);
});
test('a drawn line that rounds up to the next unit is labelled in it, not as 1000 m or 10.0 km', async (t) => {
installAnimationFrameStubs(t);
const labels = [];
const renderer = { add(anno) { labels.push(anno.label); }, update() {}, remove() {}, sync() {}, destroy() {} };
const engine = createAnnotationEngine({ viewer: {}, renderer });
const metresPerDegree = (6371.0088 * 1000 * Math.PI) / 180;
for (const metres of [994, 996, 9940, 9960]) {
await engine.annotate([
{ type: 'route', manual: true, path: [[0, 0], [0, metres / metresPerDegree]] },
]);
}
assert.deepEqual(labels, ['990 m', '1.0 km', '9.9 km', '10 km']);
engine.destroy();
});
+2 -5
View File
@@ -21,6 +21,7 @@ import { t, tn } from '../i18n/index.js';
*/
import { greatCircleMeters } from '../geoDistance.js';
import { formatDistanceText } from '../data/distanceFormat.js';
export const DRAW_SHAPES = Object.freeze(['area', 'line', 'pin']);
export const MIN_VERTICES = Object.freeze({ area: 3, line: 2, pin: 1 });
@@ -207,11 +208,7 @@ export function ringCentroid(vertices) {
export function formatMeasure(session) {
if (!session) return '';
if (session.shape === 'line') {
const m = pathLengthM(session.vertices);
// Round before the kilometre cut. 999.6 m was printed as "1000 m".
const whole = Math.round(m);
if (whole < 1000) return `${whole} m`;
return `${(m / 1000).toFixed(m >= 10000 ? 0 : 1)} km`;
return formatDistanceText(pathLengthM(session.vertices)) ?? '';
}
if (session.shape === 'area') {
const m2 = ringAreaM2(session.vertices);
+8
View File
@@ -346,6 +346,14 @@ test('a line that rounds up to a kilometre reads 1.0 km, not 1000 m', () => {
assert.match(drawHint(line), /^1\.0 km · /);
});
test('a line that rounds to 10.0 km reads in whole kilometres', () => {
const metresPerDegree = (6371.0088 * 1000 * Math.PI) / 180;
const line = createDrawSession('line');
addVertex(line, { lon: 0, lat: 0 });
addVertex(line, { lon: 0, lat: 9960 / metresPerDegree });
assert.equal(formatMeasure(line), '10 km');
});
test('an area that rounds up to the next unit reads in that unit', () => {
// A square on the equator whose shoelace area is about `m2`.
const square = (m2) => {
+7 -7
View File
@@ -1,10 +1,10 @@
export { resolveHudRailLayout } from './ui/panelRailGeometry.js';
import {
normalizeHeading,
roundHeadingDegrees,
} from './data/headingDegrees.js';
/** Normalize a heading into the [0, 360) range. */
export function normalizeHeading(value) {
if (!Number.isFinite(value)) return 0;
return ((value % 360) + 360) % 360;
}
export { resolveHudRailLayout } from './ui/panelRailGeometry.js';
export { normalizeHeading };
/**
* Three-digit heading readout, 000–359.
@@ -12,7 +12,7 @@ export function normalizeHeading(value) {
*/
export function formatHeadingReadout(degrees) {
if (!Number.isFinite(degrees)) return '---';
return String(Math.round(normalizeHeading(degrees)) % 360).padStart(3, '0');
return String(roundHeadingDegrees(degrees)).padStart(3, '0');
}
/** Advance a displayed heading along the shortest arc without exceeding a slew rate. */
+1
View File
@@ -161,6 +161,7 @@ test('a heading that rounds to north reads 000, not 360', () => {
assert.equal(formatHeadingReadout(359.4), '359');
assert.equal(formatHeadingReadout(0), '000');
assert.equal(formatHeadingReadout(10.2), '010');
assert.equal(formatHeadingReadout(-0.6), '359');
assert.equal(formatHeadingReadout(Number.NaN), '---');
});
+3 -2
View File
@@ -1,3 +1,5 @@
import { readAdsbVerticalRateFpm } from '../sources/adsbFields.js';
const KNOT_TO_MPS = 0.514444;
const FOOT_TO_M = 0.3048;
const FPM_TO_MPS = 0.00508;
@@ -61,8 +63,7 @@ export function normalizeAdsbLolAircraftState(aircraft, nowSeconds) {
const barometricFeet = onGround ? null : finiteNumber(aircraft?.alt_baro);
const geometricFeet = finiteNumber(aircraft?.alt_geom);
const groundSpeedKnots = finiteNumber(aircraft?.gs);
const verticalRateFpm =
finiteNumber(aircraft?.baro_rate) ?? finiteNumber(aircraft?.geom_rate);
const verticalRateFpm = readAdsbVerticalRateFpm(aircraft);
const track = finiteNumber(aircraft?.track);
return [
+2
View File
@@ -849,6 +849,8 @@ test('route numbers follow the page language and never break from their unit', (
assert.equal(routeDistanceText(994), '990\u00a0m');
assert.equal(routeDistanceText(996), '1.0\u00a0km');
assert.equal(routeDistanceText(1500), '1.5\u00a0km');
assert.equal(routeDistanceText(9940), '9.9\u00a0km');
assert.equal(routeDistanceText(9960), '10\u00a0km');
assert.equal(routeDistanceText(2_345_000), '2345\u00a0km');
assert.equal(routeDurationText(39), '39\u00a0s');
assert.equal(routeDurationText(59.4), '59\u00a0s');
+43
View File
@@ -0,0 +1,43 @@
/**
* Kilometre decimals for a distance: one below 10 km, none from there on.
* The cut is taken on the tenth that prints, so 9.96 km reads 10 km, not 10.0.
* @param {number} meters
* @returns {0|1}
*/
export function kilometreDigits(meters) {
return Math.round(meters / 100) < 100 ? 1 : 0;
}
/**
* The unit and precision a distance prints with: metres below 1 km, then
* kilometres with `kilometreDigits`. Metres round to `metreStep` (10 for tens),
* except below `exactBelowM`, where they stay whole. Each cut is taken after
* rounding, so 999.6 m reads 1.0 km rather than 1000 m.
* @param {number} meters
* @param {{metreStep?: number, exactBelowM?: number}} [options]
* @returns {{value: number, unit: 'm'|'km', digits: 0|1}|null} null for a
* distance that is negative or not finite. The value is already rounded to
* `digits`, so toFixed and Intl render it the same way.
*/
export function distanceParts(meters, { metreStep = 1, exactBelowM = 0 } = {}) {
if (!Number.isFinite(meters) || meters < 0) return null;
const step = meters < exactBelowM ? 1 : metreStep;
const shownMeters = Math.round(meters / step) * step;
if (shownMeters < 1000) return { value: shownMeters, unit: 'm', digits: 0 };
const digits = kilometreDigits(meters);
const value =
digits === 1 ? Math.round(meters / 100) / 10 : Math.round(meters / 1000);
return { value, unit: 'km', digits };
}
/**
* Plain `850 m` / `4.6 km` / `12 km` text for labels that are not localized.
* @param {number} meters
* @param {{metreStep?: number, exactBelowM?: number}} [options]
* @returns {string|null}
*/
export function formatDistanceText(meters, options) {
const parts = distanceParts(meters, options);
if (!parts) return null;
return `${parts.value.toFixed(parts.digits)} ${parts.unit}`;
}
+50
View File
@@ -0,0 +1,50 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
distanceParts,
formatDistanceText,
kilometreDigits,
} from './distanceFormat.js';
test('a distance switches to kilometres where its metres round to 1,000', () => {
assert.equal(formatDistanceText(999.4), '999 m');
assert.equal(formatDistanceText(999.5), '1.0 km');
assert.equal(formatDistanceText(999.6), '1.0 km');
assert.equal(formatDistanceText(994, { metreStep: 10 }), '990 m');
assert.equal(formatDistanceText(995, { metreStep: 10 }), '1.0 km');
});
test('kilometres drop their decimal where the tenth rounds to 10.0', () => {
assert.equal(kilometreDigits(9949), 1);
assert.equal(kilometreDigits(9950), 0);
assert.equal(formatDistanceText(9949), '9.9 km');
assert.equal(formatDistanceText(9950), '10 km');
assert.equal(formatDistanceText(9960), '10 km');
assert.equal(formatDistanceText(99950), '100 km');
});
test('metres below exactBelowM stay whole while the rest round to the step', () => {
const options = { metreStep: 10, exactBelowM: 100 };
assert.equal(formatDistanceText(8, options), '8 m');
assert.equal(formatDistanceText(99.4, options), '99 m');
assert.equal(formatDistanceText(846, options), '850 m');
assert.deepEqual(distanceParts(846, options), {
value: 850,
unit: 'm',
digits: 0,
});
});
test('a negative or non-finite distance has no parts', () => {
for (const value of [-1, Number.NaN, Infinity, -Infinity, null, undefined]) {
assert.equal(distanceParts(value), null);
assert.equal(formatDistanceText(value), null);
}
assert.equal(formatDistanceText(0), '0 m');
});
test('kilometres come back rounded, so toFixed and Intl agree', () => {
assert.deepEqual(distanceParts(1450), { value: 1.5, unit: 'km', digits: 1 });
assert.equal(formatDistanceText(1450), '1.5 km');
assert.equal(formatDistanceText(1150), '1.2 km');
});
+19
View File
@@ -0,0 +1,19 @@
/** Normalize a heading into the [0, 360) range; 0 when it is not finite. */
export function normalizeHeading(value) {
if (!Number.isFinite(value)) return 0;
// Only a negative remainder takes the extra turn, so a positive bearing
// keeps its precision (0.49999999999999 must not become 360.5).
const remainder = value % 360;
const wrapped = remainder < 0 ? remainder + 360 : remainder;
return wrapped === 360 ? 0 : wrapped;
}
/**
* Whole degrees for display, 0–359. 359.5° and above rounds to north (0),
* and negative or multi-turn values wrap. Null when the value is not finite,
* so each caller keeps its own placeholder.
*/
export function roundHeadingDegrees(value) {
if (!Number.isFinite(value)) return null;
return Math.round(normalizeHeading(value)) % 360;
}
+47
View File
@@ -0,0 +1,47 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { normalizeHeading, roundHeadingDegrees } from './headingDegrees.js';
test('normalizeHeading wraps into [0, 360) and reads a non-finite value as 0', () => {
assert.equal(normalizeHeading(370), 10);
assert.equal(normalizeHeading(-10), 350);
assert.equal(normalizeHeading(720), 0);
assert.equal(normalizeHeading(Number.NaN), 0);
assert.equal(normalizeHeading(Infinity), 0);
});
test('roundHeadingDegrees reads 359.5° and above as north, never 360', () => {
assert.equal(roundHeadingDegrees(359.4), 359);
assert.equal(roundHeadingDegrees(359.5), 0);
assert.equal(roundHeadingDegrees(359.6), 0);
assert.equal(roundHeadingDegrees(360), 0);
assert.equal(roundHeadingDegrees(0), 0);
assert.equal(roundHeadingDegrees(194.2), 194);
});
test('roundHeadingDegrees wraps negative and multi-turn bearings', () => {
assert.equal(Object.is(roundHeadingDegrees(-0.4), 0), true);
assert.equal(roundHeadingDegrees(-0.6), 359);
assert.equal(roundHeadingDegrees(-1), 359);
assert.equal(roundHeadingDegrees(719.6), 0);
assert.equal(roundHeadingDegrees(-360.6), 359);
});
test('roundHeadingDegrees returns null for a value that is not finite', () => {
for (const value of [
Number.NaN,
Infinity,
-Infinity,
null,
undefined,
'12',
]) {
assert.equal(roundHeadingDegrees(value), null);
}
});
test('normalizeHeading keeps a positive bearing just under a half degree', () => {
assert.equal(roundHeadingDegrees(0.49999999999999), 0);
assert.equal(roundHeadingDegrees(359.49999999999994), 359);
assert.equal(normalizeHeading(-1e-15) < 360, true);
});
+3
View File
@@ -79,6 +79,9 @@ test('Contacts moves a distance that rounds to 1,000 m into kilometres', () => {
assert.equal(presentAwarenessDistance(999.4), '999\u00a0m');
assert.equal(presentAwarenessDistance(999.6), '1.0\u00a0km');
assert.equal(presentAwarenessDistance(4600), '4.6\u00a0km');
assert.equal(presentAwarenessDistance(9949), '9.9\u00a0km');
assert.equal(presentAwarenessDistance(9950), '10\u00a0km');
assert.equal(presentAwarenessDistance(9960), '10\u00a0km');
});
function installAwarenessRuntime({
+2 -4
View File
@@ -2,6 +2,7 @@
import { msg, t } from '../i18n/index.js';
import { UNNAMED_PLACE_NAMES } from './installationNames.js';
import { formatDistanceText } from './distanceFormat.js';
const UNNAMED_NAMES = new Set(UNNAMED_PLACE_NAMES);
@@ -46,10 +47,7 @@ export function findByDoublingRadius(
/** @param {number} meters @returns {string} */
export function formatAwarenessDistance(meters) {
if (!Number.isFinite(meters) || meters < 0) return '—';
return meters < 1000
? `${Math.round(meters)} m`
: `${(meters / 1000).toFixed(meters < 10000 ? 1 : 0)} km`;
return formatDistanceText(meters) ?? '—';
}
/**
@@ -25,6 +25,15 @@ test('awareness formatters preserve valid zeroes and replace missing or invalid
assert.equal(formatAwarenessLabel(undefined), '—');
});
test('a contact marker moves a distance that rounds to 1,000 m into kilometres', () => {
assert.equal(formatAwarenessDistance(999.4), '999 m');
assert.equal(formatAwarenessDistance(999.6), '1.0 km');
assert.equal(formatAwarenessDistance(4600), '4.6 km');
assert.equal(formatAwarenessDistance(9949), '9.9 km');
assert.equal(formatAwarenessDistance(9950), '10 km');
assert.equal(formatAwarenessDistance(9960), '10 km');
});
test('awareness cohorts sort nearest-first and do not turn empty data into out of range', () => {
const result = summarizeAwarenessCohort([{ id: 'far', distance: 9000 }, { id: 'near', distanceM: 1000 }]);
assert.equal(result.relationship, 'NEARBY');
+12
View File
@@ -445,6 +445,18 @@ test('holds replay at the pad for a real-time countdown independent of replay sp
assert.ok(liftoff.phaseProgress > 0);
});
test('runs the five-second tile hold before a full T-10 countdown', () => {
const launch = { launchTime: '2026-07-20T10:00:00Z', timeline: [] };
// REPLAY ASCENT puts liftoff 5 s (tile hold) + 10 s (countdown) ahead.
const hold = replayState(launch, 115000, 12, 28, 5400, 1, 104500, 5);
assert.equal(hold.preCountdownActive, true);
assert.equal(hold.countdownActive, false);
const countdown = replayState(launch, 115000, 12, 28, 5400, 1, 105000, 5);
assert.equal(countdown.preCountdownActive, false);
assert.equal(countdown.countdownActive, true);
assert.equal(countdown.countdownSeconds, 10);
});
test('hands replay directly from ascent into orbit', () => {
const launch = { launchTime: '2026-07-20T10:00:00Z', timeline: [] };
const orbit = replayState(launch, 0, 12, 28, 5400, 1, 16_000);
+3 -5
View File
@@ -13,6 +13,8 @@
* roundabout turn, exit roundabout, exit rotary, notification, use lane.
*/
import { formatDistanceText } from './distanceFormat.js';
/** Cap on steps returned for one route — a 2,500 km drive rarely needs more. */
export const ROUTE_STEPS_MAX = 200;
@@ -216,11 +218,7 @@ export function normalizeOsrmSteps(route) {
* @returns {string}
*/
export function formatRouteDistance(meters) {
if (!Number.isFinite(meters) || meters < 0) return '';
if (meters < 100) return `${Math.round(meters)} m`;
if (meters < 1000) return `${Math.round(meters / 10) * 10} m`;
const km = meters / 1000;
return `${km < 10 ? km.toFixed(1) : Math.round(km)} km`;
return formatDistanceText(meters, { metreStep: 10, exactBelowM: 100 }) ?? '';
}
/**
+2
View File
@@ -236,6 +236,8 @@ test('distance and duration formatting', () => {
assert.equal(formatRouteDistance(8), '8 m');
assert.equal(formatRouteDistance(846), '850 m');
assert.equal(formatRouteDistance(1234), '1.2 km');
assert.equal(formatRouteDistance(996), '1.0 km');
assert.equal(formatRouteDistance(9960), '10 km');
assert.equal(formatRouteDistance(21479), '21 km');
assert.equal(formatRouteDistance(-1), '');
assert.equal(formatRouteDuration(40), '40 s');
@@ -1754,3 +1754,252 @@ test('another GeoJSON source renders with its own label and rebuilds from accept
env.layer.destroy(env.viewer);
}
});
function multipartCableFixture() {
return [
{
type: 'Feature',
properties: {
id: 'alpha',
name: 'Alpha cable',
color: '#ff0000',
coordinates: [-40, 35],
},
geometry: {
type: 'MultiLineString',
coordinates: [
[
[-40, 35],
[-39, 36],
],
[
[-38, 37],
[-37, 38],
],
[
[-36, 39],
[-35, 40],
],
],
},
},
{
type: 'Feature',
properties: {
id: 'beta',
name: 'Beta cable',
color: '#0000ff',
coordinates: [140, -35],
},
geometry: {
type: 'LineString',
coordinates: [
[140, -35],
[141, -34],
],
},
},
];
}
function geometrySource(cables, landingPoints = []) {
return {
label: 'Multipart test source',
async fetch() {
return {
cables: { type: 'FeatureCollection', features: cables },
landingPoints: { type: 'FeatureCollection', features: landingPoints },
};
},
};
}
test('multipart association accepts frozen provider collections without modifying properties', async () => {
const payload = {
cables: { type: 'FeatureCollection', features: multipartCableFixture() },
landingPoints: {
type: 'FeatureCollection',
features: multipartCableFixture(),
},
};
payload.cables.features[0].properties.__gevSourceFeatureIndex =
'provider value';
const before = structuredClone(payload);
const freeze = (value) => {
if (value && typeof value === 'object') {
for (const child of Object.values(value)) freeze(child);
Object.freeze(value);
}
return value;
};
freeze(payload);
const env = await createRealCableLayerHarness({
source: { label: 'Frozen multipart source', fetch: async () => payload },
});
try {
for (const dataSource of env.dataSources.slice(0, 2)) {
assert.deepEqual(
dataSource.entities.values.map(
(entity) => entity.__gevTeleGeography?.label,
),
['Alpha cable', 'Alpha cable', 'Alpha cable', 'Beta cable'],
);
}
assert.equal(env.dataSources[2].entities.values.length, 4);
assert.deepEqual(payload, before);
const entity = env.dataSources[0].entities.values[0];
assert.equal(entity.properties.__gevSourceFeatureIndex.getValue(), 0);
assert.match(entity.description.getValue(), /__gevSourceFeatureIndex/);
} finally {
env.layer.destroy(env.viewer);
}
});
test('multipart cables retain their own styling, picking and culling on every segment', async () => {
const features = multipartCableFixture();
const before = JSON.stringify(features);
const env = await createRealCableLayerHarness({
source: geometrySource(features),
});
try {
const entities = env.dataSources[0].entities.values;
assert.equal(entities.length, 4);
for (const [index, entity] of entities.entries()) {
const feature = features[index < 3 ? 0 : 1];
const info = entity.__gevTeleGeography;
assert.equal(info?.label, feature.properties.name, entity.id);
assert.deepEqual(info.reference, {
lon: feature.properties.coordinates[0],
lat: feature.properties.coordinates[1],
});
assert.deepEqual(
entity.polyline.material.color.getValue(),
Cesium.Color.fromCssColorString(feature.properties.color).withAlpha(
0.92,
),
);
assert.equal(entity.polyline.width.getValue(), 2.5);
}
env.raiseSweep();
assert.deepEqual(
entities.map((entity) => entity.show),
[true, true, true, false],
);
assert.equal(
env.dataSources[2].entities.values.length,
2,
'one reference per feature',
);
assert.equal(
JSON.stringify(features),
before,
'source features stay unchanged',
);
} finally {
env.layer.destroy(env.viewer);
}
});
test('multipart association supports missing and repeated IDs without guessing Cesium suffixes', async () => {
const features = multipartCableFixture();
features[1].id = 'alpha_2';
delete features[1].properties.id;
const generated = structuredClone(features[1]);
delete generated.id;
generated.properties.name = 'Generated cable';
features.push(generated);
const repeated = structuredClone(features[1]);
repeated.properties.id = 'alpha';
repeated.properties.name = 'Repeated ID cable';
features.push(repeated);
const env = await createRealCableLayerHarness({
source: geometrySource(features),
});
try {
const entities = env.dataSources[0].entities.values;
assert.deepEqual(
entities.map((entity) => entity.id),
['alpha', 'alpha_2', 'alpha_3', 'alpha_2_2', 'cable-2', 'alpha_4'],
);
assert.deepEqual(
entities.map((entity) => entity.__gevTeleGeography?.label),
[
'Alpha cable',
'Alpha cable',
'Alpha cable',
'Beta cable',
'Generated cable',
'Repeated ID cable',
],
);
assert.deepEqual(
env.dataSources[2].entities.values.map(
(entity) => entity.__gevTeleGeography.featureId,
),
['alpha', 'alpha_2', 'cable-2', 'alpha'],
);
} finally {
env.layer.destroy(env.viewer);
}
});
test('multipart landing geometry keeps every pick with one reference per feature', async () => {
const features = multipartCableFixture();
const env = await createRealCableLayerHarness({
source: geometrySource([], features),
});
try {
const entities = env.dataSources[1].entities.values;
assert.deepEqual(
entities.map((entity) => entity.__gevTeleGeography?.label),
['Alpha cable', 'Alpha cable', 'Alpha cable', 'Beta cable'],
);
assert.ok(
entities.every(
(entity) => entity.__gevTeleGeography.kind === 'landing-point',
),
);
assert.equal(env.dataSources[2].entities.values.length, 2);
} finally {
env.layer.destroy(env.viewer);
}
});
test('every bundled cable segment is associated with its source feature', async () => {
const { features } = JSON.parse(
readFileSync(
new URL(
'./local_data/telegeography_submarine_cables/cable-geo.json',
import.meta.url,
),
'utf8',
),
);
// Some bundled features share a cable ID but have different references.
const expandedFeatures = features.flatMap((feature) =>
feature.geometry.coordinates.map(() => feature),
);
assert.ok(expandedFeatures.length > features.length);
const env = await createRealCableLayerHarness({
source: geometrySource(features),
});
try {
const entities = env.dataSources[0].entities.values;
assert.equal(entities.length, expandedFeatures.length);
for (const [index, entity] of entities.entries()) {
const feature = expandedFeatures[index];
assert.equal(
entity.__gevTeleGeography?.label,
feature.properties.name,
entity.id,
);
assert.deepEqual(entity.__gevTeleGeography.reference, {
lon: feature.properties.coordinates[0],
lat: feature.properties.coordinates[1],
});
}
assert.equal(env.dataSources[2].entities.values.length, features.length);
} finally {
env.layer.destroy(env.viewer);
}
});
+11
View File
@@ -1,5 +1,8 @@
import assert from 'node:assert/strict';
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
@@ -136,6 +139,7 @@ test('traffic timing pairs real ordering to the scheduling change and guards re-
const timeouts = new Map();
let timerId = 0;
let server;
const cacheDir = mkdtempSync(path.join(tmpdir(), 'gev-traffic-timing-vite-'));
let trafficLayer;
let viewer;
@@ -159,6 +163,12 @@ test('traffic timing pairs real ordering to the scheduling change and guards re-
};
server = await createServer({
root: fileURLToPath(new URL('../..', import.meta.url)),
// Its own optimizer cache, not node_modules/.vite in the package tree:
// a host running Core's suite refuses files inside Core.
cacheDir,
// The test loads modules over SSR; the browser dependency optimizer would
// keep writing its cache after the server closes.
optimizeDeps: { noDiscovery: true, include: [] },
configFile: false,
appType: 'custom',
logLevel: 'silent',
@@ -328,6 +338,7 @@ test('traffic timing pairs real ordering to the scheduling change and guards re-
console.warn = originalWarn;
performance.clearMarks();
performance.clearMeasures();
rmSync(cacheDir, { recursive: true, force: true, maxRetries: 3 });
}
});
+3 -2
View File
@@ -1029,10 +1029,11 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
// Panel adds timeout and named voice/provider targets; consolidated guidance
// preserves the mission mapping and globe-rotation arguments.
// S2 GhostMaps extends the generated layer enums; both feature contracts remain.
assert.equal(block.length, 31206, 'serialized tool schema length drifted');
// The layer alias hint leaves out aliases the layer id already spells.
assert.equal(block.length, 30901, 'serialized tool schema length drifted');
assert.equal(
crypto.createHash('sha256').update(block).digest('hex'),
'f772df95404494bf492e02fde1beabffdfe61986940bffd0cf9ad5a38d768746',
'9e1bd8fde648953c47718ba248758415a95c1484b0da6369df6b89b0505c0de2',
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
);
const instructions = fs.readFileSync(
+2 -2
View File
@@ -5,6 +5,7 @@ import {
DIRECTION_CONE_HALF_ANGLE_DEG,
MARKER_SCALE_BY_DISTANCE,
} from './policy.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
/**
* Badge scale at a camera distance, matching Cesium's NearFarScalar for the
@@ -49,9 +50,8 @@ export function alprLabelDetails(record, source) {
const details = [
sourceName === 'OpenStreetMap' ? 'OSM MAPPED' : `Source: ${sourceName}`,
];
// Wrap after rounding: 359.6° rounds to 360°, which is north.
if (Number.isFinite(record.directionDeg))
details.push(`DIRECTION ${Math.round(record.directionDeg) % 360}°`);
details.push(`DIRECTION ${roundHeadingDegrees(record.directionDeg)}°`);
const equipment = [
...new Set(
[record.manufacturer, record.operator, record.cameraType]
+6 -7
View File
@@ -10,6 +10,7 @@ import {
} from '../../i18n/index.js';
import { formatQuantity } from '../../ui/unitLabels.js';
import { AWARENESS_PAGE_SIZE, AWARENESS_PAGE_ROTATE_MS } from './policy.js';
import { distanceParts } from '../../data/distanceFormat.js';
/** Format a cohort total without presenting a retained cap as exact. */
export function formatAwarenessCount(summary = {}) {
@@ -31,13 +32,11 @@ function presentAwarenessCount(summary = {}) {
/** A distance as the panel shows it: 850 m, 4.6 km, 120 km, in the UI language. */
export function presentAwarenessDistance(meters) {
if (!Number.isFinite(meters) || meters < 0) return '—';
const wholeMeters = Math.round(meters);
if (wholeMeters < 1000) return formatQuantity(wholeMeters, 'm');
const digits = meters < 10000 ? 1 : 0;
return formatQuantity(meters / 1000, 'km', {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
const parts = distanceParts(meters);
if (!parts) return '—';
return formatQuantity(parts.value, parts.unit, {
minimumFractionDigits: parts.digits,
maximumFractionDigits: parts.digits,
});
}
+3 -2
View File
@@ -1,3 +1,5 @@
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
/**
* Format a compass direction for the awareness overlay.
* @param {number} degrees
@@ -5,6 +7,5 @@
*/
export function formatAwarenessDirection(degrees) {
if (!Number.isFinite(degrees)) return '—';
const normalized = ((degrees % 360) + 360) % 360;
return String(Math.round(normalized) % 360).padStart(3, '0');
return String(roundHeadingDegrees(degrees)).padStart(3, '0');
}
+59
View File
@@ -451,3 +451,62 @@ test('unknown GBFS counts stay unknown through the source, model and station col
assert.equal(model.toNonNegativeInteger('3'), 3);
}
});
test('a station without a published capacity re-derives it from every status poll', async () => {
const { createRendering } = await import('./rendering.js');
const { COLOR_YELLOW } = await import('./policy.js');
const model = createModel({});
const state = {
_cityRuntime: new Map(),
_stationRenderMap: new Map(),
_pointCollection: { add: (options) => ({ ...options }) },
};
const rendering = createRendering({
state,
services: { render: { governorRequestRender() {} } },
parts: { model },
});
const info = model.parseStationInformation({
data: {
stations: [
{ station_id: 'open', name: 'Open', lat: 30.26, lon: -97.74 },
{
station_id: 'fixed',
name: 'Fixed',
lat: 30.27,
lon: -97.75,
capacity: 13,
},
],
},
});
rendering.ensureCityPoints('austin', info);
const poll = (stations) =>
rendering.applyStatusToPoints(
'austin',
model.parseStationStatus({ data: { stations } }),
);
const open = state._stationRenderMap.get('austin:open');
const fixed = state._stationRenderMap.get('austin:fixed');
// The first poll has no row for the station: capacity is a placeholder.
poll([
{
station_id: 'fixed',
num_bikes_available: 4,
num_docks_available: 9,
},
]);
poll([
{ station_id: 'open', num_bikes_available: 10, num_docks_available: 20 },
{ station_id: 'fixed', num_bikes_available: 2, num_docks_available: 3 },
]);
assert.equal(open.capacity, 30, 'the first poll fixed the capacity');
assert.equal(open.point.color, COLOR_YELLOW, '10 of 30 is not plenty');
assert.equal(fixed.capacity, 13, 'a published capacity is kept');
poll([
{ station_id: 'open', num_bikes_available: 4, num_docks_available: 2 },
]);
assert.equal(open.capacity, 6);
});
+3 -1
View File
@@ -185,6 +185,7 @@ export function createRendering({
stationName: station.name,
point,
capacity: parts.model.toNonNegativeInteger(station.capacity),
publishedCapacity: parts.model.toNonNegativeInteger(station.capacity),
bikesAvailable: null,
docksAvailable: null,
isInstalled: station.isInstalled,
@@ -245,7 +246,8 @@ export function createRendering({
if (!record) continue;
const status = statusMap.get(record.stationId) || null;
const capacity = resolveCapacity(record.capacity, status);
// Resolve from the published capacity, not last poll's derived value.
const capacity = resolveCapacity(record.publishedCapacity, status);
record.capacity = capacity;
record.bikesAvailable = parts.model.toNonNegativeInteger(
+2 -2
View File
@@ -1,4 +1,5 @@
import { t } from '../../i18n/index.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
/**
* @module headingConfidence
@@ -44,8 +45,7 @@ export function isHeadingEstimated(camera) {
* @returns {string}
*/
export function headingHudToken(camera) {
// 359.5° and above rounds to north; modulo keeps it from printing 360°.
const hdg = Math.round(Number(camera?.headingDeg) || 0) % 360;
const hdg = roundHeadingDegrees(Number(camera?.headingDeg)) ?? 0;
return isHeadingEstimated(camera)
? t('HDG {value}° (ESTIMATED)', { value: hdg })
: t('HDG {value}°', { value: hdg });
+7 -11
View File
@@ -31,6 +31,7 @@ import {
quantitySeparator,
unitLabel,
} from '../../ui/unitLabels.js';
import { distanceParts } from '../../data/distanceFormat.js';
export const DIRECTIONS_STEP_OVERLAY_SOURCE_ID = 'directions-step';
export const DIRECTIONS_STEP_OVERLAY_SOURCE_OPTIONS = Object.freeze({
@@ -108,17 +109,12 @@ export const FLIGHT_PROGRESS_MS = 250;
* @returns {string}
*/
export function routeDistanceText(meters) {
if (!Number.isFinite(meters) || meters < 0) return '';
if (meters < 100) return formatQuantity(Math.round(meters), 'm');
const tens = Math.round(meters / 10) * 10;
if (tens < 1000) return formatQuantity(tens, 'm');
const km = meters / 1000;
return km < 10
? formatQuantity(km, 'km', {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
})
: formatQuantity(Math.round(km), 'km');
const parts = distanceParts(meters, { metreStep: 10, exactBelowM: 100 });
if (!parts) return '';
return formatQuantity(parts.value, parts.unit, {
minimumFractionDigits: parts.digits,
maximumFractionDigits: parts.digits,
});
}
/**
+22
View File
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import { createCivilFlightLayer } from './index.js';
import { createFlightState } from './state.js';
import { createEnrichment } from './enrichment.js';
import { createTracking } from './tracking.js';
function services() {
const names = [
@@ -124,3 +125,24 @@ test('a synchronous enrichment failure stays bounded and releases its slot', asy
await new Promise((done) => setImmediate(done));
assert.equal(flightState._enrichActive, 0);
});
test('voice context reads a track that rounds to north as 0°, not 360°', () => {
const tracking = createTracking({
flightState: {
feed: { _lastSource: 'Fixture aircraft' },
records: { data: new Map() },
},
services: services(),
parts: {
queries: {
_describeFlight: () => ({ track: 359.6, altitudeM: 9000 }),
_contactLabel: () => 'AAL1',
},
},
layer: {},
});
assert.equal(
tracking._contextSubjectMetadata('abc123').properties.heading,
'0°',
);
});
+2 -1
View File
@@ -24,6 +24,7 @@ import {
TRAIL_COLOR,
CYAN_TRANSPARENT,
} from './policy.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
export function createTracking({
flightState,
@@ -126,7 +127,7 @@ export function createTracking({
? `${Math.round(described.velocityMps * 1.944)} kt`
: '',
heading: Number.isFinite(described.track)
? `${Math.round(described.track)}°`
? `${roundHeadingDegrees(described.track)}°`
: '',
route: route || '',
icao24,
+5 -2
View File
@@ -139,8 +139,10 @@ export function createReplay({ state: layerState, services, parts, source }) {
0,
Number(preCountdownDurationSec) || 0,
);
// The hold precedes the whole countdown; ending it at -hold cut T-10 to T-5.
const preCountdownActive =
preCountdownDuration > 0 && realSecondsSinceStart < -preCountdownDuration;
preCountdownDuration > 0 &&
realSecondsSinceStart < -REPLAY_COUNTDOWN_DURATION_SEC;
const countdownActive = realSecondsSinceStart < 0 && !preCountdownActive;
const countdownSeconds = countdownActive
? Math.ceil(-realSecondsSinceStart)
@@ -247,7 +249,8 @@ export function createReplay({ state: layerState, services, parts, source }) {
? layerState._replayPausedAtMs
: Date.now();
for (const [launchId, startedAt] of layerState._animationStarts) {
if (!Number.isFinite(startedAt)) continue;
// A countdown runs in real time; rescaling it turned T-8 into T-32 at 0.25x.
if (!Number.isFinite(startedAt) || startedAt > now) continue;
const elapsedMissionMs = (now - startedAt) * previousSpeed;
layerState._animationStarts.set(
launchId,
+47
View File
@@ -0,0 +1,47 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createState } from './state.js';
import { createReplay } from './replay.js';
import { createPaths } from './paths.js';
const noop = () => {};
function replayRig() {
const state = createState({
services: {
overlays: {
setOverlayEntries: noop,
setOverlaySourceVisible: noop,
clearOverlaySource: noop,
},
},
});
const parts = { panel: { publishPanel: noop } };
parts.paths = createPaths({ state, parts });
return { state, replay: createReplay({ state, parts }) };
}
test('changing replay speed during the countdown keeps its real-time liftoff', (t) => {
t.mock.timers.enable({ apis: ['Date'], now: 1_000_000 });
const { state, replay } = replayRig();
const launch = { launchTime: '2026-07-20T10:00:00Z', timeline: [] };
state._animationStarts.set('countdown', 1_008_000); // T-8
state._animationStarts.set('flying', 990_000); // 10 s into the mission
replay.setReplaySpeed(0.25);
const startedAt = state._animationStarts.get('countdown');
assert.equal(startedAt, 1_008_000);
const countdown = replay.replayState(
launch,
startedAt,
12,
28,
5400,
state._replaySpeed,
Date.now(),
);
assert.equal(countdown.countdownSeconds, 8);
// Mission time already flown is still preserved across the change.
assert.equal(state._animationStarts.get('flying'), 960_000);
});
+2 -1
View File
@@ -1,6 +1,7 @@
import { AIRCRAFT_CLASS_LABELS } from '../../data/aircraftClass.js';
import { formatNumber, t, tn } from '../../i18n/index.js';
import { BAND_LABELS, LOCAL_ADSB_COLOR, SOURCE_LABELS } from './policy.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
const DASH = '—';
@@ -152,7 +153,7 @@ export function localAdsbCardModel(
t('ALT {altitude} FT · GS {speed} KT · TRK {track}', {
altitude: grouped(altitude),
speed: speed === null ? DASH : speed,
track: track === null ? DASH : `${track % 360}°`,
track: track === null ? DASH : `${roundHeadingDegrees(track)}°`,
}),
vertical === null
? t('V/S {rate}', { rate: DASH })
+25
View File
@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { createMilitaryFlightLayer } from './index.js';
import { createFlightState } from './state.js';
import { createTracking } from './tracking.js';
function services() {
const names = [
@@ -211,3 +212,27 @@ test('a newly seen military contact appears at its delayed position, not ahead o
`expected about ${Math.round(expectedM)} m behind the fix, got ${Math.round(behindM)} m`,
);
});
test('voice context reads a track that rounds to north as 0°, not 360°', () => {
const stubs = services();
stubs.aircraftPresentation = { tr3bTypeLabel: (id, type) => type };
const tracking = createTracking({
flightState: {
feed: { _lastSource: 'Fixture aircraft' },
records: { data: new Map() },
},
services: stubs,
parts: {
queries: {
_describeFlight: () => ({ track: 359.6, callsign: 'RCH123' }),
_toCleanText: (value) => value || '',
_formatAltitude: () => '',
},
},
layer: {},
});
assert.equal(
tracking._contextSubjectMetadata('ae1234').properties.heading,
'0°',
);
});
+2 -1
View File
@@ -25,6 +25,7 @@ import {
AMBER_TRANSPARENT,
TRACKED_ICON_COLOR,
} from './policy.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
export function createTracking({
flightState,
@@ -120,7 +121,7 @@ export function createTracking({
? `${Math.round(described.velocityMps * 1.944)} kt`
: '',
heading: Number.isFinite(described.track)
? `${Math.round(described.track)}°`
? `${roundHeadingDegrees(described.track)}°`
: '',
icao24,
// Honesty cue: the contact is coasting on dead reckoning, so the
+2 -1
View File
@@ -1,6 +1,6 @@
import * as Cesium from 'cesium';
import { twoline2satrec } from 'satellite.js';
import { noradIdFromSatnum } from '../../sources/tle.js';
import { noradIdFromSatnum, tleLineLaunchYear } from '../../sources/tle.js';
import {
DENSE_REFRESH_FRAMES,
DENSE_GROUP_PATH,
@@ -123,6 +123,7 @@ export function createCatalog({ state: layerState, services, parts, source }) {
name: entry.name,
satrec,
group: 'dense',
launchYear: tleLineLaunchYear(entry.line1),
});
const point = layerState._pointCollection.add({
position: Cesium.Cartesian3.fromDegrees(
+3 -1
View File
@@ -1,7 +1,7 @@
import { twoline2satrec } from 'satellite.js';
import * as Cesium from 'cesium';
import { msg } from '../../i18n/index.js';
import { noradIdFromSatnum } from '../../sources/tle.js';
import { noradIdFromSatnum, tleLineLaunchYear } from '../../sources/tle.js';
import { CATALOG_GROUPS, ISS_NORAD, POINT_STYLES } from './policy.js';
export function createIngestion({
@@ -129,6 +129,8 @@ export function createIngestion({
name: entry.name,
satrec,
group: entry.group,
// satellite.js does not keep the designator the year match reads.
launchYear: tleLineLaunchYear(entry.line1),
});
coreStale ||= entry.stale;
+42 -1
View File
@@ -1,7 +1,8 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseTleText } from '../../sources/tle.js';
import { parseTleText, tleLineLaunchYear } from '../../sources/tle.js';
import { createIngestion } from './ingestion.js';
import { createOrbits } from './orbits.js';
// The ISS set (1 January 2024) with its catalog field swapped per satellite.
const ISS_LINE1 =
@@ -78,3 +79,43 @@ test('every Alpha-5 satellite keeps its own catalog entry through dedupe', async
assert.deepEqual([...state._points.keys()], [100000, 110001, 339999]);
assert.equal(state._count, 3);
});
test('a mission payload does not match a loaded satellite from another launch year', async (t) => {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
// SXM-11, international designator 2026-148A.
const { state, update } = ingestionFor(
[
'SXM-11',
'1 69728U 26148A 26208.65166678 .00000015 00000+0 00000+0 0 9990',
'2 69728 0.0784 264.9826 0001797 240.4588 273.9506 1.00271527 440',
].join('\n'),
);
await update();
const { getSatelliteOrbitTrack } = createOrbits({ state });
const track = (launchTime) =>
getSatelliteOrbitTrack('Sirius SXM-11', { launchTime });
assert.equal(track('2026-06-29T02:25:00Z')?.noradId, 69728);
assert.equal(track('2030-06-29T02:25:00Z'), null);
});
test('a satellite with a blank designator keeps matching by name', async (t) => {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
const { state, update } = ingestionFor(
[
'SXM-11',
'1 69728U 26208.65166678 .00000015 00000+0 00000+0 0 9990',
'2 69728 0.0784 264.9826 0001797 240.4588 273.9506 1.00271527 440',
].join('\n'),
);
await update();
const { getSatelliteOrbitTrack } = createOrbits({ state });
assert.equal(tleLineLaunchYear('1 69728U 26208.65166678'), null);
assert.equal(
getSatelliteOrbitTrack('Sirius SXM-11', {
launchTime: '2030-06-29T02:25:00Z',
})?.noradId,
69728,
);
});
+6 -16
View File
@@ -8,7 +8,11 @@ import {
twoline2satrec,
} from 'satellite.js';
import { findNextSatellitePass } from '../../data/satellitePass.js';
import { noradIdFromSatnum, parseTleText } from '../../sources/tle.js';
import {
noradIdFromSatnum,
parseTleText,
tleLineLaunchYear,
} from '../../sources/tle.js';
import { ORBIT_PATH_STEPS, ISS_NORAD } from './policy.js';
export function createOrbits({ state: layerState, services, parts, source }) {
@@ -258,13 +262,6 @@ export function createOrbits({ state: layerState, services, parts, source }) {
return score;
}
function internationalDesignatorYear(satrec) {
const match = String(satrec?.intldesg || '').match(/^(\d{2})/);
if (!match) return null;
const shortYear = Number(match[1]);
return shortYear >= 57 ? 1900 + shortYear : 2000 + shortYear;
}
function lookupTleEntries(tleText) {
if (tleText !== layerState._lookupTleText) {
layerState._lookupTleText = tleText;
@@ -273,12 +270,6 @@ export function createOrbits({ state: layerState, services, parts, source }) {
return layerState._lookupTleEntries;
}
function tleLineLaunchYear(line1) {
const shortYear = Number(String(line1 || '').slice(9, 11));
if (!Number.isFinite(shortYear)) return null;
return shortYear >= 57 ? 1900 + shortYear : 2000 + shortYear;
}
function orbitTrackFromRecord(name, satrec) {
const referenceDate = new Date();
const current = propagatePosition(satrec, referenceDate);
@@ -350,7 +341,7 @@ export function createOrbits({ state: layerState, services, parts, source }) {
: null;
let bestScore = 0;
for (const [id, sat] of layerState._catalog) {
const designatorYear = internationalDesignatorYear(sat.satrec);
const designatorYear = sat.launchYear ?? null;
if (
launchYear !== null &&
designatorYear !== null &&
@@ -379,7 +370,6 @@ export function createOrbits({ state: layerState, services, parts, source }) {
getNextSatellitePass,
resolveSatelliteForPass,
scoreSatelliteNameMatch,
internationalDesignatorYear,
lookupTleEntries,
tleLineLaunchYear,
orbitTrackFromRecord,
+14
View File
@@ -235,6 +235,20 @@ test('dense provenance belongs to its admitted records and clears on removal', a
);
});
test('dense extras keep their launch year for mission orbit matching', async (t) => {
const f = await fixture(t);
await f.update();
f.state._params.catalog = 'dense';
assert.equal((await f.parts.catalog._loadDenseCatalog()).status, 'ready');
// Every fixture set carries the 1998-067A designator.
const track = (launchTime) =>
f.parts.orbits.getSatelliteOrbitTrack('OWNED ORBIT FIXTURE 25551', {
launchTime,
});
assert.equal(track('1998-11-20T06:40:00Z')?.noradId, 25551);
assert.equal(track('2026-06-29T02:25:00Z'), null);
});
test('cancelling dense replacement preserves admitted stale extras until rebuild', async (t) => {
const f = await fixture(t);
await f.update();
+30 -4
View File
@@ -6,6 +6,21 @@ import {
} from './geometry.js';
import * as Cesium from 'cesium';
const SOURCE_FEATURE_INDEX = '__gevSourceFeatureIndex';
function indexedCollection(features) {
return {
type: 'FeatureCollection',
features: features.map((feature, index) => ({
...feature,
properties: {
...feature.properties,
[SOURCE_FEATURE_INDEX]: index,
},
})),
};
}
export function createIngestion({ state, parts, source }) {
async function load(viewer) {
if (state._loading || state._loaded) return;
@@ -49,7 +64,7 @@ export function createIngestion({ state, parts, source }) {
const landingFeatures = normalizeFeatures(landingJson, 'landing');
const cableDataSource = await Cesium.GeoJsonDataSource.load(
{ type: 'FeatureCollection', features: cableFeatures },
indexedCollection(cableFeatures),
{
clampToGround: true,
stroke: state.cableColor.withAlpha(0.95),
@@ -61,7 +76,7 @@ export function createIngestion({ state, parts, source }) {
);
if (!owns()) return;
const landingDataSource = await Cesium.GeoJsonDataSource.load(
{ type: 'FeatureCollection', features: landingFeatures },
indexedCollection(landingFeatures),
{
clampToGround: true,
stroke: state.landingColor.withAlpha(0.9),
@@ -131,7 +146,12 @@ export function createIngestion({ state, parts, source }) {
state._referenceRecords = [];
state._surfaceRecords = [];
cableEntities.forEach((entity, index) => {
// Cesium expands multipart geometry into several entities and suffixes
// colliding IDs. Its copied properties retain this source index even
// when a provider has no ID, so every segment keeps the right owner.
const cableReferences = new Set();
cableEntities.forEach((entity) => {
const index = entity.properties?.[SOURCE_FEATURE_INDEX]?.getValue();
const feature = cableFeatures[index];
const reference = featureReference(feature);
if (!reference) return;
@@ -146,6 +166,8 @@ export function createIngestion({ state, parts, source }) {
entity,
base: Cesium.Cartesian3.fromDegrees(reference.lon, reference.lat, 0),
});
if (cableReferences.has(index)) return;
cableReferences.add(index);
parts.rendering.addReferenceStem({
reference,
label: featureLabel(feature),
@@ -155,7 +177,9 @@ export function createIngestion({ state, parts, source }) {
});
});
landingEntities.forEach((entity, index) => {
const landingReferences = new Set();
landingEntities.forEach((entity) => {
const index = entity.properties?.[SOURCE_FEATURE_INDEX]?.getValue();
const feature = landingFeatures[index];
const reference = featureReference(feature);
if (!reference) return;
@@ -170,6 +194,8 @@ export function createIngestion({ state, parts, source }) {
entity,
base: Cesium.Cartesian3.fromDegrees(reference.lon, reference.lat, 0),
});
if (landingReferences.has(index)) return;
landingReferences.add(index);
parts.rendering.addReferenceStem({
reference,
label: featureLabel(feature),
+13 -2
View File
@@ -259,7 +259,8 @@ export function createControls({ state: layerState, services, parts, source }) {
* `error` carries this instant's health, so a live-configured layer whose
* flow feed went down reads DEGRADED with the reason instead of a stale
* LIVE coverage number. `flowCoveragePct` is shown dots on matched roads /
* all shown dots (0–100 int), excluding closures; `tilesFetched` counts flow-tile requests
* all shown dots (0–100 int, kept within 1–99 while both kinds are shown),
* excluding closures; `tilesFetched` counts flow-tile requests
* issued to the proxy this session (decode-cache hits excluded).
* @returns {{count:number, lastUpdate:number|null, loading:boolean,
* mode:'live'|'sim', error:string|null, flowCoveragePct:number,
@@ -304,8 +305,18 @@ export function createControls({ state: layerState, services, parts, source }) {
Boolean(layerState._heightPending);
const { free, slow, jam, sim } = layerState._bucketCounts;
const matched = free + slow + jam;
// The label reads 0 as "no matches" and 100 as nothing simulated, so
// rounding must not reach either while both kinds of dot are shown.
const flowCoveragePct =
matched + sim > 0 ? Math.round((100 * matched) / (matched + sim)) : 0;
matched + sim > 0
? Math.min(
sim > 0 ? 99 : 100,
Math.max(
matched > 0 ? 1 : 0,
Math.round((100 * matched) / (matched + sim)),
),
)
: 0;
const feed = parts.model.trafficFeedPresentation({
liveMode: layerState._liveMode,
fetching: loading,
+20
View File
@@ -519,6 +519,26 @@ test('late closures hide and stop dots; coverage counts shown matched dots, and
assert.equal(controls.getStats().flowCoveragePct, 80);
});
test('coverage never rounds to "no matches" or full coverage while both kinds of dot are shown', async () => {
const { state, controls } = await matchingContext(async () => []);
state._bucketCounts = { free: 1, slow: 0, jam: 0, sim: 300 };
assert.equal(controls.getStats().flowCoveragePct, 1);
assert.equal(
controls.getStats().loadingLabel,
'LIVE · Roads: OpenStreetMap · Flow: TomTom · 1% cov · Unmatched: simulated',
);
state._bucketCounts = { free: 300, slow: 0, jam: 0, sim: 1 };
assert.equal(controls.getStats().flowCoveragePct, 99);
assert.equal(
controls.getStats().loadingLabel,
'LIVE · Roads: OpenStreetMap · Flow: TomTom · 99% cov · Unmatched: simulated',
);
state._bucketCounts = { free: 300, slow: 0, jam: 0, sim: 0 };
assert.equal(controls.getStats().flowCoveragePct, 100);
state._bucketCounts = { free: 0, slow: 0, jam: 0, sim: 300 };
assert.equal(controls.getStats().flowCoveragePct, 0);
});
test('refresh failure removes cached matches and reports simulated flow', async () => {
const { flow, state, model, controls } = await matchingContext(async () => {
throw new Error('HTTP 429');
+2 -2
View File
@@ -9,6 +9,7 @@ import {
transitRouteLabel,
} from '../../data/transitFeeds.js';
import { displayMotion, playbackPosition } from './movement.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
export const TRANSIT_SELECTED_OVERLAY_SOURCE_ID = 'transit-selected';
export const TRANSIT_SELECTED_OVERLAY_SOURCE_OPTIONS = Object.freeze({
@@ -405,11 +406,10 @@ export function buildTransitSelectionCopy(
reportedMotion.push(
t('reported {speed} km/h', { speed: Math.round(record.speedMps * 3.6) }),
);
// 359.5° and above rounds to north; modulo keeps it from printing 360°.
if (Number.isFinite(record.bearing))
reportedMotion.push(
t('reported hdg {heading}°', {
heading: Math.round(record.bearing) % 360,
heading: roundHeadingDegrees(record.bearing),
}),
);
// The operator's own claim, kept as a REPORT rather than as the live state.
+6 -5
View File
@@ -4,6 +4,7 @@ import {
normalizeVesselType,
} from '../../data/vesselLabels.js';
import { formatNumber, t } from '../../i18n/index.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
export function createCards({
vesselState,
@@ -55,10 +56,8 @@ export function createCards({
if (record.speed !== null && record.speed !== undefined)
parts.push(formatSpeed(record.speed));
const direction = record.heading ?? record.course;
// A course of 359.5° and above rounds to north; modulo keeps it from
// printing 360°. The selected card and the HUD wrap the same way.
if (Number.isFinite(direction))
parts.push(`${Math.round(direction) % 360}°`);
parts.push(`${roundHeadingDegrees(direction)}°`);
return {
id: vesselOverlayEntryId(record),
actionable: Boolean(record?.mmsi),
@@ -89,7 +88,9 @@ export function createCards({
[
vesselTypeShort(record) || t('VESSEL'),
formatSpeed(record.speed),
Number.isFinite(direction) ? `${Math.round(direction) % 360}°` : '--°',
Number.isFinite(direction)
? `${roundHeadingDegrees(direction)}°`
: '--°',
].join(' · '),
];
const destination = String(record.destination || '').trim();
@@ -179,7 +180,7 @@ export function createCards({
function formatHeading(heading) {
return t('{heading}DEG', {
heading: Number.isFinite(heading) ? Math.round(heading) % 360 : '--',
heading: roundHeadingDegrees(heading) ?? '--',
});
}
+41 -3
View File
@@ -54,14 +54,22 @@ async function call(handlers, url, init = {}) {
Object.assign(req, {
method: init.method || 'GET',
url: parsed.pathname.slice(mount.length) + parsed.search || '/',
headers: {},
headers: init.headers || {},
socket: { remoteAddress: '127.0.0.1' },
});
let status, headers, body;
let status, body;
let headers = {};
await handlers.get(mount)(req, {
// The cross-site gate answers through statusCode/setHeader, not writeHead.
set statusCode(s) {
status = s;
},
setHeader(name, value) {
headers[name] = value;
},
writeHead(s, h) {
status = s;
headers = h;
headers = { ...headers, ...h };
},
end(b) {
body = b;
@@ -166,6 +174,36 @@ test('zero egress: every Overpass consumer reaches real default handlers, and re
);
});
test('the installations proxy refuses cross-site and forwarded requests before any Overpass work', async (t) => {
env(t);
t.mock.method(globalThis, 'fetch', () =>
assert.fail('unexpected upstream fetch'),
);
const handlers = routes(militaryInstallationsProxy());
const url =
'/api/military-installations?south=48.8&west=2.2&north=48.9&east=2.4';
for (const headers of [
{ 'sec-fetch-site': 'cross-site' },
{ 'sec-fetch-site': 'same-site' },
{ origin: 'https://foreign.example' },
{ origin: 'null' },
{ 'sec-fetch-site': 'same-origin', 'x-forwarded-for': '203.0.113.9' },
]) {
const response = await call(handlers, url, { headers });
assert.equal(response.status, 403, JSON.stringify(headers));
assert.equal(response.headers.get('cache-control'), 'no-store');
}
for (const headers of [
{},
{ origin: 'http://localhost:4173', 'sec-fetch-site': 'same-origin' },
]) {
const response = await call(handlers, url, {
headers: { host: 'localhost:4173', ...headers },
});
assert.notEqual(response.status, 403, JSON.stringify(headers));
}
});
test('an unconfigured server is probed once and never sent a feature query', async (t) => {
env(t);
t.mock.method(globalThis, 'fetch', () =>
+16
View File
@@ -0,0 +1,16 @@
/** A readsb rate field: a finite JSON number, else null. */
function rateField(value) {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
/**
* Vertical rate in ft/min from a readsb-style aircraft row (readsb, tar1090,
* adsb.lol): the barometric `baro_rate`, else the GNSS-sourced `geom_rate`
* readsb sends instead when it has no barometric rate. A barometric 0 is kept.
* Only a finite number counts; null when neither field has one.
* @param {object|null|undefined} row
* @returns {number|null}
*/
export function readAdsbVerticalRateFpm(row) {
return rateField(row?.baro_rate) ?? rateField(row?.geom_rate);
}
+33
View File
@@ -0,0 +1,33 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readAdsbVerticalRateFpm } from './adsbFields.js';
test('the barometric rate wins, a barometric 0 included', () => {
assert.equal(
readAdsbVerticalRateFpm({ baro_rate: -640, geom_rate: -832 }),
-640,
);
assert.equal(readAdsbVerticalRateFpm({ baro_rate: 0, geom_rate: 600 }), 0);
});
test('a GNSS rate stands in when no barometric rate is usable', () => {
for (const baro_rate of [
undefined,
null,
Number.NaN,
Infinity,
'300',
false,
]) {
assert.equal(readAdsbVerticalRateFpm({ baro_rate, geom_rate: -832 }), -832);
}
});
test('no usable rate reads as null', () => {
assert.equal(readAdsbVerticalRateFpm({}), null);
assert.equal(readAdsbVerticalRateFpm(null), null);
assert.equal(
readAdsbVerticalRateFpm({ baro_rate: '300', geom_rate: true }),
null,
);
});
+3 -1
View File
@@ -30,6 +30,8 @@
* This module is portable: no Cesium, DOM, network or storage access.
*/
import { readAdsbVerticalRateFpm } from './adsbFields.js';
/** A marker is dropped once its newest position is this old. */
export const LOCAL_ADSB_POSITION_STALE_MS = 60_000;
/** An aircraft is forgotten once no message has arrived for this long. */
@@ -247,7 +249,7 @@ export function normalizeDump1090Aircraft(json, nowMs, { band = '1090' } = {}) {
altitudeFt: altitude,
groundSpeedKt: finiteOrNull(entry.gs),
trackDeg: normalizeTrack(entry.track),
verticalRateFpm: finiteOrNull(entry.baro_rate),
verticalRateFpm: readAdsbVerticalRateFpm(entry),
lastPositionAt: hasPosition
? receivedAt - Math.max(0, seenPosS) * 1000
: null,
+14
View File
@@ -169,6 +169,20 @@ test('a feed document names its band on every record', () => {
assert.ok(uat.every((record) => record.source === 'feed'));
});
test('a decoder feed reads a GNSS-sourced vertical rate when no baro rate is sent', () => {
// dump1090-fa and readsb file a velocity message's rate under geom_rate
// instead of baro_rate when its source bit says GNSS.
const rate = (fields) =>
normalizeDump1090Aircraft(
{ aircraft: [{ hex: 'abc123', seen: 1, ...fields }] },
10_000,
)[0].verticalRateFpm;
assert.equal(rate({ geom_rate: -832 }), -832);
assert.equal(rate({ baro_rate: -768, geom_rate: -832 }), -768);
assert.equal(rate({ baro_rate: 0, geom_rate: -832 }), 0);
assert.equal(rate({}), null);
});
function heard(overrides = {}) {
return {
icao: 'abc123',
+2 -1
View File
@@ -6,6 +6,7 @@ import {
epoch,
finite,
} from './contract.js';
import { readAdsbVerticalRateFpm } from '../adsbFields.js';
const openSkyId = (row) =>
Array.isArray(row) ? cleanText(row[0]).toLowerCase() : '';
@@ -48,7 +49,7 @@ export function normalizeReadsbAircraft(row, snapshotTimeMs) {
const baroFt = finite(row.alt_baro),
geoFt = finite(row.alt_geom);
const speedKts = finite(row.gs),
rateFtMin = finite(row.baro_rate);
rateFtMin = readAdsbVerticalRateFpm(row);
const seen = finite(row.seen),
seenPos = finite(row.seen_pos);
return {
+14
View File
@@ -145,6 +145,20 @@ test('readsb units and position/contact ages are normalized once against the sou
);
});
test('readsb vertical rate falls back to the GNSS rate when no baro rate is sent', () => {
// readsb reports the rate under geom_rate when the aircraft's source is
// GNSS; 26 of 157 positioned rows in a live /v2/mil fetch had only that.
const row = { hex: 'ae638d', lat: 20.3, lon: -158.5, geom_rate: -2432 };
assert.equal(
normalizeReadsbAircraft(row, now).verticalRateMps,
-2432 * 0.00508,
);
assert.equal(
normalizeReadsbAircraft({ ...row, baro_rate: -2400 }, now).verticalRateMps,
-2400 * 0.00508,
);
});
test('vessel identity, opaque reference and heading/course remain distinct, unknowns stay null', () => {
const row = normalizeVesselObservation(
{
+13
View File
@@ -72,3 +72,16 @@ export function tleCatalogNumber(line) {
const field = String(line).slice(2, 7);
return decodeCatalogField(field.replace(/^ +/, ''));
}
/**
* Launch year from TLE line 1's international designator (columns 10-11);
* null when the designator is blank.
* @param {string} line1
* @returns {number|null}
*/
export function tleLineLaunchYear(line1) {
const digits = String(line1 || '').slice(9, 11);
if (!/^\d{2}$/.test(digits)) return null;
const shortYear = Number(digits);
return shortYear >= 57 ? 1900 + shortYear : 2000 + shortYear;
}
+6
View File
@@ -57,6 +57,7 @@ export const findSubmarineCables = defineTool({
Number.isFinite(point.lat) &&
(!area || areaContains(area, point)),
);
const seen = new Set();
const rows = (cables?.features || [])
.filter((feature) => {
const name = String(feature.properties?.name || '');
@@ -71,6 +72,11 @@ export const findSubmarineCables = defineTool({
id: feature.properties.id ?? null,
name: feature.properties.name,
}))
// TeleGeography splits some cables into several features under one id.
.filter((row) => {
const key = row.id ?? row.name;
return !seen.has(key) && seen.add(key);
})
.sort((a, b) => a.name.localeCompare(b.name));
const where = area ? ` in ${area.label}` : '';
const what = wanted ? ` matching "${args.name}"` : '';
+53
View File
@@ -89,6 +89,59 @@ test('cables are found by area and name, with landing points and attribution', a
);
});
test('a cable split into several features is listed once', async () => {
// TeleGeography splits a few cables (2Africa, Echo, Hawaiki) into several
// features under one id.
const cable = (id, name, coordinates) => ({
properties: { id, name },
geometry: { type: 'MultiLineString', coordinates: [coordinates] },
});
const split = {
async fetch() {
return {
cables: {
features: [
cable('2africa', '2Africa', [
[60, 18],
[57, 22],
]),
cable('equiano', 'Equiano', [
[-10, 5],
[3, 6],
]),
cable('2africa', '2Africa', [
[-21, 16],
[-17, 14],
]),
],
},
landingPoints: { features: [] },
};
},
};
const catalog = composeCatalog({
tools: coreTools,
services: { cables: split },
});
const named = await catalog.call('find_submarine_cables', {
name: '2africa',
});
assert.equal(named.summary, '1 submarine cable matching "2africa".');
assert.deepEqual(named.data.rows, [{ id: '2africa', name: '2Africa' }]);
const both = await catalog.call('find_submarine_cables', {
area: { bbox: [-30, 0, 70, 30] },
});
assert.deepEqual(
both.data.rows.map((row) => row.id),
['2africa', 'equiano'],
);
// Only the second part crosses this box; the cable is still found.
const west = await catalog.call('find_submarine_cables', {
area: { bbox: [-25, 10, -10, 20] },
});
assert.deepEqual(west.data.rows, [{ id: '2africa', name: '2Africa' }]);
});
test('the local fetch serves bundled data files and nothing outside them', async () => {
const fetchImpl = createApiFetch({
apiBase: 'http://127.0.0.1:1',
+1
View File
@@ -42,6 +42,7 @@ const label = computed(() =>
:side="side"
:label="label"
:panel-open="active !== null"
collapsible
@select="onSelect"
/>
</template>
+2 -1
View File
@@ -3,6 +3,7 @@ import { cctvSourceLabelText, cctvSourceMessageText } from './cctvFrames.js';
import { createCctvVideoSurface } from './cctvVideo.js';
import { presentCctvCalibration } from './cctvCalibration.js';
import { roundHeadingDegrees } from '../data/headingDegrees.js';
export function _calBadgeLabel(badge) {
switch (badge) {
case 'calibrated':
@@ -107,7 +108,7 @@ function metaText(state, enabled, activeCamera, catalogCount) {
'{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}',
{
city: activeCamera.city,
heading: `${Math.round(activeCamera.headingDeg) % 360}°`,
heading: `${roundHeadingDegrees(Number(activeCamera.headingDeg)) ?? 0}°`,
fov: `${Math.round(activeCamera.fovDeg)}°`,
range: Math.round(activeCamera.rangeM),
projection: projLabel,
+1 -1
View File
@@ -120,7 +120,7 @@ export function updateHud(
(tick) => formatAltitudeRulerTick(tick.valueFt),
);
view.heading = {
text: String(Math.round(heading) % 360).padStart(3, '0'),
text: formatHeadingReadout(heading),
value: heading,
immediate: forceContext,
};
+8 -4
View File
@@ -1,6 +1,11 @@
/** Pure formatting and fixed presentation constants used by Cockpit components. */
import { formatNumber, getLocale, isolate, t, msg } from '../i18n/index.js';
import { formatQuantity } from './unitLabels.js';
import {
normalizeHeading,
roundHeadingDegrees,
} from '../data/headingDegrees.js';
import { kilometreDigits } from '../data/distanceFormat.js';
export const COCKPIT_HEADING_SLEW_DPS = 28;
@@ -116,7 +121,7 @@ export function formatCockpitCoordinate(value, positive, negative) {
* (`4,2 КМ`): one decimal under 10 km, whole kilometres beyond.
*/
export function formatCockpitDistance(distanceM) {
const digits = distanceM < 10000 ? 1 : 0;
const digits = kilometreDigits(distanceM);
const distance = formatQuantity(distanceM / 1000, 'km', {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
@@ -135,8 +140,7 @@ export function cockpitUnitCase(text) {
export function formatCockpitWindDirection(value) {
if (!Number.isFinite(value)) return t('DIR UNKNOWN');
const labels = Object.keys(COMPASS_POINT_LABELS);
const normalized = ((value % 360) + 360) % 360;
// 359.5° and above is north. Modulo keeps that from printing 360°.
const degrees = Math.round(normalized) % 360;
const normalized = normalizeHeading(value);
const degrees = roundHeadingDegrees(value);
return `${compassPointLabel(labels[Math.round(normalized / 45) % labels.length])} · ${degrees}°`;
}
+9 -1
View File
@@ -1,6 +1,9 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { formatCockpitWindDirection } from './cockpitPresentation.js';
import {
formatCockpitDistance,
formatCockpitWindDirection,
} from './cockpitPresentation.js';
test('wind just west of north reads 0°, not 360°', () => {
assert.equal(formatCockpitWindDirection(90), 'E · 90°');
@@ -8,3 +11,8 @@ test('wind just west of north reads 0°, not 360°', () => {
assert.equal(formatCockpitWindDirection(359.4), 'N · 359°');
assert.equal(formatCockpitWindDirection(359.6), 'N · 0°');
});
test('a cockpit distance that rounds to 10.0 km drops the decimal', () => {
assert.match(formatCockpitDistance(9949), /^9\.9\s?KM$/);
assert.match(formatCockpitDistance(9960), /^10\s?KM$/);
});
+2 -3
View File
@@ -1,6 +1,7 @@
/** Tracking identity, entry, exit and contact navigation for the Cockpit controller. */
import { t } from '../i18n/index.js';
import {
formatHeadingReadout,
normalizeHeading,
resolveTrackedAircraftInfo,
} from '../cockpitMath.js';
@@ -186,9 +187,7 @@ export function enter() {
this.startBriefRotation();
const trackLabel =
info.callsign || info.registration || info.icao24 || t('AIRCRAFT');
const trackHeading = String(
Math.round(normalizeHeading(info.track ?? 0)),
).padStart(3, '0');
const trackHeading = formatHeadingReadout(info.track ?? 0);
this.pushCockpitSignal(
'track',
'track',
+72
View File
@@ -71,3 +71,75 @@ test('tips open toward the screen, and not for the open panel', async () => {
await other.trigger('focusout');
assert.equal(wrapper.find('[role="tooltip"]').exists(), false);
});
test('a collapsible rail shows labelled pills until a panel opens', async () => {
const wrapper = mountRail({ collapsible: true });
const nav = wrapper.get('nav');
assert.ok(nav.classes().includes('ui-rail--collapsible'));
assert.ok(nav.classes().includes('ui-rail--expanded'));
assert.deepEqual(
wrapper.findAll('.ui-rail__label').map((label) => label.text()),
['DATA LAYERS', 'AUTHORING', 'CCTV'],
);
// The label is on screen, so its tip would only repeat it.
await wrapper.findAll('.ui-tooltip')[0].trigger('focusin');
assert.equal(wrapper.find('[role="tooltip"]').exists(), false);
// The button keeps its own accessible name; the visible label is hidden
// from assistive technology so the name is not read twice.
const button = wrapper.get('[data-rail-item="layers"]');
assert.equal(button.attributes('aria-label'), 'DATA LAYERS');
assert.equal(button.get('.ui-rail__label').attributes('aria-hidden'), 'true');
await wrapper.setProps({ active: 'layers', panelOpen: true });
assert.ok(nav.classes().includes('ui-rail--collapsible'));
assert.equal(nav.classes().includes('ui-rail--expanded'), false);
// Collapsed, the other items' tips come back.
await wrapper.findAll('.ui-tooltip')[1].trigger('focusin');
await new Promise((resolve) => setTimeout(resolve, 450));
assert.equal(wrapper.get('[role="tooltip"]').text(), 'AUTHORING');
});
test('a label cut short by the pill keeps its tip', async (t) => {
const observers = [];
const original = globalThis.ResizeObserver;
globalThis.ResizeObserver = class {
constructor(callback) {
this.callback = callback;
this.targets = [];
observers.push(this);
}
observe(target) {
this.targets.push(target);
}
unobserve() {}
disconnect() {}
};
t.after(() => (globalThis.ResizeObserver = original));
const wrapper = mountRail({ collapsible: true });
const [observer] = observers;
const [layers, scenes] = observer.targets;
Object.defineProperties(layers, {
scrollWidth: { value: 260 },
clientWidth: { value: 220 },
});
Object.defineProperties(scenes, {
scrollWidth: { value: 90 },
clientWidth: { value: 90 },
});
observer.callback([{ target: layers }, { target: scenes }]);
await wrapper.vm.$nextTick();
await wrapper.findAll('.ui-tooltip')[0].trigger('focusin');
assert.equal(wrapper.get('[role="tooltip"]').text(), 'DATA LAYERS');
await wrapper.findAll('.ui-tooltip')[0].trigger('focusout');
await wrapper.findAll('.ui-tooltip')[1].trigger('focusin');
assert.equal(wrapper.find('[role="tooltip"]').exists(), false);
});
test('a plain rail has no labels', () => {
const wrapper = mountRail();
assert.equal(wrapper.find('.ui-rail__label').exists(), false);
assert.equal(
wrapper.get('nav').classes().includes('ui-rail--expanded'),
false,
);
});
+58 -3
View File
@@ -1,9 +1,10 @@
<script setup>
import { computed, onBeforeUnmount, reactive } from 'vue';
import UiIcon from './UiIcon.vue';
import UiTooltip from './UiTooltip.vue';
import { RAIL_PANEL_OPEN_TOOLTIP_DELAY_MS } from './rail.js';
defineProps({
const props = defineProps({
/** @type {import('vue').PropType<import('./rail.js').UiRailItem[]>} */
items: { type: Array, required: true },
/** The selected item's id, or null. */
@@ -17,18 +18,65 @@ defineProps({
* panel's title, and the other tips wait for the pointer to rest.
*/
panelOpen: { type: Boolean, default: false },
/**
* Show each item as a labelled pill while no panel is open, and collapse to
* icons once one is, making room for the panel.
*/
collapsible: { type: Boolean, default: false },
});
const emit = defineEmits(['select']);
const expanded = computed(() => props.collapsible && !props.panelOpen);
// A label cut short by the pill's width keeps its tip, so the full name can
// still be read. Each label is measured whenever its box changes size, which
// includes the moment it settles after expanding.
const truncated = reactive(new Set());
const labelIds = new WeakMap();
const labelElements = new Map();
const labelObserver =
typeof ResizeObserver === 'function'
? new ResizeObserver((entries) => {
for (const { target } of entries) {
const id = labelIds.get(target);
if (target.scrollWidth > target.clientWidth) truncated.add(id);
else truncated.delete(id);
}
})
: null;
function trackLabel(id, element) {
const previous = labelElements.get(id);
if (previous === element) return;
if (previous) {
labelObserver?.unobserve(previous);
labelElements.delete(id);
truncated.delete(id);
}
if (!element) return;
labelElements.set(id, element);
labelIds.set(element, id);
labelObserver?.observe(element);
}
onBeforeUnmount(() => labelObserver?.disconnect());
const tipHidden = (item) =>
(expanded.value && !truncated.has(item.id)) ||
(props.panelOpen && item.id === props.active);
</script>
<template>
<nav class="ui-rail" :class="`ui-rail--${side}`" :aria-label="label">
<nav
class="ui-rail"
:class="[
`ui-rail--${side}`,
{ 'ui-rail--collapsible': collapsible, 'ui-rail--expanded': expanded },
]"
:aria-label="label"
>
<template v-for="item in items" :key="item.id">
<i v-if="item.separatorBefore" class="ui-rail__sep" aria-hidden="true" />
<UiTooltip
:text="item.label"
:side="side === 'left' ? 'right' : 'left'"
:disabled="panelOpen && item.id === active"
:disabled="tipHidden(item)"
:delay="panelOpen ? RAIL_PANEL_OPEN_TOOLTIP_DELAY_MS : 0"
>
<button
@@ -42,6 +90,13 @@ const emit = defineEmits(['select']);
@click="emit('select', item.id)"
>
<UiIcon :name="item.icon" />
<span
v-if="collapsible"
:ref="(element) => trackLabel(item.id, element)"
class="ui-rail__label"
aria-hidden="true"
>{{ item.label }}</span
>
<span v-if="item.badge !== undefined" class="ui-rail__badge">{{
item.badge
}}</span>
+10 -4
View File
@@ -66,13 +66,19 @@ test('the Cyber cockpit keeps Data Layers on the left edge in every language', (
test('the panel rails stay on the outer side of their stacks in right-to-left', () => {
const layers = sheet('layers.css');
// Each rail is placed against its stack's outer edge; a physical side
// would put it over the panels once the stacks swap sides.
// Each rail is placed by its screen-side edge; a physical side would put
// it over the panels once the stacks swap sides.
const left = ruleBody(layers, '.gev-panel-rail--left');
assert.match(left, /inset-inline-end: calc\(100% \+ 6px\);/);
assert.match(
left,
/inset-inline-start: calc\(-1 \* var\(--panel-rail-clearance\)\);/,
);
assert.doesNotMatch(left, /(?:^|\s)(?:left|right):/);
const right = ruleBody(layers, '.gev-panel-rail--right');
assert.match(right, /inset-inline-start: calc\(100% \+ 6px\);/);
assert.match(
right,
/inset-inline-end: calc\(-1 \* var\(--panel-rail-clearance\)\);/,
);
assert.doesNotMatch(right, /(?:^|\s)(?:left|right):/);
// Their tips open toward the middle of the screen.
const kit = sheet('kit.css');
+2 -2
View File
@@ -7,6 +7,7 @@ import {
translateKnown,
} from '../i18n/index.js';
import { keyGuidanceText, keyRequirementText } from './keyGuidance.js';
import { roundHeadingDegrees } from '../data/headingDegrees.js';
// Street Level UI state to panel strings and flags. Pure, so it is testable.
@@ -175,9 +176,8 @@ function presentViewer(state) {
const { street } = state;
const right = [];
if (street.isPano) right.push('360°');
// 359.5° and above rounds to north; modulo keeps it from printing 360°.
if (Number.isFinite(street.bearing))
right.push(`${Math.round(street.bearing) % 360}°`);
right.push(`${roundHeadingDegrees(street.bearing)}°`);
if (street.capturedAt)
right.push(formatDate(street.capturedAt, street.capturedAtPrecision));
const left = [];
+30 -7
View File
@@ -495,6 +495,36 @@ body:not(.ui-clean-view):not(.recording-mode):has(
#command-dock .gev-voice-error-tray {
width: min(24rem, calc(100vw - 1.5rem));
}
/* The wings shrink to --dock-side-compact at this width, open or closed (an
open tray pops out above the dock), which is too narrow for the text
labels; they would spill over the voice control and take its taps. The
wings show only their icon; the toggles keep their aria-label. Cyber sets
a wider --dock-side-compact that fits its labels, so it keeps them. */
:root:not([data-ui-theme='cyber'])
#command-dock
:is(#location-bar, #control-panel)
.panel-title,
:root:not([data-ui-theme='cyber'])
#command-dock
:is(#location-bar, #control-panel)
.location-toolbar-label {
font-size: 0;
letter-spacing: 0;
/* The empty text is still a flex item; drop the gap it would claim. */
gap: 0;
}
:root:not([data-ui-theme='cyber'])
#command-dock
:is(#location-bar, #control-panel)
.panel-title::after,
:root:not([data-ui-theme='cyber'])
#command-dock
:is(#location-bar, #control-panel)
.location-toolbar-label::after {
content: none;
}
}
@media (max-width: 620px) {
@@ -503,13 +533,6 @@ body:not(.ui-clean-view):not(.recording-mode):has(
--dock-voice-width: 8.75rem;
}
#command-dock .panel-title,
#command-dock .location-toolbar-label {
max-width: 2.3rem;
overflow: hidden;
text-overflow: ellipsis;
}
#left-panel-stack {
bottom: calc(50vh + 8px);
}
+45
View File
@@ -116,6 +116,51 @@
opacity: 0.35;
cursor: default;
}
/* A collapsible rail shows each item as an icon and its label while no panel
is open, and folds the labels away once one is. The collapsed item keeps
the plain rail's 32px square. */
.ui-rail--collapsible {
transition: border-radius var(--transition-fast);
}
.ui-rail--collapsible .ui-rail__item {
display: flex;
align-items: center;
justify-content: flex-start;
width: 100%;
min-width: 32px;
padding: 0 6px;
transition:
color var(--transition-fast),
background var(--transition-fast),
border-color var(--transition-fast),
border-radius var(--transition-fast);
}
.ui-rail__label {
max-width: 0;
overflow: hidden;
opacity: 0;
white-space: nowrap;
text-overflow: ellipsis;
font: var(--dock-label-font-weight) var(--dock-label-font-size) / 1
var(--font-sans);
letter-spacing: var(--dock-label-letter-spacing);
transition:
max-width var(--transition-fast),
margin var(--transition-fast),
opacity var(--transition-fast);
}
.ui-rail--expanded {
border-radius: 21px;
}
.ui-rail--expanded .ui-rail__item {
border-radius: 16px;
}
.ui-rail--expanded .ui-rail__label {
max-width: 220px;
margin-inline: 8px 8px;
opacity: 1;
}
.ui-rail__badge {
position: absolute;
top: 1px;
+5 -2
View File
@@ -255,11 +255,14 @@
pointer-events: auto;
transition: top var(--transition-fast);
}
/* Each rail keeps its outer edge at the screen-side clearance, so a rail that
widens (labelled pills while its side has no open panel) grows toward the
middle of the screen. Collapsed, it sits 6px outside its stack. */
.gev-panel-rail--left {
inset-inline-end: calc(100% + 6px);
inset-inline-start: calc(-1 * var(--panel-rail-clearance));
}
.gev-panel-rail--right {
inset-inline-start: calc(100% + 6px);
inset-inline-end: calc(-1 * var(--panel-rail-clearance));
}
body.mobile-shell #left-panel-stack > .gev-panel-rail,
body.mobile-shell #right-context-rail > .gev-panel-rail,
+2 -1
View File
@@ -47,7 +47,8 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea
// is consolidated without changing either feature's arguments.
// Snow leaves the set_visual_style enum.
// The Operator HUD layout leaves set_hud's layout enum.
'f47a7f78f13b1f79978fa524fad2618e32bd813e1a5dfd746bf35d29909fc91b',
// The layer alias hint leaves out aliases the layer id already spells.
'e65812db60a2702cebb496fca257cb04a28f1bbbf250cb9ab65a3db33005e0c8',
);
});
+15 -6
View File
@@ -525,22 +525,31 @@ const SELF_EVIDENT_IDS = new Set([
'radio',
'wind',
'directions',
// The brand is already the layer id; all spoken aliases remain accepted.
's2-ghostmaps',
]);
/** Lower case with spaces and hyphens removed: "Data centers" → "datacenters". */
const spelled = (text) => text.toLowerCase().replace(/[\s-]+/g, '');
/**
* Compact model-facing alias hint: "alias/alias→id; …" for toggleable layers
* whose id is not already the spoken word. At most two aliases per layer keep
* the tool JSON small; the runner accepts the full alias list.
* the tool JSON small; the runner accepts the full alias list. Of those two,
* an alias the id already spells ("dams"→local-dams, "weather radar"→
* weather-radar) is left out, and a layer with neither left gets no hint.
* @returns {string} One line for the layerId description.
*/
export function voiceLayerAliasHint() {
return VOICE_LAYER_MANIFEST.filter(
(entry) =>
entry.toggle && entry.aliases.length && !SELF_EVIDENT_IDS.has(entry.id),
(entry) => entry.toggle && !SELF_EVIDENT_IDS.has(entry.id),
)
.map((entry) => `${entry.aliases.slice(0, 2).join('/')}→${entry.id}`)
.map((entry) => ({
id: entry.id,
aliases: entry.aliases
.slice(0, 2)
.filter((alias) => !spelled(entry.id).includes(spelled(alias))),
}))
.filter((entry) => entry.aliases.length)
.map((entry) => `${entry.aliases.join('/')}→${entry.id}`)
.join(';');
}
+18
View File
@@ -9,6 +9,7 @@ import {
VOICE_TOGGLE_LAYER_IDS,
VOICE_CONTEXT_LAYER_IDS,
VOICE_QUERY_LAYER_IDS,
voiceLayerAliasHint,
voiceLayerAliasMap,
} from './layerManifest.js';
@@ -78,6 +79,23 @@ test('tool enums are generated from the manifest', () => {
assert.ok(panels.includes('weather-panel') && panels.includes('recent-imagery-panel'));
});
test('the alias hint leaves out names the layer id already spells', () => {
const hint = voiceLayerAliasHint();
const entries = hint.split(';').map((entry) => entry.split('→'));
const flat = (text) => text.toLowerCase().replace(/[\s-]+/g, '');
for (const [aliases, id] of entries) {
for (const alias of aliases.split('/')) {
assert.ok(!flat(id).includes(flat(alias)), `"${alias}" is already spelled by ${id}`);
}
}
const ids = entries.map(([, id]) => id);
assert.ok(!ids.includes('local-dams'), 'a layer left with no alias gets no entry');
assert.ok(!ids.includes('weather-radar'));
assert.match(hint, /(^|;)aqhi→air-quality(;|$)/, 'an alias the id does not spell stays');
assert.match(hint, /(^|;)hurricanes→weather-cyclones(;|$)/);
assert.match(hint, /(^|;)fires\/wildfires→local-firms(;|$)/);
});
test('spoken aliases resolve to real layers, keep every legacy phrase and never collide', () => {
const aliases = voiceLayerAliasMap();
for (const id of aliases.values()) assert.ok(manifestIds.includes(id), id);
+3 -3
View File
@@ -74,9 +74,9 @@ function listPhrase(items) {
}
function feetRounded(meters) {
const value = Number(meters);
return Number.isFinite(value)
? Math.round((value * 3.28084) / 100) * 100
// A missing altitude is unknown, not sea level: Number(null) is 0.
return Number.isFinite(meters)
? Math.round((meters * 3.28084) / 100) * 100
: null;
}
+28
View File
@@ -218,6 +218,34 @@ test('nearest-aircraft results name the aircraft, distance and altitude briefly'
assert.equal(stale.say, 'Selected AAL1, 3.3 km from Austin, stale.');
});
test('nearest-aircraft results leave out an altitude the feed did not report', () => {
// gevActions sends `altitudeM: null` for an airborne military contact
// without a barometric altitude; that is unknown, not sea level.
const unknown = envelope('select_nearest_aircraft', {
ok: true,
location: 'Ramstein',
layerId: 'military',
feed: { state: 'nominal' },
aircraft: { callsign: 'RCH123', altitudeM: null, distanceKm: 8.2 },
});
assert.equal(unknown.say, 'Selected RCH123, 8.2 km from Ramstein.');
assert.deepEqual(unknown.display.lines, ['8.2\u00a0km from Ramstein']);
// A reported sea-level altitude is still spoken.
const seaLevel = envelope('select_nearest_aircraft', {
ok: true,
location: 'Ramstein',
layerId: 'military',
feed: { state: 'nominal' },
aircraft: { callsign: 'RCH123', altitudeM: 0, distanceKm: 8.2 },
});
assert.equal(seaLevel.say, 'Selected RCH123, 8.2 km from Ramstein, 0 feet.');
assert.deepEqual(seaLevel.display.lines, [
'8.2\u00a0km from Ramstein',
'Altitude 0 feet',
]);
});
test('frame_overhead keeps the essential scope audible and lists referents', () => {
const framed = envelope('frame_overhead', {
ok: true,