mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Merge branch 'main' into fix/e-satellite-launch-year
This commit is contained in:
commit
6a01355560
34 files changed
+405
-56
No files matched your search
@@ -0,0 +1,6 @@
|
||||
- Show a Contacts marker 999.5 m or more away as 1.0 km. The on-globe marker
|
||||
chose metres before rounding, so 999.6 m read "1000 m" while the panel read
|
||||
"1.0 km" (Aditya, @Rohilalala, #1433).
|
||||
- Distance labels share one formatter that takes each unit cut after rounding,
|
||||
so the Contacts panel and markers, the drawn-line measure and the Cockpit
|
||||
show 9.96 km as 10 km rather than 10.0 km (#1433).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Selecting the nearest aircraft by voice no longer says "0 feet" for a contact
|
||||
whose feed reported no altitude; the reply and card leave the altitude out
|
||||
(Aditya, @Rohilalala, #1434).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Keep Street Traffic's flow status honest at the edges. A few TomTom-matched
|
||||
dots among hundreds rounded to 0% and read "(no matches)", and a few
|
||||
simulated dots rounded to "100% cov"; coverage now stays within 1–99% while
|
||||
both are shown (Aditya, @Rohilalala, #1442).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Run Space Missions' Replay Ascent countdown from T−10 after the five-second
|
||||
tile hold; the hold ran 10 s and the countdown started at T−05 (Aditya,
|
||||
@Rohilalala, #1443).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Keep Space Missions' replay countdown in real time when the replay speed
|
||||
changes before liftoff; 0.25× had stretched T−8 to T−32 (Aditya,
|
||||
@Rohilalala, #1444).
|
||||
@@ -0,0 +1,3 @@
|
||||
- List each submarine cable once in find_submarine_cables. TeleGeography
|
||||
splits 17 cables (2Africa, Echo, Hawaiki) into several features, so a
|
||||
search for Echo read "3 submarine cables" (Aditya, @Rohilalala, #1449).
|
||||
@@ -2,4 +2,4 @@
|
||||
looks up place names when no OpenAI key is configured, reverse-geocodes at
|
||||
most two points instead of up to four, and looks up again only once the view
|
||||
shows a different area, not after every small pan, so a small pan no longer
|
||||
asks OpenAI for a new summary either (samehkhamis, #0000).
|
||||
asks OpenAI for a new summary either (samehkhamis, #1459).
|
||||
+26
-6
@@ -197,6 +197,9 @@ catalog, loaded on first use. See [tools and the MCP server](TOOLS.md).
|
||||
Aircraft altitude-band queries require a reported barometric or geometric altitude
|
||||
for each match, so a grounded contact that reports none is left out of a band too.
|
||||
Queries without an altitude band retain unknown-altitude contacts.
|
||||
`find_submarine_cables` lists and counts each cable once: TeleGeography splits
|
||||
a few cables (2Africa, Echo) into several features under one id, and such a
|
||||
cable matches when any of its parts crosses the area.
|
||||
|
||||
Catalog calls check their cancellation signal immediately before executing a
|
||||
tool. Already-cancelled queries do not start; cancellation after execution starts
|
||||
@@ -4616,8 +4619,21 @@ silent for five minutes are removed. Credits appear when the feed is used.
|
||||
coordinator, but it is not a user-visible Data Layers entry. Its visible entry
|
||||
point is the right-side `CONTEXT` chooser's `CONTACTS` mode.
|
||||
|
||||
Contacts distance labels select metres or kilometres after rounding to the
|
||||
nearest metre, so a 999.5-metre contact displays as `1.0 km`, not `1000 m`.
|
||||
Contacts distance labels, in the panel and on the globe's contact markers,
|
||||
select metres or kilometres after rounding to the nearest metre, so a
|
||||
999.5-metre contact displays as `1.0 km`, not `1000 m`, and drop the
|
||||
kilometre decimal from 9.95 km, so 9,960 m displays as `10 km`.
|
||||
|
||||
Distance labels that switch between metres and kilometres share
|
||||
`distanceParts` / `formatDistanceText` in `src/data/distanceFormat.js`: the
|
||||
Contacts panel and markers, Directions rows and step cards, placed annotation
|
||||
labels, the drawn-line measure and `formatRouteDistance`. Each cut is taken on
|
||||
the value as it prints, and kilometres come back already rounded, so the
|
||||
plain and localized labels agree. Callers choose the metre step (whole metres;
|
||||
tens, as annotation labels use; or tens with whole metres under 100, as
|
||||
Directions uses) and keep their own rendering; the Cockpit's kilometre-only
|
||||
distance uses `kilometreDigits`. The HUD altitude tag and the spoken view
|
||||
height still choose their own unit.
|
||||
|
||||
At global scale, ambient Radio cluster badges are hard-opacity shared-host
|
||||
entries: count/category updates and identity replacement do not run keyhole or
|
||||
@@ -5267,6 +5283,8 @@ and unreachable upstream (502/504) separately from road geometry.
|
||||
cannot race the nearest-aircraft query ahead of layer enablement. A healthy fallback feed is
|
||||
queried normally and its source is returned with the selection; fallback with
|
||||
no airborne records remains an honest no-data result, not an enable failure.
|
||||
The spoken reply and the card leave out an altitude the feed did not report
|
||||
rather than reading it as 0 feet.
|
||||
- Voice treats the parent Context panel and Contacts as separate intents. An
|
||||
explicit request to open Context expands only `global-context-panel`; it does
|
||||
not choose a mode. An explicit request to open Contacts expands that parent
|
||||
@@ -5503,7 +5521,7 @@ request viewbox and continues searching without that geographic preference.
|
||||
- **A vertex is a lon/lat, and the mark is DRAPED.** Clicks are picked through `pickWorldFromScreen` — one of two scene helpers the annotation resolver factory (`src/annotations/resolver.js`) exposes alongside `sampleGroundHeight`, so the app has ONE depth-aware pick cascade rather than a second written beside it — which returns the height the pick landed on as well as the coordinate, so the live preview hangs on the roof or hillside under the pointer instead of sinking to sea level. The world renderer draws areas and routes with `clampToGround`, so the height is dropped at finish in one place (`finishSpec`) rather than being carried downstream and discarded silently: what you get is the outline you drew, lying on the surface beneath it.
|
||||
- **Draped marks classify onto `ClassificationType.BOTH`** — terrain AND 3D tiles — not onto 3D tiles alone. With only `CESIUM_3D_TILE`, every area fill, area outline, route and arrow rendered to nothing on a keyless boot, where Cesium's own globe carries Esri/OSM imagery and there are no tiles to classify against; the labels appeared and the geometry did not. That hit spoken annotations exactly as hard as hand-drawn ones. `scripts/qa-draw-tool.mjs` counts the mark's own colour in the rendered frame before and after a shape is finished, on a keyed AND a keyless server, so "the board says there is a mark" can no longer pass for "the mark is on screen".
|
||||
- **A finished area is a CLOSED ring.** The fill polygon closes itself but the outline beside it is a polyline, so an open ring drew every edge except the one back to the first vertex — a triangle with two sides. `finishSpec` repeats the first position at the end. A line is never closed: joining its ends would invent a segment nobody drew.
|
||||
- Drawn-line distance labels select metres or kilometres after rounding to the nearest metre, so a 999.5-metre line displays as `1.0 km` rather than `1000 m`. The placed mark's label (shared with spoken routes and arrows) rounds before each unit cut, so a 996-metre line is labelled `1.0 km`, not `1000 m`, and a 9,960-metre one `10 km`, not `10.0 km`.
|
||||
- Drawn-line distance labels select metres or kilometres after rounding to the nearest metre, so a 999.5-metre line displays as `1.0 km` rather than `1000 m`, and drop the kilometre decimal from 9.95 km. The placed mark's label (shared with spoken routes and arrows) rounds before each unit cut, so a 996-metre line is labelled `1.0 km`, not `1000 m`, and a 9,960-metre one `10 km`, not `10.0 km`.
|
||||
- **Measurements and centres are computed on unwrapped longitudes** (`unwrapLongitudes` / `wrapLongitude` in `drawMode.js`, used by `ringAreaM2`, `ringCentroid` and the engine's manual-area anchor). A ~50,000 m² rectangle straddling the antimeridian reads its 0.002° width as 359.998° under raw arithmetic: it measured thousands of square kilometres and anchored on the Greenwich meridian, half a world from the shape. Path length was already safe — haversine is periodic in the longitude delta — and a test pins that so a "fix" cannot break it.
|
||||
- **Bounds.** One shape holds at most `MAX_VERTICES` = 512 points; two clicks within 0.5 m are one vertex (that is what makes a double-click finish without leaving a stray point); a vertex that is not a finite on-globe coordinate is refused. A shape that encloses nothing is refused rather than placed: three collinear points are not an area and a line under a metre long is not a line, and the hint says which.
|
||||
- Drawn-area measures round before choosing m², ha or km², so 9,999.6 m² displays as `1.0 ha`, not `10000 m²`, and 999,960 m² as `1.00 km²`, not `100.0 ha`.
|
||||
@@ -5972,9 +5990,11 @@ easier to meet (detection is now on more often), but does not create it.
|
||||
OSM choice), so Hybrid fill is never replayed as OSM roads. Failed flow
|
||||
refreshes clear old matches and report simulation. `flowCoveragePct` is the
|
||||
percentage of shown dots on roads with flow, including all unmatched dots in
|
||||
the denominator and excluding dots hidden by closures. The OSM keyed status
|
||||
reads `LIVE · Roads: OpenStreetMap · Flow: TomTom · N% cov`. Road fetch
|
||||
coverage follows a 5×5 ellipsoid sample of the central screen square.
|
||||
the denominator and excluding dots hidden by closures. It reads 1–99 whenever
|
||||
both matched and simulated dots are shown, so rounding never turns a few
|
||||
matches into `(no matches)` or a few simulated dots into full coverage. The
|
||||
OSM keyed status reads `LIVE · Roads: OpenStreetMap · Flow: TomTom · N% cov`.
|
||||
Road fetch coverage follows a 5×5 ellipsoid sample of the central screen square.
|
||||
Horizon rays are capped at 10 km from nadir (reduced if necessary to fit
|
||||
16 z12 tiles); polygon-intersecting tiles load nearest first. Up to 16 z14
|
||||
tiles provide near detail, with z12 roads beyond them. Cached tiles serve
|
||||
|
||||
@@ -1182,5 +1182,6 @@
|
||||
"src/ui/stores/firstRun.js",
|
||||
"src/firstRunLauncherView.test.mjs",
|
||||
"src/firstRunCopy.js",
|
||||
"src/data/headingDegrees.test.mjs"
|
||||
"src/data/headingDegrees.test.mjs",
|
||||
"src/data/distanceFormat.test.mjs"
|
||||
]
|
||||
@@ -205,6 +205,7 @@
|
||||
"runtime": "node",
|
||||
"exports": ["./server/providers/places"],
|
||||
"modules": [
|
||||
"src/data/distanceFormat.js",
|
||||
"server/providers/places.js",
|
||||
"server/providers/places/google.js",
|
||||
"server/providers/places/google-key.js",
|
||||
@@ -391,6 +392,7 @@
|
||||
"runtime": "node",
|
||||
"exports": ["./server/providers/overpass"],
|
||||
"modules": [
|
||||
"src/data/distanceFormat.js",
|
||||
"server/providers/common/geo.js",
|
||||
"server/providers/common/http.js",
|
||||
"server/providers/common/rate-limit.js",
|
||||
@@ -816,6 +818,7 @@
|
||||
],
|
||||
"modules": [
|
||||
"src/data/headingDegrees.js",
|
||||
"src/data/distanceFormat.js",
|
||||
"src/ui/cockpit.js",
|
||||
"src/ui/cockpitBriefing.js",
|
||||
"src/ui/cockpitBriefPages.js",
|
||||
@@ -886,6 +889,7 @@
|
||||
"exports": ["./ui/shell"],
|
||||
"modules": [
|
||||
"src/data/headingDegrees.js",
|
||||
"src/data/distanceFormat.js",
|
||||
"src/i18n/marker.js",
|
||||
"src/ui/mobileShell.js",
|
||||
"src/exclusiveSurfaces.js",
|
||||
@@ -1652,6 +1656,7 @@
|
||||
"exports": ["./layers/awareness"],
|
||||
"modules": [
|
||||
"src/data/headingDegrees.js",
|
||||
"src/data/distanceFormat.js",
|
||||
"src/i18n/marker.js",
|
||||
"src/cyberSonar.js",
|
||||
"src/cockpitMath.js",
|
||||
@@ -2199,6 +2204,7 @@
|
||||
],
|
||||
"modules": [
|
||||
"src/data/headingDegrees.js",
|
||||
"src/data/distanceFormat.js",
|
||||
"src/ui/placeNames.js",
|
||||
"src/cyberSonarScene.js",
|
||||
"src/cyberSonar.js",
|
||||
@@ -3174,6 +3180,7 @@
|
||||
"exports": ["./application/layers"],
|
||||
"modules": [
|
||||
"src/data/headingDegrees.js",
|
||||
"src/data/distanceFormat.js",
|
||||
"src/cyberSonar.js",
|
||||
"src/data/approxDistance.js",
|
||||
"src/data/bhoteKoshiEvent.js",
|
||||
@@ -4054,6 +4061,7 @@
|
||||
"directions-layer": {
|
||||
"exports": ["./layers/directions"],
|
||||
"modules": [
|
||||
"src/data/distanceFormat.js",
|
||||
"src/i18n/marker.js",
|
||||
"src/layers/directions/index.js",
|
||||
"src/data/pointerOwnerNames.js",
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
sampleGroundHeight,
|
||||
} from './annotationResolver.js';
|
||||
import { ringCentroid } from './drawMode.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).
|
||||
@@ -1572,12 +1573,7 @@ function greatCircleM(a, b) {
|
||||
}
|
||||
|
||||
function formatDistance(m) {
|
||||
if (!Number.isFinite(m)) return null;
|
||||
// Round before each unit cut: 996 m was printed as "1000 m" and
|
||||
// 9,960 m as "10.0 km".
|
||||
const tens = Math.round(m / 10) * 10;
|
||||
if (tens < 1000) return `${tens} m`;
|
||||
return `${(m / 1000).toFixed(Math.round(m / 100) >= 100 ? 0 : 1)} km`;
|
||||
return formatDistanceText(m, { metreStep: 10 });
|
||||
}
|
||||
|
||||
function composeRouteLabel(baseLabel, distM, durS, mode, fallback) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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,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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }) ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,18 +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;
|
||||
// Pick the unit from the tenth it prints: 9.96 km read "10.0 km".
|
||||
return Math.round(meters / 100) < 100
|
||||
? 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,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Vendored
+13
-2
@@ -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,
|
||||
|
||||
@@ -516,6 +516,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');
|
||||
|
||||
@@ -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}"` : '';
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
normalizeHeading,
|
||||
roundHeadingDegrees,
|
||||
} from '../data/headingDegrees.js';
|
||||
import { kilometreDigits } from '../data/distanceFormat.js';
|
||||
|
||||
export const COCKPIT_HEADING_SLEW_DPS = 28;
|
||||
|
||||
@@ -120,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,
|
||||
|
||||
@@ -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$/);
|
||||
});
|
||||
+3
-3
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user