Compare commits

..
Author SHA1 Message Date
Mazeyar Moeini Feizabadi 7ebe98ba84 Merge remote-tracking branch 'origin/main' into mazeyar/shared-terrain-surface
# Conflicts:
#	src/app/constructCatalog.js
2026-11-14 21:03:10 +01:00
Mazeyar Moeini FeizabadiandClaude Fable 5.2 d4f86748ea Merge the Transit provider fix and current main; fix the Bike Share test harness
The provider/globe identity check from #1688 moves into the shared terrain
reader, so every layer gets it; Transit shows the reader the flat provider
with observe(), since it answers a flat globe itself.

The Bike Share height test kept one array entry per event-loop turn and
spread it into Math.max, which overflowed the stack on slow CI runners. It
now keeps a running maximum.

Both found in review by kvnloo.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-13 03:27:30 +01:00
Mazeyar Moeini Feizabadi c29ee98e23 Revert "style(transit): format the terrain provider test"
This reverts commit 5110f4cce3.
2026-11-13 03:26:17 +01:00
Mazeyar Moeini FeizabadiandClaude Fable 5.2 5110f4cce3 style(transit): format the terrain provider test
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-13 03:25:07 +01:00
Mazeyar Moeini Feizabadi 8792e4701e Merge remote-tracking branch 'origin/main' into mazeyar/transit-terrain-height-cache 2026-11-13 03:22:10 +01:00
Mazeyar Moeini FeizabadiandClaude Fable 5.2 87674240bd fix(transit): start terrain heights afresh when the provider or globe changes
A height read after the camera and tiles settled was kept until either
changed, but swapping the terrain provider or the globe with a still camera
changed neither, so the previous terrain's elevation could stand
indefinitely. The cache is now cleared when either identity changes, checked
before the flat-globe answer so terrain -> flat -> terrain is covered too.

Found in review by kvnloo.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-13 03:22:10 +01:00
Mazeyar Moeini FeizabadiandClaude Fable 5.2 888662749a docs(changelog): name the shared terrain fragment after its pull request
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-12 23:05:39 +01:00
Mazeyar Moeini FeizabadiandClaude Fable 5.2 0f4dda5cc6 perf(terrain): one shared, cached and budgeted terrain height reader for Transit, Rail, Bike Share and local data
globe.getHeight is a ray cast against the drawn tile's mesh and
scene.sampleHeight a GPU depth render; several layers asked for them
uncached and unbudgeted. The cache written for Transit moves to
src/services/terrainSurface.js and is handed to every layer through the
ground service, so layers share one cache and one read budget.

- Rail reads each train on a ~4 m grid instead of every frame.
- Local data layers read terrain floors through it and stop depth samples
  at 8 ms a frame.
- Bike Share draws stations at once and finds their heights afterwards in
  4 ms steps.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-12 23:05:24 +01:00
Mazeyar Moeini FeizabadiandClaude Fable 5.2 d5f7926c7e docs(changelog): name the transit height fragment after its pull request
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-12 21:50:22 +01:00
Mazeyar Moeini FeizabadiandClaude Fable 5.2 1c49a89faa fix(transit): keep terrain heights per point so rereads stop freezing the page
On a terrain globe every floor reread asked globe.getHeight, a ray cast against
the drawn tile's mesh, for every trail fix of every vehicle in view, twice per
pass and seven passes per poll. Over Boston's 650 vehicles that was about
150,000 reads per 10 s for about 8,000 distinct points, and blocked the page
for up to 19 s at a time while zooming.

Heights are now kept per point (1e-5 degrees), read again only while the camera
moves or tiles load, and read within a time budget: vehicle positions first,
the path a marker is drawn along next, undrawn trail history last.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-12 21:10:49 +01:00
232 changed files with 3460 additions and 10774 deletions

No files matched your search

-15
View File
@@ -419,7 +419,6 @@ The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `p
| **Natural Earth countries, states and provinces** (260 admin-0/map units + 4,587 admin-1 polygons) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) | | **Natural Earth countries, states and provinces** (260 admin-0/map units + 4,587 admin-1 polygons) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) |
| **Natural Earth disputed areas** (11 of the source's 99 features, hand-picked; [list](src/data/local_data/natural_earth/README.md#disputed-areas-disputed_areasjson)) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) | | **Natural Earth disputed areas** (11 of the source's 99 features, hand-picked; [list](src/data/local_data/natural_earth/README.md#disputed-areas-disputed_areasjson)) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) |
| **Natural Earth populated places** (7,342 points: name, position, rank) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) | | **Natural Earth populated places** (7,342 points: name, position, rank) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) |
| **Wikidata cities** (31,827 settlements of 15,000 people or more: name, position, population, country) | `wikidata_cities/` | **CC0 1.0** (public domain dedication) | ✅ (no restrictions) | "City, town and village names from Wikidata (CC0)" (courtesy credit — not legally required) |
| **US Census Bureau counties** (3,235 county polygons) | `us_census_counties/` | **Public domain** | ✅ (no restrictions) | "U.S. Census Bureau" (courtesy — not legally required) | | **US Census Bureau counties** (3,235 county polygons) | `us_census_counties/` | **Public domain** | ✅ (no restrictions) | "U.S. Census Bureau" (courtesy — not legally required) |
| **US Census Bureau places** (32,629 city, town and CDP polygons) | `us_census_places/` | **Public domain** | ✅ (no restrictions) | "U.S. Census Bureau" (courtesy — not legally required) | | **US Census Bureau places** (32,629 city, town and CDP polygons) | `us_census_places/` | **Public domain** | ✅ (no restrictions) | "U.S. Census Bureau" (courtesy — not legally required) |
| **Who's On First neighborhoods** (61,441 polygons) | `wof_neighborhoods/` | Per source: CC BY, ODC-By, Open Government licences, CC0, PDDL, public domain (folder `ATTRIBUTION.md`) | ✅ (attribution) | "Neighborhood boundaries: Who's On First and contributors"; per-source credits in the folder | | **Who's On First neighborhoods** (61,441 polygons) | `wof_neighborhoods/` | Per source: CC BY, ODC-By, Open Government licences, CC0, PDDL, public domain (folder `ATTRIBUTION.md`) | ✅ (attribution) | "Neighborhood boundaries: Who's On First and contributors"; per-source credits in the folder |
@@ -519,20 +518,6 @@ its width ("Sichuan, China · near Chengdu"), offline, with the states and provi
the division. Public domain; credited "Made with Natural Earth" with the other packs. Rebuild: the division. Public domain; credited "Made with Natural Earth" with the other packs. Rebuild:
`node scripts/build-admin-packs.mjs --only places`; provenance in the folder README. `node scripts/build-admin-packs.mjs --only places`; provenance in the folder README.
### Wikidata cities (`wikidata_cities/`)
Settlements of 15,000 people or more that Wikidata states a population and coordinates for,
read once from the Wikidata Query Service by `scripts/build-wikidata-cities.mjs`: one row per
place with its English label, latitude and longitude to 3 decimals, largest stated population,
ISO 3166-1 country code and Wikidata id (31,827 places, 1.6 MB). The pack is read the
first time a place name is looked up and gives the nearest city, the places in view and a city
found by name (`findPlace`: "Paris, France", or the nearest namesake in view). Groupings of
settlements (metropolitan areas, agglomerations) and places that no longer stand are left out
unless they are also a living city. Names match without accents ("Sao Paulo" finds São Paulo).
CC0; credited "City, town and village names from Wikidata (CC0)" as a courtesy once a name from
the pack is shown. Rebuild: `node scripts/build-wikidata-cities.mjs`; provenance in the folder
README. The app never calls Wikidata itself.
### Sky background (`sky/`) ### Sky background (`sky/`)
Sources, licences, the changes CC BY asks to state, hashes and build commands Sources, licences, the changes CC BY asks to state, hashes and build commands
@@ -0,0 +1,7 @@
- Fix the page freezing for seconds at a time with Transit on over a terrain
globe, most visibly while zooming over a large fleet. Every reread asked the
terrain for the height of every trail fix of every vehicle in view; heights
are now kept per point, read again only while the camera moves or tiles
load, and read within a time budget that puts vehicle positions first.
Switching the terrain provider or globe starts the kept heights afresh
(mazy1998, review by kvnloo, #1688).
@@ -0,0 +1,5 @@
- Read terrain heights through one shared, cached and budgeted reader
(`src/services/terrainSurface.js`) in Transit, Rail, Bike Share and the local
data layers. Opening Data Centers no longer blocks the page for over two
seconds, opening Bike Share for most of a second, and Rail asks the terrain
about a twentieth as often (mazy1998, #1689).
@@ -1,10 +0,0 @@
- Draw transit vehicles as their kind, not one of five: trolleybuses, cable
cars, funiculars, gondolas and monorails are modes of their own, with their
own icon, colour, card word and fleet model. A vehicle record may carry its
route's GTFS `route_type` (`routeType`), which settles its mode before the
feed's route hints and picks the finer model (a coach for 200, a high-speed
set for 101). Prague's trolleybuses and Petřín funicular, and Bucharest,
Cluj and Turin's trams, trolleybuses and metro lines, now read as such from
the route ids in each operator's routes.txt. Le Havre's funicular, which its
routes.txt types as a bus, is a named correction that outranks the route
type (samehkhamis, #1734).
-5
View File
@@ -1,5 +0,0 @@
- Move Visual to the left rail, after Data Layers, Utilities and Authoring, and
lead the right column with Context; an open Context or CCTV panel takes the
column's full height. A switch in each panel header moves that side's panels
over their rail to the screen edge, and the scroll wheel at a screen edge
steps through that side's panels (manjunath22466, samehkhamis, #1735).
@@ -1,9 +0,0 @@
- Fix three time readings on the world timeline. A scrub near now goes live
only within two minutes of now, however long the track: with a layer whose
source covers years, any time in the last few weeks used to snap to live.
Fire cards and fire details count a detection's age from the end of the
24 hours shown, and from now only when live, so a window that ended months
ago reads "new 3h" rather than "new 105d". Weather and satellite cloud
frames stay shown, and the latest reads late, for at least one and a half
of the feed's own frame spacings, so a feed whose frames come an hour apart
no longer goes blank for half of every hour (samehkhamis, #1736).
-7
View File
@@ -1,7 +0,0 @@
- Let a host application decide where live contacts are drawn.
`createApplicationCatalog({ placement })` takes a placement provider whose
`layer(kind)` hands Flights, Military Flights, AIS Vessels, Transit and Rail
a `place(id, wallMs)` (and optionally `observe(id, fix)`, `forget(id)`):
each layer tells it every live fix it accepts and draws a contact where it
places it. Without one, or for a contact it does not place, every layer
moves as before (samehkhamis, #1737).
-4
View File
@@ -1,4 +0,0 @@
- Group the Visual panel into Look, Map source, Overlay and Effects with a
Reset at its foot, and add a Scope opacity slider (50–100%) that share links
carry when it is below full; Reset keeps the HUD layout, and the HUD gets
shadows when Scope is off (manjunath22466, samehkhamis, #1738).
-4
View File
@@ -1,4 +0,0 @@
- Give the desktop cockpit the main page's side rails: Data Layers alone on the
left, and Display and Radio as a right rail that opens one panel at a time
beside it, at the same height, with Contact and Live Signals restyled to
match (manjunath22466, samehkhamis, #1739).
-3
View File
@@ -1,3 +0,0 @@
- Add a search and a filter (On / Off and category) to the Data Layers panel,
matching layer names in the UI language, and a section index down its side
that jumps to each group (manjunath22466, samehkhamis, #1740).
-6
View File
@@ -1,6 +0,0 @@
- Let an application that composes Core use its translator and word Core's
messages its own way. The `./i18n` entry exports `t`, `tn`, `msg`,
`translateKnown` and the number and date formatters, and
`setMessageOverrides({ en: {…}, de: {…} })` shows the application's text for
any catalog key, in the active locale or else in English (samehkhamis,
#1741).
-3
View File
@@ -1,3 +0,0 @@
- Bundle 31,827 cities from Wikidata (CC0) for offline place lookups: the
nearest city, the cities around a point and a city by name, matched without
accents (manjunath22466, samehkhamis, #1743).
-4
View File
@@ -1,4 +0,0 @@
- A weather source can name its provider for each product with
`sourceName(kind)`; the Rain radar, Lightning density and Satellite clouds
rows show it until a manifest names its own, instead of always saying NOAA
nowCOAST (samehkhamis, #1744).
@@ -1,51 +0,0 @@
- Timeline marks have labels. Hovering a tick shows its label (a launch's
mission name, or an earthquake's magnitude and place, such as
`M6.4 · 113 km SSE of Lata, Solomon Islands`), and a tap on or within 6 px
of a tick goes exactly to its mark rather than to the point tapped; a tap
elsewhere and a drag work as before. The arrow keys still step from mark to
mark, and the handle then tells the mark's label with the time to screen
readers and shows it over the track while focused. A layer's world clock
coverage may give each mark as `{ time, label, id, kind }` as well as a
bare time. `worldClock.coverage()` keeps `marks` as the sorted times and
adds `markDetails`, the merged marks themselves, merged on their whole
label; a label longer than `WORLD_CLOCK_MARK_LABEL_LENGTH` (60 characters,
exported from `gods-eye-view/time`) is then cut, ending in `…`, never
splitting an emoji. A zoom or a pan clears the hovered label, and a label
is never wider than the track (samehkhamis, #1745).
- The timeline marks only events: launches, and earthquakes of magnitude 6
and up while the Earthquakes layer is on (about one every two days
worldwide). It no longer draws a tick for every weather frame, which came
every few minutes and made the track a wall of dashes; the weather frames
still set the time the timeline covers, and Play still waits at each next
frame until it loads. With no mark ahead, the arrow keys step along the
track. The map still draws every earthquake, and an earthquake layer never
widens the timeline beyond its strong quakes' own times. The Earthquakes
layer registers with the world clock (`gods-eye-view/time`) once it is
initialised, and has time to show only while on;
`gods-eye-view/layers/earthquakes` adds `EARTHQUAKE_MARK_MAGNITUDE`,
`earthquakeMagnitudeTitle`, `earthquakeMarkLabel` and `earthquakeCoverage`.
A weather row's `step` param goes to the previous or next weather frame
(samehkhamis, #1745).
- The main timeline is the only time control. The Weather panel no longer
has a slider, step buttons, Latest or Play of its own; it shows the frame
time instead: the newest frame and its age while live, the timeline time
alone at a past time; while live it shows the newest frame even when the
products are suspended (reduced motion, a hidden page, no imagery host).
The timeline's Play runs the world clock at the chosen speed for weather
too, stopping at each next frame until it loads, rather than stepping the
observed frames every two seconds and going round from the last to the
first. A weather row's `play` and `latest` params move the world clock as
the timeline does: `play` runs at the timeline's chosen speed, from the
start when live or at the end, and does nothing while every product the
row shows is suspended or while there is no time to show. The world clock
keeps a chosen rate while paused and live (`goLive()` no longer sets it
back to 1), so any play runs at it, and the timeline's speed shows the
clock's rate whoever set it (the nearest offered speed for another rate).
A weather layer made without a weather clock leaves the world clock alone
until it is first enabled, does nothing once destroyed, and destroying it
leaves the world clock's time where it is. **Breaking:** the catalog's
`weatherClock` (`gods-eye-view/application/layers`) no longer has
`setTarget`, `step`, `latest`, `play`, `pause` or `togglePlay`, its state
no longer has `playing`, and it no longer attaches itself to the world
clock as a driver; move time with the world clock (`gods-eye-view/time`,
also `weatherClock.world`) instead (samehkhamis, #1745).
@@ -1,5 +0,0 @@
- Move satellites every frame. Points were propagated once a second and held
still in between, so at 1 min/s they jumped about 45 px a second; each now
glides from its last SGP4 sample (position, velocity, gravity and its rate
of change) to the world time, resampled every second and whenever time
moves 30 s from it, within about 10 m of SGP4 (Sameh Khamis, #1746).
-5
View File
@@ -1,5 +0,0 @@
- Move vessels every frame. Vessels between fixes, and live vessels a host
placement places, were redrawn at most every 250 ms with a frame asked for
once a second; they are now redrawn every frame, and the next frame is asked
for once the fastest drawn vessel will have moved half a pixel on screen
(Sameh Khamis, #1747).
+1 -28
View File
@@ -187,28 +187,7 @@ instances. Configure sources with `application/sources` before registration or
state restoration, and release them after the consumers stop. The catalog state restoration, and release them after the consumers stop. The catalog
constructor (`application/layers`) also requires `wind`, `weather` and constructor (`application/layers`) also requires `wind`, `weather` and
`cyclones` sources with `getSnapshot` for the Weather layers, and owns the `cyclones` sources with `getSnapshot` for the Weather layers, and owns the
observed-history clock those layers share for the catalog's lifetime. That observed-history clock those layers share for the catalog's lifetime.
clock, the catalog's `weatherClock`, follows the world clock (`gods-eye-view/time`)
and has no time controls of its own: move time with the world clock's `seek`,
`play`, `pause` and `goLive` (`weatherClock.world` is the one it follows).
A layer gives the world clock's timeline the time it can show with
`worldClock.register({ id, coverage })` (`gods-eye-view/time`), which returns
the unregister; call `worldClock.coverageChanged()` when that time changes.
`coverage()` returns null while the layer has none, or
`{ start, end, marks? }` in epoch ms. `marks` are the moments worth going to,
drawn as ticks the timeline's arrow keys step to and a tap on or within 6 px of
snaps to. Each mark is a number, or `{ time, label?, id?, kind? }`: `label` is
short text (cut to 60 characters, ending in `…`) shown when the pointer is on
the tick and told with the time while the handle is on the mark, `id` the layer's own key for the event and `kind`
what sort of event it is (`'launch'`, `'earthquake'`). The two forms may be
mixed; a mark without a finite time, and a `label`, `id` or `kind` that is not
a non-empty string, are ignored. `worldClock.coverage()` returns the union of
the registered layers, `{ start, end, marks, markDetails }`, or null: `marks`
is every mark's time once, in order, as numbers, and `markDetails` the marks in
time order, each a frozen `{ time, label?, id?, kind? }` holding only the
fields given, with marks given alike by several layers once and a bare time
left out where a mark with more to say shares it.
A `weather` source may also supply decoded frames: an optional A `weather` source may also supply decoded frames: an optional
`getFrameImage({ product, time, size, bbox, signal })` returns a whole-extent or `getFrameImage({ product, time, size, bbox, signal })` returns a whole-extent or
detail image and an optional `getFrameTile({ product, time, z, x, y, size, detail image and an optional `getFrameTile({ product, time, z, x, y, size,
@@ -216,12 +195,6 @@ signal })` returns one tile or null for a transparent tile, each as an
`ImageBitmap`, `ImageData`, canvas or `VideoFrame` of exactly the requested `ImageBitmap`, `ImageData`, canvas or `VideoFrame` of exactly the requested
size, which the layer draws once and closes. Without them the layers read the size, which the layer draws once and closes. Without them the layers read the
image and tile routes (`WeatherFrameFeed` in `src/layers/weather/source.js`). image and tile routes (`WeatherFrameFeed` in `src/layers/weather/source.js`).
It may also name its source with an optional `sourceName(kind)`, `kind` being
`'radar'`, `'lightning'` or `'satellite'`: the layer's row shows that name until
a manifest names its own, and NOAA nowCOAST when the source names none. The
name is read once, when the layer is built, and held to the same rule as a
manifest's `display.source`. The map credit still comes from each manifest's
`display.credit`, so a source that is not nowCOAST sets that too.
`application/services` accepts boundary, terrain, regional-context, weather and `application/services` accepts boundary, terrain, regional-context, weather and
summary services; its weather service supplies cockpit local conditions, not the summary services; its weather service supplies cockpit local conditions, not the
+8 -7
View File
@@ -293,9 +293,9 @@ the user's later collapse choice, including body remounts. Mark the body
`max-height` and puts the scroll offset back afterwards. Panel bodies only `max-height` and puts the scroll offset back afterwards. Panel bodies only
scroll on an open-card change, with one write, never on a refresh. scroll on an open-card change, with one write, never on a refresh.
Compose `createRailCards({ container, document, onParams, onOpen })`. Rails Compose `createRailCards({ container, document, onParams, onOpen })` and
have no time controls of their own; the world timeline moves time. Cards `createRailTimeline({ container, document, onCommit, onPreview, onStep,
reconcile `{ id, title, badge, open, compact, onLatest, onPlay })`. Cards reconcile `{ id, title, badge, open, compact,
compactStatus, blocks }` in place. Their full-width native button headers expose compactStatus, blocks }` in place. Their full-width native button headers expose
`aria-expanded` and `aria-controls`; articles expose `data-open`. The feature `aria-expanded` and `aria-controls`; articles expose `data-open`. The feature
adapter owns accordion state. Generic cards do not select data or enable layers. adapter owns accordion state. Generic cards do not select data or enable layers.
@@ -330,9 +330,10 @@ consumers. They expose `summary.settings`, `summary.actions` and optional
subscriptions still refresh the cards. Cards own configuration and readings. subscriptions still refresh the cards. Cards own configuration and readings.
Weather orders cyclones, wind, then a bordered Observed history group containing Weather orders cyclones, wind, then a bordered Observed history group containing
the frame time and active radar, satellite and lightning cards. The group the timeline and active radar, satellite and lightning cards. The group heading
heading and scope name its active products. The frame time shows whenever any and scope name its active products. The timeline stays visible whenever any
observed product is enabled and has a frame; the world timeline moves time. observed product is enabled; fewer than two ticks disables transport. Pass
`heading: false` to the timeline when the containing group owns the heading.
Exactly one active card is open. Header clicks and newly enabled ids choose it Exactly one active card is open. Header clicks and newly enabled ids choose it
(the last new entry wins a batch); refreshes and clock ticks retain it. First (the last new entry wins a batch); refreshes and clock ticks retain it. First
appearance uses cyclones with storms, otherwise the first card. Disabling the appearance uses cyclones with storms, otherwise the first card. Disabling the
@@ -340,7 +341,7 @@ open layer falls back to the first remaining card. A WeakMap per document keeps
explicit choices through body remounts without adding share state. Card opening, explicit choices through body remounts without adding share state. Card opening,
storm selection and layer enabling remain independent. storm selection and layer enabling remain independent.
Optional card/badge class names provide feature styling. Card titles use Optional card/badge/slider class names provide feature styling. Card titles use
`.data-name` typography; meta/time/badges use `.data-toggle-meta` typography; `.data-name` typography; meta/time/badges use `.data-toggle-meta` typography;
setting/result labels use `.panel-title`; chips retain `.data-toggle-chip`. setting/result labels use `.panel-title`; chips retain `.data-toggle-chip`.
Reserve one status line, even without a legend. Use app tokens. Wind's footer Reserve one status line, even without a legend. Use app tokens. Wind's footer
+56 -246
View File
@@ -365,26 +365,6 @@ The source factories are exported at `layers/directions/source` and
`layers/recent-imagery/source`. Configuration is applied at construction time; `layers/recent-imagery/source`. Configuration is applied at construction time;
this does not introduce live source replacement. this does not introduce live source replacement.
Application catalog composition also accepts an optional `placement`, a
host-supplied placement provider (`src/data/placementProvider.js`): an object
whose `layer(kind)` returns, for `aircraft`, `military`, `vessels`, `transit`
or `rail`, either null or `{ place(id, wallMs), observe?(id, fix),
forget?(id) }`. While live, each of those layers tells it every fix it accepts
(time, position, the height it draws the fix at, reported speed, course and
ground state) and draws a contact where `place` puts it: position, height
(the layer's own when none is given), course and speed. A null answer, a
provider that throws, a kind it leaves out, or no provider at all keeps the
layer's own motion, unchanged; following the world clock, the layers never
ask it. Transit and rail vehicles a provider places are advanced every frame
in view while their placed position changes; one it holds still lets the
render loop idle and is asked again each second, moving on once its placement
does. A placed transit vehicle is drawn at its placed point, and a placed
train where it is placed rather than on its bound railway line. Live vessels
a provider places move on between refreshes: redrawn in place every frame,
with the next frame asked for as soon as the fastest drawn vessel will have
moved half a pixel on screen, at least once a second while the provider places
any drawn vessel, and none once the layer is off.
Application catalog composition accepts an optional `streetLevelProviders` list. Application catalog composition accepts an optional `streetLevelProviders` list.
An explicit list replaces the default Mapillary provider; an empty list removes An explicit list replaces the default Mapillary provider; an empty list removes
all imagery providers. Without that option, Mapillary is registered only when all imagery providers. Without that option, Mapillary is registered only when
@@ -647,139 +627,6 @@ panel that holds its row, and `set_panel_open` reaches it as
`utilities-panel` or by "utilities", "draw", "drawing" or "import". On a phone `utilities-panel` or by "utilities", "draw", "drawing" or "import". On a phone
it sits collapsed in the Layers sheet under Data Layers. it sits collapsed in the Layers sheet under Data Layers.
## The Visual panel — November 14, 2026
Visual (`#pp-toggles`, `src/ui/views/panels/DisplayPanel.vue`) is headed
VISUAL, has the edge switch in its header like the other side panels, and is
grouped from the top: Look, the six style presets with their 1–6 keys, and
Map source, the map source chips (`VisualPresets.vue`); Overlay, the HUD,
detection, style parameters, 3D aircraft, Scope and Clean UI; Effects,
Celestial, Bloom and Sharpen; and Reset to default at the foot. Each control
is a card (`src/ui/styles/visual-panel.css`) whose header row ends in an
on/off pill worded in the current language (Clean UI, an action, has none;
the buttons carry their state as `aria-pressed`). Under the Cyber HUD the
cards keep Cyber's own red skin. The presets and chips call the
same Display store actions as the dock's Visual Presets tray, which stays on
desktop and phone as before, along with Location; voice `Visual Presets`
still opens that tray. On a phone the More sheet shows that tray right above
Visual, so Visual leaves out its own presets and chips there. In Cockpit,
the groups that Cockpit's Display popover takes leave the panel as before and
keep their own styling there.
Scope has an Opacity slider (`#scope-opacity-slider`, 50–100%, default 100%;
`SCOPE_OPACITY_MIN_PCT` in `src/ui/visualSettings.js`, since a fainter mask
reads as Scope off while labels still fade outside the keyhole)
that fades the whole mask (`setScopeMaskOpacity` in `src/scopeMask.js`, the
canvas's CSS opacity) without repainting it or changing the altitude
terminus; it is disabled while Scope is off and keeps its value. Share links
carry it as `sco` in the long form and field 30 in the compact form, written
only below 100%, so links made before it keep their codes and open at full
opacity, and a link without it sets the opacity back to 100%; a link asking
for less than 50% opens at 50%. Scenes do not
save it: a scene shot leaves the opacity as it is.
Reset to default (`resetVisual` in `src/ui/displayBindings.js`) brings back
the out-of-the-box look: the Normal style, the first-run post defaults
(bloom off, sharpen on at 49%, the HUD shown, Dense detection at 75%
with its fade and outside opacity, the celestial ring off), Elastic
allocation, Scope on at 100% feather and 100% opacity, and 3D aircraft on in
Proximity. The HUD layout follows the Normal style, as choosing it does: the
Tactical HUD, except that the Cyber HUD stays Cyber. Inside Contacts, which
lends detection for the session, the reset's detection becomes what leaving
Contacts puts back and what a share link carries. It leaves the map source, the pinned scope terminus, Sonar's
settings, other styles' parameters, the layers and the panels alone.
With Scope off the side rails, open panels and the dock's trays carry a soft
drop shadow and the rails' labels and icons a text shadow
(`src/ui/styles/scope-off-shadows.css`), so their edges hold against bright
imagery.
## Side rails and the screen edge — November 14, 2026
Visual (`#pp-toggles`, the display settings, rail item `palette`) is the last
panel of the left column, after Data Layers, Utilities and Authoring, and
opens from the left rail like them (`PanelLayoutController`, falling back to
the right column in a host without a left stack). Context leads the right
column, followed by CCTV, Street Level and the other right panels. An open
Context or CCTV panel takes the column's full height; the other right panels
keep the content-sized layout and its hysteresis band. With nothing open, a
rail's icons and their names show as two glass pieces side by side; once a
panel opens, the icons stay in place and a highlight slides to the open one.
Each collapsible panel header has an edge switch (`UiPanelEdge.vue`,
`src/ui/panelEdge.js`): it moves that side's panels over their rail to the
screen edge, with the rail as a row of icons above the open panel, and back.
The choice is per side, kept in `localStorage` (`gev.panelEdge`), and applies
on the desktop shell only: phones and Cockpit keep their own layouts and do
not show the switch.
Scrolling the wheel at a screen edge (`src/ui/railEdgeScroll.js`, from the
edge to just past the rail, at most 84 px) steps that side's rail to its next
or previous panel instead of zooming the globe, wrapping round at either end.
One gesture is one step, trackpad coasting included, with at least half a
second between steps; a step that comes too soon is held until then and is
dropped if the pointer leaves the strip. Panels, controls, dialogs and the
world timeline in the strip keep their own wheel. The strip is off on phones
and touch screens, while the rails are hidden (clean view, recording), and it
follows the rails' sides in a right-to-left language. On a phone, Visual is in
the More sheet with the dock's Visual Presets, as before.
## Wikidata cities pack — November 15, 2026
`src/data/local_data/wikidata_cities/cities.json` bundles 31,827 settlements of
15,000 people or more from Wikidata (CC0, 1.6 MB): name, position, population,
country and Wikidata id. `src/data/wikidataCities.js` reads it on the first
lookup and answers `nearestCity` (the largest city within 12 km, else the
nearest within 60 km), `citiesAround`, `findPlace` ("Paris, France", or the
nearest namesake within 150 km of the view, else the largest) and
`countryName`. Names match without case or accents ("lodz" finds Łódź). The
app never calls Wikidata; `scripts/build-wikidata-cities.mjs` rebuilds the
pack and fails if well-known cities are missing or a known grouping such as
the New York metropolitan area gets in. Nothing shows names from it yet; the
Context panel is its first reader. Sources and licence: `DATA_SOURCES.md`.
## Data Layers finder and section index — November 15, 2026
The Data Layers header has a search button and a filter button
(`src/ui/views/panels/LayerFinder.vue`). Search opens a field under the
header that matches each layer's name on screen and its English name; Escape
closes it and leaves the panel open. The filter is a small popover with two
groups, Status (All layers, On, Off: whether the layer is enabled) and
Category (the list's groups, kept by their key, so a language change keeps
the choice). The arrows, Home and End move through it and Escape closes it.
Rows and groups that are left out hide; chips under the header show the
active filters and clear them, and "No layers match" says when nothing is
left. Neither the search nor the filter is saved or put in share links. A
voice request that points at a hidden layer clears the finder first
(`gev:layer-finder-clear`).
A column of ticks down the list's inline end
(`src/ui/views/panels/LayerSectionIndex.vue`) has one tick per group still
shown; hovering or focusing a tick names its group beside it, on whichever
side has room, and clicking scrolls the list there. The tick for the group in
view is lit. Phones do not show it.
## Cockpit side panels — November 14, 2026
On the desktop, the cockpit's side panels match the main page's
(`src/ui/styles/cockpit-panels.css`). The left rail shows Data Layers only:
Authoring, Utilities and Visual, with their panels, are hidden, since the
cockpit's own Display panel stands in for Visual. Display and Radio
(`CockpitUtilityControls.vue`) become a right rail of two icons with their
names beside them, and each opens one panel at a time beside the rail, with a
header and a "−" to close it. The rail's buttons are disclosures
(`aria-expanded`, `aria-controls`) and go through the `toggleCockpitUtility`
action (`src/ui/radioBindings.js`); closing a panel gives focus back to the
rail. Both rails start at the same height: the left one in CSS, and Display
and Radio through the cockpit layout pass, whose floor
(`COCKPIT_UTILITY_MIN_TOP_PX` / `_RATIO` in `src/ui/cockpitPresentation.js`)
matches it, so an open panel still stops short of the briefing. Contact and
Live Signals are shorter, in the same glass, with the briefing's rows and
weather tiles tightened; Regional News stays a briefing page. In a right-to-left language, the rail keeps its
physical side as the cockpit's other windows do. The Cyber HUD and the phone
keep their own cockpit layouts, with the Display and Radio launchers as
before, and edge mode does not apply in the cockpit.
## God's Eye View in conversations — October 2, 2026 ## 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, Tool answers that can be shown in God's Eye View include a view: camera, layers,
@@ -872,8 +719,6 @@ Display's native Scope checkbox defaults ON with feather at 100%, adjustable
from 0–200%. OFF disables the slider while retaining its value. HUD layout from 0–200%. OFF disables the slider while retaining its value. HUD layout
changes preserve both settings; share links and scenes restore them. The changes preserve both settings; share links and scenes restore them. The
historical missing-`scf` fallback remains 35%; explicit 0% keeps the hard edge. historical missing-`scf` fallback remains 35%; explicit 0% keeps the hard edge.
Its Opacity slider (0–100%, default 100%) fades the mask; share links
restore it (`sco`), scenes do not (see The Visual panel).
With Scope OFF, Tactical and Minimal add text-sized black backplates With Scope OFF, Tactical and Minimal add text-sized black backplates
at 70% opacity behind telemetry, vertical metadata and scene chrome. Original at 70% opacity behind telemetry, vertical metadata and scene chrome. Original
@@ -1782,14 +1627,7 @@ Earthquake rendering is exposed through `./layers/earthquakes`. The layer owns
its entities and request lifecycle; the application supplies the overlay host its entities and request lifecycle; the application supplies the overlay host
and snapshot source. The standalone adapter keeps the existing USGS daily feed, and snapshot source. The standalone adapter keeps the existing USGS daily feed,
M2.5+ filtering, static discs, magnitude labels and analyst records. Disabling or M2.5+ filtering, static discs, magnitude labels and analyst records. Disabling or
destroying the layer cancels pending work and ignores late results. While on, destroying the layer cancels pending work and ignores late results.
the layer registers its quakes of magnitude 6 and up
(`EARTHQUAKE_MARK_MAGNITUDE`) with the world clock as timeline marks, spanning
only their own times, and none while off; the map still draws every M2.5+
quake. Each mark is labelled with the magnitude as the map shows it and the
feed's place, cut to 40 characters (`earthquakeMarkLabel` in
`src/layers/earthquakes/model.js`: `M6.4 · 113 km SSE of Lata, Solomon
Islands`, or `M6.4` with no place).
## Rendered-mesh floor readings — November 9, 2026 ## Rendered-mesh floor readings — November 9, 2026
@@ -2000,8 +1838,7 @@ Playing, the layer stays a snapshot ahead of the time, and play does not pass a
snapshot still loading for more than 8 seconds. Those snapshots apply as polls snapshot still loading for more than 8 seconds. Those snapshots apply as polls
do, so contacts move on smoothly: aircraft are drawn at the clock's time do, so contacts move on smoothly: aircraft are drawn at the clock's time
rather than behind it, and vessels between two fixes move along the great rather than behind it, and vessels between two fixes move along the great
circle, never past the newer one, redrawn every frame with frames asked for circle, never past the newer one. Records, absences and the Layers panel's age
as for placed live vessels (vessels out of view move on every 250 ms). Records, absences and the Layers panel's age
are judged at the clock's time. Outside the coverage the layer shows no are judged at the clock's time. Outside the coverage the layer shows no
contacts and loads nothing; while the coverage is unknown it loads nothing and contacts and loads nothing; while the coverage is unknown it loads nothing and
clears nothing. A snapshot that fails clears the time and is asked again after clears nothing. A snapshot that fails clears the time and is asked again after
@@ -3588,8 +3425,7 @@ reads `NASA FIRMS · LIVE · updated …`. A source may instead answer the 24 ho
to a set time, naming its end as `at` (epoch ms): the row then reads to a set time, naming its end as `at` (epoch ms): the row then reads
`NASA FIRMS · 24 h to MM-DD HH:MM UTC`, `getStats()` reports `at`, and `NASA FIRMS · 24 h to MM-DD HH:MM UTC`, `getStats()` reports `at`, and
`get_active_fires` says "in the 24 hours to" that time and reports it as `get_active_fires` says "in the 24 hours to" that time and reports it as
`window_end`, and card and detail ages count back from that time instead of `window_end`. The dev proxy never sets `at`.
now. The dev proxy never sets `at`.
The FIRMS CSV parser rejects impossible acquisition calendar dates before the proxy filters records to the trailing 24 hours, while retaining valid UTC acquisition times. The FIRMS CSV parser rejects impossible acquisition calendar dates before the proxy filters records to the trailing 24 hours, while retaining valid UTC acquisition times.
FIRMS ages round to whole hours before choosing a unit, so a 47.6-hour age reads "2d", not "48h"; with detections filtered to 24 hours, this shows mostly in the `STALE · cached {age}` stats line. FIRMS ages round to whole hours before choosing a unit, so a 47.6-hour age reads "2d", not "48h"; with detections filtered to 24 hours, this shows mostly in the `STALE · cached {age}` stats line.
@@ -4942,8 +4778,8 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
> Cesium entity carries no label graphic. > Cesium entity carries no label graphic.
> - **Phase 5 ISS and tracked-satellite labels:** The ISS path and large red > - **Phase 5 ISS and tracked-satellite labels:** The ISS path and large red
> point remain native, while persistent `ISS` text is a one-entry moving > point remain native, while persistent `ISS` text is a one-entry moving
> ambient-label host source. Its getter reads the point's own position, which > ambient-label host source. Its getter reads the already-propagated point
> moves every frame, eliminating the former second ISS > cache, preserving the 1 Hz fleet epoch and eliminating the former second ISS
> propagation. Satellite tracking continues to publish exactly one protected > propagation. Satellite tracking continues to publish exactly one protected
> tracked-lane card through `gevLabelModel` and `_trackedDisplayCached`; the > tracked-lane card through `gevLabelModel` and `_trackedDisplayCached`; the
> tracked entity is point-only. Tracking ISS suppresses the ambient entry and > tracked entity is point-only. Tracking ISS suppresses the ambient entry and
@@ -6032,26 +5868,7 @@ Helsinki, the Netherlands, Norway, South East Queensland, Rio de Janeiro
Ontario — Toronto (TTC surface vehicles; the subway publishes no Ontario — Toronto (TTC surface vehicles; the subway publishes no
positions), MiWay, Hamilton HSR, Durham Region, YRT/Viva, Guelph, positions), MiWay, Hamilton HSR, Durham Region, YRT/Viva, Guelph,
Burlington, Barrie and Kingston. Mode silhouettes Burlington, Barrie and Kingston. Mode silhouettes
identify buses, trolleybuses, trams, cable cars, subways, monorails, trains, identify buses, trams, subways, trains, ferries and unclassified vehicles.
funiculars, gondolas (aerial lifts), ferries and unclassified vehicles.
A vehicle's mode comes from, in order: the feed's fleet-number hint
(`vehicleMode`, which knows a bus standing in on a streetcar route); the feed's
correction for a route its own routes.txt mistypes (`routeCorrections`: Le
Havre's funicular `8-FUNI`, which LiA's routes.txt types as a bus); the GTFS
`route_type` of its route, when its record carries one as `routeType` (basic
0–12 or extended 100–1799, each extended type taken as the basic mode it is a
kind of: 200 coach is a bus, 101 high-speed is rail, 405 is a monorail); the
feed's route-id hint (`routeMode`); and the feed's default (`transitModeFor`,
`src/data/transitFeeds.js`). The GTFS-Realtime decoder carries no route type, so
a record from the bundled proxy is classified by the hints alone. Route hints
read from each operator's routes.txt cover Prague's trolleybus lines and Petřín
funicular, and Bucharest, Cluj-Napoca and Turin's trams, trolleybuses and metro
lines (and Turin's Sassi–Superga rack tramway, typed a funicular in its
routes.txt). Le Havre's funicular is a correction, not read from routes.txt: its
route hint and its correction both say funicular. Vilnius publishes no route ids in its positions, so
its trolleybuses read as buses. Retained history classifies its epochs the same
way, so a trail joins the live vehicle.
Retained transit history uses the same outlier quarantine as live observations and Retained transit history uses the same outlier quarantine as live observations and
preserves the active live bracket. Rejected reports do not publish route or mode preserves the active live bracket. Rejected reports do not publish route or mode
@@ -6117,10 +5934,9 @@ External halos are 1/1.5 px in normal, 1.25/1.25 px in CRT and 2/2 px in sensors
final unpadded display size. Each mode has an outline with a front (a nose, a final unpadded display size. Each mode has an outline with a front (a nose, a
bow or a chamfered cab) for a vehicle with a known course and a symmetric bow or a chamfered cab) for a vehicle with a known course and a symmetric
outline for one without, which would otherwise sit at rotation 0 and point outline for one without, which would otherwise sit at rotation 0 and point
north; a vehicle switches to the pointed glyph when a course arrives. Eleven modes, two sizes, three north; a vehicle switches to the pointed glyph when a course arrives. Six modes, two sizes, three
profiles and the two outlines cap the raster cache at 132 variants. Normal mode colours are bus `#5EF08A`, profiles and the two outlines cap the raster cache at 72 variants. Normal mode colours are bus `#5EF08A`, tram
trolleybus `#B6F04A`, tram `#FFC24A`, cable car `#FF8F4A`, subway `#FF4538`, monorail `#FF6FB8`, rail `#FFC24A`, subway `#FF4538`, rail `#D9A6FF`, ferry `#5FD6FF`, and unknown
`#D9A6FF`, funicular `#E8C98A`, gondola `#7FA8FF`, ferry `#5FD6FF`, and unknown
`#D8DDE5`. Selection keeps the mode colour. Sensors reuse each normal silhouette's exact `#D8DDE5`. Selection keeps the mode colour. Sensors reuse each normal silhouette's exact
outer path, filled opaque white without interior panel lines, with an opaque outer path, filled opaque white without interior panel lines, with an opaque
`#05080C` halo. Only the external padding grows; the unpadded silhouette remains `#05080C` halo. Only the external padding grows; the unpadded silhouette remains
@@ -6144,6 +5960,39 @@ Noir samples also exclude its deliberately vignetted outer field, using shader
settings and screen position rather than observed brightness. Selected-trail settings and screen position rather than observed brightness. Selected-trail
diagnostics report every condition even when selection fails. diagnostics report every condition even when selection fails.
Heights of the terrain globe's loaded surface come from one shared reader,
`src/services/terrainSurface.js`, which the ground service hands to every layer
(`groundFloor.terrainSurface`). `globe.getHeight` is a ray cast against the
drawn tile's mesh, so the reader keeps each answer per point, about a metre
apart. While the camera moves or tiles load a kept height is read again after
2 s; a height read once both have been still for 5 s is kept until either
changes. Reads are budgeted at 16 ms per 200 ms window across all layers, 12 ms
of it for re-reads, longest-kept first. A request ranks as a marker (always
read), drawn geometry, or background (a quarter of each budget). Past the
budget a kept point keeps its last height and a new point takes the last
height read in its ~111 m floor cell or waits.
- Transit asks for every vehicle and trail fix: vehicle positions as markers,
the path a marker is drawn along next, the undrawn trail history of
unselected vehicles as background, and its reread cycle continues until no
point waits. Before this, every reread cast a ray for every trail fix of
every vehicle in view, which blocked the page for up to 19 s at a time over
a 650-vehicle fleet while zooming.
- Rail asks for each train every frame on a grid about 4 m apart, as a marker,
so a moving train reads a few times a second and a standing one not at all.
- Local data layers (Data Centers, Dams) read terrain floors through it, and
their depth samples (`scene.sampleHeight`, a GPU render) stop at 8 ms a
frame per layer and continue on the next frames.
- Bike Share draws a city's stations at once at the fallback height and finds
their ground heights afterwards in 4 ms steps, from the terrain reader on a
terrain globe and from depth samples where the globe is hidden.
Verified in the browser on a keyless terrain globe only: Boston for Transit,
Rail and Bike Share, northern Virginia for Data Centers. Not checked there:
photorealistic 3D Tiles, a terrain globe with a building tileset on top, Dams,
and any city steeper than Boston. Street Traffic keeps its own height cache
and ALPR cameras their own sample budget.
Transit brackets use the normal-composite overlay surface: a 1.25 px mode-colour Transit brackets use the normal-composite overlay surface: a 1.25 px mode-colour
stroke over 3.25 px dark backing. Other contact themes retain their existing stroke over 3.25 px dark backing. Other contact themes retain their existing
behavior. The selected trail uses a 3 px core over 5 px backing, mode-coloured behavior. The selected trail uses a 3 px core over 5 px backing, mode-coloured
@@ -7004,7 +6853,7 @@ silently demoting every later lookup for the session.
`activeCameraCardEnabled` presentation option can publish the retained `activeCameraCardEnabled` presentation option can publish the retained
protected-card path. Disable tears the tier down completely. protected-card path. Disable tears the tier down completely.
Coverage/viewshed semantics unchanged. Coverage/viewshed semantics unchanged.
- **Satellites**: every point moves every frame. Each satellite's SGP4 sample (position, velocity, gravity and its rate of change; `src/layers/satellites/motion.js`) is reckoned forward or back to the world time and turned into the Earth-fixed frame, so points glide at any play speed instead of stepping once a second. Core satellites take a fresh sample every second, or as soon as the world time is 30 s from their sample (fast play, a seek), which keeps every point within about 10 m of SGP4; Starlink extras are sampled round-robin, more per frame the faster time plays, and a point more than two minutes from its sample waits for the next one. Hidden points are not moved until shown. 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. - **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. 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 - **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. 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. 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.
@@ -7614,7 +7463,7 @@ pins and share links already supply complete boxes.
30-second backoff ahead of the current clock are ignored. A smaller rollback 30-second backoff ahead of the current clock are ignored. A smaller rollback
can retain a bounded retry delay of up to 30 seconds. can retain a bounded retry delay of up to 30 seconds.
- Commercial and military aircraft use the same high-level FLEET model regime: 2D billboards when zoomed out, optional glTF models when closer, controlled by the DISPLAY rail `3D` toggle and `Proximity` / `All` modes. Every model is one of the project's procedural fleet models (`public/models/fleet/`, MIT, 122 GLBs, about 2 MB in all; `public/models/README.md`), chosen per aircraft by `aircraftModelSpec` (`src/data/fleetModels.js`): the first of its ICAO type's callsign rules in `types.json` (`aircraft_callsign`) its callsign matches, else its type's entry, else the model of its `classifyAircraft` class for a type the map lacks, else its ADS-B emitter category's, else the default airliner. It is drawn LIFE SIZE: the type's published length over the model's built length (`src/layers/flights/modelSpec.js`, shared by Flights, Military Flights and Local ADS-B), and a grounded aircraft stands the model's scaled clearance above the ground. An airport ground vehicle (feed type `ZVEH`) is a fire truck (`FIRE…`), pushback tug (`PUSH…`, `TUG…`), van (`DEICER…`, `SQUID…`, `SQB…`) or else a car by its callsign, and a ground station (`GRND`) is an 11.8 m radio mast; these models (catalog category `other`) are drawn at their built size, and such a contact with no altitude stands on the ground instead of at the airborne default (`src/layers/flights/records.js`). `classifyAircraft` still picks the 2D icons. The whole set loads once, up front, when a layer that draws it starts (`loadFleetModelSet`, which logs `[Fleet] N/122 models (X MB) loaded in N ms`); models are built from those bytes, never fetched mid-session, and an aircraft stays its billboard until the set has loaded. Every aircraft model draws its mesh's own colours (`aircraftModelBlend`, `src/data/aircraftModelLighting.js`): Cesium's `ColorBlendMode.HIGHLIGHT` with white, in Flights, Military Flights and Local ADS-B alike; the tracked colour (cyan, or amber in Military Flights) and the emergency red multiply into the mesh, so its paint still shows. IR boost draws them flat white (`MIX` at `1.0`). Under NVG/FLIR (map preset or Cockpit vision) models render unlit flat-white at full alpha and scene fog is disabled (fog otherwise blacks out distant models with the globe hidden); state restores on exit. - Commercial and military aircraft use the same high-level FLEET model regime: 2D billboards when zoomed out, optional glTF models when closer, controlled by the DISPLAY rail `3D` toggle and `Proximity` / `All` modes. Every model is one of the project's procedural fleet models (`public/models/fleet/`, MIT, 122 GLBs, about 2 MB in all; `public/models/README.md`), chosen per aircraft by `aircraftModelSpec` (`src/data/fleetModels.js`): the first of its ICAO type's callsign rules in `types.json` (`aircraft_callsign`) its callsign matches, else its type's entry, else the model of its `classifyAircraft` class for a type the map lacks, else its ADS-B emitter category's, else the default airliner. It is drawn LIFE SIZE: the type's published length over the model's built length (`src/layers/flights/modelSpec.js`, shared by Flights, Military Flights and Local ADS-B), and a grounded aircraft stands the model's scaled clearance above the ground. An airport ground vehicle (feed type `ZVEH`) is a fire truck (`FIRE…`), pushback tug (`PUSH…`, `TUG…`), van (`DEICER…`, `SQUID…`, `SQB…`) or else a car by its callsign, and a ground station (`GRND`) is an 11.8 m radio mast; these models (catalog category `other`) are drawn at their built size, and such a contact with no altitude stands on the ground instead of at the airborne default (`src/layers/flights/records.js`). `classifyAircraft` still picks the 2D icons. The whole set loads once, up front, when a layer that draws it starts (`loadFleetModelSet`, which logs `[Fleet] N/122 models (X MB) loaded in N ms`); models are built from those bytes, never fetched mid-session, and an aircraft stays its billboard until the set has loaded. Every aircraft model draws its mesh's own colours (`aircraftModelBlend`, `src/data/aircraftModelLighting.js`): Cesium's `ColorBlendMode.HIGHLIGHT` with white, in Flights, Military Flights and Local ADS-B alike; the tracked colour (cyan, or amber in Military Flights) and the emergency red multiply into the mesh, so its paint still shows. IR boost draws them flat white (`MIX` at `1.0`). Under NVG/FLIR (map preset or Cockpit vision) models render unlit flat-white at full alpha and scene fog is disabled (fog otherwise blacks out distant models with the globe hidden); state restores on exit.
- Vessels, Transit and Rail draw fleet models under the same DISPLAY rail `3D` toggle, at the same altitude ceiling, caps and add/keep radii as the aircraft (`FLEET_MODEL_POLICY`, `src/layers/flights/policy.js`; `createFleetModelLayer`, `src/data/fleetModelInstances.js`). A vessel's model follows its AIS ship type and is drawn at its reported length and beam (AIS static dimensions A+B and C+D, now carried on vessel rows as `length` and `beam`), standing at the sea surface, heading by true heading then course. An aid to navigation is not a hull: a vessel record whose type names a buoy, beacon, mark, RACON or light, or whose MMSI is an aid-to-navigation station's (`99MIDxxxx`), is `isAidToNavigation` (`src/data/vesselTypes.js`) and is drawn as the set's `buoys` model of the IALA mark its type names (`aidToNavigationModel`, `src/data/fleetModels.js`: cardinal N/E/S/W, port or starboard hand, preferred channel by its side, safe water, isolated danger, special mark; a beacon, light, light vessel, RACON or reference point with no mark of its own is the special mark), else a red can (`DEFAULT_AID_MODEL`), at its built size and facing north whatever heading it reports. A type is an aid's when it is one of the 32 kinds an aid report names or uses a word for one (buoy, beacon, cardinal, safe water, RACON, lighthouse and so on); a vessel that works on aids (`Buoy Tender`, `Buoy Layer`) is not one. Its icon is a bone-white ringed disc (`AID_TO_NAVIGATION_STYLE`, `src/data/vesselLabels.js`) instead of a chevron, never rotated, and its card, selected card and HUD readout (`src/layers/vessels/cards.js`) show its kind alone, whole, in that colour: no speed or heading, since a fixed mark has neither. AISStream subscribes to `AidsToNavigationReport` (message 21) by default, and the server names each report's aid type code (0 to 31) as its kind (`aidToNavigationTypeName`: `20` is `Cardinal Mark N`) rather than reading it as a ship type, and its name runs on into the report's `NameExtension`. A virtual aid (the report's `VirtualAtoN` flag: a mark broadcast where nothing physical stands) carries `virtual: true` on its row, and on its vessel source record (the live-source contract's `virtual`, `src/sources/live/contract.js`, false unless a source says true; any source may set it). It keeps its disc but never gets a model (`vesselModelSpec` returns null for it, and the vessel layer gathers no model item for it), and its card and readout add `VIRTUAL` after its kind. `aisStreamRows` (`server/providers/vessels/ais-store.js`) puts every vessel ahead of every aid, so a capped request (the layer asks for 12,000 rows) never gives up a ship for a buoy. The `vessels_in_area` tool (and the brief built on it) counts ships only: it leaves aids out of its rows, counts, breakdowns and speed stats. A transit vehicle's model follows its route's GTFS `route_type` when its record carries one and that type is a kind of the vehicle's mode (`types.json` `transit.route_types`: 200 a coach, 101 a high-speed set, 106 a regional DMU, 710 a double-decker), else its layer mode (`transit.modes`: bus, trolleybus, tram, cable car, subway, monorail, rail, funicular, gondola or ferry; a vehicle of `unknown` mode keeps its icon); a fleet hint that overrules the route (a bus on a tram route) leaves the type unused. A monorail and a gondola are raised by their `transit.elevation` lifts. A train's model follows its rail category and mode (rail, metro or tram). The bundled GTFS-Realtime proxy carries no route type, so its vehicles draw their mode's model; a source that adds `routeType` reaches the finer ones. Both stand on the road or rail top they are drawn on, without the 1.5 m `GROUND_FLOOR_LIFT_M` their icons carry, and are painted in their mode or category colour where the model's `_TINT` attribute marks it. A vessel model is clipped at its waterline (one clipping plane per model, at the model origin's horizontal plane), so its hull below the water never shows through a plain globe, which Cesium does not depth-test models against. Under the NVG/FLIR styles (the Flights `irBoost` parameter) these models render unlit flat white, as aircraft models do. Each vehicle's icon stays the visual until its model is loaded, ready and placed; a model is picked as its icon is, and a vehicle drawn as its model keeps its selection card and its place in the detection overlay. - Vessels, Transit and Rail draw fleet models under the same DISPLAY rail `3D` toggle, at the same altitude ceiling, caps and add/keep radii as the aircraft (`FLEET_MODEL_POLICY`, `src/layers/flights/policy.js`; `createFleetModelLayer`, `src/data/fleetModelInstances.js`). A vessel's model follows its AIS ship type and is drawn at its reported length and beam (AIS static dimensions A+B and C+D, now carried on vessel rows as `length` and `beam`), standing at the sea surface, heading by true heading then course. An aid to navigation is not a hull: a vessel record whose type names a buoy, beacon, mark, RACON or light, or whose MMSI is an aid-to-navigation station's (`99MIDxxxx`), is `isAidToNavigation` (`src/data/vesselTypes.js`) and is drawn as the set's `buoys` model of the IALA mark its type names (`aidToNavigationModel`, `src/data/fleetModels.js`: cardinal N/E/S/W, port or starboard hand, preferred channel by its side, safe water, isolated danger, special mark; a beacon, light, light vessel, RACON or reference point with no mark of its own is the special mark), else a red can (`DEFAULT_AID_MODEL`), at its built size and facing north whatever heading it reports. A type is an aid's when it is one of the 32 kinds an aid report names or uses a word for one (buoy, beacon, cardinal, safe water, RACON, lighthouse and so on); a vessel that works on aids (`Buoy Tender`, `Buoy Layer`) is not one. Its icon is a bone-white ringed disc (`AID_TO_NAVIGATION_STYLE`, `src/data/vesselLabels.js`) instead of a chevron, never rotated, and its card, selected card and HUD readout (`src/layers/vessels/cards.js`) show its kind alone, whole, in that colour: no speed or heading, since a fixed mark has neither. AISStream subscribes to `AidsToNavigationReport` (message 21) by default, and the server names each report's aid type code (0 to 31) as its kind (`aidToNavigationTypeName`: `20` is `Cardinal Mark N`) rather than reading it as a ship type, and its name runs on into the report's `NameExtension`. A virtual aid (the report's `VirtualAtoN` flag: a mark broadcast where nothing physical stands) carries `virtual: true` on its row, and on its vessel source record (the live-source contract's `virtual`, `src/sources/live/contract.js`, false unless a source says true; any source may set it). It keeps its disc but never gets a model (`vesselModelSpec` returns null for it, and the vessel layer gathers no model item for it), and its card and readout add `VIRTUAL` after its kind. `aisStreamRows` (`server/providers/vessels/ais-store.js`) puts every vessel ahead of every aid, so a capped request (the layer asks for 12,000 rows) never gives up a ship for a buoy. The `vessels_in_area` tool (and the brief built on it) counts ships only: it leaves aids out of its rows, counts, breakdowns and speed stats. A transit vehicle's model follows its layer mode (bus, tram, subway, rail or ferry; a vehicle of `unknown` mode keeps its icon), a train's its rail category and mode (rail, metro or tram). Neither the transit nor the rail sources carry a GTFS `route_type` or a finer mode (monorail, gondola, funicular, cable car), so the set's models for those and their `transit.elevation` lifts are not reached by any live source today. Both stand on the road or rail top they are drawn on, without the 1.5 m `GROUND_FLOOR_LIFT_M` their icons carry, and are painted in their mode or category colour where the model's `_TINT` attribute marks it. A vessel model is clipped at its waterline (one clipping plane per model, at the model origin's horizontal plane), so its hull below the water never shows through a plain globe, which Cesium does not depth-test models against. Under the NVG/FLIR styles (the Flights `irBoost` parameter) these models render unlit flat white, as aircraft models do. Each vehicle's icon stays the visual until its model is loaded, ready and placed; a model is picked as its icon is, and a vehicle drawn as its model keeps its selection card and its place in the detection overlay.
- **Aircraft models skip Cesium's dynamic environment map (2026-10-24).** Flights, Military Flights and Local ADS-B pass `environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS` (`{ enabled: false }`, `src/data/aircraftModelLighting.js`) to every drawn model. Otherwise each `Model` renders the sky around it and reads it back from the GPU (`readPixels`) when it is first placed, and again whenever it moves more than 1 km, the scene's atmosphere settings change, or (under sunlight dynamic lighting) an hour of scene time passes (`DynamicEnvironmentMapManager`, Cesium 1.138). The models are lit from Cesium's default spherical harmonics instead, with no specular environment map, so the sky's model-lighting cube map no longer reaches aircraft. `src/data/aircraftModelLighting.test.mjs` pins the option at every drawn model call; every model is built from the fleet set loaded up front, so there is no separate preload. - **Aircraft models skip Cesium's dynamic environment map (2026-10-24).** Flights, Military Flights and Local ADS-B pass `environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS` (`{ enabled: false }`, `src/data/aircraftModelLighting.js`) to every drawn model. Otherwise each `Model` renders the sky around it and reads it back from the GPU (`readPixels`) when it is first placed, and again whenever it moves more than 1 km, the scene's atmosphere settings change, or (under sunlight dynamic lighting) an hour of scene time passes (`DynamicEnvironmentMapManager`, Cesium 1.138). The models are lit from Cesium's default spherical harmonics instead, with no specular environment map, so the sky's model-lighting cube map no longer reaches aircraft. `src/data/aircraftModelLighting.test.mjs` pins the option at every drawn model call; every model is built from the fleet set loaded up front, so there is no separate preload.
- The DISPLAY-rail `3D` toggle is the user-facing activation path for both aircraft layers. Their small approved GLBs build their render resources without Cesium's frame-spread job queue, preventing continuous Photorealistic 3D Tiles streaming from starving model readiness; model caps, tracking, camera, and fallback billboards are unchanged. - The DISPLAY-rail `3D` toggle is the user-facing activation path for both aircraft layers. Their small approved GLBs build their render resources without Cesium's frame-spread job queue, preventing continuous Photorealistic 3D Tiles streaming from starving model readiness; model caps, tracking, camera, and fallback billboards are unchanged.
- **The `3D` toggle DEFAULTS ON in `proximity` on a first run.** Proximity is itself the budget — models appear only below `MODEL_ALT_CEIL_M` and only for the nearest `MODEL_MAX` in view — so the default costs nothing at globe scale, and `all` remains a deliberate opt-in. A fresh boot runs NO layer-state restoration (`LayerStateCoordinator.start()` returns early with neither a share payload nor stored state), so four independent initializers decide what a first-run operator sees and must agree: `booleanOption('models3d', 'e', true)` in `src/data/layerState.js`, `_models3dEnabled = true` in BOTH flight layers, `this._models3dEnabled = true` in `src/ui/aircraftDisplay.js`, and the `active` / `visible` classes on `#models3d-toggle` / `#models3d-mode-row` in `index.html`. All four are pinned together in `src/data/layerState.test.mjs`. Explicit state still wins: because the codec omits default-valued options, `models3d: false` is now what travels in a link (`lo=…f.e.0`) and restores OFF at both aircraft layers. **Consequence for returning users:** a stored `gev:layer-state:v2` blob is a FULL options snapshot, so a session that wrote one before this change carries `models3d:false` and keeps 3D off until the operator flips it (or clears the key) — the durable snapshot is treated as the recipient's own state, by design. - **The `3D` toggle DEFAULTS ON in `proximity` on a first run.** Proximity is itself the budget — models appear only below `MODEL_ALT_CEIL_M` and only for the nearest `MODEL_MAX` in view — so the default costs nothing at globe scale, and `all` remains a deliberate opt-in. A fresh boot runs NO layer-state restoration (`LayerStateCoordinator.start()` returns early with neither a share payload nor stored state), so four independent initializers decide what a first-run operator sees and must agree: `booleanOption('models3d', 'e', true)` in `src/data/layerState.js`, `_models3dEnabled = true` in BOTH flight layers, `this._models3dEnabled = true` in `src/ui/aircraftDisplay.js`, and the `active` / `visible` classes on `#models3d-toggle` / `#models3d-mode-row` in `index.html`. All four are pinned together in `src/data/layerState.test.mjs`. Explicit state still wins: because the codec omits default-valued options, `models3d: false` is now what travels in a link (`lo=…f.e.0`) and restores OFF at both aircraft layers. **Consequence for returning users:** a stored `gev:layer-state:v2` blob is a FULL options snapshot, so a session that wrote one before this change carries `models3d:false` and keeps 3D off until the operator flips it (or clears the key) — the durable snapshot is treated as the recipient's own state, by design.
@@ -8199,7 +8048,7 @@ The Space Missions roster prioritizes data-rich records using available mission,
When no live catalog track is available, the mission view marks the approximate orbital ring as `PROJECTED ORBIT` in purple and renders the ascent-to-insertion transfer in green; catalog-backed satellite orbits remain cyan. When no live catalog track is available, the mission view marks the approximate orbital ring as `PROJECTED ORBIT` in purple and renders the ascent-to-insertion transfer in green; catalog-backed satellite orbits remain cyan.
The replay vehicle is a smaller solid cyan silhouette without the former orange flame; its initial pad anchor uses photoreal terrain, globe height, or launch elevation fallback so it remains above the surface during tile loading. Replay begins at a close launch-complex range so pad detail remains visible before the camera widens into the ascent context view. Its initial camera heading is perpendicular to the ascent/orbit direction for a profile view, then eases into tangent tracking. Small screen-space reprojection changes are damped for the animated marker on both ascent and orbit, while large camera or phase changes snap to the authoritative path position. The replay vehicle is a smaller solid cyan silhouette without the former orange flame; its initial pad anchor uses photoreal terrain, globe height, or launch elevation fallback so it remains above the surface during tile loading. Replay begins at a close launch-complex range so pad detail remains visible before the camera widens into the ascent context view. Its initial camera heading is perpendicular to the ascent/orbit direction for a profile view, then eases into tangent tracking. Small screen-space reprojection changes are damped for the animated marker on both ascent and orbit, while large camera or phase changes snap to the authoritative path position.
Space Missions keeps the Satellite layer available for catalog/TLE matching but suppresses its standalone fleet points and orbit rings. While those visuals are hidden, their sampling, per-frame point moves, and one-second orbit-matrix rotation are suspended; selected mission telemetry continues to propagate independently. The selected mission's live or estimated satellite marker uses fractional wall-clock time, so it moves continuously rather than creating a once-per-second position discontinuity and one-frame photoreal globe LOD pulse. Space Missions keeps the Satellite layer available for catalog/TLE matching but suppresses its standalone fleet points and orbit rings. While those visuals are hidden, their per-frame dense propagation, one-second core point-buffer rewrite, and one-second orbit-matrix rotation are suspended; selected mission telemetry continues to propagate independently. The selected mission's live or estimated satellite marker uses fractional wall-clock time, so it moves continuously rather than creating a once-per-second position discontinuity and one-frame photoreal globe LOD pulse.
During ascent replay, the camera, Cesium callbacks, and HTML vehicle overlay share one replay sample per rendered frame. The tracked overlay is projected directly from that shared position instead of applying a second screen-space lag filter, preventing the vehicle and globe from repeatedly advancing and snapping back. During ascent replay, the camera, Cesium callbacks, and HTML vehicle overlay share one replay sample per rendered frame. The tracked overlay is projected directly from that shared position instead of applying a second screen-space lag filter, preventing the vehicle and globe from repeatedly advancing and snapping back.
@@ -8541,63 +8390,24 @@ Wind supports selectable GFS/IFS models with persisted model choice. Each fetch
Weather combines the GFS/ECMWF surface forecast with three observation layers: Weather combines the GFS/ECMWF surface forecast with three observation layers:
NOAA Rain radar (CONUS), Satellite clouds (regional GOES or slower global infrared), NOAA Rain radar (CONUS), Satellite clouds (regional GOES or slower global infrared),
and 15-minute lightning density (Americas and Pacific). One transient observed and 15-minute lightning density (Americas and Pacific). One transient observed
clock moves all enabled observation rows with the world clock; the world clock drives all enabled observation rows. Earlier/Later use the sorted union of
timeline is the only time control. Each product's advertised times span time on non-suspended products' advertised times; each product selects its nearest frame
that timeline but are not marks on it: the timeline marks only events (launches, at or before the requested UTC time, never a future frame or an interpolation.
and earthquakes of magnitude 6 and up while that layer is on), so its arrow keys
step to those, or along the track where there are none. A mark may carry a
label (a launch's mission name, cut to 27 characters in capitals as the mission
index shows names; an earthquake's magnitude and place). Marks merge on their
whole label; one longer than 60 characters (graphemes, so an emoji is never
split) then shows cut, ending in `…`, and a label is never wider than the
track. A zoom or
a pan clears a hovered label. The pointer within
6 px of a tick shows its label over the track, and a tap there goes to the
mark's time exactly; a tap elsewhere goes to the time under it and a drag
scrubs. With the handle on a mark, its label follows the time in the handle's
`aria-valuetext` and shows over the track while the handle has focus. The
coverage contract is in [APPLICATION.md](./APPLICATION.md). A weather row's `step`
param goes to the previous or next weather frame. Each product selects its
nearest frame at or before the world time, never a future frame or an
interpolation.
The maximum gap is 30 minutes for radar, regional infrared and lightning, and The maximum gap is 30 minutes for radar, regional infrared and lightning, and
3 hours for hourly global infrared, but never less than one and a half of the 3 hours for hourly global infrared. A missing eligible frame hides that product
feed's median frame spacing over its latest frames, so a regional feed whose
frames come an hour apart holds each for 90 minutes; the latest frame reads late
after the same floor (20 minutes for radar and regional infrared, 45 for
lightning, 4 hours for global infrared). A missing eligible frame hides that product
and reports the gap; rows show actual displayed UTC time with synced/nearest labels. and reports the gap; rows show actual displayed UTC time with synced/nearest labels.
Manifest rollover preserves the requested history time, even when frames expire. Manifest rollover preserves the requested history time, even when frames expire.
Live follows each product's own newest frame, so its timestamps can differ. Latest follows each product's own newest frame, so its timestamps can differ.
The timeline's Play runs the world clock at the chosen speed and stops at each Playback advances two seconds after all products settle, wraps, and stops with
product's next frame until that frame loads (for at most eight seconds), so fewer than two union times or when all products are suspended. Hidden documents,
every frame shows. Hidden documents, reduced motion and unavailable imagery reduced motion and unavailable imagery hosts suspend products. Re-enabling or
hosts suspend products. Re-enabling or
restoring a host joins the retained history target. The wind row explicitly says restoring a host joins the retained history target. The wind row explicitly says
“Forecast · does not follow history” while the observed clock is in history mode. “Forecast · does not follow history” while the observed clock is in history mode.
The Weather panel's frame time shows the newest frame and its age while live The catalog exposes `weatherClock` and destroys it with its lifetime signal.
(each product's newest, suspended products included), and the timeline time
alone at a past time.
The catalog exposes `weatherClock` and destroys it with its lifetime signal. It
has no time controls of its own: `weatherClock.world` is the world clock it
follows, where time moves. A weather row's `play`, `latest` and `step` params
move that world clock as the timeline does: `play` runs at the speed chosen on
the timeline (the world clock keeps its rate while paused and live, and the
timeline's speed selector shows the clock's rate, or the nearest offered speed
for a rate a host set), from the start of the covered time when live or at its
end, and does nothing while every product the row shows is suspended or while
there is no covered time. A weather layer built without a
shared clock builds one of its own on the page's world clock when first
enabled (`createWeatherClock({ shared: false })`) and keeps it for its
lifetime; destroying it leaves the world's time where it is, and enabling it
after that does nothing, and only the last
shared weather clock destroyed sends the world live. Neither has a frame replay
of its own.
Source, opacity and coverage navigation live in the WEATHER panel's cards, not Source, opacity, coverage navigation and transport controls live in the WEATHER
in the rows; the panel shows the frame time and has no time controls. The panel's cards and timeline, not in the rows. The viewer clock is untouched.
viewer clock is untouched.
Observation layers are off by default; share/persist includes appearance and product, while history Observation layers are off by default; share/persist includes appearance and product, while history
is transient and shared links open latest. Rendering retains the previous frame is transient and shared links open latest. Rendering retains the previous frame
when an incoming frame fails; no eligible historical frame instead hides imagery. when an incoming frame fails; no eligible historical frame instead hides imagery.
-15
View File
@@ -83,21 +83,6 @@ menu.onChoose = (code) => switchLocale(code); // switched in place and stored (i
`switchLocale` does what a choice in the globe picker does, and returns `'unchanged'` for the active language. `onLocaleChange` tells the application's own menu to mark the new language. `switchLocale` does what a choice in the globe picker does, and returns `'unchanged'` for the active language. `onLocaleChange` tells the application's own menu to mark the new language.
The same entry exports the translator Core's text goes through (`t`, `tn`, `msg`, `translateKnown`, `formatNumber`, `formatDate`, `formatRelativeTime`), so the application's own text follows the same language. To show its own wording for one of Core's messages, it overrides it by the catalog key, in English and in any locale it words:
```js
import { setMessageOverrides } from '<core package>/i18n';
setMessageOverrides({
en: { 'Download scene JSON': 'Download scene file' },
de: { 'Download scene JSON': 'Szenendatei herunterladen' },
});
```
Keys are the catalogs' (`context|text` for a context, the English plural for `tn`). A `t` entry is text; a `tn` entry is `{one, other, …}` forms, chosen by the plural rules of the locale they are worded in; an entry of the other kind is ignored. An overridden message shows the application's text in the active locale, else its English: an English-only override replaces Core's translations of that message too, so word every locale that should keep its own. Each call replaces the earlier overrides and `{}` removes them.
Call it before views render. `onLocaleChange` listeners word their text again at once, but a view already drawn keeps its text until it next renders. Import it from the `./i18n` entry only: a second copy of the module (a path alias into Core's sources) keeps its own overrides and language.
## Rules ## Rules
- **Pass literals.** `t('Close')`, not `t(label)` (unless `label` holds a `msg()` value) and never a template literal with `${…}`. - **Pass literals.** `t('Close')`, not `t(label)` (unless `label` holds a `msg()` value) and never a template literal with `${…}`.
+1
View File
@@ -34,6 +34,7 @@ const layers = createInfrastructureLayers({
| `clearSelectedEntityContextForLayer` | `(layerId)`; clear selection only if owned by this layer | | `clearSelectedEntityContextForLayer` | `(layerId)`; clear selection only if owned by this layer |
| `removeEntityContextsForLayer` | `(layerId)`; remove records owned by the layer on destruction or failed setup | | `removeEntityContextsForLayer` | `(layerId)`; remove records owned by the layer on destruction or failed setup |
| `governorRequestRender` | `(reason)`; request a frame through the existing render scheduler | | `governorRequestRender` | `(reason)`; request a frame through the existing render scheduler |
| `terrainSurface` | Optional. The application's shared terrain surface (`groundFloor.terrainSurface`), so its layers read each terrain height once; without it each layer keeps its own |
Each returned layer supports `init(viewer)`, `enable(viewer)`, `disable(viewer)`, Each returned layer supports `init(viewer)`, `enable(viewer)`, `disable(viewer)`,
`update(viewer)`, `destroy(viewer)`, `getStats()`, `getLodDiagnostics()` and `update(viewer)`, `destroy(viewer)`, `getStats()`, `getLodDiagnostics()` and
-5
View File
@@ -148,7 +148,6 @@ probability of about 255 in 256 (many of them already fail as base64url).
| 27 | `styleParams` | LEN | style | packed `(token, value × 100)` pairs | | 27 | `styleParams` | LEN | style | packed `(token, value × 100)` pairs |
| 28 | `worldTime` | epoch | none (live) | zigzag seconds since 2026-01-01T00:00:00Z | | 28 | `worldTime` | epoch | none (live) | zigzag seconds since 2026-01-01T00:00:00Z |
| 29 | `library` | LEN | none | Data Library ledger tokens, two base-36 characters each, concatenated | | 29 | `library` | LEN | none | Data Library ledger tokens, two base-36 characters each, concatenated |
| 30 | `scopeOpacityPct` | uint | 100 | 0–100, how strongly the scope mask shows |
Field 1 absent means `thing` when field 13 is present and `place` otherwise, Field 1 absent means `thing` when field 13 is present and `place` otherwise,
so v1 never writes it; a later view kind writes its code. so v1 never writes it; a later view kind writes its code.
@@ -171,10 +170,6 @@ tokens in `src/library/ledger.json` (the long form's `lib=ab.cd`). A share
without it switches no library entry on; the recipient's added entries stay without it switches no library entry on; the recipient's added entries stay
in their panel, off. Tokens this build does not know are skipped. in their panel, off. Tokens this build does not know are skipped.
Field 30 is how strongly the scope mask shows, from the Visual panel's
Opacity slider (the long form's `sco`). It is written only below 100, so a
link made before it, which opens at full opacity, keeps its code.
Flag bits (field 9): 0 `bloom`, 1 `sharpen`, 2 `hudVisible`, 3 Flag bits (field 9): 0 `bloom`, 1 `sharpen`, 2 `hudVisible`, 3
`celestialRing` (each defaulting by style), 4 `scopeEnabled` (default on). `celestialRing` (each defaulting by style), 4 `scopeEnabled` (default on).
+3 -3
View File
@@ -153,9 +153,9 @@ a feature supplying only its accent.
`t()`, which follows the locale as the view renders; the views carry no `t()`, which follows the locale as the view renders; the views carry no
`data-i18n*` markers, which `npm run i18n` rejects in a template. Shared builders become components first and `data-i18n*` markers, which `npm run i18n` rejects in a template. Shared builders become components first and
keep their imperative entry points until their owners are components: keep their imperative entry points until their owners are components:
the rail cards and card blocks (`src/ui/rail/`) render through the rail cards, card blocks and timeline (`src/ui/rail/`) render through
Vue's synchronous `render()` behind `createRailCards()` and Vue's synchronous `render()` behind `createRailCards()`,
`createRailCardBlocks()`. Order: Scenes (done: `SceneOptions` and `createRailCardBlocks()` and `createRailTimeline()`. Order: Scenes (done: `SceneOptions` and
`SceneShotList` over the `gev-scenes` store; the panel's buttons, status `SceneShotList` over the `gev-scenes` store; the panel's buttons, status
line, progress bar and the runtime chip render the panel SceneControls line, progress bar and the runtime chip render the panel SceneControls
presents there, and the buttons and file chooser call its actions), Weather (done: the owner presents there, and the buttons and file chooser call its actions), Weather (done: the owner
+1 -1
View File
@@ -26,7 +26,7 @@
with the sources: src/materialSymbolsSubset.test.mjs fails when a source 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 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. --> 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,align_horizontal_left,align_horizontal_right,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,bug_report,category,chevron_left,chevron_right,close,close_fullscreen,dark_mode,delete,directions_boat,draw,east,eco,egg_alt,filter_list,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,palette,partly_cloudy_day,pause,pentagon,pest_control,pets,play_arrow,public,question_mark,radar,radio,radio_button_checked,radio_button_unchecked,raven,restart_alt,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,set_meal,settings,skip_next,skip_previous,streetview,timeline,traffic,tune,umbrella,upload_file,videocam,view_in_ar,visibility,visibility_off,water_drop,zoom_in_map" 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,bug_report,category,chevron_left,chevron_right,close,close_fullscreen,dark_mode,delete,directions_boat,draw,east,eco,egg_alt,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,pest_control,pets,play_arrow,public,question_mark,radar,radio,raven,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,set_meal,settings,skip_next,skip_previous,streetview,timeline,traffic,tune,umbrella,upload_file,videocam,view_in_ar,visibility,visibility_off,water_drop,zoom_in_map" rel="stylesheet" />
</head> </head>
<body> <body>
<div class="gev-view" data-gev-view="scene-chrome"></div> <div class="gev-view" data-gev-view="scene-chrome"></div>
-455
View File
@@ -1,455 +0,0 @@
#!/usr/bin/env node
/**
* Build the bundled world cities pack read by `src/data/wikidataCities.js`:
*
* src/data/local_data/wikidata_cities/cities.json
*
* Source: Wikidata (CC0), read once from the public query service at build
* time. The app itself never calls Wikidata: it reads this file.
*
* What goes in: items that are a human settlement (an instance of any subclass
* of Q486972), have coordinates (P625) and a population (P1082) at or above
* the cut-off. Each row keeps the English label, coordinates to three
* decimals, the largest stated population, the ISO 3166-1 alpha-2 country
* code and the Wikidata id. Groupings of settlements (metropolitan areas,
* agglomerations, conurbations) and places that no longer stand are left out.
*
* The query service limits each query to 60 s, so the population range is
* read in bands without labels, the settlement test runs here against the
* class list, and labels and countries are read afterwards in batches. One
* request runs at a time, with a pause between requests, and a refusal's
* Retry-After is honoured.
*
* Usage:
* node scripts/build-wikidata-cities.mjs [--min-population 15000]
*/
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ENDPOINT = 'https://query.wikidata.org/sparql';
const USER_AGENT =
'gods-eye-view-places-builder/0.2 (offline data pack; one-off build; https://github.com/bilawalsidhu/gods-eye-view)';
const SETTLEMENT = 'Q486972';
const PAUSE_MS = 1500;
const LABEL_BATCH = 400;
const args = process.argv.slice(2);
const flag = (name) =>
args.includes(name) ? Number(args[args.indexOf(name) + 1]) : NaN;
const minPopulation = flag('--min-population') || 15000;
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const outDir = path.join(root, 'src/data/local_data/wikidata_cities');
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const qid = (uri) => uri.slice(uri.lastIndexOf('/') + 1);
/** The service ran out of time on a query: a smaller one may still answer. */
class QueryTimeout extends Error {}
async function sparql(query, attempt = 1) {
let response;
try {
response = await fetch(ENDPOINT, {
method: 'POST',
headers: {
'User-Agent': USER_AGENT,
Accept: 'application/sparql-results+json',
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `query=${encodeURIComponent(query)}`,
// The service stops a query at 60 s; past that the answer is lost.
signal: AbortSignal.timeout(90000),
});
} catch (error) {
// Our own time limit: the query is too big, as with a 504.
if (error.name === 'TimeoutError' && attempt >= 2)
throw new QueryTimeout('Wikidata did not answer in time');
if (attempt >= 4) throw error;
console.warn(` ${error.message}; waiting ${20 * attempt} s`);
await sleep(20000 * attempt);
return sparql(query, attempt + 1);
}
if (response.status === 504 && attempt >= 2)
throw new QueryTimeout('Wikidata answered 504');
// A malformed query fails the same way every time.
if (
response.status >= 400 &&
response.status < 500 &&
response.status !== 429
)
throw new Error(
`Wikidata answered ${response.status}: ${(await response.text()).slice(0, 300)}`,
);
if (!response.ok) {
if (attempt >= 4) throw new Error(`Wikidata answered ${response.status}`);
const wait =
Number(response.headers.get('retry-after')) * 1000 || 20000 * attempt;
console.warn(` ${response.status}; waiting ${Math.round(wait / 1000)} s`);
await sleep(wait);
return sparql(query, attempt + 1);
}
// The service can stop a long answer partway; that arrives as broken JSON.
let rows;
try {
rows = JSON.parse(await response.text()).results.bindings;
} catch (error) {
// The service stops a query that runs too long partway through its answer.
if (attempt >= 2)
throw new QueryTimeout(`Wikidata answer was cut short: ${error.message}`);
console.warn(` answer cut short; waiting ${20 * attempt} s`);
await sleep(20000 * attempt);
return sparql(query, attempt + 1);
}
await sleep(PAUSE_MS);
return rows;
}
/**
* Population bands, narrow where places are many, so each answer stays well
* under the service's 60 s limit (about 35,000 places a band).
*/
function bands(min, max) {
const stepAt = (pop) =>
pop < 2000
? 200
: pop < 5000
? 500
: pop < 10000
? 1000
: pop < 20000
? 2500
: pop < 30000
? 10000
: pop < 50000
? 20000
: pop < 100000
? 50000
: Infinity;
const list = [];
for (let low = min; low < max;) {
const high = Math.min(max, low + stepAt(low));
list.push([low, high]);
low = high;
}
return list;
}
console.log('Settlement classes…');
const classes = new Set(
(await sparql(`SELECT ?c WHERE { ?c wdt:P279* wd:${SETTLEMENT} }`)).map(
(row) => qid(row.c.value),
),
);
console.log(` ${classes.size} classes`);
// Groupings of settlements (metropolitan areas, agglomerations, conurbations,
// capital regions)
// are settlements in Wikidata too, and are left out so a view is named after
// the city rather than the area around it. An item stated as one of these
// classes goes, whatever else it is ("New York metropolitan area" is also a
// megacity). An item of a narrower kind only goes when it is nothing else:
// Istanbul is a "metropolitan municipality in Turkey", a kind of
// metropolitan area, and a megacity, so it stays.
const GROUPING_TREES = [
'Q1907114', // metropolitan area
'Q159313', // urban agglomeration
'Q245260', // conurbation
'Q1062177', // capital region
'Q1768043', // metropolitan statistical area
'Q3175539', // functional urban area
];
// Left out as stated only: "city in the United States" is a subclass of
// these, so their subclasses must stay.
const GROUPING_CLASSES = [
'Q702492', // urban area
'Q2342385', // urban area (statistical)
'Q5951278', // urban area of the United States
];
// Places that no longer stand as settlements (former entities, ghost towns,
// abandoned villages, archaeological sites) are settlements in Wikidata too.
// An item typed only as one of these, with every subclass, is left out; one
// that is also a living settlement (a city that is also a former capital)
// stays.
const HISTORICAL_TREES = [
'Q15893266', // former entity
'Q19953632', // former administrative territorial entity
'Q74047', // ghost town
'Q350895', // abandoned village
'Q839954', // archaeological site
];
const former = new Set(
(
await sparql(
`SELECT ?c WHERE { VALUES ?top { ${HISTORICAL_TREES.map((id) => `wd:${id}`).join(' ')} } ?c wdt:P279* ?top }`,
)
).map((row) => qid(row.c.value)),
);
console.log(` ${former.size} former-place classes`);
const groupings = new Set([
...(
await sparql(
`SELECT ?c WHERE { VALUES ?top { ${GROUPING_TREES.map((id) => `wd:${id}`).join(' ')} } ?c wdt:P279* ?top }`,
)
).map((row) => qid(row.c.value)),
...GROUPING_CLASSES,
]);
console.log(` ${groupings.size} grouping classes left out`);
// A band's rows; one the service cannot answer in time is asked again in two
// halves.
async function bandRows(low, high) {
const range = Number.isFinite(high)
? `?pop >= ${low} && ?pop < ${high}`
: `?pop >= ${low}`;
try {
return await sparql(
`SELECT ?item ?pop ?coord ?type WHERE { ?item wdt:P1082 ?pop . FILTER(${range}) ?item wdt:P625 ?coord ; wdt:P31 ?type . }`,
);
} catch (error) {
const mid = Number.isFinite(high) ? Math.round((low + high) / 2) : low * 4;
if (!(error instanceof QueryTimeout) || mid <= low || mid === high)
throw error;
console.warn(` splitting ${low}–${high} at ${mid}`);
return [...(await bandRows(low, mid)), ...(await bandRows(mid, high))];
}
}
const GROUPING_STATED = new Set([...GROUPING_TREES, ...GROUPING_CLASSES]);
const living = new Set();
const stated = new Set();
const grouped = new Set();
const settled = new Set();
const places = new Map();
for (const [low, high] of bands(minPopulation, Infinity)) {
console.log(`Population ${low}–${Number.isFinite(high) ? high : '∞'}…`);
const rows = await bandRows(low, high);
let kept = 0;
for (const row of rows) {
const type = qid(row.type.value);
if (GROUPING_STATED.has(type)) stated.add(qid(row.item.value));
if (groupings.has(type)) grouped.add(qid(row.item.value));
if (!classes.has(type)) continue;
if (!groupings.has(type) && !former.has(type))
settled.add(qid(row.item.value));
if (!former.has(type)) living.add(qid(row.item.value));
const match = /^Point\(([-\d.eE]+) ([-\d.eE]+)\)$/.exec(row.coord.value);
if (!match) continue;
const id = qid(row.item.value);
const pop = Math.round(Number(row.pop.value));
const known = places.get(id);
if (known) {
known.pop = Math.max(known.pop, pop);
// Of several stated coordinates, the same one on every build.
const lon = Number(match[1]);
const lat = Number(match[2]);
if (lat < known.lat || (lat === known.lat && lon < known.lon)) {
known.lat = lat;
known.lon = lon;
}
continue;
}
places.set(id, { id, pop, lon: Number(match[1]), lat: Number(match[2]) });
kept++;
}
console.log(` ${rows.length} rows, ${kept} new settlements`);
}
let groupingsLeftOut = 0;
for (const id of grouped) {
if ((settled.has(id) && !stated.has(id)) || !places.delete(id)) continue;
groupingsLeftOut++;
}
console.log(`${groupingsLeftOut} groupings left out`);
// A place typed only as a former kind is held until its end date is known: a
// unit dissolved in a recent reform still stands (below).
for (const [id, place] of places) if (!living.has(id)) place.formerOnly = true;
const ids = [...places.keys()];
console.log(`Labels and countries for ${ids.length} places…`);
for (let start = 0; start < ids.length; start += LABEL_BATCH) {
const values = ids
.slice(start, start + LABEL_BATCH)
.map((id) => `wd:${id}`)
.join(' ');
const rows = await sparql(
`SELECT ?item ?label ?iso ?ended WHERE { VALUES ?item { ${values} } OPTIONAL { ?item rdfs:label ?label . FILTER(LANG(?label) = "en") } OPTIONAL { ?item wdt:P17 ?country . ?country wdt:P297 ?iso } OPTIONAL { ?item wdt:P576 ?ended } }`,
);
for (const row of rows) {
const place = places.get(qid(row.item.value));
if (!place) continue;
if (row.label && !place.name) place.name = row.label.value;
// Of several stated countries, the same one on every build.
if (row.iso && (!place.cc || row.iso.value < place.cc))
place.cc = row.iso.value;
// Dissolved, abolished or merged as a unit (P576), and when.
if (row.ended) {
place.ended = true;
const year = Number(/^-?\d+/.exec(row.ended.value)?.[0]);
if (Number.isFinite(year))
place.endedYear = Math.max(place.endedYear ?? -Infinity, year);
}
}
if ((start / LABEL_BATCH) % 10 === 0)
console.log(
` ${Math.min(start + LABEL_BATCH, ids.length)} / ${ids.length}`,
);
}
// A dissolved place goes only where another place still names the spot (a
// city merged into its neighbour, a former district inside its city). Where
// nothing else does, the town is still there under its name and stays: many
// of Vietnam's provincial cities ended as units in 2025, and with them their
// class, so they are typed only as a former kind. A place typed only as a
// former kind stays only that way: dissolved within the last ten years with
// nothing else near it. Ghost towns and ancient cities go.
const SUCCESSOR_KM = 15;
const RECENT_YEARS = 10;
const recent = new Date().getUTCFullYear() - RECENT_YEARS;
const standing = [...places.values()].filter(
(place) => place.name && !place.ended && !place.formerOnly,
);
const near = (a, b) => {
const rad = Math.PI / 180;
const h =
Math.sin(((b.lat - a.lat) * rad) / 2) ** 2 +
Math.cos(a.lat * rad) *
Math.cos(b.lat * rad) *
Math.sin(((b.lon - a.lon) * rad) / 2) ** 2;
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(h))) <= SUCCESSOR_KM;
};
const byCell = new Map();
const cellOf = (lat, lon) => `${Math.floor(lat)},${Math.floor(lon)}`;
for (const place of standing) {
const key = cellOf(place.lat, place.lon);
if (!byCell.has(key)) byCell.set(key, []);
byCell.get(key).push(place);
}
let dissolved = 0;
let formerOnly = 0;
for (const [id, place] of places) {
if (place.formerOnly && !(place.ended && place.endedYear >= recent)) {
places.delete(id);
formerOnly++;
continue;
}
if (!place.ended) continue;
let succeeded = false;
for (let dLat = -1; dLat <= 1 && !succeeded; dLat++)
for (let dLon = -1; dLon <= 1 && !succeeded; dLon++)
succeeded = (
byCell.get(cellOf(place.lat + dLat, place.lon + dLon)) || []
).some((other) => near(place, other));
if (succeeded) dissolved++;
else place.ended = false;
}
console.log(`${formerOnly} former places left out`);
console.log(`${dissolved} dissolved places left out`);
// A place whose stated country is a former one (no ISO code) takes the
// country of the area it lies in, one area level up at a time (a path of
// several levels in one query runs past the service's time limit).
const COUNTRY_BATCH = 50;
const countryless = () =>
[...places.values()]
.filter((place) => place.name && !place.cc && !place.ended)
.map((place) => place.id);
for (const hops of [1, 2, 3]) {
const ids = countryless();
if (!ids.length) break;
console.log(`Countries ${hops} area level(s) up for ${ids.length} places…`);
const pathToArea = Array.from({ length: hops }, () => 'wdt:P131').join('/');
for (let start = 0; start < ids.length; start += COUNTRY_BATCH) {
const values = ids
.slice(start, start + COUNTRY_BATCH)
.map((id) => `wd:${id}`)
.join(' ');
const rows = await sparql(
`SELECT ?item ?iso WHERE { VALUES ?item { ${values} } ?item ${pathToArea} ?area . ?area wdt:P17 ?country . ?country wdt:P297 ?iso }`,
);
for (const row of rows) {
const place = places.get(qid(row.item.value));
if (!place || (place.cc && place.hops !== hops)) continue;
if (!place.cc || row.iso.value < place.cc) {
place.cc = row.iso.value;
place.hops = hops;
}
}
}
}
console.log(` ${countryless().length} places still without a country`);
const rows = [...places.values()]
.filter(
(place) =>
place.name &&
!place.ended &&
Math.abs(place.lat) <= 90 &&
Math.abs(place.lon) <= 180,
)
.sort((a, b) => b.pop - a.pop || a.id.localeCompare(b.id))
.map((place) => [
place.name,
Number(place.lat.toFixed(3)),
Number(place.lon.toFixed(3)),
place.pop,
place.cc || '',
place.id,
]);
// A filter that is too wide drops real cities without any error; these must
// survive, and no settlement grouping may.
const MUST_HAVE = {
Q1490: 'Tokyo',
Q84: 'London',
Q16559: 'Austin',
Q16555: 'Houston',
Q1355: 'Bengaluru',
Q3630: 'Jakarta',
Q174: 'São Paulo',
Q34600: 'Kyoto',
Q656: 'Saint Petersburg',
Q406: 'Istanbul',
Q8678: 'Rio de Janeiro',
Q1345: 'Philadelphia',
Q586: 'Bonn',
Q1449: 'Genoa',
Q1563: 'Havana',
// Provincial cities dissolved as units in 2025 that still stand.
Q19491: 'Nha Trang',
Q36081: 'Quy Nhon',
};
// Groupings that are also typed as cities; none may get in.
const MUST_NOT = {
Q683705: 'New York metropolitan area',
Q665894: 'Greater Mexico City',
Q13580: 'Metro Manila',
Q3114019: 'Katowice urban area',
};
const present = new Set(rows.map((row) => row[5]));
const missing = Object.entries(MUST_HAVE).filter(([id]) => !present.has(id));
const intruders = Object.entries(MUST_NOT).filter(([id]) => present.has(id));
if (intruders.length) {
throw new Error(
`Groupings got in: ${intruders.map(([id, name]) => `${name} (${id})`).join(', ')}`,
);
}
if (missing.length) {
throw new Error(
`Expected cities are missing: ${missing.map(([id, name]) => `${name} (${id})`).join(', ')}`,
);
}
const meta = {
source: 'Wikidata (https://www.wikidata.org), via the Wikidata Query Service',
license: 'CC0 1.0',
built: new Date().toISOString(),
minPopulation,
count: rows.length,
columns: ['name', 'lat', 'lon', 'population', 'country', 'wikidata'],
};
await mkdir(outDir, { recursive: true });
const file = path.join(outDir, 'cities.json');
await writeFile(file, `${JSON.stringify({ meta, rows })}\n`);
console.log(`Wrote ${rows.length} places to ${path.relative(root, file)}`);
+3 -23
View File
@@ -456,7 +456,6 @@
"src/layers/satellites/ingestion.js", "src/layers/satellites/ingestion.js",
"src/layers/satellites/index.js", "src/layers/satellites/index.js",
"src/layers/satellites/orbits.js", "src/layers/satellites/orbits.js",
"src/layers/satellites/motion.js",
"src/layers/launches/lifecycle.js", "src/layers/launches/lifecycle.js",
"src/layers/launches/controls.js", "src/layers/launches/controls.js",
"src/layers/launches/panel.js", "src/layers/launches/panel.js",
@@ -484,8 +483,6 @@
"src/data/rocketLaunches.js", "src/data/rocketLaunches.js",
"src/layers/satellites/source.test.mjs", "src/layers/satellites/source.test.mjs",
"src/layers/satellites/stale.test.mjs", "src/layers/satellites/stale.test.mjs",
"src/layers/satellites/motion.test.mjs",
"src/layers/satellites/reckon.test.mjs",
"src/layers/launches/source.test.mjs", "src/layers/launches/source.test.mjs",
"src/layers/launches/ingestion.test.mjs", "src/layers/launches/ingestion.test.mjs",
"src/layers/launches/failedEntry.test.mjs", "src/layers/launches/failedEntry.test.mjs",
@@ -939,6 +936,8 @@
"src/ui/weatherPanel.test.mjs", "src/ui/weatherPanel.test.mjs",
"src/ui/railCards.js", "src/ui/railCards.js",
"src/ui/railCards.test.mjs", "src/ui/railCards.test.mjs",
"src/ui/railTimeline.js",
"src/ui/railTimeline.test.mjs",
"src/layers/weather/imageryOrder.js", "src/layers/weather/imageryOrder.js",
"src/layers/weather/imageryOrder.test.mjs", "src/layers/weather/imageryOrder.test.mjs",
"server/providers/cyclones.js", "server/providers/cyclones.js",
@@ -1450,24 +1449,5 @@
"src/data/fleetModelInstances.test.mjs", "src/data/fleetModelInstances.test.mjs",
"src/testSupport/fleetGlb.mjs", "src/testSupport/fleetGlb.mjs",
"src/testSupport/fleetModelFakes.mjs", "src/testSupport/fleetModelFakes.mjs",
"scripts/measure-fleet-trail-anchors.mjs", "scripts/measure-fleet-trail-anchors.mjs"
"src/ui/panelEdge.js",
"src/ui/railEdgeScroll.js",
"src/ui/railEdgeScroll.test.mjs",
"src/ui/kit/UiPanelEdge.vue",
"src/ui/styles/rail-edge.css",
"src/ui/styles/rail-split.css",
"src/ui/styles/left-tools.css",
"src/ui/styles/panel-edge.css",
"src/data/placementProvider.test.mjs",
"src/layers/vessels/placement.test.mjs",
"src/ui/styles/visual-panel.css",
"src/ui/styles/scope-off-shadows.css",
"src/ui/styles/cockpit-panels.css",
"src/ui/styles/layer-section-index.css",
"src/ui/styles/layer-finder.css",
"src/ui/layerFinder.test.mjs",
"src/data/wikidataCities.js",
"src/data/wikidataCities.test.mjs",
"scripts/build-wikidata-cities.mjs"
] ]
+19 -23
View File
@@ -11,7 +11,8 @@
"src/data/localGeojsonLod.js", "src/data/localGeojsonLod.js",
"src/data/inputOwnership.js", "src/data/inputOwnership.js",
"src/data/infrastructureOverlayEntry.js", "src/data/infrastructureOverlayEntry.js",
"src/sources/infrastructureData.js" "src/sources/infrastructureData.js",
"src/services/terrainSurface.js"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -102,7 +103,6 @@
"src/ui/stores/streetLevel.js", "src/ui/stores/streetLevel.js",
"src/ui/rail/RailChips.vue", "src/ui/rail/RailChips.vue",
"src/ui/views/panels/DisplayPanel.vue", "src/ui/views/panels/DisplayPanel.vue",
"src/ui/views/panels/VisualPresets.vue",
"src/ui/views/panels/GeoImport.vue", "src/ui/views/panels/GeoImport.vue",
"src/ui/views/panels/DrawControls.vue", "src/ui/views/panels/DrawControls.vue",
"src/ui/views/panels/DrawMarkList.vue", "src/ui/views/panels/DrawMarkList.vue",
@@ -113,8 +113,6 @@
"src/ui/views/panels/DataLibraryCard.vue", "src/ui/views/panels/DataLibraryCard.vue",
"src/ui/views/panels/DataLibraryDialog.vue", "src/ui/views/panels/DataLibraryDialog.vue",
"src/ui/views/panels/DataLibraryBrowse.vue", "src/ui/views/panels/DataLibraryBrowse.vue",
"src/ui/views/panels/LayerFinder.vue",
"src/ui/views/panels/LayerSectionIndex.vue",
"src/ui/libraryBrowser.js", "src/ui/libraryBrowser.js",
"src/ui/localSdrPresentation.js", "src/ui/localSdrPresentation.js",
"src/ui/unitLabels.js", "src/ui/unitLabels.js",
@@ -157,8 +155,6 @@
"src/ui/kit/UiSegmented.vue", "src/ui/kit/UiSegmented.vue",
"src/ui/rail/RailPanel.vue", "src/ui/rail/RailPanel.vue",
"src/ui/kit/UiPanel.vue", "src/ui/kit/UiPanel.vue",
"src/ui/kit/UiPanelEdge.vue",
"src/ui/panelEdge.js",
"src/ui/kit/UiPanelToggle.vue", "src/ui/kit/UiPanelToggle.vue",
"src/ui/stores/panelChrome.js", "src/ui/stores/panelChrome.js",
"src/ui/rail/PanelCollapseButton.vue", "src/ui/rail/PanelCollapseButton.vue",
@@ -804,6 +800,7 @@
"src/ui/layerPanel/RowSwitch.vue", "src/ui/layerPanel/RowSwitch.vue",
"src/ui/layerPanel/RowRange.vue", "src/ui/layerPanel/RowRange.vue",
"src/ui/layerPanel/RowSlider.vue", "src/ui/layerPanel/RowSlider.vue",
"src/ui/railTimeline.js",
"src/ui/clearLayersControl.js", "src/ui/clearLayersControl.js",
"src/loadingFeedback.js", "src/loadingFeedback.js",
"src/data/installationFeedback.js", "src/data/installationFeedback.js",
@@ -836,6 +833,7 @@
"src/ui/rail/RailCard.vue", "src/ui/rail/RailCard.vue",
"src/ui/rail/RailCards.vue", "src/ui/rail/RailCards.vue",
"src/ui/rail/utcClock.js", "src/ui/rail/utcClock.js",
"src/ui/rail/RailTimeline.vue",
"src/ui/rail/rangeValue.js", "src/ui/rail/rangeValue.js",
"src/ui/weather/WeatherReadout.vue", "src/ui/weather/WeatherReadout.vue",
"src/ui/newsPanel.js", "src/ui/newsPanel.js",
@@ -1139,7 +1137,6 @@
"src/styles/surveillance.js", "src/styles/surveillance.js",
"src/styles/thermal.js", "src/styles/thermal.js",
"src/ui/applicationShell.js", "src/ui/applicationShell.js",
"src/ui/railEdgeScroll.js",
"src/ui/localePicker.js", "src/ui/localePicker.js",
"src/ui/cyberSonarControls.js", "src/ui/cyberSonarControls.js",
"src/ui/locationNavigation.js", "src/ui/locationNavigation.js",
@@ -1215,6 +1212,7 @@
"src/ui/layerPanel/RowSwitch.vue", "src/ui/layerPanel/RowSwitch.vue",
"src/ui/layerPanel/RowRange.vue", "src/ui/layerPanel/RowRange.vue",
"src/ui/layerPanel/RowSlider.vue", "src/ui/layerPanel/RowSlider.vue",
"src/ui/railTimeline.js",
"src/ui/layers.js", "src/ui/layers.js",
"src/ui/leftPanelRail.js", "src/ui/leftPanelRail.js",
"src/ui/location.js", "src/ui/location.js",
@@ -1309,6 +1307,7 @@
"src/ui/rail/RailCard.vue", "src/ui/rail/RailCard.vue",
"src/ui/rail/RailCards.vue", "src/ui/rail/RailCards.vue",
"src/ui/rail/utcClock.js", "src/ui/rail/utcClock.js",
"src/ui/rail/RailTimeline.vue",
"src/ui/rail/rangeValue.js", "src/ui/rail/rangeValue.js",
"src/ui/weather/WeatherReadout.vue", "src/ui/weather/WeatherReadout.vue",
"src/ui/newsPanel.js", "src/ui/newsPanel.js",
@@ -1587,8 +1586,7 @@
"src/ui/unitLabels.js", "src/ui/unitLabels.js",
"src/time/worldClock.js", "src/time/worldClock.js",
"src/time/julian.js", "src/time/julian.js",
"src/data/planespottersPhoto.js", "src/data/planespottersPhoto.js"
"src/layers/flights/placed.js"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -1661,8 +1659,7 @@
"src/i18n/locales/zh-Hant.json", "src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js", "src/ui/unitLabels.js",
"src/time/worldClock.js", "src/time/worldClock.js",
"src/time/julian.js", "src/time/julian.js"
"src/layers/flights/placed.js"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -1762,8 +1759,7 @@
"src/layers/earthquakes/index.js", "src/layers/earthquakes/index.js",
"src/layers/earthquakes/model.js", "src/layers/earthquakes/model.js",
"src/layers/earthquakes/source.js", "src/layers/earthquakes/source.js",
"src/layers/earthquakes/records.js", "src/layers/earthquakes/records.js"
"src/time/worldClock.js"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -1854,7 +1850,6 @@
"src/layers/satellites/interaction.js", "src/layers/satellites/interaction.js",
"src/layers/satellites/labels.js", "src/layers/satellites/labels.js",
"src/layers/satellites/lifecycle.js", "src/layers/satellites/lifecycle.js",
"src/layers/satellites/motion.js",
"src/layers/satellites/orbits.js", "src/layers/satellites/orbits.js",
"src/layers/satellites/records.js", "src/layers/satellites/records.js",
"src/layers/satellites/policy.js", "src/layers/satellites/policy.js",
@@ -2119,6 +2114,7 @@
"src/layers/bikeshare/state.js", "src/layers/bikeshare/state.js",
"src/layers/bikeshare/testing.js", "src/layers/bikeshare/testing.js",
"src/layers/bikeshare/viewport.js", "src/layers/bikeshare/viewport.js",
"src/services/terrainSurface.js",
"src/data/inputOwnership.js", "src/data/inputOwnership.js",
"src/data/cameraSensitivity.js", "src/data/cameraSensitivity.js",
"src/i18n/index.js", "src/i18n/index.js",
@@ -2973,7 +2969,6 @@
"src/layers/satellites/interaction.js", "src/layers/satellites/interaction.js",
"src/layers/satellites/labels.js", "src/layers/satellites/labels.js",
"src/layers/satellites/lifecycle.js", "src/layers/satellites/lifecycle.js",
"src/layers/satellites/motion.js",
"src/layers/satellites/orbits.js", "src/layers/satellites/orbits.js",
"src/sources/tle.js", "src/sources/tle.js",
"src/layers/satellites/records.js", "src/layers/satellites/records.js",
@@ -3071,6 +3066,7 @@
"src/search/placeSearch.js", "src/search/placeSearch.js",
"src/services/application.js", "src/services/application.js",
"src/services/groundFloor.js", "src/services/groundFloor.js",
"src/services/terrainSurface.js",
"src/services/groundSnap.js", "src/services/groundSnap.js",
"src/services/meshFloorSampler.js", "src/services/meshFloorSampler.js",
"src/services/requests.js", "src/services/requests.js",
@@ -3094,7 +3090,6 @@
"src/styles/surveillance.js", "src/styles/surveillance.js",
"src/styles/thermal.js", "src/styles/thermal.js",
"src/ui/applicationShell.js", "src/ui/applicationShell.js",
"src/ui/railEdgeScroll.js",
"src/ui/mobileShell.js", "src/ui/mobileShell.js",
"src/exclusiveSurfaces.js", "src/exclusiveSurfaces.js",
"src/ui/localePicker.js", "src/ui/localePicker.js",
@@ -3167,6 +3162,7 @@
"src/ui/layerPanel/RowSwitch.vue", "src/ui/layerPanel/RowSwitch.vue",
"src/ui/layerPanel/RowRange.vue", "src/ui/layerPanel/RowRange.vue",
"src/ui/layerPanel/RowSlider.vue", "src/ui/layerPanel/RowSlider.vue",
"src/ui/railTimeline.js",
"src/ui/layers.js", "src/ui/layers.js",
"src/ui/leftPanelRail.js", "src/ui/leftPanelRail.js",
"src/ui/location.js", "src/ui/location.js",
@@ -3722,6 +3718,7 @@
"src/ui/rail/RailCards.vue", "src/ui/rail/RailCards.vue",
"src/ui/rail/RailCardBlocks.vue", "src/ui/rail/RailCardBlocks.vue",
"src/ui/rail/utcClock.js", "src/ui/rail/utcClock.js",
"src/ui/rail/RailTimeline.vue",
"src/ui/rail/rangeValue.js", "src/ui/rail/rangeValue.js",
"src/ui/weather/WeatherReadout.vue", "src/ui/weather/WeatherReadout.vue",
"src/ui/newsPanel.js", "src/ui/newsPanel.js",
@@ -3826,9 +3823,7 @@
"src/data/planespottersPhoto.js", "src/data/planespottersPhoto.js",
"src/ui/stores/dataLibrary.js", "src/ui/stores/dataLibrary.js",
"src/data/openVesselPhoto.js", "src/data/openVesselPhoto.js",
"src/layers/vessels/photos.js", "src/layers/vessels/photos.js"
"src/layers/flights/placed.js",
"src/data/placementProvider.js"
], ],
"external": [ "external": [
"hls.js", "hls.js",
@@ -4161,7 +4156,6 @@
"src/layers/satellites/interaction.js", "src/layers/satellites/interaction.js",
"src/layers/satellites/labels.js", "src/layers/satellites/labels.js",
"src/layers/satellites/lifecycle.js", "src/layers/satellites/lifecycle.js",
"src/layers/satellites/motion.js",
"src/layers/satellites/orbits.js", "src/layers/satellites/orbits.js",
"src/sources/tle.js", "src/sources/tle.js",
"src/layers/satellites/records.js", "src/layers/satellites/records.js",
@@ -4253,6 +4247,7 @@
"src/scopeMaskPolicy.js", "src/scopeMaskPolicy.js",
"src/scopeMask.js", "src/scopeMask.js",
"src/services/groundFloor.js", "src/services/groundFloor.js",
"src/services/terrainSurface.js",
"src/data/routeSteps.js", "src/data/routeSteps.js",
"src/layers/directions/index.js", "src/layers/directions/index.js",
"src/data/pointerOwnerNames.js", "src/data/pointerOwnerNames.js",
@@ -4547,9 +4542,7 @@
"src/data/imagingSensors.js", "src/data/imagingSensors.js",
"src/data/planespottersPhoto.js", "src/data/planespottersPhoto.js",
"src/layers/vessels/photos.js", "src/layers/vessels/photos.js",
"src/data/openVesselPhoto.js", "src/data/openVesselPhoto.js"
"src/layers/flights/placed.js",
"src/data/placementProvider.js"
], ],
"external": [ "external": [
"hls.js", "hls.js",
@@ -4603,6 +4596,7 @@
"src/search/placeSearch.js", "src/search/placeSearch.js",
"src/services/application.js", "src/services/application.js",
"src/services/groundFloor.js", "src/services/groundFloor.js",
"src/services/terrainSurface.js",
"src/services/groundSnap.js", "src/services/groundSnap.js",
"src/services/meshFloorSampler.js", "src/services/meshFloorSampler.js",
"src/services/requests.js", "src/services/requests.js",
@@ -5279,6 +5273,7 @@
"src/renderGovernor.js", "src/renderGovernor.js",
"src/services/application.js", "src/services/application.js",
"src/services/groundFloor.js", "src/services/groundFloor.js",
"src/services/terrainSurface.js",
"src/services/meshFloorSampler.js", "src/services/meshFloorSampler.js",
"src/services/requests.js", "src/services/requests.js",
"src/services/terrainHeights.js", "src/services/terrainHeights.js",
@@ -5331,6 +5326,7 @@
"src/data/transitPresetStyle.js", "src/data/transitPresetStyle.js",
"src/layers/transit/controls.js", "src/layers/transit/controls.js",
"src/layers/transit/height.js", "src/layers/transit/height.js",
"src/services/terrainSurface.js",
"src/layers/transit/index.js", "src/layers/transit/index.js",
"src/layers/transit/cockpitSubject.js", "src/layers/transit/cockpitSubject.js",
"src/layers/transit/ingestion.js", "src/layers/transit/ingestion.js",
-22
View File
@@ -202,25 +202,3 @@ test('a feed fleet-number mode outranks its route mode in the epoch context', (t
assert.equal(h.get('ttc', '4507', 100000).epochs[0].mode, 'tram'); assert.equal(h.get('ttc', '4507', 100000).epochs[0].mode, 'tram');
assert.equal(h.get('ttc', 'x1', 100000).epochs[0].mode, 'tram'); assert.equal(h.get('ttc', 'x1', 100000).epochs[0].mode, 'tram');
}); });
test('the epoch mode is the live mode: a route type, else hints, else the default', (t) => {
const h = history(t);
const pid = {
id: 'pid',
historyRetention: true,
defaultMode: 'bus',
routeMode: (route) => (route === 'L22' ? 'tram' : 'unknown'),
};
h.ingest(
pid,
[
row(100000, 'a', { routeId: 'L58', routeType: 11 }),
row(100000, 'b', { routeId: 'L22' }),
row(100000, 'c', { routeId: 'L9999' }),
],
100000,
);
assert.equal(h.get('pid', 'a', 100000).epochs[0].mode, 'trolleybus');
assert.equal(h.get('pid', 'b', 100000).epochs[0].mode, 'tram');
// A hint that knows nothing leaves the default, as the live layer does.
assert.equal(h.get('pid', 'c', 100000).epochs[0].mode, 'bus');
});
@@ -216,7 +216,7 @@ test('outline upgrade preserves the existing SVG group identity', async (t) => {
assert.ok(after === before, 'the rendered group is mutated, not replaced'); assert.ok(after === before, 'the rendered group is mutated, not replaced');
assert.deepEqual(sampledAnchors.at(-1), [-97.5, 31.2], 'the existing group projects from the re-seated anchor'); assert.deepEqual(sampledAnchors.at(-1), [-97.5, 31.2], 'the existing group projects from the re-seated anchor');
assert.equal(groups().length, 1); assert.equal(groups().length, 1);
assert.equal(after.querySelector('.gev-anno-ring') === null, true, 'reticle rings are removed in place'); assert.equal(after.querySelector('.gev-anno-ring'), null, 'reticle rings are removed in place');
assert.ok(after.querySelector('.gev-anno-callout') === callout, 'the original callout remains in the group'); assert.ok(after.querySelector('.gev-anno-callout') === callout, 'the original callout remains in the group');
assert.ok(after.querySelector('.gev-anno-dot') === dot, 'the anchor dot remains in the group'); assert.ok(after.querySelector('.gev-anno-dot') === dot, 'the anchor dot remains in the group');
assert.ok(svg()); assert.ok(svg());
@@ -330,7 +330,7 @@ test('a removed mark fades out, then leaves the board', async (t) => {
assert.equal(group.isConnected, false); assert.equal(group.isConnected, false);
renderer.destroy(); renderer.destroy();
await settle(); await settle();
assert.equal(document.querySelector('.gev-screen-whiteboard') === null, true); assert.equal(document.querySelector('.gev-screen-whiteboard'), null);
}); });
// ── Partial-add unwind (review round 2) ─────────────────────────────────────── // ── Partial-add unwind (review round 2) ───────────────────────────────────────
+5 -12
View File
@@ -6,7 +6,6 @@ import { createWeatherLayer } from '../layers/weather/index.js';
import { createApplicationCyclones } from './layers/cyclones.js'; import { createApplicationCyclones } from './layers/cyclones.js';
import { createWindLayer } from '../layers/wind/index.js'; import { createWindLayer } from '../layers/wind/index.js';
import { createLayerCatalog } from './catalog.js'; import { createLayerCatalog } from './catalog.js';
import { layerPlacement } from '../data/placementProvider.js';
import { LAYER_STATE_REGISTRY } from '../data/layerState.js'; import { LAYER_STATE_REGISTRY } from '../data/layerState.js';
import { createMilitaryRegistry } from '../layers/aircraft/classification.js'; import { createMilitaryRegistry } from '../layers/aircraft/classification.js';
import { createApplicationFlights } from './layers/flights.js'; import { createApplicationFlights } from './layers/flights.js';
@@ -64,9 +63,6 @@ export const APPLICATION_LAYER_METADATA = Object.freeze([
/** Construct the current catalog without choosing any source provider. /** Construct the current catalog without choosing any source provider.
* Scene engines remain page-owned; layers and classification have this app's lifetime. * Scene engines remain page-owned; layers and classification have this app's lifetime.
* The manager owns layer destruction, while abort releases classification even if startup fails. * The manager owns layer destruction, while abort releases classification even if startup fails.
* `placement`, a host-supplied placement provider (../data/placementProvider.js),
* places live aircraft, vessels, transit and trains; without one each layer
* uses its own motion.
*/ */
export function createApplicationCatalog({ export function createApplicationCatalog({
surface, surface,
@@ -78,7 +74,6 @@ export function createApplicationCatalog({
resolveAsset, resolveAsset,
nepalBoundaryResolver, nepalBoundaryResolver,
streetLevelProviders, streetLevelProviders,
placement = null,
}) { }) {
if (!signal?.addEventListener) if (!signal?.addEventListener)
throw new TypeError('An application lifetime signal is required'); throw new TypeError('An application lifetime signal is required');
@@ -102,20 +97,17 @@ export function createApplicationCatalog({
const flights = createApplicationFlights({ const flights = createApplicationFlights({
surface, surface,
source: sources.flights, source: sources.flights,
placement: layerPlacement(placement, 'aircraft'),
militaryRegistry, militaryRegistry,
resolveAsset, resolveAsset,
}); });
const military = createApplicationMilitary({ const military = createApplicationMilitary({
surface, surface,
source: sources.military, source: sources.military,
placement: layerPlacement(placement, 'military'),
militaryRegistry, militaryRegistry,
resolveAsset, resolveAsset,
}); });
const vessels = createApplicationVessels({ const vessels = createApplicationVessels({
source: sources.vessels, source: sources.vessels,
placement: layerPlacement(placement, 'vessels'),
options: vesselOptions, options: vesselOptions,
displayParams: () => flights.getParams(), displayParams: () => flights.getParams(),
...(resolveAsset ? { resolveAsset } : {}), ...(resolveAsset ? { resolveAsset } : {}),
@@ -180,11 +172,10 @@ export function createApplicationCatalog({
createApplicationTransit({ createApplicationTransit({
surface, surface,
source: sources.transit, source: sources.transit,
placement: layerPlacement(placement, 'transit'),
displayParams: () => flights.getParams(), displayParams: () => flights.getParams(),
...(resolveAsset ? { resolveAsset } : {}), ...(resolveAsset ? { resolveAsset } : {}),
}), }),
createApplicationBikeshare({ source: sources.bikeshare }), createApplicationBikeshare({ surface, source: sources.bikeshare }),
createApplicationDirections({ source: sources.directions }), createApplicationDirections({ source: sources.directions }),
createApplicationRecentImagery({ source: sources['recent-imagery'] }), createApplicationRecentImagery({ source: sources['recent-imagery'] }),
createApplicationOsmPlaces(), createApplicationOsmPlaces(),
@@ -214,12 +205,14 @@ export function createApplicationCatalog({
}), }),
createApplicationCyclones({ feed: sources.cyclones }), createApplicationCyclones({ feed: sources.cyclones }),
...createOsmOverlayLayers(), ...createOsmOverlayLayers(),
...createInfrastructureLayers(localGeoJsonServices), ...createInfrastructureLayers({
...localGeoJsonServices,
terrainSurface: surface.groundFloor.terrainSurface,
}),
createApplicationCables({ source: sources.cables }), createApplicationCables({ source: sources.cables }),
createApplicationRail({ createApplicationRail({
surface, surface,
source: sources.rail, source: sources.rail,
placement: layerPlacement(placement, 'rail'),
displayParams: () => flights.getParams(), displayParams: () => flights.getParams(),
...(resolveAsset ? { resolveAsset } : {}), ...(resolveAsset ? { resolveAsset } : {}),
}), }),
+3 -19
View File
@@ -6,11 +6,6 @@ import { createStandaloneLayerSources } from '../standalone/layerSources.js';
import { catalogControlServices } from './catalog.js'; import { catalogControlServices } from './catalog.js';
import { LayerLifecycle } from '../data/lifecycle.js'; import { LayerLifecycle } from '../data/lifecycle.js';
import { fakeStreetLevelProvider } from '../testSupport/streetLevelFakes.mjs'; import { fakeStreetLevelProvider } from '../testSupport/streetLevelFakes.mjs';
import { worldClock } from '../time/worldClock.js';
const flush = async () => {
for (let i = 0; i < 20; i++) await Promise.resolve();
};
function fixtureSources(ids, calls) { function fixtureSources(ids, calls) {
const sources = createStandaloneLayerSources(); const sources = createStandaloneLayerSources();
@@ -32,7 +27,6 @@ test('catalogs construct distinct layers and classification from their supplied
t.after(() => { t.after(() => {
a.abort(); a.abort();
b.abort(); b.abort();
worldClock.goLive();
}); });
const callsA = []; const callsA = [];
const callsB = []; const callsB = [];
@@ -59,10 +53,7 @@ test('catalogs construct distinct layers and classification from their supplied
'the hardware-local layer is never serialized into links', 'the hardware-local layer is never serialized into links',
); );
assert.notEqual(first.weatherClock, second.weatherClock); assert.notEqual(first.weatherClock, second.weatherClock);
// Time moves on the world clock, which the catalog's weather clock follows. await first.weatherClock.setTarget('2026-09-21T12:00:00.000Z');
assert.equal(first.weatherClock.world, worldClock);
worldClock.seek(Date.parse('2026-09-21T12:00:00.000Z'));
await flush();
assert.match( assert.match(
first.get('wind').getRowControls().info, first.get('wind').getRowControls().info,
/Forecast · does not follow history/, /Forecast · does not follow history/,
@@ -120,16 +111,9 @@ test('catalogs construct distinct layers and classification from their supplied
assert.equal(first.militaryRegistry.isMilitaryIcao('def456'), false); assert.equal(first.militaryRegistry.isMilitaryIcao('def456'), false);
assert.equal(second.militaryRegistry.isMilitaryIcao('def456'), true); assert.equal(second.militaryRegistry.isMilitaryIcao('def456'), true);
a.abort(); a.abort();
worldClock.seek(Date.parse('2026-09-21T13:00:00.000Z'));
await flush();
assert.equal( assert.equal(
first.weatherClock.getState().target, await first.weatherClock.setTarget('2026-09-21T13:00:00.000Z'),
'2026-09-21T12:00:00.000Z', false,
'an aborted catalog no longer follows the world clock',
);
assert.equal(
second.weatherClock.getState().target,
'2026-09-21T13:00:00.000Z',
); );
assert.equal(callsA[0].aborted, true); assert.equal(callsA[0].aborted, true);
assert.equal(first.militaryRegistry.isMilitaryIcao('abc123'), false); assert.equal(first.militaryRegistry.isMilitaryIcao('abc123'), false);
-3
View File
@@ -16,11 +16,9 @@ import { fleetModelServices } from './fleetModels.js';
* Construct one layer using the application scene owners and a supplied * Construct one layer using the application scene owners and a supplied
* source. `displayParams` reads the Flights layer's DISPLAY-rail 3D * source. `displayParams` reads the Flights layer's DISPLAY-rail 3D
* preference, under which nearby vessels are drawn as fleet models. * preference, under which nearby vessels are drawn as fleet models.
* `placement`: a host-supplied placement for its live vessels, or null.
*/ */
export function createApplicationVessels({ export function createApplicationVessels({
source, source,
placement = null,
options = {}, options = {},
displayParams = null, displayParams = null,
resolveAsset, resolveAsset,
@@ -40,7 +38,6 @@ export function createApplicationVessels({
worldFocus, worldFocus,
render, render,
readouts, readouts,
placement,
fleet: fleetModelServices({ fleet: fleetModelServices({
display: displayParams, display: displayParams,
...(resolveAsset ? { resolveAsset } : {}), ...(resolveAsset ? { resolveAsset } : {}),
+9 -2
View File
@@ -5,9 +5,16 @@ import * as picking from '../../data/pickRegistry.js';
import * as overlays from '../../overlays/worldOverlay.js'; import * as overlays from '../../overlays/worldOverlay.js';
/** Construct one layer using the application scene owners and a supplied source. */ /** Construct one layer using the application scene owners and a supplied source. */
export function createApplicationBikeshare({ source }) { export function createApplicationBikeshare({ source, surface }) {
return createBikeshareLayer({ return createBikeshareLayer({
source, source,
services: { render, sprites, picking, overlays }, services: {
render,
sprites,
picking,
overlays,
// Station heights come from the terrain surface every layer shares.
ground: { terrainSurface: surface?.groundFloor?.terrainSurface },
},
}); });
} }
+1 -4
View File
@@ -12,13 +12,11 @@ import * as context from '../../data/contextStore.js';
import * as render from '../../renderGovernor.js'; import * as render from '../../renderGovernor.js';
import * as recession from '../../data/aircraftRecession.js'; import * as recession from '../../data/aircraftRecession.js';
/** Construct one layer using the application scene owners and a supplied source. /** Construct one layer using the application scene owners and a supplied source. */
* `placement`: a host-supplied placement for its live aircraft, or null. */
export function createApplicationFlights({ export function createApplicationFlights({
surface, surface,
source, source,
militaryRegistry, militaryRegistry,
placement = null,
resolveAsset = (url) => resolveAsset = (url) =>
`${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`, `${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`,
}) { }) {
@@ -43,7 +41,6 @@ export function createApplicationFlights({
render, render,
groundSnap, groundSnap,
recession, recession,
placement,
}, },
}); });
} }
+1 -4
View File
@@ -12,13 +12,11 @@ import * as context from '../../data/contextStore.js';
import * as render from '../../renderGovernor.js'; import * as render from '../../renderGovernor.js';
import * as recession from '../../data/aircraftRecession.js'; import * as recession from '../../data/aircraftRecession.js';
/** Construct one layer using the application scene owners and a supplied source. /** Construct one layer using the application scene owners and a supplied source. */
* `placement`: a host-supplied placement for its live aircraft, or null. */
export function createApplicationMilitary({ export function createApplicationMilitary({
surface, surface,
source, source,
militaryRegistry, militaryRegistry,
placement = null,
resolveAsset = (url) => resolveAsset = (url) =>
`${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`, `${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`,
}) { }) {
@@ -43,7 +41,6 @@ export function createApplicationMilitary({
render, render,
groundSnap, groundSnap,
recession, recession,
placement,
}, },
}); });
} }
+1 -4
View File
@@ -16,15 +16,13 @@ import {
* ground floor, the same floor Transit and the aircraft stand on. * ground floor, the same floor Transit and the aircraft stand on.
* `displayParams` reads the Flights layer's DISPLAY-rail 3D preference, under * `displayParams` reads the Flights layer's DISPLAY-rail 3D preference, under
* which nearby trains are drawn as fleet models. * which nearby trains are drawn as fleet models.
* `placement` is a host-supplied placement for its live trains, or null. * @param {{surface: object, source?: object,
* @param {{surface: object, source?: object, placement?: object|null,
* displayParams?: () => object|null, resolveAsset?: (url: string) => string}} options * displayParams?: () => object|null, resolveAsset?: (url: string) => string}} options
* @returns {object} * @returns {object}
*/ */
export function createApplicationRail({ export function createApplicationRail({
surface, surface,
source, source,
placement = null,
displayParams = null, displayParams = null,
resolveAsset, resolveAsset,
}) { }) {
@@ -38,7 +36,6 @@ export function createApplicationRail({
sprites, sprites,
picking, picking,
overlays, overlays,
placement,
credits: { registerDynamicCredit, railFeedCredit }, credits: { registerDynamicCredit, railFeedCredit },
ground: { ground: {
GROUND_FLOOR_LIFT_M: groundFloor.GROUND_FLOOR_LIFT_M, GROUND_FLOOR_LIFT_M: groundFloor.GROUND_FLOOR_LIFT_M,
+1 -4
View File
@@ -23,15 +23,13 @@ import {
* `displayParams` reads the Flights layer's DISPLAY-rail 3D preference, under * `displayParams` reads the Flights layer's DISPLAY-rail 3D preference, under
* which nearby vehicles are drawn as fleet models. * which nearby vehicles are drawn as fleet models.
* *
* `placement` is a host-supplied placement for its live vehicles, or null. * @param {{surface: object, source?: object,
* @param {{surface: object, source?: object, placement?: object|null,
* displayParams?: () => object|null, resolveAsset?: (url: string) => string}} options * displayParams?: () => object|null, resolveAsset?: (url: string) => string}} options
* @returns {object} * @returns {object}
*/ */
export function createApplicationTransit({ export function createApplicationTransit({
surface, surface,
source, source,
placement = null,
displayParams = null, displayParams = null,
resolveAsset, resolveAsset,
}) { }) {
@@ -45,7 +43,6 @@ export function createApplicationTransit({
sprites, sprites,
picking, picking,
overlays, overlays,
placement,
credits: { registerDynamicCredit, transitFeedCredit }, credits: { registerDynamicCredit, transitFeedCredit },
ground: { ground: {
GROUND_FLOOR_LIFT_M: groundFloor.GROUND_FLOOR_LIFT_M, GROUND_FLOOR_LIFT_M: groundFloor.GROUND_FLOOR_LIFT_M,
+20 -41
View File
@@ -26,7 +26,10 @@ import {
setSignalCollapsed, setSignalCollapsed,
} from './ui/cockpitLayout.js'; } from './ui/cockpitLayout.js';
import { createBriefState } from './ui/cockpitBriefPages.js'; import { createBriefState } from './ui/cockpitBriefPages.js';
import { publishBriefView, setBriefAutoRotate } from './ui/cockpitBriefing.js'; import {
publishBriefView,
setBriefAutoRotate,
} from './ui/cockpitBriefing.js';
import { CockpitDisplayPortal } from './ui/cockpitDisplayPortal.js'; import { CockpitDisplayPortal } from './ui/cockpitDisplayPortal.js';
import { _handleContextLayerChange } from './ui/contextLayerChanges.js'; import { _handleContextLayerChange } from './ui/contextLayerChanges.js';
import { clearSelectedLayers } from './ui/contextActions.js'; import { clearSelectedLayers } from './ui/contextActions.js';
@@ -435,10 +438,13 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls',
/data-cockpit-launcher="radio"[\s\S]*?id="cockpit-radio-toggle-btn"/, /data-cockpit-launcher="radio"[\s\S]*?id="cockpit-radio-toggle-btn"/,
); );
const cockpitRadio = html.match( const cockpitRadio = html.match(
/id="cockpit-radio-panel"[\s\S]*?<\/div>\s*<\/div>\s*<div class="cockpit-utility-rail/, /id="cockpit-radio-panel"[\s\S]*?<\/div>\s*<\/div>\s*<\/aside>/,
); );
assert.ok(cockpitRadio, 'Cockpit compact Radio controls are missing'); assert.ok(cockpitRadio, 'Cockpit compact Radio controls are missing');
assert.doesNotMatch(cockpitRadio[0], /radio-filter|radio-tuner/); assert.doesNotMatch(
cockpitRadio[0],
/radio-filter|radio-tuner/,
);
assert.match( assert.match(
css, css,
/\.cockpit-utility-controls[\s\S]*?top:\s*var\(\s*--cockpit-utility-top/, /\.cockpit-utility-controls[\s\S]*?top:\s*var\(\s*--cockpit-utility-top/,
@@ -524,12 +530,12 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls',
); );
}); });
test('Visual orders Parameters below Detection, 3D below them, and Clean UI above the Effects', () => { test('Display orders 3D above Celestial, Clean UI below it, and Parameters below Detection', () => {
const display = html.match(/id="pp-toggles"[\s\S]*?id="clean-view-exit"/); const display = html.match(/id="pp-toggles"[\s\S]*?id="clean-view-exit"/);
assert.ok(display, 'Display markup is missing'); assert.ok(display, 'Display markup is missing');
assert.match( assert.match(
display[0], display[0],
/id="detection-toggle"[\s\S]*?id="param-slider-panel"[\s\S]*?id="models3d-toggle"[\s\S]*?id="scope-toggle"[\s\S]*?id="clean-view-toggle"[\s\S]*?class="pp-section-heading">Effects<[\s\S]*?id="celestial-toggle"/, /id="detection-toggle"[\s\S]*?id="param-slider-panel"[\s\S]*?id="models3d-toggle"[\s\S]*?id="celestial-toggle"[\s\S]*?id="clean-view-toggle"/,
); );
assert.equal( assert.equal(
(html.match(/id="param-slider-panel"/g) || []).length, (html.match(/id="param-slider-panel"/g) || []).length,
@@ -805,27 +811,18 @@ test('Reset releases Contact camera ownership through its selection-preserving r
); );
assert.match(ui, /onReset: \(\) => this\.resetToGlobeView\(\)/); assert.match(ui, /onReset: \(\) => this\.resetToGlobeView\(\)/);
assert.match(ui, /this\._connectGlobeReset\?\.\(this\._locationControls\)/); assert.match(ui, /this\._connectGlobeReset\?\.\(this\._locationControls\)/);
assert.match( assert.match(controls, /reset\(\) \{\s*if \(!this\.destroyed\) return this\.onReset\(\);/);
controls,
/reset\(\) \{\s*if \(!this\.destroyed\) return this\.onReset\(\);/,
);
// Both reset buttons ask the one reset owner. // Both reset buttons ask the one reset owner.
const globeActions = fs.readFileSync( const globeActions = fs.readFileSync(
path.join(ROOT, 'src', 'ui', 'views', 'panels', 'GlobeActions.vue'), path.join(ROOT, 'src', 'ui', 'views', 'panels', 'GlobeActions.vue'),
'utf8', 'utf8',
); );
assert.match( assert.match(globeActions, /id="reset-globe-view"[^>]*@click="act\('reset'\)"/);
globeActions,
/id="reset-globe-view"[^>]*@click="act\('reset'\)"/,
);
const cockpitView = readRadioSource( const cockpitView = readRadioSource(
new URL('./ui/views/CockpitView.vue', import.meta.url), new URL('./ui/views/CockpitView.vue', import.meta.url),
'utf8', 'utf8',
); );
assert.match( assert.match(cockpitView, /id="cockpit-reset-globe"[^>]*@click="globe\.act\('reset'\)"/);
cockpitView,
/id="cockpit-reset-globe"[^>]*@click="globe\.act\('reset'\)"/,
);
assert.match( assert.match(
cockpitEnter.toString(), cockpitEnter.toString(),
/Object\.assign\(this\.presence, \{\s*active: true,/, /Object\.assign\(this\.presence, \{\s*active: true,/,
@@ -1196,10 +1193,7 @@ test('cockpit summary presents the focused item as Contact', () => {
const match = html.match(/<aside id="cockpit-context"[\s\S]*?<\/aside>/); const match = html.match(/<aside id="cockpit-context"[\s\S]*?<\/aside>/);
assert.ok(match, 'cockpit Contact summary is missing'); assert.ok(match, 'cockpit Contact summary is missing');
assert.match(match[0], /aria-label="Contact cockpit summary"/); assert.match(match[0], /aria-label="Contact cockpit summary"/);
assert.match( assert.match(match[0], /class="cockpit-card-kicker cockpit-context-kicker">CONTACT</);
match[0],
/class="cockpit-card-kicker cockpit-context-kicker">CONTACT</,
);
assert.match(match[0], /aria-label="Contact navigation"/); assert.match(match[0], /aria-label="Contact navigation"/);
assert.match( assert.match(
match[0], match[0],
@@ -1414,30 +1408,15 @@ test('Contact folded for one Cockpit left panel comes back only once neither is
const previous = globalThis.document; const previous = globalThis.document;
const panels = {}; const panels = {};
globalThis.document = { getElementById: (id) => panels[id] ?? null }; globalThis.document = { getElementById: (id) => panels[id] ?? null };
const panel = (...classes) => ({ const panel = (...classes) => ({ classList: { contains: (name) => classes.includes(name) } });
classList: { contains: (name) => classes.includes(name) },
});
try { try {
const otherOpen = (id) => const otherOpen = (id) => PanelChrome.prototype._otherCockpitLeftPanelOpen(id);
PanelChrome.prototype._otherCockpitLeftPanelOpen(id);
panels['data-panel'] = panel('collapsed'); panels['data-panel'] = panel('collapsed');
assert.equal( assert.equal(otherOpen('utilities-panel'), false, 'Data Layers closed: restore');
otherOpen('utilities-panel'),
false,
'Data Layers closed: restore',
);
panels['data-panel'] = panel('collapsed', 'layout-auto-collapsed'); panels['data-panel'] = panel('collapsed', 'layout-auto-collapsed');
assert.equal( assert.equal(otherOpen('utilities-panel'), true, 'Data Layers comes back: keep Contact folded');
otherOpen('utilities-panel'),
true,
'Data Layers comes back: keep Contact folded',
);
panels['data-panel'] = panel(); panels['data-panel'] = panel();
assert.equal( assert.equal(otherOpen('utilities-panel'), true, 'Data Layers open: keep it folded');
otherOpen('utilities-panel'),
true,
'Data Layers open: keep it folded',
);
} finally { } finally {
globalThis.document = previous; globalThis.document = previous;
} }
+2 -2
View File
@@ -209,7 +209,7 @@ test('rows render the signals the controller presents', async (t) => {
); );
assert.equal(status.className, 'warning'); assert.equal(status.className, 'warning');
assert.equal(status.querySelector('div > strong').textContent, 'UNKNOWN'); assert.equal(status.querySelector('div > strong').textContent, 'UNKNOWN');
assert.equal(status.querySelector('button') === null, true); assert.equal(status.querySelector('button'), null);
}); });
test('actual Context refresh retains the focused signal button across unchanged and changed details', async (t) => { test('actual Context refresh retains the focused signal button across unchanged and changed details', async (t) => {
@@ -315,7 +315,7 @@ test('removing the focused identity uses the shown briefing tab once and permits
assert.equal(document.activeElement === f.signalTab(), true); assert.equal(document.activeElement === f.signalTab(), true);
assert.equal(f.focusCalls(), calls + 1); assert.equal(f.focusCalls(), calls + 1);
assert.equal(f.losses(), 0); assert.equal(f.losses(), 0);
assert.equal(f.signalTab().closest('[hidden]') === null, true); assert.equal(f.signalTab().closest('[hidden]'), null);
// Traversal carries on forward from the footer tab. // Traversal carries on forward from the footer tab.
f.tab(); f.tab();
assert.equal(document.activeElement === f.pageTab(1), true); assert.equal(document.activeElement === f.pageTab(1), true);
+36 -127
View File
@@ -4,31 +4,18 @@ import { _selectContextMode } from './ui/contextTransactions.js';
import { _initGlobalContextPanel } from './ui/contextBindings.js'; import { _initGlobalContextPanel } from './ui/contextBindings.js';
import { clearSelectedLayers } from './ui/contextActions.js'; import { clearSelectedLayers } from './ui/contextActions.js';
import { readFileSync as readRadioSource } from 'node:fs'; import { readFileSync as readRadioSource } from 'node:fs';
const radioBindings = readRadioSource( const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
new URL('./ui/radioBindings.js', import.meta.url), const contextView = readRadioSource(new URL('./ui/views/ContextView.vue', import.meta.url), 'utf8');
'utf8',
);
const contextView = readRadioSource(
new URL('./ui/views/ContextView.vue', import.meta.url),
'utf8',
);
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
const html = await renderApplicationHtml( const html = await renderApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
readFileSync(new URL('../index.html', import.meta.url), 'utf8'),
);
const css = readStylesheet(new URL('../style.css', import.meta.url)); const css = readStylesheet(new URL('../style.css', import.meta.url));
test('Contacts and Space Missions both participate in the ordinary Tab sequence', () => { test('Contacts and Space Missions both participate in the ordinary Tab sequence', () => {
for (const id of [ for (const id of ['global-context-flights-btn', 'global-context-missions-btn']) {
'global-context-flights-btn', const button = html.match(new RegExp(`<button id="${id}"[\\s\\S]*?</button>`));
'global-context-missions-btn',
]) {
const button = html.match(
new RegExp(`<button id="${id}"[\\s\\S]*?</button>`),
);
assert.ok(button, `${id} is missing`); assert.ok(button, `${id} is missing`);
assert.match(button[0], /role="tab"/); assert.match(button[0], /role="tab"/);
assert.match(button[0], /tabindex="0"/); assert.match(button[0], /tabindex="0"/);
@@ -36,10 +23,7 @@ test('Contacts and Space Missions both participate in the ordinary Tab sequence'
// The view keeps both tabs' tabindex static and never disables them; the // The view keeps both tabs' tabindex static and never disables them; the
// transition itself is covered over happy-dom in ui/contextPanel.test.mjs. // transition itself is covered over happy-dom in ui/contextPanel.test.mjs.
for (const id of [ for (const id of ['global-context-flights-btn', 'global-context-missions-btn']) {
'global-context-flights-btn',
'global-context-missions-btn',
]) {
const tag = contextView.match(new RegExp(`<button id="${id}"[^>]*>`))?.[0]; const tag = contextView.match(new RegExp(`<button id="${id}"[^>]*>`))?.[0];
assert.ok(tag, `${id} is missing from the view`); assert.ok(tag, `${id} is missing from the view`);
assert.doesNotMatch(tag, /:tabindex|:disabled|\sdisabled/); assert.doesNotMatch(tag, /:tabindex|:disabled|\sdisabled/);
@@ -50,22 +34,9 @@ test('Context activation and Clear All never native-disable tabs and guard repea
const init = _initGlobalContextPanel.toString(); const init = _initGlobalContextPanel.toString();
const select = _selectContextMode.toString(); const select = _selectContextMode.toString();
const clear = clearSelectedLayers.toString(); const clear = clearSelectedLayers.toString();
assert.equal( assert.equal((init.match(/if \(\s*this\.destroyed\s*\|\|\s*this\._contextModeChanging\s*\|\|\s*this\._clearSelectedLayersPromise\s*\)\s*return;/g) || []).length, 2);
( assert.doesNotMatch(select, /_globalContext(?:Flights|Missions)Btn\.disabled\s*=\s*true/);
init.match( assert.doesNotMatch(clear, /_globalContext(?:Flights|Missions)Btn\.disabled\s*=\s*true/);
/if \(\s*this\.destroyed\s*\|\|\s*this\._contextModeChanging\s*\|\|\s*this\._clearSelectedLayersPromise\s*\)\s*return;/g,
) || []
).length,
2,
);
assert.doesNotMatch(
select,
/_globalContext(?:Flights|Missions)Btn\.disabled\s*=\s*true/,
);
assert.doesNotMatch(
clear,
/_globalContext(?:Flights|Missions)Btn\.disabled\s*=\s*true/,
);
}); });
test('Context tablist retains Left, Right, Home, and End keyboard navigation', () => { test('Context tablist retains Left, Right, Home, and End keyboard navigation', () => {
@@ -74,62 +45,40 @@ test('Context tablist retains Left, Right, Home, and End keyboard navigation', (
assert.match(initSource, /event\.key === 'ArrowLeft'/); assert.match(initSource, /event\.key === 'ArrowLeft'/);
assert.match(initSource, /event\.key === 'Home'/); assert.match(initSource, /event\.key === 'Home'/);
assert.match(initSource, /event\.key === 'End'/); assert.match(initSource, /event\.key === 'End'/);
assert.match( assert.match(initSource, /contextTabs\[nextIndex\]\.focus\(\{ preventScroll: true \}\)/);
initSource,
/contextTabs\[nextIndex\]\.focus\(\{ preventScroll: true \}\)/,
);
assert.match(initSource, /contextTabs\[nextIndex\]\.click\(\)/); assert.match(initSource, /contextTabs\[nextIndex\]\.click\(\)/);
}); });
test('Context tabs draw a visible keyboard-focus outline including active tabs', () => { test('Context tabs draw a visible keyboard-focus outline including active tabs', () => {
const rules = [...css.matchAll(/([^{}]+)\{([^{}]*)\}/g)]; const rules = [...css.matchAll(/([^{}]+)\{([^{}]*)\}/g)];
const focusRule = rules.find( const focusRule = rules.find(([, selector, body]) => (
([, selector, body]) => selector.trim().endsWith('.context-mode-button:focus-visible')
selector.trim().endsWith('.context-mode-button:focus-visible') && && /outline:\s*2px solid var\(--text-primary\)/.test(body)
/outline:\s*2px solid var\(--text-primary\)/.test(body), ));
); assert.ok(focusRule, 'Context focus-visible rule must draw a two-pixel outline');
assert.ok(
focusRule,
'Context focus-visible rule must draw a two-pixel outline',
);
assert.match(focusRule[2], /outline-offset:\s*-3px/); assert.match(focusRule[2], /outline-offset:\s*-3px/);
const activeRule = rules.find(([, selector]) => const activeRule = rules.find(([, selector]) => selector.trim().endsWith('.context-mode-button.active'));
selector.trim().endsWith('.context-mode-button.active'),
);
assert.ok(activeRule, 'Context active-state rule is missing'); assert.ok(activeRule, 'Context active-state rule is missing');
assert.doesNotMatch(activeRule[2], /outline:\s*none/); assert.doesNotMatch(activeRule[2], /outline:\s*none/);
}); });
test('Context async action buttons remain focused while busy', () => { test('Context async action buttons remain focused while busy', () => {
const init = _initGlobalContextPanel.toString(); const init = _initGlobalContextPanel.toString();
const radio = radioBindings.slice( const radio = radioBindings.slice(radioBindings.indexOf('const toggleRadio = async (trigger) => {'), radioBindings.indexOf('this._storeToken = this.store?.connect?.('));
radioBindings.indexOf('const toggleRadio = async (trigger) => {'), const radioPanel = readFileSync(new URL('./ui/views/panels/RadioPanel.vue', import.meta.url), 'utf8');
radioBindings.indexOf('this._storeToken = this.store?.connect?.('),
);
const radioPanel = readFileSync(
new URL('./ui/views/panels/RadioPanel.vue', import.meta.url),
'utf8',
);
const clear = clearSelectedLayers.toString(); const clear = clearSelectedLayers.toString();
assert.match(init, /if \(this\._installationsSearchBusy\) return;/); assert.match(init, /if \(this\._installationsSearchBusy\) return;/);
assert.doesNotMatch(init, /\.disabled\s*=\s*true/); assert.doesNotMatch(init, /\.disabled\s*=\s*true/);
const search = contextView.match( const search = contextView.match(/<button id="installations-search-btn"[^>]*>/)?.[0];
/<button id="installations-search-btn"[^>]*>/,
)?.[0];
assert.ok(search, 'nearby sites search is missing'); assert.ok(search, 'nearby sites search is missing');
assert.doesNotMatch(search, /:disabled|\sdisabled/); assert.doesNotMatch(search, /:disabled|\sdisabled/);
assert.match(search, /:aria-busy=/); assert.match(search, /:aria-busy=/);
assert.match(radio, /trigger\.getAttribute\('aria-busy'\) === 'true'/); assert.match(radio, /trigger\.getAttribute\('aria-busy'\) === 'true'/);
assert.doesNotMatch(radio, /trigger\.disabled\s*=\s*true/); assert.doesNotMatch(radio, /trigger\.disabled\s*=\s*true/);
const cockpitRadio = readFileSync( const cockpitRadio = readFileSync(new URL('./ui/views/panels/CockpitUtilityControls.vue', import.meta.url), 'utf8');
new URL('./ui/views/panels/CockpitUtilityControls.vue', import.meta.url), const cockpitEnable = cockpitRadio.match(/<UiButton id="cockpit-radio-enable-btn"[^>]*>/)?.[0];
'utf8',
);
const cockpitEnable = cockpitRadio.match(
/<UiButton id="cockpit-radio-enable-btn"[^>]*>/,
)?.[0];
assert.ok(cockpitEnable, 'Cockpit Radio Enable button is missing'); assert.ok(cockpitEnable, 'Cockpit Radio Enable button is missing');
assert.doesNotMatch(cockpitEnable, /disabled(?!=")|:disabled/); assert.doesNotMatch(cockpitEnable, /disabled(?!=")|:disabled/);
assert.match(cockpitEnable, /:aria-busy=/); assert.match(cockpitEnable, /:aria-busy=/);
@@ -140,84 +89,44 @@ test('Context async action buttons remain focused while busy', () => {
assert.match(enable, /:aria-busy=/); assert.match(enable, /:aria-busy=/);
assert.match(clear, /this\.setClearBusy\(true\)/); assert.match(clear, /this\.setClearBusy\(true\)/);
// The globe actions view marks the clear button busy, never disabled. // The globe actions view marks the clear button busy, never disabled.
const globeActions = readFileSync( const globeActions = readFileSync(new URL('./ui/views/panels/GlobeActions.vue', import.meta.url), 'utf8');
new URL('./ui/views/panels/GlobeActions.vue', import.meta.url), const clearButton = globeActions.match(/<button id="clear-selected-layers"[^>]*>/)?.[0];
'utf8',
);
const clearButton = globeActions.match(
/<button id="clear-selected-layers"[^>]*>/,
)?.[0];
assert.ok(clearButton, 'Clear selected layers button is missing'); assert.ok(clearButton, 'Clear selected layers button is missing');
assert.doesNotMatch(clearButton, /disabled(?!=")|:disabled=/); assert.doesNotMatch(clearButton, /disabled(?!=")|:disabled=/);
assert.match(clearButton, /:aria-busy=/); assert.match(clearButton, /:aria-busy=/);
}); });
test('Context header holds only its title, edge switch and collapse control', () => { test('Context header holds only its title and collapse control', () => {
const panel = html.match( const panel = html.match(/<div id="global-context-panel"[\s\S]*?<div class="cyber-panel-body[ "]/);
/<div id="global-context-panel"[\s\S]*?<div class="cyber-panel-body[ "]/,
);
assert.ok(panel, 'Global Context panel is missing'); assert.ok(panel, 'Global Context panel is missing');
const header = panel[0].match( const header = panel[0].match(/<div class="ui-panel__head panel-header">([\s\S]*?)<\/div>\s*<div class="cyber-panel-body[ "]/);
/<div class="ui-panel__head panel-header">([\s\S]*?)<\/div>\s*<div class="cyber-panel-body[ "]/,
);
assert.ok(header, 'Global Context header is missing'); assert.ok(header, 'Global Context header is missing');
// The kit's own ui-* classes carry no Core styles. // The kit's own ui-* classes carry no Core styles.
const tags = [ const tags = [...header[1].matchAll(/<(\w+)\s+(?:type="button"\s+)?class="([^"]+)"/g)].map(([, tag, cls]) =>
...header[1].matchAll(/<(\w+)\s+(?:type="button"\s+)?class="([^"]+)"/g), `${tag}.${cls.split(' ').filter((name) => !/^ui-/.test(name)).join(' ')}`);
].map( assert.deepEqual(tags, ['span.panel-title', 'span.panel-divider', 'button.panel-collapse-btn']);
([, tag, cls]) =>
`${tag}.${cls
.split(' ')
.filter((name) => !/^ui-/.test(name))
.join(' ')}`,
);
assert.deepEqual(tags, [
'span.panel-title',
'span.panel-divider',
'button.panel-edge-btn',
'span.material-symbols-outlined',
'button.panel-collapse-btn',
]);
}); });
test('Context reads as one segmented control with legible type and no halo', () => { test('Context reads as one segmented control with legible type and no halo', () => {
const rules = [...css.matchAll(/([^{}]+)\{([^{}]*)\}/g)]; const rules = [...css.matchAll(/([^{}]+)\{([^{}]*)\}/g)];
const clean = (selector) => const clean = (selector) => selector.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\s+/g, ' ').trim();
selector const rule = (selector) => rules.find(([, s]) => clean(s) === selector)?.[2] || '';
.replace(/\/\*[\s\S]*?\*\//g, '') const px = (body, prop) => Number(body.match(new RegExp(`${prop}:[^;]*?(\\d+(?:\\.\\d+)?)px`))?.[1] || 0);
.replace(/\s+/g, ' ')
.trim();
const rule = (selector) =>
rules.find(([, s]) => clean(s) === selector)?.[2] || '';
const px = (body, prop) =>
Number(
body.match(new RegExp(`${prop}:[^;]*?(\\d+(?:\\.\\d+)?)px`))?.[1] || 0,
);
assert.match(rule('#global-context-panel .panel-glow'), /display:\s*none/); assert.match(rule('#global-context-panel .panel-glow'), /display:\s*none/);
const modes = rule('.global-context-modes'); const modes = rule('.global-context-modes');
assert.match(modes, /border:\s*1px solid/); assert.match(modes, /border:\s*1px solid/);
assert.match(modes, /padding:\s*2px/); assert.match(modes, /padding:\s*2px/);
assert.ok( assert.ok(px(rule('.context-mode-button'), 'font') >= 10, 'mode labels must stay at least 10px');
px(rule('.context-mode-button'), 'font') >= 10,
'mode labels must stay at least 10px',
);
assert.doesNotMatch(rule('.context-mode-button.active'), /box-shadow/); assert.doesNotMatch(rule('.context-mode-button.active'), /box-shadow/);
const standby = rule('.context-mode-standby'); const standby = rule('.context-mode-standby');
assert.ok( assert.ok(px(standby, 'font') >= 10, 'standby guidance must stay at least 10px');
px(standby, 'font') >= 10,
'standby guidance must stay at least 10px',
);
assert.doesNotMatch(standby, /dashed/); assert.doesNotMatch(standby, /dashed/);
assert.ok( assert.ok(px(rule('.global-context-actions .panel-layer-toggle'), 'font-size') >= 10);
px(rule('.global-context-actions .panel-layer-toggle'), 'font-size') >= 10,
);
// The shared rail title colour (brighter than --text-secondary) keeps the // The shared rail title colour (brighter than --text-secondary) keeps the
// open Context title legible; no Context-only rule dims it again. // open Context title legible; no Context-only rule dims it again.
assert.equal( assert.equal(
rule( rule(":root:not([data-ui-theme='cyber']) #global-context-panel:not(.collapsed) .panel-title"),
":root:not([data-ui-theme='cyber']) #global-context-panel:not(.collapsed) .panel-title",
),
'', '',
); );
}); });
-9
View File
@@ -700,15 +700,6 @@ export const NATURAL_EARTH_CREDIT = {
'<a href="https://www.naturalearthdata.com" target="_blank" rel="noopener">Natural Earth</a> (public domain)', '<a href="https://www.naturalearthdata.com" target="_blank" rel="noopener">Natural Earth</a> (public domain)',
}; };
/** Registered when a name from the bundled Wikidata places packs is first
* shown (CC0 — no attribution required; credited as a courtesy). */
export const WIKIDATA_PLACES_CREDIT = {
key: 'wikidata-places',
html:
'City, town and village names from ' +
'<a href="https://www.wikidata.org" target="_blank" rel="noopener">Wikidata</a> (CC0)',
};
/** Registered when the first bundled US county or place outline resolves /** Registered when the first bundled US county or place outline resolves
* (public domain — credited as a courtesy). */ * (public domain — credited as a courtesy). */
export const US_CENSUS_CREDIT = { export const US_CENSUS_CREDIT = {
-33
View File
@@ -25,7 +25,6 @@ import {
vesselModelSpec, vesselModelSpec,
} from './fleetModels.js'; } from './fleetModels.js';
import { FLEET_TRAIL_ANCHORS } from './fleetTrailAnchors.js'; import { FLEET_TRAIL_ANCHORS } from './fleetTrailAnchors.js';
import { TRANSIT_MODES } from './transitFeeds.js';
import { import {
centrelineSegments, centrelineSegments,
distanceToSurface, distanceToSurface,
@@ -445,38 +444,6 @@ test('aids to navigation are the buoy of the mark their type names', () => {
} }
}); });
test('every transit mode but unknown draws its own model, and a route type a finer one', () => {
for (const mode of TRANSIT_MODES.filter((m) => m !== 'unknown')) {
assert.ok(types.transit.modes[mode], `${mode} has a model`);
assert.equal(
resolveTransit(types, { mode }).model,
types.transit.modes[mode][0],
);
}
for (const [mode, model] of [
['trolleybus', 'trolleybus'],
['cable_tram', 'cable_tram'],
['aerial_lift', 'gondola'],
['funicular', 'funicular'],
['monorail', 'monorail'],
])
assert.equal(resolveTransit(types, { mode }).model, model, mode);
// A route type outranks the mode: a coach and a high-speed set are still
// the bus and rail modes.
for (const [routeType, mode, model] of [
[200, 'bus', 'coach'],
[101, 'rail', 'high_speed'],
[106, 'rail', 'regional_dmu'],
[710, 'bus', 'double_decker'],
])
assert.equal(resolveTransit(types, { routeType, mode }).model, model);
// A type the table lacks falls back to the mode.
assert.equal(
resolveTransit(types, { routeType: 300, mode: 'rail' }).model,
'commuter_emu',
);
});
test('transit specs: rail category and mode, then route type, then mode, then the default', () => { test('transit specs: rail category and mode, then route type, then mode, then the default', () => {
assert.deepEqual(resolveTransit(types, { railCategory: 'national' }), { assert.deepEqual(resolveTransit(types, { railCategory: 'national' }), {
model: 'intercity', model: 'intercity',
+1
View File
@@ -196,6 +196,7 @@ test('consumer build includes only infrastructure code and resolves assets under
'infrastructureOverlayEntry.js', 'infrastructureOverlayEntry.js',
'localGeojsonCore.js', 'localGeojsonCore.js',
'localGeojsonLod.js', 'localGeojsonLod.js',
'terrainSurface.js',
]); ]);
assert.deepEqual( assert.deepEqual(
entry.imports, entry.imports,
+185 -5
View File
@@ -23,9 +23,10 @@ import {
} from '../renderGovernor.js'; } from '../renderGovernor.js';
/** The catalog's services, with the overlay host each test supplies. */ /** The catalog's services, with the overlay host each test supplies. */
const createLocalGeoJsonLayer = (options) => const createLocalGeoJsonLayer = (options, services = {}) =>
createLayer(options, { createLayer(options, {
...localGeoJsonServices, ...localGeoJsonServices,
...services,
overlayHost: options.overlayHost ?? localGeoJsonServices.overlayHost, overlayHost: options.overlayHost ?? localGeoJsonServices.overlayHost,
}); });
@@ -58,10 +59,15 @@ class MockLayerEvent {
* @param {boolean} [options.sampleHeightSupported] Scene height-sampling capability. * @param {boolean} [options.sampleHeightSupported] Scene height-sampling capability.
* @param {Function} [options.sampleHeight] Initial scene.sampleHeight behavior * @param {Function} [options.sampleHeight] Initial scene.sampleHeight behavior
* (default: throws like a scene whose tiles are not sampleable yet). * (default: throws like a scene whose tiles are not sampleable yet).
* @param {number[][]} [options.points] `[lon, lat]` sites to load
* instead of the single dam polygon.
* @param {object} [options.services] Extra services for the layer.
*/ */
async function createRealLocalLayerHarness({ async function createRealLocalLayerHarness({
sampleHeightSupported = false, sampleHeightSupported = false,
sampleHeight = () => { throw new Error('tiles not sampleable yet'); }, sampleHeight = () => { throw new Error('tiles not sampleable yet'); },
points = null,
services = {},
} = {}) { } = {}) {
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;
const originalWindow = globalThis.window; const originalWindow = globalThis.window;
@@ -72,7 +78,22 @@ async function createRealLocalLayerHarness({
globalThis.fetch = async () => ({ globalThis.fetch = async () => ({
ok: true, ok: true,
status: 200, status: 200,
text: async () => JSON.stringify({ text: async () => points ? points.map(([lon, lat], index) => JSON.stringify({
type: 'Feature',
id: `site-${index}`,
properties: { name: `Site ${index}` },
// A small outline: the layer stems its centre. (Cesium draws a point
// feature as a canvas pin, which Node cannot make.)
geometry: {
type: 'Polygon',
coordinates: [[
[lon - 0.001, lat - 0.001],
[lon + 0.001, lat - 0.001],
[lon, lat + 0.001],
[lon - 0.001, lat - 0.001],
]],
},
})).join('\n') : JSON.stringify({
type: 'Feature', type: 'Feature',
id: 'real-dam', id: 'real-dam',
properties: { name: 'Runtime Dam', tags: { associated_river: 'Test River' } }, properties: { name: 'Runtime Dam', tags: { associated_river: 'Test River' } },
@@ -149,7 +170,7 @@ async function createRealLocalLayerHarness({
setInputAction() {}, setInputAction() {},
destroy() {}, destroy() {},
}), }),
}); }, services);
try { try {
await layer.enable(viewer); await layer.enable(viewer);
} finally { } finally {
@@ -903,12 +924,13 @@ test('settled terrain refinement lifts an already sampled stem without another G
env.preRender.raise(); env.preRender.raise();
assert.ok(Math.abs(baseHeightM(env) - 117) < 0.01, 'wait for the refined mesh to settle'); assert.ok(Math.abs(baseHeightM(env) - 117) < 0.01, 'wait for the refined mesh to settle');
globe.tilesLoaded = true; globe.tilesLoaded = true;
clock.advance(500); // The terrain surface reuses a height for 2 s before reading the mesh again.
clock.advance(2_100);
env.preRender.raise(); env.preRender.raise();
assert.ok(Math.abs(baseHeightM(env) - 120) < 0.01, 'a parked marker must follow the refined floor'); assert.ok(Math.abs(baseHeightM(env) - 120) < 0.01, 'a parked marker must follow the refined floor');
terrain = 110; terrain = 110;
clock.advance(500); clock.advance(2_100);
env.preRender.raise(); env.preRender.raise();
assert.ok(Math.abs(baseHeightM(env) - 120) < 0.01, 'a lower floor must not flatten existing geometry'); assert.ok(Math.abs(baseHeightM(env) - 120) < 0.01, 'a lower floor must not flatten existing geometry');
terrain = 140; terrain = 140;
@@ -1493,3 +1515,161 @@ test('globe-LOD re-selects during continuous motion, without ever seeing a moveE
assert.deepEqual(env.layer.getLodDiagnostics(), parked, 'a parked camera never re-selects'); assert.deepEqual(env.layer.getLodDiagnostics(), parked, 'a parked camera never re-selects');
}); });
/** A loaded terrain globe that counts its ray casts. */
function countingTerrain(height) {
const globe = {
show: true,
tilesLoaded: true,
reads: 0,
getHeight: () => { globe.reads += 1; return height; },
};
return globe;
}
/** Height of every stem base, in entity order. */
function allBaseHeightsM(env) {
return env.dataSources[0].entities.values.map(
(entity) => Cesium.Cartographic.fromCartesian(entity.__localBaseCartesian).height,
);
}
const SAMPLE_SITES = [
[-97.70, 30.20], [-97.69, 30.20], [-97.70, 30.21], [-97.69, 30.21], [-97.695, 30.205],
];
test('repeated walks over a grounded stem reuse its terrain height instead of re-casting it', async (t) => {
const env = await createRealLocalLayerHarness({
sampleHeightSupported: true, sampleHeight: () => 117,
});
const clock = installFakeClock(t);
t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); });
const globe = countingTerrain(117);
env.viewer.scene.globe = globe;
setCameraAltitude(env, 20_000);
for (let walk = 0; walk < 40; walk += 1) {
env.preRender.raise();
clock.advance(500);
}
assert.ok(Math.abs(baseHeightM(env) - 117) < 0.01);
// 20 s parked: a few reads while the surface settles, then none.
assert.ok(globe.reads <= 8, `${globe.reads} terrain ray casts over 40 parked walks`);
const settled = globe.reads;
for (let walk = 0; walk < 20; walk += 1) {
env.preRender.raise();
clock.advance(500);
}
assert.equal(globe.reads, settled, 'a parked camera over settled terrain casts no more rays');
});
test('repeated walks past an unseen base do not re-cast its terrain height', async (t) => {
const env = await createRealLocalLayerHarness({
sampleHeightSupported: true, sampleHeight: () => 117,
});
const clock = installFakeClock(t);
t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); });
const globe = countingTerrain(117);
env.viewer.scene.globe = globe;
setCameraAltitude(env, 20_000);
env.setBaseInView(false);
for (let walk = 0; walk < 40; walk += 1) {
env.preRender.raise();
clock.advance(500);
}
assert.equal(env.sampleCalls.count, 0);
assert.ok(globe.reads <= 8, `${globe.reads} terrain ray casts over 40 walks past an unseen base`);
});
test('terrain heights come from the shared surface the host supplies, in degrees', async (t) => {
const asked = [];
const terrainSurface = {
height: (viewer, lat, lon, need) => { asked.push({ viewer, lat, lon, need }); return 130; },
};
const env = await createRealLocalLayerHarness({
sampleHeightSupported: true, sampleHeight: () => 117,
points: [[-97.695, 30.205]],
services: { terrainSurface },
});
const clock = installFakeClock(t);
t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); });
const globe = countingTerrain(117);
env.viewer.scene.globe = globe;
setCameraAltitude(env, 20_000);
env.preRender.raise();
assert.ok(Math.abs(baseHeightM(env) - 117) < 0.01, 'the sample is floored by the globe itself');
clock.advance(500);
env.preRender.raise();
assert.ok(asked.length >= 2);
for (const call of asked) {
assert.equal(call.viewer, env.viewer);
assert.ok(Math.abs(call.lat - 30.205) < 0.002 && Math.abs(call.lon + 97.695) < 0.002,
`asked at ${call.lat}, ${call.lon}`);
}
assert.ok(Math.abs(baseHeightM(env) - 130) < 0.01, 'the refined floor comes from the shared surface');
assert.equal(globe.reads, 1, 'only the floor under a fresh sample is read from the globe directly');
});
test('a walk of expensive ground samples stops at its budget and later frames finish the rest', async (t) => {
const heights = new Map();
let clock = null;
const env = await createRealLocalLayerHarness({
sampleHeightSupported: true,
points: SAMPLE_SITES,
sampleHeight: (carto) => {
clock.advance(15); // one GPU depth render
const height = 100 + heights.size;
heights.set(`${carto.longitude},${carto.latitude}`, height);
return height;
},
});
_resetRenderGovernorForTest();
const governorScene = { renders: 0, requestRender() { this.renders += 1; } };
installRenderGovernor({ scene: governorScene });
governorScene.renders = 0; // discard the governor's own install settling frame
clock = installFakeClock(t);
t.after(() => {
env.layer.destroy(env.viewer);
env.cleanup();
_resetRenderGovernorForTest();
});
setCameraAltitude(env, 20_000);
env.preRender.raise();
assert.equal(env.sampleCalls.count, 1, 'one 15 ms sample spends the walk budget');
assert.equal(governorScene.renders, 1, 'the walk asks for the frame that continues it');
// The following frames arrive well inside the 450 ms walk interval.
for (let frame = 2; frame <= SAMPLE_SITES.length; frame += 1) {
clock.advance(16);
env.preRender.raise();
assert.equal(env.sampleCalls.count, frame, 'each frame takes up where the last stopped');
}
assert.equal(governorScene.renders, SAMPLE_SITES.length - 1, 'no frame is requested once every site is sampled');
clock.advance(16);
env.preRender.raise();
assert.equal(env.sampleCalls.count, SAMPLE_SITES.length, 'nothing is sampled twice');
const expected = env.dataSources[0].entities.values.map((entity) => {
const carto = entity.__localBaseCarto;
return heights.get(`${carto.longitude},${carto.latitude}`);
});
assert.deepEqual(allBaseHeightsM(env).map((h) => Math.round(h * 100) / 100), expected);
for (const entity of env.dataSources[0].entities.values) {
const stem = entity.polyline.positions.getValue();
assert.ok(Cesium.Cartesian3.equals(stem[0], entity.__localBaseCartesian), 'each stem stands on its sampled base');
}
});
test('cheap ground samples are all taken on the first walk, at the same heights as paced ones', async (t) => {
const env = await createRealLocalLayerHarness({
sampleHeightSupported: true,
points: SAMPLE_SITES,
sampleHeight: () => 117,
});
installFakeClock(t);
t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); });
setCameraAltitude(env, 20_000);
env.preRender.raise();
assert.equal(env.sampleCalls.count, SAMPLE_SITES.length);
for (const height of allBaseHeightsM(env)) assert.ok(Math.abs(height - 117) < 0.01);
});
+134 -15
View File
@@ -11,6 +11,10 @@ import {
applyInfraEvictionGrace, applyInfraEvictionGrace,
shouldRecomputeInfraLod, shouldRecomputeInfraLod,
} from './localGeojsonLod.js'; } from './localGeojsonLod.js';
import {
createTerrainSurface,
TERRAIN_NEED,
} from '../services/terrainSurface.js';
const DEFAULT_LABEL_MAX = 900; const DEFAULT_LABEL_MAX = 900;
const DEFAULT_LABEL_GRID_PX = 132; const DEFAULT_LABEL_GRID_PX = 132;
@@ -38,6 +42,15 @@ const GROUND_SAMPLE_MAX_ABS_HEIGHT_M = 9000;
* 30 × 2 s ≈ 60 s, far longer than a tile stream-in. * 30 × 2 s ≈ 60 s, far longer than a tile stream-in.
*/ */
export const GROUND_SAMPLE_MAX_ARMED_RETRIES = 30; export const GROUND_SAMPLE_MAX_ARMED_RETRIES = 30;
/**
* Milliseconds of scene.sampleHeight one frame may spend. Each call is an
* offscreen depth render (about 15 ms measured), and a first walk near a dense
* cluster wants one per site in view: 168 of them held the main thread for
* 2.6 s. Past the budget the remaining sites queue, and each following frame
* takes as many as fit, so the sites reach the same heights a frame or a few
* apart instead of all inside one blocked frame.
*/
export const GROUND_SAMPLE_FRAME_BUDGET_MS = 8;
/** Ignore sub-metre camera-derived stem-tip noise at camera settle. */ /** Ignore sub-metre camera-derived stem-tip noise at camera settle. */
export const LOCAL_STEM_TIP_EPSILON_M = 0.5; export const LOCAL_STEM_TIP_EPSILON_M = 0.5;
const LOCAL_STEM_TIP_EPSILON_SQ = LOCAL_STEM_TIP_EPSILON_M ** 2; const LOCAL_STEM_TIP_EPSILON_SQ = LOCAL_STEM_TIP_EPSILON_M ** 2;
@@ -336,8 +349,20 @@ export function createLocalGeoJsonLayer(
governorRequestRender, governorRequestRender,
showOsmCredit, showOsmCredit,
hideOsmCredit, hideOsmCredit,
terrainSurface,
}, },
) { ) {
/**
* What ground sampling shares across a layer's records: the terrain
* surface (the application's, so every layer reads a point once and spends
* one budget, or this layer's own), the sample time spent this frame, and
* the records the frame budget left for a later frame.
*/
const _ground = {
terrain: terrainSurface || createTerrainSurface(),
sampleMs: 0,
queued: [],
};
let _dataSource = null; let _dataSource = null;
let _enabled = false; let _enabled = false;
let _clickHandler = null; let _clickHandler = null;
@@ -444,6 +469,42 @@ export function createLocalGeoJsonLayer(
clearTimeout(_groundRetryTimer); clearTimeout(_groundRetryTimer);
_groundRetryTimer = null; _groundRetryTimer = null;
} }
/**
* Sample the sites an earlier frame's budget left over, within this
* frame's. They were never attempted, so the 2 s retry spacing does not
* apply to them.
* @param {Cesium.Viewer} viewer
* @param {number} now
* @returns {void}
*/
function sampleQueuedGround(viewer, now) {
const queued = _ground.queued;
if (queued.length === 0) return;
_ground.queued = [];
for (const record of queued) {
record.groundSampleQueued = false;
// The camera may have left the site since it queued.
if (_lodComputed && !_activeLodIds.has(record.id)) continue;
const distance = Cesium.Cartesian3.distance(
viewer.camera.positionWC,
record.base,
);
if (distance >= GROUND_SAMPLE_MAX_DISTANCE_M) continue;
// Past the budget this puts the record back in the queue.
if (sampleLocalGroundHeight(viewer, record, now, _ground)) {
updateLocalStemGeometry(viewer, record, now, _ground, distance);
_groundRetryArms = 0;
}
}
}
/** Ask for the frame that samples what this one's budget left over. */
function requestQueuedGroundFrame() {
if (_ground.queued.length > 0)
governorRequestRender(`local-ground-sample:${id}`);
}
const _overlayPublisher = createLocalInfrastructureOverlayPublisher({ const _overlayPublisher = createLocalInfrastructureOverlayPublisher({
sourceId: id, sourceId: id,
host: overlayHost, host: overlayHost,
@@ -472,6 +533,7 @@ export function createLocalGeoJsonLayer(
const source = _dataSource; const source = _dataSource;
_dataSource = null; _dataSource = null;
_stemRecords = []; _stemRecords = [];
_ground.queued = [];
_stemGeometryDirty = true; _stemGeometryDirty = true;
_lastVisibilityUpdate = Number.NEGATIVE_INFINITY; _lastVisibilityUpdate = Number.NEGATIVE_INFINITY;
removeEntityContextsForLayer(id); removeEntityContextsForLayer(id);
@@ -744,6 +806,8 @@ export function createLocalGeoJsonLayer(
// Store references for bounded stem scaling and native picking. // Store references for bounded stem scaling and native picking.
feature.__localBaseCarto = carto; feature.__localBaseCarto = carto;
feature.__localBaseCartesian = base; feature.__localBaseCartesian = base;
const latDeg = Cesium.Math.toDegrees(carto.latitude);
const lonDeg = Cesium.Math.toDegrees(carto.longitude);
const context = registerEntityContext(feature, { const context = registerEntityContext(feature, {
id: `${id}:${recordId}`, id: `${id}:${recordId}`,
layerId: id, layerId: id,
@@ -795,7 +859,11 @@ export function createLocalGeoJsonLayer(
stemPositionBuffers, stemPositionBuffers,
stemPositionBufferIndex: 0, stemPositionBufferIndex: 0,
groundHeight, groundHeight,
// Degrees, as the terrain surface takes them.
latDeg,
lonDeg,
groundSampled: false, groundSampled: false,
groundSampleQueued: false,
lastGroundSampleMs: 0, lastGroundSampleMs: 0,
priority, priority,
entry: labels entry: labels
@@ -916,7 +984,13 @@ export function createLocalGeoJsonLayer(
_preRenderRemover = viewer.scene.preRender.addEventListener(() => { _preRenderRemover = viewer.scene.preRender.addEventListener(() => {
if (!_enabled || !_dataSource) return; if (!_enabled || !_dataSource) return;
const now = performance.now(); const now = performance.now();
if (now - _lastVisibilityUpdate < VISIBILITY_UPDATE_MS) return; // One sample budget per frame, shared by the queue and the walk.
_ground.sampleMs = 0;
sampleQueuedGround(viewer, now);
if (now - _lastVisibilityUpdate < VISIBILITY_UPDATE_MS) {
requestQueuedGroundFrame();
return;
}
_lastVisibilityUpdate = now; _lastVisibilityUpdate = now;
const cameraPos = viewer.camera.positionWC; const cameraPos = viewer.camera.positionWC;
@@ -967,6 +1041,9 @@ export function createLocalGeoJsonLayer(
_lastGroundSampleCapability = canSampleGround; _lastGroundSampleCapability = canSampleGround;
let groundRetryPending = false; let groundRetryPending = false;
let groundSampleProgress = false; let groundSampleProgress = false;
// This walk asks for no height while tiles load, so it shows the
// surface the load itself; see terrainSurface.js observe().
_ground.terrain.observe?.(viewer);
// --- Globe-LOD: recompute the active-stem set on camera moves. --- // --- Globe-LOD: recompute the active-stem set on camera moves. ---
// `_stemGeometryDirty` is set by moveEnd, the first enable, and the // `_stemGeometryDirty` is set by moveEnd, the first enable, and the
@@ -1028,9 +1105,10 @@ export function createLocalGeoJsonLayer(
const terrainFloorChanged = refreshLocalTerrainFloor( const terrainFloorChanged = refreshLocalTerrainFloor(
viewer, viewer,
record, record,
_ground,
); );
if (refreshStemGeometry || terrainFloorChanged) { if (refreshStemGeometry || terrainFloorChanged) {
updateLocalStemGeometry(viewer, record, now); updateLocalStemGeometry(viewer, record, now, _ground);
} else if ( } else if (
canSampleGround && canSampleGround &&
!record.groundSampled && !record.groundSampled &&
@@ -1044,9 +1122,9 @@ export function createLocalGeoJsonLayer(
); );
if ( if (
distance < GROUND_SAMPLE_MAX_DISTANCE_M && distance < GROUND_SAMPLE_MAX_DISTANCE_M &&
sampleLocalGroundHeight(viewer, record, now) sampleLocalGroundHeight(viewer, record, now, _ground)
) { ) {
updateLocalStemGeometry(viewer, record, now, distance); updateLocalStemGeometry(viewer, record, now, _ground, distance);
} }
} }
if (!wasGroundSampled && record.groundSampled) if (!wasGroundSampled && record.groundSampled)
@@ -1058,16 +1136,19 @@ export function createLocalGeoJsonLayer(
// the user happens to move. Schedule the frame the retry needs. // the user happens to move. Schedule the frame the retry needs.
// Gated on a sampleable scene and in-range, in-view records only, // Gated on a sampleable scene and in-range, in-view records only,
// so a far camera (or a keyless scene) stays fully idle; the // so a far camera (or a keyless scene) stays fully idle; the
// distance is only computed for still-unsampled stems. // distance is only computed for still-unsampled stems. A record
// the frame budget queued is sampled by the frame the queue asks
// for, with no timer.
if ( if (
canSampleGround && canSampleGround &&
!record.groundSampled && !record.groundSampled &&
!record.groundSampleQueued &&
!groundRetryPending && !groundRetryPending &&
Cesium.Cartesian3.distance( Cesium.Cartesian3.distance(
viewer.camera.positionWC, viewer.camera.positionWC,
record.base, record.base,
) < GROUND_SAMPLE_MAX_DISTANCE_M && ) < GROUND_SAMPLE_MAX_DISTANCE_M &&
groundSampleInView(viewer, record) groundSampleInView(viewer, record, _ground)
) { ) {
groundRetryPending = true; groundRetryPending = true;
} }
@@ -1078,6 +1159,7 @@ export function createLocalGeoJsonLayer(
// so the records still waiting get their own bounded run of retries. // so the records still waiting get their own bounded run of retries.
if (groundSampleProgress) _groundRetryArms = 0; if (groundSampleProgress) _groundRetryArms = 0;
if (groundRetryPending) scheduleGroundRetryRender(viewer); if (groundRetryPending) scheduleGroundRetryRender(viewer);
requestQueuedGroundFrame();
const canvas = viewer.scene.canvas; const canvas = viewer.scene.canvas;
const cohort = selectLocalInfrastructureOverlayCohort( const cohort = selectLocalInfrastructureOverlayCohort(
@@ -1268,7 +1350,7 @@ function insertLocalCellContender(contenders, record) {
contenders.length = LOCAL_OVERLAY_CELL_SURPLUS; contenders.length = LOCAL_OVERLAY_CELL_SURPLUS;
} }
function refreshLocalTerrainFloor(viewer, record) { function refreshLocalTerrainFloor(viewer, record, ground) {
const globe = viewer.scene.globe; const globe = viewer.scene.globe;
if (!record.groundSampled || !globe?.show || globe.tilesLoaded === false) if (!record.groundSampled || !globe?.show || globe.tilesLoaded === false)
return false; return false;
@@ -1280,7 +1362,15 @@ function refreshLocalTerrainFloor(viewer, record) {
// Camera motion can refine terrain after the first successful depth sample. // Camera motion can refine terrain after the first successful depth sample.
// Reuse the loaded mesh on existing bounded walks; never arm a timer or do // Reuse the loaded mesh on existing bounded walks; never arm a timer or do
// another GPU readback just to maintain this floor. Keep roof elevations. // another GPU readback just to maintain this floor. Keep roof elevations.
const height = globe.getHeight?.(record.carto); // The surface keeps the height per point and casts the ray again only after
// the camera moves or tiles load. MARKER: the answer is this point's own
// height, never a neighbour's, since a height taken here is kept for good.
const height = ground.terrain.height(
viewer,
record.latDeg,
record.lonDeg,
TERRAIN_NEED.MARKER,
);
if ( if (
!Number.isFinite(height) || !Number.isFinite(height) ||
Math.abs(height) > GROUND_SAMPLE_MAX_ABS_HEIGHT_M || Math.abs(height) > GROUND_SAMPLE_MAX_ABS_HEIGHT_M ||
@@ -1323,11 +1413,20 @@ function groundSamplePoint(record, height, result) {
* sample can accept, so high or below-ellipsoid ground in view is never * sample can accept, so high or below-ellipsoid ground in view is never
* mistaken for an unseen base. Unseen ground is sampled on the first walk that * mistaken for an unseen base. Unseen ground is sampled on the first walk that
* sees it. * sees it.
*
* The terrain height comes from the shared surface, which keeps it per point:
* this runs for every unsampled site in range on every walk, and a ray cast
* each time was 17,733 casts and 1.3 s over one zoom. The view test needs the
* height only roughly, so a height the surface has not read yet, or tiles
* still loading, count as unknown ground.
*/ */
function groundSampleInView(viewer, record) { function groundSampleInView(viewer, record, ground) {
const camera = viewer.camera; const camera = viewer.camera;
const globe = viewer.scene.globe; const globe = viewer.scene.globe;
const terrain = globe?.show ? globe.getHeight?.(record.carto) : undefined; const terrain =
globe?.show && globe.tilesLoaded !== false
? ground.terrain.height(viewer, record.latDeg, record.lonDeg)
: undefined;
const known = const known =
Number.isFinite(terrain) && Number.isFinite(terrain) &&
Math.abs(terrain) <= GROUND_SAMPLE_MAX_ABS_HEIGHT_M; Math.abs(terrain) <= GROUND_SAMPLE_MAX_ABS_HEIGHT_M;
@@ -1368,18 +1467,30 @@ function groundSampleInView(viewer, record) {
); );
} }
function sampleLocalGroundHeight(viewer, record, now) { function sampleLocalGroundHeight(viewer, record, now, ground) {
if (record.groundSampled || !viewer.scene.sampleHeightSupported) return false; if (record.groundSampled || !viewer.scene.sampleHeightSupported) return false;
if (now - record.lastGroundSampleMs < GROUND_SAMPLE_RETRY_MS) return false; if (now - record.lastGroundSampleMs < GROUND_SAMPLE_RETRY_MS) return false;
if (!groundSampleInView(viewer, record)) return false; // This frame's samples have used its budget: leave the record for the next
// frame, unattempted, so the retry spacing does not hold it back.
if (ground.sampleMs >= GROUND_SAMPLE_FRAME_BUDGET_MS) {
if (!record.groundSampleQueued) {
record.groundSampleQueued = true;
ground.queued.push(record);
}
return false;
}
if (!groundSampleInView(viewer, record, ground)) return false;
record.lastGroundSampleMs = now; record.lastGroundSampleMs = now;
const globe = viewer.scene.globe; const globe = viewer.scene.globe;
if (globe?.show && globe.tilesLoaded === false) return false; if (globe?.show && globe.tilesLoaded === false) return false;
let sampled; let sampled;
const startedAt = performance.now();
try { try {
sampled = viewer.scene.sampleHeight(record.carto, [record.entity]); sampled = viewer.scene.sampleHeight(record.carto, [record.entity]);
} catch { } catch {
return false; // tiles not ready; retry on a later bounded update return false; // tiles not ready; retry on a later bounded update
} finally {
ground.sampleMs += performance.now() - startedAt;
} }
if ( if (
!Number.isFinite(sampled) || !Number.isFinite(sampled) ||
@@ -1389,7 +1500,9 @@ function sampleLocalGroundHeight(viewer, record, now) {
// A coarse scene-depth sample can lie below the terrain already loaded by // A coarse scene-depth sample can lie below the terrain already loaded by
// the visible globe. Keep that terrain as a floor while allowing roofs and // the visible globe. Keep that terrain as a floor while allowing roofs and
// valid below-sea-level elevations. Photoreal scenes hide the globe, so its // valid below-sea-level elevations. Photoreal scenes hide the globe, so its
// inactive terrain must not constrain their geometry. // inactive terrain must not constrain their geometry. Read from the globe
// itself, once per site: the floor under a sample must be the mesh that is
// loaded now, not a height kept from a moment before.
const terrainHeight = globe?.show const terrainHeight = globe?.show
? globe.getHeight?.(record.carto) ? globe.getHeight?.(record.carto)
: undefined; : undefined;
@@ -1416,12 +1529,18 @@ function setLocalGroundHeight(record, height) {
record.entity.__localBaseCartesian = record.base; record.entity.__localBaseCartesian = record.base;
} }
function updateLocalStemGeometry(viewer, record, now, knownDistance = null) { function updateLocalStemGeometry(
viewer,
record,
now,
ground,
knownDistance = null,
) {
const distance = Number.isFinite(knownDistance) const distance = Number.isFinite(knownDistance)
? knownDistance ? knownDistance
: Cesium.Cartesian3.distance(viewer.camera.positionWC, record.base); : Cesium.Cartesian3.distance(viewer.camera.positionWC, record.base);
if (distance < GROUND_SAMPLE_MAX_DISTANCE_M) if (distance < GROUND_SAMPLE_MAX_DISTANCE_M)
sampleLocalGroundHeight(viewer, record, now); sampleLocalGroundHeight(viewer, record, now, ground);
// Keep the intended screen-size scaling in close-up views too. A 5 km // Keep the intended screen-size scaling in close-up views too. A 5 km
// minimum made a marker hundreds of metres tall beside a nearby building. // minimum made a marker hundreds of metres tall beside a nearby building.
const effectiveDistance = Math.max(distance, 1); const effectiveDistance = Math.max(distance, 1);
@@ -1,42 +0,0 @@
# Wikidata cities pack
World cities and towns for naming a spot (the nearest city), the places
around it and finding a place by name, read by `src/data/wikidataCities.js`. The app reads this file; it
does not call Wikidata or any place service for these.
| File | Contents |
|------|----------|
| `cities.json` | Human settlements with coordinates and a population at or above the cut-off (see `meta.minPopulation`), largest first |
Each row is `[name, lat, lon, population, country, wikidata]`: the English
label, coordinates to three decimals, the largest stated population, the
ISO 3166-1 alpha-2 country code (empty when Wikidata gives none) and the
Wikidata id.
**Source:** Wikidata, read once from the Wikidata Query Service by
`scripts/build-wikidata-cities.mjs`. The build time and row count are in the
file's `meta` header.
**License:** CC0 1.0 (https://www.wikidata.org/wiki/Wikidata:Licensing). No
attribution is legally required.
**What is left out:** metropolitan areas, urban agglomerations, conurbations
and similar groupings of settlements, so a view is named after the city
rather than the area around it (an item of a narrower grouping kind that is
also a city stays: Istanbul is a metropolitan municipality and a megacity);
places that no longer stand (former entities, ghost towns, abandoned
villages, archaeological sites) unless they are also a living settlement;
and places Wikidata states as dissolved or abolished (P576) where another
place within 15 km still names the spot. A dissolved unit with nothing
else near it stays: many of Vietnam's provincial cities ended as units in
2025 and still stand under their names. Also left out: places below the population cut-off and
places without an English label.
**Rebuild:** `node scripts/build-wikidata-cities.mjs [--min-population 15000]`.
The build runs one query at a time with pauses and honours the service's
Retry-After; it takes about ten minutes. It fails if a short list of
well-known cities is missing from the result, or a known grouping gets in.
**Limits:** populations are whatever Wikidata states and differ in date and
definition between places (city proper, municipality, urban district). Some
districts and suburbs appear as their own rows.
@@ -1 +0,0 @@
{"meta":{"source":"Wikidata (https://www.wikidata.org), via the Wikidata Query Service","license":"CC0 1.0","built":"2026-11-15T02:59:26.673Z","minPopulation":15000,"count":31827,"columns":["name","lat","lon","population","country","wikidata"]},"rows":[["Chongqing",29.55,106.507,32054159,"CN","Q11725"],["Delhi",28.667,77.217,26495000,"IN","Q1353"],["Shanghai",31.233,121.469,24870895,"CN","Q8686"],["Beijing",39.904,116.408,21893095,"CN","Q956"],["Chengdu",30.66,104.063,20937757,"CN","Q30002"],["Guangzhou",23.13,113.26,18676605,"CN","Q16572"],["Shenzhen",22.547,114.054,17494398,"CN","Q15174"],["Dhaka",23.729,90.394,16800000,"BD","Q1354"],["Istanbul",41.01,28.96,15655924,"TR","Q406"],["Mumbai",19.076,72.877,15414288,"IN","Q1156"],["Karachi",24.86,67.01,14910352,"PK","Q8660"],["Kinshasa",-4.322,15.312,14565700,"CD","Q3838"],["Tokyo",35.689,139.692,14264798,"JP","Q1490"],["Ho Chi Minh City",10.776,106.702,14002598,"VN","Q1854"],["Tianjin",39.147,117.206,13866009,"CN","Q11736"],["Moscow",55.751,37.617,13274285,"RU","Q649"],["Xi'an",34.261,108.942,12952907,"CN","Q5826"],["Suzhou",31.3,120.619,12748262,"CN","Q42622"],["Zhengzhou",34.749,113.661,12600574,"CN","Q30340"],["Bengaluru",12.979,77.591,12327000,"IN","Q1355"],["Wuhan",30.595,114.297,12326518,"CN","Q11746"],["Hangzhou",30.25,120.168,11936010,"CN","Q4970"],["Baoding",38.867,115.485,11544036,"CN","Q58584"],["São Paulo",-23.55,-46.634,11451999,"BR","Q174"],["Jakarta",-6.175,106.827,11135191,"ID","Q3630"],["Lahore",31.55,74.344,11126285,"PK","Q11739"],["Linyi",35.104,118.35,11018365,"CN","Q373346"],["Shijiazhuang",38.042,114.51,10640458,"CN","Q58401"],["Dongguan",23.047,113.749,10466625,"CN","Q59218"],["Qingdao",36.117,120.4,10071722,"CN","Q170322"],["Changsha",28.199,112.971,10047914,"CN","Q174091"],["Harbin",45.75,126.633,10009854,"CN","Q42956"],["Lima",-12.06,-77.037,9943800,"PE","Q2868"],["Cairo",30.044,31.236,9801536,"EG","Q85"],["Nanyang",32.999,112.529,9713112,"CN","Q404763"],["Seoul",37.56,126.99,9668465,"KR","Q8684"],["Tokyo",35.684,139.774,9640742,"JP","Q7473516"],["Wenzhou",27.999,120.656,9572903,"CN","Q42635"],["Foshan",23.029,113.106,9498863,"CN","Q34412"],["Handan",36.612,114.489,9413990,"CN","Q58570"],["Ningbo",29.875,121.549,9404283,"CN","Q42780"],["Weifang",36.717,119.1,9386705,"CN","Q217698"],["Hefei",31.864,117.281,9369881,"CN","Q185684"],["Nanjing",32.061,118.779,9314685,"CN","Q16666"],["Mexico City",19.354,-99.136,9209944,"MX","Q1489"],["Jinan",36.667,116.983,9202432,"CN","Q170247"],["Hyderabad",17.362,78.475,9191000,"IN","Q1361"],["Xuzhou",34.261,117.186,9083790,"CN","Q57719"],["Shenyang",41.803,123.428,9070093,"CN","Q11720"],["Changchun",43.88,125.323,9066906,"CN","Q92161"],["Zhoukou",33.625,114.642,9026015,"CN","Q198061"],["Kuala Lumpur",3.148,101.695,9000280,"MY","Q1865"],["Ganzhou",25.829,114.934,8970014,"CN","Q363166"],["New York City",40.713,-74.006,8804190,"US","Q60"],["London",51.507,-0.128,8799728,"GB","Q84"],["Heze",35.233,115.433,8795939,"CN","Q332753"],["Quanzhou",24.914,118.586,8782285,"CN","Q68695"],["Nanning",22.819,108.315,8741584,"CN","Q179608"],["Hanoi",21.024,105.841,8717600,"VN","Q1858"],["Tehran",35.689,51.39,8693706,"IR","Q3616"],["Kunming",25.043,102.706,8460088,"CN","Q182852"],["Jining",35.4,116.567,8357897,"CN","Q372791"],["Fuzhou",26.077,119.292,8291268,"CN","Q68481"],["Fuyang",32.899,115.804,8200264,"CN","Q360584"],["Baghdad",33.315,44.366,8126755,"IQ","Q1530"],["Veracruz",19.19,-96.153,8062579,"MX","Q173270"],["Bogotá",4.61,-74.082,8034649,"CO","Q2841"],["Shangqiu",34.426,115.647,7816831,"CN","Q404817"],["Nantong",31.983,120.887,7726635,"CN","Q57947"],["Tangshan",39.629,118.174,7717983,"CN","Q58422"],["Ahmedabad",23.022,72.58,7645000,"IN","Q1070"],["Wuxi",31.567,120.283,7462135,"CN","Q42651"],["Dalian",38.9,121.6,7450785,"CN","Q74881"],["Hong Kong",22.278,114.159,7413070,"CN","Q8646"],["Cangzhou",38.304,116.845,7300783,"CN","Q58695"],["Xingtai",37.066,114.475,7111106,"CN","Q58576"],["Yantai",37.464,121.448,7102116,"CN","Q210493"],["Luoyang",34.659,112.424,7056699,"CN","Q187136"],["Jinhua",29.105,119.649,7050683,"CN","Q58210"],["Riyadh",24.638,46.715,7009100,"SA","Q3692"],["Zhumadian",32.977,114.025,7008427,"CN","Q198240"],["Zhanjiang",21.197,110.403,6981236,"CN","Q59144"],["Bijie",27.302,105.286,6899636,"CN","Q486319"],["Yangon",16.795,96.16,6874000,"MM","Q37995"],["Yancheng",33.35,120.157,6709629,"CN","Q57866"],["Hengyang",26.897,112.586,6645243,"CN","Q144663"],["Taizhou",28.658,121.422,6622888,"CN","Q58273"],["Zunyi",27.705,106.934,6606675,"CN","Q28994"],["Chennai",13.082,80.275,6599000,"IN","Q1352"],["Shaoyang",27.242,111.472,6563520,"CN","Q194531"],["Shangrao",28.442,117.963,6491088,"CN","Q363479"],["Huanggang",30.45,114.875,6333000,"CN","Q71355"],["Moro",-6,144,6310129,"PG","Q103927888"],["Santiago",-33.438,-70.65,6257516,"CL","Q2887"],["Nanchang",28.684,115.887,6255007,"CN","Q171943"],["Xinxiang",35.299,113.885,6251929,"CN","Q404362"],["Xinyang",32.126,114.067,6234401,"CN","Q404873"],["Rio de Janeiro",-2Line truncated
-131
View File
@@ -1,131 +0,0 @@
/**
* A host-supplied placement provider: an application embedding the catalog
* may decide where its moving contacts are drawn while live. Each layer that
* takes one tells it every fix it accepts (`observe`) and asks it, each time
* it draws a contact, where to draw it now (`place`). A layer given none, or
* asking about a contact the provider does not place (`null`), draws the
* contact with its own motion, exactly as without a provider. A provider
* that throws is treated as having no answer for that call.
*
* Layers consult a provider only while live: following the world clock, the
* layer's own motion places every contact.
*
* @typedef {'aircraft'|'military'|'vessels'|'transit'|'rail'} PlacementKind
*
* @typedef {object} PlacementFix
* @property {number} timeMs The fix's own time, Unix ms.
* @property {number} lat
* @property {number} lon
* @property {number} [altitudeM] The height the layer draws the fix at,
* metres above the ellipsoid; NaN when the layer keeps its own height.
* @property {number} [speedMps] Reported ground speed, NaN for none.
* @property {number} [courseDeg] Reported course over ground, NaN for none.
* @property {number} [headingDeg] Reported heading, NaN for none.
* @property {boolean} [onGround] Reported on the ground (aircraft).
* @property {string} [feedId] The feed the contact belongs to (transit, rail).
*
* @typedef {object} Placement
* @property {number} lat
* @property {number} lon
* @property {number} altitudeM Metres above the ellipsoid, as the observed
* fixes' `altitudeM`; NaN keeps the layer's own height.
* @property {number} courseDeg Direction of travel, NaN for none.
* @property {number} speedMps NaN for none.
* @property {boolean} extrapolating Past the contact's newest fix.
*
* @typedef {object} LayerPlacement What a layer is handed: never throws.
* @property {(id: string, fix: PlacementFix) => void} observe
* @property {(id: string, wallMs: number) => Placement|null} place The
* answer is one object reused by every call: read it before the next.
* @property {(id: string) => void} forget The contact left the layer.
*
* @typedef {object} PlacementProvider
* @property {(kind: PlacementKind) => object|null|undefined} layer The
* placement for one kind of contact: an object with `place(id, wallMs)`
* and optionally `observe(id, fix)` and `forget(id)`, or null to leave that
* kind to its layer's own motion.
*/
/** The kinds of contact a provider may place. */
export const PLACEMENT_KINDS = Object.freeze([
'aircraft',
'military',
'vessels',
'transit',
'rail',
]);
const finiteOr = (value, fallback) =>
typeof value === 'number' && Number.isFinite(value) ? value : fallback;
/**
* One kind's placement from a provider, guarded so a layer can call it every
* frame: null when there is no provider or it does not place `kind`.
* @param {PlacementProvider|null|undefined} provider
* @param {PlacementKind} kind
* @returns {LayerPlacement|null}
*/
export function layerPlacement(provider, kind) {
if (!PLACEMENT_KINDS.includes(kind))
throw new TypeError(`Unknown placement kind: ${kind}`);
if (provider == null) return null;
if (typeof provider.layer !== 'function')
throw new TypeError('A placement provider needs a layer(kind) function');
const placement = provider.layer(kind);
if (placement == null) return null;
if (typeof placement.place !== 'function')
throw new TypeError(`The ${kind} placement needs a place(id, wallMs)`);
const observe =
typeof placement.observe === 'function' ? placement.observe : null;
const forget =
typeof placement.forget === 'function' ? placement.forget : null;
// Asked for every drawn contact every frame: one answer, reused.
const answer = {
lat: NaN,
lon: NaN,
altitudeM: NaN,
courseDeg: NaN,
speedMps: NaN,
extrapolating: false,
};
return Object.freeze({
observe(id, fix) {
if (!observe) return;
try {
observe.call(placement, id, fix);
} catch {
// A provider that fails to take a fix only answers less.
}
},
place(id, wallMs) {
let placed;
try {
placed = placement.place(id, wallMs);
} catch {
return null;
}
if (
!placed ||
!Number.isFinite(placed.lat) ||
!Number.isFinite(placed.lon) ||
Math.abs(placed.lat) > 90
)
return null;
answer.lat = placed.lat;
answer.lon = placed.lon;
answer.altitudeM = finiteOr(placed.altitudeM, NaN);
answer.courseDeg = finiteOr(placed.courseDeg, NaN);
answer.speedMps = finiteOr(placed.speedMps, NaN);
answer.extrapolating = placed.extrapolating === true;
return answer;
},
forget(id) {
if (!forget) return;
try {
forget.call(placement, id);
} catch {
// Nothing to undo.
}
},
});
}
-108
View File
@@ -1,108 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { PLACEMENT_KINDS, layerPlacement } from './placementProvider.js';
test('no provider, or a provider that leaves a kind alone, gives no placement', () => {
for (const kind of PLACEMENT_KINDS) {
assert.equal(layerPlacement(null, kind), null);
assert.equal(layerPlacement(undefined, kind), null);
assert.equal(layerPlacement({ layer: () => null }, kind), null);
}
assert.throws(() => layerPlacement(null, 'satellites'), TypeError);
assert.throws(() => layerPlacement({}, 'aircraft'), TypeError);
assert.throws(
() => layerPlacement({ layer: () => ({ observe() {} }) }, 'aircraft'),
TypeError,
);
});
test('a placement is asked by id and wall time, and its answer is checked', () => {
const asked = [];
const seen = [];
const forgotten = [];
const answers = new Map([
[
'a',
{ lat: 51.5, lon: -0.1, altitudeM: 900, courseDeg: 270, speedMps: 80 },
],
['partial', { lat: 10, lon: 20, altitudeM: 'high', extrapolating: true }],
['off-globe', { lat: 91, lon: 0 }],
['nan', { lat: NaN, lon: 0 }],
]);
const provider = {
layer: (kind) =>
kind === 'aircraft'
? {
observe: (id, fix) => seen.push([id, fix.timeMs]),
place(id, wallMs) {
asked.push([id, wallMs]);
return answers.get(id) ?? null;
},
forget: (id) => forgotten.push(id),
}
: null,
};
assert.equal(layerPlacement(provider, 'vessels'), null);
const placement = layerPlacement(provider, 'aircraft');
placement.observe('a', { timeMs: 5, lat: 0, lon: 0 });
assert.deepEqual(seen, [['a', 5]]);
const placed = placement.place('a', 1000);
assert.deepEqual(
{ ...placed },
{
lat: 51.5,
lon: -0.1,
altitudeM: 900,
courseDeg: 270,
speedMps: 80,
extrapolating: false,
},
);
const partial = placement.place('partial', 2000);
assert.equal(partial, placed, 'one answer object, reused');
assert.ok(Number.isNaN(partial.altitudeM));
assert.ok(Number.isNaN(partial.courseDeg));
assert.ok(Number.isNaN(partial.speedMps));
assert.equal(partial.extrapolating, true);
assert.equal(placement.place('off-globe', 3000), null);
assert.equal(placement.place('nan', 3000), null);
assert.equal(placement.place('unknown', 3000), null);
assert.deepEqual(
asked.map(([id]) => id),
['a', 'partial', 'off-globe', 'nan', 'unknown'],
);
placement.forget('a');
assert.deepEqual(forgotten, ['a']);
});
test('a provider that throws has no answer, and observe and forget are optional', () => {
const placement = layerPlacement(
{
layer: () => ({
place() {
throw new Error('not ready');
},
}),
},
'transit',
);
assert.equal(placement.place('x', 0), null);
assert.doesNotThrow(() => placement.observe('x', { timeMs: 0 }));
assert.doesNotThrow(() => placement.forget('x'));
const failing = layerPlacement(
{
layer: () => ({
place: () => null,
observe() {
throw new Error('full');
},
forget() {
throw new Error('gone');
},
}),
},
'rail',
);
assert.doesNotThrow(() => failing.observe('x', { timeMs: 0 }));
assert.doesNotThrow(() => failing.forget('x'));
});
+1 -9
View File
@@ -1244,15 +1244,7 @@ test('real mission build, select, refresh, deselect, disable, and destroy paths
assert.equal(worldClock.coverage(), null, 'nothing loaded, no time to show'); assert.equal(worldClock.coverage(), null, 'nothing loaded, no time to show');
await rocketLaunchesLayer.update(); await rocketLaunchesLayer.update();
const at = Date.parse(launchTime); const at = Date.parse(launchTime);
// The launch is a mark labelled with its mission's name. assert.deepEqual(worldClock.coverage(), { start: at, end: at, marks: [at] });
assert.deepEqual(worldClock.coverage(), {
start: at,
end: at,
marks: [at],
markDetails: [
{ time: at, label: 'GAUNTLET PAYLOAD', id: 'mission-runtime', kind: 'launch' },
],
});
assert.equal(worldClock.isSupported(), true, 'loaded launches bring up the timeline'); assert.equal(worldClock.isSupported(), true, 'loaded launches bring up the timeline');
const entities = dataSources[0].entities.values; const entities = dataSources[0].entities.values;
+16 -239
View File
@@ -29,21 +29,12 @@ import { RIO_ROUTE_NAMES } from './rioRouteNames.js';
* node:test. No Cesium, no Node built-ins. * node:test. No Cesium, no Node built-ins.
*/ */
/** /** Transit modes the layer colors. `routeMode` hints refine a feed's default. */
* Transit modes the layer colors: the GTFS basic route types, by name. A
* vehicle's GTFS `route_type` settles its mode when its record carries one;
* otherwise `routeMode` hints refine a feed's default.
*/
export const TRANSIT_MODES = Object.freeze([ export const TRANSIT_MODES = Object.freeze([
'bus', 'bus',
'trolleybus',
'tram', 'tram',
'cable_tram',
'subway', 'subway',
'monorail',
'rail', 'rail',
'funicular',
'aerial_lift',
'ferry', 'ferry',
'unknown', 'unknown',
]); ]);
@@ -51,81 +42,13 @@ export const TRANSIT_MODES = Object.freeze([
/** Per-mode icon used in labels and the selection card. */ /** Per-mode icon used in labels and the selection card. */
export const TRANSIT_MODE_ICON = Object.freeze({ export const TRANSIT_MODE_ICON = Object.freeze({
bus: '🚌', bus: '🚌',
trolleybus: '🚎',
tram: '🚊', tram: '🚊',
cable_tram: '🚋',
subway: '🚇', subway: '🚇',
monorail: '🚝',
rail: '🚆', rail: '🚆',
funicular: '🚞',
aerial_lift: '🚡',
ferry: '⛴️', ferry: '⛴️',
unknown: '🚏', unknown: '🚏',
}); });
/** The basic GTFS route types (0–7, 11, 12) by mode. */
const BASIC_ROUTE_TYPE_MODES = Object.freeze({
0: 'tram',
1: 'subway',
2: 'rail',
3: 'bus',
4: 'ferry',
5: 'cable_tram',
6: 'aerial_lift',
7: 'funicular',
11: 'trolleybus',
12: 'monorail',
});
/**
* A GTFS `route_type` as an integer: a basic type (0–12) or an extended one
* (100–1799), as a number or its decimal text. Null for anything else.
* @param {unknown} value
* @returns {number|null}
*/
export function transitRouteType(value) {
const n =
typeof value === 'number'
? value
: typeof value === 'string' && /^\d{1,4}$/.test(value.trim())
? Number(value.trim())
: NaN;
return Number.isInteger(n) && n >= 0 && n < 1800 ? n : null;
}
/**
* The mode of a GTFS `route_type`: the basic types, and the extended ones
* mapped to the basic mode they are a kind of (a coach, 200, is a bus; a
* high-speed train, 101, is rail; 405 is a monorail). Air, taxi and other
* services, and a value GTFS does not define, are `unknown`.
* @param {unknown} routeType
* @returns {string} One of TRANSIT_MODES.
*/
export function transitModeOfRouteType(routeType) {
const n = transitRouteType(routeType);
if (n === null) return 'unknown';
if (n < 100) return BASIC_ROUTE_TYPE_MODES[n] ?? 'unknown';
if (n === 405) return 'monorail';
const family = Math.floor(n / 100);
return (
{
1: 'rail', // railway
2: 'bus', // coach
3: 'rail', // suburban railway
4: 'subway', // urban railway
5: 'subway', // metro
6: 'subway', // underground
7: 'bus',
8: 'trolleybus',
9: 'tram',
10: 'ferry', // water transport
12: 'ferry', // ferry
13: 'aerial_lift',
14: 'funicular',
}[family] ?? 'unknown'
);
}
/** /**
* MBTA route ids are human-readable and mode-typed: rapid-transit lines carry * MBTA route ids are human-readable and mode-typed: rapid-transit lines carry
* colour names, commuter rail is prefixed `CR-`, ferries `Boat-`, buses are * colour names, commuter rail is prefixed `CR-`, ferries `Boat-`, buses are
@@ -520,7 +443,7 @@ const liaRouteLabel = routeIdPattern(/^8-0*(\w.*)$/);
function liaRouteMode(routeId) { function liaRouteMode(routeId) {
if (!routeId) return 'unknown'; if (!routeId) return 'unknown';
const line = liaRouteLabel(routeId); const line = liaRouteLabel(routeId);
if (line === 'FUNI') return 'funicular'; if (line === 'FUNI') return 'rail';
return /^[AB]$/.test(line) ? 'tram' : 'bus'; return /^[AB]$/.test(line) ? 'tram' : 'bus';
} }
@@ -547,14 +470,10 @@ function astuceRouteLabel(routeId) {
return ASTUCE_ROUTES[routeId ?? '']?.label ?? astuceNetworkLabel(routeId); return ASTUCE_ROUTES[routeId ?? '']?.label ?? astuceNetworkLabel(routeId);
} }
/** PID trolleybus lines: route_type 11 in PID's routes.txt (checked 2026-11-14). */
const PID_TROLLEYBUS_LINES = new Set([51, 52, 53, 58, 59, 232, 275]);
/** /**
* Prague PID route ids are `L` plus a number whose range says what runs it, * Prague PID route ids are `L` plus a number whose range says what runs it,
* per PID's GTFS routes.txt (data.pid.cz, checked 2026-11-09): 1–99 trams * per PID's GTFS routes.txt (data.pid.cz, checked 2026-11-09): 1–99 trams
* (50–59 trolleybuses, 49 the Petřín funicular `LD`), 100–990 buses (the * (50–59 trolleybuses, 49 the Petřín funicular `LD`), 100–990 buses (the
* trolleybus routes 232 and 275 among them, GTFS route type 11; the
* 800s are the X replacement lines, `L803` is X3), 991–993 metro A–C, * 800s are the X replacement lines, `L803` is X3), 991–993 metro A–C,
* 1000s and 1100s the S and R trains (`L1004` is S4, `L1109` R9), the rest * 1000s and 1100s the S and R trains (`L1004` is S4, `L1109` R9), the rest
* of 1200–1399 other trains, 1700s town buses, 1800s the P ferries, and from * of 1200–1399 other trains, 1700s town buses, 1800s the P ferries, and from
@@ -568,9 +487,7 @@ function pidRoute(routeId) {
if (!match) return { mode: 'unknown', label: routeId }; if (!match) return { mode: 'unknown', label: routeId };
const n = Number(match[1]); const n = Number(match[1]);
// i18n-ignore: line code, as signed // i18n-ignore: line code, as signed
if (n === 49) return { mode: 'funicular', label: 'LD' }; if (n === 49) return { mode: 'rail', label: 'LD' };
if (PID_TROLLEYBUS_LINES.has(n))
return { mode: 'trolleybus', label: String(n) };
if (n >= 50 && n <= 59) return { mode: 'bus', label: String(n) }; if (n >= 50 && n <= 59) return { mode: 'bus', label: String(n) };
if (n < 100) return { mode: 'tram', label: String(n) }; if (n < 100) return { mode: 'tram', label: String(n) };
if (n >= 800 && n < 900) return { mode: 'bus', label: `X${n - 800}` }; if (n >= 800 && n < 900) return { mode: 'bus', label: `X${n - 800}` };
@@ -595,102 +512,6 @@ function pidRouteLabel(routeId) {
return pidRoute(routeId).label; return pidRoute(routeId).label;
} }
/**
* A feed's route hint from its static GTFS routes.txt: the route ids of
* every mode but `bus`, which is everything else.
* @param {Record<string, string[]>} idsByMode
* @returns {(routeId: string|null) => string}
*/
function routeModeTable(idsByMode) {
const modes = new Map(
Object.entries(idsByMode).flatMap(([mode, ids]) =>
ids.map((id) => [id, mode]),
),
);
return (routeId) => (routeId ? (modes.get(routeId) ?? 'bus') : 'unknown');
}
/**
* Bucharest TPBI by route id, from its routes.txt (checked 2026-11-14): the
* trams (route type 0), metro lines M1–M5 (1) and trolleybuses (11); every
* other route is a bus. Realtime route ids are the static ones.
*/
const tpbiRouteMode = routeModeTable({
tram: [
'54',
'55',
'57',
'61',
'63',
'65',
'66',
'67',
'68',
'69',
'70',
'814',
'915',
'1013',
],
subway: ['968', '969', '970', '971', '999'],
trolleybus: [
'72',
'73',
'74',
'75',
'76',
'78',
'81',
'83',
'84',
'85',
'86',
'87',
'247',
'910',
'916',
'924',
'925',
],
});
/**
* Cluj-Napoca CTP by route id, from its routes.txt (checked 2026-11-14): the
* trams 100–102L (route type 0) and the trolleybuses (11).
*/
const ctpClujRouteMode = routeModeTable({
tram: ['2', '3', '4', '5'],
trolleybus: [
'1',
'10',
'14',
'15',
'22',
'36',
'49',
'53',
'54',
'55',
'117',
'119',
'120',
'153',
'166',
'187',
],
});
/**
* Turin GTT by route id, from its routes.txt (checked 2026-11-14): the trams
* (route type 0), metro M1, and the Sassi–Superga rack tramway `79U`, which
* the file types as a funicular (7).
*/
const gttRouteMode = routeModeTable({
tram: ['3U', '4U', '7U', '9U', '10U', '13U', '15U', '16CDU', '16CSU'],
subway: ['METROU'],
funicular: ['79U'],
});
/** /**
* Bordeaux TBM tram and boat lines by route id, from TBM's static GTFS * Bordeaux TBM tram and boat lines by route id, from TBM's static GTFS
* routes.txt (checked 2026-11-09): the trams are numbered ids, not letters. * routes.txt (checked 2026-11-09): the trams are numbered ids, not letters.
@@ -750,7 +571,6 @@ function transitFeed(fields) {
* url: string, headers?: Record<string, string>, * url: string, headers?: Record<string, string>,
* license: string, licenseUrl: string, attribution: string, * license: string, licenseUrl: string, attribution: string,
* defaultMode: string, routeMode?: (routeId: string|null) => string, * defaultMode: string, routeMode?: (routeId: string|null) => string,
* routeCorrections?: Readonly<Record<string, string>>,
* speedUnit?: 'km/h', refreshMs?: number, sourceUrl?: string, * speedUnit?: 'km/h', refreshMs?: number, sourceUrl?: string,
* creditDated?: boolean, * creditDated?: boolean,
* }>>} * }>>}
@@ -759,9 +579,6 @@ function transitFeed(fields) {
* stretches the proxy's cache for an upstream that rate-limits below one * stretches the proxy's cache for an upstream that rate-limits below one
* request per 15 s TTL. `sourceUrl` and `creditDated` add a link and a date * request per 15 s TTL. `sourceUrl` and `creditDated` add a link and a date
* to the credit for licences that ask for them (`transitFeedCredit`). * to the credit for licences that ask for them (`transitFeedCredit`).
* `routeCorrections` names the routes whose `route_type` the operator's own
* routes.txt gets wrong, `route_id` to the mode they really are; a correction
* outranks the route type, which would otherwise outrank the route hint.
*/ */
export const TRANSIT_FEED_REGISTRY = Object.freeze([ export const TRANSIT_FEED_REGISTRY = Object.freeze([
Object.freeze({ Object.freeze({
@@ -1447,7 +1264,6 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([
note: 'Keyless public endpoint from the metropolitan transport authority; no licence found. Same basis as Metro Transit and OVapi.', note: 'Keyless public endpoint from the metropolitan transport authority; no licence found. Same basis as Metro Transit and OVapi.',
}, },
defaultMode: 'bus', defaultMode: 'bus',
routeMode: tpbiRouteMode,
}), }),
transitFeed({ transitFeed({
id: 'roma-mobilita', id: 'roma-mobilita',
@@ -1480,7 +1296,6 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([
note: 'Official keyless endpoint; no licence found. Same basis as Metro Transit and OVapi. Route ids append `U` for urban lines.', note: 'Official keyless endpoint; no licence found. Same basis as Metro Transit and OVapi. Route ids append `U` for urban lines.',
}, },
defaultMode: 'bus', defaultMode: 'bus',
routeMode: gttRouteMode,
routeLabel: gttRouteLabel, routeLabel: gttRouteLabel,
}), }),
transitFeed({ transitFeed({
@@ -1586,7 +1401,6 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([
note: 'Keyless; no licence found. Same basis as Metro Transit and OVapi.', note: 'Keyless; no licence found. Same basis as Metro Transit and OVapi.',
}, },
defaultMode: 'bus', defaultMode: 'bus',
routeMode: ctpClujRouteMode,
}), }),
transitFeed({ transitFeed({
id: 'ctp-iasi', id: 'ctp-iasi',
@@ -1753,8 +1567,6 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([
defaultMode: 'bus', defaultMode: 'bus',
routeLabel: liaRouteLabel, routeLabel: liaRouteLabel,
routeMode: liaRouteMode, routeMode: liaRouteMode,
// LiA's routes.txt types the funicular (`8-FUNI`) as a bus (3).
routeCorrections: Object.freeze({ '8-FUNI': 'funicular' }),
}), }),
transitFeed({ transitFeed({
id: 'filbleu-tours', id: 'filbleu-tours',
@@ -1852,89 +1664,54 @@ export function transitFeedsInRange(lat, lon, slackKm = 0) {
} }
/** /**
* What settles a vehicle's mode, most specific first: the feed's fleet hint * The feed's own hint for a vehicle: its fleet number when the feed maps
* (`vehicleMode`, which knows a bus standing in on a tram route), else the * those (`vehicleMode`), else its route (`routeMode`). Null when neither knows.
* feed's correction for a route its routes.txt mistypes (`routeCorrections`),
* else the GTFS `route_type` of its route when the record carries one, else
* the feed's route hint (`routeMode`). Null when none of them knows.
* @param {object} feed Registry entry. * @param {object} feed Registry entry.
* @param {string|null} routeId * @param {string|null} routeId
* @param {string|null} vehicleId * @param {string|null} vehicleId
* @param {unknown} routeType GTFS `route_type`, when the record has one.
* @returns {string|null} * @returns {string|null}
*/ */
function hintedMode(feed, routeId, vehicleId, routeType) { function hintedMode(feed, routeId, vehicleId) {
const byVehicle = const byVehicle =
typeof feed?.vehicleMode === 'function' typeof feed?.vehicleMode === 'function'
? feed.vehicleMode(vehicleId) ? feed.vehicleMode(vehicleId)
: null; : null;
if (byVehicle && byVehicle !== 'unknown') return byVehicle; if (byVehicle && byVehicle !== 'unknown') return byVehicle;
const corrections = feed?.routeCorrections;
if (routeId && corrections && Object.hasOwn(corrections, routeId))
return corrections[routeId];
const byType = transitModeOfRouteType(routeType);
if (byType !== 'unknown') return byType;
return typeof feed?.routeMode === 'function' ? feed.routeMode(routeId) : null; return typeof feed?.routeMode === 'function' ? feed.routeMode(routeId) : null;
} }
/** /**
* Mode for a vehicle: its fleet hint, route type or route hint when one * Mode for a vehicle: the feed's fleet or route hint when it has one, else
* knows ({@link hintedMode}), else its feed's default. * its default.
* @param {object} feed Registry entry. * @param {object} feed Registry entry.
* @param {string|null} routeId GTFS route_id from the vehicle's trip. * @param {string|null} routeId GTFS route_id from the vehicle's trip.
* @param {string|null} [vehicleId] GTFS-RT vehicle id. * @param {string|null} [vehicleId] GTFS-RT vehicle id.
* @param {unknown} [routeType] GTFS `route_type` of the route, when known.
* @returns {string} One of TRANSIT_MODES. * @returns {string} One of TRANSIT_MODES.
*/ */
export function transitModeFor( export function transitModeFor(feed, routeId, vehicleId = null) {
feed, const hinted = hintedMode(feed, routeId, vehicleId);
routeId,
vehicleId = null,
routeType = null,
) {
const hinted = hintedMode(feed, routeId, vehicleId, routeType);
const mode = const mode =
hinted && hinted !== 'unknown' ? hinted : feed?.defaultMode || 'unknown'; hinted && hinted !== 'unknown' ? hinted : feed?.defaultMode || 'unknown';
return TRANSIT_MODES.includes(mode) ? mode : 'unknown'; return TRANSIT_MODES.includes(mode) ? mode : 'unknown';
} }
/** /**
* Whether the mode came from the vehicle, its route type or its route id * Whether the mode came from the route id rather than the feed's default. A
* rather than the feed's default. A default is a guess about a fleet — * default is a guess about a fleet — TransLink's is "bus" while it publishes
* TransLink's is "bus" while it publishes rail in the same feed — so a * rail in the same feed — so a consumer judging physical plausibility must
* consumer judging physical plausibility must not hold a defaulted vehicle * not hold a defaulted vehicle to a bus's limits.
* to a bus's limits.
* @param {object} feed Registry entry. * @param {object} feed Registry entry.
* @param {string|null} routeId * @param {string|null} routeId
* @param {string|null} [vehicleId] * @param {string|null} [vehicleId]
* @param {unknown} [routeType]
* @returns {boolean} * @returns {boolean}
*/ */
export function transitModeResolved( export function transitModeResolved(feed, routeId, vehicleId = null) {
feed, const hinted = hintedMode(feed, routeId, vehicleId);
routeId,
vehicleId = null,
routeType = null,
) {
const hinted = hintedMode(feed, routeId, vehicleId, routeType);
return ( return (
Boolean(hinted) && hinted !== 'unknown' && TRANSIT_MODES.includes(hinted) Boolean(hinted) && hinted !== 'unknown' && TRANSIT_MODES.includes(hinted)
); );
} }
/**
* The route type a vehicle's model is chosen by: its record's, when that type
* is a kind of the mode it was given. A fleet hint can overrule the route (a
* bus on a tram route), and then the route's type does not describe it.
* @param {string} mode The vehicle's mode ({@link transitModeFor}).
* @param {unknown} routeType
* @returns {number|null}
*/
export function transitModelRouteType(mode, routeType) {
const n = transitRouteType(routeType);
return n !== null && transitModeOfRouteType(n) === mode ? n : null;
}
/** /**
* The route as a rider would say it. Most feeds publish that already, so this * The route as a rider would say it. Most feeds publish that already, so this
* is identity for them; a feed that publishes internal keys supplies its own * is identity for them; a feed that publishes internal keys supplies its own
+3 -120
View File
@@ -11,13 +11,9 @@ import {
haversineKm, haversineKm,
publicTransitCatalog, publicTransitCatalog,
transitFeedsInRange, transitFeedsInRange,
TRANSIT_MODE_ICON,
transitModeFor, transitModeFor,
transitModeOfRouteType,
transitModeResolved, transitModeResolved,
transitModelRouteType,
transitRouteLabel, transitRouteLabel,
transitRouteType,
} from './transitFeeds.js'; } from './transitFeeds.js';
import { RIO_ROUTE_NAMES } from './rioRouteNames.js'; import { RIO_ROUTE_NAMES } from './rioRouteNames.js';
@@ -733,10 +729,8 @@ test('Prague route-id ranges give metro, trains, ferries and trolleybuses their
transitRouteLabel(feed, routeId), transitRouteLabel(feed, routeId),
]; ];
assert.deepEqual(read('L22'), ['tram', '22']); assert.deepEqual(read('L22'), ['tram', '22']);
assert.deepEqual(read('L58'), ['trolleybus', '58']); assert.deepEqual(read('L58'), ['bus', '58'], 'trolleybus');
assert.deepEqual(read('L55'), ['bus', '55'], 'not a PID trolleybus line'); assert.deepEqual(read('L49'), ['rail', 'LD'], 'Petřín funicular');
assert.deepEqual(read('L275'), ['trolleybus', '275'], 'route type 11');
assert.deepEqual(read('L49'), ['funicular', 'LD'], 'Petřín funicular');
assert.deepEqual(read('L135'), ['bus', '135']); assert.deepEqual(read('L135'), ['bus', '135']);
assert.deepEqual(read('L803'), ['bus', 'X3']); assert.deepEqual(read('L803'), ['bus', 'X3']);
assert.deepEqual(read('L991'), ['subway', 'A']); assert.deepEqual(read('L991'), ['subway', 'A']);
@@ -773,7 +767,7 @@ test('Rouen Métro and Calypso are not buses; Le Havre reads its own ids', () =>
assert.equal(transitModeFor(feed, 'TCAR:93'), 'bus'); assert.equal(transitModeFor(feed, 'TCAR:93'), 'bus');
assert.equal(transitRouteLabel(feed, 'TAE:F'), 'F'); assert.equal(transitRouteLabel(feed, 'TAE:F'), 'F');
const lia = getTransitFeed('lia-le-havre'); const lia = getTransitFeed('lia-le-havre');
assert.equal(transitModeFor(lia, '8-FUNI'), 'funicular'); assert.equal(transitModeFor(lia, '8-FUNI'), 'rail');
assert.equal(transitRouteLabel(lia, '8-C3'), 'C3'); assert.equal(transitRouteLabel(lia, '8-C3'), 'C3');
assert.equal(transitRouteLabel(lia, '8-09'), '9'); assert.equal(transitRouteLabel(lia, '8-09'), '9');
assert.equal(transitRouteLabel(lia, '8-A'), 'A'); assert.equal(transitRouteLabel(lia, '8-A'), 'A');
@@ -784,114 +778,3 @@ test('data.gov.my feeds ask the proxy for no more than its 4 a minute', () => {
assert.ok(getTransitFeed(id).refreshMs >= 30_000, id); assert.ok(getTransitFeed(id).refreshMs >= 30_000, id);
} }
}); });
test('a GTFS route type names its mode, basic and extended alike', () => {
for (const [routeType, mode] of [
[0, 'tram'],
[1, 'subway'],
[2, 'rail'],
[3, 'bus'],
[4, 'ferry'],
[5, 'cable_tram'],
[6, 'aerial_lift'],
[7, 'funicular'],
[11, 'trolleybus'],
[12, 'monorail'],
[101, 'rail'],
[106, 'rail'],
[200, 'bus'],
[300, 'rail'],
[401, 'subway'],
[405, 'monorail'],
[702, 'bus'],
[800, 'trolleybus'],
[900, 'tram'],
[1000, 'ferry'],
[1200, 'ferry'],
[1300, 'aerial_lift'],
[1400, 'funicular'],
['11', 'trolleybus'],
])
assert.equal(transitModeOfRouteType(routeType), mode, String(routeType));
// Air, taxi and other services, and values GTFS does not define.
for (const other of [8, 1100, 1500, 1700, 1800, -1, 2.5, '', 'tram', null])
assert.equal(transitModeOfRouteType(other), 'unknown', String(other));
assert.equal(transitRouteType(' 200 '), 200);
assert.equal(transitRouteType('2e2'), null);
});
test('every mode has an icon, and every mode a route type names is a mode', () => {
for (const mode of TRANSIT_MODES) assert.ok(TRANSIT_MODE_ICON[mode], mode);
for (let routeType = 0; routeType < 1800; routeType++)
assert.ok(TRANSIT_MODES.includes(transitModeOfRouteType(routeType)));
});
test('a route type settles the mode before a route hint or the default', () => {
const mbta = getTransitFeed('mbta');
// The hint calls every numbered MBTA route a bus; a route type is finer.
assert.equal(transitModeFor(mbta, '71'), 'bus');
assert.equal(transitModeFor(mbta, '71', 'y1', 11), 'trolleybus');
assert.equal(transitModeResolved(mbta, '71', 'y1', 11), true);
// A default-only feed is resolved by a route type alone.
const durham = getTransitFeed('durham-region');
assert.equal(transitModeResolved(durham, '915', 'b1'), false);
assert.equal(transitModeFor(durham, '915', 'b1', 200), 'bus');
assert.equal(transitModeResolved(durham, '915', 'b1', 200), true);
// A type that names no mode leaves the hints in charge.
assert.equal(transitModeFor(mbta, 'Red', null, 1700), 'subway');
assert.equal(transitModeFor(mbta, 'Red', null, 'x'), 'subway');
});
test('a fleet hint outranks the route type, and then the type picks no model', () => {
const ttc = getTransitFeed('ttc-toronto');
// A bus standing in on the 501 streetcar route (route type 0).
const mode = transitModeFor(ttc, '501', '8400', 0);
assert.equal(mode, 'bus');
assert.equal(transitModelRouteType(mode, 0), null);
assert.equal(transitModelRouteType('tram', 0), 0);
assert.equal(transitModelRouteType('bus', 200), 200, 'a coach is a bus');
assert.equal(transitModelRouteType('bus', null), null);
});
test('a route correction outranks a mistyped route type, and only for its route', () => {
const lia = getTransitFeed('lia-le-havre');
// LiA's routes.txt types the funicular as a bus (3).
assert.equal(transitModeFor(lia, '8-FUNI', null, 3), 'funicular');
assert.equal(transitModeResolved(lia, '8-FUNI', null, 3), true);
// A correction is a mode, so the bus type picks no model for it.
assert.equal(transitModelRouteType('funicular', 3), null);
// Every other route still takes its route type.
assert.equal(transitModeFor(lia, '8-A', null, 0), 'tram');
assert.equal(transitModeFor(lia, '8-C3', null, 3), 'bus');
assert.equal(transitModeFor(lia, null, null, 3), 'bus');
// Corrections are narrow: a few named routes, each to a known mode.
for (const feed of TRANSIT_FEED_REGISTRY) {
if (!feed.routeCorrections) continue;
assert.ok(Object.isFrozen(feed.routeCorrections), feed.id);
for (const [route, mode] of Object.entries(feed.routeCorrections)) {
assert.ok(TRANSIT_MODES.includes(mode), `${feed.id} ${route}`);
assert.notEqual(mode, 'unknown', `${feed.id} ${route}`);
}
}
// An inherited key is not a correction.
assert.equal(transitModeFor(lia, 'toString', null, 3), 'bus');
});
// Route ids as each realtime feed and its operator's routes.txt carried them
// on 2026-11-14.
test('Bucharest, Cluj and Turin route ids give trams, trolleybuses and a funicular', () => {
const mode = (id, routeId) => transitModeFor(getTransitFeed(id), routeId);
assert.equal(mode('tpbi-bucuresti', '85'), 'trolleybus', 'line 74');
assert.equal(mode('tpbi-bucuresti', '1013'), 'tram', 'line 4');
assert.equal(mode('tpbi-bucuresti', '968'), 'subway', 'M1');
assert.equal(mode('tpbi-bucuresti', '828'), 'bus');
assert.equal(mode('tpbi-bucuresti', 'PV1_407'), 'bus');
assert.equal(mode('ctp-cluj', '1'), 'trolleybus');
assert.equal(mode('ctp-cluj', '2'), 'tram', 'line 100');
assert.equal(mode('ctp-cluj', '6'), 'bus', 'line 19');
assert.equal(mode('gtt-torino', '79U'), 'funicular', 'Sassi–Superga');
assert.equal(mode('gtt-torino', '16CDU'), 'tram');
assert.equal(mode('gtt-torino', 'METROU'), 'subway');
assert.equal(mode('gtt-torino', '6U'), 'bus');
assert.equal(mode('gtt-torino', null), 'bus', 'no route: the default');
});
+1 -80
View File
@@ -21,9 +21,7 @@ import {
* at the ~18 px the fleet renders at, by shape and not only by colour: a bus is * at the ~18 px the fleet renders at, by shape and not only by colour: a bus is
* short and blunt, a tram is long and slender with articulation joints, a metro * short and blunt, a tram is long and slender with articulation joints, a metro
* car is squared off, a mainline train has a tapered nose, and a ferry is a * car is squared off, a mainline train has a tapered nose, and a ferry is a
* hull with a bow. A trolleybus trails its two poles, a cable car is a short * hull with a bow.
* single car, a monorail the slimmest bullet, a funicular hangs off its rope,
* and a gondola is a cabin on its cable.
* *
* Interior detail is drawn with the same dark hairline as the edge — windscreen * Interior detail is drawn with the same dark hairline as the edge — windscreen
* lines, articulation joints, a deck line — because a solid dark panel would * lines, articulation joints, a deck line — because a solid dark panel would
@@ -105,49 +103,6 @@ const HEADING_BODIES = {
fill="white" ${STROKE}/> fill="white" ${STROKE}/>
<path d="M-7.5,-6 L7.5,-6 L7.5,20 L-7.5,20 Z" ${DETAIL}/>`, <path d="M-7.5,-6 L7.5,-6 L7.5,20 L-7.5,20 Z" ${DETAIL}/>`,
// ── Trolleybus: a bus a little narrower, trailing the two trolley poles
// that reach back to its wires. Body and poles are one outline, so no
// edge is drawn across the body.
trolleybus: `
<path d="M0,-41 L15,-26 L15,25 C15,28.5 13,30 9,30 L7.5,30 L7.5,42 L4,42
L4,30 L-4,30 L-4,42 L-7.5,42 L-7.5,30 L-9,30 C-13,30 -15,28.5 -15,25
L-15,-26 Z"
fill="white" ${STROKE}/>
<path d="M-10,-21 L10,-21" ${DETAIL}/>`,
// ── Cable car (street cable railway): a short single car, under half a
// tram, with open platforms at both ends.
cable_tram: `
<path d="M0,-30 L11,-21 L11,26 C11,28 9,29 0,29 C-9,29 -11,28 -11,26
L-11,-21 Z"
fill="white" ${STROKE}/>
<path d="M-11,-12 L11,-12" ${DETAIL}/>
<path d="M-11,18 L11,18" ${DETAIL}/>`,
// ── Monorail: the slimmest train in the set, a rounded bullet nose over
// its beam.
monorail: `
<path d="M0,-45 C3,-45 8.5,-36 8.5,-22 L8.5,42 L-8.5,42 L-8.5,-22
C-8.5,-36 -3,-45 0,-45 Z"
fill="white" ${STROKE}/>
<path d="M0,-18 L0,38" ${DETAIL}/>`,
// ── Funicular: a squat car hauled by the rope at its upper end.
funicular: `
<path d="M-6,-30 L6,-30 L11,-24 L11,36 L-11,36 L-11,-24 Z
M-1.75,-46 L1.75,-46 L1.75,-30 L-1.75,-30 Z"
fill="white" ${STROKE}/>
<path d="M-11,-8 L11,-8" ${DETAIL}/>
<path d="M-11,14 L11,14" ${DETAIL}/>`,
// ── Gondola (aerial lift): a cabin hung on the cable that runs through
// it; the cabin's nose is its front.
aerial_lift: `
<path d="M0,-18 L12,-7 L12,14 C12,16 10,17 0,17 C-10,17 -12,16 -12,14
L-12,-7 Z
M-2.5,-40 L2.5,-40 L2.5,46 L-2.5,46 Z"
fill="white" ${STROKE}/>`,
// ── Unknown: a plain marker with a nose and nothing else, so an // ── Unknown: a plain marker with a nose and nothing else, so an
// unclassified vehicle never pretends to be a mode it is not. // unclassified vehicle never pretends to be a mode it is not.
unknown: ` unknown: `
@@ -191,40 +146,6 @@ const NEUTRAL_BODIES = {
fill="white" ${STROKE}/> fill="white" ${STROKE}/>
<path d="M-7.5,-13 L7.5,-13 L7.5,13 L-7.5,13 Z" ${DETAIL}/>`, <path d="M-7.5,-13 L7.5,-13 L7.5,13 L-7.5,13 Z" ${DETAIL}/>`,
trolleybus: `
<path d="M-15,-26 C-15,-30 -13,-32 -9,-32 L-7.5,-32 L-7.5,-42 L-4,-42
L-4,-32 L4,-32 L4,-42 L7.5,-42 L7.5,-32 L9,-32 C13,-32 15,-30 15,-26
L15,26 C15,30 13,32 9,32 L7.5,32 L7.5,42 L4,42 L4,32 L-4,32 L-4,42
L-7.5,42 L-7.5,32 L-9,32 C-13,32 -15,30 -15,26 Z"
fill="white" ${STROKE}/>`,
cable_tram: `
<path d="M-11,-25 C-11,-27 -9,-28 0,-28 C9,-28 11,-27 11,-25
L11,25 C11,27 9,28 0,28 C-9,28 -11,27 -11,25 Z"
fill="white" ${STROKE}/>
<path d="M-11,-17 L11,-17" ${DETAIL}/>
<path d="M-11,17 L11,17" ${DETAIL}/>`,
monorail: `
<path d="M0,-44 C5,-44 8.5,-38 8.5,-28 L8.5,28 C8.5,38 5,44 0,44
C-5,44 -8.5,38 -8.5,28 L-8.5,-28 C-8.5,-38 -5,-44 0,-44 Z"
fill="white" ${STROKE}/>
<path d="M0,-24 L0,24" ${DETAIL}/>`,
funicular: `
<path d="M-11,-30 L11,-30 L11,30 L-11,30 Z
M-1.75,-46 L1.75,-46 L1.75,-30 L-1.75,-30 Z
M-1.75,30 L1.75,30 L1.75,46 L-1.75,46 Z"
fill="white" ${STROKE}/>
<path d="M-11,-10 L11,-10" ${DETAIL}/>
<path d="M-11,10 L11,10" ${DETAIL}/>`,
aerial_lift: `
<path d="M-12,-14 C-12,-16 -10,-17 0,-17 C10,-17 12,-16 12,-14
L12,14 C12,16 10,17 0,17 C-10,17 -12,16 -12,14 Z
M-2.5,-46 L2.5,-46 L2.5,46 L-2.5,46 Z"
fill="white" ${STROKE}/>`,
unknown: ` unknown: `
<path d="M-10,-26 C-10,-30 -7,-32 0,-32 C7,-32 10,-30 10,-26 <path d="M-10,-26 C-10,-30 -7,-32 0,-32 C7,-32 10,-30 10,-26
L10,26 C10,30 7,32 0,32 C-7,32 -10,30 -10,26 Z" L10,26 C10,30 7,32 0,32 C-7,32 -10,30 -10,26 Z"
+2 -8
View File
@@ -62,7 +62,7 @@ test('the raster cache is bounded by kind, size and halo — never by call count
assert.ok(transitIconCacheSize() <= before + 3 + TRANSIT_ICON_KINDS.length); assert.ok(transitIconCacheSize() <= before + 3 + TRANSIT_ICON_KINDS.length);
}); });
test('every variant retains the specified final halo at both display sizes', async () => { test('all 72 variants retain the specified final halo at both display sizes', async () => {
const { presetSpriteScale, presetSpriteOutlinePx } = const { presetSpriteScale, presetSpriteOutlinePx } =
await import('./transitPresetStyle.js'); await import('./transitPresetStyle.js');
for (const kind of TRANSIT_ICON_KINDS) { for (const kind of TRANSIT_ICON_KINDS) {
@@ -97,8 +97,7 @@ test('every variant retains the specified final halo at both display sizes', asy
} }
for (let i = 0; i < 100; i++) for (let i = 0; i < 100; i++)
transitIcon('invalid', i, { haloScreenPx: i, screenPx: i }); transitIcon('invalid', i, { haloScreenPx: i, screenPx: i });
// Every kind at two sizes, three profiles, with and without a course. assert.equal(transitIconCacheSize(), 72);
assert.equal(transitIconCacheSize(), TRANSIT_ICON_KINDS.length * 12);
}); });
test('every mono raster preserves the normal silhouette mask and aspect at CRT display size', async () => { test('every mono raster preserves the normal silhouette mask and aspect at CRT display size', async () => {
@@ -198,11 +197,6 @@ test('sensor raster halo stays dark across its screen-space band on a white roof
subway: 11.5, subway: 11.5,
rail: 10.5, rail: 10.5,
ferry: 12.5, ferry: 12.5,
trolleybus: 15,
cable_tram: 11,
monorail: 8.5,
funicular: 11,
aerial_lift: 12,
unknown: 10, unknown: 10,
}; };
for (const selected of [false, true]) { for (const selected of [false, true]) {
+1 -11
View File
@@ -60,14 +60,9 @@ const SPRITE_STYLE = Object.freeze({
outlinePx: 1.25, outlinePx: 1.25,
rgbaByMode: Object.freeze({ rgbaByMode: Object.freeze({
bus: Object.freeze([0, 255, 102, 1]), bus: Object.freeze([0, 255, 102, 1]),
trolleybus: Object.freeze([170, 255, 0, 1]),
tram: Object.freeze([255, 179, 0, 1]), tram: Object.freeze([255, 179, 0, 1]),
cable_tram: Object.freeze([255, 120, 0, 1]),
subway: Object.freeze([255, 59, 48, 1]), subway: Object.freeze([255, 59, 48, 1]),
monorail: Object.freeze([255, 64, 170, 1]),
rail: Object.freeze([215, 139, 255, 1]), rail: Object.freeze([215, 139, 255, 1]),
funicular: Object.freeze([255, 214, 140, 1]),
aerial_lift: Object.freeze([90, 140, 255, 1]),
ferry: Object.freeze([58, 208, 255, 1]), ferry: Object.freeze([58, 208, 255, 1]),
unknown: Object.freeze([255, 255, 255, 1]), unknown: Object.freeze([255, 255, 255, 1]),
}), }),
@@ -77,14 +72,9 @@ const SPRITE_STYLE = Object.freeze({
/** Modes with a bracket tier of their own. */ /** Modes with a bracket tier of their own. */
const TIER_MODES = Object.freeze([ const TIER_MODES = Object.freeze([
'bus', 'bus',
'trolleybus',
'tram', 'tram',
'cable_tram',
'subway', 'subway',
'monorail',
'rail', 'rail',
'funicular',
'aerial_lift',
'ferry', 'ferry',
'unknown', 'unknown',
]); ]);
@@ -138,7 +128,7 @@ export function presetSpriteOutlinePx(styleName, selected = false) {
/** /**
* Detection-overlay tier key for a transit mode. Every theme's `tiers` * Detection-overlay tier key for a transit mode. Every theme's `tiers`
* table carries one per mode, so the bracket colour is per mode in every * table carries these six, so the bracket colour is per mode in every
* preset. Unlike traffic, transit sets its tier keyless as well: the feeds * preset. Unlike traffic, transit sets its tier keyless as well: the feeds
* are keyless, and there is no separate keyless experience to keep * are keyless, and there is no separate keyless experience to keep
* byte-identical. * byte-identical.
-1
View File
@@ -19,7 +19,6 @@ import {
decodeVehiclePositions, decodeVehiclePositions,
} from './gtfsRealtime.js'; } from './gtfsRealtime.js';
import { getTransitFeed } from './transitFeeds.js'; import { getTransitFeed } from './transitFeeds.js';
export { transitModeFor, transitRouteType } from './transitFeeds.js';
/** Fresh window: a snapshot younger than this is served without refetching. */ /** Fresh window: a snapshot younger than this is served without refetching. */
export const TRANSIT_PROXY_TTL_MS = 15_000; export const TRANSIT_PROXY_TTL_MS = 15_000;
-244
View File
@@ -1,244 +0,0 @@
/**
* The bundled world cities pack (Wikidata, CC0), read from
* `local_data/wikidata_cities/cities.json` (built by
* scripts/build-wikidata-cities.mjs). The file loads once, on first use, and
* every answer comes from it: no place service is called.
*
* Rows are `[name, lat, lon, population, country, wikidata]`, largest first.
*/
const CELL_DEG = 1;
// Letters that are not a base letter plus an accent, so NFD leaves them.
const PLAIN_LETTERS = {
ł: 'l',
ø: 'o',
đ: 'd',
ð: 'd',
ı: 'i',
ß: 'ss',
æ: 'ae',
œ: 'oe',
þ: 'th',
};
/**
* A name as the lookups compare it: lower case, without accents, so
* "sao paulo" finds São Paulo, "zurich" Zürich, "lodz" Łódź and "tromso"
* Tromsø.
*/
export const foldName = (name) =>
String(name || '')
.normalize('NFD')
.replace(/\p{M}/gu, '')
.toLocaleLowerCase('en')
.replace(/[łøđðıßæœþ]/g, (letter) => PLAIN_LETTERS[letter])
.trim();
const EARTH_KM = 6371;
const rad = (deg) => (deg * Math.PI) / 180;
function distanceKm(lat1, lon1, lat2, lon2) {
const a =
Math.sin(rad(lat2 - lat1) / 2) ** 2 +
Math.cos(rad(lat1)) *
Math.cos(rad(lat2)) *
Math.sin(rad(lon2 - lon1) / 2) ** 2;
return 2 * EARTH_KM * Math.asin(Math.min(1, Math.sqrt(a)));
}
const cellKey = (lat, lon) =>
`${Math.floor(lat / CELL_DEG)},${Math.floor(((((lon % 360) + 540) % 360) - 180) / CELL_DEG)}`;
/** Group rows by one-degree cell so a lookup reads only the cells near it. */
export function indexCities(rows) {
const cells = new Map();
for (const row of rows) {
const key = cellKey(row[1], row[2]);
const cell = cells.get(key);
if (cell) cell.push(row);
else cells.set(key, [row]);
}
return cells;
}
/** Rows by folded name, each list in the rows' own order (largest first). */
export function indexNames(rows) {
const names = new Map();
for (const row of rows) {
const key = foldName(row[0]);
const list = names.get(key);
if (list) list.push(row);
else names.set(key, [row]);
}
return names;
}
function toCity(row, km) {
return {
name: row[0],
latitude: row[1],
longitude: row[2],
population: row[3],
country: row[4] || null,
wikidata: row[5],
distanceKm: km,
};
}
/**
* Half the longitude a circle spans, in degrees: 180 when it reaches a pole,
* where every meridian passes through it.
*/
export function lonHalfSpan(latitude, radiusKm) {
const angle = radiusKm / EARTH_KM;
const fromPole = Math.PI / 2 - Math.abs(rad(latitude));
if (angle >= fromPole) return 180;
return (Math.asin(Math.sin(angle) / Math.cos(rad(latitude))) * 180) / Math.PI;
}
const latHalfSpan = (radiusKm) => ((radiusKm / EARTH_KM) * 180) / Math.PI;
/** Rows within `radiusKm` of a point, each with its distance. */
export function citiesWithin(cells, latitude, longitude, radiusKm) {
const latSpan = Math.ceil(latHalfSpan(radiusKm) / CELL_DEG);
const lonSpan = Math.ceil(lonHalfSpan(latitude, radiusKm) / CELL_DEG);
const found = [];
const seen = new Set();
for (let dLat = -latSpan; dLat <= latSpan; dLat++) {
const lat = latitude + dLat * CELL_DEG;
if (lat < -90 - CELL_DEG || lat > 90 + CELL_DEG) continue;
for (
let dLon = -Math.min(lonSpan, 180);
dLon <= Math.min(lonSpan, 180);
dLon++
) {
const key = cellKey(lat, longitude + dLon * CELL_DEG);
if (seen.has(key)) continue;
seen.add(key);
for (const row of cells.get(key) || []) {
const km = distanceKm(latitude, longitude, row[1], row[2]);
if (km <= radiusKm) found.push(toCity(row, km));
}
}
}
return found;
}
// Each country in the pack by what a qualifier may call it: its code, its
// English name and its name in the page language, folded.
function countryNames(rows) {
const names = new Map();
for (const code of new Set(rows.map((row) => row[4]).filter(Boolean))) {
for (const name of [code, countryName(code, 'en'), countryName(code)])
names.set(foldName(name), code);
}
return names;
}
let loading = null;
/** The indexed pack; a failed load is retried on the next call. */
export function loadCities(fetchImpl = (...args) => globalThis.fetch(...args)) {
loading ??= fetchImpl(
new URL('./local_data/wikidata_cities/cities.json', import.meta.url),
)
.then((response) => {
if (!response.ok) throw new Error(`cities pack: HTTP ${response.status}`);
return response.json();
})
.then((pack) => ({
meta: pack.meta,
rows: pack.rows,
cells: indexCities(pack.rows),
names: indexNames(pack.rows),
countries: countryNames(pack.rows),
}))
.catch((error) => {
loading = null;
throw error;
});
return loading;
}
/**
* The city a point is in or nearest to: the largest within `nearKm`, else the
* nearest within `radiusKm`, else null. Preferring the largest nearby keeps a
* view over a metropolis from being named after one of its suburbs.
*/
export async function nearestCity(
latitude,
longitude,
{ radiusKm = 60, nearKm = 12 } = {},
) {
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
const { cells } = await loadCities();
const found = citiesWithin(cells, latitude, longitude, radiusKm);
if (!found.length) return null;
const near = found.filter((city) => city.distanceKm <= nearKm);
if (near.length)
return near.reduce((a, b) => (b.population > a.population ? b : a));
return found.reduce((a, b) => (b.distanceKm < a.distanceKm ? b : a));
}
/** Cities around a point, most populous first. */
export async function citiesAround({
latitude,
longitude,
radiusKm = 50,
limit = 8,
minPopulation = 0,
}) {
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return [];
const { cells } = await loadCities();
return citiesWithin(cells, latitude, longitude, radiusKm)
.filter((city) => city.population >= minPopulation)
.sort((a, b) => b.population - a.population || a.distanceKm - b.distanceKm)
.slice(0, limit);
}
/** A country's name in the page language, from its ISO code. */
export function countryName(
code,
language = globalThis.document?.documentElement?.lang || 'en',
) {
if (!code) return null;
try {
return (
new Intl.DisplayNames([language], { type: 'region' }).of(code) || code
);
} catch {
return code;
}
}
/**
* The city with this name, or null. A qualifier after a comma that names a
* country ("Paris, France", "Paris, US") keeps only cities there, and finds
* nothing when none is; one that names no country ("Paris, Texas") is
* ignored. A city of that name within `radiusKm` of `near` (the view) wins,
* the nearest first, else the largest city of that name anywhere.
*/
export async function findPlace(
name,
{ near = null, radiusKm = 150 } = {},
fetchImpl = (...args) => globalThis.fetch(...args),
) {
const [head, ...rest] = String(name || '').split(',');
const wanted = foldName(head);
if (!wanted) return null;
const qualifier = foldName(rest.join(','));
const { names, countries } = await loadCities(fetchImpl);
const country = qualifier ? countries.get(qualifier) : undefined;
const cities = (names.get(wanted) || []).filter(
(row) => !country || row[4] === country,
);
if (!cities.length) return null;
if (Number.isFinite(near?.latitude) && Number.isFinite(near?.longitude)) {
let best = null;
for (const row of cities) {
const km = distanceKm(near.latitude, near.longitude, row[1], row[2]);
if (km <= radiusKm && (!best || km < best.distanceKm))
best = toCity(row, km);
}
if (best) return best;
}
return toCity(cities[0], 0);
}
-121
View File
@@ -1,121 +0,0 @@
// src/data/wikidataCities.test.mjs — lookups over the bundled Wikidata
// places packs, with the packs served from memory.
import test from 'node:test';
import assert from 'node:assert/strict';
import {
citiesWithin,
foldName,
indexCities,
lonHalfSpan,
} from './wikidataCities.js';
const ROWS = [
['São Paulo', -23.55, -46.633, 11451999, 'BR', 'Q174'],
['Austin', 30.267, -97.743, 961855, 'US', 'Q16559'],
['Round Rock', 30.508, -97.679, 119468, 'US', 'Q128269'],
['Zürich', 47.374, 8.541, 421878, 'CH', 'Q72'],
];
test('names compare without case or accents', () => {
assert.equal(foldName('São Paulo'), 'sao paulo');
assert.equal(foldName(' ZÜRICH '), 'zurich');
assert.equal(foldName(null), '');
});
test('a lookup reads the cells around a point and measures each place', () => {
const cells = indexCities(ROWS);
const near = citiesWithin(cells, 30.27, -97.74, 40).sort(
(a, b) => a.distanceKm - b.distanceKm,
);
assert.deepEqual(
near.map((city) => city.name),
['Austin', 'Round Rock'],
);
assert.ok(near[0].distanceKm < 1);
assert.equal(near[1].country, 'US');
assert.deepEqual(citiesWithin(cells, 0, 0, 50), []);
});
test('findPlace finds a city by its name without accents', async () => {
// A fresh module, so its pack cache starts empty.
const { findPlace } = await import(`./wikidataCities.js?case=${Date.now()}`);
const fetchImpl = async (url) => ({
ok: true,
json: async () =>
String(url).includes('cities.json') ? { meta: {}, rows: ROWS } : null,
});
const found = await findPlace('sao paulo, Brazil', {}, fetchImpl);
assert.equal(found?.wikidata, 'Q174');
assert.equal(await findPlace('Atlantis', {}, fetchImpl), null);
});
test('names fold letters that carry no separate accent', () => {
assert.equal(foldName('Łódź'), 'lodz');
assert.equal(foldName('Tromsø'), 'tromso');
assert.equal(foldName('Đà Nẵng'), 'da nang');
assert.equal(foldName('Diyarbakır'), 'diyarbakir');
assert.equal(foldName('Straße'), 'strasse');
});
const PARIS = [
['Paris', 48.857, 2.352, 2145906, 'FR', 'Q90'],
['Paris', 33.661, -95.556, 24476, 'US', 'Q830149'],
];
async function freshPack(rows) {
const module = await import(
`./wikidataCities.js?case=${Date.now()}-${Math.random()}`
);
const fetchImpl = async () => ({
ok: true,
json: async () => ({ meta: {}, rows }),
});
return { ...module, fetchImpl };
}
test('findPlace takes a country qualifier and prefers a namesake in view', async () => {
const { findPlace, fetchImpl } = await freshPack(PARIS);
const texas = { latitude: 33.2, longitude: -96.0 };
assert.equal((await findPlace('Paris', {}, fetchImpl)).wikidata, 'Q90');
assert.equal(
(await findPlace('Paris', { near: texas }, fetchImpl)).wikidata,
'Q830149',
);
assert.equal(
(await findPlace('Paris, United States', {}, fetchImpl)).wikidata,
'Q830149',
);
assert.equal(
(await findPlace('paris, us', {}, fetchImpl)).wikidata,
'Q830149',
);
// A qualifier the packs cannot place is ignored.
assert.equal(
(await findPlace('Paris, Texas', { near: texas }, fetchImpl)).wikidata,
'Q830149',
);
});
test('a circle that reaches a pole spans every longitude', () => {
assert.equal(lonHalfSpan(89, 150), 180);
assert.ok(lonHalfSpan(60, 150) < 3);
const cells = indexCities([['Pole camp', 89.9, 179, 1, '', 'Q1']]);
assert.equal(citiesWithin(cells, 89, 0, 150).length, 1);
});
test('a country qualifier that matches no namesake finds nothing', async () => {
const { findPlace, fetchImpl } = await freshPack([
...PARIS,
['Berlin', 52.52, 13.405, 3755251, 'DE', 'Q64'],
]);
assert.equal(await findPlace('Paris, Germany', {}, fetchImpl), null);
assert.equal(
(await findPlace('Paris, France', {}, fetchImpl)).wikidata,
'Q90',
);
});
test('a lookup wraps at the antimeridian', () => {
const cells = indexCities([['Taveuni', -16.8, -179.9, 15000, 'FJ', 'Q2']]);
assert.equal(citiesWithin(cells, -16.8, 179.9, 40).length, 1);
});
+2 -2
View File
@@ -165,7 +165,7 @@ test('the DISMISS button closes the card the way ESC does', async (t) => {
assert.notEqual(sessionStorageRef.read(), null, 'dismissed for the session'); assert.notEqual(sessionStorageRef.read(), null, 'dismissed for the session');
root.dispatchEvent(new Event('transitionend')); root.dispatchEvent(new Event('transitionend'));
await nextTick(); await nextTick();
assert.equal(document.getElementById('first-run-launcher') === null, true); assert.equal(document.getElementById('first-run-launcher'), null);
}); });
test('the scroll fade clears when the tiles fit after a resize', async (t) => { test('the scroll fade clears when the tiles fit after a resize', async (t) => {
@@ -191,5 +191,5 @@ test('ESC closes the card, returns focus, and removes it without a transition',
// Reduced motion: no transitionend, so the bounded fallback removes it. // Reduced motion: no transitionend, so the bounded fallback removes it.
t.mock.timers.tick(400); t.mock.timers.tick(400);
await nextTick(); await nextTick();
assert.equal(document.getElementById('first-run-launcher') === null, true); assert.equal(document.getElementById('first-run-launcher'), null);
}); });
+2 -74
View File
@@ -177,69 +177,7 @@ function interpolate(pattern, values) {
/** A catalog string with something to show; an empty one counts as missing. */ /** A catalog string with something to show; an empty one counts as missing. */
const filled = (value) => typeof value === 'string' && value.trim() !== ''; const filled = (value) => typeof value === 'string' && value.trim() !== '';
// A host application's own text for messages Core shows (setMessageOverrides),
// by locale: { en: { 'English key': 'Its text' }, de: { … } }.
let overrides = Object.freeze({});
/**
* Show a host application's own text for Core's messages. `messages` maps a
* locale (`en` included) to entries keyed as the catalogs are: the English
* text, `context|text` for a t() context, the English plural for tn() (whose
* entry is a `{one, other, …}` object). An overridden message shows the host's
* text in the active locale, else the host's English, else Core's. Each call
* replaces the previous overrides; `{}` removes them. Call it before views
* render: onLocaleChange listeners word their text again at once, but a view
* already drawn keeps its text until it next renders. A t() entry is text, a
* tn() entry `{one, other, …}` forms worded in its locale; any other entry is
* ignored.
* @param {Record<string, Record<string, string|Record<string, string>>>} messages
*/
export function setMessageOverrides(messages) {
const next = {};
for (const [locale, entries] of Object.entries(messages ?? {})) {
const code = normalizeLocale(locale);
if (!code || !entries || typeof entries !== 'object') continue;
const own = Object.fromEntries(
Object.entries(entries).map(([key, entry]) => [
key,
entry && typeof entry === 'object'
? Object.freeze({ ...entry })
: entry,
]),
);
next[code] = Object.freeze({ ...next[code], ...own });
}
overrides = Object.freeze(next);
notifyLocaleChange();
}
/** A host's entry for t() is text; for tn() plural forms with an `other`. */
const usable = (entry, plural) =>
plural
? Boolean(entry) && typeof entry === 'object' && filled(entry.other)
: filled(entry);
/**
* The host's entry for a message, in the active locale or else in English,
* with the locale it is worded in (whose plural rules pick its form).
*/
function overridden(text, plural = false) {
for (const locale of new Set([getLocale(), DEFAULT_LOCALE])) {
const entries = overrides[locale];
const entry =
entries && Object.hasOwn(entries, text) ? entries[text] : undefined;
if (usable(entry, plural)) return { entry, locale };
}
return null;
}
function lookup(text) { function lookup(text) {
const host = overridden(text);
return host ? host.entry : catalogEntry(text);
}
/** Core's own catalog entry for a message in the active locale. */
function catalogEntry(text) {
const catalog = CATALOGS[getLocale()]; const catalog = CATALOGS[getLocale()];
if (!catalog) return undefined; if (!catalog) return undefined;
const entry = Object.hasOwn(catalog, text) ? catalog[text] : undefined; const entry = Object.hasOwn(catalog, text) ? catalog[text] : undefined;
@@ -274,14 +212,7 @@ export function t(text, values) {
* @returns {string} * @returns {string}
*/ */
export function tn(count, one, other, values) { export function tn(count, one, other, values) {
const host = overridden(other, true); const entry = lookup(other);
if (host) {
const forms = host.entry;
const category = pluralRules(host.locale).select(count);
const pattern = filled(forms[category]) ? forms[category] : forms.other;
return interpolate(pattern, { count, ...values });
}
const entry = catalogEntry(other);
const category = pluralRules(getLocale()).select(count); const category = pluralRules(getLocale()).select(count);
let pattern; let pattern;
const forms = entry && typeof entry === 'object' ? entry : null; const forms = entry && typeof entry === 'object' ? entry : null;
@@ -299,11 +230,8 @@ export function tn(count, one, other, values) {
* missing-translation warning. * missing-translation warning.
*/ */
export function translateKnown(text) { export function translateKnown(text) {
if (typeof text !== 'string') return text;
const host = overridden(text);
if (host) return host.entry;
const catalog = CATALOGS[getLocale()]; const catalog = CATALOGS[getLocale()];
if (!catalog) return text; if (!catalog || typeof text !== 'string') return text;
const entry = Object.hasOwn(catalog, text) ? catalog[text] : undefined; const entry = Object.hasOwn(catalog, text) ? catalog[text] : undefined;
return filled(entry) ? entry : text; return filled(entry) ? entry : text;
} }
+7 -26
View File
@@ -2032,6 +2032,7 @@
"60°S–60°N · typically 2–3 h delayed": "60° ج–60° ش · بتأخير 2–3 س عادةً", "60°S–60°N · typically 2–3 h delayed": "60° ج–60° ش · بتأخير 2–3 س عادةً",
"North America · infrared imagery": "أمريكا الشمالية · صور بالأشعة تحت الحمراء", "North America · infrared imagery": "أمريكا الشمالية · صور بالأشعة تحت الحمراء",
"Reduced motion · history playback unavailable": "تقليل الحركة · تشغيل السجل غير متاح", "Reduced motion · history playback unavailable": "تقليل الحركة · تشغيل السجل غير متاح",
"Reduced motion · manual history available": "تقليل الحركة · التصفح اليدوي للسجل متاح",
"Rain radar · US": "رادار الأمطار · الولايات المتحدة", "Rain radar · US": "رادار الأمطار · الولايات المتحدة",
"Lightning density · 15 min": "كثافة البرق · 15 د", "Lightning density · 15 min": "كثافة البرق · 15 د",
"Satellite clouds": "الغيوم من الأقمار الصناعية", "Satellite clouds": "الغيوم من الأقمار الصناعية",
@@ -2541,8 +2542,12 @@
"PAUSE": "إيقاف مؤقت", "PAUSE": "إيقاف مؤقت",
"RESUME": "استئناف", "RESUME": "استئناف",
"Clear reading": "مسح القراءة", "Clear reading": "مسح القراءة",
"Earlier observation": "رصد أسبق",
"Observed history": "سجل الأرصاد", "Observed history": "سجل الأرصاد",
"Later observation": "رصد لاحق",
"Latest": "الأحدث", "Latest": "الأحدث",
"Newest frame per product": "أحدث إطار لكل منتج",
"Replay observed history": "إعادة تشغيل سجل الأرصاد",
"Imagery on Esri · Google 3D returns when cleared": "الصور على Esri · تعود Google 3D عند المسح", "Imagery on Esri · Google 3D returns when cleared": "الصور على Esri · تعود Google 3D عند المسح",
"Point the camera at the ground to use the view": "وجّه الكاميرا نحو الأرض لاستخدام العرض", "Point the camera at the ground to use the view": "وجّه الكاميرا نحو الأرض لاستخدام العرض",
"That point cannot anchor a box": "لا يمكن تثبيت منطقة على تلك النقطة", "That point cannot anchor a box": "لا يمكن تثبيت منطقة على تلك النقطة",
@@ -2843,6 +2848,7 @@
"dB": "dB", "dB": "dB",
"{time} · {minutes} min ago": "{time} · قبل {minutes} د", "{time} · {minutes} min ago": "{time} · قبل {minutes} د",
"Active weather": "الطقس النشط", "Active weather": "الطقس النشط",
"LATEST · newest per product": "الأحدث · آخر إطار لكل منتج",
"Forecast · valid {valid} · issued {issued}": "توقع عند {valid} · صدر {issued}", "Forecast · valid {valid} · issued {issued}": "توقع عند {valid} · صدر {issued}",
"{detail} · Does not follow history": "{detail} · لا يتبع السجل", "{detail} · Does not follow history": "{detail} · لا يتبع السجل",
"Forecast · valid {valid}": "توقع عند {valid}", "Forecast · valid {valid}": "توقع عند {valid}",
@@ -3956,30 +3962,5 @@
"Reset view reloads the app without the view restored from the address bar.": "تعيد «إعادة ضبط العرض» تحميل التطبيق دون العرض المستعاد من شريط العنوان.", "Reset view reloads the app without the view restored from the address bar.": "تعيد «إعادة ضبط العرض» تحميل التطبيق دون العرض المستعاد من شريط العنوان.",
"Reload": "إعادة التحميل", "Reload": "إعادة التحميل",
"Retry": "إعادة المحاولة", "Retry": "إعادة المحاولة",
"Reset view": "إعادة ضبط العرض", "Reset view": "إعادة ضبط العرض"
"Trolleybus": "ترولي باص",
"Cable car": "ترام كابلي",
"Monorail": "قطار أحادي السكة",
"Funicular": "قطار جبلي مائل",
"Gondola": "تلفريك",
"Move panels back beside the rail": "إعادة اللوحات إلى جانب الشريط",
"Move panels over the rail, to the edge": "نقل اللوحات فوق الشريط إلى الحافة",
"panel|VISUAL": "المرئيات",
"Look": "المظهر",
"Overlay": "التراكب",
"Effects": "المؤثرات",
"Scope opacity": "عتامة المنظار",
"How dark the scope shades the view outside the keyhole": "مدى تعتيم المنظار للعرض خارج الفتحة",
"Reset to default": "إعادة التعيين إلى الافتراضي",
"Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "إعادة المرئيات إلى إعداداتها الافتراضية: المظهر العادي وHUD والمنظار والتسميات والمؤثرات",
"All layers": "كل الطبقات",
"On": "تشغيل",
"Off": "إيقاف",
"Search layers": "البحث في الطبقات",
"Filter layers": "تصفية الطبقات",
"Status": "الحالة",
"Search layers…": "ابحث في الطبقات…",
"Layer groups": "مجموعات الطبقات",
"Jump to {name}": "الانتقال إلى {name}",
"No layers match": "لا توجد طبقات مطابقة"
} }
+7 -26
View File
@@ -1936,6 +1936,7 @@
"60°S–60°N · typically 2–3 h delayed": "60°S–60°N · meist 2–3 h verzögert", "60°S–60°N · typically 2–3 h delayed": "60°S–60°N · meist 2–3 h verzögert",
"North America · infrared imagery": "Nordamerika · Infrarotbilder", "North America · infrared imagery": "Nordamerika · Infrarotbilder",
"Reduced motion · history playback unavailable": "Reduzierte Bewegung · Verlaufswiedergabe nicht verfügbar", "Reduced motion · history playback unavailable": "Reduzierte Bewegung · Verlaufswiedergabe nicht verfügbar",
"Reduced motion · manual history available": "Reduzierte Bewegung · manueller Verlauf verfügbar",
"Rain radar · US": "Regenradar · USA", "Rain radar · US": "Regenradar · USA",
"Lightning density · 15 min": "Blitzdichte · 15 min", "Lightning density · 15 min": "Blitzdichte · 15 min",
"Satellite clouds": "Wolken (Satellit)", "Satellite clouds": "Wolken (Satellit)",
@@ -2401,8 +2402,12 @@
"PAUSE": "PAUSE", "PAUSE": "PAUSE",
"RESUME": "FORTSETZEN", "RESUME": "FORTSETZEN",
"Clear reading": "Wert ausblenden", "Clear reading": "Wert ausblenden",
"Earlier observation": "Frühere Beobachtung",
"Observed history": "Beobachteter Verlauf", "Observed history": "Beobachteter Verlauf",
"Later observation": "Spätere Beobachtung",
"Latest": "Neueste", "Latest": "Neueste",
"Newest frame per product": "Neuester Frame je Produkt",
"Replay observed history": "Beobachteten Verlauf abspielen",
"Imagery on Esri · Google 3D returns when cleared": "Bilder auf Esri · nach LÖSCHEN wieder Google 3D", "Imagery on Esri · Google 3D returns when cleared": "Bilder auf Esri · nach LÖSCHEN wieder Google 3D",
"Point the camera at the ground to use the view": "Kamera auf den Boden richten, um die Ansicht zu verwenden", "Point the camera at the ground to use the view": "Kamera auf den Boden richten, um die Ansicht zu verwenden",
"That point cannot anchor a box": "Dieser Punkt kann keinen Bereich verankern", "That point cannot anchor a box": "Dieser Punkt kann keinen Bereich verankern",
@@ -2651,6 +2656,7 @@
"dB": "dB", "dB": "dB",
"{time} · {minutes} min ago": "{time} · vor {minutes} min", "{time} · {minutes} min ago": "{time} · vor {minutes} min",
"Active weather": "Aktives Wetter", "Active weather": "Aktives Wetter",
"LATEST · newest per product": "NEUESTE · je Produkt",
"Forecast · valid {valid} · issued {issued}": "Vorhersage · gültig {valid} · ausgegeben {issued}", "Forecast · valid {valid} · issued {issued}": "Vorhersage · gültig {valid} · ausgegeben {issued}",
"{detail} · Does not follow history": "{detail} · Folgt nicht dem Verlauf", "{detail} · Does not follow history": "{detail} · Folgt nicht dem Verlauf",
"Forecast · valid {valid}": "Vorhersage · gültig {valid}", "Forecast · valid {valid}": "Vorhersage · gültig {valid}",
@@ -3608,30 +3614,5 @@
"Reset view reloads the app without the view restored from the address bar.": "Ansicht zurücksetzen lädt die App ohne die aus der Adressleiste wiederhergestellte Ansicht neu.", "Reset view reloads the app without the view restored from the address bar.": "Ansicht zurücksetzen lädt die App ohne die aus der Adressleiste wiederhergestellte Ansicht neu.",
"Reload": "Neu laden", "Reload": "Neu laden",
"Retry": "Erneut versuchen", "Retry": "Erneut versuchen",
"Reset view": "Ansicht zurücksetzen", "Reset view": "Ansicht zurücksetzen"
"Trolleybus": "Oberleitungsbus",
"Cable car": "Kabelstraßenbahn",
"Monorail": "Einschienenbahn",
"Funicular": "Standseilbahn",
"Gondola": "Gondelbahn",
"Move panels back beside the rail": "Bedienfelder zurück neben die Leiste",
"Move panels over the rail, to the edge": "Bedienfelder über die Leiste an den Rand verschieben",
"panel|VISUAL": "DARSTELLUNG",
"Look": "Look",
"Overlay": "Overlay",
"Effects": "Effekte",
"Scope opacity": "Scope-Deckkraft",
"How dark the scope shades the view outside the keyhole": "Wie stark der Scope die Ansicht außerhalb des Schlüssellochs abdunkelt",
"Reset to default": "Auf Standard zurücksetzen",
"Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "Darstellung auf die Standardwerte zurücksetzen: normaler Look, HUD, Scope, Beschriftungen und Effekte",
"All layers": "Alle Ebenen",
"On": "An",
"Off": "Aus",
"Search layers": "Ebenen suchen",
"Filter layers": "Ebenen filtern",
"Status": "Status",
"Search layers…": "Ebenen suchen…",
"Layer groups": "Ebenengruppen",
"Jump to {name}": "Zu {name} springen",
"No layers match": "Keine passenden Ebenen"
} }
+7 -26
View File
@@ -1436,6 +1436,7 @@
"60°S–60°N · typically 2–3 h delayed": "60°S–60°N · normalmente con 2–3 h de retraso", "60°S–60°N · typically 2–3 h delayed": "60°S–60°N · normalmente con 2–3 h de retraso",
"North America · infrared imagery": "Norteamérica · imágenes infrarrojas", "North America · infrared imagery": "Norteamérica · imágenes infrarrojas",
"Reduced motion · history playback unavailable": "Movimiento reducido · reproducción del historial no disponible", "Reduced motion · history playback unavailable": "Movimiento reducido · reproducción del historial no disponible",
"Reduced motion · manual history available": "Movimiento reducido · puedes recorrer el historial a mano",
"Rain radar · US": "Radar de lluvia · EE. UU.", "Rain radar · US": "Radar de lluvia · EE. UU.",
"Lightning density · 15 min": "Densidad de rayos · 15 min", "Lightning density · 15 min": "Densidad de rayos · 15 min",
"Satellite clouds": "Nubes por satélite", "Satellite clouds": "Nubes por satélite",
@@ -1666,8 +1667,12 @@
"CH {index} / {total}": "CANAL {index} / {total}", "CH {index} / {total}": "CANAL {index} / {total}",
"Station unavailable after directory refresh": "Emisora no disponible tras actualizar el directorio", "Station unavailable after directory refresh": "Emisora no disponible tras actualizar el directorio",
"Clear reading": "Borrar lectura", "Clear reading": "Borrar lectura",
"Earlier observation": "Observación anterior",
"Observed history": "Historial observado", "Observed history": "Historial observado",
"Later observation": "Observación posterior",
"Latest": "Último", "Latest": "Último",
"Newest frame per product": "Fotograma más reciente por producto",
"Replay observed history": "Reproducir el historial observado",
"S on another day replaces it · × unpins": "S en otro día lo reemplaza · × quita la fijación", "S on another day replaces it · × unpins": "S en otro día lo reemplaza · × quita la fijación",
"← → preview · S shows the focused day": "← → vista previa · S muestra el día enfocado", "← → preview · S shows the focused day": "← → vista previa · S muestra el día enfocado",
"Recent imagery": "Imágenes recientes", "Recent imagery": "Imágenes recientes",
@@ -1831,6 +1836,7 @@
"Reset map bearing to north": "Restablecer la orientación del mapa al norte", "Reset map bearing to north": "Restablecer la orientación del mapa al norte",
"Rain radar": "Radar de lluvia", "Rain radar": "Radar de lluvia",
"Lightning density": "Densidad de rayos", "Lightning density": "Densidad de rayos",
"LATEST · newest per product": "ÚLTIMO · más reciente por producto",
"Forecast · valid {valid} · issued {issued}": "Pronóstico · válido {valid} · emitido {issued}", "Forecast · valid {valid} · issued {issued}": "Pronóstico · válido {valid} · emitido {issued}",
"{detail} · Does not follow history": "{detail} · No sigue el historial", "{detail} · Does not follow history": "{detail} · No sigue el historial",
"Forecast · valid {valid}": "Pronóstico · válido {valid}", "Forecast · valid {valid}": "Pronóstico · válido {valid}",
@@ -3695,30 +3701,5 @@
"Reset view reloads the app without the view restored from the address bar.": "Restablecer la vista recarga la app sin la vista restaurada desde la barra de direcciones.", "Reset view reloads the app without the view restored from the address bar.": "Restablecer la vista recarga la app sin la vista restaurada desde la barra de direcciones.",
"Reload": "Recargar", "Reload": "Recargar",
"Retry": "Reintentar", "Retry": "Reintentar",
"Reset view": "Restablecer la vista", "Reset view": "Restablecer la vista"
"Trolleybus": "Trolebús",
"Cable car": "Tranvía de cable",
"Monorail": "Monorraíl",
"Funicular": "Funicular",
"Gondola": "Góndola",
"Move panels back beside the rail": "Devolver los paneles junto a la barra",
"Move panels over the rail, to the edge": "Mover los paneles sobre la barra, hasta el borde",
"panel|VISUAL": "VISUAL",
"Look": "Aspecto",
"Overlay": "Superposición",
"Effects": "Efectos",
"Scope opacity": "Opacidad de la mirilla",
"How dark the scope shades the view outside the keyhole": "Cuánto oscurece la mirilla la vista fuera del ojo de cerradura",
"Reset to default": "Restablecer valores predeterminados",
"Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "Restablecer Visual a sus valores predeterminados: aspecto Normal, HUD, mirilla, etiquetas y efectos",
"All layers": "Todas las capas",
"On": "Activadas",
"Off": "Desactivadas",
"Search layers": "Buscar capas",
"Filter layers": "Filtrar capas",
"Status": "Estado",
"Search layers…": "Buscar capas…",
"Layer groups": "Grupos de capas",
"Jump to {name}": "Ir a {name}",
"No layers match": "Ninguna capa coincide"
} }
+7 -26
View File
@@ -1350,6 +1350,7 @@
"60°S–60°N · typically 2–3 h delayed": "60°S–60°N · retard habituel de 2 à 3 h", "60°S–60°N · typically 2–3 h delayed": "60°S–60°N · retard habituel de 2 à 3 h",
"North America · infrared imagery": "Amérique du Nord · imagerie infrarouge", "North America · infrared imagery": "Amérique du Nord · imagerie infrarouge",
"Reduced motion · history playback unavailable": "Animations réduites · lecture de l'historique indisponible", "Reduced motion · history playback unavailable": "Animations réduites · lecture de l'historique indisponible",
"Reduced motion · manual history available": "Animations réduites · historique parcourable manuellement",
"Rain radar · US": "Radar de pluie · États-Unis", "Rain radar · US": "Radar de pluie · États-Unis",
"Lightning density · 15 min": "Densité de foudre · 15 min", "Lightning density · 15 min": "Densité de foudre · 15 min",
"Satellite clouds": "Nuages par satellite", "Satellite clouds": "Nuages par satellite",
@@ -2149,8 +2150,12 @@
"Radio is disabling…": "Désactivation de la Radio…", "Radio is disabling…": "Désactivation de la Radio…",
"Radio lifecycle is uncertain — use Enable or Disable to reconcile": "État de la Radio incertain — utilisez Activer ou Désactiver pour le rétablir", "Radio lifecycle is uncertain — use Enable or Disable to reconcile": "État de la Radio incertain — utilisez Activer ou Désactiver pour le rétablir",
"Clear reading": "Effacer le relevé", "Clear reading": "Effacer le relevé",
"Earlier observation": "Observation précédente",
"Observed history": "Historique des observations", "Observed history": "Historique des observations",
"Later observation": "Observation suivante",
"Latest": "Dernière", "Latest": "Dernière",
"Newest frame per product": "Image la plus récente par produit",
"Replay observed history": "Rejouer l'historique des observations",
"Imagery on Esri · Google 3D returns when cleared": "Sur Esri · EFFACER rétablit Google 3D", "Imagery on Esri · Google 3D returns when cleared": "Sur Esri · EFFACER rétablit Google 3D",
"Point the camera at the ground to use the view": "Orientez la caméra vers le sol pour utiliser la vue", "Point the camera at the ground to use the view": "Orientez la caméra vers le sol pour utiliser la vue",
"That point cannot anchor a box": "Ce point ne peut pas ancrer une zone", "That point cannot anchor a box": "Ce point ne peut pas ancrer une zone",
@@ -2441,6 +2446,7 @@
"DETECT": "DÉTECT.", "DETECT": "DÉTECT.",
"{time} · {minutes} min ago": "{time} · il y a {minutes} min", "{time} · {minutes} min ago": "{time} · il y a {minutes} min",
"Active weather": "Couches météo actives", "Active weather": "Couches météo actives",
"LATEST · newest per product": "DERNIÈRE · la plus récente par produit",
"Forecast · valid {valid} · issued {issued}": "Prévision · valide {valid} · émise {issued}", "Forecast · valid {valid} · issued {issued}": "Prévision · valide {valid} · émise {issued}",
"{detail} · Does not follow history": "{detail} · Ne suit pas l'historique", "{detail} · Does not follow history": "{detail} · Ne suit pas l'historique",
"Forecast · valid {valid}": "Prévision · valide {valid}", "Forecast · valid {valid}": "Prévision · valide {valid}",
@@ -3695,30 +3701,5 @@
"Reset view reloads the app without the view restored from the address bar.": "Réinitialiser la vue recharge l’app sans la vue restaurée depuis la barre d’adresse.", "Reset view reloads the app without the view restored from the address bar.": "Réinitialiser la vue recharge l’app sans la vue restaurée depuis la barre d’adresse.",
"Reload": "Recharger", "Reload": "Recharger",
"Retry": "Réessayer", "Retry": "Réessayer",
"Reset view": "Réinitialiser la vue", "Reset view": "Réinitialiser la vue"
"Trolleybus": "Trolleybus",
"Cable car": "Tramway à câble",
"Monorail": "Monorail",
"Funicular": "Funiculaire",
"Gondola": "Télécabine",
"Move panels back beside the rail": "Remettre les panneaux à côté de la barre",
"Move panels over the rail, to the edge": "Déplacer les panneaux par-dessus la barre, jusqu’au bord",
"panel|VISUAL": "VISUEL",
"Look": "Aspect",
"Overlay": "Surcouche",
"Effects": "Effets",
"Scope opacity": "Opacité de la lunette",
"How dark the scope shades the view outside the keyhole": "À quel point la lunette assombrit la vue hors de l'ouverture",
"Reset to default": "Rétablir les valeurs par défaut",
"Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "Rétablir les valeurs par défaut de Visuel : aspect Normal, HUD, lunette, libellés et effets",
"All layers": "Toutes les couches",
"On": "Actives",
"Off": "Inactives",
"Search layers": "Rechercher des couches",
"Filter layers": "Filtrer les couches",
"Status": "État",
"Search layers…": "Rechercher des couches…",
"Layer groups": "Groupes de couches",
"Jump to {name}": "Aller à {name}",
"No layers match": "Aucune couche ne correspond"
} }
+7 -26
View File
@@ -1208,6 +1208,7 @@
"Color: strikes/km²/min ×10³": "צבע: פגיעות ברק/קמ\"ר/דק׳ ×10³", "Color: strikes/km²/min ×10³": "צבע: פגיעות ברק/קמ\"ר/דק׳ ×10³",
"Color: {units}": "צבע: {units}", "Color: {units}": "צבע: {units}",
"Reduced motion · history playback unavailable": "תנועה מופחתת · הצגת ההיסטוריה אינה זמינה", "Reduced motion · history playback unavailable": "תנועה מופחתת · הצגת ההיסטוריה אינה זמינה",
"Reduced motion · manual history available": "תנועה מופחתת · היסטוריה ידנית זמינה",
"Rain radar · US": "רדאר גשם · ארה\"ב", "Rain radar · US": "רדאר גשם · ארה\"ב",
"Lightning density · 15 min": "צפיפות ברקים · 15 דק׳", "Lightning density · 15 min": "צפיפות ברקים · 15 דק׳",
"Satellite clouds": "עננים מלוויין", "Satellite clouds": "עננים מלוויין",
@@ -1995,7 +1996,11 @@
"READY": "מוכן", "READY": "מוכן",
"Clear reading": "ניקוי הקריאה", "Clear reading": "ניקוי הקריאה",
"Observed history": "היסטוריה נצפית", "Observed history": "היסטוריה נצפית",
"Earlier observation": "תצפית קודמת",
"Later observation": "תצפית מאוחרת",
"Newest frame per product": "השקופית החדשה לכל מוצר",
"Latest": "האחרונה", "Latest": "האחרונה",
"Replay observed history": "הצגת ההיסטוריה הנצפית שוב",
"Imagery on Esri · Google 3D returns when cleared": "צילומים ב-Esri · Google 3D חוזר כשמנקים", "Imagery on Esri · Google 3D returns when cleared": "צילומים ב-Esri · Google 3D חוזר כשמנקים",
"Point the camera at the ground to use the view": "כוון את המצלמה אל הקרקע כדי להשתמש בתצוגה", "Point the camera at the ground to use the view": "כוון את המצלמה אל הקרקע כדי להשתמש בתצוגה",
"That point cannot anchor a box": "אין אפשרות לעגן תיבה באותה נקודה", "That point cannot anchor a box": "אין אפשרות לעגן תיבה באותה נקודה",
@@ -2778,6 +2783,7 @@
"Detection overlay: off": "שכבת הזיהוי: כבויה", "Detection overlay: off": "שכבת הזיהוי: כבויה",
"Active weather": "מזג אוויר פעיל", "Active weather": "מזג אוויר פעיל",
"{time} · {minutes} min ago": "{time} · לפני {minutes} דק׳", "{time} · {minutes} min ago": "{time} · לפני {minutes} דק׳",
"LATEST · newest per product": "האחרון · החדש ביותר לכל מוצר",
"Forecast · valid {valid} · issued {issued}": "תחזית · תקפה {valid} · פורסמה {issued}", "Forecast · valid {valid} · issued {issued}": "תחזית · תקפה {valid} · פורסמה {issued}",
"{detail} · Does not follow history": "{detail} · אינו נע עם ההיסטוריה", "{detail} · Does not follow history": "{detail} · אינו נע עם ההיסטוריה",
"Forecast · valid {valid}": "תחזית · תקפה {valid}", "Forecast · valid {valid}": "תחזית · תקפה {valid}",
@@ -3695,30 +3701,5 @@
"Reset view reloads the app without the view restored from the address bar.": "\"איפוס תצוגה\" טוען מחדש את האפליקציה בלי התצוגה ששוחזרה משורת הכתובת.", "Reset view reloads the app without the view restored from the address bar.": "\"איפוס תצוגה\" טוען מחדש את האפליקציה בלי התצוגה ששוחזרה משורת הכתובת.",
"Reload": "טען מחדש", "Reload": "טען מחדש",
"Retry": "נסה שוב", "Retry": "נסה שוב",
"Reset view": "איפוס תצוגה", "Reset view": "איפוס תצוגה"
"Trolleybus": "טרוליבוס",
"Cable car": "חשמלית כבלים",
"Monorail": "מונורייל",
"Funicular": "פוניקולר",
"Gondola": "רכבל",
"Move panels back beside the rail": "החזרת הלוחות לצד הסרגל",
"Move panels over the rail, to the edge": "הזזת הלוחות מעל הסרגל, אל הקצה",
"panel|VISUAL": "חזותי",
"Look": "מראה",
"Overlay": "שכבת על",
"Effects": "אפקטים",
"Scope opacity": "אטימות הסקופ",
"How dark the scope shades the view outside the keyhole": "עד כמה הסקופ מכהה את התצוגה מחוץ לחור המנעול",
"Reset to default": "איפוס לברירת המחדל",
"Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "איפוס החזותי לברירות המחדל: מראה רגיל, HUD, סקופ, תוויות ואפקטים",
"All layers": "כל השכבות",
"On": "פעילות",
"Off": "כבויות",
"Search layers": "חיפוש שכבות",
"Filter layers": "סינון שכבות",
"Status": "מצב",
"Search layers…": "חיפוש שכבות…",
"Layer groups": "קבוצות שכבות",
"Jump to {name}": "מעבר אל {name}",
"No layers match": "אין שכבות תואמות"
} }
+7 -26
View File
@@ -1350,6 +1350,7 @@
"60°S–60°N · typically 2–3 h delayed": "60°S–60°N · di solito 2–3 h di ritardo", "60°S–60°N · typically 2–3 h delayed": "60°S–60°N · di solito 2–3 h di ritardo",
"North America · infrared imagery": "Nord America · immagini all'infrarosso", "North America · infrared imagery": "Nord America · immagini all'infrarosso",
"Reduced motion · history playback unavailable": "Movimento ridotto · riproduzione dello storico non disponibile", "Reduced motion · history playback unavailable": "Movimento ridotto · riproduzione dello storico non disponibile",
"Reduced motion · manual history available": "Movimento ridotto · storico manuale disponibile",
"Rain radar · US": "Radar pioggia · USA", "Rain radar · US": "Radar pioggia · USA",
"Lightning density · 15 min": "Densità di fulmini · 15 min", "Lightning density · 15 min": "Densità di fulmini · 15 min",
"Satellite clouds": "Nuvole da satellite", "Satellite clouds": "Nuvole da satellite",
@@ -2149,8 +2150,12 @@
"Radio is disabling…": "Disattivazione della Radio…", "Radio is disabling…": "Disattivazione della Radio…",
"Radio lifecycle is uncertain — use Enable or Disable to reconcile": "Il ciclo di vita della Radio è incerto: usa Attiva o Disattiva per riconciliare", "Radio lifecycle is uncertain — use Enable or Disable to reconcile": "Il ciclo di vita della Radio è incerto: usa Attiva o Disattiva per riconciliare",
"Clear reading": "Cancella la lettura", "Clear reading": "Cancella la lettura",
"Earlier observation": "Osservazione precedente",
"Observed history": "Storico osservato", "Observed history": "Storico osservato",
"Later observation": "Osservazione successiva",
"Latest": "Più recente", "Latest": "Più recente",
"Newest frame per product": "Fotogramma più recente per prodotto",
"Replay observed history": "Rivedi lo storico osservato",
"Imagery on Esri · Google 3D returns when cleared": "Immagini su Esri · Google 3D torna con CANCELLA", "Imagery on Esri · Google 3D returns when cleared": "Immagini su Esri · Google 3D torna con CANCELLA",
"Point the camera at the ground to use the view": "Punta la camera verso il suolo per usare la vista", "Point the camera at the ground to use the view": "Punta la camera verso il suolo per usare la vista",
"That point cannot anchor a box": "Quel punto non può ancorare un riquadro", "That point cannot anchor a box": "Quel punto non può ancorare un riquadro",
@@ -2441,6 +2446,7 @@
"DETECT": "RILEV.", "DETECT": "RILEV.",
"{time} · {minutes} min ago": "{time} · {minutes} min fa", "{time} · {minutes} min ago": "{time} · {minutes} min fa",
"Active weather": "Meteo attivo", "Active weather": "Meteo attivo",
"LATEST · newest per product": "ULTIMO · il più recente per prodotto",
"Forecast · valid {valid} · issued {issued}": "Previsione · valida {valid} · emessa {issued}", "Forecast · valid {valid} · issued {issued}": "Previsione · valida {valid} · emessa {issued}",
"{detail} · Does not follow history": "{detail} · Non segue lo storico", "{detail} · Does not follow history": "{detail} · Non segue lo storico",
"Forecast · valid {valid}": "Previsione · valida {valid}", "Forecast · valid {valid}": "Previsione · valida {valid}",
@@ -3695,30 +3701,5 @@
"Reset view reloads the app without the view restored from the address bar.": "Ripristina vista ricarica l’app senza la vista ripristinata dalla barra degli indirizzi.", "Reset view reloads the app without the view restored from the address bar.": "Ripristina vista ricarica l’app senza la vista ripristinata dalla barra degli indirizzi.",
"Reload": "Ricarica", "Reload": "Ricarica",
"Retry": "Riprova", "Retry": "Riprova",
"Reset view": "Ripristina vista", "Reset view": "Ripristina vista"
"Trolleybus": "Filobus",
"Cable car": "Tram a fune",
"Monorail": "Monorotaia",
"Funicular": "Funicolare",
"Gondola": "Cabinovia",
"Move panels back beside the rail": "Riporta i pannelli accanto alla barra",
"Move panels over the rail, to the edge": "Sposta i pannelli sopra la barra, fino al bordo",
"panel|VISUAL": "VISUALE",
"Look": "Aspetto",
"Overlay": "Overlay",
"Effects": "Effetti",
"Scope opacity": "Opacità dello scope",
"How dark the scope shades the view outside the keyhole": "Quanto lo scope scurisce la vista fuori dal foro",
"Reset to default": "Ripristina predefiniti",
"Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "Ripristina Visuale ai valori predefiniti: aspetto Normale, HUD, scope, etichette ed effetti",
"All layers": "Tutti i livelli",
"On": "Attivi",
"Off": "Disattivati",
"Search layers": "Cerca livelli",
"Filter layers": "Filtra livelli",
"Status": "Stato",
"Search layers…": "Cerca livelli…",
"Layer groups": "Gruppi di livelli",
"Jump to {name}": "Vai a {name}",
"No layers match": "Nessun livello corrisponde"
} }
+7 -26
View File
@@ -1312,6 +1312,7 @@
"60°S–60°N · typically 2–3 h delayed": "60°S–60°N · 通常2–3時間遅れ", "60°S–60°N · typically 2–3 h delayed": "60°S–60°N · 通常2–3時間遅れ",
"North America · infrared imagery": "北米 · 赤外画像", "North America · infrared imagery": "北米 · 赤外画像",
"Reduced motion · history playback unavailable": "動きを減らす設定 · 履歴再生は利用不可", "Reduced motion · history playback unavailable": "動きを減らす設定 · 履歴再生は利用不可",
"Reduced motion · manual history available": "動きを減らす設定 · 履歴は手動で表示可能",
"Rain radar · US": "降水レーダー · 米国", "Rain radar · US": "降水レーダー · 米国",
"Lightning density · 15 min": "雷密度 · 15分", "Lightning density · 15 min": "雷密度 · 15分",
"Satellite clouds": "衛星雲画像", "Satellite clouds": "衛星雲画像",
@@ -2075,8 +2076,12 @@
"Radio is disabling…": "ラジオを無効化しています…", "Radio is disabling…": "ラジオを無効化しています…",
"Radio lifecycle is uncertain — use Enable or Disable to reconcile": "ラジオのライフサイクルが不明確です。有効化または無効化で再調整してください", "Radio lifecycle is uncertain — use Enable or Disable to reconcile": "ラジオのライフサイクルが不明確です。有効化または無効化で再調整してください",
"Clear reading": "読み取り値を消去", "Clear reading": "読み取り値を消去",
"Earlier observation": "前の観測",
"Observed history": "観測履歴", "Observed history": "観測履歴",
"Later observation": "次の観測",
"Latest": "最新", "Latest": "最新",
"Newest frame per product": "プロダクトごとの最新フレーム",
"Replay observed history": "観測履歴を再生",
"Imagery on Esri · Google 3D returns when cleared": "画像はEsri上に表示 · 消去するとGoogle 3Dに戻ります", "Imagery on Esri · Google 3D returns when cleared": "画像はEsri上に表示 · 消去するとGoogle 3Dに戻ります",
"Point the camera at the ground to use the view": "このビューを使うにはカメラを地面に向けてください", "Point the camera at the ground to use the view": "このビューを使うにはカメラを地面に向けてください",
"That point cannot anchor a box": "その地点は範囲の基点にできません", "That point cannot anchor a box": "その地点は範囲の基点にできません",
@@ -2341,6 +2346,7 @@
"DETECT": "検出", "DETECT": "検出",
"{time} · {minutes} min ago": "{time} · {minutes}分前", "{time} · {minutes} min ago": "{time} · {minutes}分前",
"Active weather": "有効な気象レイヤー", "Active weather": "有効な気象レイヤー",
"LATEST · newest per product": "最新 · プロダクトごとの最新",
"Forecast · valid {valid} · issued {issued}": "予報 · 対象時刻 {valid} · 発表 {issued}", "Forecast · valid {valid} · issued {issued}": "予報 · 対象時刻 {valid} · 発表 {issued}",
"{detail} · Does not follow history": "{detail} · 履歴とは連動しません", "{detail} · Does not follow history": "{detail} · 履歴とは連動しません",
"Forecast · valid {valid}": "予報 · 対象時刻 {valid}", "Forecast · valid {valid}": "予報 · 対象時刻 {valid}",
@@ -3521,30 +3527,5 @@
"Reset view reloads the app without the view restored from the address bar.": "「ビューをリセット」は、アドレスバーから復元したビューを使わずにアプリを再読み込みします。", "Reset view reloads the app without the view restored from the address bar.": "「ビューをリセット」は、アドレスバーから復元したビューを使わずにアプリを再読み込みします。",
"Reload": "再読み込み", "Reload": "再読み込み",
"Retry": "再試行", "Retry": "再試行",
"Reset view": "ビューをリセット", "Reset view": "ビューをリセット"
"Trolleybus": "トロリーバス",
"Cable car": "ケーブルトラム",
"Monorail": "モノレール",
"Funicular": "ケーブルカー",
"Gondola": "ゴンドラ",
"Move panels back beside the rail": "パネルをレールの横に戻す",
"Move panels over the rail, to the edge": "パネルをレールの上、画面端へ移動",
"panel|VISUAL": "ビジュアル",
"Look": "見た目",
"Overlay": "オーバーレイ",
"Effects": "エフェクト",
"Scope opacity": "スコープの不透明度",
"How dark the scope shades the view outside the keyhole": "キーホール外のビューをスコープがどれだけ暗くするか",
"Reset to default": "既定に戻す",
"Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "ビジュアルを既定に戻す:標準の見た目、HUD、スコープ、ラベル、エフェクト",
"All layers": "すべてのレイヤー",
"On": "オン",
"Off": "オフ",
"Search layers": "レイヤーを検索",
"Filter layers": "レイヤーを絞り込む",
"Status": "状態",
"Search layers…": "レイヤーを検索…",
"Layer groups": "レイヤーのグループ",
"Jump to {name}": "{name} へ移動",
"No layers match": "一致するレイヤーはありません"
} }
+7 -26
View File
@@ -1348,6 +1348,7 @@
"60°S–60°N · typically 2–3 h delayed": "60°S–60°N · normalmente com 2–3 h de atraso", "60°S–60°N · typically 2–3 h delayed": "60°S–60°N · normalmente com 2–3 h de atraso",
"North America · infrared imagery": "América do Norte · imagens infravermelhas", "North America · infrared imagery": "América do Norte · imagens infravermelhas",
"Reduced motion · history playback unavailable": "Movimento reduzido · reprodução do histórico indisponível", "Reduced motion · history playback unavailable": "Movimento reduzido · reprodução do histórico indisponível",
"Reduced motion · manual history available": "Movimento reduzido · é possível percorrer o histórico manualmente",
"Rain radar · US": "Radar de chuva · EUA", "Rain radar · US": "Radar de chuva · EUA",
"Lightning density · 15 min": "Densidade de raios · 15 min", "Lightning density · 15 min": "Densidade de raios · 15 min",
"Satellite clouds": "Nuvens por satélite", "Satellite clouds": "Nuvens por satélite",
@@ -2149,8 +2150,12 @@
"Radio is disabling…": "O rádio está sendo desativado…", "Radio is disabling…": "O rádio está sendo desativado…",
"Radio lifecycle is uncertain — use Enable or Disable to reconcile": "O ciclo de vida do rádio é incerto — use Ativar ou Desativar para reconciliar", "Radio lifecycle is uncertain — use Enable or Disable to reconcile": "O ciclo de vida do rádio é incerto — use Ativar ou Desativar para reconciliar",
"Clear reading": "Limpar leitura", "Clear reading": "Limpar leitura",
"Earlier observation": "Observação anterior",
"Observed history": "Histórico observado", "Observed history": "Histórico observado",
"Later observation": "Observação posterior",
"Latest": "Mais recente", "Latest": "Mais recente",
"Newest frame per product": "Quadro mais recente por produto",
"Replay observed history": "Reproduzir o histórico observado",
"Imagery on Esri · Google 3D returns when cleared": "Imagens sobre o Esri · LIMPAR restaura o Google 3D", "Imagery on Esri · Google 3D returns when cleared": "Imagens sobre o Esri · LIMPAR restaura o Google 3D",
"Point the camera at the ground to use the view": "Aponte a câmera para o chão para usar a vista", "Point the camera at the ground to use the view": "Aponte a câmera para o chão para usar a vista",
"That point cannot anchor a box": "Esse ponto não pode ancorar uma área", "That point cannot anchor a box": "Esse ponto não pode ancorar uma área",
@@ -2441,6 +2446,7 @@
"DETECT": "DETECÇÃO", "DETECT": "DETECÇÃO",
"{time} · {minutes} min ago": "{time} · há {minutes} min", "{time} · {minutes} min ago": "{time} · há {minutes} min",
"Active weather": "Camadas de clima ativas", "Active weather": "Camadas de clima ativas",
"LATEST · newest per product": "MAIS RECENTE · último por produto",
"Forecast · valid {valid} · issued {issued}": "Previsão · válida para {valid} · emitida em {issued}", "Forecast · valid {valid} · issued {issued}": "Previsão · válida para {valid} · emitida em {issued}",
"{detail} · Does not follow history": "{detail} · Não acompanha o histórico", "{detail} · Does not follow history": "{detail} · Não acompanha o histórico",
"Forecast · valid {valid}": "Previsão · válida para {valid}", "Forecast · valid {valid}": "Previsão · válida para {valid}",
@@ -3695,30 +3701,5 @@
"Reset view reloads the app without the view restored from the address bar.": "Redefinir vista recarrega o app sem a vista restaurada da barra de endereços.", "Reset view reloads the app without the view restored from the address bar.": "Redefinir vista recarrega o app sem a vista restaurada da barra de endereços.",
"Reload": "Recarregar", "Reload": "Recarregar",
"Retry": "Tentar novamente", "Retry": "Tentar novamente",
"Reset view": "Redefinir vista", "Reset view": "Redefinir vista"
"Trolleybus": "Trólebus",
"Cable car": "Bonde a cabo",
"Monorail": "Monotrilho",
"Funicular": "Funicular",
"Gondola": "Teleférico",
"Move panels back beside the rail": "Voltar os painéis para junto da barra",
"Move panels over the rail, to the edge": "Mover os painéis sobre a barra, até a borda",
"panel|VISUAL": "VISUAL",
"Look": "Aparência",
"Overlay": "Sobreposição",
"Effects": "Efeitos",
"Scope opacity": "Opacidade da mira",
"How dark the scope shades the view outside the keyhole": "Quanto a mira escurece a vista fora do visor",
"Reset to default": "Redefinir para o padrão",
"Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "Redefinir o Visual para o padrão: aparência Normal, HUD, mira, rótulos e efeitos",
"All layers": "Todas as camadas",
"On": "Ativadas",
"Off": "Desativadas",
"Search layers": "Pesquisar camadas",
"Filter layers": "Filtrar camadas",
"Status": "Estado",
"Search layers…": "Pesquisar camadas…",
"Layer groups": "Grupos de camadas",
"Jump to {name}": "Ir para {name}",
"No layers match": "Nenhuma camada corresponde"
} }
+7 -26
View File
@@ -1458,6 +1458,7 @@
"60°S–60°N · typically 2–3 h delayed": "60° ю. ш.–60° с. ш. · обычно задержка 2–3 ч", "60°S–60°N · typically 2–3 h delayed": "60° ю. ш.–60° с. ш. · обычно задержка 2–3 ч",
"North America · infrared imagery": "Северная Америка · ИК-снимки", "North America · infrared imagery": "Северная Америка · ИК-снимки",
"Reduced motion · history playback unavailable": "Уменьшение движения · воспроизведение истории недоступно", "Reduced motion · history playback unavailable": "Уменьшение движения · воспроизведение истории недоступно",
"Reduced motion · manual history available": "Уменьшение движения · историю можно листать вручную",
"Rain radar · US": "Радар осадков · США", "Rain radar · US": "Радар осадков · США",
"Lightning density · 15 min": "Плотность молний · 15 мин", "Lightning density · 15 min": "Плотность молний · 15 мин",
"Satellite clouds": "Спутниковая облачность", "Satellite clouds": "Спутниковая облачность",
@@ -1696,8 +1697,12 @@
"CH {index} / {total}": "КАН. {index} / {total}", "CH {index} / {total}": "КАН. {index} / {total}",
"Station unavailable after directory refresh": "Станция недоступна после обновления каталога", "Station unavailable after directory refresh": "Станция недоступна после обновления каталога",
"Clear reading": "Очистить показания", "Clear reading": "Очистить показания",
"Earlier observation": "Более раннее наблюдение",
"Observed history": "История наблюдений", "Observed history": "История наблюдений",
"Later observation": "Более позднее наблюдение",
"Latest": "Последние", "Latest": "Последние",
"Newest frame per product": "Самый свежий кадр каждого продукта",
"Replay observed history": "Воспроизвести историю наблюдений",
"S on another day replaces it · × unpins": "S в другой день заменяет его · × открепляет", "S on another day replaces it · × unpins": "S в другой день заменяет его · × открепляет",
"← → preview · S shows the focused day": "← → просмотр · S показывает выбранный день", "← → preview · S shows the focused day": "← → просмотр · S показывает выбранный день",
"Recent imagery": "Свежие снимки", "Recent imagery": "Свежие снимки",
@@ -1862,6 +1867,7 @@
"Reset map bearing to north": "Сбросить азимут карты на север", "Reset map bearing to north": "Сбросить азимут карты на север",
"Rain radar": "Радар осадков", "Rain radar": "Радар осадков",
"Lightning density": "Плотность молний", "Lightning density": "Плотность молний",
"LATEST · newest per product": "ПОСЛЕДНИЕ · самые свежие по каждому продукту",
"Forecast · valid {valid} · issued {issued}": "Прогноз · действует на {valid} · выпущен {issued}", "Forecast · valid {valid} · issued {issued}": "Прогноз · действует на {valid} · выпущен {issued}",
"{detail} · Does not follow history": "{detail} · не следует за историей", "{detail} · Does not follow history": "{detail} · не следует за историей",
"Forecast · valid {valid}": "Прогноз · действует на {valid}", "Forecast · valid {valid}": "Прогноз · действует на {valid}",
@@ -3782,30 +3788,5 @@
"Reset view reloads the app without the view restored from the address bar.": "«Сбросить вид» перезагружает приложение без вида, восстановленного из адресной строки.", "Reset view reloads the app without the view restored from the address bar.": "«Сбросить вид» перезагружает приложение без вида, восстановленного из адресной строки.",
"Reload": "Перезагрузить", "Reload": "Перезагрузить",
"Retry": "Повторить", "Retry": "Повторить",
"Reset view": "Сбросить вид", "Reset view": "Сбросить вид"
"Trolleybus": "Троллейбус",
"Cable car": "Канатный трамвай",
"Monorail": "Монорельс",
"Funicular": "Фуникулёр",
"Gondola": "Канатная дорога",
"Move panels back beside the rail": "Вернуть панели к боковой панели",
"Move panels over the rail, to the edge": "Переместить панели поверх боковой панели к краю",
"panel|VISUAL": "ВИЗУАЛ",
"Look": "Вид",
"Overlay": "Наложение",
"Effects": "Эффекты",
"Scope opacity": "Непрозрачность прицела",
"How dark the scope shades the view outside the keyhole": "Насколько прицел затемняет вид за пределами маски",
"Reset to default": "Сбросить по умолчанию",
"Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "Сбросить «Визуал» к настройкам по умолчанию: обычный вид, HUD, прицел, подписи и эффекты",
"All layers": "Все слои",
"On": "Включены",
"Off": "Выключены",
"Search layers": "Поиск слоёв",
"Filter layers": "Фильтр слоёв",
"Status": "Состояние",
"Search layers…": "Поиск слоёв…",
"Layer groups": "Группы слоёв",
"Jump to {name}": "Перейти к «{name}»",
"No layers match": "Нет подходящих слоёв"
} }
+7 -26
View File
@@ -1858,6 +1858,7 @@
"60°S–60°N · typically 2–3 h delayed": "60° S–60° N · oftast 2–3 h fördröjning", "60°S–60°N · typically 2–3 h delayed": "60° S–60° N · oftast 2–3 h fördröjning",
"North America · infrared imagery": "Nordamerika · infraröda bilder", "North America · infrared imagery": "Nordamerika · infraröda bilder",
"Reduced motion · history playback unavailable": "Minskad rörelse · uppspelning av historik otillgänglig", "Reduced motion · history playback unavailable": "Minskad rörelse · uppspelning av historik otillgänglig",
"Reduced motion · manual history available": "Minskad rörelse · manuell historik tillgänglig",
"Rain radar · US": "Regnradar · USA", "Rain radar · US": "Regnradar · USA",
"Lightning density · 15 min": "Blixttäthet · 15 min", "Lightning density · 15 min": "Blixttäthet · 15 min",
"Satellite clouds": "Satellitmoln", "Satellite clouds": "Satellitmoln",
@@ -2323,8 +2324,12 @@
"PAUSE": "PAUSA", "PAUSE": "PAUSA",
"RESUME": "FORTSÄTT", "RESUME": "FORTSÄTT",
"Clear reading": "Rensa avläsningen", "Clear reading": "Rensa avläsningen",
"Earlier observation": "Tidigare observation",
"Observed history": "Observerad historik", "Observed history": "Observerad historik",
"Later observation": "Senare observation",
"Latest": "Senaste", "Latest": "Senaste",
"Newest frame per product": "Senaste bildruta per produkt",
"Replay observed history": "Spela upp observerad historik",
"Imagery on Esri · Google 3D returns when cleared": "Bilder på Esri · Google 3D återkommer när de rensas", "Imagery on Esri · Google 3D returns when cleared": "Bilder på Esri · Google 3D återkommer när de rensas",
"Point the camera at the ground to use the view": "Rikta kameran mot marken för att använda vyn", "Point the camera at the ground to use the view": "Rikta kameran mot marken för att använda vyn",
"That point cannot anchor a box": "Den punkten kan inte förankra ett område", "That point cannot anchor a box": "Den punkten kan inte förankra ett område",
@@ -2573,6 +2578,7 @@
"dB": "dB", "dB": "dB",
"{time} · {minutes} min ago": "{time} · för {minutes} min sedan", "{time} · {minutes} min ago": "{time} · för {minutes} min sedan",
"Active weather": "Aktuellt väder", "Active weather": "Aktuellt väder",
"LATEST · newest per product": "SENASTE · nyaste per produkt",
"Forecast · valid {valid} · issued {issued}": "Prognos · gäller {valid} · utfärdad {issued}", "Forecast · valid {valid} · issued {issued}": "Prognos · gäller {valid} · utfärdad {issued}",
"{detail} · Does not follow history": "{detail} · Följer inte historiken", "{detail} · Does not follow history": "{detail} · Följer inte historiken",
"Forecast · valid {valid}": "Prognos · gäller {valid}", "Forecast · valid {valid}": "Prognos · gäller {valid}",
@@ -3608,30 +3614,5 @@
"Reset view reloads the app without the view restored from the address bar.": "”Återställ vy” laddar om appen utan vyn som återställdes från adressfältet.", "Reset view reloads the app without the view restored from the address bar.": "”Återställ vy” laddar om appen utan vyn som återställdes från adressfältet.",
"Reload": "Ladda om", "Reload": "Ladda om",
"Retry": "Försök igen", "Retry": "Försök igen",
"Reset view": "Återställ vy", "Reset view": "Återställ vy"
"Trolleybus": "Trådbuss",
"Cable car": "Kabelspårvagn",
"Monorail": "Enskensbana",
"Funicular": "Bergbana",
"Gondola": "Kabinbana",
"Move panels back beside the rail": "Flytta tillbaka panelerna bredvid listen",
"Move panels over the rail, to the edge": "Flytta panelerna över listen, till kanten",
"panel|VISUAL": "VISUELLT",
"Look": "Utseende",
"Overlay": "Överlägg",
"Effects": "Effekter",
"Scope opacity": "Scope-opacitet",
"How dark the scope shades the view outside the keyhole": "Hur mycket scopet mörkar vyn utanför nyckelhålet",
"Reset to default": "Återställ till standard",
"Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "Återställ Visuellt till standard: Normal stil, HUD, scope, etiketter och effekter",
"All layers": "Alla lager",
"On": "På",
"Off": "Av",
"Search layers": "Sök lager",
"Filter layers": "Filtrera lager",
"Status": "Status",
"Search layers…": "Sök lager…",
"Layer groups": "Lagergrupper",
"Jump to {name}": "Gå till {name}",
"No layers match": "Inga lager matchar"
} }
+7 -26
View File
@@ -1368,6 +1368,7 @@
"60°S–60°N · typically 2–3 h delayed": "60° пд. ш.–60° пн. ш. · зазвичай затримка 2–3 год", "60°S–60°N · typically 2–3 h delayed": "60° пд. ш.–60° пн. ш. · зазвичай затримка 2–3 год",
"North America · infrared imagery": "Північна Америка · ІЧ-знімки", "North America · infrared imagery": "Північна Америка · ІЧ-знімки",
"Reduced motion · history playback unavailable": "Зменшення руху · відтворення історії недоступне", "Reduced motion · history playback unavailable": "Зменшення руху · відтворення історії недоступне",
"Reduced motion · manual history available": "Зменшення руху · історію можна гортати вручну",
"Rain radar · US": "Радар опадів · США", "Rain radar · US": "Радар опадів · США",
"Lightning density · 15 min": "Щільність блискавок · 15 хв", "Lightning density · 15 min": "Щільність блискавок · 15 хв",
"Satellite clouds": "Супутникова хмарність", "Satellite clouds": "Супутникова хмарність",
@@ -2185,8 +2186,12 @@
"Radio is disabling…": "Радіо вимикається…", "Radio is disabling…": "Радіо вимикається…",
"Radio lifecycle is uncertain — use Enable or Disable to reconcile": "Стан радіо невизначений — звірте через «Увімкнути» або «Вимкнути»", "Radio lifecycle is uncertain — use Enable or Disable to reconcile": "Стан радіо невизначений — звірте через «Увімкнути» або «Вимкнути»",
"Clear reading": "Очистити покази", "Clear reading": "Очистити покази",
"Earlier observation": "Попереднє спостереження",
"Observed history": "Історія спостережень", "Observed history": "Історія спостережень",
"Later observation": "Наступне спостереження",
"Latest": "Останні", "Latest": "Останні",
"Newest frame per product": "Найсвіжіший кадр кожного продукту",
"Replay observed history": "Відтворити історію спостережень",
"Imagery on Esri · Google 3D returns when cleared": "Підкладка: Esri · скидання поверне Google 3D", "Imagery on Esri · Google 3D returns when cleared": "Підкладка: Esri · скидання поверне Google 3D",
"Point the camera at the ground to use the view": "Наведіть камеру на землю, щоб вибрати видиму область", "Point the camera at the ground to use the view": "Наведіть камеру на землю, щоб вибрати видиму область",
"That point cannot anchor a box": "Від цієї точки не можна побудувати область", "That point cannot anchor a box": "Від цієї точки не можна побудувати область",
@@ -2490,6 +2495,7 @@
"DETECT": "ВИЯВЛЕННЯ", "DETECT": "ВИЯВЛЕННЯ",
"{time} · {minutes} min ago": "{time} · {minutes} хв тому", "{time} · {minutes} min ago": "{time} · {minutes} хв тому",
"Active weather": "Активні погодні шари", "Active weather": "Активні погодні шари",
"LATEST · newest per product": "ОСТАННІ · найсвіжіші для кожного продукту",
"Forecast · valid {valid} · issued {issued}": "Прогноз · чинний на {valid} · випущено {issued}", "Forecast · valid {valid} · issued {issued}": "Прогноз · чинний на {valid} · випущено {issued}",
"{detail} · Does not follow history": "{detail} · не синхронізується з історією", "{detail} · Does not follow history": "{detail} · не синхронізується з історією",
"Forecast · valid {valid}": "Прогноз · чинний на {valid}", "Forecast · valid {valid}": "Прогноз · чинний на {valid}",
@@ -3782,30 +3788,5 @@
"Reset view reloads the app without the view restored from the address bar.": "«Скинути вигляд» перезавантажує застосунок без вигляду, відновленого з адресного рядка.", "Reset view reloads the app without the view restored from the address bar.": "«Скинути вигляд» перезавантажує застосунок без вигляду, відновленого з адресного рядка.",
"Reload": "Перезавантажити", "Reload": "Перезавантажити",
"Retry": "Повторити", "Retry": "Повторити",
"Reset view": "Скинути вигляд", "Reset view": "Скинути вигляд"
"Trolleybus": "Тролейбус",
"Cable car": "Канатний трамвай",
"Monorail": "Монорейка",
"Funicular": "Фунікулер",
"Gondola": "Канатна дорога",
"Move panels back beside the rail": "Повернути панелі до бічної смуги",
"Move panels over the rail, to the edge": "Перемістити панелі поверх бічної смуги до краю",
"panel|VISUAL": "ВІЗУАЛ",
"Look": "Вигляд",
"Overlay": "Накладка",
"Effects": "Ефекти",
"Scope opacity": "Непрозорість прицілу",
"How dark the scope shades the view outside the keyhole": "Наскільки приціл затемнює вигляд за межами прицілу",
"Reset to default": "Скинути до типових",
"Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "Скинути «Візуал» до типових налаштувань: звичайний вигляд, HUD, приціл, підписи та ефекти",
"All layers": "Усі шари",
"On": "Увімкнені",
"Off": "Вимкнені",
"Search layers": "Пошук шарів",
"Filter layers": "Фільтр шарів",
"Status": "Стан",
"Search layers…": "Пошук шарів…",
"Layer groups": "Групи шарів",
"Jump to {name}": "Перейти до «{name}»",
"No layers match": "Немає відповідних шарів"
} }
+7 -26
View File
@@ -1288,6 +1288,7 @@
"60°S–60°N · typically 2–3 h delayed": "60°S–60°N · 通常延迟 2–3 小时", "60°S–60°N · typically 2–3 h delayed": "60°S–60°N · 通常延迟 2–3 小时",
"North America · infrared imagery": "北美洲 · 红外影像", "North America · infrared imagery": "北美洲 · 红外影像",
"Reduced motion · history playback unavailable": "减弱动效 · 历史回放不可用", "Reduced motion · history playback unavailable": "减弱动效 · 历史回放不可用",
"Reduced motion · manual history available": "减弱动效 · 可手动浏览历史",
"Rain radar · US": "降雨雷达 · 美国", "Rain radar · US": "降雨雷达 · 美国",
"Lightning density · 15 min": "闪电密度 · 15 分钟", "Lightning density · 15 min": "闪电密度 · 15 分钟",
"Satellite clouds": "卫星云图", "Satellite clouds": "卫星云图",
@@ -2045,8 +2046,12 @@
"Radio is disabling…": "电台正在停用…", "Radio is disabling…": "电台正在停用…",
"Radio lifecycle is uncertain — use Enable or Disable to reconcile": "电台生命周期状态不确定——请使用“启用”或“停用”进行核对", "Radio lifecycle is uncertain — use Enable or Disable to reconcile": "电台生命周期状态不确定——请使用“启用”或“停用”进行核对",
"Clear reading": "清除读数", "Clear reading": "清除读数",
"Earlier observation": "较早观测",
"Observed history": "观测历史", "Observed history": "观测历史",
"Later observation": "较晚观测",
"Latest": "最新", "Latest": "最新",
"Newest frame per product": "每个产品的最新一帧",
"Replay observed history": "回放观测历史",
"Imagery on Esri · Google 3D returns when cleared": "影像显示在 Esri 底图上 · 清除后恢复 Google 3D", "Imagery on Esri · Google 3D returns when cleared": "影像显示在 Esri 底图上 · 清除后恢复 Google 3D",
"Point the camera at the ground to use the view": "将相机对准地面才能使用当前视野", "Point the camera at the ground to use the view": "将相机对准地面才能使用当前视野",
"That point cannot anchor a box": "该点无法作为框选锚点", "That point cannot anchor a box": "该点无法作为框选锚点",
@@ -2305,6 +2310,7 @@
"DETECT": "探测", "DETECT": "探测",
"{time} · {minutes} min ago": "{time} · {minutes} 分钟前", "{time} · {minutes} min ago": "{time} · {minutes} 分钟前",
"Active weather": "已启用的天气图层", "Active weather": "已启用的天气图层",
"LATEST · newest per product": "最新 · 每个产品的最新一帧",
"Forecast · valid {valid} · issued {issued}": "预报 · 有效时间 {valid} · 发布时间 {issued}", "Forecast · valid {valid} · issued {issued}": "预报 · 有效时间 {valid} · 发布时间 {issued}",
"{detail} · Does not follow history": "{detail} · 不随观测历史变化", "{detail} · Does not follow history": "{detail} · 不随观测历史变化",
"Forecast · valid {valid}": "预报 · 有效时间 {valid}", "Forecast · valid {valid}": "预报 · 有效时间 {valid}",
@@ -3521,30 +3527,5 @@
"Reset view reloads the app without the view restored from the address bar.": "“重置视图”会重新加载应用,不使用从地址栏恢复的视图。", "Reset view reloads the app without the view restored from the address bar.": "“重置视图”会重新加载应用,不使用从地址栏恢复的视图。",
"Reload": "重新加载", "Reload": "重新加载",
"Retry": "重试", "Retry": "重试",
"Reset view": "重置视图", "Reset view": "重置视图"
"Trolleybus": "无轨电车",
"Cable car": "缆索电车",
"Monorail": "单轨列车",
"Funicular": "缆索铁路",
"Gondola": "缆车",
"Move panels back beside the rail": "将面板移回侧栏旁",
"Move panels over the rail, to the edge": "将面板移到侧栏上方,贴靠边缘",
"panel|VISUAL": "视觉",
"Look": "外观",
"Overlay": "叠加层",
"Effects": "效果",
"Scope opacity": "视域不透明度",
"How dark the scope shades the view outside the keyhole": "视域遮罩使视域之外的画面变暗的程度",
"Reset to default": "恢复默认",
"Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "将视觉恢复为默认设置:常规外观、HUD、视域、标签和效果",
"All layers": "全部图层",
"On": "已开启",
"Off": "已关闭",
"Search layers": "搜索图层",
"Filter layers": "筛选图层",
"Status": "状态",
"Search layers…": "搜索图层…",
"Layer groups": "图层分组",
"Jump to {name}": "跳转到{name}",
"No layers match": "没有匹配的图层"
} }
+7 -26
View File
@@ -1283,6 +1283,7 @@
"60°S–60°N · typically 2–3 h delayed": "60°S–60°N · 通常延遲 2–3 小時", "60°S–60°N · typically 2–3 h delayed": "60°S–60°N · 通常延遲 2–3 小時",
"North America · infrared imagery": "北美洲 · 紅外影像", "North America · infrared imagery": "北美洲 · 紅外影像",
"Reduced motion · history playback unavailable": "減弱動效 · 歷史回放不可用", "Reduced motion · history playback unavailable": "減弱動效 · 歷史回放不可用",
"Reduced motion · manual history available": "減弱動效 · 可手動瀏覽歷史",
"Rain radar · US": "降雨雷達 · 美國", "Rain radar · US": "降雨雷達 · 美國",
"Lightning density · 15 min": "閃電密度 · 15 分鐘", "Lightning density · 15 min": "閃電密度 · 15 分鐘",
"Satellite clouds": "衛星雲圖", "Satellite clouds": "衛星雲圖",
@@ -2035,8 +2036,12 @@
"Radio is disabling…": "電臺正在停用…", "Radio is disabling…": "電臺正在停用…",
"Radio lifecycle is uncertain — use Enable or Disable to reconcile": "電臺生命週期狀態不確定——請使用“啟用”或“停用”進行核對", "Radio lifecycle is uncertain — use Enable or Disable to reconcile": "電臺生命週期狀態不確定——請使用“啟用”或“停用”進行核對",
"Clear reading": "清除讀數", "Clear reading": "清除讀數",
"Earlier observation": "較早觀測",
"Observed history": "觀測歷史", "Observed history": "觀測歷史",
"Later observation": "較晚觀測",
"Latest": "最新", "Latest": "最新",
"Newest frame per product": "每個產品的最新一幀",
"Replay observed history": "回放觀測歷史",
"Imagery on Esri · Google 3D returns when cleared": "影像顯示在 Esri 底圖上 · 清除後恢復 Google 3D", "Imagery on Esri · Google 3D returns when cleared": "影像顯示在 Esri 底圖上 · 清除後恢復 Google 3D",
"Point the camera at the ground to use the view": "將相機對準地面才能使用當前視野", "Point the camera at the ground to use the view": "將相機對準地面才能使用當前視野",
"That point cannot anchor a box": "該點無法作為框選錨點", "That point cannot anchor a box": "該點無法作為框選錨點",
@@ -2295,6 +2300,7 @@
"DETECT": "探測", "DETECT": "探測",
"{time} · {minutes} min ago": "{time} · {minutes} 分鐘前", "{time} · {minutes} min ago": "{time} · {minutes} 分鐘前",
"Active weather": "已啟用的天氣圖層", "Active weather": "已啟用的天氣圖層",
"LATEST · newest per product": "最新 · 每個產品的最新一幀",
"Forecast · valid {valid} · issued {issued}": "預報 · 有效時間 {valid} · 發布時間 {issued}", "Forecast · valid {valid} · issued {issued}": "預報 · 有效時間 {valid} · 發布時間 {issued}",
"{detail} · Does not follow history": "{detail} · 不隨觀測歷史變化", "{detail} · Does not follow history": "{detail} · 不隨觀測歷史變化",
"Forecast · valid {valid}": "預報 · 有效時間 {valid}", "Forecast · valid {valid}": "預報 · 有效時間 {valid}",
@@ -3521,30 +3527,5 @@
"Reset view reloads the app without the view restored from the address bar.": "「重置視圖」會重新加載應用,不使用從地址欄恢復的視圖。", "Reset view reloads the app without the view restored from the address bar.": "「重置視圖」會重新加載應用,不使用從地址欄恢復的視圖。",
"Reload": "重新加載", "Reload": "重新加載",
"Retry": "重試", "Retry": "重試",
"Reset view": "重置視圖", "Reset view": "重置視圖"
"Trolleybus": "無軌電車",
"Cable car": "纜索電車",
"Monorail": "單軌列車",
"Funicular": "纜索鐵路",
"Gondola": "纜車",
"Move panels back beside the rail": "將面板移回側欄旁",
"Move panels over the rail, to the edge": "將面板移到側欄上方,貼齊邊緣",
"panel|VISUAL": "視覺",
"Look": "外觀",
"Overlay": "疊加層",
"Effects": "效果",
"Scope opacity": "視域不透明度",
"How dark the scope shades the view outside the keyhole": "視域遮罩使視域之外的畫面變暗的程度",
"Reset to default": "還原預設",
"Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "將視覺還原為預設設定:常規外觀、HUD、視域、標籤和效果",
"All layers": "全部圖層",
"On": "已開啟",
"Off": "已關閉",
"Search layers": "搜尋圖層",
"Filter layers": "篩選圖層",
"Status": "狀態",
"Search layers…": "搜尋圖層…",
"Layer groups": "圖層分組",
"Jump to {name}": "跳至{name}",
"No layers match": "沒有符合的圖層"
} }
-99
View File
@@ -1,99 +0,0 @@
import { test, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import {
onLocaleChange,
setLocale,
setMessageOverrides,
t,
tn,
translateKnown,
} from './index.js';
import * as page from './page.js';
afterEach(() => {
setMessageOverrides({});
setLocale('en');
});
test('a host shows its own text for a Core message, in English and each locale', () => {
setMessageOverrides({
en: { 'Download scene JSON': 'Download scene file' },
de: { 'Download scene JSON': 'Szenendatei herunterladen' },
});
assert.equal(t('Download scene JSON'), 'Download scene file');
setLocale('de');
assert.equal(t('Download scene JSON'), 'Szenendatei herunterladen');
// A locale the host did not word shows the host's English, not Core's text.
setLocale('fr');
assert.equal(t('Download scene JSON'), 'Download scene file');
// Messages it does not name stay Core's.
assert.equal(t('Share selected scene'), 'Partager la scène sélectionnée');
});
test('overrides interpolate, take contexts and plurals, and reach translateKnown', () => {
setMessageOverrides({
en: {
'Imported {file}': 'Opened {file}',
'action|CLEAR': 'WIPE',
'{count} cameras': { one: '{count} feed', other: '{count} feeds' },
},
});
assert.equal(t('Imported {file}', { file: 'a.zip' }), 'Opened a.zip');
assert.equal(t('CLEAR', { context: 'action' }), 'WIPE');
assert.equal(t('CLEAR'), 'CLEAR');
assert.equal(tn(1, '{count} camera', '{count} cameras'), '1 feed');
assert.equal(tn(3, '{count} camera', '{count} cameras'), '3 feeds');
assert.equal(translateKnown('Imported {file}'), 'Opened {file}');
// English forms follow English plural rules in any locale.
for (const locale of ['ru', 'zh-CN', 'fr']) {
setLocale(locale);
assert.equal(tn(1, '{count} camera', '{count} cameras'), '1 feed');
assert.equal(tn(21, '{count} camera', '{count} cameras'), '21 feeds');
assert.equal(tn(0, '{count} camera', '{count} cameras'), '0 feeds');
}
});
test('an entry of the wrong kind is ignored, and Core translates', () => {
setLocale('ru');
const core = {
t: t('Share selected scene'),
tn: tn(3, '{count} camera', '{count} cameras'),
};
setMessageOverrides({
en: { 'Share selected scene': {}, '{count} cameras': 'cams' },
});
assert.equal(t('Share selected scene'), core.t);
assert.equal(tn(3, '{count} camera', '{count} cameras'), core.tn);
});
test('each call replaces the overrides, words listeners again, and ignores junk', () => {
let calls = 0;
const stop = onLocaleChange(() => calls++);
setMessageOverrides({
en: { EXPORT: 'SAVE' },
xx: { EXPORT: 'no' },
de: null,
});
assert.equal(t('EXPORT'), 'SAVE');
setMessageOverrides({ en: { IMPORT: 'OPEN' } });
assert.equal(t('EXPORT'), 'EXPORT');
assert.equal(t('IMPORT'), 'OPEN');
// An empty entry counts as none.
setMessageOverrides({ en: { IMPORT: '' } });
assert.equal(t('IMPORT'), 'IMPORT');
assert.equal(calls, 3);
stop();
});
test('the package entry exports the translator and the overrides', () => {
for (const name of [
't',
'tn',
'msg',
'translateKnown',
'setMessageOverrides',
'formatNumber',
])
assert.equal(typeof page[name], 'function', name);
});
+1 -15
View File
@@ -5,9 +5,7 @@
* page) instead of the globe picker. It starts the language the standalone * page) instead of the globe picker. It starts the language the standalone
* application starts, keeps `<html lang>`/`<html dir>` and the page's * application starts, keeps `<html lang>`/`<html dir>` and the page's
* `data-i18n` markup in step, and switches in place, stored for the next * `data-i18n` markup in step, and switches in place, stored for the next
* visit, exactly as the picker does. It also exports the translator itself, * visit, exactly as the picker does. Guide: docs/I18N.md.
* and lets the application show its own text for Core's messages
* (`setMessageOverrides`). Guide: docs/I18N.md.
*/ */
import { import {
DEFAULT_LOCALE, DEFAULT_LOCALE,
@@ -35,18 +33,6 @@ export {
isRtlLocale, isRtlLocale,
onLocaleChange, onLocaleChange,
}; };
// The translator Core's own text goes through, for an application's text and
// for showing its own wording of Core's messages (setMessageOverrides).
export {
formatDate,
formatNumber,
formatRelativeTime,
msg,
setMessageOverrides,
t,
tn,
translateKnown,
} from './index.js';
/** /**
* Start the page in its language: `?lang=`, then the stored choice, then the * Start the page in its language: `?lang=`, then the stored choice, then the
+2 -13
View File
@@ -113,7 +113,6 @@
"Layout", "Layout",
"Live", "Live",
"London", "London",
"Look",
"Los Angeles", "Los Angeles",
"MIL", "MIL",
"MINI", "MINI",
@@ -141,7 +140,6 @@
"Oldies", "Oldies",
"OpenStreetMap + optional Google Maps Places", "OpenStreetMap + optional Google Maps Places",
"Operator", "Operator",
"Overlay",
"PAUSE", "PAUSE",
"PLAY", "PLAY",
"POS: LIVE", "POS: LIVE",
@@ -251,8 +249,7 @@
"Regional", "Regional",
"OPT-IN", "OPT-IN",
"{minutes} min", "{minutes} min",
"1 min/s", "1 min/s"
"Status"
], ],
"es": [ "es": [
"1 min/s", "1 min/s",
@@ -294,7 +291,6 @@
"FOV {value}°", "FOV {value}°",
"FT", "FT",
"Folk", "Folk",
"Funicular",
"Funk", "Funk",
"GEO", "GEO",
"GOOGLE STREET VIEW", "GOOGLE STREET VIEW",
@@ -386,7 +382,6 @@
"mph", "mph",
"nominal", "nominal",
"panel", "panel",
"panel|VISUAL",
"view band|GLOBAL", "view band|GLOBAL",
"voice|ERROR", "voice|ERROR",
"{hours} h", "{hours} h",
@@ -506,7 +501,6 @@
"MINI", "MINI",
"MISSION", "MISSION",
"MM", "MM",
"Monorail",
"MSG/S", "MSG/S",
"Metal", "Metal",
"Minimal", "Minimal",
@@ -577,7 +571,6 @@
"TAGS · {tags}", "TAGS · {tags}",
"TEMP", "TEMP",
"TLE", "TLE",
"Trolleybus",
"TYPE", "TYPE",
"Tallinn", "Tallinn",
"Techno", "Techno",
@@ -736,7 +729,6 @@
"ON", "ON",
"ON/OFF", "ON/OFF",
"Oldies", "Oldies",
"Overlay",
"PAYLOAD", "PAYLOAD",
"PLAY", "PLAY",
"POS: LIVE", "POS: LIVE",
@@ -958,7 +950,6 @@
"FOV {value}°", "FOV {value}°",
"FT", "FT",
"Folk", "Folk",
"Funicular",
"Funk", "Funk",
"GEO", "GEO",
"GOOGLE STREET VIEW", "GOOGLE STREET VIEW",
@@ -1052,7 +1043,6 @@
"mini", "mini",
"mph", "mph",
"nominal", "nominal",
"panel|VISUAL",
"via {source}", "via {source}",
"view band|GLOBAL", "view band|GLOBAL",
"{hours} h", "{hours} h",
@@ -1276,8 +1266,7 @@
"Period", "Period",
"ICAO: {id}", "ICAO: {id}",
"NORAD: {id}", "NORAD: {id}",
"MMSI: {id}", "MMSI: {id}"
"Status"
], ],
"uk": [ "uk": [
"1900–1945", "1900–1945",
@@ -0,0 +1,331 @@
// Station ground heights. A city's stations are all drawn at once at the
// fallback height; their ground heights are found afterwards a few
// milliseconds at a time, so a city of hundreds of docks never holds the main
// thread for the length of hundreds of depth renders. Each case drives the
// real rendering part (and the real lifecycle where it tears down) over a
// scene whose height reads are counted and timed.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createState } from './state.js';
import { createModel } from './model.js';
import { createRendering } from './rendering.js';
import { createLifecycle } from './lifecycle.js';
import { POINT_HEIGHT_OFFSET_M } from './policy.js';
import { createTerrainSurface } from '../../services/terrainSurface.js';
globalThis.document ??= {
addEventListener() {},
removeEventListener() {},
};
/** Milliseconds one fake depth render holds the thread. */
const SAMPLE_COST_MS = 1;
/** The ground under a point, as the fake scene and globe both report it. */
const groundAt = (lat, lon) => 100 + (lat - 42) * 1000 + (lon + 71) * 10;
function busyWait(ms) {
const until = performance.now() + ms;
while (performance.now() < until);
}
/** `count` stations on a line through Boston. */
function stations(count, prefix = 's') {
return {
data: {
stations: Array.from({ length: count }, (_, i) => ({
station_id: `${prefix}${i}`,
name: `Dock ${i}`,
lat: 42.3 + i * 1e-4,
lon: -71.1 + i * 1e-4,
capacity: 10,
})),
},
};
}
/**
* The rendering part over a scene that counts its height reads.
* @param {{terrainGlobe?: boolean, shared?: boolean, unloaded?: (lat: number) => boolean}} options
* `terrainGlobe`: the globe is showing (else photorealistic tiles hide it);
* `shared`: the application's terrain surface is supplied;
* `unloaded`: latitudes whose terrain tile is not drawn.
*/
function makeLayer({
terrainGlobe = false,
shared = false,
unloaded = () => false,
} = {}) {
const calls = { sampleHeight: 0, getHeight: 0, sharedHeight: 0, render: 0 };
// sampleHeight calls in this turn of the event loop, and the most in any
// turn so far. Two numbers, not a list: a slow run has a great many turns.
let thisTurn = 0;
let mostInATurn = 0;
let ticking = true;
const tick = () => {
if (!ticking) return;
thisTurn = 0;
setImmediate(tick);
};
setImmediate(tick);
const degrees = (carto) => [
Cesium.Math.toDegrees(carto.latitude),
Cesium.Math.toDegrees(carto.longitude),
];
const viewer = {
camera: {
positionCartographic: Cesium.Cartographic.fromDegrees(-71, 42.3, 12000),
changed: { addEventListener() {}, removeEventListener() {} },
},
scene: {
sampleHeightSupported: true,
sampleHeight(carto) {
calls.sampleHeight += 1;
thisTurn += 1;
if (thisTurn > mostInATurn) mostInATurn = thisTurn;
busyWait(SAMPLE_COST_MS);
return groundAt(...degrees(carto));
},
globe: {
show: terrainGlobe,
tilesLoaded: true,
getHeight(carto) {
calls.getHeight += 1;
const [lat, lon] = degrees(carto);
return unloaded(lat) ? undefined : groundAt(lat, lon);
},
},
primitives: { add() {}, remove() {} },
},
};
const surface = createTerrainSurface();
const services = {
render: {
governorRequestRender() {
calls.render += 1;
},
},
overlays: {
setOverlayEntries() {},
setOverlaySourceVisible() {},
clearOverlaySource() {},
},
sprites: { registerSpriteCollection() {}, restoreSpriteOrder() {} },
picking: { registerPickOwner() {}, unregisterPickOwner() {} },
};
if (shared) {
services.ground = {
terrainSurface: {
...surface,
height(...args) {
calls.sharedHeight += 1;
return surface.height(...args);
},
},
};
}
const state = createState({ services });
const parts = {};
const context = { state, services, parts, source: {} };
parts.model = createModel(context);
parts.rendering = createRendering(context);
parts.selection = {
_installClickHandler() {},
_clearSelection() {},
_onKeyDown() {},
};
parts.ingestion = { abortAllInFlight() {} };
// Leaves the points alone, so a test of disable() sees what disable() stops.
parts.viewport = {
onCameraChanged() {},
deactivateAllCities() {},
runProximityCheck: async () => {},
};
parts.lifecycle = createLifecycle(context);
parts.lifecycle.methods.init(viewer);
state._pointCollection = {
show: true,
add: (options) => ({ ...options }),
remove() {},
};
const heightOf = (key) =>
Cesium.Cartographic.fromCartesian(
state._stationRenderMap.get(key).point.position,
).height;
return {
state,
parts,
viewer,
calls,
rendering: parts.rendering,
lifecycle: parts.lifecycle.methods,
heightOf,
add(cityId, count) {
const info = parts.model.parseStationInformation(stations(count, cityId));
parts.rendering.ensureCityPoints(cityId, info);
return info;
},
/** Wait until every queued station has its height, or fail. */
async refined() {
const deadline = performance.now() + 20_000;
while (parts.rendering.pendingHeightCount() > 0) {
assert.ok(performance.now() < deadline, 'refinement finished');
await new Promise((resolve) => setTimeout(resolve, 5));
}
},
/** The most sampleHeight calls made without yielding to the loop. */
longestBurst() {
return mostInATurn;
},
done() {
ticking = false;
parts.rendering.stopHeightRefinement();
},
};
}
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
test('a city of stations is drawn at once without sampling heights', (t) => {
const layer = makeLayer();
t.after(layer.done);
const started = performance.now();
layer.add('boston', 300);
const spent = performance.now() - started;
assert.equal(layer.state._stationRenderMap.size, 300, 'every station drawn');
assert.equal(layer.calls.sampleHeight, 0, 'no depth render while drawing');
assert.ok(spent < 100, `drawing took ${spent.toFixed(1)} ms`);
assert.ok(
Math.abs(layer.heightOf('boston:boston0') - POINT_HEIGHT_OFFSET_M) < 1e-3,
'drawn at the fallback height',
);
assert.equal(layer.rendering.pendingHeightCount(), 300);
});
test('heights arrive afterwards in short steps and match a direct sample', async (t) => {
const layer = makeLayer();
t.after(layer.done);
const info = layer.add('boston', 120);
const rendersBefore = layer.calls.render;
await layer.refined();
assert.equal(layer.calls.sampleHeight, 120, 'one sample per station');
// 120 ms of samples in one go is the stall; a step is a few milliseconds.
assert.ok(
layer.longestBurst() <= 10,
`longest burst was ${layer.longestBurst()} samples`,
);
for (const station of info.values()) {
const expected = groundAt(station.lat, station.lon) + POINT_HEIGHT_OFFSET_M;
const actual = layer.heightOf(`boston:${station.stationId}`);
assert.ok(
Math.abs(actual - expected) < 1e-3,
`${station.stationId}: ${actual} vs ${expected}`,
);
}
assert.ok(layer.calls.render > rendersBefore, 'moved points ask for a frame');
});
test('on a terrain globe the shared terrain surface answers, not a depth render', async (t) => {
const layer = makeLayer({ terrainGlobe: true, shared: true });
t.after(layer.done);
const info = layer.add('boston', 200);
assert.equal(layer.calls.getHeight, 0, 'nothing read while drawing');
await layer.refined();
assert.equal(layer.calls.sampleHeight, 0);
assert.equal(layer.calls.sharedHeight, 200, 'asked the shared surface');
assert.equal(layer.calls.getHeight, 200);
for (const station of info.values()) {
const expected = groundAt(station.lat, station.lon) + POINT_HEIGHT_OFFSET_M;
const actual = layer.heightOf(`boston:${station.stationId}`);
assert.ok(Math.abs(actual - expected) < 1e-3, station.stationId);
}
});
test('without a shared surface the layer reads the terrain globe itself', async (t) => {
const layer = makeLayer({ terrainGlobe: true });
t.after(layer.done);
layer.add('boston', 50);
await layer.refined();
assert.equal(layer.calls.sampleHeight, 0);
assert.equal(layer.calls.getHeight, 50);
assert.ok(
Math.abs(
layer.heightOf('boston:boston7') -
(groundAt(42.3007, -71.0993) + POINT_HEIGHT_OFFSET_M),
) < 1e-3,
);
});
test('a station whose terrain tile is not drawn falls back to a depth render', async (t) => {
// Stations 10 and up lie on a tile the globe is not drawing.
const layer = makeLayer({
terrainGlobe: true,
shared: true,
unloaded: (lat) => lat > 42.30095,
});
t.after(layer.done);
layer.add('boston', 20);
await layer.refined();
assert.equal(layer.calls.sampleHeight, 10);
assert.ok(
Math.abs(
layer.heightOf('boston:boston15') -
(groundAt(42.3015, -71.0985) + POINT_HEIGHT_OFFSET_M),
) < 1e-3,
);
});
test('removing a city stops its refinement and leaves the other running', async (t) => {
const layer = makeLayer();
t.after(layer.done);
layer.add('boston', 40);
layer.add('toronto', 40);
layer.rendering.removeCityPoints('boston');
assert.equal(layer.rendering.pendingHeightCount(), 40);
await layer.refined();
assert.equal(layer.calls.sampleHeight, 40, 'only the remaining city');
assert.ok(layer.heightOf('toronto:toronto3') > 50);
layer.add('boston', 40);
layer.rendering.removeCityPoints('boston');
layer.rendering.removeCityPoints('toronto');
assert.equal(layer.rendering.pendingHeightCount(), 0);
assert.equal(layer.rendering.heightRefinementScheduled(), false);
await pause(60);
assert.equal(layer.calls.sampleHeight, 40, 'nothing sampled after removal');
});
test('disabling the layer stops refinement and leaves no timer', async (t) => {
const layer = makeLayer();
t.after(layer.done);
layer.lifecycle.enable(layer.viewer);
layer.add('boston', 200);
await pause(30);
const partWay = layer.calls.sampleHeight;
assert.ok(partWay < 200, 'still refining');
layer.lifecycle.disable(layer.viewer);
assert.equal(layer.rendering.pendingHeightCount(), 0);
assert.equal(layer.rendering.heightRefinementScheduled(), false);
await pause(60);
assert.equal(layer.calls.sampleHeight, partWay, 'no sample after disable');
});
test('a station selected before its height arrives is re-selected at it', async (t) => {
const layer = makeLayer();
t.after(layer.done);
layer.add('boston', 3);
const reselected = [];
layer.state._selectedKey = 'boston:boston1';
// The selection part's select path; here it records the key.
layer.parts.selection._selectStation = (key) => reselected.push(key);
await layer.refined();
assert.deepEqual(reselected, ['boston:boston1']);
});
+3
View File
@@ -44,6 +44,7 @@ export function createLifecycle({
layerState._inFlightInfo = new Map(); layerState._inFlightInfo = new Map();
layerState._inFlightStatus = new Map(); layerState._inFlightStatus = new Map();
layerState._stationRenderMap = new Map(); layerState._stationRenderMap = new Map();
parts.rendering?.stopHeightRefinement?.();
layerState._clickHandler = null; layerState._clickHandler = null;
layerState._selectedKey = null; layerState._selectedKey = null;
layerState._selectedEntity = null; layerState._selectedEntity = null;
@@ -134,6 +135,7 @@ export function createLifecycle({
parts.ingestion.abortAllInFlight(); parts.ingestion.abortAllInFlight();
parts.viewport.deactivateAllCities(); parts.viewport.deactivateAllCities();
parts.rendering?.stopHeightRefinement?.();
layerState._cityRuntime.clear(); layerState._cityRuntime.clear();
layerState._pointCollection.show = false; layerState._pointCollection.show = false;
layerState._count = 0; layerState._count = 0;
@@ -165,6 +167,7 @@ export function createLifecycle({
layerState._cameraChangedAttached = false; layerState._cameraChangedAttached = false;
} }
parts.ingestion.abortAllInFlight(); parts.ingestion.abortAllInFlight();
parts.rendering?.stopHeightRefinement?.();
if (layerState._pointCollection) { if (layerState._pointCollection) {
viewer.scene.primitives.remove(layerState._pointCollection); viewer.scene.primitives.remove(layerState._pointCollection);
layerState._pointCollection = null; layerState._pointCollection = null;
+11
View File
@@ -50,6 +50,17 @@ export const DEFAULT_CAPACITY = 15;
export const POINT_HEIGHT_OFFSET_M = 2.0; export const POINT_HEIGHT_OFFSET_M = 2.0;
/**
* Milliseconds one step may spend finding station ground heights. A height
* the terrain globe cannot give is a depth render of a millisecond or more.
*/
export const HEIGHT_REFINE_BUDGET_MS = 4;
/** Milliseconds between those steps, about a frame. */
export const HEIGHT_REFINE_INTERVAL_MS = 16;
/** Hard cap on total rendered station points across all cities. */ /** Hard cap on total rendered station points across all cities. */
export const MAX_TOTAL_POINTS = 8000; export const MAX_TOTAL_POINTS = 8000;
+106 -12
View File
@@ -11,7 +11,13 @@ import {
POINT_HEIGHT_OFFSET_M, POINT_HEIGHT_OFFSET_M,
MAX_TOTAL_POINTS, MAX_TOTAL_POINTS,
COLOR_OUTLINE, COLOR_OUTLINE,
HEIGHT_REFINE_BUDGET_MS,
HEIGHT_REFINE_INTERVAL_MS,
} from './policy.js'; } from './policy.js';
import {
createTerrainSurface,
TERRAIN_NEED,
} from '../../services/terrainSurface.js';
export function createRendering({ export function createRendering({
state: layerState, state: layerState,
@@ -20,6 +26,14 @@ export function createRendering({
source, source,
}) { }) {
const { governorRequestRender } = services.render; const { governorRequestRender } = services.render;
// The terrain globe's loaded surface: the application's shared reader, or
// the layer's own when it is built without one.
const terrainSurface =
services.ground?.terrainSurface || createTerrainSurface();
/** Stations drawn at the fallback height, waiting for their ground height. */
let heightQueue = [];
let heightTimer = null;
const heightPoint = new Cesium.Cartographic();
/** /**
* Resolve the effective dock capacity for a station. * Resolve the effective dock capacity for a station.
@@ -87,8 +101,10 @@ export function createRendering({
} }
/** /**
* Create a Cartesian3 position for a station, terrain-clamped when possible. * Create a Cartesian3 position for a station at the fallback height. Its
* Falls back to a fixed small height offset if terrain sampling is unsupported. * ground height is found afterwards (refineHeights): a city's stations are
* drawn at once, and finding hundreds of heights in the same task held the
* main thread for most of a second.
* @param {Object} station - Station info object with lat/lon. * @param {Object} station - Station info object with lat/lon.
* @returns {Cesium.Cartesian3|null} World position, or null if coordinates are invalid. * @returns {Cesium.Cartesian3|null} World position, or null if coordinates are invalid.
*/ */
@@ -97,18 +113,78 @@ export function createRendering({
const lon = Number(station?.lon); const lon = Number(station?.lon);
const lat = Number(station?.lat); const lat = Number(station?.lat);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
return Cesium.Cartesian3.fromDegrees(lon, lat, POINT_HEIGHT_OFFSET_M);
}
let height = POINT_HEIGHT_OFFSET_M; /**
// Sample terrain height so points sit on ground rather than at ellipsoid level * The ground height under a station, or undefined when the scene cannot say.
if (layerState._viewer?.scene?.sampleHeightSupported) { * A terrain globe answers from its loaded surface, which is cheap. Where it
const carto = Cesium.Cartographic.fromDegrees(lon, lat); * cannot (the globe is hidden under photorealistic tiles, or draws no tile
const sampled = layerState._viewer.scene.sampleHeight(carto); * at the point) the scene renders the depth there, at a millisecond or more
if (Number.isFinite(sampled)) { * a point.
height = sampled + POINT_HEIGHT_OFFSET_M; */
}
function sampleGroundHeight(lat, lon) {
const viewer = layerState._viewer;
const scene = viewer?.scene;
if (!scene) return undefined;
// A station is a marker: its one read is not left for a later window.
// The step's own time budget bounds what a pass spends.
const terrain = terrainSurface.height(
viewer,
lat,
lon,
TERRAIN_NEED.MARKER,
);
if (Number.isFinite(terrain)) return terrain;
if (!scene.sampleHeightSupported) return undefined;
Cesium.Cartographic.fromDegrees(lon, lat, 0, heightPoint);
const sampled = scene.sampleHeight(heightPoint);
return Number.isFinite(sampled) ? sampled : undefined;
}
/**
* Move queued stations onto the ground for a few milliseconds, then leave
* the rest to the next step. A station the scene has no height for keeps
* the fallback height, as it did when heights were read while drawing.
*/
function refineHeights() {
heightTimer = null;
const started = performance.now();
let moved = false;
while (
heightQueue.length > 0 &&
performance.now() - started < HEIGHT_REFINE_BUDGET_MS
) {
const { record, lat, lon } = heightQueue.pop();
const ground = sampleGroundHeight(lat, lon);
if (ground === undefined) continue;
record.point.position = Cesium.Cartesian3.fromDegrees(
lon,
lat,
ground + POINT_HEIGHT_OFFSET_M,
);
moved = true;
// The highlight and card of a selected station copy its position.
if (layerState._selectedKey === record.key)
parts.selection._selectStation(record.key);
} }
if (moved) governorRequestRender('bikeshare-heights');
scheduleHeightRefinement();
}
return Cesium.Cartesian3.fromDegrees(lon, lat, height); function scheduleHeightRefinement() {
if (heightTimer !== null || heightQueue.length === 0) return;
heightTimer = setTimeout(refineHeights, HEIGHT_REFINE_INTERVAL_MS);
}
/** Drop every queued station and the pending step. */
function stopHeightRefinement() {
heightQueue = [];
clearTimeout(heightTimer);
heightTimer = null;
} }
/** /**
@@ -178,7 +254,7 @@ export function createRendering({
id: key, id: key,
}); });
layerState._stationRenderMap.set(key, { const record = {
key, key,
cityId, cityId,
stationId: station.stationId, stationId: station.stationId,
@@ -191,12 +267,19 @@ export function createRendering({
isInstalled: station.isInstalled, isInstalled: station.isInstalled,
isRenting: station.isRenting, isRenting: station.isRenting,
isReturning: station.isReturning, isReturning: station.isReturning,
};
layerState._stationRenderMap.set(key, record);
heightQueue.push({
record,
lat: Number(station.lat),
lon: Number(station.lon),
}); });
runtime.stationKeys.add(key); runtime.stationKeys.add(key);
} }
layerState._count = layerState._stationRenderMap.size; layerState._count = layerState._stationRenderMap.size;
scheduleHeightRefinement();
} }
/** /**
@@ -224,6 +307,12 @@ export function createRendering({
layerState._stationRenderMap.delete(key); layerState._stationRenderMap.delete(key);
} }
// Its stations no longer wait for a height.
heightQueue = heightQueue.filter(
({ record }) => layerState._stationRenderMap.get(record.key) === record,
);
if (heightQueue.length === 0) stopHeightRefinement();
layerState._cityRuntime.delete(cityId); layerState._cityRuntime.delete(cityId);
layerState._count = layerState._stationRenderMap.size; layerState._count = layerState._stationRenderMap.size;
} }
@@ -280,5 +369,10 @@ export function createRendering({
ensureCityPoints, ensureCityPoints,
removeCityPoints, removeCityPoints,
applyStatusToPoints, applyStatusToPoints,
stopHeightRefinement,
/** Stations still waiting for their ground height. */
pendingHeightCount: () => heightQueue.length,
/** Whether a refinement step is waiting on a timer. */
heightRefinementScheduled: () => heightTimer !== null,
}; };
} }
-31
View File
@@ -1,5 +1,4 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { worldClock } from '../../time/worldClock.js';
import { import {
EARTHQUAKE_OVERLAY_SOURCE_ID, EARTHQUAKE_OVERLAY_SOURCE_ID,
EARTHQUAKE_OVERLAY_COHORT_LIMIT, EARTHQUAKE_OVERLAY_COHORT_LIMIT,
@@ -8,7 +7,6 @@ import {
createEarthquakeOverlayEntry, createEarthquakeOverlayEntry,
selectEarthquakeOverlayCohort, selectEarthquakeOverlayCohort,
mapAnalystRecord, mapAnalystRecord,
earthquakeCoverage,
} from './model.js'; } from './model.js';
export * from './model.js'; export * from './model.js';
export { createUsgsEarthquakeSource } from './source.js'; export { createUsgsEarthquakeSource } from './source.js';
@@ -29,21 +27,6 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) {
let _lastUpdate = null; let _lastUpdate = null;
let _lastError = null; let _lastError = null;
let _enabled = false; let _enabled = false;
// The quakes last drawn, as the timeline reads them
// ({ mag, time, place, usgsId }).
let _events = [];
let _uncover = null;
// The marks last told to the clock, so a refresh with the same strong
// quakes does not tell it again.
let _marksKey = null;
const coverage = () => (_enabled ? earthquakeCoverage(_events) : null);
const coverageChanged = () => {
const covered = coverage();
const key = covered ? JSON.stringify(covered.marks) : null;
if (key === _marksKey) return;
_marksKey = key;
worldClock.coverageChanged();
};
const layer = { const layer = {
id: 'earthquakes', id: 'earthquakes',
@@ -58,10 +41,6 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) {
_dataSource = new Cesium.CustomDataSource('earthquakes'); _dataSource = new Cesium.CustomDataSource('earthquakes');
_dataSource.show = false; _dataSource.show = false;
viewer.dataSources.add(_dataSource); viewer.dataSources.add(_dataSource);
// While on, the strong quakes are marks on the world clock's timeline.
// Registered once; off, the layer has no time to show.
_marksKey = null;
_uncover = worldClock.register({ id: 'earthquakes', coverage });
_count = 0; _count = 0;
_lastUpdate = null; _lastUpdate = null;
_lastError = null; _lastError = null;
@@ -76,7 +55,6 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) {
// layer has no per-frame animator to keep the render loop alive for. // layer has no per-frame animator to keep the render loop alive for.
if (_dataSource) _dataSource.show = true; if (_dataSource) _dataSource.show = true;
overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, true); overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, true);
coverageChanged();
}, },
disable(viewer) { disable(viewer) {
@@ -86,7 +64,6 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) {
if (_dataSource) _dataSource.show = false; if (_dataSource) _dataSource.show = false;
overlayHost.clearSource(EARTHQUAKE_OVERLAY_SOURCE_ID); overlayHost.clearSource(EARTHQUAKE_OVERLAY_SOURCE_ID);
overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, false); overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, false);
coverageChanged();
}, },
async update(viewer) { async update(viewer) {
@@ -100,7 +77,6 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) {
return false; return false;
const nextEntities = []; const nextEntities = [];
const nextEvents = [];
let count = 0; let count = 0;
const overlayEntries = []; const overlayEntries = [];
@@ -115,7 +91,6 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) {
time, time,
} of rows) { } of rows) {
count++; count++;
nextEvents.push({ mag, time, place, usgsId });
const baseRadius = Math.pow(2, mag) * 1000; const baseRadius = Math.pow(2, mag) * 1000;
const color = depthColor(depthKm || 0); const color = depthColor(depthKm || 0);
const isSignificant = mag >= 5.0; const isSignificant = mag >= 5.0;
@@ -162,8 +137,6 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) {
_dataSource.entities.removeAll(); _dataSource.entities.removeAll();
for (const entity of nextEntities) _dataSource.entities.add(entity); for (const entity of nextEntities) _dataSource.entities.add(entity);
_events = nextEvents;
coverageChanged();
if (_enabled) { if (_enabled) {
overlayHost.setEntries( overlayHost.setEntries(
EARTHQUAKE_OVERLAY_SOURCE_ID, EARTHQUAKE_OVERLAY_SOURCE_ID,
@@ -203,10 +176,6 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) {
viewer.dataSources.remove(_dataSource, true); viewer.dataSources.remove(_dataSource, true);
_dataSource = null; _dataSource = null;
} }
_events = [];
_uncover?.();
_uncover = null;
_marksKey = null;
_count = 0; _count = 0;
_lastUpdate = null; _lastUpdate = null;
_lastError = null; _lastError = null;
+1 -59
View File
@@ -3,64 +3,6 @@ export const EARTHQUAKE_OVERLAY_SOURCE_ID = 'earthquakes';
export const EARTHQUAKE_OVERLAY_COHORT_LIMIT = 96; export const EARTHQUAKE_OVERLAY_COHORT_LIMIT = 96;
export const EARTHQUAKE_OVERLAY_COLLISION_CAPACITY = 48; export const EARTHQUAKE_OVERLAY_COLLISION_CAPACITY = 48;
/**
* The smallest magnitude the timeline marks. Most quakes are small shakes;
* M6 and up come about once every two days worldwide, so each is an event
* worth stepping to. The map still draws every quake.
*/
export const EARTHQUAKE_MARK_MAGNITUDE = 6;
/** The longest place a timeline mark's label carries, ellipsis and all. */
const MARK_PLACE_LENGTH = 40;
/** A magnitude as the map's labels show it: `M6.4`. */
export const earthquakeMagnitudeTitle = (magnitude) =>
`M${Number(magnitude).toFixed(1)}`;
/**
* A strong quake's timeline label: its magnitude and, where the feed names
* one, its place, cut short (`M6.4 · 113 km SSE of Lata, Solomon Islands`).
* @param {{mag: number, place?: string | null}} event
* @returns {string}
*/
export function earthquakeMarkLabel({ mag, place }) {
const title = earthquakeMagnitudeTitle(mag);
const where =
typeof place === 'string' ? place.replace(/\s+/g, ' ').trim() : '';
if (!where) return title;
const short =
where.length > MARK_PLACE_LENGTH
? `${where.slice(0, MARK_PLACE_LENGTH - 1).trimEnd()}…`
: where;
return `${title} · ${short}`;
}
/**
* The time a world clock's timeline can show for `events` (`{mag, time,
* place?, usgsId?}`, time in ms): the span of the quakes of
* EARTHQUAKE_MARK_MAGNITUDE and up, each one a mark labelled with its
* magnitude and place, or null with none, so the layer never stretches the
* timeline past what its feed covers.
* @returns {{start: number, end: number, marks: Array<{time: number, label: string, id?: string, kind: string}>} | null}
*/
export function earthquakeCoverage(events) {
const marks = events
.filter(
({ mag, time }) =>
mag >= EARTHQUAKE_MARK_MAGNITUDE && Number.isFinite(time),
)
.map((event) => ({
time: event.time,
label: earthquakeMarkLabel(event),
...(event.usgsId ? { id: String(event.usgsId) } : {}),
kind: 'earthquake',
}))
.sort((a, b) => a.time - b.time);
return marks.length
? { start: marks[0].time, end: marks.at(-1).time, marks }
: null;
}
/** /**
* Color by depth: * Color by depth:
* - Shallow (<70km): Red * - Shallow (<70km): Red
@@ -95,7 +37,7 @@ export function createEarthquakeOverlayEntry({
id: String(id), id: String(id),
position, position,
variant: 'label', variant: 'label',
title: earthquakeMagnitudeTitle(mag), title: `M${mag.toFixed(1)}`,
accent, accent,
priority: Math.round(mag * 1000), priority: Math.round(mag * 1000),
collisionGroup: 'ambient-label', collisionGroup: 'ambient-label',
-70
View File
@@ -2,8 +2,6 @@ import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { createEarthquakesLayer } from './index.js'; import { createEarthquakesLayer } from './index.js';
import { createUsgsEarthquakeSource } from './source.js'; import { createUsgsEarthquakeSource } from './source.js';
import { EARTHQUAKE_MARK_MAGNITUDE, earthquakeMarkLabel } from './model.js';
import { worldClock } from '../../time/worldClock.js';
function harness(source) { function harness(source) {
const sources = []; const sources = [];
const events = []; const events = [];
@@ -90,71 +88,3 @@ test('USGS body completion honors cancellation even with an uncooperative transp
name: 'AbortError', name: 'AbortError',
}); });
}); });
test('the timeline marks only strong quakes, while the layer is on', async () => {
assert.equal(EARTHQUAKE_MARK_MAGNITUDE, 6);
const rows = [
{ ...row, stableId: 'small', mag: 4.5, time: 1_000 },
{ ...row, stableId: 'strong', usgsId: 'us-strong', mag: 6, time: 5_000 },
{
...row,
stableId: 'great',
usgsId: 'us-great',
mag: 7.2,
place: ' 113 km SSE of Lata, Solomon Islands region of the Pacific ',
time: 3_000,
},
{ ...row, stableId: 'untimed', mag: 6.5, time: null },
];
let snapshot = rows;
const h = harness({ getSnapshot: async () => snapshot });
assert.equal(worldClock.coverage(), null, 'nothing loaded, no time to show');
let changes = 0;
const unsubscribe = worldClock.subscribe(() => changes++);
await h.layer.update(h.viewer);
// Every quake is drawn; only M6 and up are marks, spanning their own time.
assert.equal(h.sources[0].entities.values.length, 4);
// Each labelled with its magnitude and, cut short, its place.
assert.deepEqual(worldClock.coverage(), {
start: 3_000,
end: 5_000,
marks: [3_000, 5_000],
markDetails: [
{
time: 3_000,
label: 'M7.2 · 113 km SSE of Lata, Solomon Islands reg…',
id: 'us-great',
kind: 'earthquake',
},
{
time: 5_000,
label: 'M6.0 · Fixture',
id: 'us-strong',
kind: 'earthquake',
},
],
});
const told = changes;
await h.layer.update(h.viewer);
assert.equal(changes, told, 'the same strong quakes are not told again');
snapshot = rows.slice(0, 1);
await h.layer.update(h.viewer);
assert.equal(worldClock.coverage(), null, 'small shakes alone cover no time');
snapshot = rows;
await h.layer.update(h.viewer);
h.layer.disable(h.viewer);
assert.equal(worldClock.coverage(), null, 'off, no time to show');
h.layer.enable(h.viewer);
assert.deepEqual(worldClock.coverage().marks, [3_000, 5_000]);
h.layer.destroy();
assert.equal(worldClock.coverage(), null);
unsubscribe();
});
test('a strong quake with no place is labelled with its magnitude alone', () => {
assert.equal(earthquakeMarkLabel({ mag: 6.44, place: null }), 'M6.4');
assert.equal(earthquakeMarkLabel({ mag: 6, place: ' ' }), 'M6.0');
assert.equal(
earthquakeMarkLabel({ mag: 8.1, place: 'Off the coast' }),
'M8.1 · Off the coast',
);
});
+1 -2
View File
@@ -31,8 +31,7 @@ export function createCards({
function rebuildAmbientLabels() { function rebuildAmbientLabels() {
if (!layerState._viewer) return; if (!layerState._viewer) return;
const scene = layerState._viewer.scene; const scene = layerState._viewer.scene;
// Ages count back from the time shown: the window's end, or now when live. const now = Date.now();
const now = components.model.ageReferenceMs();
const entries = []; const entries = [];
/** @type {Array<{x: number, y: number}>} Screen positions of accepted labels. */ /** @type {Array<{x: number, y: number}>} Screen positions of accepted labels. */
const accepted = []; const accepted = [];
+4 -19
View File
@@ -343,7 +343,7 @@ export function createModel({
* (on top) and never distance-faded by the overlay. * (on top) and never distance-faded by the overlay.
* Exported for unit tests. * Exported for unit tests.
* @param {Object} fire - Detection record. * @param {Object} fire - Detection record.
* @param {number} nowMs - Epoch milliseconds ages count back from. * @param {number} nowMs - Current epoch milliseconds.
* @returns {Object} firmsLabels entry. * @returns {Object} firmsLabels entry.
*/ */
@@ -384,10 +384,9 @@ export function createModel({
* Card model for an ambient fire detection, e.g. title "▲ 47 MW", detail * Card model for an ambient fire detection, e.g. title "▲ 47 MW", detail
* "high · 14h · N20". Ages are computed against each detection's acquisition * "high · 14h · N20". Ages are computed against each detection's acquisition
* time — with the live feed everything reads under 24 h (and a stale cache * time — with the live feed everything reads under 24 h (and a stale cache
* reads truthfully old); `nowMs` is the time ages count back from, the end * reads truthfully old). Exported for unit tests.
* of the window shown (see {@link ageReferenceMs}). Exported for unit tests.
* @param {{fire: Object, position: Cesium.Cartesian3}} candidate - Label candidate. * @param {{fire: Object, position: Cesium.Cartesian3}} candidate - Label candidate.
* @param {number} nowMs - Epoch milliseconds ages count back from. * @param {number} nowMs - Current epoch milliseconds.
* @returns {Object} firmsLabels entry. * @returns {Object} firmsLabels entry.
*/ */
@@ -419,7 +418,7 @@ export function createModel({
* "max 210 MW · new 3h". Accent comes from the candidate (renderHeat sets * "max 210 MW · new 3h". Accent comes from the candidate (renderHeat sets
* it with {@link cellCardAccent}). Exported for unit tests. * it with {@link cellCardAccent}). Exported for unit tests.
* @param {{cell: Object, position: Cesium.Cartesian3, accent: string}} candidate * @param {{cell: Object, position: Cesium.Cartesian3, accent: string}} candidate
* @param {number} nowMs - Epoch milliseconds ages count back from. * @param {number} nowMs - Current epoch milliseconds.
* @returns {Object} firmsLabels entry. * @returns {Object} firmsLabels entry.
*/ */
@@ -550,19 +549,6 @@ export function createModel({
return { unit: 'd', count: Math.round(hours / 24) }; return { unit: 'd', count: Math.round(hours / 24) };
} }
/**
* The time a card's ages count back from: the end of the 24 hours shown
* when the snapshot ends at a set time, now while it runs to now. A window
* that ended months ago then reads its fires as hours old, as they were.
* @param {number} [nowMs] - Current epoch milliseconds.
* @returns {number}
*/
function ageReferenceMs(nowMs = Date.now()) {
const at = layerState?._at;
return Number.isFinite(at) ? at : nowMs;
}
/** Millisecond delta → "<1h" / "Xh" / "Xd", or '' for invalid input. */ /** Millisecond delta → "<1h" / "Xh" / "Xd", or '' for invalid input. */
function formatAge(deltaMs) { function formatAge(deltaMs) {
@@ -638,7 +624,6 @@ export function createModel({
cellCardAccent, cellCardAccent,
formatLatLon, formatLatLon,
formatFrp, formatFrp,
ageReferenceMs,
formatAge, formatAge,
formatAgeLabel, formatAgeLabel,
formatAgoMinutes, formatAgoMinutes,
+1 -3
View File
@@ -202,9 +202,7 @@ export function createSelection({
confidence: components.model.confidenceBucket(fire.confidence), confidence: components.model.confidenceBucket(fire.confidence),
age: age:
fire.acqMs > 0 fire.acqMs > 0
? components.model.formatAge( ? components.model.formatAge(Date.now() - fire.acqMs)
components.model.ageReferenceMs() - fire.acqMs,
)
: 'unknown', : 'unknown',
sensor: fire.sensor || 'unknown', sensor: fire.sensor || 'unknown',
}, },
-53
View File
@@ -1,10 +1,8 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { getLocale, setLocale } from '../../i18n/index.js'; import { getLocale, setLocale } from '../../i18n/index.js';
import { createModel } from './model.js'; import { createModel } from './model.js';
import { createQueries } from './queries.js'; import { createQueries } from './queries.js';
import { createCards } from './cards.js';
test('a FIRMS snapshot that ends at a set time names it instead of LIVE', (context) => { test('a FIRMS snapshot that ends at a set time names it instead of LIVE', (context) => {
const now = 1800000000000; const now = 1800000000000;
@@ -44,54 +42,3 @@ test('a FIRMS snapshot that ends at a set time names it instead of LIVE', (conte
setLocale(previousLocale); setLocale(previousLocale);
} }
}); });
test('fire cards count ages from the end of the window shown, and from now when live', (context) => {
const now = Date.UTC(2026, 10, 14, 12);
context.mock.method(Date, 'now', () => now);
// A window that ended months before now: its newest fire three hours earlier.
const at = Date.UTC(2026, 7, 1, 12);
const layerState = { _at: at };
const model = createModel({ layerState, services: { anchors: {} } });
const cell = {
count: 4,
maxFrp: 210,
newestAcqMs: at - 3 * 3_600_000,
latCell: 1,
lonCell: 2,
};
const previousLocale = getLocale();
try {
setLocale('en');
assert.equal(model.ageReferenceMs(), at);
const card = model.buildCellCard({ cell, position: {} }, model.ageReferenceMs());
assert.equal(card.details[0], 'max 210 MW · new 3h', 'not months old');
// The cards the scene shows use the same time.
const entries = [];
context.mock.method(Cesium.SceneTransforms, 'worldToWindowCoordinates', () => ({ x: 400, y: 300 }));
layerState._viewer = { scene: { canvas: { clientWidth: 800, clientHeight: 600 } } };
layerState._fireByCardId = new Map();
layerState._selectedFire = null;
layerState._labelCandidates = [{ cell, position: {} }];
layerState._labelLodDistance = 0;
const { rebuildAmbientLabels } = createCards({
layerState,
services: {},
components: { model, rendering: { fireHorizonOccluder: () => null } },
config: { overlayHost: { setEntries: (source, shown) => entries.push(...shown) } },
});
rebuildAmbientLabels();
assert.equal(entries.length, 1);
assert.equal(entries[0].details[0], 'max 210 MW · new 3h');
// Live, they count from now.
layerState._at = null;
cell.newestAcqMs = now - 5 * 3_600_000;
assert.equal(model.ageReferenceMs(), now);
entries.length = 0;
rebuildAmbientLabels();
assert.equal(entries[0].details[0], 'max 210 MW · new 5h');
} finally {
setLocale(previousLocale);
}
});
+1 -7
View File
@@ -1,20 +1,14 @@
import { forgetPlacedAircraft } from './placed.js';
/** /**
* What the layer drops when the world clock moves it to another time (its * What the layer drops when the world clock moves it to another time (its
* source declares a timeline; see src/time/snapshotTimeline.js). * source declares a timeline; see src/time/snapshotTimeline.js).
*/ */
export function createClockFollow({ flightState, services, parts }) { export function createClockFollow({ flightState, parts }) {
/** /**
* Forget how every contact moved, before the snapshot at another time takes * Forget how every contact moved, before the snapshot at another time takes
* the place of every contact: their fixes, courses, floors and the tracked * the place of every contact: their fixes, courses, floors and the tracked
* trail belong to the time the clock left. * trail belong to the time the clock left.
*/ */
function resetMotion() { function resetMotion() {
forgetPlacedAircraft(
services?.placement,
flightState._positionHistory.keys(),
);
flightState.records.reset(); flightState.records.reset();
flightState._positionHistory.clear(); flightState._positionHistory.clear();
flightState._displayCourse.clear(); flightState._displayCourse.clear();
-19
View File
@@ -217,25 +217,6 @@ test('a jump forgets how contacts moved; clearing drops every contact', () => {
assert.ok(calls.includes('model:abc123')); assert.ok(calls.includes('model:abc123'));
}); });
test('leaving the live edge forgets every contact a host placement was told of', () => {
const forgotten = [];
const flightState = {
records: records(),
_positionHistory: new Map([
['abc123', []],
['def456', []],
]),
_displayCourse: new Map(),
_groundSnap: { clear() {} },
_displayFloorState: new Map(),
};
const parts = { tracking: { _clearTrail() {} } };
const services = { placement: { forget: (id) => forgotten.push(id) } };
createClockFollow({ flightState, services, parts }).resetMotion();
assert.deepEqual(forgotten, ['abc123', 'def456']);
assert.equal(flightState._positionHistory.size, 0);
});
test('a snapshot at a time the source drops for a newer one sets no error', async () => { test('a snapshot at a time the source drops for a newer one sets no error', async () => {
const probe = ingestion({ const probe = ingestion({
async getSnapshot() { async getSnapshot() {
-1
View File
@@ -35,7 +35,6 @@ export function createCivilFlightLayer({
parts.clockFollow = createClockFollow(context); parts.clockFollow = createClockFollow(context);
const applySnapshot = createFlightSnapshotRenderer({ const applySnapshot = createFlightSnapshotRenderer({
flightState, flightState,
placement: services.placement ?? null,
records: flightState.records, records: flightState.records,
militaryRegistry: services.militaryRegistry, militaryRegistry: services.militaryRegistry,
groundFloor: services.groundFloor, groundFloor: services.groundFloor,
-9
View File
@@ -1,6 +1,5 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { loadFleetModelSet } from '../../data/fleetModels.js'; import { loadFleetModelSet } from '../../data/fleetModels.js';
import { forgetPlacedAircraft } from './placed.js';
export function createLifecycle({ export function createLifecycle({
flightState, flightState,
@@ -73,10 +72,6 @@ export function createLifecycle({
flightState._cullPositions.clear(); flightState._cullPositions.clear();
flightState._detectionObjects = new Map(); flightState._detectionObjects = new Map();
flightState.records.data = new Map(); flightState.records.data = new Map();
forgetPlacedAircraft(
services.placement,
flightState._positionHistory.keys(),
);
flightState._positionHistory = new Map(); flightState._positionHistory = new Map();
flightState._displayCourse.clear(); flightState._displayCourse.clear();
flightState._groundSnap.clear(); flightState._groundSnap.clear();
@@ -296,10 +291,6 @@ export function createLifecycle({
flightState._cullPositions.clear(); flightState._cullPositions.clear();
flightState._detectionObjects.clear(); flightState._detectionObjects.clear();
flightState.records.data.clear(); flightState.records.data.clear();
forgetPlacedAircraft(
services.placement,
flightState._positionHistory.keys(),
);
flightState._positionHistory.clear(); flightState._positionHistory.clear();
flightState._displayCourse.clear(); flightState._displayCourse.clear();
flightState._groundSnap.clear(); flightState._groundSnap.clear();
-14
View File
@@ -16,7 +16,6 @@ import {
COAST_MAX_SPEED_MPS, COAST_MAX_SPEED_MPS,
} from '../../data/motionModel.js'; } from '../../data/motionModel.js';
import { CLASS_SCALE_2D } from '../../data/aircraftClass.js'; import { CLASS_SCALE_2D } from '../../data/aircraftClass.js';
import { placeLiveAircraft } from './placed.js';
import { import {
modelVisualAnchor, modelVisualAnchor,
modelAnchorWorld, modelAnchorWorld,
@@ -174,19 +173,6 @@ export function createMotion({
} }
const out = result || new Cesium.Cartesian3(); const out = result || new Cesium.Cartesian3();
// A host-supplied placement decides where a live contact is drawn; with
// none, or no answer for this contact, the layer's own motion below does.
if (
placeLiveAircraft(
flightState,
services.placement,
icao24,
info,
history,
out,
)
)
return out;
// Render one poll interval behind real time so we interpolate between // Render one poll interval behind real time so we interpolate between
// two KNOWN fixes whenever possible (see RENDER_DELAY_SEC rationale). // two KNOWN fixes whenever possible (see RENDER_DELAY_SEC rationale).
const renderTime = const renderTime =
+2 -191
View File
@@ -3,11 +3,8 @@ import assert from 'node:assert/strict';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { createMotion } from './motion.js'; import { createMotion } from './motion.js';
import { arcOffsetEnu } from '../../data/motionModel.js'; import { arcOffsetEnu } from '../../data/motionModel.js';
import { createMotion as createMilitaryMotion } from '../military/motion.js';
import { layerPlacement } from '../../data/placementProvider.js';
import { observeLiveAircraft } from './placed.js';
function fixture(placement = null) { function fixture() {
const flightState = { const flightState = {
records: { data: new Map() }, records: { data: new Map() },
_positionHistory: new Map(), _positionHistory: new Map(),
@@ -19,7 +16,7 @@ function fixture(placement = null) {
}; };
const motion = createMotion({ const motion = createMotion({
flightState, flightState,
services: { groundFloor: {}, camera: {}, focus: {}, placement }, services: { groundFloor: {}, camera: {}, focus: {} },
parts: {}, parts: {},
}); });
return { flightState, motion }; return { flightState, motion };
@@ -187,189 +184,3 @@ test('tracked callers still sample a fresh delayed time without a fleet epoch',
); );
assert.equal(reads, 2); assert.equal(reads, 2);
}); });
/** Two fixes a minute apart, read across the minute from a clock at `start`. */
function placedScenario(create, placement, record) {
const flightState = {
records: { data: new Map() },
_positionHistory: new Map(),
_focusEvidenceIds: new Set(),
_scratchArc: {},
_scratchOffset: new Cesium.Cartesian3(),
_scratchRenderTime: new Cesium.JulianDate(),
now: () => 0,
};
const motion = create({
flightState,
services: { groundFloor: {}, camera: {}, focus: {}, placement },
parts: {},
});
const start = Date.parse('2026-10-24T12:00:00Z');
const at = (seconds) =>
Cesium.JulianDate.fromDate(new Date(start + seconds * 1000));
flightState.records.data.set('abc123', record);
flightState._positionHistory.set('abc123', [
{
time: at(0),
position: Cesium.Cartesian3.fromDegrees(-97, 30, 1000),
track: 90,
velocity: 100,
},
{
time: at(60),
position: Cesium.Cartesian3.fromDegrees(-96.99, 30, 1000),
track: 90,
velocity: 100,
},
]);
const drawn = [];
for (let seconds = 0; seconds <= 150; seconds += 7.5) {
flightState.now = () => start + seconds * 1000;
const position = motion._deadReckon('abc123', new Cesium.Cartesian3());
drawn.push({
position,
course: flightState._drCourseDeg,
speed: flightState._drSpeedMps,
hold: flightState._drCourseHold,
});
}
return { flightState, motion, drawn, start };
}
const declining = (asked) =>
layerPlacement(
{
layer: () => ({
place: (id, wallMs) => {
asked.push([id, wallMs]);
return null;
},
}),
},
'aircraft',
);
// Each kind's record carries only its own kinematics fields.
for (const [name, create, record] of [
[
'civil',
createMotion,
{ velocity: 100, true_track: 90, renderAltitudeM: 1234 },
],
[
'military',
createMilitaryMotion,
{ speedMps: 100, track: 90, renderAltitudeM: 1234 },
],
]) {
test(`${name} aircraft move as before without a placement or with one that places nothing`, () => {
const own = placedScenario(create, null, record).drawn;
const asked = [];
const declined = placedScenario(create, declining(asked), record).drawn;
assert.equal(asked.length, own.length);
assert.equal(declined.length, own.length);
for (let i = 0; i < own.length; i++) {
assert.ok(
Cesium.Cartesian3.equals(declined[i].position, own[i].position),
);
assert.equal(declined[i].course, own[i].course);
assert.equal(declined[i].speed, own[i].speed);
assert.equal(declined[i].hold, own[i].hold);
}
});
test(`${name} aircraft are drawn where a placement places them, live only`, () => {
const asked = [];
const placement = layerPlacement(
{
layer: () => ({
place(id, wallMs) {
asked.push(wallMs);
return wallMs % 2 === 0
? {
lat: 30.5,
lon: -96.5,
altitudeM: 2000,
courseDeg: 45,
speedMps: 0.5,
extrapolating: true,
}
: { lat: 30.6, lon: -96.4 };
},
}),
},
'aircraft',
);
const { flightState, motion, start } = placedScenario(
create,
placement,
record,
);
flightState.now = () => start + 2000;
const out = motion._deadReckon('abc123', new Cesium.Cartesian3());
close(out, Cesium.Cartesian3.fromDegrees(-96.5, 30.5, 2000));
assert.equal(flightState._drCourseDeg, 45);
assert.equal(flightState._drSpeedMps, 0.5);
assert.equal(flightState._drCourseHold, true);
// No height, course or speed placed: the record's drawn height and kinematics.
flightState.now = () => start + 2001;
close(
motion._deadReckon('abc123', new Cesium.Cartesian3()),
Cesium.Cartesian3.fromDegrees(-96.4, 30.6, 1234),
);
assert.equal(flightState._drCourseDeg, 90);
assert.equal(flightState._drSpeedMps, 100);
// Following the world clock the layer's own motion places it, unasked.
const before = asked.length;
flightState.timeline = { active: () => true };
const own = motion._deadReckon('abc123', new Cesium.Cartesian3());
assert.equal(asked.length, before);
assert.ok(Cesium.Cartesian3.distance(own, out) > 1000);
});
}
test('an aircraft fix is told to the placement live, with its drawn height', () => {
const told = [];
const placement = layerPlacement(
{
layer: () => ({
place: () => null,
observe: (id, fix) => told.push([id, fix]),
}),
},
'aircraft',
);
const state = { timeline: { active: () => false } };
const meta = { rawLat: 1, rawLon: 2, renderAltitudeM: 300, onGround: false };
observeLiveAircraft(state, placement, 'abc123', 5000, meta, 80, 270);
observeLiveAircraft(
state,
placement,
'abc123',
6000,
{ ...meta, renderAltitudeInvented: true, onGround: true },
null,
undefined,
);
observeLiveAircraft(state, null, 'abc123', 7000, meta, 80, 270);
state.timeline.active = () => true;
observeLiveAircraft(state, placement, 'abc123', 8000, meta, 80, 270);
assert.equal(told.length, 2);
assert.deepEqual(told[0], [
'abc123',
{
timeMs: 5000,
lat: 1,
lon: 2,
altitudeM: 300,
speedMps: 80,
courseDeg: 270,
headingDeg: NaN,
onGround: false,
},
]);
assert.ok(Number.isNaN(told[1][1].altitudeM));
assert.ok(Number.isNaN(told[1][1].speedMps));
assert.ok(Number.isNaN(told[1][1].courseDeg));
assert.equal(told[1][1].onGround, true);
});
-112
View File
@@ -1,112 +0,0 @@
import * as Cesium from 'cesium';
import { COURSE_HOLD_SPEED_MPS } from '../../data/motionModel.js';
import { followsClock } from './policy.js';
/** Scratch for a placed contact's held height. */
const _placedCarto = new Cesium.Cartographic();
/**
* Where the host-supplied placement (`services.placement`,
* ../../data/placementProvider.js) draws a live aircraft now, written into
* `out` with the course scratches `_deadReckon` sets on `flightState`; false
* when there is no placement, the layer follows the world clock, or it has
* no answer for this aircraft, and the layer's own motion places it. A
* placement without a height keeps the aircraft's newest drawn height.
* @param {object} flightState The civil or military flight state.
* @param {object|null|undefined} placement
* @param {string} icao24
* @param {object|undefined} info The aircraft's record.
* @param {{position: Cesium.Cartesian3}[]} history Its fixes, newest last.
* @param {Cesium.Cartesian3} out
* @returns {boolean}
*/
export function placeLiveAircraft(
flightState,
placement,
icao24,
info,
history,
out,
) {
if (!placement || followsClock(flightState)) return false;
const placed = placement.place(icao24, flightState.now());
if (!placed) return false;
let heightM = placed.altitudeM;
if (!Number.isFinite(heightM)) heightM = info?.renderAltitudeM;
if (!Number.isFinite(heightM))
heightM = Cesium.Cartographic.fromCartesian(
history[history.length - 1].position,
Cesium.Ellipsoid.WGS84,
_placedCarto,
).height;
const speed = Number.isFinite(placed.speedMps)
? placed.speedMps
: // Civil records carry velocity and true_track, military speedMps and track.
Number.isFinite(info?.velocity)
? info.velocity
: info?.speedMps || 0;
flightState._drCourseDeg = Number.isFinite(placed.courseDeg)
? placed.courseDeg
: Number.isFinite(info?.true_track)
? info.true_track
: info?.track || 0;
flightState._drSpeedMps = speed;
flightState._drCourseHold = speed < COURSE_HOLD_SPEED_MPS;
flightState._drExtrapolating = placed.extrapolating;
Cesium.Cartesian3.fromDegrees(
placed.lon,
placed.lat,
heightM,
Cesium.Ellipsoid.WGS84,
out,
);
return true;
}
/**
* Tell the host-supplied placement, if any, that the aircraft `ids` left the
* layer (removed, the layer off, or the world clock moved it to another time).
* @param {object|null|undefined} placement
* @param {Iterable<string>} ids
*/
export function forgetPlacedAircraft(placement, ids) {
if (!placement) return;
for (const id of ids) placement.forget(id);
}
/**
* Tell the host-supplied placement an aircraft's fix as the layer took it
* (nothing while the layer follows the world clock): its own time, its
* position, the height the layer draws it at (NaN when that height is
* invented), and its reported speed, course and ground state.
* @param {object} flightState
* @param {object|null|undefined} placement
* @param {string} icao24
* @param {number} fixEpochMs
* @param {{rawLat: number, rawLon: number, renderAltitudeM: number,
* renderAltitudeInvented?: boolean, onGround?: boolean}} meta
* @param {number|null|undefined} speedMps
* @param {number|null|undefined} courseDeg
*/
export function observeLiveAircraft(
flightState,
placement,
icao24,
fixEpochMs,
meta,
speedMps,
courseDeg,
) {
if (!placement || followsClock(flightState)) return;
placement.observe(icao24, {
timeMs: fixEpochMs,
lat: meta.rawLat,
lon: meta.rawLon,
altitudeM:
meta.renderAltitudeInvented === true ? NaN : meta.renderAltitudeM,
speedMps: Number.isFinite(speedMps) ? speedMps : NaN,
courseDeg: Number.isFinite(courseDeg) ? courseDeg : NaN,
headingDeg: NaN,
onGround: meta.onGround === true,
});
}
-15
View File
@@ -9,12 +9,10 @@ import {
import { aircraftIcon } from '../../data/aircraftIcons.js'; import { aircraftIcon } from '../../data/aircraftIcons.js';
import { POSITION_HISTORY_LIMIT } from './recordPolicy.js'; import { POSITION_HISTORY_LIMIT } from './recordPolicy.js';
import { followsClock } from './policy.js'; import { followsClock } from './policy.js';
import { observeLiveAircraft } from './placed.js';
/** Apply reconciled aircraft records to Cesium resources and follow state. */ /** Apply reconciled aircraft records to Cesium resources and follow state. */
export function createFlightSnapshotRenderer({ export function createFlightSnapshotRenderer({
flightState, flightState,
placement = null,
records, records,
militaryRegistry, militaryRegistry,
groundFloor, groundFloor,
@@ -67,7 +65,6 @@ export function createFlightSnapshotRenderer({
flightState._billboards.delete(icao24); flightState._billboards.delete(icao24);
rendering._releaseModel(icao24); // military-suppression: drop any 3D model too rendering._releaseModel(icao24); // military-suppression: drop any 3D model too
records.forget(icao24); records.forget(icao24);
placement?.forget(icao24);
flightState._cullPositions.delete(icao24); flightState._cullPositions.delete(icao24);
flightState._positionHistory.delete(icao24); flightState._positionHistory.delete(icao24);
flightState._displayCourse.delete(icao24); flightState._displayCourse.delete(icao24);
@@ -101,17 +98,6 @@ export function createFlightSnapshotRenderer({
); );
const isTracked = icao24 === flightState._trackedIcao; const isTracked = icao24 === flightState._trackedIcao;
// A host-supplied placement, if any, is told every fix as taken.
observeLiveAircraft(
flightState,
placement,
icao24,
fixEpochMs,
meta,
meta.velocity,
meta.true_track,
);
// Append to position history stamped with the FEED's fix epoch // Append to position history stamped with the FEED's fix epoch
// (time_position), not client receipt time — OpenSky positions arrive // (time_position), not client receipt time — OpenSky positions arrive
// 5-15s stale and receipt-time stamping is what caused the // 5-15s stale and receipt-time stamping is what caused the
@@ -325,7 +311,6 @@ export function createFlightSnapshotRenderer({
flightState._billboards.delete(icao24); flightState._billboards.delete(icao24);
rendering._releaseModel(icao24); // aged-out aircraft: drop its 3D model (no orphan / cap leak) rendering._releaseModel(icao24); // aged-out aircraft: drop its 3D model (no orphan / cap leak)
records.forget(icao24); records.forget(icao24);
placement?.forget(icao24);
flightState._cullPositions.delete(icao24); flightState._cullPositions.delete(icao24);
flightState._positionHistory.delete(icao24); flightState._positionHistory.delete(icao24);
flightState._displayCourse.delete(icao24); flightState._displayCourse.delete(icao24);
-1
View File
@@ -1289,7 +1289,6 @@ export function createTracking({
flightState._billboards.delete(icao24); flightState._billboards.delete(icao24);
parts.rendering._releaseModel(icao24); // military-suppression: drop any 3D model too parts.rendering._releaseModel(icao24); // military-suppression: drop any 3D model too
flightState.records.data.delete(icao24); flightState.records.data.delete(icao24);
services.placement?.forget(icao24);
flightState._cullPositions.delete(icao24); flightState._cullPositions.delete(icao24);
flightState._positionHistory.delete(icao24); flightState._positionHistory.delete(icao24);
flightState._displayCourse.delete(icao24); flightState._displayCourse.delete(icao24);
+6 -21
View File
@@ -149,9 +149,6 @@ function restoreCameraView(viewer, view, landedView) {
camera.setView(view); camera.setView(view);
} }
/** The longest name a timeline mark carries, as in the mission index. */
const MARK_LABEL_LENGTH = 27;
export function createLifecycle({ export function createLifecycle({
state: layerState, state: layerState,
services, services,
@@ -161,30 +158,18 @@ export function createLifecycle({
const { holdContinuousRender, releaseContinuousRender } = services.render; const { holdContinuousRender, releaseContinuousRender } = services.render;
let hover = null; let hover = null;
// While on, the launches loaded span the time from the first to the last, // While on, the launches loaded span the time from the first to the last,
// each one a mark labelled with its mission's name (the launch's where it // each one a mark. Registered once; off, the layer has no time to show, and
// has none), cut short and in capitals as the mission index shows names. // each step tells the clock once it has settled.
// Registered once; off, the layer has no time to show, and each step tells
// the clock once it has settled.
worldClock.register({ worldClock.register({
id: 'rocket-launches', id: 'rocket-launches',
coverage: () => { coverage: () => {
if (!layerState._enabled) return null; if (!layerState._enabled) return null;
const marks = layerState._launches const marks = layerState._launches
.map((launch) => ({ .map((launch) => Date.parse(launch.launchTime))
time: Date.parse(launch.launchTime), .filter(Number.isFinite)
label: parts.overlays .sort((a, b) => a - b);
.shortMissionLabel(
launch.missionName || launch.name,
MARK_LABEL_LENGTH,
)
.toUpperCase(),
...(launch.id != null ? { id: String(launch.id) } : {}),
kind: 'launch',
}))
.filter(({ time }) => Number.isFinite(time))
.sort((a, b) => a.time - b.time);
if (!marks.length) return null; if (!marks.length) return null;
return { start: marks[0].time, end: marks.at(-1).time, marks }; return { start: marks[0], end: marks.at(-1), marks };
}, },
}); });
+1 -7
View File
@@ -1,20 +1,14 @@
import { forgetPlacedAircraft } from '../flights/placed.js';
/** /**
* What the layer drops when the world clock moves it to another time (its * What the layer drops when the world clock moves it to another time (its
* source declares a timeline; see src/time/snapshotTimeline.js). * source declares a timeline; see src/time/snapshotTimeline.js).
*/ */
export function createClockFollow({ flightState, services, parts }) { export function createClockFollow({ flightState, parts }) {
/** /**
* Forget how every contact moved, before the snapshot at another time takes * Forget how every contact moved, before the snapshot at another time takes
* the place of every contact: their fixes, courses and the tracked trail * the place of every contact: their fixes, courses and the tracked trail
* belong to the time the clock left. * belong to the time the clock left.
*/ */
function resetMotion() { function resetMotion() {
forgetPlacedAircraft(
services?.placement,
flightState._positionHistory.keys(),
);
flightState.records.reset(); flightState.records.reset();
flightState._positionHistory.clear(); flightState._positionHistory.clear();
flightState._displayCourse.clear(); flightState._displayCourse.clear();
-1
View File
@@ -31,7 +31,6 @@ export function createMilitaryFlightLayer({
parts.clockFollow = createClockFollow(context); parts.clockFollow = createClockFollow(context);
const applySnapshot = createMilitarySnapshotRenderer({ const applySnapshot = createMilitarySnapshotRenderer({
flightState, flightState,
placement: services.placement ?? null,
records: flightState.records, records: flightState.records,
groundFloor: services.groundFloor, groundFloor: services.groundFloor,
meshFloor: services.meshFloor, meshFloor: services.meshFloor,
-9
View File
@@ -1,6 +1,5 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { loadFleetModelSet } from '../../data/fleetModels.js'; import { loadFleetModelSet } from '../../data/fleetModels.js';
import { forgetPlacedAircraft } from '../flights/placed.js';
export function createLifecycle({ export function createLifecycle({
flightState, flightState,
@@ -67,10 +66,6 @@ export function createLifecycle({
flightState._cullPositions.clear(); flightState._cullPositions.clear();
flightState._detectionObjects = new Map(); flightState._detectionObjects = new Map();
flightState.records.data = new Map(); flightState.records.data = new Map();
forgetPlacedAircraft(
services.placement,
flightState._positionHistory.keys(),
);
flightState._positionHistory = new Map(); flightState._positionHistory = new Map();
flightState._displayCourse.clear(); flightState._displayCourse.clear();
flightState._groundSnap.clear(); flightState._groundSnap.clear();
@@ -269,10 +264,6 @@ export function createLifecycle({
flightState._cullPositions.clear(); flightState._cullPositions.clear();
flightState._detectionObjects.clear(); flightState._detectionObjects.clear();
flightState.records.data.clear(); flightState.records.data.clear();
forgetPlacedAircraft(
services.placement,
flightState._positionHistory.keys(),
);
flightState._positionHistory.clear(); flightState._positionHistory.clear();
flightState._displayCourse.clear(); flightState._displayCourse.clear();
flightState._groundSnap.clear(); flightState._groundSnap.clear();
Loaded 100 of 232 files, more files were not shown because too many files have changed in this diff. Show more