mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
Merge branch 'sameh/world-clock' into sameh/world-timeline-launches
This commit is contained in:
commit
0d96fe6acd
188 files changed
+4471
-520
No files matched your search
@@ -5,6 +5,7 @@ import { greatCircleMeters } from '../geoDistance.js';
|
||||
import {
|
||||
holdContinuousRender,
|
||||
releaseContinuousRender,
|
||||
governorRequestRender,
|
||||
} from '../renderGovernor.js';
|
||||
import {
|
||||
isRateLimitedOutcome,
|
||||
@@ -12,6 +13,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).
|
||||
@@ -144,6 +146,17 @@ export function createAnnotationEngine({
|
||||
// task with the FINAL outcome — resolved or failed — so narration that said
|
||||
// "tracing the boundary now" can later honestly confirm or correct itself.
|
||||
const outlineListeners = new Set();
|
||||
// Told whenever the set of marks, or one mark's look, changes (Draw's list).
|
||||
const changeListeners = new Set();
|
||||
const notifyChange = () => {
|
||||
for (const listener of changeListeners) {
|
||||
try {
|
||||
listener();
|
||||
} catch {
|
||||
/* one listener's failure must not stop the others */
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function emitOutlineEvent(evt) {
|
||||
for (const listener of outlineListeners) {
|
||||
@@ -189,8 +202,11 @@ export function createAnnotationEngine({
|
||||
// World annotations carry persistent per-frame scene animation (pulsing
|
||||
// live colors, camera-scaled rings, route-flow uniforms in the renderer),
|
||||
// so the scene must render continuously while any mark exists. (perf wave 2)
|
||||
// Only a mark someone can see needs every frame drawn: a board of marks
|
||||
// hidden from Draw's list lets the scene idle.
|
||||
function syncAnnotationHold() {
|
||||
if (annotations.size > 0) holdContinuousRender('annotations');
|
||||
const visible = [...annotations.values()].some((anno) => !anno.hidden);
|
||||
if (visible) holdContinuousRender('annotations');
|
||||
else releaseContinuousRender('annotations');
|
||||
}
|
||||
|
||||
@@ -219,6 +235,7 @@ export function createAnnotationEngine({
|
||||
const now = performance.now();
|
||||
let changed = false;
|
||||
let pending = false; // any mark still fading in / out, or with a TTL fade ahead
|
||||
let expired = false;
|
||||
for (const anno of annotations.values()) {
|
||||
const next = computeAlpha(anno, now);
|
||||
if (next !== anno.alpha) {
|
||||
@@ -229,6 +246,7 @@ export function createAnnotationEngine({
|
||||
annotations.delete(anno.id);
|
||||
renderer.remove(anno);
|
||||
changed = true;
|
||||
expired = true;
|
||||
continue;
|
||||
}
|
||||
if (pendingAnimation(anno, now)) pending = true;
|
||||
@@ -236,7 +254,11 @@ export function createAnnotationEngine({
|
||||
if (changed) {
|
||||
renderer.sync(annotations);
|
||||
syncAnnotationHold();
|
||||
// The frame that takes a just-hidden mark off: with every mark hidden
|
||||
// the hold is gone, and nothing else would draw it.
|
||||
governorRequestRender('annotations');
|
||||
}
|
||||
if (expired) notifyChange();
|
||||
// Stop once the board is STABLE — persistent marks (persist=true is the default)
|
||||
// finish fading in after ~260ms and never change again, so without this the loop
|
||||
// would spin O(N) every frame forever. ensureTicking() restarts on the next
|
||||
@@ -466,6 +488,7 @@ export function createAnnotationEngine({
|
||||
if (ids.length) {
|
||||
renderer.sync(annotations);
|
||||
ensureTicking();
|
||||
notifyChange();
|
||||
}
|
||||
if (opts.flyTo && firstAnchor) frameAnnotation(firstAnchor);
|
||||
else if (ids.length) ensureMarksVisible(ids);
|
||||
@@ -795,6 +818,11 @@ export function createAnnotationEngine({
|
||||
* degenerate geocode stacks on one point (several Capitol monuments) would erase each other. */
|
||||
function findDuplicate(anno) {
|
||||
if (!anno.anchor) return null;
|
||||
// A shape drawn by hand is always new: each click is meant, and every
|
||||
// shape is unnamed until its row names it, so two nearby pins would
|
||||
// otherwise read as one and the second would replace or vanish into the
|
||||
// first.
|
||||
if (anno.manual) return null;
|
||||
const near = (a, b) =>
|
||||
a &&
|
||||
b &&
|
||||
@@ -813,6 +841,10 @@ export function createAnnotationEngine({
|
||||
};
|
||||
for (const ex of annotations.values()) {
|
||||
if (ex.type !== anno.type || !ex.anchor) continue;
|
||||
// A shape drawn by hand and a voice mark are never the same mark: a
|
||||
// match would let a voice TTL expire the drawn one, or replace it. A
|
||||
// hidden mark is not refreshed either; a new shape there is new.
|
||||
if (Boolean(ex.manual) !== Boolean(anno.manual) || ex.hidden) continue;
|
||||
// Identity is GEOMETRY, not label: "Marina" and "Marina District" resolve to the SAME
|
||||
// polygon/anchor and must collapse to one mark (the caller replaces label/color in place
|
||||
// so the latest caption wins). Resolution is deterministic, so the same place re-resolves
|
||||
@@ -885,6 +917,11 @@ export function createAnnotationEngine({
|
||||
alpha: 0, // animate in
|
||||
bornAt: now,
|
||||
expiring: false,
|
||||
// Drawn by hand (drawTool.js): listed under Draw, renamed and recoloured
|
||||
// there. `name` is the person's own label, before any distance is added.
|
||||
manual: spec?.manual === true,
|
||||
name: spec?.manual === true ? cleanLabel(spec?.label) || null : null,
|
||||
hidden: false,
|
||||
};
|
||||
|
||||
if (type === 'route') {
|
||||
@@ -970,6 +1007,97 @@ export function createAnnotationEngine({
|
||||
annotations.clear();
|
||||
renderer.sync(annotations);
|
||||
syncAnnotationHold();
|
||||
notifyChange();
|
||||
}
|
||||
|
||||
/** Take one mark off the board at once. */
|
||||
function remove(id) {
|
||||
const anno = annotations.get(id);
|
||||
if (!anno) return false;
|
||||
annotations.delete(id);
|
||||
renderer.remove(anno);
|
||||
renderer.sync(annotations);
|
||||
syncAnnotationHold();
|
||||
notifyChange();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Hide or show one mark; a hidden mark stays on the board at alpha 0. */
|
||||
function setHidden(id, hidden) {
|
||||
const anno = annotations.get(id);
|
||||
if (!anno) return false;
|
||||
anno.hidden = Boolean(hidden);
|
||||
// Skip the fade-in, so showing it again is immediate.
|
||||
anno.bornAt = Math.min(anno.bornAt, performance.now() - 260);
|
||||
// Take it off (or put it back) now, not when its alpha next changes: a
|
||||
// mark hidden before its first frame has no alpha change to wait for.
|
||||
renderer.sync(annotations);
|
||||
syncAnnotationHold();
|
||||
governorRequestRender('annotations');
|
||||
ensureTicking();
|
||||
notifyChange();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename or recolour a hand-drawn mark. The renderers build a mark's look
|
||||
* when it is added, so the mark is added again with the new look.
|
||||
*/
|
||||
function restyle(id, { name, color } = {}) {
|
||||
const anno = annotations.get(id);
|
||||
if (!anno?.manual) return false;
|
||||
if (name !== undefined) {
|
||||
anno.name = cleanLabel(name) || null;
|
||||
anno.label =
|
||||
anno.type === 'route'
|
||||
? composeRouteLabel(anno.name, anno.distanceM, null, 'manual', false)
|
||||
: anno.name;
|
||||
}
|
||||
if (color !== undefined && COLORS.has(color)) anno.color = color;
|
||||
renderer.remove(anno);
|
||||
try {
|
||||
renderer.add(anno);
|
||||
} catch (error) {
|
||||
rollbackRendererState(anno);
|
||||
annotations.delete(id);
|
||||
renderer.sync(annotations);
|
||||
syncAnnotationHold();
|
||||
notifyChange();
|
||||
throw error;
|
||||
}
|
||||
renderer.sync(annotations);
|
||||
ensureTicking();
|
||||
notifyChange();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Fly the camera to one mark; a line is framed whole, not by its start. */
|
||||
function frame(id) {
|
||||
const anno = annotations.get(id);
|
||||
if (!anno) return false;
|
||||
if (anno.type === 'route' && anno.path?.length) {
|
||||
const ring = anno.path.map((p) => [p.lon, p.lat]);
|
||||
// Unwrap around the first vertex, as ringRange does, so a line across
|
||||
// the antimeridian is centred on it, not on the far side of the globe.
|
||||
const ref = ring[0][0];
|
||||
const lons = ring.map(
|
||||
([lon]) => lon - 360 * Math.round((lon - ref) / 360),
|
||||
);
|
||||
const lats = ring.map(([, lat]) => lat);
|
||||
const midLon = (Math.min(...lons) + Math.max(...lons)) / 2;
|
||||
frameAnnotation({
|
||||
...anno,
|
||||
to: null,
|
||||
ring,
|
||||
polygons: null,
|
||||
anchor: {
|
||||
lon: ((((midLon + 180) % 360) + 360) % 360) - 180,
|
||||
lat: (Math.min(...lats) + Math.max(...lats)) / 2,
|
||||
height: 0,
|
||||
},
|
||||
});
|
||||
} else frameAnnotation(anno);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Begin a graceful fade-out of everything, then remove. */
|
||||
@@ -1100,6 +1228,16 @@ export function createAnnotationEngine({
|
||||
const engine = {
|
||||
annotate,
|
||||
clear,
|
||||
remove,
|
||||
setHidden,
|
||||
restyle,
|
||||
frame,
|
||||
/** Subscribe to board changes; returns an unsubscribe fn. */
|
||||
onChange(listener) {
|
||||
if (destroyed || typeof listener !== 'function') return () => {};
|
||||
changeListeners.add(listener);
|
||||
return () => changeListeners.delete(listener);
|
||||
},
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
@@ -1109,6 +1247,7 @@ export function createAnnotationEngine({
|
||||
if (tickHandle != null) cancelAnimationFrame(tickHandle);
|
||||
tickHandle = null;
|
||||
outlineListeners.clear();
|
||||
changeListeners.clear();
|
||||
releaseContinuousRender('annotations');
|
||||
renderer.destroy();
|
||||
}
|
||||
@@ -1364,6 +1503,7 @@ function normalizeType(type) {
|
||||
}
|
||||
|
||||
function computeAlpha(anno, now) {
|
||||
if (anno.hidden) return 0;
|
||||
const inT = (now - anno.bornAt) / 260;
|
||||
const fadeIn = inT >= 1 ? 1 : Math.max(0, inT);
|
||||
|
||||
@@ -1572,9 +1712,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) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { createStandalonePlaceSearch } from '../standalone/placeSearch.js';
|
||||
//
|
||||
// Run with: npm test (node --test)
|
||||
import { test } from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createAnnotationEngine,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
import { resolveAnnotationTarget } from './annotationResolver.js';
|
||||
import {
|
||||
getRenderGovernorDiagnostics,
|
||||
installRenderGovernor,
|
||||
_resetRenderGovernorForTest,
|
||||
} from '../renderGovernor.js';
|
||||
|
||||
@@ -730,3 +732,363 @@ test('only an explicit navigation request permits resolving distant annotation t
|
||||
assert.deepEqual(received.map(options => options.allowDistant), [false, true]);
|
||||
engine.destroy();
|
||||
});
|
||||
|
||||
// Draw's list works one hand-drawn mark at a time: hide, rename, recolour,
|
||||
// frame and remove it, and hear about every change to the board.
|
||||
test('hand-drawn marks: one mark can be hidden, renamed, recoloured, framed and removed', async (t) => {
|
||||
installAnimationFrameStubs(t);
|
||||
_resetRenderGovernorForTest();
|
||||
t.after(() => _resetRenderGovernorForTest());
|
||||
const { renderer, calls } = fakeRenderer();
|
||||
const flights = [];
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: { camera: { heading: 0, flyToBoundingSphere: (sphere) => flights.push(sphere) } },
|
||||
renderer,
|
||||
});
|
||||
let changes = 0;
|
||||
const unsubscribe = engine.onChange(() => { changes += 1; });
|
||||
|
||||
const drawn = await engine.annotate([
|
||||
{ type: 'route', manual: true, path: [[-97.75, 30.26], [-97.73, 30.27]], label: '' },
|
||||
{ type: 'pin', manual: true, latitude: 30.27, longitude: -97.74, label: 'Camp', color: 'amber' },
|
||||
], { persist: true });
|
||||
assert.equal(drawn.drawn, 2);
|
||||
assert.equal(changes, 1, 'one annotate, one change');
|
||||
const [line, pin] = engine.list();
|
||||
assert.equal(line.manual, true);
|
||||
assert.equal(line.name, null);
|
||||
assert.match(line.label, /km|m$/, 'an unnamed line is labelled by its length');
|
||||
assert.equal(pin.name, 'Camp');
|
||||
|
||||
assert.equal(engine.setHidden(pin.id, true), true);
|
||||
assert.equal(pin.hidden, true);
|
||||
assert.equal(changes, 2);
|
||||
|
||||
const addsBefore = calls.add;
|
||||
assert.equal(engine.restyle(line.id, { name: 'Ridge walk', color: 'green' }), true);
|
||||
assert.equal(line.name, 'Ridge walk');
|
||||
assert.match(line.label, /^Ridge walk — /, 'a renamed line keeps its length');
|
||||
assert.equal(line.color, 'green');
|
||||
assert.equal(calls.add, addsBefore + 1, 'the new look is drawn again');
|
||||
assert.equal(engine.restyle(line.id, { color: 'not-a-colour' }), true);
|
||||
assert.equal(line.color, 'green', 'an unknown colour is ignored');
|
||||
|
||||
assert.equal(engine.frame(line.id), true);
|
||||
assert.equal(flights.length, 1);
|
||||
assert.equal(engine.frame('anno-missing'), false);
|
||||
|
||||
assert.equal(engine.remove(pin.id), true);
|
||||
assert.deepEqual(engine.list().map(({ id }) => id), [line.id]);
|
||||
assert.equal(engine.remove(pin.id), false);
|
||||
|
||||
engine.clear();
|
||||
assert.equal(engine.list().length, 0);
|
||||
const seen = changes;
|
||||
unsubscribe();
|
||||
await engine.annotate([{ type: 'pin', manual: true, latitude: 1, longitude: 1 }], { persist: true });
|
||||
assert.equal(changes, seen, 'an unsubscribed listener hears nothing');
|
||||
});
|
||||
|
||||
// The list trusts these calls to change the globe, not just the list: each
|
||||
// is checked by its effect (renderer calls, alpha, camera, notifications).
|
||||
test('hand-drawn marks: remove, hide, clear, expiry and framing have their effect', async (t) => {
|
||||
const frames = [];
|
||||
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
|
||||
const originalCancelAnimationFrame = globalThis.cancelAnimationFrame;
|
||||
globalThis.requestAnimationFrame = (callback) => frames.push(callback);
|
||||
globalThis.cancelAnimationFrame = () => {};
|
||||
let now = 1_000_000;
|
||||
t.mock.method(performance, 'now', () => now);
|
||||
_resetRenderGovernorForTest();
|
||||
t.after(() => {
|
||||
globalThis.requestAnimationFrame = originalRequestAnimationFrame;
|
||||
globalThis.cancelAnimationFrame = originalCancelAnimationFrame;
|
||||
_resetRenderGovernorForTest();
|
||||
});
|
||||
const runFrames = () => {
|
||||
for (let i = 0; i < 5 && frames.length; i += 1) frames.shift()();
|
||||
};
|
||||
const { renderer, calls } = fakeRenderer();
|
||||
const flights = [];
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {
|
||||
camera: {
|
||||
heading: 0,
|
||||
flyToBoundingSphere: (sphere) => flights.push(sphere),
|
||||
},
|
||||
},
|
||||
renderer,
|
||||
});
|
||||
let changes = 0;
|
||||
engine.onChange(() => {
|
||||
changes += 1;
|
||||
});
|
||||
|
||||
await engine.annotate(
|
||||
[
|
||||
{
|
||||
type: 'pin',
|
||||
manual: true,
|
||||
latitude: 30.27,
|
||||
longitude: -97.74,
|
||||
label: 'Camp',
|
||||
},
|
||||
{
|
||||
type: 'pin',
|
||||
manual: true,
|
||||
latitude: 30.3,
|
||||
longitude: -97.7,
|
||||
label: 'Base',
|
||||
},
|
||||
],
|
||||
{ persist: true },
|
||||
);
|
||||
now += 1000;
|
||||
runFrames();
|
||||
const [camp, base] = engine.list();
|
||||
assert.equal(camp.alpha, 1);
|
||||
|
||||
engine.setHidden(camp.id, true);
|
||||
runFrames();
|
||||
assert.equal(camp.alpha, 0, 'a hidden mark is drawn at alpha 0');
|
||||
assert.ok(engine.list().includes(camp), 'and stays on the board');
|
||||
engine.setHidden(camp.id, false);
|
||||
runFrames();
|
||||
assert.equal(camp.alpha, 1, 'showing it again is immediate, with no fade-in');
|
||||
|
||||
const removes = calls.remove;
|
||||
let seen = changes;
|
||||
engine.remove(base.id);
|
||||
assert.equal(calls.remove, removes + 1, 'remove takes it off the globe');
|
||||
assert.equal(changes, seen + 1, 'and tells the list');
|
||||
|
||||
seen = changes;
|
||||
engine.clear();
|
||||
assert.equal(changes, seen + 1, 'a clear tells the list');
|
||||
|
||||
// A voice mark with a TTL: its expiry must reach the list too.
|
||||
await engine.annotate(
|
||||
[{ type: 'pin', latitude: 1, longitude: 1, label: 'Brief' }],
|
||||
{
|
||||
persist: false,
|
||||
ttlMs: 2000,
|
||||
},
|
||||
);
|
||||
seen = changes;
|
||||
now += 60_000;
|
||||
runFrames();
|
||||
assert.equal(engine.list().length, 0, 'the mark expired');
|
||||
assert.equal(changes, seen + 1, 'expiry tells the list');
|
||||
|
||||
// A line across the antimeridian is framed there, not on the far side.
|
||||
await engine.annotate(
|
||||
[
|
||||
{
|
||||
type: 'route',
|
||||
manual: true,
|
||||
path: [
|
||||
[179.9, -17],
|
||||
[-179.9, -17.1],
|
||||
],
|
||||
label: '',
|
||||
},
|
||||
],
|
||||
{ persist: true },
|
||||
);
|
||||
const [line] = engine.list();
|
||||
engine.frame(line.id);
|
||||
const centre = Cesium.Cartographic.fromCartesian(flights.at(-1).center);
|
||||
assert.ok(
|
||||
Math.abs(Math.abs(Cesium.Math.toDegrees(centre.longitude)) - 180) < 0.5,
|
||||
`framed at ${Cesium.Math.toDegrees(centre.longitude)}°, not near 180°`,
|
||||
);
|
||||
});
|
||||
|
||||
test('hand-drawn marks: a voice mark or a hidden mark is not taken as their duplicate', async (t) => {
|
||||
installAnimationFrameStubs(t);
|
||||
_resetRenderGovernorForTest();
|
||||
t.after(() => _resetRenderGovernorForTest());
|
||||
const { renderer } = fakeRenderer();
|
||||
const engine = createAnnotationEngine({ viewer: {}, renderer });
|
||||
await engine.annotate(
|
||||
[
|
||||
{
|
||||
type: 'pin',
|
||||
manual: true,
|
||||
latitude: 30.27,
|
||||
longitude: -97.74,
|
||||
label: 'Camp',
|
||||
},
|
||||
],
|
||||
{ persist: true },
|
||||
);
|
||||
const [drawn] = engine.list();
|
||||
// The agent marks the same spot with the same label, briefly.
|
||||
await engine.annotate(
|
||||
[{ type: 'pin', latitude: 30.27, longitude: -97.74, label: 'Camp' }],
|
||||
{
|
||||
persist: false,
|
||||
},
|
||||
);
|
||||
assert.equal(engine.list().length, 2, 'the voice mark is its own mark');
|
||||
assert.equal(drawn.ttlMs, null, 'the drawn pin keeps persisting');
|
||||
assert.equal(drawn.manual, true);
|
||||
|
||||
engine.setHidden(drawn.id, true);
|
||||
const again = await engine.annotate(
|
||||
[
|
||||
{
|
||||
type: 'pin',
|
||||
manual: true,
|
||||
latitude: 30.27,
|
||||
longitude: -97.74,
|
||||
label: 'Camp',
|
||||
},
|
||||
],
|
||||
{ persist: true },
|
||||
);
|
||||
assert.equal(again.drawn, 1);
|
||||
const visible = engine.list().filter((mark) => mark.manual && !mark.hidden);
|
||||
assert.equal(
|
||||
visible.length,
|
||||
1,
|
||||
'a pin dropped on a hidden one is a new, visible pin',
|
||||
);
|
||||
});
|
||||
|
||||
test('hand-drawn marks: only a manual mark can be restyled', async (t) => {
|
||||
installAnimationFrameStubs(t);
|
||||
_resetRenderGovernorForTest();
|
||||
t.after(() => _resetRenderGovernorForTest());
|
||||
const { renderer } = fakeRenderer();
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {},
|
||||
renderer,
|
||||
resolveTarget: async () => ({ ...FIXED_POINT }),
|
||||
});
|
||||
await engine.annotate([{ type: 'point', target: 'Texas Capitol', label: 'Capitol' }]);
|
||||
const [voiceMark] = engine.list();
|
||||
assert.equal(voiceMark.manual, false);
|
||||
assert.equal(engine.restyle(voiceMark.id, { name: 'Renamed' }), false);
|
||||
assert.equal(voiceMark.label, 'Capitol');
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
test('hand-drawn marks: two unnamed pins close together are two pins, whatever their colours', async (t) => {
|
||||
installAnimationFrameStubs(t);
|
||||
_resetRenderGovernorForTest();
|
||||
t.after(() => _resetRenderGovernorForTest());
|
||||
const { renderer } = fakeRenderer();
|
||||
const engine = createAnnotationEngine({ viewer: {}, renderer });
|
||||
const pin = (lon, color) => ({ type: 'pin', manual: true, latitude: 30.27, longitude: lon, label: '', color });
|
||||
await engine.annotate([pin(-97.74, 'primary')], { persist: true });
|
||||
await engine.annotate([pin(-97.7402, 'primary')], { persist: true });
|
||||
await engine.annotate([pin(-97.7404, 'red')], { persist: true });
|
||||
assert.deepEqual(
|
||||
engine.list().map(({ color }) => color),
|
||||
['primary', 'primary', 'red'],
|
||||
'no drawn pin is dropped or replaced by the next',
|
||||
);
|
||||
});
|
||||
|
||||
test('hand-drawn marks: hiding a mark before its first frame takes it off at once', async (t) => {
|
||||
installAnimationFrameStubs(t);
|
||||
_resetRenderGovernorForTest();
|
||||
t.after(() => _resetRenderGovernorForTest());
|
||||
let syncs = 0;
|
||||
const renderer = { add() {}, update() {}, remove() {}, sync() { syncs += 1; }, destroy() {} };
|
||||
const engine = createAnnotationEngine({ viewer: {}, renderer });
|
||||
await engine.annotate([{ type: 'pin', manual: true, latitude: 1, longitude: 1 }], { persist: true });
|
||||
const [mark] = engine.list();
|
||||
const before = syncs;
|
||||
engine.setHidden(mark.id, true);
|
||||
assert.ok(syncs > before, 'the renderer hears about it now');
|
||||
assert.ok(
|
||||
!getRenderGovernorDiagnostics().holds.includes('annotations'),
|
||||
'and the render hold goes with the last visible mark',
|
||||
);
|
||||
});
|
||||
|
||||
test('hand-drawn marks: with every mark hidden the scene idles, and showing one wakes it', async (t) => {
|
||||
installAnimationFrameStubs(t);
|
||||
_resetRenderGovernorForTest();
|
||||
t.after(() => _resetRenderGovernorForTest());
|
||||
let renders = 0;
|
||||
installRenderGovernor({ scene: { requestRenderMode: true, requestRender: () => (renders += 1) } });
|
||||
const { renderer } = fakeRenderer();
|
||||
const engine = createAnnotationEngine({ viewer: {}, renderer });
|
||||
await engine.annotate([
|
||||
{ type: 'pin', manual: true, latitude: 1, longitude: 1 },
|
||||
{ type: 'pin', manual: true, latitude: 2, longitude: 2 },
|
||||
], { persist: true });
|
||||
const [first, second] = engine.list();
|
||||
const held = () => getRenderGovernorDiagnostics().holds.includes('annotations');
|
||||
assert.ok(held(), 'visible marks keep the scene drawing');
|
||||
engine.setHidden(first.id, true);
|
||||
assert.ok(held(), 'one visible mark is enough');
|
||||
const before = renders;
|
||||
engine.setHidden(second.id, true);
|
||||
assert.equal(held(), false, 'every mark hidden: the scene idles');
|
||||
assert.ok(renders > before, 'after one last frame that takes them off');
|
||||
engine.setHidden(second.id, false);
|
||||
assert.ok(held(), 'showing one wakes it again');
|
||||
});
|
||||
|
||||
test('hand-drawn marks: a restyle the renderer cannot draw takes the mark off and says so', async (t) => {
|
||||
installAnimationFrameStubs(t);
|
||||
_resetRenderGovernorForTest();
|
||||
t.after(() => _resetRenderGovernorForTest());
|
||||
const { renderer, failAddOnce } = throwingRendererHarness();
|
||||
let syncs = 0;
|
||||
renderer.sync = () => (syncs += 1);
|
||||
const engine = createAnnotationEngine({ viewer: {}, renderer });
|
||||
await engine.annotate([{ type: 'pin', manual: true, latitude: 1, longitude: 1 }], { persist: true });
|
||||
const [mark] = engine.list();
|
||||
let changes = 0;
|
||||
engine.onChange(() => (changes += 1));
|
||||
const before = syncs;
|
||||
failAddOnce();
|
||||
assert.throws(() => engine.restyle(mark.id, { color: 'red' }), /WebGL context lost/);
|
||||
assert.equal(engine.list().length, 0, 'no ghost row is left behind');
|
||||
assert.equal(changes, 1, 'the list hears about it');
|
||||
assert.ok(syncs > before, 'and the renderer is told');
|
||||
});
|
||||
|
||||
test('hand-drawn marks: a mark shown again right after it was drawn appears at once, not fading in', async (t) => {
|
||||
_resetRenderGovernorForTest();
|
||||
t.after(() => _resetRenderGovernorForTest());
|
||||
const queued = [];
|
||||
const raf = globalThis.requestAnimationFrame;
|
||||
const caf = globalThis.cancelAnimationFrame;
|
||||
globalThis.requestAnimationFrame = (callback) => queued.push(callback);
|
||||
globalThis.cancelAnimationFrame = () => {};
|
||||
t.after(() => {
|
||||
globalThis.requestAnimationFrame = raf;
|
||||
globalThis.cancelAnimationFrame = caf;
|
||||
});
|
||||
const tick = () => queued.splice(0).forEach((callback) => callback());
|
||||
const { renderer } = fakeRenderer();
|
||||
const engine = createAnnotationEngine({ viewer: {}, renderer });
|
||||
await engine.annotate([{ type: 'pin', manual: true, latitude: 1, longitude: 1 }], { persist: true });
|
||||
const [mark] = engine.list();
|
||||
engine.setHidden(mark.id, true);
|
||||
tick();
|
||||
assert.equal(mark.alpha, 0);
|
||||
engine.setHidden(mark.id, false);
|
||||
tick();
|
||||
assert.equal(mark.alpha, 1, 'no fade-in after a hide');
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* The shapes drawn by hand, as UTILITIES ▸ Draw lists them: one row per shape
|
||||
* with its colour, its name, and show/hide, fly-to and remove, the way Import
|
||||
* lists its files. The board is the annotation engine's; voice marks are not
|
||||
* listed, only `manual` ones.
|
||||
*/
|
||||
import { msg, t } from '../i18n/index.js';
|
||||
|
||||
export const DRAW_COLORS = Object.freeze([
|
||||
'primary',
|
||||
'amber',
|
||||
'cyan',
|
||||
'green',
|
||||
'red',
|
||||
]);
|
||||
/** Each colour as drawn: the preview, the swatches and the drawn shape. */
|
||||
export const DRAW_COLOR_CSS = Object.freeze({
|
||||
primary: '#8be9ff',
|
||||
amber: '#ffb547',
|
||||
cyan: '#39d0ff',
|
||||
green: '#5dff9f',
|
||||
red: '#ff6b6b',
|
||||
});
|
||||
export const DRAW_COLOR_NAMES = Object.freeze({
|
||||
primary: msg('Primary'),
|
||||
amber: msg('Amber'),
|
||||
cyan: msg('Cyan'),
|
||||
green: msg('Green'),
|
||||
red: msg('Red'),
|
||||
});
|
||||
const SHAPE_NAMES = Object.freeze({
|
||||
area: msg('Area'),
|
||||
route: msg('Line'),
|
||||
pin: msg('Pin'),
|
||||
});
|
||||
|
||||
/** What a row calls a shape that has no name of its own. */
|
||||
export function drawMarkFallbackName(mark) {
|
||||
const shape = t(SHAPE_NAMES[mark?.type] || SHAPE_NAMES.pin);
|
||||
// An unnamed line's label is its length; keep it, it is the useful part.
|
||||
return mark?.type === 'route' && !mark.name && mark.label
|
||||
? `${shape} · ${mark.label}`
|
||||
: shape;
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows for the hand-drawn marks on the board, in board order. Text is
|
||||
* worded here, in the current language; the view only lays it out.
|
||||
* @param {object[]} marks The engine's `list()`.
|
||||
*/
|
||||
export function presentDrawMarks(marks = []) {
|
||||
return marks
|
||||
.filter((mark) => mark?.manual === true)
|
||||
.map((mark) => {
|
||||
const fallback = drawMarkFallbackName(mark);
|
||||
const title = mark.name || fallback;
|
||||
const visible = !mark.hidden;
|
||||
return {
|
||||
id: mark.id,
|
||||
name: mark.name || '',
|
||||
placeholder: fallback,
|
||||
color: DRAW_COLORS.includes(mark.color) ? mark.color : 'primary',
|
||||
hidden: !visible,
|
||||
labels: {
|
||||
name: t('Name of {name}', { name: title }),
|
||||
swatch: t('Change the colour of {name}', { name: title }),
|
||||
colors: t('Colour of {name}', { name: title }),
|
||||
toggle: visible
|
||||
? t('Hide {name}', { name: title })
|
||||
: t('Show {name}', { name: title }),
|
||||
fly: t('Fly to {name}', { name: title }),
|
||||
remove: t('Remove {name}', { name: title }),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
DRAW_COLORS,
|
||||
DRAW_COLOR_CSS,
|
||||
drawMarkFallbackName,
|
||||
presentDrawMarks,
|
||||
} from './drawMarks.js';
|
||||
|
||||
test('an unnamed shape is called by its kind, and an unnamed line by its length too', () => {
|
||||
assert.equal(drawMarkFallbackName({ type: 'area' }), 'Area');
|
||||
assert.equal(drawMarkFallbackName({ type: 'pin' }), 'Pin');
|
||||
assert.equal(
|
||||
drawMarkFallbackName({ type: 'route', label: '300 m' }),
|
||||
'Line · 300 m',
|
||||
);
|
||||
assert.equal(
|
||||
drawMarkFallbackName({
|
||||
type: 'route',
|
||||
name: 'Walk',
|
||||
label: 'Walk — 300 m',
|
||||
}),
|
||||
'Line',
|
||||
);
|
||||
});
|
||||
|
||||
test('rows list only hand-drawn marks, in board order, with their words', () => {
|
||||
const rows = presentDrawMarks([
|
||||
{
|
||||
id: 'a1',
|
||||
type: 'route',
|
||||
manual: true,
|
||||
name: null,
|
||||
label: '950 m',
|
||||
color: 'amber',
|
||||
},
|
||||
{ id: 'a2', type: 'area', manual: false, label: 'Texas', color: 'primary' },
|
||||
{
|
||||
id: 'a3',
|
||||
type: 'pin',
|
||||
manual: true,
|
||||
name: 'Camp',
|
||||
color: 'teal',
|
||||
hidden: true,
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(
|
||||
rows.map(({ id, name, placeholder, color, hidden }) => ({
|
||||
id,
|
||||
name,
|
||||
placeholder,
|
||||
color,
|
||||
hidden,
|
||||
})),
|
||||
[
|
||||
{
|
||||
id: 'a1',
|
||||
name: '',
|
||||
placeholder: 'Line · 950 m',
|
||||
color: 'amber',
|
||||
hidden: false,
|
||||
},
|
||||
{
|
||||
id: 'a3',
|
||||
name: 'Camp',
|
||||
placeholder: 'Pin',
|
||||
color: 'primary',
|
||||
hidden: true,
|
||||
},
|
||||
],
|
||||
);
|
||||
assert.equal(rows[0].labels.toggle, 'Hide Line · 950 m');
|
||||
assert.equal(rows[1].labels.toggle, 'Show Camp');
|
||||
assert.equal(rows[1].labels.fly, 'Fly to Camp');
|
||||
assert.equal(rows[1].labels.colors, 'Colour of Camp');
|
||||
});
|
||||
|
||||
test('every colour has its swatch', () => {
|
||||
assert.deepEqual(Object.keys(DRAW_COLOR_CSS), [...DRAW_COLORS]);
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
+69
-31
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* Manual whiteboard drawing: the Cesium + DOM half.
|
||||
*
|
||||
* DISPLAY ▸ Draw turns the globe into a whiteboard you draw on by hand: pick a
|
||||
* shape (area, line or pin), click the vertices on the real world, double-click
|
||||
* or press Enter to finish, type a label. Each finished shape goes through the
|
||||
* UTILITIES ▸ Draw turns the globe into a whiteboard you draw on by hand: pick a
|
||||
* shape (area, line or pin) and a colour, click the vertices on the real world,
|
||||
* double-click or press Enter to finish, then name, recolour, hide, fly to or
|
||||
* remove the shape from its row (drawMarks.js). Each finished shape goes through the
|
||||
* SAME `annotationEngine.annotate()` the voice agent uses, with its geometry
|
||||
* supplied and `manual: true`, so it renders with the whiteboard look,
|
||||
* persists, de-dups, shows up in `.list()`, and clears with the board.
|
||||
@@ -49,32 +50,30 @@ import {
|
||||
removeLastVertex,
|
||||
} from './drawMode.js';
|
||||
|
||||
import { DRAW_COLORS, DRAW_COLOR_CSS, presentDrawMarks } from './drawMarks.js';
|
||||
|
||||
import { onLocaleChange, t, translateKnown } from '../i18n/index.js';
|
||||
/** The id this tool claims the pointer under. */
|
||||
const DRAW_POINTER_OWNER = 'draw';
|
||||
const PREVIEW_DATA_SOURCE_NAME = 'gev-draw-preview';
|
||||
|
||||
const COLORS = ['primary', 'amber', 'cyan', 'green', 'red'];
|
||||
const PREVIEW = {
|
||||
primary: '#8be9ff',
|
||||
amber: '#ffb547',
|
||||
cyan: '#39d0ff',
|
||||
green: '#5dff9f',
|
||||
red: '#ff6b6b',
|
||||
};
|
||||
const PREVIEW = DRAW_COLOR_CSS;
|
||||
|
||||
/**
|
||||
* Run the Draw control. Returns a handle with a `destroy()` the application
|
||||
* lifetime owns, plus the console/test seam (`window.__gevDrawTool`).
|
||||
* The tool hands what the controls show (`{ active, shape, color, label,
|
||||
* hint }`) to `publish`, which the view renders, and answers the controls
|
||||
* The tool hands what the controls show (`{ active, shape, color, hint,
|
||||
* marks }`) to `publish`, which the view renders, and answers the controls
|
||||
* through the actions it gives `connect`, which returns their disconnect.
|
||||
* @param {{viewer: Cesium.Viewer, annotations: {annotate: Function, clear: Function}, publish?: Function, connect?: Function}} deps
|
||||
* A row's fly-to is an immediate navigation through `navigation`, like
|
||||
* Import's, so Cockpit can refuse it and a running orbit or follow lets go.
|
||||
* @param {{viewer: Cesium.Viewer, annotations: {annotate: Function, clear: Function}, navigation?: object, publish?: Function, connect?: Function}} deps
|
||||
* @returns {{destroy: Function}|null}
|
||||
*/
|
||||
export function initDrawTool({
|
||||
viewer,
|
||||
annotations,
|
||||
navigation = null,
|
||||
publish = () => {},
|
||||
connect = () => () => {},
|
||||
}) {
|
||||
@@ -88,7 +87,6 @@ export function initDrawTool({
|
||||
let session = null;
|
||||
let shape = 'area';
|
||||
let color = 'primary';
|
||||
let label = '';
|
||||
let hint = '';
|
||||
let handler = null;
|
||||
let lease = null;
|
||||
@@ -117,7 +115,14 @@ export function initDrawTool({
|
||||
() => null,
|
||||
);
|
||||
|
||||
const present = () => publish({ active, shape, color, label, hint });
|
||||
const present = () =>
|
||||
publish({
|
||||
active,
|
||||
shape,
|
||||
color,
|
||||
hint,
|
||||
marks: presentDrawMarks(annotations.list?.() || []),
|
||||
});
|
||||
// The hint is kept as the call that words it, so a language switch can
|
||||
// word it again.
|
||||
let hintText = () => '';
|
||||
@@ -240,15 +245,14 @@ export function initDrawTool({
|
||||
if (reason !== 'too-few') setHint(() => drawHint(session));
|
||||
return null;
|
||||
}
|
||||
const spec = finishSpec(session, { label, color });
|
||||
// Named afterwards, in the shape's row.
|
||||
const spec = finishSpec(session, { label: '', color });
|
||||
if (!spec) return null;
|
||||
// Start a fresh session BEFORE awaiting, so a second Enter or the second
|
||||
// half of a double-click cannot submit the same shape twice.
|
||||
session = createDrawSession(shape);
|
||||
cursor = null;
|
||||
syncPreview();
|
||||
label = '';
|
||||
present();
|
||||
const attempt = generation;
|
||||
try {
|
||||
const result = await annotations.annotate([spec], {
|
||||
@@ -287,11 +291,9 @@ export function initDrawTool({
|
||||
};
|
||||
|
||||
// ---- keys: only while drawing, never while typing in another field ----
|
||||
// The label field is this tool's own: typing there still drives drawing.
|
||||
const isLabelField = (target) => target?.id === 'draw-label-input';
|
||||
const typingElsewhere = (event) => {
|
||||
const t = event.target;
|
||||
if (!t || isLabelField(t)) return false;
|
||||
if (!t) return false;
|
||||
return (
|
||||
t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable
|
||||
);
|
||||
@@ -299,12 +301,12 @@ export function initDrawTool({
|
||||
/**
|
||||
* Enter is the finish key, but on a focused control Enter means "press this
|
||||
* control". Keyboard-focusing Clear and pressing Enter must clear the board,
|
||||
* not finish the shape — so the finish key is only taken from the canvas,
|
||||
* the page body, or the label field this tool owns.
|
||||
* not finish the shape — so the finish key is only taken from the canvas or
|
||||
* the page body.
|
||||
*/
|
||||
const enterBelongsToDrawing = (event) => {
|
||||
const t = event.target;
|
||||
if (!t || isLabelField(t)) return true;
|
||||
if (!t) return true;
|
||||
if (
|
||||
typeof t.closest === 'function' &&
|
||||
t.closest('button, a, select, [role="button"], [role="radio"]')
|
||||
@@ -316,6 +318,9 @@ export function initDrawTool({
|
||||
};
|
||||
const onKey = (event) => {
|
||||
if (!active || destroyed || typingElsewhere(event)) return;
|
||||
// Keys on the shape list's own controls are the list's (Esc closes a
|
||||
// colour picker there), not the drawing's.
|
||||
if (event.target?.closest?.('#draw-mark-list')) return;
|
||||
// Enter goes to finish() once a shape has been started; finish() owns the
|
||||
// decision and says WHY it refused. Swallowing the key on a degenerate
|
||||
// shape left the person pressing Enter at a silent panel. A focused button
|
||||
@@ -334,7 +339,7 @@ export function initDrawTool({
|
||||
else setActive(false);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Backspace' && !isLabelField(event.target)) {
|
||||
if (event.key === 'Backspace') {
|
||||
if (removeLastVertex(session)) {
|
||||
event.preventDefault();
|
||||
syncPreview();
|
||||
@@ -431,6 +436,20 @@ export function initDrawTool({
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Frame one drawn shape: an immediate navigation, so Cockpit can refuse it
|
||||
* and the current orbit, follow or track lets go first.
|
||||
*/
|
||||
function flyToMark(id) {
|
||||
if (typeof navigation?.runImmediateNavigation === 'function')
|
||||
return (
|
||||
navigation.runImmediateNavigation('drawing', () =>
|
||||
annotations.frame?.(id),
|
||||
) !== false
|
||||
);
|
||||
return annotations.frame?.(id) ?? false;
|
||||
}
|
||||
|
||||
/** Draw `next` from now on; an open shape starts over in it. */
|
||||
function chooseShape(next) {
|
||||
shape = normalizeShape(next);
|
||||
@@ -446,16 +465,26 @@ export function initDrawTool({
|
||||
toggleDraw: () => setActive(!active),
|
||||
setDrawShape: chooseShape,
|
||||
setDrawColor: (value) => {
|
||||
color = COLORS.includes(value) ? value : 'primary';
|
||||
color = DRAW_COLORS.includes(value) ? value : 'primary';
|
||||
present();
|
||||
syncPreview();
|
||||
},
|
||||
setDrawLabel: (text) => {
|
||||
label = String(text ?? '');
|
||||
present();
|
||||
},
|
||||
clearDraw: clearAll,
|
||||
renameDrawMark: (id, name) => annotations.restyle?.(id, { name }),
|
||||
recolorDrawMark: (id, next) => annotations.restyle?.(id, { color: next }),
|
||||
toggleDrawMark: (id) => {
|
||||
const mark = annotations.list?.().find((entry) => entry.id === id);
|
||||
if (mark) annotations.setHidden?.(id, !mark.hidden);
|
||||
},
|
||||
flyToDrawMark: flyToMark,
|
||||
removeDrawMark: (id) => annotations.remove?.(id),
|
||||
});
|
||||
// The rows follow the board: a shape drawn, renamed, hidden or removed
|
||||
// here, by voice or by a clear.
|
||||
const offBoard =
|
||||
annotations.onChange?.(() => {
|
||||
if (!destroyed) present();
|
||||
}) || (() => {});
|
||||
setHint(() => drawHint(null));
|
||||
|
||||
const api = {
|
||||
@@ -470,6 +499,14 @@ export function initDrawTool({
|
||||
},
|
||||
setActive,
|
||||
setShape: chooseShape,
|
||||
/** The hand-drawn marks the list shows, as its rows. */
|
||||
marks: () => presentDrawMarks(annotations.list?.() || []),
|
||||
setColor(next) {
|
||||
color = DRAW_COLORS.includes(next) ? next : 'primary';
|
||||
present();
|
||||
syncPreview();
|
||||
},
|
||||
flyToMark,
|
||||
/** Test seam: add a vertex from lon/lat as if it had been clicked. */
|
||||
addVertex(lon, lat, height = 0) {
|
||||
if (!session) return false;
|
||||
@@ -519,6 +556,7 @@ export function initDrawTool({
|
||||
if (active) setActive(false);
|
||||
destroyed = true;
|
||||
offLocale();
|
||||
offBoard();
|
||||
disconnect();
|
||||
releaseSceneHandler();
|
||||
releasePointer(lease);
|
||||
|
||||
@@ -23,7 +23,7 @@ test('the application composition wires the draw tool and owns its teardown', ()
|
||||
const tools = read('src/app/tools.js');
|
||||
const engineAt = tools.indexOf('const annotations = initAnnotations({');
|
||||
const drawAt = tools.indexOf(
|
||||
'const drawTool = initDrawTool({\n viewer,\n annotations,\n publish: (view) => displayStore.publishDraw(view),',
|
||||
'const drawTool = initDrawTool({\n viewer,\n annotations,\n navigation: styleManager,\n publish: (view) => displayStore.publishDraw(view),',
|
||||
);
|
||||
assert.ok(engineAt >= 0, 'initAnnotations call missing');
|
||||
assert.ok(
|
||||
@@ -62,42 +62,62 @@ test('the engine resolves manual geometry before any name resolution', () => {
|
||||
});
|
||||
|
||||
test('the Draw control markup carries the ids the tool binds to', () => {
|
||||
// The DISPLAY rail is a Vue view now, rendered into index.html's view host —
|
||||
// the markup is not in index.html any more.
|
||||
const html = read('src/ui/views/panels/DisplayPanel.vue');
|
||||
// UTILITIES ▸ Draw is a Vue view (DrawControls, with DrawMarkList for the
|
||||
// drawn shapes' rows), rendered into the Utilities panel.
|
||||
const html = [
|
||||
read('src/ui/views/panels/DrawControls.vue'),
|
||||
read('src/ui/views/panels/DrawMarkList.vue'),
|
||||
].join('\n');
|
||||
const ids = [
|
||||
'draw-toggle',
|
||||
'draw-mode-row',
|
||||
'draw-colors',
|
||||
'draw-label-row',
|
||||
'draw-label-input',
|
||||
'draw-color-select',
|
||||
'draw-clear',
|
||||
'draw-hint',
|
||||
'draw-mark-list',
|
||||
];
|
||||
for (const id of ids) {
|
||||
assert.match(
|
||||
html,
|
||||
new RegExp(`id="${id}"`),
|
||||
`#${id} missing from index.html`,
|
||||
`#${id} missing from the Draw views`,
|
||||
);
|
||||
}
|
||||
for (const shape of ['area', 'line', 'pin']) {
|
||||
// Shapes are icons, named for screen readers and tooltips.
|
||||
for (const [shape, icon] of [
|
||||
['area', 'pentagon'],
|
||||
['line', 'timeline'],
|
||||
['pin', 'location_on'],
|
||||
]) {
|
||||
assert.match(
|
||||
html,
|
||||
new RegExp(`'data-shape': '${shape}'`),
|
||||
new RegExp(`icon: '${icon}', attrs: \\{ 'data-shape': '${shape}' \\}`),
|
||||
`shape button ${shape} missing`,
|
||||
);
|
||||
}
|
||||
assert.ok(
|
||||
read('src/ui/views/LayerPanelsView.vue').includes('<DrawControls />'),
|
||||
'the Utilities panel renders Draw',
|
||||
);
|
||||
assert.ok(
|
||||
!read('src/ui/views/panels/DisplayPanel.vue').includes('draw-toggle'),
|
||||
'Draw is no longer in DISPLAY',
|
||||
);
|
||||
// The rows and the hint render what the tool publishes; the controls
|
||||
// call the actions the tool connects to the display store.
|
||||
for (const action of [
|
||||
"act('toggleDraw')",
|
||||
"act('setDrawShape', $event)",
|
||||
"act('setDrawLabel', $event.target.value)",
|
||||
"act('setDrawColor', $event)",
|
||||
"act('clearDraw')",
|
||||
"act('renameDrawMark', row.id, next)",
|
||||
"act('recolorDrawMark', row.id, color)",
|
||||
"act('toggleDrawMark', row.id)",
|
||||
"act('flyToDrawMark', row.id)",
|
||||
"act('removeDrawMark', row.id)",
|
||||
]) {
|
||||
assert.ok(html.includes(action), `DisplayPanel must call ${action}`);
|
||||
assert.ok(html.includes(action), `the Draw views must call ${action}`);
|
||||
const name = /act\('([a-zA-Z]+)'/.exec(action)[1];
|
||||
assert.match(
|
||||
tool(),
|
||||
@@ -119,14 +139,15 @@ test('the DISPLAY rail styles live in their current owner, not the shim', () =>
|
||||
assert.deepEqual(rules, [], 'style.css is an import shim, not a stylesheet');
|
||||
assert.doesNotMatch(
|
||||
shim,
|
||||
/draw-hint|pp-text-input/,
|
||||
/draw-hint|draw-color/,
|
||||
'draw styles belong to a component stylesheet',
|
||||
);
|
||||
const controls = read('src/ui/styles/controls.css');
|
||||
for (const rule of [
|
||||
'#draw-mode-row',
|
||||
'#draw-label-row',
|
||||
'.pp-text-input',
|
||||
'.draw-color',
|
||||
'.draw-mark-name',
|
||||
'.draw-clear-btn',
|
||||
'.draw-hint',
|
||||
'body.gev-drawing',
|
||||
@@ -149,10 +170,14 @@ test('nothing promises a GeoJSON export for drawn shapes', () => {
|
||||
`${file} still claims a GeoJSON export`,
|
||||
);
|
||||
}
|
||||
for (const file of ['README.md', 'CHANGELOG.md', 'docs/CURRENT-STATE.md']) {
|
||||
// Draw lives in UTILITIES; only the changelog's history still says DISPLAY.
|
||||
for (const [file, heading] of [
|
||||
['README.md', 'UTILITIES ▸ **Draw**'],
|
||||
['CHANGELOG.md', 'DISPLAY ▸ Draw'],
|
||||
['docs/CURRENT-STATE.md', 'UTILITIES ▸ **Draw**'],
|
||||
]) {
|
||||
const source = read(file);
|
||||
const at = source.indexOf('DISPLAY ▸ **Draw**');
|
||||
const start = at >= 0 ? at : source.indexOf('DISPLAY ▸ Draw');
|
||||
const start = source.indexOf(heading);
|
||||
assert.ok(start >= 0, `${file} should describe the Draw control`);
|
||||
const section = source.slice(start, start + 1200);
|
||||
assert.doesNotMatch(
|
||||
|
||||
@@ -96,11 +96,10 @@ function fakeDom() {
|
||||
byId.set(id, element);
|
||||
return element;
|
||||
};
|
||||
// The control whose presence means the page has Draw, a focused button
|
||||
// whose Enter is its own, and the tool's label field.
|
||||
// The control whose presence means the page has Draw, and a focused button
|
||||
// whose Enter is its own.
|
||||
make('draw-toggle', 'BUTTON');
|
||||
make('draw-clear', 'BUTTON');
|
||||
make('draw-label-input', 'INPUT');
|
||||
|
||||
const body = new FakeElement('BODY');
|
||||
const documentDouble = {
|
||||
@@ -176,9 +175,16 @@ function fakeViewer() {
|
||||
}
|
||||
|
||||
function fakeAnnotations() {
|
||||
const calls = { annotate: [], clear: 0 };
|
||||
const calls = { annotate: [], clear: 0, restyle: [], setHidden: [], frame: [], remove: [] };
|
||||
const marks = [];
|
||||
const listeners = new Set();
|
||||
const changed = () => listeners.forEach((listener) => listener());
|
||||
const find = (id) => marks.find((mark) => mark.id === id);
|
||||
return {
|
||||
calls,
|
||||
marks,
|
||||
listeners,
|
||||
changed,
|
||||
async annotate(specs, options) {
|
||||
calls.annotate.push({ specs, options });
|
||||
return { drawn: specs.length };
|
||||
@@ -186,11 +192,38 @@ function fakeAnnotations() {
|
||||
clear() {
|
||||
calls.clear += 1;
|
||||
},
|
||||
list: () => marks,
|
||||
onChange(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
restyle(id, change) {
|
||||
calls.restyle.push([id, change]);
|
||||
Object.assign(find(id), change);
|
||||
changed();
|
||||
return true;
|
||||
},
|
||||
setHidden(id, hidden) {
|
||||
calls.setHidden.push([id, hidden]);
|
||||
find(id).hidden = hidden;
|
||||
changed();
|
||||
return true;
|
||||
},
|
||||
frame(id) {
|
||||
calls.frame.push(id);
|
||||
return true;
|
||||
},
|
||||
remove(id) {
|
||||
calls.remove.push(id);
|
||||
marks.splice(marks.indexOf(find(id)), 1);
|
||||
changed();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Stand up a tool against fresh doubles, with globals restored afterwards. */
|
||||
function harness() {
|
||||
function harness({ navigation = null } = {}) {
|
||||
resetPointerOwnership();
|
||||
const { byId, documentDouble } = fakeDom();
|
||||
const viewer = fakeViewer();
|
||||
@@ -205,6 +238,7 @@ function harness() {
|
||||
const tool = initDrawTool({
|
||||
viewer,
|
||||
annotations,
|
||||
navigation,
|
||||
publish: (view) => (shown.view = view),
|
||||
connect: (actions) => {
|
||||
controls.actions = actions;
|
||||
@@ -426,6 +460,7 @@ test('destroy releases the pointer, the handler, the listeners and the stock act
|
||||
h.tool.addVertex(-97.75, 30.26);
|
||||
assert.equal(pointerOwner(), 'draw');
|
||||
assert.ok(h.tool.diagnostics().domListeners > 0);
|
||||
assert.equal(h.annotations.listeners.size, 1, 'the rows follow the board');
|
||||
|
||||
await h.tool.destroy();
|
||||
await Promise.resolve();
|
||||
@@ -443,6 +478,7 @@ test('destroy releases the pointer, the handler, the listeners and the stock act
|
||||
'the viewer got its selecting click back',
|
||||
);
|
||||
assert.equal(h.viewer._attached.length, 0, 'the preview data source is detached');
|
||||
assert.equal(h.annotations.listeners.size, 0, 'and it no longer follows the board');
|
||||
|
||||
// Idempotent: a second destroy is harmless.
|
||||
await h.tool.destroy();
|
||||
@@ -467,16 +503,15 @@ test('Clear wipes the board and the shape in progress', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('the controls choose the shape, colour and label the next shape is drawn with', async () => {
|
||||
test('the controls choose the shape and colour the next shape is drawn with; it is named in its row', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
h.act('toggleDraw');
|
||||
h.act('setDrawShape', 'line');
|
||||
h.act('setDrawColor', 'amber');
|
||||
h.act('setDrawLabel', 'Route A');
|
||||
assert.deepEqual(
|
||||
{ ...h.shown.view, hint: undefined },
|
||||
{ active: true, shape: 'line', color: 'amber', label: 'Route A', hint: undefined },
|
||||
{ active: true, shape: 'line', color: 'amber', hint: undefined, marks: [] },
|
||||
'the view shows what the controls chose',
|
||||
);
|
||||
h.act('setDrawColor', 'purple');
|
||||
@@ -494,36 +529,83 @@ test('the controls choose the shape, colour and label the next shape is drawn wi
|
||||
const spec = h.annotations.calls.annotate[0].specs[0];
|
||||
assert.equal(spec.type, 'route', 'a drawn line is a manual route');
|
||||
assert.equal(spec.color, 'cyan');
|
||||
assert.equal(spec.label, 'Route A');
|
||||
assert.equal(h.shown.view.label, '', 'a finished shape empties the label');
|
||||
assert.ok(!spec.label, 'a shape is named afterwards, in its row');
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keys typed in the label field: Enter finishes, Backspace edits, Escape cancels', async () => {
|
||||
test('each drawn shape is a row whose actions reach the board, and fly-to is a navigation', async () => {
|
||||
const navigations = [];
|
||||
const h = harness({
|
||||
navigation: {
|
||||
runImmediateNavigation: (noun, navigate) => {
|
||||
navigations.push(noun);
|
||||
return navigate();
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
const board = h.annotations;
|
||||
board.marks.push(
|
||||
{ id: 'a1', type: 'route', manual: true, name: null, label: '950 m', color: 'amber', hidden: false },
|
||||
{ id: 'a2', type: 'pin', manual: false, label: 'Voice pin', color: 'primary', hidden: false },
|
||||
);
|
||||
board.changed();
|
||||
assert.deepEqual(
|
||||
h.shown.view.marks.map(({ id, placeholder, hidden }) => ({ id, placeholder, hidden })),
|
||||
[{ id: 'a1', placeholder: 'Line · 950 m', hidden: false }],
|
||||
'only hand-drawn marks are listed, an unnamed line by its length',
|
||||
);
|
||||
h.act('renameDrawMark', 'a1', 'Ridge walk');
|
||||
h.act('recolorDrawMark', 'a1', 'green');
|
||||
h.act('toggleDrawMark', 'a1');
|
||||
h.act('flyToDrawMark', 'a1');
|
||||
h.act('removeDrawMark', 'a1');
|
||||
assert.deepEqual(board.calls.restyle, [
|
||||
['a1', { name: 'Ridge walk' }],
|
||||
['a1', { color: 'green' }],
|
||||
]);
|
||||
assert.deepEqual(board.calls.setHidden, [['a1', true]]);
|
||||
assert.deepEqual(board.calls.frame, ['a1']);
|
||||
assert.deepEqual(navigations, ['drawing'], 'fly-to goes through the navigation policy');
|
||||
assert.deepEqual(board.calls.remove, ['a1']);
|
||||
assert.deepEqual(h.shown.view.marks, [], 'the row goes with its mark');
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('Backspace on the page removes the last vertex', () => {
|
||||
const h = harness();
|
||||
try {
|
||||
const label = h.element('draw-label-input');
|
||||
h.act('toggleDraw');
|
||||
h.tool.addVertex(-97.75, 30.26);
|
||||
h.tool.addVertex(-97.74, 30.26);
|
||||
h.documentDouble.emit('keydown', { key: 'Backspace', target: h.documentDouble.body });
|
||||
assert.equal(h.tool.session.vertices.length, 1);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keys on the shape list are the list\'s, not the drawing\'s', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
const listButton = new FakeElement('BUTTON');
|
||||
listButton.closest = (selector) => (selector === '#draw-mark-list' ? {} : null);
|
||||
const elsewhere = new FakeElement('INPUT', 'location-search');
|
||||
h.act('toggleDraw');
|
||||
h.tool.addVertex(-97.75, 30.26);
|
||||
h.tool.addVertex(-97.74, 30.26);
|
||||
h.documentDouble.emit('keydown', { key: 'Backspace', target: label });
|
||||
assert.equal(h.tool.session.vertices.length, 2, 'Backspace edits the label');
|
||||
h.documentDouble.emit('keydown', { key: 'Escape', target: listButton });
|
||||
assert.equal(h.tool.session.vertices.length, 2, 'Escape in the list keeps the shape');
|
||||
assert.equal(h.tool.active, true, 'and keeps Draw on');
|
||||
h.documentDouble.emit('keydown', { key: 'Enter', target: elsewhere });
|
||||
await Promise.resolve();
|
||||
assert.equal(h.annotations.calls.annotate.length, 0, 'another field keeps its Enter');
|
||||
h.documentDouble.emit('keydown', { key: 'Escape', target: label });
|
||||
assert.equal(h.tool.session.vertices.length, 0, 'Escape drops the open shape');
|
||||
assert.equal(h.tool.active, true, 'and leaves Draw on');
|
||||
h.act('setDrawShape', 'line');
|
||||
h.tool.addVertex(-97.75, 30.26);
|
||||
h.tool.addVertex(-97.74, 30.26);
|
||||
h.documentDouble.emit('keydown', { key: 'Enter', target: label });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
assert.equal(h.annotations.calls.annotate.length, 1, 'Enter in the label finishes');
|
||||
h.documentDouble.emit('keydown', { key: 'Escape', target: h.documentDouble.body });
|
||||
assert.equal(h.tool.session.vertices.length, 0, 'Escape on the page drops the open shape');
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
|
||||
@@ -483,7 +483,9 @@ export function createScreenAnnotationRenderer(
|
||||
|
||||
for (const rec of records.values()) {
|
||||
const { anno, group, parts } = rec;
|
||||
if (rec.hidden) {
|
||||
// A mark hidden from Draw's list is not projected, so it neither shows
|
||||
// nor pushes a visible callout aside in the placement below.
|
||||
if (rec.hidden || anno.hidden) {
|
||||
group.display = 'none';
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -390,3 +390,49 @@ test("an earlier renderer leaves a later renderer's board alone", async (t) => {
|
||||
assert.equal(groups().length, 1);
|
||||
assert.equal(groups()[0].querySelector('text').textContent, 'Kept');
|
||||
});
|
||||
|
||||
test('a mark hidden from Draw is not drawn or placed, and comes back when shown', async (t) => {
|
||||
// happy-dom measures every SVG box the same, so callout spreading cannot
|
||||
// settle here; a hidden mark simply has to stay out of drawing and
|
||||
// placement, which is what keeps it from pushing a visible callout aside.
|
||||
const { store, svg } = board(t);
|
||||
projectTo(t, { x: 200, y: 300 });
|
||||
const camera = cameraOver(0, 0);
|
||||
let project = null;
|
||||
const renderer = createScreenAnnotationRenderer(
|
||||
{
|
||||
scene: sceneFor(camera, {
|
||||
postRender: {
|
||||
addEventListener: (listener) => (project = listener),
|
||||
removeEventListener() {},
|
||||
},
|
||||
}),
|
||||
camera,
|
||||
trackedEntity: null,
|
||||
},
|
||||
{ store, activeTrackedReadoutId: () => null },
|
||||
);
|
||||
t.after(() => renderer.destroy());
|
||||
const camp = {
|
||||
id: 'camp',
|
||||
type: 'pin',
|
||||
color: 'amber',
|
||||
label: 'Camp',
|
||||
alpha: 1,
|
||||
hidden: true,
|
||||
anchor: { lon: 0, lat: 0, height: 0 },
|
||||
};
|
||||
renderer.add(camp);
|
||||
project();
|
||||
await settle();
|
||||
const drawn = () =>
|
||||
[...svg().querySelectorAll('g.gev-anno')].some(
|
||||
(group) =>
|
||||
group.textContent.includes('Camp') && group.style.display !== 'none',
|
||||
);
|
||||
assert.equal(drawn(), false, 'the hidden mark is not drawn');
|
||||
camp.hidden = false;
|
||||
project();
|
||||
await settle();
|
||||
assert.equal(drawn(), true, 'shown again, it is drawn');
|
||||
});
|
||||
@@ -51,6 +51,8 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
const dataSource = new Cesium.CustomDataSource('gev-annotations');
|
||||
viewer.dataSources.add(dataSource);
|
||||
const osmAnnotations = new Set();
|
||||
// Every mark with entities on the globe, so sync() can take a hidden one out.
|
||||
const drawn = new Set();
|
||||
|
||||
// Register the GevRouteFlow fabric once so Cesium's Material.fromType() can build the
|
||||
// material the route pipeline renders. The animated `time` uniform is read straight
|
||||
@@ -89,6 +91,7 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
// path cannot reach them and they stay on the globe forever.
|
||||
const entities = [];
|
||||
anno._entities = entities;
|
||||
drawn.add(anno);
|
||||
|
||||
if (
|
||||
anno.ring &&
|
||||
@@ -247,6 +250,7 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
width: 9,
|
||||
material: new FlowMaterialProperty(
|
||||
PALETTE[anno.color] || PALETTE.primary,
|
||||
() => anno.alpha ?? 1,
|
||||
),
|
||||
clampToGround: true,
|
||||
classificationType: CLASSIFY,
|
||||
@@ -320,6 +324,7 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
function remove(anno) {
|
||||
hideOsmCredit(viewer, anno);
|
||||
osmAnnotations.delete(anno);
|
||||
drawn.delete(anno);
|
||||
if (!anno?._entities) return;
|
||||
for (const entity of anno._entities) {
|
||||
try {
|
||||
@@ -331,13 +336,23 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
anno._entities = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* CallbackProperty drives per-frame alpha and pulse. A hidden mark is taken
|
||||
* out whole, so no material that keeps its own colour (the route's flow),
|
||||
* ground classification or pick can still show it.
|
||||
*/
|
||||
function sync() {
|
||||
// No-op: CallbackProperty drives per-frame alpha/pulse animation.
|
||||
for (const anno of drawn) {
|
||||
const show = !anno.hidden;
|
||||
for (const entity of anno._entities || [])
|
||||
if (entity.show !== show) entity.show = show;
|
||||
}
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
for (const anno of osmAnnotations) hideOsmCredit(viewer, anno);
|
||||
osmAnnotations.clear();
|
||||
drawn.clear();
|
||||
try {
|
||||
viewer.dataSources.remove(dataSource, true);
|
||||
} catch {
|
||||
@@ -407,8 +422,11 @@ export function ensureFlowFabricRegistered() {
|
||||
* returned a standalone Material Cesium never rendered, or treated `result` as a
|
||||
* Material — both left the real uniforms untouched, so nothing animated.)
|
||||
*/
|
||||
export function FlowMaterialProperty(colorCss) {
|
||||
export function FlowMaterialProperty(colorCss, alphaOf = () => 1) {
|
||||
this._color = Cesium.Color.fromCssColorString(colorCss).withAlpha(0.95);
|
||||
// The mark's live alpha (fade in and out, hidden), applied every frame.
|
||||
this._alphaOf = alphaOf;
|
||||
this._live = this._color.clone();
|
||||
this._definitionChanged = new Cesium.Event();
|
||||
}
|
||||
Object.defineProperties(FlowMaterialProperty.prototype, {
|
||||
@@ -431,7 +449,8 @@ FlowMaterialProperty.prototype.getValue = function getValue(time, result) {
|
||||
// `time` is read straight from the wall clock so the dashes flow every rendered
|
||||
// frame (the scene renders continuously; no requestRenderMode here).
|
||||
if (!Cesium.defined(result)) result = {};
|
||||
result.color = this._color;
|
||||
this._live.alpha = this._color.alpha * this._alphaOf();
|
||||
result.color = this._live;
|
||||
result.time = performance.now() / 1000; // the per-frame animated value
|
||||
result.repeat = 64.0;
|
||||
result.duty = 0.46;
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// A mark hidden from Draw's list leaves the globe whole: every entity it put
|
||||
// there is taken out on sync and comes back when shown, and the line's
|
||||
// flowing stroke, which keeps its own colour, follows the mark's alpha.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as C from 'cesium';
|
||||
import {
|
||||
FlowMaterialProperty,
|
||||
createWorldAnnotationRenderer,
|
||||
} from './worldAnnotationRenderer.js';
|
||||
|
||||
const noop = () => {};
|
||||
function viewer(t) {
|
||||
for (const name of ['HTMLCanvasElement', 'HTMLImageElement', 'ImageBitmap', 'OffscreenCanvas']) {
|
||||
const original = globalThis[name];
|
||||
globalThis[name] = class {};
|
||||
t.after(() => { if (original === undefined) delete globalThis[name]; else globalThis[name] = original; });
|
||||
}
|
||||
const before = globalThis.document;
|
||||
globalThis.document = { addEventListener: noop, removeEventListener: noop };
|
||||
t.after(() => { if (before === undefined) delete globalThis.document; else globalThis.document = before; });
|
||||
return {
|
||||
creditDisplay: { addStaticCredit: noop, removeStaticCredit: noop },
|
||||
entities: new C.EntityCollection(),
|
||||
dataSources: new C.DataSourceCollection(),
|
||||
camera: { moveEnd: new C.Event(), positionCartographic: { height: 1000 } },
|
||||
scene: { requestRender: noop, canvas: { addEventListener: noop, removeEventListener: noop }, preRender: new C.Event(),
|
||||
primitives: { add: (p) => p, remove: noop }, screenSpaceCameraController: { enableInputs: true } },
|
||||
};
|
||||
}
|
||||
|
||||
const marks = () => [
|
||||
{ id: 'route', type: 'route', color: 'amber', alpha: 1, anchor: { lat: 0, lon: 0 }, path: [{ lat: 0, lon: 0 }, { lat: 0.01, lon: 0.01 }] },
|
||||
{ id: 'area', type: 'area', color: 'green', alpha: 1, anchor: { lat: 0.005, lon: 0.005 }, ring: [[0, 0], [0.01, 0], [0.01, 0.01], [0, 0]] },
|
||||
{ id: 'pin', type: 'pin', color: 'primary', alpha: 1, anchor: { lat: 0.02, lon: 0.02 } },
|
||||
];
|
||||
|
||||
test('a hidden mark has every entity taken off on sync, and back when shown', (t) => {
|
||||
const renderer = createWorldAnnotationRenderer(viewer(t));
|
||||
const [route, area, pin] = marks();
|
||||
for (const anno of [route, area, pin]) renderer.add(anno);
|
||||
const shown = (anno) => anno._entities.map((entity) => entity.show);
|
||||
for (const anno of [route, area, pin]) assert.ok(anno._entities.length > 0, anno.id);
|
||||
|
||||
route.hidden = true;
|
||||
area.hidden = true;
|
||||
renderer.sync();
|
||||
assert.ok(shown(route).every((show) => show === false), 'the line is off');
|
||||
assert.ok(shown(area).every((show) => show === false), 'the area is off');
|
||||
assert.ok(shown(pin).every((show) => show === true), 'a visible mark stays');
|
||||
|
||||
route.hidden = false;
|
||||
renderer.sync();
|
||||
assert.ok(shown(route).every((show) => show === true), 'the line is back');
|
||||
renderer.remove(area);
|
||||
renderer.sync();
|
||||
assert.equal(area._entities, null, 'a removed mark is no longer synced');
|
||||
renderer.destroy();
|
||||
});
|
||||
|
||||
test('the line\'s flowing stroke follows its mark\'s alpha', () => {
|
||||
let alpha = 1;
|
||||
const material = new FlowMaterialProperty('#ffb547', () => alpha);
|
||||
assert.ok(Math.abs(material.getValue(null, {}).color.alpha - 0.95) < 1e-9);
|
||||
alpha = 0;
|
||||
assert.equal(material.getValue(null, {}).color.alpha, 0, 'hidden: nothing painted');
|
||||
alpha = 0.5;
|
||||
assert.ok(Math.abs(material.getValue(null, {}).color.alpha - 0.475) < 1e-9, 'fading');
|
||||
assert.equal(new FlowMaterialProperty('#fff').getValue(null, {}).color.alpha, 0.95, 'no alpha source: opaque as before');
|
||||
});
|
||||
+5
-3
@@ -62,17 +62,19 @@ export function createApplicationTools({
|
||||
const token = displayStore.connect(actions);
|
||||
return () => displayStore.detach(token);
|
||||
};
|
||||
// DISPLAY ▸ Draw: the same whiteboard, drawn by hand. It claims the pointer
|
||||
// UTILITIES ▸ Draw: the same whiteboard, drawn by hand. It claims the pointer
|
||||
// while a session is open, so its teardown belongs to the application
|
||||
// lifetime rather than to whoever last pressed the button.
|
||||
// lifetime rather than to whoever last pressed the button. A row's fly-to
|
||||
// goes through the shell's navigation policy, as Import's does.
|
||||
const drawTool = initDrawTool({
|
||||
viewer,
|
||||
annotations,
|
||||
navigation: styleManager,
|
||||
publish: (view) => displayStore.publishDraw(view),
|
||||
connect: connectDisplay,
|
||||
});
|
||||
defer(() => drawTool?.destroy());
|
||||
// DISPLAY ▸ Import: the person's own GeoJSON/KML/KMZ/GPX files, each its
|
||||
// UTILITIES ▸ Import: the person's own GeoJSON/KML/KMZ/GPX files, each its
|
||||
// own data source. They live for the page session, so the application
|
||||
// lifetime removes them and the drop listeners on the viewer container.
|
||||
const geoFileImport = initGeoFileImport({
|
||||
|
||||
+6
-1
@@ -1746,8 +1746,13 @@ export function flyRoute(
|
||||
const speed = ROUTE_M_S[String(args.speed || 'normal').toLowerCase()]
|
||||
? String(args.speed || 'normal').toLowerCase()
|
||||
: 'normal';
|
||||
// A route hidden from Draw's list is not on screen, so it is not flown.
|
||||
const routes = (annoList || []).filter(
|
||||
(a) => a.type === 'route' && Array.isArray(a.path) && a.path.length >= 2,
|
||||
(a) =>
|
||||
a.type === 'route' &&
|
||||
!a.hidden &&
|
||||
Array.isArray(a.path) &&
|
||||
a.path.length >= 2,
|
||||
);
|
||||
if (!routes.length) {
|
||||
return {
|
||||
|
||||
@@ -633,17 +633,22 @@ test('Cockpit side surfaces behave as two single-expanded accordions', () => {
|
||||
);
|
||||
assert.match(
|
||||
radioBindings,
|
||||
/'gev:cockpit-context-expanded'[\s\S]*?setPanelCollapsed\('data-panel', true\);/,
|
||||
/'gev:cockpit-context-expanded'[\s\S]*?setPanelCollapsed\('data-panel', true\);\s*this\.actions\.setPanelCollapsed\('utilities-panel', true\);/,
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/!nextCollapsed\s*&&\s*this\.cockpitView\?\.active\s*&&\s*panelId === 'data-panel'[\s\S]*?_cockpitContextCollapsedForDataPanel =\s*!this\.cockpitView\.contextCollapsed[\s\S]*?this\.cockpitView\.setContextCollapsed\(true\);/,
|
||||
/!nextCollapsed\s*&&\s*this\.cockpitView\?\.active\s*&&\s*COCKPIT_LEFT_PANEL_IDS\.has\(panelId\)[\s\S]*?_cockpitContextCollapsedForDataPanel =\s*!this\.cockpitView\.contextCollapsed[\s\S]*?this\.cockpitView\.setContextCollapsed\(true\);/,
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/nextCollapsed\s*&&\s*this\.cockpitView\?\.active\s*&&\s*panelId === 'data-panel'[\s\S]*?_cockpitContextCollapsedForDataPanel[\s\S]*?this\.cockpitView\.setContextCollapsed\(false\);/,
|
||||
/nextCollapsed\s*&&\s*this\.cockpitView\?\.active\s*&&\s*COCKPIT_LEFT_PANEL_IDS\.has\(panelId\)[\s\S]*?_cockpitContextCollapsedForDataPanel[\s\S]*?this\.cockpitView\.setContextCollapsed\(false\);/,
|
||||
'closing Data Layers must restore Contact only after an automatic collapse',
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/const COCKPIT_LEFT_PANEL_IDS = new Set\(\['data-panel', 'utilities-panel'\]\);/,
|
||||
'Utilities folds Contact away in Cockpit as Data Layers does',
|
||||
);
|
||||
assert.match(
|
||||
setContextCollapsed.toString(),
|
||||
/const wasCollapsed = this\.contextCollapsed;[\s\S]*?this\.active && wasCollapsed && !this\.contextCollapsed[\s\S]*?'gev:cockpit-context-expanded'/,
|
||||
@@ -678,6 +683,7 @@ test('fresh Cockpit entry temporarily collapses map panels and exit restores the
|
||||
for (const panelId of [
|
||||
'data-panel',
|
||||
'cctv-panel',
|
||||
'utilities-panel',
|
||||
'scene-panel',
|
||||
'pp-toggles',
|
||||
'global-context-panel',
|
||||
@@ -1379,3 +1385,27 @@ test('cockpit state cannot report entryAllowed while already active', () => {
|
||||
assert.match(state, /'contacts-inactive'/);
|
||||
assert.match(state, /'no-tracked-aircraft'/);
|
||||
});
|
||||
|
||||
test('Contact folded for one Cockpit left panel comes back only once neither is open', async () => {
|
||||
assert.match(
|
||||
ui,
|
||||
/if \(!this\._cockpitContextCollapsedForDataPanel\)\s*this\._cockpitContextCollapsedForDataPanel =\s*!this\.cockpitView\.contextCollapsed;/,
|
||||
'opening the second left panel keeps the fold the first recorded',
|
||||
);
|
||||
const { PanelChrome } = await import('./ui/panelChrome.js');
|
||||
const previous = globalThis.document;
|
||||
const panels = {};
|
||||
globalThis.document = { getElementById: (id) => panels[id] ?? null };
|
||||
const panel = (...classes) => ({ classList: { contains: (name) => classes.includes(name) } });
|
||||
try {
|
||||
const otherOpen = (id) => PanelChrome.prototype._otherCockpitLeftPanelOpen(id);
|
||||
panels['data-panel'] = panel('collapsed');
|
||||
assert.equal(otherOpen('utilities-panel'), false, 'Data Layers closed: restore');
|
||||
panels['data-panel'] = panel('collapsed', 'layout-auto-collapsed');
|
||||
assert.equal(otherOpen('utilities-panel'), true, 'Data Layers comes back: keep Contact folded');
|
||||
panels['data-panel'] = panel();
|
||||
assert.equal(otherOpen('utilities-panel'), true, 'Data Layers open: keep it folded');
|
||||
} finally {
|
||||
globalThis.document = previous;
|
||||
}
|
||||
});
|
||||
+7
-7
@@ -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. */
|
||||
|
||||
@@ -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), '---');
|
||||
});
|
||||
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -1321,6 +1321,27 @@ test('buildVesselCard: heading falls back to course; missing parts are omitted',
|
||||
assert.deepEqual(bare.details, []);
|
||||
});
|
||||
|
||||
test('vessel cards read a course that rounds to north as 0°, not 360°', () => {
|
||||
const card = (course) => buildVesselCard(makeRecord({ heading: null, course })).details[0];
|
||||
assert.equal(card(359.4), 'CONTAINER SHIP · 14.5KT · 359°');
|
||||
assert.equal(card(359.7), 'CONTAINER SHIP · 14.5KT · 0°');
|
||||
assert.equal(
|
||||
buildSelectedVesselCard(makeRecord({ heading: null, course: 359.7 })).details[0],
|
||||
'CONTAINER SHIP · 14.5KT · 0°',
|
||||
);
|
||||
});
|
||||
|
||||
test('the vessel HUD reads a course that rounds to north as HDG 0DEG', () => {
|
||||
const harness = installWireHarness(undefined, { selectedRecord: null });
|
||||
try {
|
||||
Object.assign(harness.record, { heading: null, course: 359.7 });
|
||||
assert.equal(aisLiveVesselsLayer.selectById(harness.record.mmsi), true);
|
||||
assert.match(harness.hud.readout.text, /HDG: 0DEG/);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('buildVesselCard: unnamed vessels title as MMSI; long names truncate', () => {
|
||||
const unnamed = buildVesselCard(makeRecord({ name: 'VESSEL' }));
|
||||
assert.equal(unnamed.title, 'MMSI 353136000');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -446,6 +446,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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1029,10 +1029,12 @@ 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.
|
||||
// The Utilities panel adds one set_panel_open target.
|
||||
assert.equal(block.length, 30919, 'serialized tool schema length drifted');
|
||||
assert.equal(
|
||||
crypto.createHash('sha256').update(block).digest('hex'),
|
||||
'f772df95404494bf492e02fde1beabffdfe61986940bffd0cf9ad5a38d768746',
|
||||
'dbf9a45649179e626b2e3d938c43903f573c50ffa279b3245b18244dbe9a9284',
|
||||
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
|
||||
);
|
||||
const instructions = fs.readFileSync(
|
||||
|
||||
@@ -3138,14 +3138,10 @@
|
||||
"Sector": "القطاع",
|
||||
"Sonar sector width": "عرض قطاع السونار",
|
||||
"Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "ارسم على العالم — انقر على الرؤوس، وانقر نقرًا مزدوجًا أو اضغط Enter للإنهاء، وEsc للإلغاء",
|
||||
"Shape": "الشكل",
|
||||
"Shape to draw": "الشكل المراد رسمه",
|
||||
"Area": "مساحة",
|
||||
"Line": "خط",
|
||||
"Pin": "دبوس",
|
||||
"Label (optional)": "التسمية (اختيارية)",
|
||||
"Label for the drawn shape": "تسمية الشكل المرسوم",
|
||||
"Colour of the drawn shape": "لون الشكل المرسوم",
|
||||
"Primary": "أساسي",
|
||||
"Amber": "كهرماني",
|
||||
"Cyan": "سماوي",
|
||||
@@ -3219,6 +3215,15 @@
|
||||
"Download the timing log of the last play": "تنزيل سجل توقيت آخر تشغيل",
|
||||
"Edit anchors, data packs and shot details": "تعديل نقاط الارتكاز وحزم البيانات وتفاصيل اللقطة",
|
||||
"Download this scene as JSON or an asset bundle": "تنزيل هذا المشهد بصيغة JSON أو حزمة موارد",
|
||||
"Name of {name}": "اسم {name}",
|
||||
"Change the colour of {name}": "تغيير لون {name}",
|
||||
"Colour of {name}": "لون {name}",
|
||||
"Colour of the next shape": "لون الشكل التالي",
|
||||
"Drawn shapes": "الأشكال المرسومة",
|
||||
"Audio": "الصوت",
|
||||
"UTILITIES": "الأدوات",
|
||||
"Exit cockpit to fly to a drawing": "اخرج من القمرة للانتقال إلى رسم",
|
||||
"Directions, drawing and your own files": "الاتجاهات والرسم وملفاتك الخاصة",
|
||||
"World time": "وقت العالم",
|
||||
"Back to live": "العودة إلى البث المباشر",
|
||||
"Live": "مباشر",
|
||||
|
||||
@@ -2864,14 +2864,10 @@
|
||||
"Sector": "Sektor",
|
||||
"Sonar sector width": "Breite des Sonarsektors",
|
||||
"Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "Auf die Welt zeichnen – Eckpunkte anklicken, Doppelklick oder Enter zum Beenden, Esc zum Abbrechen",
|
||||
"Shape": "Form",
|
||||
"Shape to draw": "Zu zeichnende Form",
|
||||
"Area": "Fläche",
|
||||
"Line": "Linie",
|
||||
"Pin": "Pin",
|
||||
"Label (optional)": "Beschriftung (optional)",
|
||||
"Label for the drawn shape": "Beschriftung der gezeichneten Form",
|
||||
"Colour of the drawn shape": "Farbe der gezeichneten Form",
|
||||
"Primary": "Primär",
|
||||
"Amber": "Bernstein",
|
||||
"Cyan": "Cyan",
|
||||
@@ -2939,6 +2935,15 @@
|
||||
"Right panels": "Rechte Panels",
|
||||
"CONFIRMED": "BESTÄTIGT",
|
||||
"PAD / RTLS": "STARTPLATZ / RTLS",
|
||||
"Name of {name}": "Name von {name}",
|
||||
"Change the colour of {name}": "Farbe von {name} ändern",
|
||||
"Colour of {name}": "Farbe von {name}",
|
||||
"Colour of the next shape": "Farbe der nächsten Form",
|
||||
"Drawn shapes": "Gezeichnete Formen",
|
||||
"Audio": "Audio",
|
||||
"UTILITIES": "WERKZEUGE",
|
||||
"Exit cockpit to fly to a drawing": "Cockpit verlassen, um zu einer Zeichnung zu fliegen",
|
||||
"Directions, drawing and your own files": "Route, Zeichnen und eigene Dateien",
|
||||
"World time": "Weltzeit",
|
||||
"Back to live": "Zurück zu live",
|
||||
"Live": "Live",
|
||||
|
||||
@@ -885,14 +885,15 @@
|
||||
"Board cleared.": "Tablero borrado.",
|
||||
"Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "Dibuja sobre el mundo: haz clic en los vértices, doble clic o Enter para terminar, Esc para cancelar",
|
||||
"Draw": "Dibujo",
|
||||
"Shape": "Forma",
|
||||
"Shape to draw": "Forma a dibujar",
|
||||
"Area": "Área",
|
||||
"Line": "Línea",
|
||||
"Pin": "Marcador",
|
||||
"Label (optional)": "Etiqueta (opcional)",
|
||||
"Label for the drawn shape": "Etiqueta de la forma dibujada",
|
||||
"Colour of the drawn shape": "Color de la forma dibujada",
|
||||
"Colour of {name}": "Color de {name}",
|
||||
"Change the colour of {name}": "Cambiar el color de {name}",
|
||||
"Name of {name}": "Nombre de {name}",
|
||||
"Colour of the next shape": "Color de la próxima forma",
|
||||
"Drawn shapes": "Formas dibujadas",
|
||||
"Primary": "Principal",
|
||||
"Amber": "Ámbar",
|
||||
"Cyan": "Cian",
|
||||
@@ -1456,6 +1457,7 @@
|
||||
"NOIR": "NOIR",
|
||||
"Rendered globe frames per second · toggle with `": "Fotogramas por segundo renderizados del globo · alterna con `",
|
||||
"Utilities": "Utilidades",
|
||||
"Audio": "Audio",
|
||||
"Other layers": "Otras capas",
|
||||
"Movement": "Movimiento",
|
||||
"Infrastructure": "Infraestructura",
|
||||
@@ -3009,6 +3011,9 @@
|
||||
"Download this scene as JSON or an asset bundle": "Descarga esta escena como JSON o como paquete de recursos",
|
||||
"Left panels": "Paneles izquierdos",
|
||||
"Right panels": "Paneles derechos",
|
||||
"UTILITIES": "UTILIDADES",
|
||||
"Exit cockpit to fly to a drawing": "Sal de la cabina para volar a un dibujo",
|
||||
"Directions, drawing and your own files": "Indicaciones, dibujo y tus propios archivos",
|
||||
"World time": "Hora mundial",
|
||||
"Back to live": "Volver al directo",
|
||||
"Live": "En directo",
|
||||
|
||||
@@ -2883,14 +2883,10 @@
|
||||
"Scope edge feather": "Adoucissement du bord de la lunette",
|
||||
"Scope edge feather as a percentage of the keyhole radius": "Adoucissement du bord de la lunette, en pourcentage du rayon de l'ouverture",
|
||||
"Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "Dessinez sur le monde — cliquez sur les sommets, double-clic ou Enter pour terminer, Esc pour annuler",
|
||||
"Shape": "Forme",
|
||||
"Shape to draw": "Forme à dessiner",
|
||||
"Area": "Zone",
|
||||
"Line": "Ligne",
|
||||
"Pin": "Épingle",
|
||||
"Label (optional)": "Étiquette (facultative)",
|
||||
"Label for the drawn shape": "Étiquette de la forme dessinée",
|
||||
"Colour of the drawn shape": "Couleur de la forme dessinée",
|
||||
"Primary": "Principale",
|
||||
"Amber": "Ambre",
|
||||
"Cyan": "Cyan",
|
||||
@@ -3009,6 +3005,15 @@
|
||||
"Right panels": "Panneaux de droite",
|
||||
"CONFIRMED": "CONFIRMÉE",
|
||||
"PAD / RTLS": "PAS DE TIR / RTLS",
|
||||
"Name of {name}": "Nom de {name}",
|
||||
"Change the colour of {name}": "Changer la couleur de {name}",
|
||||
"Colour of {name}": "Couleur de {name}",
|
||||
"Colour of the next shape": "Couleur de la prochaine forme",
|
||||
"Drawn shapes": "Formes dessinées",
|
||||
"Audio": "Audio",
|
||||
"UTILITIES": "OUTILS",
|
||||
"Exit cockpit to fly to a drawing": "Quittez le cockpit pour rejoindre un dessin",
|
||||
"Directions, drawing and your own files": "Itinéraire, dessin et vos propres fichiers",
|
||||
"World time": "Heure du monde",
|
||||
"Back to live": "Revenir au direct",
|
||||
"Live": "Direct",
|
||||
|
||||
@@ -2883,14 +2883,10 @@
|
||||
"Scope edge feather": "Sfumatura del bordo dello scope",
|
||||
"Scope edge feather as a percentage of the keyhole radius": "Sfumatura del bordo dello scope, come percentuale del raggio del foro",
|
||||
"Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "Disegna sul mondo: fai clic sui vertici, doppio clic o Enter per finire, Esc per annullare",
|
||||
"Shape": "Forma",
|
||||
"Shape to draw": "Forma da disegnare",
|
||||
"Area": "Area",
|
||||
"Line": "Linea",
|
||||
"Pin": "Segnaposto",
|
||||
"Label (optional)": "Etichetta (opzionale)",
|
||||
"Label for the drawn shape": "Etichetta della forma disegnata",
|
||||
"Colour of the drawn shape": "Colore della forma disegnata",
|
||||
"Primary": "Principale",
|
||||
"Amber": "Ambra",
|
||||
"Cyan": "Ciano",
|
||||
@@ -3009,6 +3005,15 @@
|
||||
"Right panels": "Pannelli a destra",
|
||||
"CONFIRMED": "CONFERMATA",
|
||||
"PAD / RTLS": "SITO DI LANCIO / RTLS",
|
||||
"Name of {name}": "Nome di {name}",
|
||||
"Change the colour of {name}": "Cambia il colore di {name}",
|
||||
"Colour of {name}": "Colore di {name}",
|
||||
"Colour of the next shape": "Colore della prossima forma",
|
||||
"Drawn shapes": "Forme disegnate",
|
||||
"Audio": "Audio",
|
||||
"UTILITIES": "STRUMENTI",
|
||||
"Exit cockpit to fly to a drawing": "Esci dal cockpit per volare a un disegno",
|
||||
"Directions, drawing and your own files": "Indicazioni, disegno e i tuoi file",
|
||||
"World time": "Ora del mondo",
|
||||
"Back to live": "Torna alla diretta",
|
||||
"Live": "Diretta",
|
||||
|
||||
@@ -2741,14 +2741,10 @@
|
||||
"Scope edge feather": "スコープ縁のぼかし",
|
||||
"Scope edge feather as a percentage of the keyhole radius": "スコープ縁のぼかし(キーホール半径に対する割合)",
|
||||
"Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "地球上に描画:クリックで頂点を追加、ダブルクリックまたはEnterで完了、Escでキャンセル",
|
||||
"Shape": "図形",
|
||||
"Shape to draw": "描画する図形",
|
||||
"Area": "範囲",
|
||||
"Line": "線",
|
||||
"Pin": "ピン",
|
||||
"Label (optional)": "ラベル(任意)",
|
||||
"Label for the drawn shape": "描画した図形のラベル",
|
||||
"Colour of the drawn shape": "描画した図形の色",
|
||||
"Primary": "プライマリー",
|
||||
"Amber": "アンバー",
|
||||
"Cyan": "シアン",
|
||||
@@ -2869,6 +2865,15 @@
|
||||
"Right panels": "右パネル",
|
||||
"CONFIRMED": "確認済み",
|
||||
"PAD / RTLS": "射場 / RTLS",
|
||||
"Name of {name}": "{name}の名前",
|
||||
"Change the colour of {name}": "{name}の色を変更",
|
||||
"Colour of {name}": "{name}の色",
|
||||
"Colour of the next shape": "次の図形の色",
|
||||
"Drawn shapes": "描いた図形",
|
||||
"Audio": "オーディオ",
|
||||
"UTILITIES": "ユーティリティ",
|
||||
"Exit cockpit to fly to a drawing": "描画の位置へ移動するにはコックピットを終了してください",
|
||||
"Directions, drawing and your own files": "ルート案内、描画、自分のファイル",
|
||||
"World time": "世界時刻",
|
||||
"Back to live": "ライブに戻る",
|
||||
"Live": "ライブ",
|
||||
|
||||
@@ -2885,14 +2885,10 @@
|
||||
"Scope edge feather": "Suavização da borda da mira",
|
||||
"Scope edge feather as a percentage of the keyhole radius": "Suavização da borda da mira, em porcentagem do raio do visor",
|
||||
"Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "Desenhar sobre o mundo — clique nos vértices, clique duplo ou Enter para concluir, Esc para cancelar",
|
||||
"Shape": "Forma",
|
||||
"Shape to draw": "Forma a desenhar",
|
||||
"Area": "Área",
|
||||
"Line": "Linha",
|
||||
"Pin": "Marcador",
|
||||
"Label (optional)": "Rótulo (opcional)",
|
||||
"Label for the drawn shape": "Rótulo da forma desenhada",
|
||||
"Colour of the drawn shape": "Cor da forma desenhada",
|
||||
"Primary": "Principal",
|
||||
"Amber": "Âmbar",
|
||||
"Cyan": "Ciano",
|
||||
@@ -3009,6 +3005,15 @@
|
||||
"Right panels": "Painéis à direita",
|
||||
"CONFIRMED": "CONFIRMADA",
|
||||
"PAD / RTLS": "PLATAFORMA / RTLS",
|
||||
"Name of {name}": "Nome de {name}",
|
||||
"Change the colour of {name}": "Mudar a cor de {name}",
|
||||
"Colour of {name}": "Cor de {name}",
|
||||
"Colour of the next shape": "Cor da próxima forma",
|
||||
"Drawn shapes": "Formas desenhadas",
|
||||
"Audio": "Áudio",
|
||||
"UTILITIES": "UTILITÁRIOS",
|
||||
"Exit cockpit to fly to a drawing": "Saia da cabine para voar até um desenho",
|
||||
"Directions, drawing and your own files": "Rotas, desenho e seus próprios arquivos",
|
||||
"World time": "Hora do mundo",
|
||||
"Back to live": "Voltar ao vivo",
|
||||
"Live": "Ao vivo",
|
||||
|
||||
@@ -894,14 +894,15 @@
|
||||
"Board cleared.": "Доска очищена.",
|
||||
"Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "Рисовать на карте мира — щёлкайте по вершинам, двойной щелчок или Enter — завершить, Esc — отменить",
|
||||
"Draw": "Рисование",
|
||||
"Shape": "Фигура",
|
||||
"Shape to draw": "Фигура для рисования",
|
||||
"Area": "Зона",
|
||||
"Line": "Линия",
|
||||
"Pin": "Метка",
|
||||
"Label (optional)": "Подпись (необязательно)",
|
||||
"Label for the drawn shape": "Подпись нарисованной фигуры",
|
||||
"Colour of the drawn shape": "Цвет нарисованной фигуры",
|
||||
"Colour of {name}": "Цвет: {name}",
|
||||
"Change the colour of {name}": "Изменить цвет: {name}",
|
||||
"Name of {name}": "Название: {name}",
|
||||
"Colour of the next shape": "Цвет следующей фигуры",
|
||||
"Drawn shapes": "Нарисованные фигуры",
|
||||
"Primary": "Основной",
|
||||
"Amber": "Янтарный",
|
||||
"Cyan": "Голубой",
|
||||
@@ -1479,6 +1480,7 @@
|
||||
"NOIR": "НУАР",
|
||||
"Rendered globe frames per second · toggle with `": "Кадров глобуса в секунду · переключение клавишей `",
|
||||
"Utilities": "Инструменты",
|
||||
"Audio": "Аудио",
|
||||
"Other layers": "Другие слои",
|
||||
"Movement": "Движение",
|
||||
"Infrastructure": "Инфраструктура",
|
||||
@@ -3079,6 +3081,9 @@
|
||||
"Download this scene as JSON or an asset bundle": "Скачать эту сцену в формате JSON или архивом ресурсов",
|
||||
"Left panels": "Левые панели",
|
||||
"Right panels": "Правые панели",
|
||||
"UTILITIES": "ИНСТРУМЕНТЫ",
|
||||
"Exit cockpit to fly to a drawing": "Выйдите из кабины, чтобы перелететь к рисунку",
|
||||
"Directions, drawing and your own files": "Маршруты, рисование и ваши файлы",
|
||||
"World time": "Мировое время",
|
||||
"Back to live": "Вернуться к прямому эфиру",
|
||||
"Live": "Сейчас",
|
||||
|
||||
@@ -2953,14 +2953,10 @@
|
||||
"Scope edge feather": "Розмиття краю прицілу",
|
||||
"Scope edge feather as a percentage of the keyhole radius": "Розмиття краю прицілу у відсотках від його радіуса",
|
||||
"Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "Малювати на карті світу — клацайте вершини, подвійне клацання або Enter — завершити, Esc — скасувати",
|
||||
"Shape": "Фігура",
|
||||
"Shape to draw": "Фігура для малювання",
|
||||
"Area": "Зона",
|
||||
"Line": "Лінія",
|
||||
"Pin": "Мітка",
|
||||
"Label (optional)": "Підпис (необовʼязково)",
|
||||
"Label for the drawn shape": "Підпис намальованої фігури",
|
||||
"Colour of the drawn shape": "Колір намальованої фігури",
|
||||
"Primary": "Основний",
|
||||
"Amber": "Бурштиновий",
|
||||
"Cyan": "Блакитний",
|
||||
@@ -3079,6 +3075,15 @@
|
||||
"Right panels": "Праві панелі",
|
||||
"CONFIRMED": "ПІДТВЕРДЖЕНО",
|
||||
"PAD / RTLS": "МІСЦЕ ЗАПУСКУ / RTLS",
|
||||
"Name of {name}": "Назва: {name}",
|
||||
"Change the colour of {name}": "Змінити колір: {name}",
|
||||
"Colour of {name}": "Колір: {name}",
|
||||
"Colour of the next shape": "Колір наступної фігури",
|
||||
"Drawn shapes": "Намальовані фігури",
|
||||
"Audio": "Аудіо",
|
||||
"UTILITIES": "ІНСТРУМЕНТИ",
|
||||
"Exit cockpit to fly to a drawing": "Вийдіть із кабіни, щоб перелетіти до малюнка",
|
||||
"Directions, drawing and your own files": "Маршрути, малювання та ваші файли",
|
||||
"World time": "Світовий час",
|
||||
"Back to live": "Повернутися до прямого ефіру",
|
||||
"Live": "Зараз",
|
||||
|
||||
@@ -1757,6 +1757,7 @@
|
||||
"Events": "事件",
|
||||
"Weather": "天气",
|
||||
"Utilities": "工具",
|
||||
"Audio": "音频",
|
||||
"Other layers": "其他图层",
|
||||
"{state} · {source}": "{state} · {source}",
|
||||
"UNCERTAIN · {source} · lifecycle state requires reconciliation": "不确定 · {source} · 生命周期状态需要核对",
|
||||
@@ -2639,14 +2640,15 @@
|
||||
"Scope edge feather": "视域边缘羽化",
|
||||
"Scope edge feather as a percentage of the keyhole radius": "视域边缘羽化,以视域半径的百分比表示",
|
||||
"Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "在地球上绘制——单击放置顶点,双击或按 Enter 完成,按 Esc 取消",
|
||||
"Shape": "形状",
|
||||
"Shape to draw": "要绘制的形状",
|
||||
"Area": "面",
|
||||
"Line": "线",
|
||||
"Pin": "图钉",
|
||||
"Label (optional)": "标签(可选)",
|
||||
"Label for the drawn shape": "绘制形状的标签",
|
||||
"Colour of the drawn shape": "绘制形状的颜色",
|
||||
"Colour of {name}": "{name} 的颜色",
|
||||
"Change the colour of {name}": "更改 {name} 的颜色",
|
||||
"Name of {name}": "{name} 的名称",
|
||||
"Colour of the next shape": "下一个形状的颜色",
|
||||
"Drawn shapes": "已绘制的形状",
|
||||
"Primary": "主题色",
|
||||
"Amber": "琥珀色",
|
||||
"Cyan": "青色",
|
||||
@@ -2869,6 +2871,9 @@
|
||||
"Download this scene as JSON or an asset bundle": "将此场景下载为 JSON 或资源包",
|
||||
"Left panels": "左侧面板",
|
||||
"Right panels": "右侧面板",
|
||||
"UTILITIES": "工具",
|
||||
"Exit cockpit to fly to a drawing": "请先退出座舱,再飞往绘制位置",
|
||||
"Directions, drawing and your own files": "路线导航、绘制和你自己的文件",
|
||||
"World time": "世界时间",
|
||||
"Back to live": "返回实时",
|
||||
"Live": "实时",
|
||||
|
||||
@@ -1747,6 +1747,7 @@
|
||||
"Events": "事件",
|
||||
"Weather": "天氣",
|
||||
"Utilities": "工具",
|
||||
"Audio": "音訊",
|
||||
"Other layers": "其他圖層",
|
||||
"{state} · {source}": "{state} · {source}",
|
||||
"UNCERTAIN · {source} · lifecycle state requires reconciliation": "不確定 · {source} · 生命週期狀態需要核對",
|
||||
@@ -2627,14 +2628,15 @@
|
||||
"Scope edge feather": "視域邊緣羽化",
|
||||
"Scope edge feather as a percentage of the keyhole radius": "視域邊緣羽化,以視域半徑的百分比表示",
|
||||
"Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "在地球上繪製——單擊放置頂點,雙擊或按 Enter 完成,按 Esc 取消",
|
||||
"Shape": "形狀",
|
||||
"Shape to draw": "要繪製的形狀",
|
||||
"Area": "面",
|
||||
"Line": "線",
|
||||
"Pin": "圖釘",
|
||||
"Label (optional)": "標籤(可選)",
|
||||
"Label for the drawn shape": "繪製形狀的標籤",
|
||||
"Colour of the drawn shape": "繪製形狀的顏色",
|
||||
"Colour of {name}": "{name} 的顏色",
|
||||
"Change the colour of {name}": "更改 {name} 的顏色",
|
||||
"Name of {name}": "{name} 的名稱",
|
||||
"Colour of the next shape": "下一個形狀的顏色",
|
||||
"Drawn shapes": "已繪製的形狀",
|
||||
"Primary": "主題色",
|
||||
"Amber": "琥珀色",
|
||||
"Cyan": "青色",
|
||||
@@ -2869,6 +2871,9 @@
|
||||
"Download this scene as JSON or an asset bundle": "將此場景下載為 JSON 或資源包",
|
||||
"Left panels": "左側面板",
|
||||
"Right panels": "右側面板",
|
||||
"UTILITIES": "工具",
|
||||
"Exit cockpit to fly to a drawing": "請先退出座艙,再飛往繪製位置",
|
||||
"Directions, drawing and your own files": "路線導航、繪製和你自己的文件",
|
||||
"World time": "世界時間",
|
||||
"Back to live": "返回即時",
|
||||
"Live": "即時",
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"Alternative",
|
||||
"Ambient",
|
||||
"Anime",
|
||||
"Audio",
|
||||
"Austin",
|
||||
"BRG {value}°",
|
||||
"BRG —",
|
||||
@@ -211,6 +212,7 @@
|
||||
"AUTO",
|
||||
"Ambient",
|
||||
"Anime",
|
||||
"Audio",
|
||||
"Austin",
|
||||
"BRG {value}°",
|
||||
"BRG —",
|
||||
@@ -340,6 +342,7 @@
|
||||
"Altitude {altitude}",
|
||||
"Ambient",
|
||||
"Anime",
|
||||
"Audio",
|
||||
"Austin",
|
||||
"Aviation / Marine",
|
||||
"BRG {value}°",
|
||||
@@ -524,6 +527,7 @@
|
||||
"Ambient",
|
||||
"Anime",
|
||||
"Area",
|
||||
"Audio",
|
||||
"Austin",
|
||||
"BRG {value}°",
|
||||
"BRG —",
|
||||
|
||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createAlprOverlay } from './overlay.js';
|
||||
import { normalizeAlprNode } from './records.js';
|
||||
import {
|
||||
directionWedgePositions,
|
||||
alprLabelDetails,
|
||||
@@ -218,6 +219,23 @@ test('adapter labels never claim a custom source is public OSM data', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('a mapped bearing that rounds up to a whole turn reads 0°, not 360°', () => {
|
||||
const direction = (tag) =>
|
||||
alprLabelDetails(
|
||||
normalizeAlprNode({
|
||||
type: 'node',
|
||||
id: 1,
|
||||
lat: 30,
|
||||
lon: -97,
|
||||
tags: { 'surveillance:type': 'ALPR', 'camera:direction': tag },
|
||||
}),
|
||||
{ label: 'Custom directory' },
|
||||
).find((line) => line.startsWith('DIRECTION'));
|
||||
assert.equal(direction('359.6'), 'DIRECTION 0°');
|
||||
assert.equal(direction('359.4'), 'DIRECTION 359°');
|
||||
assert.equal(direction('0.4'), 'DIRECTION 0°');
|
||||
});
|
||||
|
||||
test('overlay glyphs and native badges shrink alike with distance', () => {
|
||||
assert.equal(markerScale(100), 1.15);
|
||||
assert.equal(markerScale(40_000), 0.45);
|
||||
|
||||
@@ -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
|
||||
@@ -50,7 +51,7 @@ export function alprLabelDetails(record, source) {
|
||||
sourceName === 'OpenStreetMap' ? 'OSM MAPPED' : `Source: ${sourceName}`,
|
||||
];
|
||||
if (Number.isFinite(record.directionDeg))
|
||||
details.push(`DIRECTION ${Math.round(record.directionDeg)}°`);
|
||||
details.push(`DIRECTION ${roundHeadingDegrees(record.directionDeg)}°`);
|
||||
const equipment = [
|
||||
...new Set(
|
||||
[record.manufacturer, record.operator, record.cameraType]
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -446,3 +446,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);
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
|
||||
|
||||
/**
|
||||
* @module headingConfidence
|
||||
@@ -44,7 +45,7 @@ export function isHeadingEstimated(camera) {
|
||||
* @returns {string}
|
||||
*/
|
||||
export function headingHudToken(camera) {
|
||||
const hdg = Math.round(Number(camera?.headingDeg) || 0);
|
||||
const hdg = roundHeadingDegrees(Number(camera?.headingDeg)) ?? 0;
|
||||
return isHeadingEstimated(camera)
|
||||
? t('HDG {value}° (ESTIMATED)', { value: hdg })
|
||||
: t('HDG {value}°', { value: hdg });
|
||||
|
||||
@@ -44,6 +44,15 @@ test('HUD token tags a synthetic bearing and rounds the degrees', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('HUD token reads a bearing that rounds to north as 0°, not 360°', () => {
|
||||
assert.equal(headingHudToken({ headingDeg: 359.4 }), 'HDG 359°');
|
||||
assert.equal(headingHudToken({ headingDeg: 359.6 }), 'HDG 0°');
|
||||
assert.equal(
|
||||
headingHudToken({ headingDeg: 359.5, headingConfidence: 'low' }),
|
||||
'HDG 0° (ESTIMATED)',
|
||||
);
|
||||
});
|
||||
|
||||
test('HUD token stays untagged for surveyed and calibrated bearings', () => {
|
||||
assert.equal(
|
||||
headingHudToken({ headingDeg: 67.5, headingConfidence: 'high' }),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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°',
|
||||
);
|
||||
});
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
TRAIL_COLOR,
|
||||
CYAN_TRANSPARENT,
|
||||
} from './policy.js';
|
||||
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
|
||||
|
||||
export function createTracking({
|
||||
flightState,
|
||||
@@ -127,7 +128,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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 })
|
||||
|
||||
@@ -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°',
|
||||
);
|
||||
});
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
AMBER_TRANSPARENT,
|
||||
TRACKED_ICON_COLOR,
|
||||
} from './policy.js';
|
||||
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
|
||||
|
||||
export function createTracking({
|
||||
flightState,
|
||||
@@ -121,7 +122,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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 =
|
||||
@@ -80,3 +81,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,
|
||||
);
|
||||
});
|
||||
@@ -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(layerState.now());
|
||||
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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
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');
|
||||
|
||||
@@ -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({
|
||||
@@ -407,7 +408,9 @@ export function buildTransitSelectionCopy(
|
||||
);
|
||||
if (Number.isFinite(record.bearing))
|
||||
reportedMotion.push(
|
||||
t('reported hdg {heading}°', { heading: Math.round(record.bearing) }),
|
||||
t('reported hdg {heading}°', {
|
||||
heading: roundHeadingDegrees(record.bearing),
|
||||
}),
|
||||
);
|
||||
// The operator's own claim, kept as a REPORT rather than as the live state.
|
||||
// It describes the moment of the report, which the display may not have
|
||||
|
||||
@@ -43,6 +43,18 @@ test('the selection card calls a Toronto 501 a streetcar', () => {
|
||||
assert.doesNotMatch(copy.details.join(' | '), /Tram/);
|
||||
});
|
||||
|
||||
test('the selection card reads a bearing that rounds to north as hdg 0°', () => {
|
||||
const hdg = (bearing) =>
|
||||
buildTransitSelectionCopy(
|
||||
ttc(),
|
||||
{ id: '4400', routeId: '501', timestamp: 0, bearing },
|
||||
'tram',
|
||||
0,
|
||||
).details.find((line) => line.includes('hdg'));
|
||||
assert.equal(hdg(359.4), 'reported hdg 359°');
|
||||
assert.equal(hdg(359.6), 'reported hdg 0°');
|
||||
});
|
||||
|
||||
test('detection labels carry the operator vocabulary within the field width', () => {
|
||||
const klass = transitDetectionClass('tram', ttc());
|
||||
assert.equal(klass, 'STREETCAR TTC');
|
||||
|
||||
@@ -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,7 +56,8 @@ export function createCards({
|
||||
if (record.speed !== null && record.speed !== undefined)
|
||||
parts.push(formatSpeed(record.speed));
|
||||
const direction = record.heading ?? record.course;
|
||||
if (Number.isFinite(direction)) parts.push(`${Math.round(direction)}°`);
|
||||
if (Number.isFinite(direction))
|
||||
parts.push(`${roundHeadingDegrees(direction)}°`);
|
||||
return {
|
||||
id: vesselOverlayEntryId(record),
|
||||
actionable: Boolean(record?.mmsi),
|
||||
@@ -86,7 +88,9 @@ export function createCards({
|
||||
[
|
||||
vesselTypeShort(record) || t('VESSEL'),
|
||||
formatSpeed(record.speed),
|
||||
Number.isFinite(direction) ? `${Math.round(direction)}°` : '--°',
|
||||
Number.isFinite(direction)
|
||||
? `${roundHeadingDegrees(direction)}°`
|
||||
: '--°',
|
||||
].join(' · '),
|
||||
];
|
||||
const destination = String(record.destination || '').trim();
|
||||
@@ -176,7 +180,7 @@ export function createCards({
|
||||
|
||||
function formatHeading(heading) {
|
||||
return t('{heading}DEG', {
|
||||
heading: Number.isFinite(heading) ? Math.round(heading) : '--',
|
||||
heading: roundHeadingDegrees(heading) ?? '--',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ const COCKPIT_REFUSALS = Object.freeze({
|
||||
fire: msg('Exit cockpit to fly to a fire'),
|
||||
route: msg('Exit cockpit to fly to a route'),
|
||||
file: msg('Exit cockpit to fly to a file'),
|
||||
drawing: msg('Exit cockpit to fly to a drawing'),
|
||||
scene: msg('Exit cockpit to fly to a scene'),
|
||||
// Radar, satellite and lightning fly to the layer's coverage area (their
|
||||
// «View coverage» button); the cyclone layer flies to the selected storm.
|
||||
|
||||
@@ -117,6 +117,7 @@ test('every noun the callers pass has its own Cockpit refusal', () => {
|
||||
'photo',
|
||||
'shared view',
|
||||
'file',
|
||||
'drawing',
|
||||
'scene',
|
||||
'route',
|
||||
'weather',
|
||||
|
||||
@@ -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', () =>
|
||||
|
||||
@@ -297,3 +297,25 @@ test('expanded right panels highlight the title divider without changing collaps
|
||||
/#param-slider-panel:not\(\.collapsed\) \.param-panel-divider\s*\{[\s\S]*?linear-gradient\(\s*90deg,\s*rgb\(0 212 255 \/ 28%\),\s*rgba\(0, 212, 255, 0\.18\) 58%,\s*transparent\s*\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Utilities sits between Data Layers and Scenes and follows the left panels through every mode', () => {
|
||||
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
||||
const order = (id) => Number(new RegExp(`#left-panel-stack > #${id} \\{\\s*order: (\\d+);`).exec(css)?.[1]);
|
||||
assert.ok(order('data-panel') < order('utilities-panel'));
|
||||
assert.ok(order('utilities-panel') < order('scene-panel'));
|
||||
assert.match(css, /body\.ui-clean-view #utilities-panel,/);
|
||||
assert.match(css, /body\.recording-mode #utilities-panel,/);
|
||||
// Directions stays reachable in Cockpit; only Draw and Import step aside.
|
||||
assert.match(css, /body\.cockpit-mode #utilities-panel \.utilities-tools \{\s*display: none !important;/);
|
||||
assert.doesNotMatch(css, /body\.cockpit-mode #left-panel-stack > #utilities-panel[,\s{]/);
|
||||
assert.match(css, /body\.mobile-shell\[data-mobile-sheet='layers'\]\s+#left-panel-stack\s+> #utilities-panel,/);
|
||||
});
|
||||
|
||||
test('every left panel has its rail item, Utilities with its own icon', async () => {
|
||||
const { PANEL_RAIL_ITEMS } = await import('./ui/panelRailCatalog.js');
|
||||
const view = readFileSync(new URL('./ui/views/LayerPanelsView.vue', import.meta.url), 'utf8');
|
||||
const ids = [...view.matchAll(/<RailPanel id="([\w-]+)"/g)].map(([, id]) => id);
|
||||
assert.ok(ids.includes('utilities-panel'));
|
||||
for (const id of ids) assert.ok(PANEL_RAIL_ITEMS[id], `${id} has a rail item`);
|
||||
assert.equal(PANEL_RAIL_ITEMS['utilities-panel'].icon, 'handyman');
|
||||
});
|
||||
@@ -152,7 +152,7 @@ test('the edited existing tools changed exactly as intended', () => {
|
||||
const panel = byName.get('set_panel_open');
|
||||
assert.deepEqual(
|
||||
panel.parameters.properties.panelId.enum,
|
||||
['data-panel', 'location-bar', 'control-panel', 'cctv-panel', 'radio-panel', 'scene-panel', 'pp-toggles', 'global-context-panel', 'weather-panel', 'recent-imagery-panel', 'voice-settings', 'voice-preview', 'provider-settings'],
|
||||
['data-panel', 'location-bar', 'control-panel', 'cctv-panel', 'radio-panel', 'scene-panel', 'utilities-panel', 'pp-toggles', 'global-context-panel', 'weather-panel', 'recent-imagery-panel', 'voice-settings', 'voice-preview', 'provider-settings'],
|
||||
);
|
||||
assert.deepEqual(panel.parameters.required, ['panelId', 'open']);
|
||||
|
||||
|
||||
@@ -1078,3 +1078,20 @@ test('a new viewer lifetime drops the previous view-target getter', () => {
|
||||
interruptCameraMotion('test-cleanup');
|
||||
second.restore();
|
||||
});
|
||||
|
||||
test('a route hidden from Draw is not flown; the newest visible one is', () => {
|
||||
const viewer = createTickableViewer();
|
||||
initCameraVerbs(viewer.viewer, () => null);
|
||||
const route = (label, hidden) => ({
|
||||
type: 'route',
|
||||
label,
|
||||
hidden,
|
||||
path: TWO_TURN_ROUTE.map(([lon, lat]) => ({ lon, lat, height: 0 })),
|
||||
});
|
||||
const onlyHidden = flyRoute([route('hidden walk', true)]);
|
||||
assert.equal(onlyHidden.ok, false, 'a board of hidden routes has none to fly');
|
||||
const started = flyRoute([route('shown walk', false), route('hidden walk', true)]);
|
||||
assert.equal(started.ok, true);
|
||||
assert.match(JSON.stringify(started), /shown walk/, 'the visible route is flown');
|
||||
interruptCameraMotion?.('test');
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
chmodSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
cachedVueModule,
|
||||
compileVueModule,
|
||||
ownCacheDirectory,
|
||||
vueCacheDirectory,
|
||||
vueCacheKey,
|
||||
} from '../../build/vueLoader.js';
|
||||
|
||||
const SOURCE =
|
||||
'<script setup>\nconst label = "Hi";\n</script>\n<template><b>{{ label }}</b></template>\n';
|
||||
const FILE = '/virtual/Greeting.vue';
|
||||
|
||||
test('a component compiles once and later processes read the cached module', (t) => {
|
||||
const directory = mkdtempSync(path.join(tmpdir(), 'gev-vue-cache-test-'));
|
||||
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
const compiled = cachedVueModule(SOURCE, FILE, directory);
|
||||
assert.equal(compiled, compileVueModule(SOURCE, FILE));
|
||||
const entries = readdirSync(directory);
|
||||
assert.deepEqual(entries, [`${vueCacheKey(SOURCE, FILE)}.mjs`]);
|
||||
// A cached entry is returned as stored, without compiling again.
|
||||
writeFileSync(path.join(directory, entries[0]), 'export default "cached";');
|
||||
assert.equal(
|
||||
cachedVueModule(SOURCE, FILE, directory),
|
||||
'export default "cached";',
|
||||
);
|
||||
});
|
||||
|
||||
test('an edited component or another path is a new cache entry', () => {
|
||||
const key = vueCacheKey(SOURCE, FILE);
|
||||
assert.notEqual(vueCacheKey(`${SOURCE}\n`, FILE), key);
|
||||
assert.notEqual(vueCacheKey(SOURCE, '/virtual/Other.vue'), key);
|
||||
assert.equal(vueCacheKey(SOURCE, FILE), key);
|
||||
});
|
||||
|
||||
test('GEV_VUE_CACHE=0 compiles every time; otherwise the cache sits in a per-user directory', () => {
|
||||
assert.equal(vueCacheDirectory({ GEV_VUE_CACHE: '0' }), null);
|
||||
if (process.platform !== 'win32') {
|
||||
const directory = vueCacheDirectory({});
|
||||
assert.equal(path.dirname(directory), tmpdir());
|
||||
assert.match(path.basename(directory), /^gev-vue-sfc-/);
|
||||
}
|
||||
// Windows temp directories may be shared, so the cache lives in the
|
||||
// user's profile, or not at all.
|
||||
assert.equal(
|
||||
vueCacheDirectory(
|
||||
{ LOCALAPPDATA: 'C:\\Users\\a\\AppData\\Local' },
|
||||
'win32',
|
||||
),
|
||||
path.join('C:\\Users\\a\\AppData\\Local', 'gev-vue-sfc'),
|
||||
);
|
||||
assert.equal(vueCacheDirectory({}, 'win32'), null);
|
||||
assert.equal(
|
||||
cachedVueModule(SOURCE, FILE, null),
|
||||
compileVueModule(SOURCE, FILE),
|
||||
);
|
||||
});
|
||||
|
||||
test('a cache directory that cannot be written still returns the compiled module', (t) => {
|
||||
const directory = mkdtempSync(path.join(tmpdir(), 'gev-vue-cache-test-'));
|
||||
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
// A file where the cache directory should be makes every write fail.
|
||||
const blocked = path.join(directory, 'blocked');
|
||||
writeFileSync(blocked, '');
|
||||
assert.equal(
|
||||
cachedVueModule(SOURCE, FILE, blocked),
|
||||
compileVueModule(SOURCE, FILE),
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'a cache directory others can write to, or a link to one, is never read',
|
||||
{
|
||||
skip: process.platform === 'win32',
|
||||
},
|
||||
(t) => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'gev-vue-cache-test-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const shared = path.join(root, 'shared');
|
||||
mkdirSync(shared);
|
||||
chmodSync(shared, 0o777);
|
||||
// A module planted where the cache would look must not run.
|
||||
writeFileSync(
|
||||
path.join(shared, `${vueCacheKey(SOURCE, FILE)}.mjs`),
|
||||
'export default "planted";',
|
||||
);
|
||||
assert.equal(ownCacheDirectory(shared), false);
|
||||
assert.equal(
|
||||
cachedVueModule(SOURCE, FILE, shared),
|
||||
compileVueModule(SOURCE, FILE),
|
||||
);
|
||||
const own = path.join(root, 'own');
|
||||
mkdirSync(own, { mode: 0o700 });
|
||||
const link = path.join(root, 'link');
|
||||
symlinkSync(own, link);
|
||||
assert.equal(ownCacheDirectory(link), false);
|
||||
assert.equal(ownCacheDirectory(own), true);
|
||||
},
|
||||
);
|
||||
@@ -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',
|
||||
|
||||
@@ -42,6 +42,7 @@ const label = computed(() =>
|
||||
:side="side"
|
||||
:label="label"
|
||||
:panel-open="active !== null"
|
||||
collapsible
|
||||
@select="onSelect"
|
||||
/>
|
||||
</template>
|
||||
@@ -490,3 +490,26 @@ test('the scene summary types in, then holds the whole text', async (t) => {
|
||||
await nextTick();
|
||||
assert.equal(byId('cctv-summary').textContent, text);
|
||||
});
|
||||
|
||||
test('the panel meta line reads a bearing that rounds to north as HDG 0°', () => {
|
||||
const meta = (headingDeg) => {
|
||||
const camera = {
|
||||
id: 'a',
|
||||
city: 'Austin',
|
||||
headingDeg,
|
||||
fovDeg: 74,
|
||||
rangeM: 700,
|
||||
};
|
||||
return presentCctvPanel(
|
||||
{
|
||||
enabled: true,
|
||||
activeCameraId: camera.id,
|
||||
activeCamera: camera,
|
||||
cameras: [camera],
|
||||
},
|
||||
true,
|
||||
).meta;
|
||||
};
|
||||
assert.match(meta(359.4), /· HDG 359° ·/);
|
||||
assert.match(meta(359.6), /· HDG 0° ·/);
|
||||
});
|
||||
@@ -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)}°`,
|
||||
heading: `${roundHeadingDegrees(Number(activeCamera.headingDeg)) ?? 0}°`,
|
||||
fov: `${Math.round(activeCamera.fovDeg)}°`,
|
||||
range: Math.round(activeCamera.rangeM),
|
||||
projection: projLabel,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -84,8 +84,9 @@ export function syncContextLayout() {
|
||||
return;
|
||||
}
|
||||
const defaultTop = Math.max(255, Math.min(300, window.innerHeight * 0.28));
|
||||
// An open left panel Cockpit keeps (Data Layers or Utilities).
|
||||
const dataPanel = document.querySelector(
|
||||
'#left-panel-stack > #data-panel:not(.collapsed)',
|
||||
'#left-panel-stack > :is(#data-panel, #utilities-panel):not(.collapsed)',
|
||||
);
|
||||
let desiredTop = defaultTop;
|
||||
if (this.contextCollapsed && dataPanel) {
|
||||
|
||||
@@ -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}°`;
|
||||
}
|
||||
@@ -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$/);
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -539,11 +539,11 @@ test('Cyber side panels share one width and one framed surface material', () =>
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/#left-panel-stack\s*> :is\(#data-panel, #scene-panel\)\.collapsed \{\s*width: var\(--cyber-panel-collapsed-width\);/,
|
||||
/#left-panel-stack\s*> :is\(#data-panel, #utilities-panel, #scene-panel\)\.collapsed \{\s*width: var\(--cyber-panel-collapsed-width\);/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/:is\(#data-panel, #scene-panel\)\.collapsed\s+:is\(\.data-panel-inner, \.scene-panel-inner\) \{[\s\S]*?height: 50px;[\s\S]*?min-height: 50px;/,
|
||||
/:is\(#data-panel, #utilities-panel, #scene-panel\)\.collapsed\s+:is\(\.data-panel-inner, \.utilities-panel-inner, \.scene-panel-inner\) \{[\s\S]*?height: 50px;[\s\S]*?min-height: 50px;/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
@@ -854,7 +854,7 @@ test('Cyber Cockpit keeps Data Layers inside its bounded rail', () => {
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/body\.cockpit-mode #left-panel-stack > #data-panel \{[\s\S]*?max-height: min\(64vh, 610px\);/,
|
||||
/body\.cockpit-mode\s+#left-panel-stack\s+> :is\(#data-panel, #utilities-panel\) \{[\s\S]*?max-height: min\(64vh, 610px\);/,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -4,9 +4,11 @@ import '../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createPinia } from 'pinia';
|
||||
import { createApp, nextTick } from 'vue';
|
||||
import { createApp, h, nextTick } from 'vue';
|
||||
import { useDisplayStore } from './stores/display.js';
|
||||
import DisplayPanel from './views/panels/DisplayPanel.vue';
|
||||
import DrawControls from './views/panels/DrawControls.vue';
|
||||
import GeoImport from './views/panels/GeoImport.vue';
|
||||
import { AircraftDisplay } from './aircraftDisplay.js';
|
||||
import { VisualSettings } from './visualSettings.js';
|
||||
import { initGeoFileImport } from './geoFileImport.js';
|
||||
@@ -15,11 +17,14 @@ import { CockpitDisplayPortal } from './cockpitDisplayPortal.js';
|
||||
const coreClasses = (el) =>
|
||||
[...el.classList].filter((name) => !name.startsWith('ui-')).join(' ');
|
||||
|
||||
function fixture(t) {
|
||||
// UTILITIES' Draw and Import, as the Utilities panel holds them.
|
||||
const UtilitiesTools = { render: () => [h(DrawControls), h(GeoImport)] };
|
||||
|
||||
function fixture(t, view = DisplayPanel) {
|
||||
document.body.replaceChildren();
|
||||
const host = document.body.appendChild(document.createElement('div'));
|
||||
const pinia = createPinia();
|
||||
const app = createApp(DisplayPanel).use(pinia);
|
||||
const app = createApp(view).use(pinia);
|
||||
app.mount(host);
|
||||
let mounted = true;
|
||||
const unmount = () => {
|
||||
@@ -354,17 +359,32 @@ test('the 3D aircraft group renders what AircraftDisplay shows', async (t) => {
|
||||
assert.equal(all.getAttribute('aria-checked'), 'false');
|
||||
});
|
||||
|
||||
test('DISPLAY no longer holds Draw or Import', (t) => {
|
||||
const { $ } = fixture(t);
|
||||
for (const id of ['draw-toggle', 'draw-mode-row', 'geo-import-button'])
|
||||
assert.equal($(id), null, `${id} moved to UTILITIES`);
|
||||
});
|
||||
|
||||
test('the Draw controls render what the draw tool publishes', async (t) => {
|
||||
const { store, $ } = fixture(t);
|
||||
const { store, $ } = fixture(t, UtilitiesTools);
|
||||
const toggle = $('draw-toggle');
|
||||
const segment = (shape) =>
|
||||
document.querySelector(`#draw-mode-row [data-shape="${shape}"]`);
|
||||
assert.equal(toggle.getAttribute('aria-pressed'), 'false');
|
||||
assert.equal(segment('area').getAttribute('aria-checked'), 'true');
|
||||
// Shapes are icons, named for screen readers and tooltips.
|
||||
assert.equal(segment('area').getAttribute('aria-label'), 'Area');
|
||||
assert.equal(segment('area').title, 'Area');
|
||||
assert.equal(segment('area').textContent, 'pentagon');
|
||||
const swatch = (color) => document.querySelector(`#draw-colors [data-color="${color}"]`);
|
||||
assert.equal(swatch('primary').getAttribute('aria-checked'), 'true');
|
||||
assert.equal(swatch('amber').getAttribute('aria-label'), 'Amber');
|
||||
assert.equal($('draw-hint').textContent, '');
|
||||
|
||||
store.publishDraw({ active: true, shape: 'line', hint: 'Click to add points.' });
|
||||
store.publishDraw({ active: true, shape: 'line', color: 'amber', hint: 'Click to add points.' });
|
||||
await nextTick();
|
||||
assert.equal(swatch('amber').getAttribute('aria-checked'), 'true');
|
||||
assert.equal(swatch('primary').classList.contains('active'), false);
|
||||
assert.equal(coreClasses(toggle), 'pp-toggle-btn active');
|
||||
assert.equal(toggle.getAttribute('aria-pressed'), 'true');
|
||||
assert.equal($('draw-mode-row').classList.contains('visible'), true);
|
||||
@@ -382,7 +402,7 @@ test('the Draw controls render what the draw tool publishes', async (t) => {
|
||||
});
|
||||
|
||||
test('the imported files render what the file import publishes and keep focus', async (t) => {
|
||||
const { store, $ } = fixture(t);
|
||||
const { store, $ } = fixture(t, UtilitiesTools);
|
||||
const list = $('geo-import-list');
|
||||
assert.equal(list.className, 'geo-import-list');
|
||||
assert.equal(list.children.length, 0);
|
||||
|
||||
@@ -10,13 +10,15 @@ import { useDisplayStore } from './stores/display.js';
|
||||
import { DisplayBindings } from './displayBindings.js';
|
||||
import CommandDockView from './views/CommandDockView.vue';
|
||||
import DisplayControlsView from './views/DisplayControlsView.vue';
|
||||
import DrawControls from './views/panels/DrawControls.vue';
|
||||
|
||||
function mount(t) {
|
||||
document.body.replaceChildren();
|
||||
const pinia = createPinia();
|
||||
const store = useDisplayStore(pinia);
|
||||
const app = createApp({
|
||||
render: () => [h(DisplayControlsView), h(CommandDockView)],
|
||||
// Draw sits in UTILITIES now; it answers through the same store.
|
||||
render: () => [h(DisplayControlsView), h(CommandDockView), h(DrawControls)],
|
||||
}).use(pinia);
|
||||
app.mount(document.body.appendChild(document.createElement('div')));
|
||||
t.after(() => app.unmount());
|
||||
@@ -102,13 +104,11 @@ test('the Draw controls call the draw tool and show what it publishes', async (t
|
||||
const { calls } = recorder(store);
|
||||
$('draw-toggle').click();
|
||||
document.querySelector('[data-shape="pin"]').click();
|
||||
input($('draw-label-input'), 'Camp');
|
||||
input($('draw-color-select'), 'red', 'change');
|
||||
document.querySelector('#draw-colors [data-color="red"]').click();
|
||||
$('draw-clear').click();
|
||||
assert.deepEqual(calls, [
|
||||
['toggleDraw'],
|
||||
['setDrawShape', 'pin'],
|
||||
['setDrawLabel', 'Camp'],
|
||||
['setDrawColor', 'red'],
|
||||
['clearDraw'],
|
||||
]);
|
||||
@@ -116,15 +116,16 @@ test('the Draw controls call the draw tool and show what it publishes', async (t
|
||||
active: true,
|
||||
shape: 'line',
|
||||
color: 'green',
|
||||
label: '',
|
||||
hint: 'Click to add points.',
|
||||
marks: [],
|
||||
});
|
||||
await nextTick();
|
||||
const line = document.querySelector('[data-shape="line"]');
|
||||
assert.equal(line.getAttribute('aria-checked'), 'true');
|
||||
assert.ok(line.classList.contains('active'));
|
||||
assert.equal($('draw-color-select').value, 'green');
|
||||
assert.equal($('draw-label-input').value, '', 'a finished shape empties it');
|
||||
const green = document.querySelector('#draw-colors [data-color="green"]');
|
||||
assert.equal(green.getAttribute('aria-checked'), 'true');
|
||||
assert.ok(green.classList.contains('active'));
|
||||
assert.equal($('draw-hint').textContent, 'Click to add points.');
|
||||
assert.equal($('draw-toggle').getAttribute('aria-pressed'), 'true');
|
||||
});
|
||||
|
||||
Loaded 100 of 188 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user