mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
Merge remote-tracking branch 'origin/main' into sameh/world-clock
# Conflicts: # index.html # src/i18n/locales/ar.json # src/i18n/locales/de.json # src/i18n/locales/es.json # src/i18n/locales/fr.json # src/i18n/locales/it.json # src/i18n/locales/ja.json # src/i18n/locales/pt.json # src/i18n/locales/ru.json # src/i18n/locales/uk.json # src/i18n/locales/zh-CN.json # src/i18n/locales/zh-Hant.json
This commit is contained in:
commit
32e08f5043
188 files changed
+4471
-520
No files matched your search
@@ -40,7 +40,7 @@ body:
|
||||
attributes:
|
||||
label: OS, Node version, browser
|
||||
description: Include the versions you can find. Write "unknown" for anything unavailable.
|
||||
placeholder: "Windows 11, Node 24.14.0, Chrome 151"
|
||||
placeholder: "Windows 11, Node 24.22.2, Chrome 151"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
|
||||
@@ -34,7 +34,8 @@ If it's bigger, link to it instead.
|
||||
|
||||
<!--
|
||||
Which layer-panel group should it sit in? The current groups are in PANEL_GROUPS
|
||||
in src/ui/layerPanel.js: Movement, Cameras, Infrastructure, Events, Weather, Utilities.
|
||||
in src/ui/layerPanel.js: Movement, Cameras, Infrastructure, Events, Weather, Audio,
|
||||
and Utilities (its rows render in the separate UTILITIES panel).
|
||||
If none fits, propose a new group and say why.
|
||||
-->
|
||||
|
||||
|
||||
@@ -20,10 +20,10 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node: [24.14.0, 26.x]
|
||||
node: [24.22.2, 26.x]
|
||||
env:
|
||||
PUPPETEER_SKIP_DOWNLOAD: '1'
|
||||
GEV_REQUIRE_ALLOCATION_GATE: ${{ matrix.node == '24.14.0' && '1' || '0' }}
|
||||
GEV_REQUIRE_ALLOCATION_GATE: ${{ matrix.node == '24.22.2' && '1' || '0' }}
|
||||
steps:
|
||||
- name: Check out source
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 24.14.0
|
||||
node-version: 24.22.2
|
||||
cache: npm
|
||||
|
||||
- name: Install locked dependencies and headless Chrome
|
||||
@@ -131,7 +131,7 @@ jobs:
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 24.14.0
|
||||
node-version: 24.22.2
|
||||
cache: npm
|
||||
|
||||
- name: Run the Pinokio install path
|
||||
|
||||
+27
-5
@@ -4,13 +4,15 @@ Thanks for being here. God's Eye View is an open foundation for live spatial int
|
||||
|
||||
## Getting set up
|
||||
|
||||
Use Node.js 24.14.x or 26.x (also enforced by `package.json`).
|
||||
Use Node.js 24 (24.14.0 or later) or 26.x (also enforced by `package.json`).
|
||||
Prefer 24.22.1 or later: earlier releases lack the October 21, 2026 security
|
||||
fixes, including CVE-2026-111497 (a Brotli decoder flaw).
|
||||
|
||||
```bash
|
||||
git clone https://github.com/bilawalsidhu/gods-eye-view.git
|
||||
cd gods-eye-view
|
||||
nvm install 24.14.0
|
||||
nvm use 24.14.0
|
||||
nvm install 24.22.2
|
||||
nvm use 24.22.2
|
||||
npm install
|
||||
npm run doctor
|
||||
./scripts/dev-fresh.sh # or: npm run dev (keys are optional)
|
||||
@@ -27,6 +29,25 @@ through Pinokio; the terminal path above remains the contributor path.
|
||||
|
||||
Open `http://localhost:4173`. Before sending a PR run `npm run build`, `npm test`, and `npm run test:track` (dev server must be up) — **all three must stay green.**
|
||||
|
||||
### Test prerequisites
|
||||
|
||||
- **`npm test`** — unit tests. Runs standalone; no dev server or credentials
|
||||
required. Exit code is the gate.
|
||||
- **`npm run test:track`** — the 3D flight-tracking regression harness. It
|
||||
drives the **real app** in headless Chromium, so it **requires a running
|
||||
dev server** (default `http://localhost:4173`). To target another instance,
|
||||
run `npm run test:track -- --url <app-url>` (or
|
||||
`node scripts/track-regression.mjs --url <app-url>`). It does not depend on
|
||||
live OpenSky / adsb.lol / AISStream (those are optional or rate-limited
|
||||
locally); it installs a persistent `fetch` shim that returns synthetic
|
||||
aircraft in the exact upstream payload shapes the layers parse.
|
||||
- **`npm run build`** — production Vite build. No server or credentials
|
||||
required; it validates that the client bundle compiles cleanly.
|
||||
|
||||
Each feature also has its own headless gate; see
|
||||
[Feature regression gates](#feature-regression-gates) below. Docs and changelog
|
||||
rules are under [Pull requests](#pull-requests).
|
||||
|
||||
## Checking a built app locally
|
||||
|
||||
Run `npm run build` followed by `npm run preview`. Preview serves the built
|
||||
@@ -226,8 +247,9 @@ template has a section for them.
|
||||
instead of attaching it.
|
||||
3. **The proposed category.** Say which layer-panel group the layer belongs in.
|
||||
The groups are defined in `PANEL_GROUPS` in `src/ui/layerPanel.js` (today:
|
||||
Movement, Cameras, Infrastructure, Events, Weather and Utilities). A layer
|
||||
that isn't listed there falls under "Other layers". If none of the groups
|
||||
Movement, Cameras, Infrastructure, Events, Weather and Audio, plus Utilities,
|
||||
whose rows render in the separate UTILITIES panel). A layer that isn't
|
||||
listed there falls under "Other layers". If none of the groups
|
||||
fits, propose a new one and explain why.
|
||||
4. **The data source and licence.** Name the provider and endpoint, link its
|
||||
licence or terms, give the attribution shown in the app, and say whether it
|
||||
|
||||
@@ -304,6 +304,15 @@ the retrieval date (2026-07-30), exact download URL, license evidence, and the
|
||||
deterministic transform (`scripts/build-sf-neighborhoods.mjs`: `nhood` → `name`, ~2 m
|
||||
Douglas-Peucker simplification, 6-decimal rounding).
|
||||
|
||||
### Local CCTV source packs (`config/`)
|
||||
|
||||
The server can read a local camera list from `CCTV_SOURCES_FILE` (default
|
||||
`config/cctv_sources.austin.json`, which ships empty) and inline JSON from
|
||||
`CCTV_SOURCES_JSON`; the two are merged, and inline entries win on a duplicate
|
||||
id. A non-empty local pack replaces the live open-data camera packs unless
|
||||
`CCTV_FORCE_AUSTIN=1` is set. The packs under `config/` show the schema, and a
|
||||
pack's cameras keep their own source's license and attribution.
|
||||
|
||||
---
|
||||
|
||||
## Weather sources
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
# proxies (ADS-B, AIS, Overpass, OpenAI Realtime, ...) as middleware, so there
|
||||
# is nothing else to containerise. See compose.yaml for the usual entry point.
|
||||
# Same Node as CI (package.json engines, .github/workflows), pinned by digest.
|
||||
FROM node:24.14.0-slim@sha256:d8e448a56fc63242f70026718378bd4b00f8c82e78d20eefb199224a4d8e33d8
|
||||
FROM node:24.22.2-slim@sha256:c86ecead9e55a5210ac1727de38a4ca840bf03dcaf34dc9749addbf383b1920c
|
||||
|
||||
# Puppeteer is only used by the QA scripts; skip its ~150MB Chromium download.
|
||||
ENV PUPPETEER_SKIP_DOWNLOAD=1
|
||||
|
||||
@@ -89,7 +89,7 @@ Start with the included data sources, then add your own. Each layer is a separat
|
||||
|
||||
## ⚡ Quick Start
|
||||
|
||||
**Start without an account or API keys.** Both paths open the same app with
|
||||
**Start without an account or API keys.** Every path opens the same app with
|
||||
Esri satellite imagery and keyless terrain. OSM is the fallback if Esri is
|
||||
unreachable. Flights, military traffic, satellites, earthquakes, public
|
||||
cameras, radio, and launches are available without keys.
|
||||
@@ -129,8 +129,9 @@ hardware; it does not establish a minimum GPU requirement.
|
||||
|
||||
### Path 2 — Terminal / coding agent
|
||||
|
||||
Use **Node.js 24.x (24.14.0 or later) or 26.x**. The setup doctor warns about
|
||||
Node 25, which is end-of-life.
|
||||
Use **Node.js 24.x (24.14.0 or later) or 26.x**; 24.22.1 or later (26.12.1 or
|
||||
later on 26) includes the October 21, 2026 security fixes, among them
|
||||
CVE-2026-111497. The setup doctor warns about Node 25, which is end-of-life.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/bilawalsidhu/gods-eye-view.git
|
||||
@@ -233,6 +234,41 @@ the container.
|
||||
|
||||
</details>
|
||||
|
||||
### Path 4 — GitHub Codespaces (cloud, no local install)
|
||||
|
||||
Runs the same dev server in a cloud workspace, so Node and the dependencies
|
||||
never touch your machine. The globe still renders in your own browser, so it
|
||||
needs the same graphics there as any other path. GitHub Free includes 120
|
||||
core-hours a month with no card on file — about 60 hours on the default 2-core
|
||||
machine. Org and Enterprise plans include no free quota; current terms and
|
||||
quotas apply.
|
||||
|
||||
1. Fork this repository, then open **your fork** on GitHub.
|
||||
2. Click the green **Code** button, the **Codespaces** tab, then **Create codespace on main**.
|
||||
3. In the codespace terminal:
|
||||
```bash
|
||||
nvm install 24 && nvm use 24
|
||||
npm ci
|
||||
npm run doctor
|
||||
GEV_ALLOWED_HOSTS="$CODESPACE_NAME-4173.$GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN" npm run dev
|
||||
```
|
||||
4. Open the **Ports** tab, find `4173`, and click the globe icon to open it in
|
||||
the browser. The port is private by default, so the URL asks for your GitHub
|
||||
login. Keep it private: a public port lets anyone use the app and the keys
|
||||
in your `.env`.
|
||||
|
||||
`GEV_ALLOWED_HOSTS` there is required, not optional. The dev server answers only
|
||||
the hostnames it trusts, and a forwarded port arrives on
|
||||
`$CODESPACE_NAME-4173.$GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN`. Without it the
|
||||
page is `Blocked request. This host is not allowed.` Leading-dot wildcards are
|
||||
ignored on purpose, so pass that exact name.
|
||||
|
||||
⚠️ **A codespace is reachable from the internet.** Treat it as the
|
||||
wider-publish case in [Sharing an instance](#-sharing-an-instance). Provider
|
||||
Settings does not open on the forwarded URL, so put keys in the workspace
|
||||
`.env` by hand. `.env` is gitignored — never commit it. [SECURITY.md](SECURITY.md) has the
|
||||
threat model.
|
||||
|
||||
### Then power it up — in the app, not in a file
|
||||
|
||||
Keys are upgrades, not prerequisites. When you want one, click the **POWER UP**
|
||||
@@ -357,7 +393,7 @@ camera input stops rotation.
|
||||
|
||||
> 🗣️ _"Outline the state of Texas."_ · _"Annotate the Texas State Capitol and its grounds"_ — it draws the **actual enclosing boundary**, not a circle. · _"How far is the Eiffel Tower from the Louvre?"_ — a connector arrow appears and it speaks the distance. Everything persists until you say _"clear the map."_
|
||||
|
||||
**✍️ Or draw it yourself** — DISPLAY ▸ **Draw**: pick Area, Line or Pin, click the vertices on the real world, double-click to finish, label it. Same whiteboard, same persistence, no microphone needed.
|
||||
**✍️ Or draw it yourself** — UTILITIES ▸ **Draw**: pick Area, Line or Pin and a colour, click the vertices on the real world, double-click to finish, then name, recolour, hide or fly to each shape from its row. Same whiteboard, same persistence, no microphone needed.
|
||||
|
||||

|
||||
|
||||
@@ -415,7 +451,7 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi
|
||||
|
||||
**Weather, keyless.** **Wind** animates 10 m forecast flow from NOAA GFS or ECMWF IFS, with an optional color field and a reading at map center. **Rain radar**, **Satellite clouds** and **Lightning density** are observations on one history timeline in the **WEATHER** panel (step back, **Play**, **Latest**); **Cyclone advisories** draw NHC and CPHC positions, forecast tracks and cones. On Google 3D Tiles the observed layers float above the city as translucent shells, sharpened around your view.
|
||||
|
||||
In the app, **Data Layers** groups them as Movement, Cameras, Infrastructure, Events, Weather and Utilities.
|
||||
In the app, **Data Layers** groups them as Movement, Cameras, Infrastructure, Events, Weather and Audio; Directions sits in the separate **Utilities** panel with Draw and Import.
|
||||
|
||||
**The basemap ladder — what each tier buys you:**
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { register } from 'node:module';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { load } from './vueLoader.js';
|
||||
|
||||
// Lets Node import single-file components: `node --import=<this file>`.
|
||||
// Hosts reach it as `<core package>/build/register-vue`.
|
||||
register('./vueLoader.js', import.meta.url);
|
||||
// Hosts reach it as `<core package>/build/register-vue`. The hooks run on the
|
||||
// loading thread: off-thread hooks (module.register) would add a round trip
|
||||
// to every module a test imports, not only the components.
|
||||
registerHooks({ load });
|
||||
+123
-6
@@ -1,14 +1,32 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import {
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { compileScript, compileTemplate, parse } from 'vue/compiler-sfc';
|
||||
import { VUE_TEMPLATE_OPTIONS } from './vueOptions.js';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
// The compiler loads on the first component that is not cached, so a test
|
||||
// process that imports no views, or only cached ones, never pays for it.
|
||||
let compiler = null;
|
||||
const sfcCompiler = () => (compiler ??= require('vue/compiler-sfc'));
|
||||
|
||||
/**
|
||||
* Compile a single-file component to an ES module for Node, so unit tests (the
|
||||
* application's and a host's) can import components without a bundler.
|
||||
* Styles are dropped: tests read the global stylesheet separately.
|
||||
*/
|
||||
export function compileVueModule(source, filename) {
|
||||
const { compileScript, compileTemplate, parse } = sfcCompiler();
|
||||
// The template is parsed with the SFC; parse it as the browser build does.
|
||||
const { descriptor, errors } = parse(source, {
|
||||
filename,
|
||||
@@ -40,14 +58,113 @@ export function compileVueModule(source, filename) {
|
||||
return `${script}\n${template}\nif (typeof render === 'function') __sfc__.render = render;\nexport default __sfc__;\n`;
|
||||
}
|
||||
|
||||
/** Node module hook: load `.vue` files as compiled modules. */
|
||||
export async function load(url, context, nextLoad) {
|
||||
// The loader's own code is part of every key, so a change to how components
|
||||
// compile never reuses output from before it.
|
||||
let loaderFingerprint = null;
|
||||
const fingerprint = () =>
|
||||
(loaderFingerprint ??= createHash('sha256')
|
||||
.update(readFileSync(fileURLToPath(import.meta.url)))
|
||||
.update(readFileSync(new URL('./vueOptions.js', import.meta.url)))
|
||||
.update(require('vue/package.json').version)
|
||||
.digest('hex'));
|
||||
|
||||
/**
|
||||
* Where compiled components are kept between test processes, never a
|
||||
* package's own tree (a host's checks refuse files inside Core): the system
|
||||
* temp directory, or on Windows the per-user local app data directory, since
|
||||
* a temp directory there may be shared and its ownership is not checked.
|
||||
* GEV_VUE_CACHE=0 compiles every time.
|
||||
*/
|
||||
export function vueCacheDirectory(
|
||||
env = process.env,
|
||||
platform = process.platform,
|
||||
) {
|
||||
if (env.GEV_VUE_CACHE === '0') return null;
|
||||
if (platform === 'win32') {
|
||||
return env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, 'gev-vue-sfc') : null;
|
||||
}
|
||||
return path.join(tmpdir(), `gev-vue-sfc-${process.getuid()}`);
|
||||
}
|
||||
|
||||
/** The cache entry for one component: its path, its text and the loader. */
|
||||
export function vueCacheKey(source, filename) {
|
||||
return createHash('sha256')
|
||||
.update(JSON.stringify([fingerprint(), filename]))
|
||||
.update('\0')
|
||||
.update(source)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile a component, reusing a previous process's result for the same text.
|
||||
* A cache that cannot be read or written only costs the compile.
|
||||
*/
|
||||
export function cachedVueModule(
|
||||
source,
|
||||
filename,
|
||||
directory = vueCacheDirectory(),
|
||||
) {
|
||||
if (!directory || !ownCacheDirectory(directory)) {
|
||||
return compileVueModule(source, filename);
|
||||
}
|
||||
const entry = path.join(directory, `${vueCacheKey(source, filename)}.mjs`);
|
||||
try {
|
||||
return readFileSync(entry, 'utf8');
|
||||
} catch {
|
||||
// Not compiled yet.
|
||||
}
|
||||
const compiled = compileVueModule(source, filename);
|
||||
// Concurrent test processes and threads may compile the same component;
|
||||
// each writes its own new file and the rename makes the entry appear whole.
|
||||
const partial = `${entry}.${randomBytes(6).toString('hex')}.tmp`;
|
||||
try {
|
||||
writeFileSync(partial, compiled, { flag: 'wx', mode: 0o600 });
|
||||
renameSync(partial, entry);
|
||||
} catch {
|
||||
// An unwritable directory, or a rename another process holds open,
|
||||
// leaves the cache without this entry.
|
||||
try {
|
||||
rmSync(partial, { force: true });
|
||||
} catch {
|
||||
// A partial file that cannot be removed is only clutter.
|
||||
}
|
||||
}
|
||||
return compiled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cached modules run as code, so the directory must be this user's own: a
|
||||
* real directory (not a link), owned by the current user and writable by no
|
||||
* one else. In a shared temp directory another user could otherwise create it
|
||||
* first and plant modules. On Windows it sits in the user's own profile.
|
||||
*/
|
||||
const checkedDirectories = new Map();
|
||||
export function ownCacheDirectory(directory) {
|
||||
if (!checkedDirectories.has(directory)) {
|
||||
checkedDirectories.set(directory, isOwnDirectory(directory));
|
||||
}
|
||||
return checkedDirectories.get(directory);
|
||||
}
|
||||
|
||||
function isOwnDirectory(directory) {
|
||||
try {
|
||||
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
||||
const stats = lstatSync(directory);
|
||||
if (!stats.isDirectory()) return false;
|
||||
if (process.platform === 'win32') return true;
|
||||
return stats.uid === process.getuid() && (stats.mode & 0o022) === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Synchronous module hook (module.registerHooks): `.vue` loads compiled. */
|
||||
export function load(url, context, nextLoad) {
|
||||
if (!url.endsWith('.vue')) return nextLoad(url, context);
|
||||
const filename = fileURLToPath(url);
|
||||
const source = await readFile(filename, 'utf8');
|
||||
return {
|
||||
format: 'module',
|
||||
source: compileVueModule(source, filename),
|
||||
source: cachedVueModule(readFileSync(filename, 'utf8'), filename),
|
||||
shortCircuit: true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
- Keep every submarine-cable segment associated with its source feature after
|
||||
Cesium expands multipart geometry, correcting segment colors, click destinations
|
||||
and horizon culling while retaining one reference stem per feature
|
||||
(@Aliciawque, #1093).
|
||||
@@ -0,0 +1,4 @@
|
||||
- The military-installations proxy (`/api/military-installations`) now refuses
|
||||
cross-site and reverse-proxy-forwarded browser requests with 403 before any
|
||||
Overpass work, through the same shared gate as `/api/overpass`. Same-origin
|
||||
app requests and local tools such as curl still work (@Sunil56224972, #1427).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Show a vessel course of 359.5° or more as 0°. Vessel cards and the AIS HUD
|
||||
rounded the course without wrapping, so a ship with no reported heading on
|
||||
a 359.7° course read "360°" and "HDG: 360DEG" (Aditya, @Rohilalala, #1432).
|
||||
@@ -0,0 +1,6 @@
|
||||
- Show a Contacts marker 999.5 m or more away as 1.0 km. The on-globe marker
|
||||
chose metres before rounding, so 999.6 m read "1000 m" while the panel read
|
||||
"1.0 km" (Aditya, @Rohilalala, #1433).
|
||||
- Distance labels share one formatter that takes each unit cut after rounding,
|
||||
so the Contacts panel and markers, the drawn-line measure and the Cockpit
|
||||
show 9.96 km as 10 km rather than 10.0 km (#1433).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Selecting the nearest aircraft by voice no longer says "0 feet" for a contact
|
||||
whose feed reported no altitude; the reply and card leave the altitude out
|
||||
(Aditya, @Rohilalala, #1434).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Military aircraft whose transponders report a GNSS vertical rate now carry it
|
||||
in analyst and tracked-aircraft data instead of null (Aditya, @Rohilalala,
|
||||
#1436).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Show a CCTV camera facing 359.5° or more as HDG 0°. The HUD heading token
|
||||
and the camera panel rounded the bearing without wrapping, so a camera
|
||||
facing 359.6° read "HDG 360°" (Aditya, @Rohilalala, #1441).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Keep Street Traffic's flow status honest at the edges. A few TomTom-matched
|
||||
dots among hundreds rounded to 0% and read "(no matches)", and a few
|
||||
simulated dots rounded to "100% cov"; coverage now stays within 1–99% while
|
||||
both are shown (Aditya, @Rohilalala, #1442).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Run Space Missions' Replay Ascent countdown from T−10 after the five-second
|
||||
tile hold; the hold ran 10 s and the countdown started at T−05 (Aditya,
|
||||
@Rohilalala, #1443).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Keep Space Missions' replay countdown in real time when the replay speed
|
||||
changes before liftoff; 0.25× had stretched T−8 to T−32 (Aditya,
|
||||
@Rohilalala, #1444).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Label a route, arrow or drawn line of 995 m or more as 1.0 km, and of
|
||||
9,950 m or more as 10 km. The placed label picked the unit before rounding,
|
||||
so it read "1000 m" and "10.0 km" (Aditya, @Rohilalala, #1445).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Bike Share stations whose feed publishes no capacity now take it from each
|
||||
status poll's bikes and docks instead of keeping the first poll's value or
|
||||
the placeholder 15 (Aditya, @Rohilalala, #1447).
|
||||
@@ -0,0 +1,6 @@
|
||||
- Local ADS-B decoder feeds now carry a GNSS-sourced vertical rate
|
||||
(`geom_rate`) when no barometric rate is sent, so the card shows V/S
|
||||
instead of a dash (Aditya, @Rohilalala, #1448).
|
||||
- Local ADS-B, military and the adsb.lol flight fallback read the vertical
|
||||
rate through one shared readsb field reader, so all three prefer the
|
||||
barometric rate and fall back to the GNSS rate the same way (#1448).
|
||||
@@ -0,0 +1,3 @@
|
||||
- List each submarine cable once in find_submarine_cables. TeleGeography
|
||||
splits 17 cables (2Africa, Echo, Hawaiki) into several features, so a
|
||||
search for Echo read "3 submarine cables" (Aditya, @Rohilalala, #1449).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Show a route of 9.95 km or more as 10 km. The Directions row and step
|
||||
cards kept one decimal until the raw distance reached 10 km, so 9.96 km
|
||||
read "10.0 km" (Aditya, @Rohilalala, #1450).
|
||||
@@ -0,0 +1,2 @@
|
||||
- Show an ALPR camera's mapped bearing of 359.5° or more as DIRECTION 0°
|
||||
rather than DIRECTION 360° (Aditya, @Rohilalala, #1451).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Show a transit vehicle's reported bearing of 359.5° or more as 0°. The
|
||||
selected-vehicle card rounded it without wrapping, so a bus heading 359.6°
|
||||
read "reported hdg 360°" (Aditya, @Rohilalala, #1452).
|
||||
@@ -0,0 +1,6 @@
|
||||
- Space Missions no longer shows an older satellite with a similar name and a
|
||||
different known launch year as a new payload's orbit: the launch-year check
|
||||
on loaded satellites now runs (Aditya, @Rohilalala, #1453).
|
||||
- Satellite launch years come from one TLE designator parser for both the
|
||||
loaded catalog and the active-TLE fallback; a blank designator is an
|
||||
unknown year instead of 2000 (#1453).
|
||||
@@ -0,0 +1,5 @@
|
||||
- Describe a selected flight or military contact on 359.5° or more to the
|
||||
voice assistant as heading 0°, not 360° (Aditya, @Rohilalala, #1454).
|
||||
- Heading and bearing readouts share one rounding helper, so the Cockpit's
|
||||
TRACK ACQUIRED course no longer reads 360°, and a negative bearing wraps
|
||||
instead of printing a minus sign (#1454).
|
||||
@@ -0,0 +1,5 @@
|
||||
- Between 721px and 900px wide (tablet portrait, phone landscape), the
|
||||
Location and Visual Presets dock wings show only their icons, so their
|
||||
labels no longer spill over the voice control or take taps meant for the
|
||||
mic button. The full name stays in the tooltip and accessible name, and the
|
||||
Cyber layout keeps its labels (Denis Groshev, @ZloyS, #1457).
|
||||
@@ -0,0 +1,4 @@
|
||||
- The Docker image and CI now pin Node 24.22.2, the current 24 LTS patch. It
|
||||
carries the October 21, 2026 security fixes first released in 24.22.1,
|
||||
including CVE-2026-111497 in the Brotli decoder (Luis Ortiz, @anobjectn,
|
||||
#1458).
|
||||
@@ -2,4 +2,4 @@
|
||||
looks up place names when no OpenAI key is configured, reverse-geocodes at
|
||||
most two points instead of up to four, and looks up again only once the view
|
||||
shows a different area, not after every small pan, so a small pan no longer
|
||||
asks OpenAI for a new summary either (samehkhamis, #0000).
|
||||
asks OpenAI for a new summary either (samehkhamis, #1459).
|
||||
@@ -0,0 +1,5 @@
|
||||
- Leave layer aliases the layer id already spells, such as "dams" for
|
||||
`local-dams`, out of the voice layer hint. The Realtime prompt is 305
|
||||
characters shorter, leaving room under the 52,000 gate for new
|
||||
voice-toggleable layers; every alias is still accepted (@quanticsoul4772,
|
||||
#1462).
|
||||
@@ -0,0 +1,6 @@
|
||||
- Gather Directions, Draw and Import in a new UTILITIES panel in the left
|
||||
column, between Data Layers and Scenes. Draw and Import leave DISPLAY, and
|
||||
Radio stays in Data Layers under "Audio". Each drawn shape now gets a row
|
||||
under Draw, like an imported file: name it, recolour it, hide it, fly to it
|
||||
or remove it there. Area, Line and Pin are icon buttons, and the colour for
|
||||
the next shape is a row of swatches (asturksever, #1469).
|
||||
@@ -0,0 +1,4 @@
|
||||
- `build/register-vue` loads components on the test thread and keeps compiled
|
||||
components between test processes in a per-user cache directory, so Node
|
||||
tests that import views run about twice as fast. `GEV_VUE_CACHE=0` compiles
|
||||
every time (samehkhamis, #1500).
|
||||
@@ -0,0 +1,3 @@
|
||||
- The side panel rails show each panel as a labelled pill while no panel on
|
||||
that side is open, and fold to icons once one opens. `UiRail` takes
|
||||
`collapsible` for this (samehkhamis, #1501).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Contributor docs: CONTRIBUTING lists what each of the three baseline checks
|
||||
needs to run, DATA_SOURCES documents loading a local CCTV source pack, and
|
||||
KNOWN-ISSUES points the floor-placement note at its verification script
|
||||
instead of a missing report (Gaurav Patidar, @gaurav7902, #210).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Document GitHub Codespaces as a fourth setup path in the README: a cloud
|
||||
workspace runs the dev server while the globe renders in your own browser
|
||||
(John Leshan, @johnleshan, #980).
|
||||
+112
-25
@@ -131,6 +131,26 @@ provider registration rejects compositions that cannot fit rather than truncatin
|
||||
identities. A malformed or duplicate entry invalidates the complete custom-switch
|
||||
field. Existing whole-link size limits still apply.
|
||||
|
||||
## Utilities panel — October 29, 2026
|
||||
|
||||
UTILITIES is its own left-side panel, between Data Layers and Scenes, with
|
||||
its own rail item (`handyman`). It is a `RailPanel` (`#utilities-panel`) in
|
||||
`src/ui/views/LayerPanelsView.vue` and holds the Directions layer row, which
|
||||
`LayerPanel` renders into `#utilities-toggles` instead of the Data Layers
|
||||
list, then Draw (`DrawControls.vue`) and Import (`GeoImport.vue`), which left
|
||||
DISPLAY with their ids unchanged. Without `#utilities-toggles` (a host that
|
||||
omits the panel) Directions stays in Data Layers under its Utilities heading.
|
||||
Radio, formerly the other row under that heading, stays in Data Layers under
|
||||
"Audio". Like the other side panels it follows the one-open-panel rule,
|
||||
remembers its state, and is not part of share links. It hides in clean view
|
||||
and recording. In Cockpit it stays with Directions alone, as Data Layers does;
|
||||
Draw and Import hide there, as DISPLAY always did, and opening Utilities folds
|
||||
the Cockpit context rail away as Data Layers does (and Contact expanding
|
||||
closes it). Voice `show_data_layers_menu` for Directions opens Utilities, the
|
||||
panel that holds its row, and `set_panel_open` reaches it as
|
||||
`utilities-panel` or by "utilities", "draw", "drawing" or "import". On a phone
|
||||
it sits collapsed in the Layers sheet under Data Layers.
|
||||
|
||||
## God's Eye View in conversations — October 2, 2026
|
||||
|
||||
Tool answers that can be shown in God's Eye View include a view: camera, layers,
|
||||
@@ -197,6 +217,9 @@ catalog, loaded on first use. See [tools and the MCP server](TOOLS.md).
|
||||
Aircraft altitude-band queries require a reported barometric or geometric altitude
|
||||
for each match, so a grounded contact that reports none is left out of a band too.
|
||||
Queries without an altitude band retain unknown-altitude contacts.
|
||||
`find_submarine_cables` lists and counts each cable once: TeleGeography splits
|
||||
a few cables (2Africa, Echo) into several features under one id, and such a
|
||||
cable matches when any of its parts crosses the area.
|
||||
|
||||
Catalog calls check their cancellation signal immediately before executing a
|
||||
tool. Already-cancelled queries do not start; cancellation after execution starts
|
||||
@@ -293,7 +316,7 @@ settings from active map/contact effects. Sonar settings are session-only and
|
||||
return to their defaults on reload. Sliders accept integer values matching the
|
||||
action's ranges.
|
||||
|
||||
Wind appears in the Weather group before Utilities. The surface-weather prototype
|
||||
Wind appears in the Weather group before Audio. The surface-weather prototype
|
||||
uses keyless NOAA GFS or ECMWF IFS forecasts on an approximately 1° display grid.
|
||||
It defaults to 10 m wind trails over the existing basemap. Speed shading is an explicit choice; earlier v2 links retain their original speed-shading meaning.
|
||||
Weather rows contain only the toggle and source/meta line. Configuration lives
|
||||
@@ -557,7 +580,9 @@ those reports arrive as 102.3 knots and 360 degrees. Both are stored as unknown,
|
||||
and vessel cards, the HUD and analyst queries show a missing measurement rather
|
||||
than a reading. Heading keeps its existing separate check for its own sentinel.
|
||||
Genuine readings are unchanged, including a stopped vessel's zero and the
|
||||
highest speed and course the fields can encode.
|
||||
highest speed and course the fields can encode. Vessel cards and the HUD wrap
|
||||
the direction after rounding to whole degrees, so a course of 359.5° or more
|
||||
reads `0°` (`0DEG` in the HUD) rather than `360°`.
|
||||
|
||||
For an already tracked vessel, AISStream static messages update name, type,
|
||||
destination and IMO without changing its position, motion, fix timestamps,
|
||||
@@ -958,7 +983,9 @@ change. Once the camera is still and tiles have settled, each overlay anchor is
|
||||
replaced once by the rendered surface under it (two depth samples per paint,
|
||||
accepted only within -30/+40 m of the floor cell), so badges do not float or
|
||||
sink on slopes; wedge corners follow their own floor cells instead of staying
|
||||
level with the apex. Unknown bearings never acquire a wedge. The row reads
|
||||
level with the apex. Unknown bearings never acquire a wedge. The selected
|
||||
card's `DIRECTION` wraps after rounding to whole degrees, so a mapped bearing
|
||||
of 359.5° or more reads `0°` rather than `360°`. The row reads
|
||||
`N nearby · M on screen`; when every loaded camera is outside the view it adds
|
||||
"None on screen — nearby cameras are outside the view" instead of looking
|
||||
unloaded. `scripts/qa-alpr-journey.mjs` drives an Austin journey with ALPR off,
|
||||
@@ -1131,7 +1158,8 @@ The tracked-aircraft descriptor both flight layers hand to the cockpit carries
|
||||
`verticalRate` and military records as `verticalRateMps`; the query seam maps both to
|
||||
one name, so `resolveTrackedAircraftInfo` returns a single shape for either layer. The
|
||||
rate is reported, never derived: a blank poll keeps the last transmitted value and an
|
||||
absent rate stays null rather than reading as level flight.
|
||||
absent rate stays null rather than reading as level flight. A military readsb row
|
||||
without `baro_rate` reports its GNSS rate as `geom_rate`, which is used instead.
|
||||
|
||||
## Transponder emergency highlighting
|
||||
|
||||
@@ -1578,6 +1606,10 @@ Imagery (while shown), Street Level and Context on the right. Selecting an item
|
||||
opens its panel through `setPanelCollapsed(id, false, {explicit: true})`;
|
||||
selecting the open panel's item closes it. The item of the open panel is
|
||||
pressed (`aria-pressed`), and its tooltip is suppressed while the panel is open.
|
||||
While no panel on its side is open, the rail shows each item as a labelled pill
|
||||
(icon and title, `UiRail`'s `collapsible`); opening a panel folds the labels away
|
||||
to the icon rail. The rail is placed by its screen-side edge, so the pills grow
|
||||
toward the middle of the screen, over the closed side's empty corridor.
|
||||
|
||||
One panel is open per desktop side, in every HUD layout. Opening a side panel
|
||||
by any route (rail, header, keyboard, voice) collapses its peers; restored or
|
||||
@@ -1886,6 +1918,8 @@ limit, as well as when a tile decodes or the source resets.
|
||||
GBFS null or empty availability counts retain an unknown value through station
|
||||
parsing and rendering; they use the neutral station color rather than reporting
|
||||
an empty dock. Explicit zero and caller-supplied numeric fallbacks remain valid.
|
||||
A station whose `station_information` publishes no capacity takes bikes plus
|
||||
docks from each status poll (15 until its status arrives), not from the first.
|
||||
|
||||
## Terrain height cache bound
|
||||
|
||||
@@ -2782,6 +2816,20 @@ isCurrent })` gates BOTH halves of the map-stack switch, which is its only
|
||||
> name the panel (`#location-bar` / `#control-panel`) to reach (2,5,0). Adding
|
||||
> a new tray rule means checking it against the pinned variants, not just
|
||||
> against the base rule.
|
||||
> - **Dock wings are icon-only from 900px down to 721px (2026-10-28):** at
|
||||
> ≤900px `--dock-side-compact` shrinks the Location and Visual Presets wings
|
||||
> to 4.25rem, open or closed (an open tray pops out above the dock), which is
|
||||
> too narrow for their text labels. The labels used to spill over the voice
|
||||
> control, and the LOCATION label captured taps on the left half of the mic
|
||||
> button. `src/ui/styles/command-dock-trays.css` now sets the
|
||||
> labels to `font-size: 0` and drops their `›`/`‹` chevrons in that band, so
|
||||
> each wing shows only its icon. `CommandDockView.vue` still gives the toggles
|
||||
> their "Expand/Collapse …" `aria-label` and `title` (`trayWords`), so the
|
||||
> browser tooltip is the only visible name on hover.
|
||||
> The Cyber HUD layout is excluded: it sets `--dock-side-compact` to
|
||||
> 9–9.75rem, which fits its labels. The former ≤620px ellipsis clip is gone:
|
||||
> the phone shell takes over at 720px, so it never applied to a visible
|
||||
> dock.
|
||||
> - **LOCATION mini-status is data-only today (2026-08-20):** the collapsed
|
||||
> readout now follows a free-text geocode search as well as preset pills
|
||||
> (`src/locationStatus.js` owns the copy for both), and every other camera
|
||||
@@ -3421,7 +3469,8 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
|
||||
> style-switch persistence state. Browser/GPU performance comparison remains
|
||||
> operator-side; the final accounting records only comparable population
|
||||
> reductions and deterministic Node allocation/solve measurements. Those
|
||||
> allocation gates are calibrated on Node.js 24.14.x; `package.json` permits
|
||||
> allocation gates were calibrated on Node.js 24.14.x and run in CI on the
|
||||
> pinned Node.js 24.22.2 with the same ceilings; `package.json` permits
|
||||
> supported product runtimes on Node 24 or 26, while the allocation runner
|
||||
> separately enforces Node 24 for these two calibrated probes. Each probe
|
||||
> compiles synchronously and discards explicit-GC
|
||||
@@ -4313,7 +4362,9 @@ animate). Disable clears all state.
|
||||
|
||||
At the metre/kilometre boundary, route distance labels use the rounded
|
||||
ten-metre value to select the unit, switching to kilometres at 995 metres;
|
||||
a 996-metre leg displays as `1.0 km` rather than `1000 m`.
|
||||
a 996-metre leg displays as `1.0 km` rather than `1000 m`. Whole kilometres
|
||||
start where the label would round to 10.0 km, so a 9.96 km route displays as
|
||||
`10 km` rather than `10.0 km`.
|
||||
|
||||
**Keys needed: none.** Directions works on a keyless boot, and the layer is off
|
||||
until the operator turns it on.
|
||||
@@ -4488,7 +4539,9 @@ to 0.45 at two minutes, 0.20 at ten minutes and 0.08 at fifteen minutes.
|
||||
display delay and newest-report age, reported speed/stop, vehicle ID, and
|
||||
available trail duration. Report age is rounded to whole seconds and stays in seconds while that rounded
|
||||
count is below 90; otherwise it shows completed minutes calculated from the
|
||||
rounded second count. Its anchor follows the rendered marker; text is
|
||||
rounded second count. The reported heading wraps after rounding to whole
|
||||
degrees, so a bearing of 359.5° or more reads `hdg 0°` rather than `hdg 360°`.
|
||||
Its anchor follows the rendered marker; text is
|
||||
checked at 4 Hz and published when it changes. DETECT labels retain route,
|
||||
mode and motion, with the selected label suppressed beside its fuller card.
|
||||
Candidate membership changes on revisions, coalesced at 4 Hz; paints read
|
||||
@@ -4604,8 +4657,21 @@ silent for five minutes are removed. Credits appear when the feed is used.
|
||||
coordinator, but it is not a user-visible Data Layers entry. Its visible entry
|
||||
point is the right-side `CONTEXT` chooser's `CONTACTS` mode.
|
||||
|
||||
Contacts distance labels select metres or kilometres after rounding to the
|
||||
nearest metre, so a 999.5-metre contact displays as `1.0 km`, not `1000 m`.
|
||||
Contacts distance labels, in the panel and on the globe's contact markers,
|
||||
select metres or kilometres after rounding to the nearest metre, so a
|
||||
999.5-metre contact displays as `1.0 km`, not `1000 m`, and drop the
|
||||
kilometre decimal from 9.95 km, so 9,960 m displays as `10 km`.
|
||||
|
||||
Distance labels that switch between metres and kilometres share
|
||||
`distanceParts` / `formatDistanceText` in `src/data/distanceFormat.js`: the
|
||||
Contacts panel and markers, Directions rows and step cards, placed annotation
|
||||
labels, the drawn-line measure and `formatRouteDistance`. Each cut is taken on
|
||||
the value as it prints, and kilometres come back already rounded, so the
|
||||
plain and localized labels agree. Callers choose the metre step (whole metres;
|
||||
tens, as annotation labels use; or tens with whole metres under 100, as
|
||||
Directions uses) and keep their own rendering; the Cockpit's kilometre-only
|
||||
distance uses `kilometreDigits`. The HUD altitude tag and the spoken view
|
||||
height still choose their own unit.
|
||||
|
||||
At global scale, ambient Radio cluster badges are hard-opacity shared-host
|
||||
entries: count/category updates and identity replacement do not run keyhole or
|
||||
@@ -4924,7 +4990,9 @@ silently demoting every later lookup for the session.
|
||||
wireframe dashed; colours, widths and active/idle emphasis are unchanged. A manual calibration
|
||||
(`calSource: 'manual'`) or curated pose (`poseSource: 'curated'`) is never presented as
|
||||
estimated, matching the CAL badge. Public camera state carries `headingConfidence` and
|
||||
`headingEstimated` (`src/layers/cctv/headingConfidence.js`, #639).
|
||||
`headingEstimated` (`src/layers/cctv/headingConfidence.js`, #639). The HUD token and the
|
||||
camera panel's meta line wrap the bearing after rounding to whole degrees, so a camera
|
||||
facing 359.5° or more reads `HDG 0°` rather than `HDG 360°`.
|
||||
- **CCTV identified hardware:** a camera whose source or seed names a hardware `model` that
|
||||
resolves in `src/layers/cctv/modelSpecs.js` (vendored CC0 records from the CCTV Camera
|
||||
Database) gets the model's specs appended to the HUD meta line. Only fixed single-lens
|
||||
@@ -5030,7 +5098,7 @@ silently demoting every later lookup for the session.
|
||||
protected-card path. Disable tears the tier down completely.
|
||||
Coverage/viewshed semantics unchanged.
|
||||
- **Satellites**: orbit rings rotate about Earth's Z by ΔGMST every ~1s (exact inertial→ECEF compensation; no SGP4 re-runs); the tracked satellite propagates per frame with one shared epoch for dot/label/camera. Verified: ISS holds <1km perpendicular to its ring while tracked. Pass prediction (`src/data/satellitePass.js`; `getNextSatellitePass`, `getNextIssPass`) scans at 20s, bisects rise/set to ~0.2s, fits the peak with a parabola, and marks a pass visible when the satellite is outside a cylindrical Earth shadow while the observer's Sun is at or below −6°. `next_iss_pass` preserves its geometric-pass ordering and appends visibility metadata. General `next_satellite_pass` accepts a loaded NORAD ID or unambiguous name, searches 24 hours and optionally requires estimated visibility. These estimates do not account for weather or satellite brightness.
|
||||
- **Space Missions (30d)**: recent launches render as bounded shared-host, horizon-occluded mission markers using Launch Library 2 v2.3 detailed records. Enabling the layer selects the unified right-side Context panel's Space Missions mode and enables the required satellite layer. Before applying its temporary dense/hidden Satellite mode, Space Missions snapshots the complete standalone Satellite parameter set and exact enabled-layer set. Disabling Space Missions from either Context or the left Data Layers rail restores those parameters and the exact prior enabled state, so a Satellite layer that was already on stays on while a mode-owned dependency returns off; enabling Satellites by itself remains independent. Selecting a mission isolates its launch-to-orbit transfer and dashed satellite orbit, fills that same panel with navigation/details, and animates a small phase-colored marker along the exact displayed Cartesian samples. Marker hit testing drill-picks through photorealistic tiles so the depth-test-free tactical dot remains reliably selectable; its text is non-interactive shared-host presentation. The selected pad is the camera's zoom pivot: the overview remains centered on its launch site, wheel zoom approaches that site instead of drifting elsewhere, and camera pitch progressively changes from global nadir to an oblique close 3D view. Its protected shared-host callout remains visible and expands to include both mission and launch-site names. `FOCUS` flies directly to a 12 km oblique frame around the selected launch site and retains the same anchored zoom/orbit behavior. `REPLAY ASCENT` resets the selected marker at the pad, frames it from an oblique third-person angle, and follows it through the mission-specific compressed ascent directly into one orbital lap at the default `1×` rate. A live `0.25×`–`4×` slider changes ascent and orbit playback speed; adjusting it mid-replay preserves the current path position and historical mission timestamp. Re-entry/recovery cannot be inserted into replay. At orbit insertion the camera smoothly pulls back over the first fifth of the orbital replay and pitches to a globe-scale nadir view while continuing to target the moving replay point. Replay Cancel, mission navigation, deselection, layer disable, and data refresh all release camera ownership. Reconstructed paths are one continuous 128-sample geodetic curve: horizontal departure begins near zero while altitude rises quickly, then the climb progressively bends toward insertion without the former hard 120 km corner. Unmatched orbit fallbacks are smooth planar inclined rings rather than longitude/latitude ground-track curves. The panel lists disclosed payload names, types, operators/manufacturers, mass, multiplicity, and destination when supplied; an empty LL2 payload collection is shown as `CLASSIFIED / MULTI-PAYLOAD`. Launcher, spacecraft, and recoverable payload stages appear in a compact stage table with serial/flight/reuse details, recovery outcome/type, destination, and final coordinates when those records exist; an empty recovery collection omits the section. Stage recoveries with confirmed coordinates use those coordinates; return-to-launch-site records use the pad; downrange-only records receive an explicitly labeled estimated endpoint along the ascent azimuth. Available endpoints render as static 2 px dashed descent/recovery paths with a fixed final-position dot and an estimated atmospheric-interface segment when applicable. The ascent is geodetically densified above the ellipsoid toward the orbit's nearest insertion point, then rendered with `ArcType.NONE`, so it neither cuts through Earth nor separates from the marker. Because LL2 does not normally supply continuous ascent telemetry, pad-to-insertion paths without upstream trajectory samples are labeled `RECONSTRUCTED ESTIMATE` / `ASCENT ESTIMATE`; only supplied trajectory samples receive the replay wording. Selected-orbit framing fits the complete ring, rear-side linework uses normal scene depth occlusion, and current distance, speed, and callout data come only from a launch-year-validated satellite match. Speed is the magnitude of the SGP4 inertial velocity vector at the same propagation epoch as position, displayed in km/s with km/h available as hover detail. Unavailable operator, site, launch-time, orbit, current-altitude, and speed values omit their detail rows instead of reserving panel space with placeholders. Newly launched payloads absent from the core operational groups use CelesTrak's cached active TLE feed as a lookup-only fallback; weak constellation-name matches are rejected. The replay callout maps compressed animation progress onto Launch Library's mission-relative timeline, showing the historical UTC date/time at the marker's current path position; unavailable timelines remain explicit. Matched live satellite positions propagate at one-second cadence and use a distinct green dot/callout with the current UTC date/time.
|
||||
- **Space Missions (30d)**: recent launches render as bounded shared-host, horizon-occluded mission markers using Launch Library 2 v2.3 detailed records. Enabling the layer selects the unified right-side Context panel's Space Missions mode and enables the required satellite layer. Before applying its temporary dense/hidden Satellite mode, Space Missions snapshots the complete standalone Satellite parameter set and exact enabled-layer set. Disabling Space Missions from either Context or the left Data Layers rail restores those parameters and the exact prior enabled state, so a Satellite layer that was already on stays on while a mode-owned dependency returns off; enabling Satellites by itself remains independent. Selecting a mission isolates its launch-to-orbit transfer and dashed satellite orbit, fills that same panel with navigation/details, and animates a small phase-colored marker along the exact displayed Cartesian samples. Marker hit testing drill-picks through photorealistic tiles so the depth-test-free tactical dot remains reliably selectable; its text is non-interactive shared-host presentation. The selected pad is the camera's zoom pivot: the overview remains centered on its launch site, wheel zoom approaches that site instead of drifting elsewhere, and camera pitch progressively changes from global nadir to an oblique close 3D view. Its protected shared-host callout remains visible and expands to include both mission and launch-site names. `FOCUS` flies directly to a 12 km oblique frame around the selected launch site and retains the same anchored zoom/orbit behavior. `REPLAY ASCENT` resets the selected marker at the pad, frames it from an oblique third-person angle, and follows it through the mission-specific compressed ascent directly into one orbital lap at the default `1×` rate. A live `0.25×`–`4×` slider changes ascent and orbit playback speed; adjusting it mid-replay preserves the current path position and historical mission timestamp. Re-entry/recovery cannot be inserted into replay. At orbit insertion the camera smoothly pulls back over the first fifth of the orbital replay and pitches to a globe-scale nadir view while continuing to target the moving replay point. Replay Cancel, mission navigation, deselection, layer disable, and data refresh all release camera ownership. Reconstructed paths are one continuous 128-sample geodetic curve: horizontal departure begins near zero while altitude rises quickly, then the climb progressively bends toward insertion without the former hard 120 km corner. Unmatched orbit fallbacks are smooth planar inclined rings rather than longitude/latitude ground-track curves. The panel lists disclosed payload names, types, operators/manufacturers, mass, multiplicity, and destination when supplied; an empty LL2 payload collection is shown as `CLASSIFIED / MULTI-PAYLOAD`. Launcher, spacecraft, and recoverable payload stages appear in a compact stage table with serial/flight/reuse details, recovery outcome/type, destination, and final coordinates when those records exist; an empty recovery collection omits the section. Stage recoveries with confirmed coordinates use those coordinates; return-to-launch-site records use the pad; downrange-only records receive an explicitly labeled estimated endpoint along the ascent azimuth. Available endpoints render as static 2 px dashed descent/recovery paths with a fixed final-position dot and an estimated atmospheric-interface segment when applicable. The ascent is geodetically densified above the ellipsoid toward the orbit's nearest insertion point, then rendered with `ArcType.NONE`, so it neither cuts through Earth nor separates from the marker. Because LL2 does not normally supply continuous ascent telemetry, pad-to-insertion paths without upstream trajectory samples are labeled `RECONSTRUCTED ESTIMATE` / `ASCENT ESTIMATE`; only supplied trajectory samples receive the replay wording. Selected-orbit framing fits the complete ring, rear-side linework uses normal scene depth occlusion, and current distance, speed, and callout data come only from a launch-year-validated satellite match. Both the loaded-catalog and active-TLE lookups read the launch year from the TLE international designator through one parser (`src/sources/tle.js`); a blank or malformed designator is an unknown year, which skips the year check rather than rejecting the match. Speed is the magnitude of the SGP4 inertial velocity vector at the same propagation epoch as position, displayed in km/s with km/h available as hover detail. Unavailable operator, site, launch-time, orbit, current-altitude, and speed values omit their detail rows instead of reserving panel space with placeholders. Newly launched payloads absent from the core operational groups use CelesTrak's cached active TLE feed as a lookup-only fallback; weak constellation-name matches are rejected. The replay callout maps compressed animation progress onto Launch Library's mission-relative timeline,Line truncated
|
||||
When no mission is selected, the Context panel presents a scrollable newest-first roster of every launch in the rolling window, including the smaller 5 px operator-colored marker, provider, and launch date. Hovering or keyboard-focusing a row shows four compact cyan corner brackets on both that roster row and its corresponding globe dot, rotates the globe at the current zoom to center it, and gives its label declutter priority without selecting it; the globe label remains unbracketed. Selecting a roster row invokes the same mission isolation and full-orbit framing as clicking its globe marker. The replay vehicle is one screen-space SVG/CSS HUD overlay rather than separate Cesium billboard, label, and reticle graphics. It is hidden during ordinary Focus and manual close views, where the standard selected launch-site label remains visible, and exists only while ascent replay is active. Its fixed pixel scale is shared by ascent and insertion, so camera range never resizes the rocket on screen before the phase boundary. Generic Launch Library pad names are reduced to their identifying suffix, and replay timestamps use a cyan state title over unprefixed white UTC date/time values. `REPLAY ASCENT` holds the unframed cyan/white rocket at the pad for a real-time `T−10` countdown, transitions through `LIFTOFF`, and attaches six tapered cyan/white ellipse waves directly below it from liftoff through insertion to convey thrust without adding scene geometry. While replay is active, the single start button is replaced by compact Play, Pause, and Cancel icon controls. Pause freezes countdown or mission time, vehicle/stage positions, camera target, labels, and thrust-wave animation; Play resumes from that exact frame, and Cancel releases replay camera ownership. The rocket and thrust group rotates from the path's live screen-space tangent, so its nose follows the visible ascent curve while the adjacent text remains upright. The camera begins as a close oblique launch chase, then smoothly widens between roughly 120 and 420 km vehicle altitude into a higher oblique context view that keeps the moving rocket targeted while exposing the ascent bend and orbit connection. At insertion the rocket/thrust glyph is replaced by the fixed-size cyan orbit dot, which the camera follows through the existing globe-scale orbit pullback. The chase camera limits per-frame yaw changes across heading wraps so it cannot abruptly cross in front of the vehicle and make ascent read in reverse; the replay-speed slider affects mission playback but not countdown duration.
|
||||
Mission world text has no native `LabelGraphics`: overview launch markers publish at most 48 ambient candidates for a 24-winner budget, while selecting a mission clears that overview source and publishes its launch-site callout, stage re-entry annotations, live/estimated payload-position readout, and orbit annotation as protected selected-lane entries. The source retains the exact former strings and colors. Static anchors reuse the Cartesian values used to build their mission geometry; the moving payload entry reads the layer's per-frame live-position cache; catalog-backed orbit annotation positions are cached in the same one-second matrix update that realigns the orbit primitive. Keyhole edge fade, horizon culling, final collision placement, and UI exclusion are owned by the shared host. Deselect restores the bounded overview, and refresh, disable, and destroy replace or clear both mission sources.
|
||||
A selected mission renders its orbit as four repeating tactical sectors, each containing one prominent cyan dot followed by one hundred thin translucent dashes. The bright dots act as orbit anchors while the subdued dash field remains depth-tested against the globe and is shown only for the selected mission.
|
||||
@@ -5053,6 +5121,8 @@ repointing `OVERPASS_UPSTREAMS` refetches instead of serving another instance's
|
||||
answers; untagged entries stop serving once an instance is configured.
|
||||
|
||||
The shared transport guards `/api/overpass` and `/api/military-installations`.
|
||||
Both query handlers refuse cross-site and forwarded browser requests through
|
||||
the shared local-request gate, before any rate-limit, cache or upstream work.
|
||||
With no configured upstream, each route serves existing cache data (including
|
||||
expired entries, marked stale with the original cache/retrieval date) without
|
||||
refresh. An annotation miss returns HTTP 503, `OVERPASS_NOT_CONFIGURED`,
|
||||
@@ -5253,6 +5323,8 @@ and unreachable upstream (502/504) separately from road geometry.
|
||||
cannot race the nearest-aircraft query ahead of layer enablement. A healthy fallback feed is
|
||||
queried normally and its source is returned with the selection; fallback with
|
||||
no airborne records remains an honest no-data result, not an enable failure.
|
||||
The spoken reply and the card leave out an altitude the feed did not report
|
||||
rather than reading it as 0 feet.
|
||||
- Voice treats the parent Context panel and Contacts as separate intents. An
|
||||
explicit request to open Context expands only `global-context-panel`; it does
|
||||
not choose a mode. An explicit request to open Contacts expands that parent
|
||||
@@ -5300,7 +5372,7 @@ and unreachable upstream (502/504) separately from road geometry.
|
||||
- Pins: `src/routeCinematics.test.mjs` (26 tests), with `scripts/qa-flyroute-mutations.mjs` reverting each fix individually to prove they are load-bearing (19 named defects); rendered proof `scripts/qa-flyroute-cinema.mjs` (drives the real voice runner, measures the real Cesium camera every frame, writes a labelled contact sheet).
|
||||
- **Public control facade** on StyleManager (`setHudVisible/setHudLayout/setDetection/setMapStack/setBloom/setSharpen/setOrbit/setCleanView/getControlState` plus `runImmediateNavigation`): every setter syncs DOM sliders + share links + scene snapshots and returns `{ok, ...state}` — voice confirms only what actually happened. Validated `move_camera`, `fly_route`, `frame_overhead`, strongest-fire focus, and entity tracking use the shared navigation transaction, which refuses Cockpit before mutation, advances authority, releases follow owners, cancels stale work, and only then starts the requested action. All four entity layers expose `findByQuery/getNearby/getAllPositions/trackById|selectById/stopTracking/getTrackedInfo`.
|
||||
- **Scene context** (`get_entity_context`): selected entity from the context store, or visible entities ranked by distance to the view target (≤100km altitude); plus basemap context — view-target picking, 7-point viewport sampling, view-scale classification (global/continental/regional/metro/city/local), reverse geocoding (center ≤750km, viewport samples ≤3,000km), Google Places Nearby via `/api/google/nearby-places` (≤25km), known-landmark matching against `CITY_POIS`, coarse country inference fallback. Context assembly is capped at 1.5s with cache fallbacks; caches are deduped in-flight.
|
||||
- **Every selectable layer writes that one slot** (2026-08-21). The tracking layers — flights, military, satellites — publish selection on their own awareness lane (`gev:awareness-subject-selected`, consumed by the readout card and the Contacts panel) and for the life of the voice tools never wrote the shared slot, so `scope:'selected'` silently answered `in_view` with a contact plainly selected on screen. They now call `selectTrackedSubjectContext` / `refreshTrackedSubjectContext` / `clearTrackedSubjectContext` (`src/data/contextStore.js`) on select / poll / deselect. **Invariants: (a)** that write path must NOT dispatch `gev:entity-selected` — tracking layers already own an event lane and a second one makes two surfaces fight over one subject; **(b)** exactly one record per tracking layer, because a frozen snapshot of a moved contact must never reach the visible-entity scan; **(c)** precedence is recency, not layer — one slot, last selection wins, so clicking an overlay entity supersedes a tracked plane as the voice subject while the plane stays tracked. Satellites refresh on the 1 s propagation beat, not per frame.
|
||||
- **Every selectable layer writes that one slot** (2026-08-21). The tracking layers — flights, military, satellites — publish selection on their own awareness lane (`gev:awareness-subject-selected`, consumed by the readout card and the Contacts panel) and for the life of the voice tools never wrote the shared slot, so `scope:'selected'` silently answered `in_view` with a contact plainly selected on screen. They now call `selectTrackedSubjectContext` / `refreshTrackedSubjectContext` / `clearTrackedSubjectContext` (`src/data/contextStore.js`) on select / poll / deselect. **Invariants: (a)** that write path must NOT dispatch `gev:entity-selected` — tracking layers already own an event lane and a second one makes two surfaces fight over one subject; **(b)** exactly one record per tracking layer, because a frozen snapshot of a moved contact must never reach the visible-entity scan; **(c)** precedence is recency, not layer — one slot, last selection wins, so clicking an overlay entity supersedes a tracked plane as the voice subject while the plane stays tracked. Satellites refresh on the 1 s propagation beat, not per frame. The flights and military context records wrap the track after rounding to whole degrees, so a contact on 359.5° or more reads `0°` rather than `360°`.
|
||||
- **Context-mode vocabulary is symmetric** (2026-08-21). `set_context_mode` accepts `contacts`; the internal id is `flights`. Every model-readable field (`mode`, `entering`, `priorMode`, nested `context`/`contextRollback`, and the transition diagnostic text) is reported in the accepted vocabulary, with the internal id preserved as `<field>Internal`; an absent secondary mode stays `null` rather than claiming to be `off`. Mapping lives in `src/contextModePolicy.js` so UI text and voice payloads cannot drift. Reporting the internal id made the model read `mode:'flights'` as "Contacts is off" and refuse to answer from the `contactsWindow` counts in the same payload.
|
||||
- **Analyst → track handoff carries a key, not just a label** (2026-08-21). `analyst_query` items include `icao24`/`mmsi` alongside the display `id`, and contact lookup uses the shared tiered ranking in `src/data/contactMatch.js`: hex exact → callsign exact → registration exact → callsign prefix → registration prefix → callsign substring → registration substring. The tiers keep an exact callsign ahead of a colliding registration regardless of feed order, and registrations compare separator-insensitively (`G-ABCD`/`GABCD`, `05-8152`/`058152`, `N123AB`/`N-123AB`).
|
||||
- **A typed command supersedes the turn it interrupts** (2026-08-21; hardened 2026-10-17). `sendTextCommand` defers its `response.create` behind an active response instead of colliding with it, marks that response superseded so a late function call from it is refused rather than dispatched, and drops the old turn's queued follow-up. A refused call is still ANSWERED — a terminal `{ok:false, superseded:true}` `function_call_output` — because an unanswered `function_call` strands a pending call and deadlocks the model; the refusal creates no response of its own. A burst of typed commands coalesces into one response while keeping both conversation items. If the local channel rejects the user item, that command requests no response and adds no deferred answer; earlier accepted deferred input remains queued. Interruption and pointer preparation still happen before the send attempt.
|
||||
@@ -5483,20 +5555,21 @@ request viewbox and continues searching without that geographic preference.
|
||||
|
||||
### Manual Whiteboard Drawing (September 2026)
|
||||
|
||||
- DISPLAY ▸ **Draw** turns the globe into a whiteboard drawn by hand. Pick a shape (Area, Line, Pin), click the vertices on the real world, type an optional label and pick a colour, and finish. **Keyboard contract, live only while Draw is on and never while you are typing in another field:** Enter finishes the shape (as does a double-click, and the two cannot both submit it — the session is replaced before the annotate call is awaited); Backspace removes the last vertex; Esc cancels the shape in progress, and a second Esc — Esc on an empty session — leaves draw mode. Typing in the label field keeps Backspace; Enter there also finishes. **Clear** wipes the board (the shape in progress and every placed mark), so removing marks does not require the console or the microphone.
|
||||
- Runtime entry: `createApplicationTools` (`src/app/tools.js`, behind the `src/standalone/tools.js` shim) calls `initDrawTool({ viewer, annotations })` after `initAnnotations` and defers its `destroy()`, so disposing the shell returns every DOM listener, the Cesium handler, the preview data source, the borrowed click actions and the `window.__gevDrawTool` handle. Its markup is in the DISPLAY-rail view (`src/ui/views/DisplayControlsView.vue`), rendered into its `index.html` host before the page is wired. That handle (`setActive`, `setShape`, `addVertex(lon, lat)`, `finish`, `cancel`, `clearAll`, `diagnostics`) is the no-mouse test seam.
|
||||
- Contract: a finished shape is submitted through the SAME `annotationEngine.annotate()` the voice agent uses, with geometry supplied and `manual: true` (`type: area` + `ring`, `type: route` + `path`, `type: pin` + coordinates). `resolveSpec` short-circuits manual specs (`resolveManualSpec`) so no geocoder, Places or Overpass call is made. A drawn area is a real (not synthesized) outline and drapes solid; a drawn line is a `route` with `mode: 'manual'`, labelled with its length and never a travel time; a pin is a point. Drawn marks therefore render with the whiteboard look, persist, de-dup, appear in `.list()`, and clear with `clear_annotations` / `.clear()`.
|
||||
- UTILITIES ▸ **Draw** turns the globe into a whiteboard drawn by hand. Pick a shape (icon buttons for Area, Line and Pin) and a colour swatch for the next shape, click the vertices on the real world, and finish. **Keyboard contract, live only while Draw is on and never while you are typing in another field:** Enter finishes the shape (as does a double-click, and the two cannot both submit it — the session is replaced before the annotate call is awaited); Backspace removes the last vertex; Esc cancels the shape in progress, and a second Esc — Esc on an empty session — leaves draw mode. **Clear** wipes the board (the shape in progress and every placed mark), so removing marks does not require the console or the microphone.
|
||||
- **Drawn shapes are listed under Draw like Import's files** (`src/ui/views/panels/DrawMarkList.vue`, rows worded by `presentDrawMarks` in `src/annotations/drawMarks.js`): one row per hand-drawn mark (`manual: true`; voice marks are not listed) with its colour swatch, its name, and show/hide, fly-to and remove. The draw tool publishes the rows with the rest of its controls and answers the row actions (`renameDrawMark`, `recolorDrawMark`, `toggleDrawMark`, `flyToDrawMark`, `removeDrawMark`) through the display store; it republishes on the engine's `onChange`, so a voice clear or an expiry updates the list. Rows are keyed and updated in place: a board change never takes a name being typed (the field keeps its draft while focused) or the button under a click. The name is saved on Enter or on leaving the field, once; Esc restores it, before anything else and with the row's colours open too. Both keep focus in the field, so neither drops it to the page or leaves Draw. The swatch opens the five colours right after it (shown at the end of the row), with the current colour focused; the arrows, Home and End try another, and a click or Esc closes them and returns focus to the swatch. Both colour groups (the next shape's and a row's) are one `DrawSwatches` radio group with a single tab stop, coloured from `DRAW_COLOR_CSS`; on touch screens each dot answers a 24 px target. An unnamed shape shows its kind, and an unnamed line its length; a renamed line keeps its length (`Ridge walk — 950 m`). **Hidden** keeps a mark on the board at alpha 0, and the world renderer takes every one of its entities out (`show = false`), so no material that keeps its own colour (the line's flowing stroke) still paints it; the screen renderer does not project a hidden callout, so it cannot push a visible label aside; `fly_route` skips a hidden route; and with every mark hidden the annotations render hold is released. **Fly to** is an immediate navigation (`runImmediateNavigation('drawing', …)`), like Import's: Cockpit refuses it and a running orbit, follow or track lets go first. A line is framed by its whole path, across the antimeridian too.
|
||||
- Runtime entry: `createApplicationTools` (`src/app/tools.js`, behind the `src/standalone/tools.js` shim) calls `initDrawTool({ viewer, annotations, navigation, publish, connect })` after `initAnnotations` and defers its `destroy()`, so disposing the shell returns every DOM listener, the Cesium handler, the preview data source, the borrowed click actions and the `window.__gevDrawTool` handle. Its controls are Vue views in the UTILITIES panel (`DrawControls.vue`, `DrawMarkList.vue`) that render what the tool publishes to the display store and call its actions. That handle (`setActive`, `setShape`, `addVertex(lon, lat)`, `finish`, `cancel`, `clearAll`, `marks`, `setColor`, `diagnostics`) is the no-mouse test seam.
|
||||
- Contract: a finished shape is submitted through the SAME `annotationEngine.annotate()` the voice agent uses, with geometry supplied and `manual: true` (`type: area` + `ring`, `type: route` + `path`, `type: pin` + coordinates). `resolveSpec` short-circuits manual specs (`resolveManualSpec`) so no geocoder, Places or Overpass call is made. A drawn area is a real (not synthesized) outline and drapes solid; a drawn line is a `route` with `mode: 'manual'`, labelled with its length and never a travel time; a pin is a point. Drawn marks therefore render with the whiteboard look, persist, appear in `.list()`, and clear with `clear_annotations` / `.clear()`. A drawn shape is never merged with another as a duplicate: every one is drawn on purpose, and every one is unnamed until its row names it, so two pins dropped close together are two pins.
|
||||
- **A vertex is a lon/lat, and the mark is DRAPED.** Clicks are picked through `pickWorldFromScreen` — one of two scene helpers the annotation resolver factory (`src/annotations/resolver.js`) exposes alongside `sampleGroundHeight`, so the app has ONE depth-aware pick cascade rather than a second written beside it — which returns the height the pick landed on as well as the coordinate, so the live preview hangs on the roof or hillside under the pointer instead of sinking to sea level. The world renderer draws areas and routes with `clampToGround`, so the height is dropped at finish in one place (`finishSpec`) rather than being carried downstream and discarded silently: what you get is the outline you drew, lying on the surface beneath it.
|
||||
- **Draped marks classify onto `ClassificationType.BOTH`** — terrain AND 3D tiles — not onto 3D tiles alone. With only `CESIUM_3D_TILE`, every area fill, area outline, route and arrow rendered to nothing on a keyless boot, where Cesium's own globe carries Esri/OSM imagery and there are no tiles to classify against; the labels appeared and the geometry did not. That hit spoken annotations exactly as hard as hand-drawn ones. `scripts/qa-draw-tool.mjs` counts the mark's own colour in the rendered frame before and after a shape is finished, on a keyed AND a keyless server, so "the board says there is a mark" can no longer pass for "the mark is on screen".
|
||||
- **A finished area is a CLOSED ring.** The fill polygon closes itself but the outline beside it is a polyline, so an open ring drew every edge except the one back to the first vertex — a triangle with two sides. `finishSpec` repeats the first position at the end. A line is never closed: joining its ends would invent a segment nobody drew.
|
||||
- Drawn-line distance labels select metres or kilometres after rounding to the nearest metre, so a 999.5-metre line displays as `1.0 km` rather than `1000 m`.
|
||||
- Drawn-line distance labels select metres or kilometres after rounding to the nearest metre, so a 999.5-metre line displays as `1.0 km` rather than `1000 m`, and drop the kilometre decimal from 9.95 km. The placed mark's label (shared with spoken routes and arrows) rounds before each unit cut, so a 996-metre line is labelled `1.0 km`, not `1000 m`, and a 9,960-metre one `10 km`, not `10.0 km`.
|
||||
- **Measurements and centres are computed on unwrapped longitudes** (`unwrapLongitudes` / `wrapLongitude` in `drawMode.js`, used by `ringAreaM2`, `ringCentroid` and the engine's manual-area anchor). A ~50,000 m² rectangle straddling the antimeridian reads its 0.002° width as 359.998° under raw arithmetic: it measured thousands of square kilometres and anchored on the Greenwich meridian, half a world from the shape. Path length was already safe — haversine is periodic in the longitude delta — and a test pins that so a "fix" cannot break it.
|
||||
- **Bounds.** One shape holds at most `MAX_VERTICES` = 512 points; two clicks within 0.5 m are one vertex (that is what makes a double-click finish without leaving a stray point); a vertex that is not a finite on-globe coordinate is refused. A shape that encloses nothing is refused rather than placed: three collinear points are not an area and a line under a metre long is not a line, and the hint says which.
|
||||
- Drawn-area measures round before choosing m², ha or km², so 9,999.6 m² displays as `1.0 ha`, not `10000 m²`, and 999,960 m² as `1.00 km²`, not `100.0 ha`.
|
||||
- Pointer ownership: while a session is open the tool holds the single pointer claim (`claimPointer('draw')`, `src/data/inputOwnership.js`) and **every** ambient scene selection handler — the shared tracking click gesture behind flights, military and CCTV, plus vessels, satellites, launches, fires, installations, bikeshare, radio, submarine cables, the bundled GeoJSON layers and the CCTV calibration gizmo — returns before picking. A vertex over an aircraft is a vertex, not a selection.
|
||||
- **The claim is a LEASE.** `claimPointer(owner)` returns an opaque token or `null`; `releasePointer(lease)` takes that token. A second claim fails even under the same owner name, because two live instances of one tool — an old one mid-teardown and its replacement — are two owners; releasing by name let the old instance free the claim its successor was holding, and every layer started selecting through the live session's vertices. A superseded lease now releases nothing.
|
||||
- **Cesium's own click actions are borrowed too.** The Viewer binds a selecting LEFT_CLICK and a tracking LEFT_DOUBLE_CLICK on its own handler, outside every layer and therefore outside the shared claim — guarding the layers alone still left a drawn vertex selecting the entity under it. Both are removed for the session and restored, as the same function objects, when it ends or the tool is destroyed.
|
||||
- **Enter belongs to whatever has the keyboard.** The finish key is taken only from the canvas, the page body and the tool's own label field. With a control focused — Clear, a shape button — Enter activates that control, as a keyboard user expects; it does not quietly finish the shape.
|
||||
- **Enter belongs to whatever has the keyboard.** The finish key is taken only from the canvas and the page body. With a control focused — Clear, a shape button — Enter activates that control, as a keyboard user expects; it does not quietly finish the shape.
|
||||
- Pure half: `src/annotations/drawMode.js` (session, minimum vertices, double-click de-dup at 0.5 m, vertex ceiling, degenerate-geometry refusal, ring closing, dateline-safe length/area/centroid, hint text). Regression surface: `src/annotations/drawMode.test.mjs` and `src/data/inputOwnership.test.mjs` (pure), `src/annotations/drawToolBehaviour.test.mjs` (the tool driven against DOM and viewer doubles — leases, borrowed input actions, teardown, the Enter key, preview heights), `src/annotations/drawTool.test.mjs` (structural: where files live, which ids the markup carries, what the docs may claim), and the rendered harness `scripts/qa-draw-tool.mjs`.
|
||||
- The annotation tree is a declared ownership group: `./annotations` → `src/annotations/index.js` in `package.json`, with its module graph listed in `scripts/package-boundaries.json`, so an annotation module reaching outside that graph is a gate failure rather than a surprise in a layer's bundle. `drawMode.js`, `drawTool.js` and their tests are in `scripts/format-scope.json`.
|
||||
|
||||
@@ -5524,7 +5597,7 @@ request viewbox and continues searching without that geographic preference.
|
||||
|
||||
### Import Your Own Files (October 2026)
|
||||
|
||||
- DISPLAY ▸ **Import** reads GeoJSON (`.geojson`/`.json`), KML, KMZ and GPX from the picker or a file drop anywhere in the app. Each file gets show/hide, Fly-to and Remove controls. GeoJSON and GPX use the row's color; KML/KMZ retain their styles and show a hatched swatch. The file bytes remain on the device for the page session. Rendered overlays can appear in map screenshots sent through voice or other existing screenshot tools.
|
||||
- UTILITIES ▸ **Import** reads GeoJSON (`.geojson`/`.json`), KML, KMZ and GPX from the picker or a file drop anywhere in the app. Each file gets show/hide, Fly-to and Remove controls. GeoJSON and GPX use the row's color; KML/KMZ retain their styles and show a hatched swatch. The file bytes remain on the device for the page session. Rendered overlays can appear in map screenshots sent through voice or other existing screenshot tools.
|
||||
- **Ground and height.** GeoJSON XYZ coordinates become ground footprints. KML ground lines/rings are tessellated; static recorded tracks preserve their original vertices, segment gaps and explicit altitude modes. KML with absolute/relative altitude keeps that meaning. Ground pins resolve in batches of 256 against loaded terrain and, in the nearby camera footprint on photoreal maps, `clampToHeightMostDetailed`, then draw in one owned native BillboardCollection per file, with static positions and no per-frame point Entity visualizer or clamping callbacks. Point metadata remains in `dataSource.importedEntities` for framing/style inspection; non-point geometry stays in the normal source collection. Pin collections mount only after source admission, follow file show/hide, and are destroyed on removal. Settled camera/map-source changes and completed terrain loads refresh visible files through the same serial queue; distant points retain their latest available height. No detailed worldwide terrain sweep is started. Nearby mesh sampling can add requests/time; configured photoreal providers require separate validation. The depth test is disabled for ground pins, matching Draw's marker visibility policy.
|
||||
- **Navigation.** Row Fly-to uses `runImmediateNavigation('file', …)`; Cockpit can refuse it and existing follow/track ownership is released first. Automatic Fly-to is stamped when a batch is submitted and reasserted before flying. Pointer/wheel gestures and newer navigation supersede it, including time spent in the queue. Framing uses every finite visible point/path/polygon-hole/wall vertex plus rotated overlay bounds, with viewport-aware range and a nadir view. The target uses available terrain/cached ground height and the ground guard on arrival. Empty footprints never fall back to deferred `viewer.flyTo`.
|
||||
- **Clock.** Imports expose no source clock and no entity availability. KML times/view metadata are removed before loading; recorded tracks become static lines. GPX segments remain separate and GPX times are ignored. The shared clock stays unchanged.
|
||||
@@ -5749,6 +5822,7 @@ apply to `public/` assets or built-preview serving.
|
||||
- **Cockpit camera anchor:** first-person mode does not write feed-boundary corrections directly into the camera. A cockpit-only inertial anchor advances from the selected aircraft's displayed course and speed, then converges toward the authoritative delayed track with correction capped below forward motion. The displayed kinematics are derived from the same consecutive fix segment as the rendered position, with raw feed speed/course used only as fallback; a transient zero/missing feed speed therefore cannot freeze a visibly moving aircraft after layer enable or a map/cockpit handoff. Rendered altitude continues to come from that interpolated track position. Late ADS-B fixes and short render stalls can remove drift without accelerating or reversing the view. Camera placement runs before scene update/culling at a bounded 20 Hz so a moving cockpit does not force Photoreal 3D Tiles to retraverse on every display frame; textual instruments update at 10 Hz and context/layout work at 4 Hz. Every far Cockpit contact pip shares one stable Cesium texture-atlas entry and skips unused screen-projected course calculations, while only in-range 2D aircraft silhouettes pay the screen-projected rotation cost; ambient glTF collections are hidden/retained rather than synchronously destroyed at cockpit entry, and context rails lay out only on explicit content/state changes and viewport resize. The deliberate 15/30-second layer interpolation delays and per-Cesium-frame position caches remain unchanged.
|
||||
- **Cockpit route, vision, and view controls:** visible on-screen `COCKPIT`, `RESET`, and `EXIT COCKPIT` controls replace reliance on the `C` shortcut. RESET uses the same canonical globe route as the map and voice actions, exits Cockpit, and releases its camera ownership rather than exposing the hidden map-style top action. When the tracked commercial flight has a plausible ADSBDB route, the top of the right briefing rail shows a compact `FROM → TO` airport strip and the visor shows a centered estimated-destination chevron with its relative bearing; its destination label rounds to whole degrees in 000–359, wrapping rounded north to 000° while the chevron continues to use the relative bearing independently of label rounding; absent or implausible route data hides the strip and cue rather than guessing. The cockpit-local vision control is an interactive `PREV / CURRENT / NEXT` carousel over `NORMAL`, `CRT`, `NVG`, `FLIR`, `ANIME`, and `NOIR`; its previous/next actions wrap, and activating the current value advances to the next style. Cockpit enters on the active map preset, so a Cyber FLIR map opens Cockpit on FLIR while Normal remains a distinct option instead of producing a second FLIR entry. Cockpit choices temporarily activate the existing Cesium post-process stages, while exiting Cockpit through either EXIT or RESET restores the pre-entry map style. The regional-news page uses a free Google News RSS locality query first, with the existing GDELT query retained only as a fail-soft fallback; linked headlines remain reporting, not verified incidents or risk intelligence.
|
||||
- Cockpit wind-direction labels wrap after rounding to whole degrees, so a direction of 359.5° displays as `N · 0°` rather than `N · 360°`.
|
||||
- The transit, ALPR, vessel, CCTV, Local ADS-B, flight and military context, awareness, Cockpit and Street Level heading readouts round through `roundHeadingDegrees` in `src/data/headingDegrees.js` (whole degrees 0–359, `null` when not finite, so each caller keeps its own placeholder); `normalizeHeading` lives there too and `cockpitMath.js` re-exports it. The Cockpit's `TRACK ACQUIRED` signal uses `formatHeadingReadout`, so a 359.6° track reads `COURSE 000°`.
|
||||
- **Cockpit missing observations:** missing local weather observations remain unknown rather than being converted to zero or clear skies, in the briefing and in the cloud effects; a missing temperature drops the whole local weather record, including its wind and condition.
|
||||
- **Cockpit weather status:** the earlier multi-canvas atmospheric compositor remains fail-closed and is not attached to the live viewer. Cockpit clouds are a separate transparent WebGL pass with a capped 520×320 framebuffer, 24 ray steps, three FBM octaves, and a 12 FPS ceiling. It defaults off and starts only when local storage explicitly contains the persisted `WX ON` opt-in (`'1'`). When opted in, observations refresh after five minutes or 25 km of aircraft movement, fail transparent when unavailable or clear, and stop on exit or disable. `WX OFF` governs atmospheric rendering only: the briefing still fetches source-backed Natural Earth region, headline, and Open-Meteo local-information data, aborting and replacing any in-flight request when the selected aircraft changes. No weather effect runs in map mode and no synthetic fallback is shown.
|
||||
- **Cockpit trail visibility:** entering cockpit hides the selected aircraft's trail body and head so they cannot cross the first-person view; exit restores them. This cockpit-only presentation change does not alter the normal map-mode invariant that aircraft trails render through terrain using their depth-fail material.
|
||||
@@ -5957,9 +6031,11 @@ easier to meet (detection is now on more often), but does not create it.
|
||||
OSM choice), so Hybrid fill is never replayed as OSM roads. Failed flow
|
||||
refreshes clear old matches and report simulation. `flowCoveragePct` is the
|
||||
percentage of shown dots on roads with flow, including all unmatched dots in
|
||||
the denominator and excluding dots hidden by closures. The OSM keyed status
|
||||
reads `LIVE · Roads: OpenStreetMap · Flow: TomTom · N% cov`. Road fetch
|
||||
coverage follows a 5×5 ellipsoid sample of the central screen square.
|
||||
the denominator and excluding dots hidden by closures. It reads 1–99 whenever
|
||||
both matched and simulated dots are shown, so rounding never turns a few
|
||||
matches into `(no matches)` or a few simulated dots into full coverage. The
|
||||
OSM keyed status reads `LIVE · Roads: OpenStreetMap · Flow: TomTom · N% cov`.
|
||||
Road fetch coverage follows a 5×5 ellipsoid sample of the central screen square.
|
||||
Horizon rays are capped at 10 km from nadir (reduced if necessary to fit
|
||||
16 z12 tiles); polygon-intersecting tiles load nearest first. Up to 16 z14
|
||||
tiles provide near detail, with z12 roads beyond them. Cached tiles serve
|
||||
@@ -6144,7 +6220,10 @@ The lockfile uses DOMPurify 3.4.17, protobufjs 8.8.0, PostCSS 8.5.28,
|
||||
source-map-js 1.2.2 and nanoid 3.3.19. Cesium remains on 1.138.0. Browser QA
|
||||
uses Puppeteer 25.10.0; image-processing tools use Sharp 0.35.5. QA scripts await Puppeteer's asynchronous
|
||||
executable-path lookup before testing or passing the path to Chrome. Supported Node versions remain
|
||||
24.14.x and 26.x. Use `npm ci` to reproduce the checked-in dependency tree.
|
||||
Node 24 (24.14.0 or later) and 26.x; the Docker image and CI pin 24.22.2,
|
||||
the current 24 LTS patch, which carries the October 21, 2026 Node.js security
|
||||
fixes first released in 24.22.1 (including CVE-2026-111497). Use
|
||||
`npm ci` to reproduce the checked-in dependency tree.
|
||||
|
||||
### Traffic city navigation
|
||||
|
||||
@@ -6286,7 +6365,9 @@ Receivers publish one record shape from `src/sources/adsbRecords.js`
|
||||
`lastMessageAt`, `messageCount`, `rssiDbfs`, `band`, `source`). `band` is `1090` or `978`; `source` is
|
||||
`webusb` (browser SDR) or `feed` (decoder feed).
|
||||
`normalizeDump1090Aircraft(json, nowMs, { band })` maps a dump1090/readsb/
|
||||
skyaware978 `aircraft.json` document into the same records. Voice
|
||||
skyaware978 `aircraft.json` document into the same records; a row without
|
||||
`baro_rate` reads its vertical rate from `geom_rate`, where a GNSS-sourced rate
|
||||
is sent. Voice
|
||||
`set_layer_visibility` accepts `local-adsb` ("local ADS-B", "my receiver",
|
||||
"my antenna").
|
||||
|
||||
@@ -6333,6 +6414,12 @@ three Cesium data sources; enabling rebuilds from the accepted parsed cache,
|
||||
and destroying clears it. Load ownership prevents cancelled work from adding
|
||||
entities after teardown.
|
||||
|
||||
Every Cesium entity from multipart cable or landing-point geometry stays tied
|
||||
to its source feature, including generated IDs and IDs that collide with
|
||||
Cesium's multipart suffixes. Each segment gets its own feature's styling, click
|
||||
destination and horizon-culling reference, and each source feature has one
|
||||
reference stem. The source collections are not modified.
|
||||
|
||||
Natural Earth regions and neighborhood polygon lookup are package exports.
|
||||
Their existing lazy loaders, retry behavior, bundled datasets and attribution
|
||||
are unchanged. The cable dataset remains CC BY-NC-SA 3.0 and is not covered by
|
||||
@@ -6473,8 +6560,8 @@ when their footprints touch or contain one another, never across a seam toleranc
|
||||
|
||||
### Shared native dropdowns and numeric fields
|
||||
|
||||
- Scene and CCTV selects share the default `gev-field` surface. HUD layout and
|
||||
draw colour retain compact density. Radio filter, tuner gain and FM frequency
|
||||
- Scene and CCTV selects share the default `gev-field` surface. HUD layout
|
||||
retains compact density; Draw's colour is a row of swatches, not a select. Radio filter, tuner gain and FM frequency
|
||||
use the same base while retaining their feature-specific skins and layout.
|
||||
- The CCTV inline number editor is rendered by its pose chip component with
|
||||
its existing precision and inline density. Enter/blur commit, Escape cancellation, frozen
|
||||
|
||||
@@ -71,9 +71,9 @@ Status: Open (owner-accepted 2026-07-08, documented)
|
||||
a few stragglers take one more poll.
|
||||
- **Born-grounded first poll:** a contact first seen on the ground with no altitude
|
||||
data renders at the geoid for ≤1 poll until its floor cell warms.
|
||||
- Full context, improvement ideas, and the verification oracle
|
||||
(`scripts/qa-floor-verify.mjs`):
|
||||
`docs/superpowers/reports/2026-07-08-height-datum-handover.md`.
|
||||
- Verify floor-placement behavior with `scripts/qa-floor-verify.mjs` against a
|
||||
running local app; set `QA_BASE_URL` when it is not on the default port
|
||||
(`http://localhost:4173`).
|
||||
|
||||
---
|
||||
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@
|
||||
with the sources: src/materialSymbolsSubset.test.mjs fails when a source
|
||||
names a glyph this list is missing, because a missing glyph does not draw
|
||||
a box — the ligature never forms and the element renders the literal word. -->
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,flare,flight,layers,layers_clear,light_mode,local_fire_department,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,pause,play_arrow,public,radar,radio,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,settings,skip_next,skip_previous,streetview,tune,upload_file,videocam,view_in_ar,visibility,visibility_off" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,pause,pentagon,play_arrow,public,radar,radio,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,settings,skip_next,skip_previous,streetview,timeline,tune,upload_file,videocam,view_in_ar,visibility,visibility_off" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="gev-view" data-gev-view="scene-chrome"></div>
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
"src/data/localGeojsonCore.js",
|
||||
"src/data/localGeojsonLod.js",
|
||||
"src/data/inputOwnership.js",
|
||||
"src/annotations/drawMarks.js",
|
||||
"src/annotations/drawMarks.test.mjs",
|
||||
"src/annotations/drawMode.js",
|
||||
"src/annotations/drawMode.test.mjs",
|
||||
"src/annotations/drawTool.js",
|
||||
@@ -68,6 +70,7 @@
|
||||
"server/standalone/vite.config.js",
|
||||
"vite.config.js",
|
||||
"src/tooling/viteBuild.test.mjs",
|
||||
"src/tooling/vueLoader.test.mjs",
|
||||
"src/tooling/allowedHosts.test.mjs",
|
||||
"server/providers/common/http.js",
|
||||
"server/providers/common/query.js",
|
||||
@@ -1180,5 +1183,8 @@
|
||||
"src/testSupport/hudReadouts.mjs",
|
||||
"src/ui/stores/firstRun.js",
|
||||
"src/firstRunLauncherView.test.mjs",
|
||||
"src/firstRunCopy.js"
|
||||
"src/firstRunCopy.js",
|
||||
"src/data/headingDegrees.test.mjs",
|
||||
"src/data/distanceFormat.test.mjs",
|
||||
"src/sources/adsbFields.test.mjs"
|
||||
]
|
||||
@@ -88,6 +88,10 @@
|
||||
"src/ui/rail/RailChips.vue",
|
||||
"src/ui/views/panels/DisplayPanel.vue",
|
||||
"src/ui/views/panels/GeoImport.vue",
|
||||
"src/ui/views/panels/DrawControls.vue",
|
||||
"src/ui/views/panels/DrawMarkList.vue",
|
||||
"src/ui/views/panels/DrawSwatches.vue",
|
||||
"src/annotations/drawMarks.js",
|
||||
"src/ui/stores/display.js",
|
||||
"src/ui/localSdrPresentation.js",
|
||||
"src/ui/unitLabels.js",
|
||||
@@ -188,6 +192,7 @@
|
||||
"runtime": "node",
|
||||
"exports": ["./server/providers/live"],
|
||||
"modules": [
|
||||
"src/sources/adsbFields.js",
|
||||
"server/providers/live.js",
|
||||
"server/providers/common/http.js",
|
||||
"server/providers/common/query.js",
|
||||
@@ -207,13 +212,14 @@
|
||||
},
|
||||
"adsb-lol-source": {
|
||||
"exports": ["./sources/adsb-lol"],
|
||||
"modules": ["src/data/adsbLolFallback.js"],
|
||||
"modules": ["src/data/adsbLolFallback.js", "src/sources/adsbFields.js"],
|
||||
"external": []
|
||||
},
|
||||
"place-providers": {
|
||||
"runtime": "node",
|
||||
"exports": ["./server/providers/places"],
|
||||
"modules": [
|
||||
"src/data/distanceFormat.js",
|
||||
"server/providers/places.js",
|
||||
"server/providers/places/google.js",
|
||||
"server/providers/places/google-key.js",
|
||||
@@ -400,6 +406,7 @@
|
||||
"runtime": "node",
|
||||
"exports": ["./server/providers/overpass"],
|
||||
"modules": [
|
||||
"src/data/distanceFormat.js",
|
||||
"server/providers/common/geo.js",
|
||||
"server/providers/common/http.js",
|
||||
"server/providers/common/rate-limit.js",
|
||||
@@ -438,7 +445,9 @@
|
||||
"server/providers/overpass/transport.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/i18n/marker.js",
|
||||
"src/sources/rateLimit.js"
|
||||
"src/sources/rateLimit.js",
|
||||
"server/providers/common/same-site.js",
|
||||
"src/localRequestGate.mjs"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -754,6 +763,7 @@
|
||||
"cctv-controls": {
|
||||
"exports": ["./ui/cctv"],
|
||||
"modules": [
|
||||
"src/data/headingDegrees.js",
|
||||
"src/ui/cctv.js",
|
||||
"src/ui/cctvBindings.js",
|
||||
"src/ui/cctvCalibration.js",
|
||||
@@ -823,6 +833,8 @@
|
||||
"./ui/cockpit/vision"
|
||||
],
|
||||
"modules": [
|
||||
"src/data/headingDegrees.js",
|
||||
"src/data/distanceFormat.js",
|
||||
"src/ui/cockpit.js",
|
||||
"src/ui/cockpitBriefing.js",
|
||||
"src/ui/cockpitBriefPages.js",
|
||||
@@ -892,6 +904,8 @@
|
||||
"ui-shell": {
|
||||
"exports": ["./ui/shell"],
|
||||
"modules": [
|
||||
"src/data/headingDegrees.js",
|
||||
"src/data/distanceFormat.js",
|
||||
"src/i18n/marker.js",
|
||||
"src/ui/mobileShell.js",
|
||||
"src/exclusiveSurfaces.js",
|
||||
@@ -1250,6 +1264,7 @@
|
||||
"live-observations": {
|
||||
"exports": ["./sources/live"],
|
||||
"modules": [
|
||||
"src/sources/adsbFields.js",
|
||||
"src/i18n/marker.js",
|
||||
"src/data/aircraftEmergency.js",
|
||||
"src/sources/live/aircraft.js",
|
||||
@@ -1263,6 +1278,7 @@
|
||||
"civil-flights": {
|
||||
"exports": ["./layers/flights"],
|
||||
"modules": [
|
||||
"src/data/headingDegrees.js",
|
||||
"src/i18n/marker.js",
|
||||
"src/cyberSonar.js",
|
||||
"src/data/aircraftClass.js",
|
||||
@@ -1327,6 +1343,7 @@
|
||||
"military-flights": {
|
||||
"exports": ["./layers/military"],
|
||||
"modules": [
|
||||
"src/data/headingDegrees.js",
|
||||
"src/i18n/marker.js",
|
||||
"src/cyberSonar.js",
|
||||
"src/data/aircraftClass.js",
|
||||
@@ -1414,6 +1431,7 @@
|
||||
"vessel-layer": {
|
||||
"exports": ["./layers/vessels"],
|
||||
"modules": [
|
||||
"src/data/headingDegrees.js",
|
||||
"src/layers/vessels/cards.js",
|
||||
"src/layers/vessels/evidence.js",
|
||||
"src/layers/vessels/index.js",
|
||||
@@ -1661,6 +1679,8 @@
|
||||
"awareness-layer": {
|
||||
"exports": ["./layers/awareness"],
|
||||
"modules": [
|
||||
"src/data/headingDegrees.js",
|
||||
"src/data/distanceFormat.js",
|
||||
"src/i18n/marker.js",
|
||||
"src/cyberSonar.js",
|
||||
"src/cockpitMath.js",
|
||||
@@ -1799,6 +1819,7 @@
|
||||
"cctv-layer": {
|
||||
"exports": ["./layers/cctv"],
|
||||
"modules": [
|
||||
"src/data/headingDegrees.js",
|
||||
"src/data/cctvCards.js",
|
||||
"src/data/cctvGizmo.js",
|
||||
"src/data/cctvLod.js",
|
||||
@@ -1928,6 +1949,7 @@
|
||||
"alpr-cameras": {
|
||||
"exports": ["./layers/alpr"],
|
||||
"modules": [
|
||||
"src/data/headingDegrees.js",
|
||||
"src/layers/alpr/index.js",
|
||||
"src/layers/alpr/model.js",
|
||||
"src/layers/alpr/policy.js",
|
||||
@@ -2206,6 +2228,9 @@
|
||||
"./standalone/catalog"
|
||||
],
|
||||
"modules": [
|
||||
"src/sources/adsbFields.js",
|
||||
"src/data/headingDegrees.js",
|
||||
"src/data/distanceFormat.js",
|
||||
"src/ui/placeNames.js",
|
||||
"src/cyberSonarScene.js",
|
||||
"src/cyberSonar.js",
|
||||
@@ -2824,6 +2849,7 @@
|
||||
"src/layers/vessels/recordPolicy.js",
|
||||
"src/layers/vessels/snapshotRenderer.js",
|
||||
"src/data/inputOwnership.js",
|
||||
"src/annotations/drawMarks.js",
|
||||
"src/annotations/drawMode.js",
|
||||
"src/annotations/drawTool.js",
|
||||
"src/data/geoFileImport.js",
|
||||
@@ -3183,6 +3209,9 @@
|
||||
"application-layer-construction": {
|
||||
"exports": ["./application/layers"],
|
||||
"modules": [
|
||||
"src/sources/adsbFields.js",
|
||||
"src/data/headingDegrees.js",
|
||||
"src/data/distanceFormat.js",
|
||||
"src/cyberSonar.js",
|
||||
"src/data/approxDistance.js",
|
||||
"src/data/bhoteKoshiEvent.js",
|
||||
@@ -4066,6 +4095,7 @@
|
||||
"directions-layer": {
|
||||
"exports": ["./layers/directions"],
|
||||
"modules": [
|
||||
"src/data/distanceFormat.js",
|
||||
"src/i18n/marker.js",
|
||||
"src/layers/directions/index.js",
|
||||
"src/data/pointerOwnerNames.js",
|
||||
@@ -4342,6 +4372,7 @@
|
||||
"transit-standalone": {
|
||||
"exports": ["./layers/transit/standalone"],
|
||||
"modules": [
|
||||
"src/data/headingDegrees.js",
|
||||
"src/cyberSonar.js",
|
||||
"src/celestialRing.js",
|
||||
"src/data/approxDistance.js",
|
||||
@@ -4420,6 +4451,7 @@
|
||||
"transit-layer": {
|
||||
"exports": ["./layers/transit"],
|
||||
"modules": [
|
||||
"src/data/headingDegrees.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/inputOwnership.js",
|
||||
"src/data/transitFeeds.js",
|
||||
@@ -4649,6 +4681,7 @@
|
||||
"tools": {
|
||||
"exports": ["./tools"],
|
||||
"modules": [
|
||||
"src/data/headingDegrees.js",
|
||||
"src/tools/index.js",
|
||||
"src/tools/catalog.js",
|
||||
"src/tools/schema.js",
|
||||
@@ -4725,6 +4758,7 @@
|
||||
"tools-services": {
|
||||
"exports": ["./tools/services"],
|
||||
"modules": [
|
||||
"src/sources/adsbFields.js",
|
||||
"src/tools/services.js",
|
||||
"src/layers/bikeshare/registry.js",
|
||||
"src/layers/bikeshare/source.js",
|
||||
|
||||
+12
-10
@@ -107,7 +107,7 @@ const layerState = () =>
|
||||
const manager = gev.dataManager;
|
||||
const module = manager.layers.get('directions').module;
|
||||
const row = document.querySelector(
|
||||
'#data-toggles [data-layer-id="directions"]',
|
||||
'#utilities-toggles [data-layer-id="directions"]',
|
||||
);
|
||||
const controls = module.getRowControls();
|
||||
const viewer = gev.viewer;
|
||||
@@ -152,7 +152,7 @@ const layerState = () =>
|
||||
const clickChip = async (chipId) => {
|
||||
const clicked = await page.evaluate((id) => {
|
||||
const row = document.querySelector(
|
||||
'#data-toggles [data-layer-id="directions"]',
|
||||
'#utilities-toggles [data-layer-id="directions"]',
|
||||
);
|
||||
const button = row?.querySelector(`button[data-chip-id="${id}"]`);
|
||||
if (!button || button.disabled) return false;
|
||||
@@ -232,24 +232,26 @@ try {
|
||||
});
|
||||
await sleep(3000);
|
||||
|
||||
// ── 1. Open the DATA LAYERS panel, then toggle the row on. ──────────────
|
||||
// ── 1. Open the UTILITIES panel, then toggle the row on. ────────────────
|
||||
const opened = await page.evaluate(() => {
|
||||
const panel = document.getElementById('data-panel');
|
||||
const panel = document.getElementById('utilities-panel');
|
||||
if (!panel) return false;
|
||||
if (panel.classList.contains('collapsed')) {
|
||||
panel
|
||||
.querySelector('.panel-collapse-btn[data-collapse-target="data-panel"]')
|
||||
.querySelector(
|
||||
'.panel-collapse-btn[data-collapse-target="utilities-panel"]',
|
||||
)
|
||||
?.click();
|
||||
}
|
||||
return !document
|
||||
.getElementById('data-panel')
|
||||
.getElementById('utilities-panel')
|
||||
.classList.contains('collapsed');
|
||||
});
|
||||
await sleep(700);
|
||||
check('the DATA LAYERS panel opens from its own header control', opened);
|
||||
check('the UTILITIES panel opens from its own header control', opened);
|
||||
const toggled = await page.evaluate(() => {
|
||||
const row = document.querySelector(
|
||||
'#data-toggles [data-layer-id="directions"]',
|
||||
'#utilities-toggles [data-layer-id="directions"]',
|
||||
);
|
||||
const button = row?.querySelector('.data-toggle-btn');
|
||||
if (!button) return false;
|
||||
@@ -397,7 +399,7 @@ try {
|
||||
);
|
||||
const focused = await page.evaluate(() => {
|
||||
const row = document.querySelector(
|
||||
'#data-toggles [data-layer-id="directions"]',
|
||||
'#utilities-toggles [data-layer-id="directions"]',
|
||||
);
|
||||
const items = row.querySelectorAll('.data-row-list-item');
|
||||
items[2]?.focus();
|
||||
@@ -776,7 +778,7 @@ try {
|
||||
}
|
||||
}
|
||||
const row = document.querySelector(
|
||||
'#data-toggles [data-layer-id="directions"]',
|
||||
'#utilities-toggles [data-layer-id="directions"]',
|
||||
);
|
||||
return {
|
||||
entities: left.length,
|
||||
|
||||
+198
-25
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
/** Rendered acceptance for DISPLAY ▸ Draw: real clicks, real keys, real teardown. */
|
||||
/** Rendered acceptance for UTILITIES ▸ Draw: real clicks, real keys, real teardown. */
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
@@ -58,6 +58,47 @@ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
/** A clip of the globe canvas, clear of the panels on every side. */
|
||||
const CANVAS_CLIP = { x: 340, y: 200, width: 560, height: 380 };
|
||||
|
||||
// Clear of the open UTILITIES panel (x 100–380), whose own amber swatch
|
||||
// dims when its row's shape is hidden.
|
||||
const LINE_CLIP = { x: 390, y: 200, width: 510, height: 380 };
|
||||
/** The canvas clip as a PNG, for comparing two moments of the same view. */
|
||||
const canvasShot = () =>
|
||||
page.screenshot({ clip: LINE_CLIP, type: 'png', encoding: 'base64' });
|
||||
|
||||
/**
|
||||
* Amber pixels in one shot that are not amber in another of the same view:
|
||||
* a mark's own pixels, apart from imagery and panels that both shots share.
|
||||
*/
|
||||
function amberOnlyIn(shotA, shotB) {
|
||||
return page.evaluate(
|
||||
async (a, b) => {
|
||||
const pixels = async (base64) => {
|
||||
const bitmap = await createImageBitmap(
|
||||
await (await fetch(`data:image/png;base64,${base64}`)).blob(),
|
||||
);
|
||||
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
|
||||
const context = canvas.getContext('2d');
|
||||
context.drawImage(bitmap, 0, 0);
|
||||
return context.getImageData(0, 0, bitmap.width, bitmap.height).data;
|
||||
};
|
||||
const amber = (d, i) =>
|
||||
d[i] > 150 &&
|
||||
d[i + 1] > 90 &&
|
||||
d[i + 1] < 220 &&
|
||||
d[i + 2] < 130 &&
|
||||
d[i] - d[i + 2] > 70 &&
|
||||
d[i] - d[i + 1] > 20;
|
||||
const [da, db] = [await pixels(a), await pixels(b)];
|
||||
let only = 0;
|
||||
for (let i = 0; i < da.length; i += 4)
|
||||
if (amber(da, i) && !amber(db, i)) only += 1;
|
||||
return only;
|
||||
},
|
||||
shotA,
|
||||
shotB,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* How many amber pixels the clip contains. The drawn area's stroke and fill are
|
||||
* the only amber in this scene, so the count going UP after a shape is finished
|
||||
@@ -196,12 +237,14 @@ try {
|
||||
);
|
||||
check('visible tile content settles before drawing', settled);
|
||||
|
||||
// The DISPLAY rail starts collapsed on a first run: open it the way a user does.
|
||||
// UTILITIES starts collapsed on a first run: open it the way a user does.
|
||||
await page.evaluate(() => {
|
||||
const rail = document.getElementById('pp-toggles');
|
||||
if (rail?.classList.contains('collapsed'))
|
||||
const panel = document.getElementById('utilities-panel');
|
||||
if (panel?.classList.contains('collapsed'))
|
||||
document
|
||||
.querySelector('.panel-collapse-btn[data-collapse-target="pp-toggles"]')
|
||||
.querySelector(
|
||||
'.panel-collapse-btn[data-collapse-target="utilities-panel"]',
|
||||
)
|
||||
?.click();
|
||||
});
|
||||
await page.waitForFunction(
|
||||
@@ -210,7 +253,7 @@ try {
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
check(
|
||||
'the Draw control is on the DISPLAY rail and starts off',
|
||||
'the Draw control is in the UTILITIES panel and starts off',
|
||||
await page.evaluate(() => {
|
||||
const toggle = document.getElementById('draw-toggle');
|
||||
return (
|
||||
@@ -298,8 +341,6 @@ try {
|
||||
// ── a pin, finished with Enter ──────────────────────────────────────────
|
||||
await page.click('#draw-toggle');
|
||||
await page.click('.pp-mode-btn[data-shape="pin"]');
|
||||
await page.click('#draw-label-input');
|
||||
await page.keyboard.type('Pin one');
|
||||
await clickWorld(520, 330);
|
||||
check(
|
||||
'the pointer is claimed while a session is open',
|
||||
@@ -313,7 +354,8 @@ try {
|
||||
|
||||
// ── a line along a road, with Backspace ─────────────────────────────────
|
||||
await page.click('.pp-mode-btn[data-shape="line"]');
|
||||
await clickWorld(360, 300);
|
||||
// Clear of the open UTILITIES panel, which sits beside the left rail.
|
||||
await clickWorld(420, 300);
|
||||
await clickWorld(470, 380);
|
||||
await clickWorld(600, 470);
|
||||
const beforeBackspace = (await drawState()).vertices;
|
||||
@@ -326,16 +368,9 @@ try {
|
||||
`${beforeBackspace} → ${afterBackspace}`,
|
||||
);
|
||||
await clickWorld(640, 500);
|
||||
await page.click('#draw-label-input');
|
||||
await page.keyboard.type('Market St');
|
||||
await page.keyboard.press('Enter');
|
||||
check(
|
||||
'a line finished from the label field lands on the board',
|
||||
await waitForMarks(2),
|
||||
);
|
||||
const lineMark = (await marks()).find((mark) =>
|
||||
mark.label?.startsWith('Market St'),
|
||||
);
|
||||
check('a line finished with Enter lands on the board', await waitForMarks(2));
|
||||
const lineMark = (await marks()).find((mark) => mark.type === 'route');
|
||||
check(
|
||||
'a drawn line reports its length and never a travel time',
|
||||
Boolean(lineMark) &&
|
||||
@@ -347,10 +382,8 @@ try {
|
||||
|
||||
// ── an area, coloured and labelled, finished with a double-click ────────
|
||||
await page.click('.pp-mode-btn[data-shape="area"]');
|
||||
await page.select('#draw-color-select', 'amber');
|
||||
await page.click('.draw-color[data-color="amber"]');
|
||||
const amberBefore = await amberPixels();
|
||||
await page.click('#draw-label-input');
|
||||
await page.keyboard.type('Civic Center block');
|
||||
await clickWorld(400, 280);
|
||||
await clickWorld(700, 290);
|
||||
await clickWorld(720, 520);
|
||||
@@ -373,14 +406,34 @@ try {
|
||||
afterDoubleClick === 3,
|
||||
`${afterDoubleClick} marks`,
|
||||
);
|
||||
// ── each drawn shape gets a row: named, recoloured, hidden from there ──
|
||||
const rowIds = () =>
|
||||
page.evaluate(() =>
|
||||
[...document.querySelectorAll('#draw-mark-list [data-mark-id]')].map(
|
||||
(row) => row.dataset.markId,
|
||||
),
|
||||
);
|
||||
const ids = await rowIds();
|
||||
check('every drawn shape has a row under Draw', ids.length === 3, ids);
|
||||
const areaId = ids[2];
|
||||
const rowControl = (id, control) =>
|
||||
`#draw-mark-list [data-mark-id="${id}"] [data-control="${control}"]`;
|
||||
await page.click(rowControl(areaId, 'name'));
|
||||
await page.keyboard.type('Civic Center block');
|
||||
await page.keyboard.press('Enter');
|
||||
await wait(300);
|
||||
const areaMark = (await marks()).find(
|
||||
(mark) => mark.label === 'Civic Center block',
|
||||
);
|
||||
check(
|
||||
'the label and colour chosen in the panel are what is drawn',
|
||||
'the name typed in its row and the colour picked before drawing are what is drawn',
|
||||
areaMark?.color === 'amber',
|
||||
JSON.stringify(areaMark),
|
||||
);
|
||||
check(
|
||||
'naming a shape keeps Draw on and the shape in progress untouched',
|
||||
(await drawState()).active === true,
|
||||
);
|
||||
await wait(1200);
|
||||
const amberAfter = await amberPixels();
|
||||
check(
|
||||
@@ -389,6 +442,118 @@ try {
|
||||
`${amberBefore} → ${amberAfter} amber pixels (+${amberAfter - amberBefore})`,
|
||||
);
|
||||
await shot('01-area-with-label');
|
||||
|
||||
// ── the row's own controls: recolour, hide and show, fly to ─────────────
|
||||
const markState = (id) =>
|
||||
page.evaluate((id) => {
|
||||
const mark = window.__gevAnnotations.list().find((m) => m.id === id);
|
||||
return (
|
||||
mark && { color: mark.color, hidden: mark.hidden, alpha: mark.alpha }
|
||||
);
|
||||
}, id);
|
||||
// Wait for a mark to reach a state rather than for a fixed time.
|
||||
const markReaches = (id, want) =>
|
||||
page
|
||||
.waitForFunction(
|
||||
(id, want) => {
|
||||
const mark = window.__gevAnnotations.list().find((m) => m.id === id);
|
||||
return (
|
||||
mark &&
|
||||
Object.entries(want).every(([key, value]) => mark[key] === value)
|
||||
);
|
||||
},
|
||||
{ timeout: 10_000 },
|
||||
id,
|
||||
want,
|
||||
)
|
||||
.then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
await page.click(rowControl(areaId, 'swatch'));
|
||||
await page.click(rowControl(areaId, 'color-green'));
|
||||
await markReaches(areaId, { color: 'green' });
|
||||
check(
|
||||
'a colour picked in the row recolours that shape',
|
||||
(await markState(areaId))?.color === 'green',
|
||||
);
|
||||
await page.click(rowControl(areaId, 'toggle'));
|
||||
await markReaches(areaId, { hidden: true, alpha: 0 });
|
||||
const hidden = await markState(areaId);
|
||||
check(
|
||||
'hiding a shape keeps it on the board but draws it at zero alpha',
|
||||
hidden?.hidden === true && hidden.alpha === 0,
|
||||
JSON.stringify(hidden),
|
||||
);
|
||||
await page.click(rowControl(areaId, 'toggle'));
|
||||
check(
|
||||
'showing it again brings it straight back',
|
||||
await markReaches(areaId, { hidden: false, alpha: 1 }),
|
||||
);
|
||||
// Fly-to frames the line by its whole path: once the flight settles, the
|
||||
// middle of the line sits near the middle of the view.
|
||||
await page.click(rowControl(ids[1], 'fly'));
|
||||
await page
|
||||
.waitForFunction(() => !window.__godsEyeView.viewer.camera._currentFlight, {
|
||||
timeout: 15_000,
|
||||
})
|
||||
.catch(() => {});
|
||||
await wait(300);
|
||||
const framed = await page.evaluate((id) => {
|
||||
const { viewer } = window.__godsEyeView;
|
||||
// No Cesium global in the page: borrow Cartesian3 from a live object.
|
||||
const Cartesian3 = viewer.camera.positionWC.constructor;
|
||||
const mark = window.__gevAnnotations.list().find((m) => m.id === id);
|
||||
const lons = mark.path.map((p) => p.lon);
|
||||
const lats = mark.path.map((p) => p.lat);
|
||||
const middle = Cartesian3.fromDegrees(
|
||||
(Math.min(...lons) + Math.max(...lons)) / 2,
|
||||
(Math.min(...lats) + Math.max(...lats)) / 2,
|
||||
);
|
||||
const at = viewer.scene.cartesianToCanvasCoordinates(middle);
|
||||
const canvas = viewer.scene.canvas;
|
||||
return (
|
||||
at && {
|
||||
dx: Math.abs(at.x - canvas.clientWidth / 2) / canvas.clientWidth,
|
||||
dy: Math.abs(at.y - canvas.clientHeight / 2) / canvas.clientHeight,
|
||||
}
|
||||
);
|
||||
}, ids[1]);
|
||||
check(
|
||||
'fly to frames that shape: its middle is near the middle of the view',
|
||||
Boolean(framed) && framed.dx < 0.2 && framed.dy < 0.2,
|
||||
JSON.stringify(framed),
|
||||
);
|
||||
|
||||
// ── a hidden Line leaves the globe: its flowing stroke keeps its own
|
||||
// colour, so the check is the pixels, not the mark's state ─────────────
|
||||
const lineId = ids[1];
|
||||
await page.click(rowControl(lineId, 'swatch'));
|
||||
await page.click(rowControl(lineId, 'color-amber'));
|
||||
await markReaches(lineId, { color: 'amber', alpha: 1 });
|
||||
await wait(800);
|
||||
const shown = await canvasShot();
|
||||
await page.click(rowControl(lineId, 'toggle'));
|
||||
await markReaches(lineId, { hidden: true, alpha: 0 });
|
||||
await wait(800);
|
||||
const hiddenShot = await canvasShot();
|
||||
// Amber the shown line had and the hidden one lost; amber that appears
|
||||
// only once it is hidden is noise, and must stay small beside it.
|
||||
const gone = await amberOnlyIn(shown, hiddenShot);
|
||||
const noise = await amberOnlyIn(hiddenShot, shown);
|
||||
check(
|
||||
'a hidden Line is no longer painted',
|
||||
gone > 40 && noise < gone * 0.2,
|
||||
`${gone} amber pixels went with the line, ${noise} appeared`,
|
||||
);
|
||||
await page.click(rowControl(lineId, 'toggle'));
|
||||
await wait(1200);
|
||||
const back = await amberOnlyIn(await canvasShot(), hiddenShot);
|
||||
check(
|
||||
'and paints again when shown',
|
||||
back > gone * 0.5,
|
||||
`${back} of ${gone} line pixels back`,
|
||||
);
|
||||
await shot('03-pins-and-shapes');
|
||||
|
||||
// ── Escape cancels the shape, a second Escape leaves draw mode ──────────
|
||||
@@ -419,6 +584,12 @@ try {
|
||||
afterSecondEscape.marks === 3,
|
||||
`${afterSecondEscape.marks} marks`,
|
||||
);
|
||||
await page.click(rowControl(ids[0], 'remove'));
|
||||
await waitForMarks(2);
|
||||
check(
|
||||
'remove in a row takes that one shape off the board and out of the list',
|
||||
(await rowIds()).join() === ids.slice(1).join(),
|
||||
);
|
||||
|
||||
// ── a click that would select something selects nothing while drawing ───
|
||||
const target = await findClickableTarget();
|
||||
@@ -480,10 +651,12 @@ try {
|
||||
|
||||
// ── Clear wipes the board ───────────────────────────────────────────────
|
||||
await page.evaluate(() => {
|
||||
const rail = document.getElementById('pp-toggles');
|
||||
if (rail?.classList.contains('collapsed'))
|
||||
const panel = document.getElementById('utilities-panel');
|
||||
if (panel?.classList.contains('collapsed'))
|
||||
document
|
||||
.querySelector('.panel-collapse-btn[data-collapse-target="pp-toggles"]')
|
||||
.querySelector(
|
||||
'.panel-collapse-btn[data-collapse-target="utilities-panel"]',
|
||||
)
|
||||
?.click();
|
||||
});
|
||||
await page.waitForFunction(
|
||||
|
||||
@@ -39,14 +39,13 @@ try {
|
||||
'scene-select',
|
||||
'cctv-camera-select',
|
||||
'hud-layout-select',
|
||||
'draw-color-select',
|
||||
'radio-filter',
|
||||
'sdr-gain-select',
|
||||
].map((id) => document.getElementById(id));
|
||||
return {
|
||||
ids: selects.map((node) => node.id),
|
||||
shared:
|
||||
selects.length === 6 &&
|
||||
selects.length === 5 &&
|
||||
selects.every((node) => node.classList.contains('gev-field')),
|
||||
native: selects.every(
|
||||
(node) =>
|
||||
@@ -58,7 +57,7 @@ try {
|
||||
};
|
||||
});
|
||||
check(
|
||||
'all six panel dropdowns retain native select semantics and shared surface',
|
||||
'all five panel dropdowns retain native select semantics and shared surface',
|
||||
inventory.shared && inventory.native,
|
||||
inventory,
|
||||
);
|
||||
@@ -282,13 +281,6 @@ try {
|
||||
syncShare: false,
|
||||
});
|
||||
});
|
||||
if (
|
||||
await page.$eval(
|
||||
'#draw-toggle',
|
||||
(node) => node.getAttribute('aria-pressed') !== 'true',
|
||||
)
|
||||
)
|
||||
await page.click('#draw-toggle');
|
||||
for (const lang of ['en']) {
|
||||
await page.evaluate((lang) => {
|
||||
document.documentElement.lang = lang;
|
||||
@@ -296,12 +288,7 @@ try {
|
||||
for (const width of [1440, 620, 390]) {
|
||||
await page.setViewport({ width, height: 900 });
|
||||
const layout = await page.evaluate(() => {
|
||||
const ids = [
|
||||
'scene-select',
|
||||
'cctv-camera-select',
|
||||
'hud-layout-select',
|
||||
'draw-color-select',
|
||||
];
|
||||
const ids = ['scene-select', 'cctv-camera-select', 'hud-layout-select'];
|
||||
return ids.map((id) => {
|
||||
const node = document.getElementById(id),
|
||||
rect = node.getBoundingClientRect(),
|
||||
@@ -432,19 +419,11 @@ try {
|
||||
syncShare: false,
|
||||
});
|
||||
});
|
||||
if (
|
||||
await page.$eval(
|
||||
'#draw-toggle',
|
||||
(node) => node.getAttribute('aria-pressed') !== 'true',
|
||||
)
|
||||
)
|
||||
await page.click('#draw-toggle');
|
||||
const translated = await page.evaluate(() => {
|
||||
const nodes = [
|
||||
'scene-select',
|
||||
'cctv-camera-select',
|
||||
'hud-layout-select',
|
||||
'draw-color-select',
|
||||
].map((id) => document.getElementById(id));
|
||||
return (
|
||||
document.documentElement.lang === 'ru' &&
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Rendered acceptance for DISPLAY ▸ Import: real files through the real file
|
||||
* Rendered acceptance for UTILITIES ▸ Import: real files through the real file
|
||||
* input and a real drop, parsed by Cesium in the page.
|
||||
*
|
||||
* Proves: GeoJSON, KML, KMZ (with a packed icon and ground overlay) and GPX
|
||||
@@ -269,10 +269,12 @@ try {
|
||||
}, VIEW);
|
||||
await wait(4000);
|
||||
await page.evaluate(() => {
|
||||
const rail = document.getElementById('pp-toggles');
|
||||
if (rail?.classList.contains('collapsed'))
|
||||
const panel = document.getElementById('utilities-panel');
|
||||
if (panel?.classList.contains('collapsed'))
|
||||
document
|
||||
.querySelector('.panel-collapse-btn[data-collapse-target="pp-toggles"]')
|
||||
.querySelector(
|
||||
'.panel-collapse-btn[data-collapse-target="utilities-panel"]',
|
||||
)
|
||||
?.click();
|
||||
});
|
||||
await page.waitForFunction(
|
||||
@@ -283,7 +285,7 @@ try {
|
||||
);
|
||||
const before = await state();
|
||||
check(
|
||||
'Import is on the DISPLAY rail with an empty, hidden list',
|
||||
'Import is in the UTILITIES panel with an empty, hidden list',
|
||||
before.rows === 0 &&
|
||||
!(await page.evaluate(() =>
|
||||
document
|
||||
|
||||
@@ -126,6 +126,14 @@ try {
|
||||
'ordinary layer rows return after reassembly',
|
||||
container.querySelectorAll('.data-toggle-row').length > 10,
|
||||
]);
|
||||
result.push([
|
||||
'Directions returns to the Utilities list after reassembly',
|
||||
Boolean(
|
||||
document.querySelector(
|
||||
'#utilities-toggles [data-layer-id="directions"]',
|
||||
) && !container.querySelector('[data-layer-id="directions"]'),
|
||||
),
|
||||
]);
|
||||
return result;
|
||||
});
|
||||
for (const [name, passed] of results) check(name, passed);
|
||||
|
||||
@@ -35,15 +35,21 @@ try {
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.styleManager.setPanelCollapsed('pp-toggles', false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
}),
|
||||
);
|
||||
// DISPLAY holds most segmented controls; Draw's shape group is in UTILITIES.
|
||||
await page.evaluate(() => {
|
||||
for (const panel of ['pp-toggles', 'utilities-panel'])
|
||||
window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
});
|
||||
|
||||
const surface = await page.evaluate(() => {
|
||||
const groups = [...document.querySelectorAll('[role="radiogroup"]')];
|
||||
// Colour swatches are one-of-many too, but chips, not segments: they are
|
||||
// checked on their own below.
|
||||
const groups = [
|
||||
...document.querySelectorAll('[role="radiogroup"]:not(.draw-colors)'),
|
||||
];
|
||||
const buttons = groups.flatMap((group) => [
|
||||
...group.querySelectorAll('[role="radio"]'),
|
||||
]);
|
||||
@@ -69,6 +75,31 @@ try {
|
||||
JSON.stringify(surface),
|
||||
);
|
||||
|
||||
const swatches = await page.evaluate(() => {
|
||||
const group = document.getElementById('draw-colors');
|
||||
const chips = [...(group?.querySelectorAll('[role="radio"]') || [])];
|
||||
return {
|
||||
count: chips.length,
|
||||
round: chips.every(
|
||||
(chip) => getComputedStyle(chip).borderRadius === '50%',
|
||||
),
|
||||
colours: new Set(
|
||||
chips.map((chip) => getComputedStyle(chip).backgroundColor),
|
||||
).size,
|
||||
checked: chips.filter(
|
||||
(chip) => chip.getAttribute('aria-checked') === 'true',
|
||||
).length,
|
||||
};
|
||||
});
|
||||
check(
|
||||
"Draw's colour swatches are five round chips, one checked",
|
||||
swatches.count === 5 &&
|
||||
swatches.round &&
|
||||
swatches.colours === 5 &&
|
||||
swatches.checked === 1,
|
||||
JSON.stringify(swatches),
|
||||
);
|
||||
|
||||
await page.evaluate(() =>
|
||||
document.getElementById('models3d-mode-all').click(),
|
||||
);
|
||||
|
||||
@@ -57,7 +57,7 @@ export function classifyNodeVersion(version = process.versions.node) {
|
||||
// NEWER than this release has verified is a warning, never a refusal: a
|
||||
// future Node must not brick a no-terminal install with advice its user
|
||||
// cannot follow. Too-old stays an error above — old runtimes genuinely fail.
|
||||
return { level: 'warn', summary: 'newer than this release has verified; Node 24.14.x or 26.x is the tested path' };
|
||||
return { level: 'warn', summary: 'newer than this release has verified; Node 24.22.x or 26.x is the tested path' };
|
||||
}
|
||||
|
||||
/** Verify that every direct package declared by this checkout is present. */
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
|
||||
import { admitSameSite } from './common/same-site.js';
|
||||
import {
|
||||
MILITARY_INSTALLATION_ELEMENT_CAP,
|
||||
MILITARY_INSTALLATION_MAX_RESPONSE_BYTES,
|
||||
@@ -97,6 +98,7 @@ function militaryInstallationsProxy() {
|
||||
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
|
||||
return;
|
||||
}
|
||||
if (admitSameSite(req, res)) return;
|
||||
const url = new URL(req.url, 'http://localhost');
|
||||
const requested = validMilitaryInstallationBox(url.searchParams);
|
||||
if (!requested) {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
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