Compare commits

...
Author SHA1 Message Date
Sameh Khamis 3d25a284f9 Merge pull request #1747 from bilawalsidhu/motion/vessels-frame-cadence
Move vessels every frame and ask for frames as they move
2026-11-14 23:46:38 -08:00
Sameh Khamis c2fa15b205 Merge branch 'main' into motion/vessels-frame-cadence 2026-11-14 23:37:10 -08:00
Sameh Khamis ec63345b3b Merge pull request #1746 from bilawalsidhu/motion/satellites-dead-reckon
Move satellites every frame between SGP4 samples
2026-11-14 23:36:59 -08:00
Sameh Khamis 52695daf5c fix(vessels): world-clock motion moves only drawn vessels, once per world time
Vessels out of view move on every 250 ms so one coming into view is current;
vessels at the end of their fixes ask for no frames; nothing moves while the
layer is off; 2D views judge the half-pixel step across the view's width; a
re-floor redraws vessels where they are on the next pass.
2026-11-14 23:30:38 -08:00
Sameh Khamis 7bea88b395 Add the changelog fragment for #1747 2026-11-14 23:19:27 -08:00
Sameh Khamis e4afde3317 Add the changelog fragment for #1746 2026-11-14 23:19:26 -08:00
Sameh Khamis 57bacd98a8 Move vessels every frame and ask for frames as they move
Vessels between two fixes, and live vessels a host placement places, were
redrawn at most every 250 ms, and a frame was asked for only once a second.
With nothing else asking for frames the scene drew vessels at about 1 to
2 fps live, and none at all while the world clock played at 1x.

They are now redrawn every frame the scene draws, moved in place without
new allocations. After each pass the layer works out how far the fastest
drawn vessel moved on screen and asks for the next frame for when it will
have moved another half pixel, at least once a second while any drawn
vessel moves. A vessel crossing the view close up therefore glides, and
one far away lets the scene idle. A frame already asked for later is
brought forward when a vessel speeds up.
2026-11-14 23:18:57 -08:00
Sameh Khamis e31de29ccc Move satellites every frame between SGP4 samples
Satellite points were propagated once a second and held still in between,
so while the world clock plays faster than real time they jumped: at
1 min/s, about 45 px once a second at globe scale.

Each SGP4 call now keeps a sample of the satellite's inertial position,
velocity, gravity and its rate of change (src/layers/satellites/motion.js).
Every frame each shown point is reckoned from its sample to the world time
and turned into the Earth-fixed frame by the sidereal angle, so points
glide at any play speed. Core satellites still take a fresh sample every
second, and at once when the world time is 30 s from it (fast play, a
seek), which keeps every point within about 10 m of SGP4. Starlink extras
take their first sample as they load and are resampled round-robin, more
per frame the faster time plays (up to 2,000 a frame); a point more than
two minutes from its sample waits for the next one. Hidden points are
moved when shown again, and the tracked satellite keeps its per-frame path.

Measured at globe scale: at 1 min/s points move on every frame, 0.4 px a
frame and at most 1.4 px (before: once a second, 45 px). With 11,880
satellites (Starlink on) the scene holds 60 fps, frames 16.7 ms median and
19.7 ms p95; reckoning costs 0.02 ms a frame for 1,342 points.
2026-11-14 23:18:28 -08:00
Sameh Khamis e4708a815d Merge pull request #1745 from bilawalsidhu/timeline/one-control-and-marks
One time control; the timeline marks launches and strong earthquakes, with labels
2026-11-14 21:24:45 -08:00
Sameh Khamis ac04812db2 A row plays only with a timeline to play; the speed shown follows the world clock; mark labels merge whole and cut by character
A weather row's play started the world clock even with no time to show,
where there is no timeline; it now does nothing then, as the timeline's
own Play cannot be reached. A weather layer made without a weather clock
does nothing once destroyed, so enabling it after builds no clock.

The timeline's speed follows the world clock's rate whoever set it, and
its Play runs at that rate, so it and a row's play never run at different
rates; a rate not offered shows as the nearest offered speed. Choosing a
speed sets the clock's rate first, also while a driver steps.

Marks merge on their whole label and are cut for showing after, so two
whose labels only differ past the cut stay two, and the cut counts
graphemes (code points where the runtime cannot tell them), so an emoji is
never split.

Tests that compared a DOM element with null built an assertion message
from the element when they failed, which hung the run; they compare plain
values now, so they fail at once. The timeline tests put the clock back to
real time after each.
2026-11-14 21:12:43 -08:00
Sameh Khamis 1dc2dbe4ea Name the changelog fragment for #1745 2026-11-14 20:55:43 -08:00
Sameh Khamis 620a337ce8 Weather rows play at the timeline's speed; the frame time shows under reduced motion; tidy the timeline's mark label
A weather row's play ran the world clock at whatever rate it had, and
going live set that back to 1, so the speed chosen on the timeline was
lost. The world clock now keeps its rate while paused and live, and the
timeline's speed sets it whenever it is chosen, so any play runs at it.
A row's play starts from the start of the covered time when live or at
its end, as the timeline's does, and does nothing while every product the
row shows is suspended, as its reduced-motion note says.

Live, the Weather panel's frame time is the newest of each product's
frames, suspended or not, so it no longer goes away under reduced motion,
on a hidden page or without an imagery host.

A weather layer made without a weather clock builds its own when first
enabled rather than when made, so a layer never turned on leaves the
world clock alone, and its own clock (createWeatherClock({ shared: false }))
leaves the world's time where it is when destroyed; only the last shared
weather clock sends the world live.

On the timeline, a zoom or a pan clears the hovered tick's label, a label
is never wider than the track and ends in an ellipsis, and a mark's label
is cut to 60 characters (WORLD_CLOCK_MARK_LABEL_LENGTH).

The changelog entry says what a row's step does, names the earthquake
layer's new exports and says it registers with the world clock.
2026-11-14 20:52:45 -08:00
Sameh Khamis 558e4a919a Timeline marks carry labels; a tap by a tick goes to its mark
A layer's world clock coverage may now give each mark as an object,
{ time, label?, id?, kind? }, as well as a bare epoch ms number, and the
two may be mixed. worldClock.coverage() keeps marks as the sorted list of
distinct times, so existing readers are unchanged, and adds markDetails:
the merged marks in time order (then kind, label and id), each frozen and
holding only the fields given. Marks given alike by several sources appear
once, a bare time is dropped where a labelled mark shares it, and a mark
with no finite time is ignored. The contract is documented in the
worldClock JSDoc and docs/APPLICATION.md.

Launches label their marks with the mission's name (the launch's where it
has none), cut to 27 characters in capitals as the mission index shows
names. Earthquakes of magnitude 6 and up label theirs with the magnitude
as the map shows it and the feed's place cut to 40 characters, such as
"M6.4 · 113 km SSE of Lata, Solomon Islands"; the layer now tells the clock
when the labels change as well as the times.

On the timeline, a pointer within 6 px of a tick shows its label over the
track (nothing for an unlabelled mark), and a tap there goes exactly to
the mark's time instead of the point tapped. A tap elsewhere and a drag
work as before, and ticks stay ticks. The arrow keys still step from mark
to mark; with the handle on a mark, its labels follow the time in the
handle's aria-valuetext, and show over the track while it has focus.
2026-11-14 20:30:01 -08:00
Sameh Khamis 7cb854c923 The timeline marks launches and strong earthquakes, not every weather frame
The timeline drew a tick for every registered mark, and the weather clock
registered every radar, satellite and lightning frame, which come every few
minutes, so the track was a wall of dashes and the arrow keys crept from
frame to frame.

Weather products now register only the span of their frames (start and
end, as before) with no marks. Holds, the gate and the next-frame wait
while the world plays are unchanged. A weather row's step param goes to
the previous or next weather frame (every shown product's frames, and the
row's own while it is suspended) rather than to the timeline's marks.

The Earthquakes layer registers with the world clock once, at init, and
unregisters on destroy. While on, its quakes of magnitude 6 and up
(EARTHQUAKE_MARK_MAGNITUDE) are marks, and its coverage is their own span,
or none without any, so it never stretches the timeline beyond the feed.
Off, it has none. It tells the clock when the set of marks changes. The
map still draws every M2.5+ quake.

Launches stay marks. With no mark ahead or behind, the arrow keys step
along the track as shown.
2026-11-14 20:28:56 -08:00
Sameh Khamis 326cab4cf2 A weather layer without a shared clock keeps its own on the world clock
Built without a weather clock, the weather layer still ran a frame replay
of its own: params.play stepped its frames every two seconds and went round
from the last to the first, params.step and params.latest picked frames
locally, and the layer tracked whether it followed the newest frame. That
was a second way to move time.

The layer now builds a weather clock on the page's world clock when none
is passed, and destroys it with the layer, so every layer follows the world
clock and its params move the world clock as the timeline does. The local
replay, its timer, the follow-latest flag, the wrap to the first frame, the
timerActive diagnostic and the reduced-motion "manual history available"
message are removed.
2026-11-14 20:26:26 -08:00
Sameh Khamis 3cfd414e70 The main timeline is the only time control; the Weather panel shows its frame time
The Weather panel kept a second time transport of its own: a slider, step
buttons, Latest and Play, backed by a replay in the weather clock that
stepped the observed frames every two seconds and went round from the last
to the first. The weather clock also attached itself to the world clock as
a driver, so the timeline's Play became that replay whenever weather was on.

The panel now shows the frame time only: the newest frame and its age while
live, the timeline time alone at a past time. The weather clock no longer
has setTarget, step, latest, play, pause or togglePlay, its state no longer
has playing, and it no longer drives the world clock. It still follows the
world time, registers each product's frames as marks the timeline's arrow
keys step to, holds its products, and gates the world's continuous play at
each next frame until it loads. weatherClock.world names the world clock it
follows.

A weather row's play, latest and step params move the world clock as the
timeline does: play toggles continuous play (from the start of the covered
time when live), latest goes live, and step goes to the previous or next
mark. The rail timeline component, which only the panel used, is removed,
along with its unused messages in every locale.
2026-11-14 20:25:31 -08:00
Sameh Khamis bce737bf32 Merge pull request #1744 from bilawalsidhu/weather/source-names
A weather source can name its provider for each product
2026-11-14 20:07:09 -08:00
Sameh Khamis 1cba89d860 Merge branch 'main' into weather/source-names 2026-11-14 19:58:48 -08:00
Sameh Khamis 9a81215d83 Merge pull request #1743 from bilawalsidhu/manjunath/wikidata-places
Bundle Wikidata cities for offline place lookups
2026-11-14 19:36:34 -08:00
Sameh KhamisandManjunath Paparaju fcc7d72f41 Add the changelog entry for the Wikidata cities pack
Co-authored-by: Manjunath Paparaju <[email protected]>
2026-11-14 19:27:46 -08:00
Sameh KhamisandManjunath Paparaju ce886b3cfa Describe the Wikidata cities pack in the current state
Co-authored-by: Manjunath Paparaju <[email protected]>
2026-11-14 19:27:46 -08:00
Manjunath PaparajuandSameh Khamis 1dc5a67518 Bundle Wikidata cities for offline place lookups
A pack of the world's settlements of 15,000 people or more (31,827 places,
1.6 MB, CC0) built from the Wikidata Query Service by
scripts/build-wikidata-cities.mjs. src/data/wikidataCities.js reads it on the
first lookup and answers the nearest city to a point, the cities around it,
a city by name ("Paris, France", or the nearest namesake in view) and a
country's name in the page language. Names match without accents or case.

The build leaves out groupings of settlements (metropolitan areas,
agglomerations) and places that no longer stand, keeps a city that is also a
narrower grouping kind (Istanbul) or a unit dissolved where nothing else
names the spot (the Vietnamese provincial cities of 2025), fills a country
from the area a place lies in, splits a population band the service cannot
answer in time, and fails if well-known cities are missing or a known
grouping gets in. The app never calls Wikidata itself.

Co-authored-by: Sameh Khamis <[email protected]>
2026-11-14 19:27:46 -08:00
Sameh Khamis ab4a154ba6 Changelog fragment for #1744 2026-11-14 19:20:10 -08:00
Sameh Khamis 7f1c35c5ed A weather source can name its provider for each product
The Rain radar, Lightning density and Satellite clouds rows named NOAA
nowCOAST until a manifest named its own source, whatever the source read.
A weather source may now supply sourceName(kind), kind being radar,
lightning or satellite; the row shows that name until a manifest names one.
The name is read once when the layer is built and held to the same rule as
a manifest's display.source. A source without names, or one whose name is
refused or fails, still shows NOAA nowCOAST.
2026-11-14 19:20:02 -08:00
Sameh Khamis c2e42b5dfd Merge pull request #1741 from bilawalsidhu/sameh/scene-file-format
feat(i18n): an application uses Core's translator and words Core's messages its own way
2026-11-14 18:13:03 -08:00
Sameh Khamis 35d3a1a8fe Merge branch 'main' into sameh/scene-file-format 2026-11-14 18:04:21 -08:00
Sameh Khamis c19d82d906 feat(i18n): an application uses Core's translator and words Core's messages its own way
The ./i18n entry exports t, tn, msg, translateKnown and the number and date
formatters. setMessageOverrides({ en: {...}, de: {...} }) shows the
application's text for any catalog key: in the active locale, else its
English, else Core's translation.
2026-11-14 17:51:03 -08:00
Sameh Khamis 44d9e573bd Merge pull request #1740 from bilawalsidhu/manjunath/layer-finder
Data Layers: a finder and a section index
2026-11-14 17:09:46 -08:00
Sameh KhamisandManjunath Paparaju 36e26108c1 Note the Data Layers finder in the changelog
Co-authored-by: Manjunath Paparaju <[email protected]>
2026-11-14 16:33:53 -08:00
Sameh KhamisandManjunath Paparaju 4c4f24355f Give the voice test's stub row a classList.contains
focusDataLayerRow now asks whether the finder hides the row.

Co-authored-by: Manjunath Paparaju <[email protected]>
2026-11-14 16:27:54 -08:00
Sameh KhamisandManjunath Paparaju 00794de146 Describe the Data Layers finder and section index
Co-authored-by: Manjunath Paparaju <[email protected]>
2026-11-14 16:25:55 -08:00
Sameh KhamisandManjunath Paparaju a375ace8eb Test the Data Layers finder and section index
Co-authored-by: Manjunath Paparaju <[email protected]>
2026-11-14 16:25:32 -08:00
Sameh KhamisandManjunath Paparaju f940484498 Read the section index only when the groups change, and keep its label on screen
Live counts repaint the rows many times a second; the index now reads its
headings at most once a frame, and only when a heading changes. The label
goes before the tick when there is no room after it, as in a right-to-left
language, and the ticks sit at the panel's inline end.

Co-authored-by: Manjunath Paparaju <[email protected]>
2026-11-14 16:25:32 -08:00
Sameh KhamisandManjunath Paparaju 919ea8d4c0 Make the Data Layers finder keep Escape, its category and keyboard users
- Escape in the field closes the search alone, focus back on its button,
  without folding the panel or leaving the cockpit.
- The category is the group's key (LayerRows' data-group), so a language
  change keeps it; a group that goes away clears it.
- On and Off follow whether the layer is enabled, not its feed's state.
- The filter is a disclosure of two groups of toggle buttons: opening it
  focuses the chosen option, the arrows, Home and End move, Escape closes
  it, and it stays inside the panel.
- No layers match is translated, the search matches names only, and the
  rows are filtered once per frame at most.
- A voice request to show a layer the finder hides clears the finder.

Co-authored-by: Manjunath Paparaju <[email protected]>
2026-11-14 16:25:32 -08:00
Sameh KhamisandManjunath Paparaju 6ff0923f30 Add the finder's icons to the icon font subset
Without them the filter button and menu showed the glyphs' names as text.

Co-authored-by: Manjunath Paparaju <[email protected]>
2026-11-14 16:17:20 -08:00
Sameh KhamisandManjunath Paparaju dcabbb4141 Translate the Data Layers finder and section index
Co-authored-by: Manjunath Paparaju <[email protected]>
2026-11-14 16:16:24 -08:00
Sameh KhamisandManjunath Paparaju 355fbe0bb0 Load the finder and section index styles, and own their files
Co-authored-by: Manjunath Paparaju <[email protected]>
2026-11-14 16:16:24 -08:00
Sameh KhamisandManjunath Paparaju 8da6fb7931 Find layers by the name on screen, and share the finder's state in its module
The finder matched only the English name, so a search in another language
found nothing. Its header buttons and its bar share state through the
component's module rather than a global.

Co-authored-by: Manjunath Paparaju <[email protected]>
2026-11-14 16:16:24 -08:00
Manjunath PaparajuandSameh Khamis 0550f7c2d2 Place the Data Layers group label outside the panel and soften the finder's focus
The group label is drawn on the page just outside the panel's edge instead
of over the rows; the panel clips what leaves it, so the label is teleported
to the body and placed from the tick. The finder shows focus as a soft ring
on the field's frame in place of the hard outline round the bare input.

Co-authored-by: Sameh Khamis <[email protected]>
2026-11-14 16:12:59 -08:00
Manjunath PaparajuandSameh Khamis 584e77dc4f Add a finder and a section index to Data Layers
The Data Layers panel gains a search field, an All / On / Off and category
filter, and a tick index down its side for jumping between sections. Both
sit beside the Data Library browse control.

Co-authored-by: Sameh Khamis <[email protected]>
2026-11-14 16:12:59 -08:00
Sameh Khamis b7160492e0 Merge pull request #1739 from bilawalsidhu/manjunath/cockpit-panels
Cockpit side panels: the main page's rails and panels in first person
2026-11-14 16:12:27 -08:00
Sameh KhamisandManjunath Paparaju fbf135163c Note the cockpit side panels in the changelog
Co-authored-by: Manjunath Paparaju <[email protected]>
2026-11-14 16:01:28 -08:00
Sameh KhamisandManjunath Paparaju 394bfe71c4 Leave the map credits' place in the cockpit to the credits change
Restyling them here moved the credits where the attribution layout model
cannot follow.

Co-authored-by: Manjunath Paparaju <[email protected]>
2026-11-14 15:54:32 -08:00
Sameh KhamisandManjunath Paparaju d9cc397ca9 Describe the cockpit side panels
Co-authored-by: Manjunath Paparaju <[email protected]>
2026-11-14 15:52:16 -08:00
Sameh KhamisandManjunath Paparaju 95a0be13b6 Open the cockpit panels from the rail in one step and give focus back to it
The rail's buttons are disclosures (aria-expanded, aria-controls) and go
through one toggleCockpitUtility action, which already keeps one panel open.
Collapsing returns focus to the rail when the launcher's toggle is hidden.
The edge switch is gone: edge mode does not apply in the cockpit.

Co-authored-by: Manjunath Paparaju <[email protected]>
2026-11-14 15:51:13 -08:00
Sameh KhamisandManjunath Paparaju 0c295ea770 Give only the desktop cockpit the rail look, placed by the layout pass
Cyber and the phone keep their own cockpit layouts. Display and Radio take
their top from the layout pass, whose floor now matches the left rail, so the
panel's height still stops short of the briefing. The rail keeps its physical
side in a right-to-left language, the Live Signals rows use logical sides,
and rules for the launchers' old strips, which the rail hides, are gone.

Co-authored-by: Manjunath Paparaju <[email protected]>
2026-11-14 15:51:13 -08:00
Sameh KhamisandManjunath Paparaju d157198ecb Load the cockpit panel styles with the shell, formatted
Co-authored-by: Manjunath Paparaju <[email protected]>
2026-11-14 15:51:13 -08:00
Sameh KhamisandManjunath Paparaju af7f659f0d Keep the cockpit briefing's Regional News page
The restyle had dropped the page and its tab while the news section, its
fetch and the briefing tests still use it.

Co-authored-by: Manjunath Paparaju <[email protected]>
2026-11-14 15:51:13 -08:00
Manjunath PaparajuandSameh Khamis 52b256ce94 Hide Visual from the rail in cockpit
In cockpit the left rail now shows Data Layers only: Visual's rail item
and panel are hidden there, as Authoring and Utilities are, and the
cockpit's own Display panel stands in for it. The rules that trimmed
Visual's groups in cockpit are removed as no longer reachable. If Visual
was open on entering cockpit, the rail's sliding highlight no longer
stays where its hidden icon was.

Co-authored-by: Sameh Khamis <[email protected]>
2026-11-14 15:51:13 -08:00
Manjunath PaparajuandSameh Khamis 50086f56b6 Hide Clean UI from the Visual panel in cockpit
Cockpit's Visual panel now also leaves out the Clean UI toggle, as it
does the celestial ring.

Co-authored-by: Sameh Khamis <[email protected]>
2026-11-14 15:51:13 -08:00
Manjunath PaparajuandSameh Khamis 5b3be915c8 Keep only what cockpit Display lacks in the Visual panel
In cockpit, the Visual panel hides the HUD, detection, parameters and
3D-model groups, which the cockpit's Display panel already offers, and
the celestial ring toggle. It keeps Look, Map source, Scope, Bloom and
Sharpen.

Co-authored-by: Sameh Khamis <[email protected]>
2026-11-14 15:51:13 -08:00
Manjunath PaparajuandSameh Khamis fd1f516192 Tighten the cockpit briefing's rows and weather tiles
Live Signals rows drop from 43.5 px to 36 px with less padding. The local
weather tiles lose the lit grid that showed between them, stand 5 px apart
as cards and shrink from 60 px to 53 px, so all four fit the shorter
window.

Co-authored-by: Sameh Khamis <[email protected]>
2026-11-14 15:51:13 -08:00
Manjunath PaparajuandSameh Khamis 839409a3fc Fit the side rails and panels to cockpit view
- The left rail shows Data Layers and Visual in cockpit view; Authoring and
  Utilities are hidden along with their slots and panels, so no empty
  pieces are left behind.
- With a panel open there, each icon left is its own island; a hidden
  panel still open underneath leaves the rail in one piece.
- The cockpit's Display and Radio rail uses the main rails' 12 px spacing
  and draws each icon as its own island while a panel is open.
- Contact and Live Signals stand shorter (min(27vh, 260px)); Contact's
  buttons share the kicker's row and its subject runs full width on at
  most two lines.
- Edge scrolling skips rail items that are not shown.

Co-authored-by: Sameh Khamis <[email protected]>
2026-11-14 15:51:13 -08:00
Manjunath PaparajuandSameh Khamis 733b227c01 Restyle the cockpit side panels
The cockpit's Display and Radio panels get the compact dark styling of the
rest of the shell, a header with the edge switch and collapse, and a rail.

Co-authored-by: Sameh Khamis <[email protected]>
2026-11-14 15:51:13 -08:00
Sameh Khamis cd785b1cec Merge pull request #1738 from bilawalsidhu/manjunath/visual-panel
Visual panel: scope opacity, a reset, and grouped controls
2026-11-14 15:51:02 -08:00
Sameh KhamisandManjunath Paparaju e2689dc015 Note the Visual panel in the changelog
Co-authored-by: Manjunath Paparaju <[email protected]>
2026-11-14 15:39:39 -08:00
Sameh KhamisandManjunath Paparaju 850cfd9d40 Describe the opacity floor, the reset's HUD layout and Contacts, and the Cyber skin
Co-authored-by: Manjunath Paparaju <[email protected]>
2026-11-14 15:31:33 -08:00
Sameh KhamisandManjunath Paparaju 1500ffeb17 Keep Cyber's skin on the Visual panel and give its pills to aria-pressed
The card skin stands aside under the Cyber HUD; the pills are decoration
for screen readers, the buttons carry aria-pressed, and Clean UI, an action,
has no pill. A collapsed Visual hides its new parts too.

Co-authored-by: Manjunath Paparaju <[email protected]>
2026-11-14 15:31:33 -08:00
Sameh KhamisandManjunath Paparaju 2fd8d32a5d Keep Scope opacity at half or more, and ignore a value that is not a number
A fainter mask reads as Scope off while labels still fade outside the
keyhole and the HUD gets no backplates.

Co-authored-by: Manjunath Paparaju <[email protected]>
2026-11-14 15:31:33 -08:00
Sameh KhamisandManjunath Paparaju a1e7b2a394 Make a reset inside Contacts the detection that leaving puts back
Contacts lends detection for the session and puts back the earlier state
on leaving; a share link carries that earlier state. A reset in Visual is
the user's choice, so it becomes both.

Co-authored-by: Manjunath Paparaju <[email protected]>
2026-11-14 15:31:33 -08:00
Sameh KhamisandManjunath Paparaju 63dc5756d5 Leave the presets out of Visual in the phone's More sheet
The More sheet already shows the dock's Visual Presets tray right above
Visual, so the panel's own copy is hidden on the phone shell.

Co-authored-by: Manjunath Paparaju <[email protected]>
2026-11-14 15:31:33 -08:00
Sameh KhamisandManjunath Paparaju 81a18d327a Expect the Visual panel's name and its new order in the markup tests
The panel is headed VISUAL, and Clean UI now sits with the overlay
controls, above the Effects group.

Co-authored-by: Manjunath Paparaju <[email protected]>
2026-11-14 15:31:33 -08:00
Sameh KhamisandManjunath Paparaju 1003a3c79d Describe the Visual panel, scope opacity and the reset
CURRENT-STATE covers the panel's groups, the opacity slider and what the
reset restores; the share link format lists field 30.

Co-authored-by: Manjunath Paparaju <[email protected]>
2026-11-14 15:31:33 -08:00
Sameh KhamisandManjunath Paparaju 6c1744e55b Test scope opacity, the Visual reset and the panel's presets
The share codec carries field 30 and still encodes a link made before it
to the same code; the long form writes sco only below full opacity; the
mask fades without repainting; the panel shows and sets the opacity, and
the reset and the presets call their actions.

Co-authored-by: Manjunath Paparaju <[email protected]>
2026-11-14 15:31:33 -08:00
Sameh KhamisandManjunath Paparaju 471151910d Read scope opacity for share links from the mask, as feather is
The share state took the opacity the slider last showed, which a
show-only update can leave apart from the mask; it now reads the mask
itself, as the feather does. The test reset also restores full opacity.

Co-authored-by: Manjunath Paparaju <[email protected]>
2026-11-14 15:31:33 -08:00
Sameh KhamisandManjunath Paparaju 5383b470ee Translate the Visual panel and keep its name apart from the satellite class
The panel's new headings, the scope opacity slider and the reset are
translated in every language, and the presets' names and descriptions use
the dock's translations. The cards' on/off pills take their words from the
locale instead of fixed English. VISUAL, the panel's name on the rail and in
its header, gets its own translation context: several languages had it as
"visible", from the satellite class of the same English word.

Co-authored-by: Manjunath Paparaju <[email protected]>
2026-11-14 15:31:33 -08:00
Sameh KhamisandManjunath Paparaju 493e76401e Own the Visual panel's new files in the package boundaries and format scope
Co-authored-by: Manjunath Paparaju <[email protected]>
2026-11-14 15:31:33 -08:00
Sameh KhamisandManjunath Paparaju 1dcc025832 Format the scope opacity changes
Co-authored-by: Manjunath Paparaju <[email protected]>
2026-11-14 15:31:25 -08:00
Manjunath PaparajuandSameh Khamis 8379a0ffd3 Wire scope opacity and the Visual reset into the shell
The application shell passes the scope opacity setter and the global post
defaults through to the Display bindings, the stylesheet imports the Visual
panel and Scope-off styles, and the icon subset gains the reset icon.

Co-authored-by: Sameh Khamis <[email protected]>
2026-11-14 15:31:25 -08:00
Manjunath PaparajuandSameh Khamis d145c30967 Add scope opacity and a reset to the Visual panel
The Display panel is regrouped as Visual (Look, Overlay, Effects) with style
presets, a scope opacity slider and a reset to the default look. Scope
opacity is carried in share links as field 30 and as the legacy sco
parameter; 100 is the default and is left out of a link.

Co-authored-by: Sameh Khamis <[email protected]>
2026-11-14 15:31:25 -08:00
Sameh Khamis d49d3d10cc Merge pull request #1737 from bilawalsidhu/motion/placement-provider
Let a host application place live contacts
2026-11-14 15:20:50 -08:00
Sameh Khamis 6f830a2e70 Let a host application place live contacts
createApplicationCatalog takes an optional placement provider. Its
layer(kind) hands Flights, Military Flights, AIS Vessels, Transit and Rail
a place(id, wallMs), and optionally observe(id, fix) and forget(id). While
live, each layer tells it every fix it accepts and draws a contact where it
places 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, and following
the world clock the layers never ask it. A transit vehicle or train it
holds still lets the render loop idle and is asked again each second;
vessels ask for frames only while it places one, and stop when the layer
turns off.

Tests show the layers draw and reconcile exactly as before without a
provider and with one that places nothing, draw where a provider places
them, and idle while it holds them still.
2026-11-14 15:07:25 -08:00
Sameh Khamis e53cfef1d1 Merge pull request #1736 from bilawalsidhu/timeline/scrub-ages-cloud-gaps
Fix scrubs to live on long tracks, fire ages in a past window, and gaps between hourly cloud frames
2026-11-14 15:06:42 -08:00
Sameh Khamis 5d733eb07b Fix scrubs to live on long tracks, fire ages in a past window, and gaps between hourly cloud frames
- World timeline: a scrub counts as at now within a share of the track
  (0.5%), now held to at most two minutes. A layer whose source covers
  years made the window weeks wide, so most of the last month snapped to
  live. Short tracks keep their window.
- Fires: card and detail ages count back from the end of the 24 hours the
  layer shows when its snapshot ends at a set time, and from now only when
  it runs to now. A window that ended months ago read "new 105d" on fires
  three hours old at the time shown.
- Weather: a product's max gap and the delay after which its latest frame
  reads late are at least one and a half of its feed's median frame
  spacing (over its latest frames). A regional cloud feed with frames an
  hour apart drew nothing for the second half of every hour while
  scrubbing and read late after twenty minutes; feeds with frames minutes
  apart keep their gaps.
2026-11-14 14:52:48 -08:00
Sameh Khamis 7451c9b372 Merge pull request #1735 from bilawalsidhu/manjunath/rail-panels
Side rails: Visual on the left rail, edge mode and edge scrolling
2026-11-14 14:11:08 -08:00
Sameh KhamisandManjunath Paparaju 7578fcc9a1 Own the rail modules in their package boundaries and format scope
Co-authored-by: Manjunath Paparaju <[email protected]>
2026-11-14 14:01:04 -08:00
Sameh KhamisandManjunath Paparaju 1f20b609ee Add the changelog entry for the side rails
Co-authored-by: Manjunath Paparaju <[email protected]>
2026-11-14 13:52:31 -08:00
Sameh KhamisandManjunath Paparaju 0cb8207285 Describe the side rails and the screen edge in CURRENT-STATE
Co-authored-by: Manjunath Paparaju <[email protected]>
2026-11-14 13:42:06 -08:00
Sameh KhamisandManjunath Paparaju c54ead9432 Test the rails' new layout and translate the edge switch
CCTV and Context take the full right column when open, so the hysteresis
tests use a content-sized panel, and a test covers the full-height rule.

Co-authored-by: Manjunath Paparaju <[email protected]>
2026-11-14 13:42:06 -08:00
Sameh KhamisandManjunath Paparaju d0b614d88e Keep edge mode to the desktop shell and mirror it in right-to-left
Phones and Cockpit keep their own layouts and do not show the switch. The
rails' name panels and edge mode run toward the middle in a right-to-left
language, and the switch reads its side again after the layout moves it.

Co-authored-by: Manjunath Paparaju <[email protected]>
2026-11-14 13:41:07 -08:00
Sameh KhamisandManjunath Paparaju 9374e0228b Leave the wheel alone where edge scrolling should not take it
Hidden rails (clean view, recording), sideways or empty moves, dialogs and
the world timeline keep their wheel; coasting after a step is swallowed for
at most 800 ms; line-based wheels step a panel per notch; the strip follows
where each rail is in a right-to-left language; a held step is dropped when
the pointer leaves the strip; nothing runs after removal.

Co-authored-by: Manjunath Paparaju <[email protected]>
2026-11-14 13:41:07 -08:00
Sameh KhamisandManjunath Paparaju 235fe94a89 Open Visual from the left stack in the phone's More sheet
Co-authored-by: Manjunath Paparaju <[email protected]>
2026-11-14 13:41:07 -08:00
Sameh KhamisandManjunath Paparaju 605bf5b564 Keep the dock's Location and Visual Presets trays on desktop
Nothing replaces them on this branch yet, so the dock keeps them. The credit
pill and the HUD spacing built on it are left for a change of their own.

Co-authored-by: Manjunath Paparaju <[email protected]>
2026-11-14 13:41:07 -08:00
Manjunath PaparajuandSameh Khamis 85415bda97 Install edge scrolling and the rail styles in the shell
Co-authored-by: Sameh Khamis <[email protected]>
2026-11-14 13:41:07 -08:00
Manjunath PaparajuandSameh Khamis 8da632cc2d Line the side rails up and show coordinates once in Minimal HUD
- The right rail now starts level with the left one whether or not a panel
  is open on it. It used to rise to 4% of the screen when one was open,
  which in Tactical and Cyber a top-right readout pushed back down, but in
  Minimal (which has none) left it about 35 px above the left rail.
- Minimal no longer shows its full-width coordinate strip above the corner
  coordinates block, which showed the same position twice; it keeps the
  corner block, where the other layouts have it. This changes upstream's
  Minimal layout.

Co-authored-by: Sameh Khamis <[email protected]>
2026-11-14 13:41:07 -08:00
Manjunath PaparajuandSameh Khamis 4debb49fe0 Place a rail's highlight again whenever the rail changes size
The highlight was placed only when an item was pressed or released, the
window resized, or the pointer moved through an edge strip. A rail hidden
and shown again (Context docked and undocked) was left with its open item
unmarked, and a rail turned from a column to a row (panels moved to the
screen edge) kept its highlight where the icon had been. Each rail is now
watched for size changes, and its highlight placed again when it changes.

Co-authored-by: Sameh Khamis <[email protected]>
2026-11-14 13:41:07 -08:00
Manjunath PaparajuandSameh Khamis 4568b9ff12 Fold the side rails' labels at once
When a panel opened or closed, the rails' labels eased their width, which
stretched and shrank every button in the rail. On the side rails the
labels now fold away and come back at once; only their fade eases.

Co-authored-by: Sameh Khamis <[email protected]>
2026-11-14 13:41:07 -08:00
Manjunath PaparajuandSameh Khamis 129b35e470 Show the rail highlight at icon size when a first panel opens
Opening a panel from the labelled rail folds its pills to icons over a few
frames, and the highlight faded in at a pill's width and then shrank, as
did the open button's own fill. The highlight now appears only once the
open button is an icon again (square), its size changes at once rather
than easing, and the open button carries no fill of its own.

Co-authored-by: Sameh Khamis <[email protected]>
2026-11-14 13:41:07 -08:00
Manjunath PaparajuandSameh Khamis 9261279639 Drop a held edge-scroll step when the motion turns or the pointer moves to the other rail
Co-authored-by: Sameh Khamis <[email protected]>
2026-11-14 13:41:07 -08:00
Manjunath PaparajuandSameh Khamis 81f3602134 Hold an edge-scroll step that comes too soon instead of dropping it
A scroll that reached a step within 500 ms of the last one was used up
without stepping, so mouse wheel notches 300 ms apart stepped only every
other notch and a second swipe inside the gap did nothing. Such a step now
waits and fires once the gap has passed (one held at most, skipped if the
pointer has left the strip), and the held timer is cleared on teardown.
A comment naming the items cockpit hides is corrected.

Co-authored-by: Sameh Khamis <[email protected]>
2026-11-14 13:41:07 -08:00
Manjunath PaparajuandSameh Khamis 422a11d9a4 Give a rail's open icon its own island
With a panel open, a rail's glass is drawn in pieces: one around the open
icon and one for each run of icons above and below it, with a small break
between them. The icons keep their places; the rail's icon spacing grows
from 2 px to 12 px to leave room for the break. The shadow rule for the
old lifted icon is removed.

Co-authored-by: Sameh Khamis <[email protected]>
2026-11-14 13:41:07 -08:00
Manjunath PaparajuandSameh Khamis 9cc2a1811b Leave at least half a second between edge-scroll steps
A scroll that would step a rail again within 500 ms of the last step is
used up without stepping, so panels never switch back to back.

Co-authored-by: Sameh Khamis <[email protected]>
2026-11-14 13:41:07 -08:00
Manjunath PaparajuandSameh Khamis b090937e5f Keep rail icons in place and move only the highlight
The left rail no longer lifts its open icon out beside the panel title or
reorders the rest behind it. Every icon keeps its place, open or not, and
the rail's shared highlight slides to the open one, on both rails.

The rail also holds one height whichever panel is open: its offset now
allows for the stack rising by a different amount for a tall panel than at
rest.

Co-authored-by: Sameh Khamis <[email protected]>
2026-11-14 13:41:07 -08:00
Manjunath PaparajuandSameh Khamis 59ffa87535 Keep the left rail's order when its open item is lifted out
Co-authored-by: Sameh Khamis <[email protected]>
2026-11-14 13:41:07 -08:00
Manjunath PaparajuandSameh Khamis b080857c35 Make one scroll at a rail's edge step one panel
A swipe is still speeding up when it first steps, and that rise in speed was
read as a fresh flick, so one swipe could step two panels. A flick now
counts only after the gesture has clearly slowed and a short moment after a
step; a mouse wheel notch still steps each time.

The rest of a gesture that has stepped is used up even when the pointer is
no longer in the strip, so its coasting does not zoom the globe or scroll
what opened under it. The strip is never wider than a rail's own column, a
panel in either stack keeps its own scrolling, and the slide classes come
off without an animation to end them (reduced motion).

Co-authored-by: Sameh Khamis <[email protected]>
2026-11-14 13:41:07 -08:00
Manjunath PaparajuandSameh Khamis 7ee047961b Move Visual to the left rail and let panels sit at the screen edge
The left rail holds Data Layers, Utilities, Authoring and Visual; the right
rail holds Context, CCTV and Street Level. A button in each panel header
moves that side's panels to the screen edge, with the rail as a row above
them, and scrolling at a screen edge steps through a rail's panels. An open
right panel starts just under the HUD readouts, and Context and CCTV take
the column's full height. The right rail stays level with the left column's
top whichever left panel is open, in both layouts.

Co-authored-by: Sameh Khamis <[email protected]>
2026-11-14 13:41:07 -08:00
Sameh Khamis f0a8b1193a Merge pull request #1734 from bilawalsidhu/transit/route-type-models
Draw transit vehicles as trolleybuses, cable cars, funiculars, gondolas and monorails
2026-11-14 12:38:06 -08:00
Sameh Khamis dd15223109 Class only PID's trolleybus lines as trolleybuses, and name the new modes in the plural for voice
PID's routes.txt types lines 51, 52, 53, 58, 59, 232 and 275 as
trolleybuses (route type 11); the rest of 50 to 59 stay buses, as before.
Voice now also knows trolleybuses, gondolas, cable cars, funiculars and
monorails.
2026-11-14 12:22:30 -08:00
Sameh Khamis 96cefa28b3 Number the transit route type changelog fragment for #1734 2026-11-14 12:03:54 -08:00
Sameh Khamis 39c89d563b Draw transit vehicles as trolleybuses, cable cars, funiculars, gondolas and monorails
The transit layer knew five modes, so a trolleybus drew as a bus, a
funicular as a train and a coach as a city bus. The GTFS basic route types
are now modes of their own (trolleybus, cable_tram, monorail, funicular,
aerial_lift), each with an icon, colour, detection tier, card word, voice
name, speed ceiling and fleet model.

A vehicle record may carry its route's GTFS route_type as routeType. It
settles the mode after the feed's fleet hint and before its route hint, and
picks the finer model (a coach for 200, a high-speed set for 101) when the
type is a kind of the vehicle's mode. Retained history classifies epochs
through transitModeFor too, so trails join the live vehicle.

Route hints from each operator's routes.txt: Prague's trolleybus lines
(51-59, 232, 275) and Petrin funicular, and the trams, trolleybuses and
metro lines of Bucharest, Cluj-Napoca and Turin. A feed may also name
route corrections that outrank the route type: Le Havre's routes.txt types
its funicular (8-FUNI) as a bus, so it is corrected to a funicular.
2026-11-14 12:03:12 -08:00
Sameh Khamis 250f0e20c3 Merge pull request #1733 from bilawalsidhu/fleet/aid-to-navigation-buoys
Draw aids to navigation as buoys
2026-11-14 11:27:46 -08:00
Sameh Khamis 52507a0755 Draw no buoy for a virtual aid to navigation
A virtual aid (AIS message 21's VirtualAtoN flag) is a mark broadcast
where nothing physical stands, so drawing it as a buoy showed a mark
that is not there. The vessel source record now carries `virtual`
(false unless a source says true; any source may set it), AISStream
sets it from VirtualAtoN, and the vessel layer keeps such an aid's
disc: vesselModelSpec returns null for it and the layer gathers no
model item for it. Its card, selected card and readout add VIRTUAL
after its kind.
2026-11-14 11:07:13 -08:00
Sameh Khamis 9ae121c7c0 Number the buoy models changelog fragment for #1733 2026-11-14 11:02:14 -08:00
Sameh Khamis 6c7fe48f40 Show an aid to navigation's kind, not a speed or heading
A buoy's card, selected card and HUD readout showed "0.0KT · 135°" from
whatever its source reported. A fixed mark has neither, so they now show
its kind alone, whole ("CARDINAL MARK N", which the 14-character type cut
read as "CARDINAL MARK "), in the bone white of its disc.

isAidToNavigation now knows every kind an aid report names ("Light, with
sectors", "Fixed structure off shore" had no aid word), leaves out a
"Buoy Layer" as it does a tender, and matches each type text once: the
vessel layer asks per vessel on its rotation pass, which now skips the
aid discs it never turns.

Aid rows no longer cost ships their place: aisStreamRows lists every
vessel ahead of every aid, so the layer's 12,000-row request never drops
a ship for a buoy, and vessels_in_area (and the brief built on it)
counts ships only. An aid's name runs on into its NameExtension.
2026-11-14 10:02:59 -08:00
Sameh Khamis 279aedb2f4 Draw aids to navigation as buoys
A buoy, beacon, RACON, light or IALA mark in the vessel feed was drawn
as a ship. A vessel record whose type names one, or whose MMSI is an
aid-to-navigation station's (99MIDxxxx), is now isAidToNavigation and
vesselModelSpec draws it as the fleet set's buoy of its mark (cardinal
N/E/S/W, port or starboard hand, safe water, isolated danger, special
mark; a red can when the type names no mark), at its built size and
flagged fixed so its model does not turn to a heading. Its icon is a
disc instead of a chevron.

AISStream now subscribes to AidsToNavigationReport by default, and the
server names each report's aid type code (0 to 31) as its kind instead
of reading it as a ship type.
2026-11-14 09:54:01 -08:00
Sameh Khamis f24abfc88f Merge pull request #1732 from bilawalsidhu/fix/adsblol-true-heading
fix(flights): an aircraft with no ground track faces its true heading, not north
2026-11-14 09:10:58 -08:00
Sameh Khamis 58776a6976 Merge branch 'main' into fix/adsblol-true-heading 2026-11-14 09:00:30 -08:00
Sameh Khamis 391e33b0d5 Merge pull request #1731 from bilawalsidhu/fleet/aircraft-mesh-colours
Draw aircraft models in their own mesh colours
2026-11-14 08:54:44 -08:00
Sameh Khamis deb42b38f7 Add the changelog fragment 2026-11-14 08:49:48 -08:00
Sameh Khamis fd06272e37 fix(flights): an aircraft with no ground track faces its true heading, not north
readsb rows (adsb.lol and a local dump1090 receiver) often send no track for
an aircraft on the ground, only its true heading. The track then read as
missing and the aircraft was drawn facing north, so one taxiing south looked
like it was moving backwards. Each normalizer now falls back to true_heading,
never the magnetic heading.
2026-11-14 08:49:29 -08:00
Sameh Khamis a3611caddb Fix the Local ADS-B model test name 2026-11-14 08:25:05 -08:00
Sameh Khamis 4d81d3be3a Describe aircraft models in their mesh colours in the docs 2026-11-14 08:24:51 -08:00
Sameh Khamis c2c196ab1e Add the changelog fragment 2026-11-14 08:06:59 -08:00
Sameh Khamis 8cb096fc21 Draw aircraft models in their own mesh colours
Every aircraft model (Flights, Military Flights, Local ADS-B) mixed 94% of a
flat class colour over its mesh, so airliners drew as white silhouettes and
military ones as amber. Models now use HIGHLIGHT: white leaves the mesh's own
colours as built, and the tracked and emergency colours tint the mesh without
hiding its paint. The IR boost still draws them flat white.
2026-11-14 08:06:51 -08:00
Sameh Khamis 3934be9ff9 Merge pull request #1730 from bilawalsidhu/fleet/airport-vehicle-models
Rebuild the airport vehicle models with real shapes
2026-11-14 07:45:39 -08:00
Sameh Khamis c73c5c4eb6 Add the changelog fragment 2026-11-13 23:43:17 -08:00
Sameh Khamis 491c50ed18 Rebuild the airport vehicle models with real shapes
The car, van, fire truck and pushback tug were stacked boxes. The car is now an
operations SUV (rounded body, glass house with a sloped windscreen, roof light
bar, grille and lamps), the van a high-roof panel van, the fire truck an airport
crash tender with roof and bumper turrets on three axles, and the pushback a low
chamfered tug with a glazed cab. All stand on the ground at their built size.
Their trail anchors are measured again.
2026-11-13 23:43:09 -08:00
Sameh Khamis c5cf579174 Merge pull request #1729 from bilawalsidhu/fleet/ground-vehicles
Draw airport ground vehicles by callsign and ground stations as a radio mast
2026-11-13 21:38:58 -08:00
Sameh Khamis 332551ec84 Add the changelog fragment 2026-11-13 21:20:48 -08:00
Sameh Khamis 6e655b801d feat(fleet): draw airport ground vehicles by callsign and ground stations as a radio mast
Feeds carry many airport ground vehicles (type ZVEH) and ground stations
(GRND) that were drawn as the generic plane, some at the 10 km airborne
default.

- types.json gains aircraft_callsign rules: a ZVEH callsign starting FIRE is
  a fire truck, PUSH/TUG a pushback tug, DEICER/SQUID/SQB a van, else a car.
  GRND draws a new 11.8 m radio mast (other/radio_mast.glb).
- resolveAircraft and aircraftModelSpec take the callsign; a matched rule
  names the model outright, ahead of the type's class model.
- The layer spec cache keys such types by the rule matched, so it holds at
  most one entry per rule.
- These models (catalog category `other`) draw at their built size, as such
  models already do, and a
  contact with no altitude whose model is one now stands on the ground; the
  feed's own type is kept for such contacts, as no airframe lookup knows it.
- Local ADS-B passes the callsign too.
2026-11-13 21:20:16 -08:00
Sameh Khamis 083da40d88 Merge pull request #1686 from ZloyS/denis/cockpit-subject-providers
feat(cockpit): application cockpit subjects
2026-11-13 19:06:18 -08:00
Sameh Khamis 16d163a886 Merge branch 'main' into denis/cockpit-subject-providers 2026-11-13 18:58:00 -08:00
Sameh Khamis c653b3878d Merge pull request #1723 from bilawalsidhu/fix/cctv-panel-collapse-on-disable
Collapse the CCTV panel when the layer is switched off
2026-11-13 18:57:54 -08:00
Sameh Khamis 53cbd55181 Merge branch 'main' into fix/cctv-panel-collapse-on-disable 2026-11-13 18:47:22 -08:00
Sameh Khamis a91a89ec1c Merge pull request #1579 from bilawalsidhu/german/map-render-error-state
Show a recoverable notice when the globe stops drawing
2026-11-13 18:47:16 -08:00
Sameh Khamis c43bf103d6 Merge branch 'main' into german/map-render-error-state 2026-11-13 18:37:03 -08:00
Sameh Khamis 6c55daf9ac fix(transit): a held vehicle's snapshot height is its drawn marker's while the frame pass places it
Near the ground the marker follows the terrain sampled between two reports,
which a straight blend of the reports' floors misses over uneven ground.
2026-11-13 18:36:42 -08:00
Sameh Khamis 9b46692c64 Suspend only the layers Context isolation is switching off
The session snapshot recorded a user's switch only after the CCTV panel had
already rendered the layer off, so a first switch-off by the user inside
Contacts, or after a partly failed restore, still kept the panel open.
Context controls now hold the layers _clearLayersOutsideContextMode is
switching off while its transitions run; suspendsLayer reads that set, and
the snapshot is no longer consulted.
2026-11-13 18:32:57 -08:00
Sameh Khamis 4bb7b411e9 fix(cockpit): a provider's subject rides where it is drawn, and the documented provider rides a moving train
- A frame with no speedMps gets an exact anchor: an inertial one ran on at
  zero speed and closed at 0.75 m/s, so the camera fell kilometres behind a
  moving train. The docs example now passes speedMps and anchor: 'exact',
  a name for its HUD, and isEntryAllowed for a train bound to its line.
- Transit's snapshot height is the floor where playback draws the vehicle,
  not the newest report's floor uphill, and null while that floor is cold,
  so a view waits for the surface instead of standing on the ellipsoid.
- Rail's direction follows playback once it has placed the train: a report
  that runs back (a reversal, or GPS jitter) no longer turns the view
  while the drawn train still runs forward.
- A frame's id is trimmed as the candidate's is.
- A double-click on the selected vessel no longer enters an application's
  subject that took entry after it.
- The docs import from gods-eye-view/..., the package's name.
2026-11-13 18:23:50 -08:00
Sameh Khamis 9d01f8df77 Show no notice for a lost context on a page that must not reload, and log each render error once
An inline panel that lost its WebGL context showed the panel runtime's own
notice and, over it, an alert dialog with no buttons that it could not act
on. With allowReload false a lost context now clears the notice and shows
none, as the docs already said. keepPanelRendering no longer logs render
errors itself: the render failure notice logs each one. Name the Cesium
private fields finishRaise and reportThrown rely on, and the version they
were checked against.
2026-11-13 18:16:07 -08:00
Sameh Khamis f0ee1b95ca Collapse the CCTV panel when the user switches CCTV off inside Contacts
Turning a layer on does not leave Contacts, so a user could turn CCTV back
on inside the mode and off again; the layer was still in the session
snapshot and the panel stayed open on an empty frame. The snapshot now
records every layer the user switched during the session, and
suspendsLayer is true only for a layer isolation switched off that the
user has not touched since.
2026-11-13 18:12:44 -08:00
Said Turksever f29b0b2639 Keep the CCTV panel through Context isolation, and collapse through the panel chrome
Contacts and Space Missions switch CCTV off to isolate themselves and restore
it on the way out: the panel and its share preference now stay as they are for
that round trip, as they do for Clear selected data layers. The controls no
longer read the collapsed class before asking: the panel chrome tells a panel
the accordion folded from one whose saved state is closed.

The camera controls gate asserts the collapse, reopens CCTV from the right
rail before switching it back on, and runs a Contacts entry and exit with the
panel open.
2026-11-13 17:46:43 +00:00
Said Turksever 7f18943ec4 docs: CCTV panel collapse on switch-off, and changelog fragment 2026-11-13 17:27:22 +00:00
Said Turksever 0325805eb0 Collapse the CCTV panel when the layer is switched off
The panel opened itself when a camera became active, but nothing closed it
when the layer went off: it stayed expanded, empty, and its meta line still
described the last camera. The collapse runs only on the on-to-off change, so a
panel opened by hand while the layer is off stays open, and a context-mode
layer clear keeps the panel as Radio does.
2026-11-13 17:26:34 +00:00
German Khokhlov 73bd952736 Share one render loop fixture across the render failure tests
Four test files built the same widget on CesiumWidget.prototype, viewer
proxy, store and notice; renderLoopFixture.mjs now does it once. The notice
test drives Cesium.Event instead of a hand-made double. frameEvents() drops
tileset.tileLoad, which Cesium raises inside its own error handling, and the
notice no longer guards a viewer without a canvas or requestRender.
2026-11-13 20:16:08 +04:00
Sameh Khamis 7df581765d Merge pull request #1715 from bilawalsidhu/sameh/vessel-typical-length
Draw a vessel of unknown length at its ship type's typical size
2026-11-13 07:52:52 -08:00
Denis Groshev 974023510f fix(rail, transit): a train or vehicle a first-person view holds draws no 3D model either, since the view's eye is inside it; let go, its model draws again
The fleet models (#1714) draw nearby trains and vehicles under the 3D toggle. A
held one now leaves out its model as it does its icon, as the Vessels layer does
for its bridge subject: its model item is hidden, and handing the visual back
to its icon keeps the icon hidden while held. Tests drive each layer with the
real fleet-model layer and a stubbed glTF loader; both fail without the fix.
2026-11-13 16:43:50 +01:00
Sameh Khamis 92d34bc4cc Stretch a vessel to its beam only beside a reported length
A vessel drawn at its type's typical length keeps the model's proportions:
a reported beam alone would squeeze a 300 m container ship to that width.
2026-11-13 07:42:27 -08:00
Denis Groshev 546ac22708 Merge branch 'main' into denis/cockpit-subject-providers 2026-11-13 16:38:13 +01:00
Sameh Khamis 432a29ba59 Add the changelog fragment 2026-11-13 07:34:50 -08:00
Sameh Khamis a9d390c165 Draw a vessel of unknown length at its ship type's typical size
Most live vessel feeds send no length, and the resolver then fell through
every length rule to the range's last model drawn as built, so a cruise
ship or a container ship showed as the smallest model of its range.
types.json gives each passenger, cargo, tanker, fishing and service range
a typical_len (cargo 300 m, tanker 250 m, passenger 140 m, fishing 40 m,
...); a vessel with no usable length is chosen and drawn at it.
2026-11-13 07:34:41 -08:00
German Khokhlov df41c0affb Shorten the render failure notice's comments and docs
Comments keep only the why: the loop-running flag, the mid-raise events and
the one-time canvas focus. The CURRENT-STATE section lists behaviour and
pitfalls instead of retelling the code, and no longer says every tileset
event is raised outside Scene's error handling.
2026-11-13 19:19:15 +04:00
German Khokhlov d42b6fe764 Merge branch 'main' into german/map-render-error-state 2026-11-13 18:47:36 +04:00
Denis Groshev fbc1a9a0ec fix(cockpit): a provider member that throws when read (a getter) leaves that provider out with one warning and keeps the rest; a worded member that starts throwing keeps its last good words; a candidate, frame or HUD view whose getter throws reads as null, so the view ends or keeps its last HUD 2026-11-13 09:22:55 +01:00
Denis Groshev 22f43aab01 refactor(cockpit): a provider words its text with getLocale and onLocaleChange from the ./i18n entry; the ./i18n/locale export, which gave only those two, is removed 2026-11-13 07:39:27 +01:00
Denis Groshev a7d722a1f4 Merge branch 'main' into denis/cockpit-subject-providers 2026-11-13 07:26:22 +01:00
Denis Groshev 0584292405 fix(cockpit): a train handed to another feed's copy keeps its selection time, so it never takes entry back on its own; snapshot stops are copied again when a provider updates its list in place; setCockpitSubject's return is documented as it is 2026-11-12 17:22:55 +01:00
Denis Groshev 09f3380203 docs(cockpit): a held train stays the copy drawn, the read APIs and their exports are experimental, and leaving a view does not track an earlier aircraft again 2026-11-12 17:02:37 +01:00
Denis Groshev c24352ff5e fix(cockpit): an application subject keeps its rates on a slow update interval, its ground lift eases out, and its HUD shows a speed and a progress name only when given
An application subject's camera step is now capped at its own update
interval plus 0.1 s rather than at 0.1 s, so at 500 ms its heading slew,
ground lift and anchor correction run at their configured rates; a long
stall still moves it no further than one late update. The built-in
views keep their 0.1 s cap.

When groundClamp turns false the lift the floor gave goes at the rate
it came, not at once. A HUD view with no speed has no speed readout,
and a progress line with no summary is hidden from assistive
technology instead of being an unnamed image.
2026-11-12 17:00:12 +01:00
Denis Groshev feaf59776c fix(rail): focusTrain refuses a train the layer does not draw
A ride let go with restore focuses its train again. When the train's
category was turned off during the ride, or for a copy another feed draws,
focusTrain selected and followed a train with no marker. It now returns
false for a train in a category turned off or hidden as another feed's
copy, and leaves the selection and the camera as they are.
2026-11-12 17:00:12 +01:00
Denis Groshev 4ae67fcb35 fix(rail): a held train stays the copy drawn of its train and keeps moving, hidden, when its category is turned off
The duplicate filter hid a held copy when another feed's copy outranked it
and moved the selection away, so the held train stopped playing and the
other copy's marker and card came back over the view. A copy a first-person
view holds is now drawn whatever the scores until the view lets go; the
better copy takes over at the next poll after the release. Holding a copy
that another feed drew draws it at once.

A category toggle no longer deselects a held train, and a held train keeps
playing, its marker hidden, while its category is off. Released, it is
deselected and stops as the toggle leaves any other train.
2026-11-12 17:00:12 +01:00
Denis Groshev fe7590d66a docs(application): a provider's subject takes entry when selected after every built-in subject, whose fixed order is unchanged 2026-11-12 16:59:18 +01:00
Denis Groshev 557fe7169a docs(application): the locale and rail path exports and the Rail and Transit read APIs are experimental
Says so where the cockpit subject section names them, together with
path.line, and that a snapshot read again while nothing changed is the one
read before, so a provider may read one on every update.
2026-11-12 16:58:39 +01:00
Denis Groshev a17346be6f fix(rail): a train snapshot's path.line is a frozen copy of its line, not the layer's own path
The copy keeps the path's fields with its number arrays as frozen arrays,
so pointAtS and projectOnPath read it as they read the path and nothing
written to it reaches the layer. It is built once per line and shared by
every snapshot on that line, so reading it on every frame costs nothing.
2026-11-12 16:58:39 +01:00
Denis Groshev 294ccc0ee8 perf(cockpit): a Rail or Transit snapshot read again while nothing in it changed is the one read before
getSelectedTrain, getSelectedVehicle and getCockpitSubject fill drafts and
freeze a new object only for a part whose values changed, so a view that
reads them on every frame builds nothing while the train or vehicle stands
still, and a moving one keeps its feed, stop and provenance objects. A
train's stops are copied once per list the stops provider returns, each
stop a frozen copy, instead of on every call.
2026-11-12 16:58:38 +01:00
Denis Groshev a4f321a9ef fix(cockpit): entry to the built-in views is as before: aircraft, satellite and vessel keep their fixed order whenever each was selected, and an application's subject takes entry only when it was selected after every built-in one present, so without providers nothing changes 2026-11-12 16:58:38 +01:00
Denis Groshev 6fbc98952f fix(cockpit): a provider's methods keep their this, a hold answers true or false, and a cut text never splits a surrogate pair
Provider methods and worded members are called on the provider itself,
so a class-based provider works. A hold that answers false refuses entry
and is released; any answer but true or false refuses it too, released,
with one warning. An answer whose `then` getter throws is handled as a
throw, and a text cut at its cap never leaves half an emoji.
2026-11-12 16:58:38 +01:00
Denis Groshev 4c33552105 fix(cockpit): an application subject's wait for the floor is bounded, a late asynchronous hold is released, and a hold never counts as the user's selection
- A subject with no height at all waits at most
  COCKPIT_GROUND_WAIT_TIMEOUT_MS for the floor, its HUD updating and
  reading ACQUIRING SURFACE; then the view ends and the provider gets its
  subject back (there is no height of its own to fall back to).
- A hold that returns a promise is refused as before and released again
  once it settles, unless that subject is being ridden by then; release
  must tolerate a second call.
- Rail and Transit stamp selectedAtMs only for a user's selection (a click,
  focusTrain or focusVehicle); a deselection clears it, and a train or
  vehicle selected only by a hold reads null.
2026-11-12 12:31:04 +01:00
Denis Groshev 1e4060f6ba docs(changelog): add the #1686 fragment 2026-11-12 11:36:31 +01:00
Denis Groshev 57d87d2bbf fix(cockpit): application subjects follow a change of language, never stand on the ellipsoid, and are guarded on every call; a re-selected subject takes entry again
- Labels, entry face and look may be functions, read again on every use
  and on a locale change; a failing read keeps the last good value.
- A frame with no height keeps the eye's last height, or waits
  (surfaceAcquiring) until the floor is known; heights outside
  -1,000..50,000 m read as null.
- Provider methods are synchronous: a returned promise is refused like a
  throw, and a hold that fails is released.
- A candidate may carry selectedAt; Rail and Transit stamp selectedAtMs
  on every selection, so re-selecting moves a subject to the front.
- A released Transit vehicle leaves the visible set at once.
- docs/CURRENT-STATE.md states the new entry order; docs/APPLICATION.md
  documents cockpit subjects, ./i18n/locale and ./layers/rail/paths.
2026-11-12 11:13:58 +01:00
Denis Groshev 01a74fceec fix(cockpit): an application subject's eye eases off the floor instead of jumping: its anchor is no longer clamped to the floor, and the eye's clearance above it changes at most 1.5 m/s (easeCockpitLift) 2026-11-12 10:50:27 +01:00
Denis Groshev f75179eea0 feat(cockpit): an application can add its own cockpit subjects; the Rail and Transit layers expose the read and hold APIs such a subject needs
GEV itself registers no new subject: with no provider, the Cockpit offers
aircraft, satellites and vessels as before, except that the most recently
selected of them now wins entry.

- services.cockpitSubjects: providers ({kind, layerId, entryFace, priority,
  isEntryAllowed, read, hold, release, hud}) are checked once by the
  coordinator; frames and HUD views on every update; a throwing provider
  ends its view or keeps the last HUD.
- Entry: the most recently selected subject wins, across all kinds.
- Camera: a frame may turn the ground clamp off, anchor exactly on the
  subject and update every frame.
- A generic HUD: kicker, title, cards, always-visible source rows, banner,
  a veil with light streaks (off under reduced motion) and a bottom
  progress line whose close ticks merge.
- Text comes from the provider, already in the page's language
  (./i18n/locale: getLocale, onLocaleChange).
- Rail and Transit read and hold APIs (getSelectedTrain/Vehicle,
  getCockpitSubject, setCockpitSubject, focusTrain/Vehicle); a held
  train's marker, card and route line are hidden, and its feed keeps
  polling while held.
- ./layers/rail/paths is exported.
2026-11-12 10:50:26 +01:00
German Khokhlov 29cf9ff8f6 Add the render failure notice in Swedish 2026-11-11 19:37:06 +04:00
German Khokhlov 897a74ea27 Merge remote-tracking branch 'origin/main' into german/map-render-error-state
# Conflicts:
#	src/i18n/locales/ar.json
#	src/i18n/locales/de.json
#	src/i18n/locales/es.json
#	src/i18n/locales/fr.json
#	src/i18n/locales/he.json
#	src/i18n/locales/it.json
#	src/i18n/locales/ja.json
#	src/i18n/locales/pt.json
#	src/i18n/locales/ru.json
#	src/i18n/locales/uk.json
#	src/i18n/locales/zh-CN.json
#	src/i18n/locales/zh-Hant.json
2026-11-11 19:33:49 +04:00
German Khokhlov bd1da596b8 Keep the draw tool test file in its own formatting 2026-11-11 19:25:02 +04:00
German Khokhlov 6976cbc941 Hand focus back to the globe canvas after a render failure
When the control that had focus is gone or cannot take it, focus went to
the #cesiumContainer div, where Enter no longer finished a drawing. It now
goes to the globe's canvas, which the map's keys read as the map; the canvas
is focusable only until focus moves on or the globe is pressed. A control
disabled while the notice was up no longer leaves focus on the page, and
neither focus call scrolls.
2026-11-11 18:03:30 +04:00
German Khokhlov 771ed98e3b Add the render failure notice in Hebrew 2026-11-11 12:48:44 +04:00
German Khokhlov 282ad6c5f6 Merge remote-tracking branch 'origin/main' into german/map-render-error-state
# Conflicts:
#	src/i18n/locales/ar.json
#	src/i18n/locales/de.json
#	src/i18n/locales/es.json
#	src/i18n/locales/fr.json
#	src/i18n/locales/it.json
#	src/i18n/locales/ja.json
#	src/i18n/locales/pt.json
#	src/i18n/locales/ru.json
#	src/i18n/locales/uk.json
#	src/i18n/locales/zh-CN.json
#	src/i18n/locales/zh-Hant.json
2026-11-11 12:45:05 +04:00
German Khokhlov d1f4d1acdc Say the map is not updated in the Arabic render failure notice 2026-11-11 12:44:08 +04:00
German Khokhlov ebaa439739 Tighten the render failure notice's error handling and focus
- An error a frame of the inline panel's timer throws no longer reaches the
  host page; it has been raised as a render error by then.
- An error Scene raised and threw on (rethrowRenderErrors) is raised once.
- After a lost context, later render errors are only logged, so the notice
  keeps saying why.
- A render error without a value shows no detail.
- The globe's tile load progress and the scene's tileset load events are
  finished too when a listener's error cuts them short.
- Focus goes back where it was when the globe draws again, or to the map.
- The appearance counter and the guard for a missing viewer are gone.

Tests cover every finished event, a thrown timer frame, a single report under
rethrowRenderErrors and the focus return; the inline tests share one setup.
2026-11-11 12:44:08 +04:00
German Khokhlov 265074aa54 Raise the render failure notice above floating panels
The notice sat at z-index 1001, under the Québec camera viewer (2000), which
covered its text and, with video playing, its buttons on a phone. It now sits
at 2001. On touch screens its buttons are 44 px tall.
2026-11-11 12:44:08 +04:00
German Khokhlov c763c38193 Keep the render loop marked running after a frame Retry could race
An error Scene catches itself marks Cesium's render loop stopped while the
loop's next animation frame is still queued. A Retry, or a tab shown again,
before that frame ran started a second loop, and the globe drew every frame
twice. The render and resize wrapper now keeps the loop marked running after
a call made while it ran; the queued frame stops it if nothing restarted it.
This also covers the inline panel's timer frames, so its own guard goes.
2026-11-10 16:40:22 +04:00
German Khokhlov 2448662574 Merge remote-tracking branch 'origin/main' into german/map-render-error-state
# Conflicts:
#	src/app/scene.js
#	src/app/viewer.js
#	src/i18n/locales/ar.json
#	src/i18n/locales/de.json
#	src/i18n/locales/es.json
#	src/i18n/locales/fr.json
#	src/i18n/locales/it.json
#	src/i18n/locales/ja.json
#	src/i18n/locales/pt.json
#	src/i18n/locales/ru.json
#	src/i18n/locales/uk.json
#	src/i18n/locales/zh-CN.json
#	src/i18n/locales/zh-Hant.json
#	src/ui/views/SceneChromeView.vue
2026-11-10 14:29:34 +04:00
German Khokhlov d9e34f10c6 Finish an interrupted frame event as soon as its error surfaces
Retry used to apply the queued listener changes of a Cesium event a
throwing listener left mid-raise, adds first and removes second. A layer
turned off and on while the notice was up landed in both queues and was
removed. The render and resize wrapper now finishes those events before
it raises the render error, so later adds and removes apply at once and
in order.
2026-11-10 14:28:01 +04:00
German Khokhlov 02a8442e6e Let Retry finish a frame event a throwing listener cut short
Cesium's Event.raiseEvent leaves an event mid-raise when a listener throws, so listeners added or removed afterwards stay queued. Retry now applies that queue for the frame and clock events: the notice's frame watchers see the next frame, and a listener removed since, such as a layer turned off, is not called again.
2026-11-06 14:45:42 +04:00
German Khokhlov 628de8386d Report thrown render loop errors and keep one loop after an inline Retry
An error thrown outside Scene's own error handling (a postRender listener,
the clock's onTick, a resize) stopped Cesium's loop without a renderError,
and with Cesium's panel turned off the globe froze silently. The notice now
raises such an error as a render error.

In an inline panel whose host stopped animation frames, Retry left the
panel's timer latched off, and once frames resumed a second render loop
drew every frame twice. The timer now follows Cesium's loop, and keeps the
loop marked running when its next frame is still queued.
2026-11-05 21:21:03 +04:00
German Khokhlov abfc85e03d Add the changelog entry for #1579 2026-11-05 13:03:42 +04:00
German Khokhlov fbec1488ac Show a recoverable notice when the globe stops drawing
Cesium stops its render loop after a render error and offers only a
panel that dismisses itself, and a lost WebGL context never draws again,
so the globe froze or went black while the controls kept responding.

The scene chrome now shows an alert dialog with the error. Retry restarts
the render loop and keeps the notice until a frame renders without an
error; after a lost context it reloads the page, whose address keeps the
view. Reset view opens the app root without the restored hash or share
path. An inline conversation panel never reloads its host page. The
application viewer turns off Cesium's own panel; other callers keep it.
2026-11-05 13:03:14 +04:00
338 changed files with 20379 additions and 2038 deletions

No files matched your search

+1 -1
View File
@@ -176,7 +176,7 @@ VITE_AIS_LIVE_LABEL_MAX_ROWS=900
AISSTREAM_API_KEY= AISSTREAM_API_KEY=
# Optional AISStream subscription filters. # Optional AISStream subscription filters.
# AISSTREAM_BOUNDING_BOXES=[[[-90,-180],[90,180]]] # AISSTREAM_BOUNDING_BOXES=[[[-90,-180],[90,180]]]
# AISSTREAM_MESSAGE_TYPES=PositionReport,StandardClassBPositionReport,ExtendedClassBPositionReport,ShipStaticData,StaticDataReport # AISSTREAM_MESSAGE_TYPES=PositionReport,StandardClassBPositionReport,ExtendedClassBPositionReport,ShipStaticData,StaticDataReport,AidsToNavigationReport
# Feed watchdog: silence (ms) after which the feed is REPORTED stale; the socket # Feed watchdog: silence (ms) after which the feed is REPORTED stale; the socket
# is recycled at 2.5x that. Default 120000. The watchdog self-arms only for the # is recycled at 2.5x that. Default 120000. The watchdog self-arms only for the
# default worldwide subscription — a narrow filter above can be legitimately # default worldwide subscription — a narrow filter above can be legitimately
+15
View File
@@ -419,6 +419,7 @@ 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 |
@@ -518,6 +519,20 @@ 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
+1 -1
View File
@@ -60,7 +60,7 @@ Start with the included data sources, then add your own. Each layer is a separat
- **📡 Contacts:** A 250 km roster of everything near your target — step through live aircraft and drop into any cockpit. - **📡 Contacts:** A 250 km roster of everything near your target — step through live aircraft and drop into any cockpit.
- **🎯 Click-to-track anything:** Camera locks on, draws a fading trail, surfaces full metadata — and a tracked fire or vessel hands you off to the nearest live camera in one click. - **🎯 Click-to-track anything:** Camera locks on, draws a fading trail, surfaces full metadata — and a tracked fire or vessel hands you off to the nearest live camera in one click.
- **🖊️ Voice whiteboard:** Speak annotations onto the world — real boundary polygons, marks, and routes. - **🖊️ Voice whiteboard:** Speak annotations onto the world — real boundary polygons, marks, and routes.
- **🛫 3D fleet:** 121 procedural models drawn life size by type — airliners, business jets, helicopters, drones, ships, buses, trams and trains — and every contact swaps from glyph to 3D model as you close in. - **🛫 3D fleet:** 122 procedural models drawn life size by type — airliners, business jets, helicopters, drones, ships, buses, trams and trains — and every contact swaps from glyph to 3D model as you close in.
- **🎨 Reskin reality:** GLSL sensor looks over the normal globe — CRT, NVG, FLIR/thermal, Noir. - **🎨 Reskin reality:** GLSL sensor looks over the normal globe — CRT, NVG, FLIR/thermal, Noir.
- **🟩 Detection overlay:** Screen-space bounding boxes and IDs on everything in view. - **🟩 Detection overlay:** Screen-space bounding boxes and IDs on everything in view.
- **🎖️ Military HUD:** Tactical heads-up display with intelligence-style telemetry. - **🎖️ Military HUD:** Tactical heads-up display with intelligence-style telemetry.
@@ -0,0 +1,6 @@
- When the globe stops drawing after a render error or a lost WebGL context,
an alert dialog says so instead of leaving a frozen or black globe behind
working controls. Retry restarts drawing (Reload after a lost context), and
Reset view reopens the app without the view restored from the address bar.
Cesium's "Rendering has stopped" panel no longer appears in the app
(German, @gekh, #1579).
+12
View File
@@ -0,0 +1,12 @@
- Let an application add its own Cockpit subjects through
`services.cockpitSubjects`. A provider supplies the subject's position,
heading and HUD (in the page's language, via `getLocale` and
`onLocaleChange` from `./i18n`); the camera can follow it without the
ground clamp, anchored exactly on it, and a generic HUD adds source rows, a
banner, a veil (off under reduced motion) and a bottom progress line. The
Rail and Transit layers expose read and hold APIs so a selected train or
vehicle can be ridden, and `./layers/rail/paths` is exported; that export
and the read APIs are experimental. `docs/APPLICATION.md` shows a minimal
provider. GEV itself registers none, and entry to the built-in aircraft,
satellite and vessel views is unchanged; a provider's subject takes entry
when it was selected after them (Denis Groshev, @ZloyS, #1686).
@@ -0,0 +1,3 @@
- Draw a vessel whose feed sends no length at its ship type's typical size, so
a cruise ship or container ship no longer shows as its type's smallest model
(samehkhamis, #1715).
@@ -0,0 +1,5 @@
- Collapse the CCTV panel when the layer is switched off. It stayed open with
an empty frame and a meta line that still described the last camera; the
meta line of a disabled layer now reads the catalog count. Contacts, Space
Missions and Clear selected data layers leave the panel as it is
(asturksever, #1723).
@@ -0,0 +1,5 @@
- Draw airport ground vehicles and ground stations as what they are instead of
the generic plane. An airport vehicle (type `ZVEH`) is a fire truck, pushback
tug, van or car by its callsign, a ground station (`GRND`) is a new 11.8 m
radio mast, and one with no altitude stands on the ground instead of at the
10 km airborne default (samehkhamis, #1729).
@@ -0,0 +1,4 @@
- Draw airport ground vehicles as real shapes instead of stacked boxes: the
car is an operations SUV, the van a high-roof panel van, the fire truck an
airport crash tender with turrets and the pushback a low tug with a glazed
cab, all on the ground at their built size (samehkhamis, #1730).
@@ -0,0 +1,3 @@
- Draw aircraft models in their own mesh colours instead of a 94% flat
white (amber for military) over the model; the tracked and emergency
colours now tint the mesh without hiding its paint (samehkhamis, #1731).
@@ -0,0 +1,4 @@
- An aircraft that reports no ground track (parked or taxiing, from adsb.lol
or a local receiver) now faces its reported true heading instead of north,
so one taxiing south no longer looks like it is moving backwards
(samehkhamis, #1732).
+11
View File
@@ -0,0 +1,11 @@
- Draw aids to navigation as buoys instead of ships: a buoy, beacon, RACON,
light or mark from the vessel feed (or any AIS station numbered 99MIDxxxx)
is now the fleet set's buoy of its IALA mark (cardinal, port or starboard
hand, safe water, isolated danger or special mark), standing still with no
heading, and its icon is a disc rather than a chevron. Its card and readout
show its kind of mark with no speed or heading. AISStream now subscribes to
aid-to-navigation reports and names each by its kind of mark and its full
name; the feed lists every ship ahead of any aid, and `vessels_in_area`
counts ships only. A virtual aid (a mark broadcast where nothing physical
stands) keeps its disc, is never drawn as a buoy, and its card says it is
virtual; vessel sources may mark any record `virtual` (samehkhamis, #1733).
@@ -0,0 +1,10 @@
- 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
@@ -0,0 +1,5 @@
- 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).
@@ -0,0 +1,9 @@
- 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
@@ -0,0 +1,7 @@
- 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
@@ -0,0 +1,4 @@
- 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
@@ -0,0 +1,4 @@
- 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
@@ -0,0 +1,3 @@
- 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
@@ -0,0 +1,6 @@
- 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
@@ -0,0 +1,3 @@
- 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
@@ -0,0 +1,4 @@
- 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).
@@ -0,0 +1,51 @@
- 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).
@@ -0,0 +1,5 @@
- 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
@@ -0,0 +1,5 @@
- 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).
+150 -1
View File
@@ -187,7 +187,28 @@ 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. observed-history clock those layers share for the catalog's lifetime. That
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,
@@ -195,6 +216,12 @@ 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
@@ -230,3 +257,125 @@ starts and switches the language with the `./i18n` entry (`startPageLocale`,
`localePicker: false` to `createApplicationControls` instead of editing the `localePicker: false` to `createApplicationControls` instead of editing the
markup. Node tests that import application modules register the component markup. Node tests that import application modules register the component
loader with `node --import=<core package>/build/register-vue`. loader with `node --import=<core package>/build/register-vue`.
### Cockpit subjects
Core's Cockpit rides a tracked aircraft, the tracked satellite and the
selected AIS vessel. An application adds kinds of its own with
`services.cockpitSubjects` on `createApplicationControls`: a list of
providers, checked once when the controls are built
(`src/cockpitSubjectProviders.js`). A malformed provider, or one with a member
that throws when read (a getter), is left out with a warning and the rest are
kept. A provider's subject takes entry when it was selected after every
built-in subject present (a tracked aircraft or satellite, a selected vessel),
whose own fixed order is unchanged, and needs the provider's `layerId` on; the
provider then holds its subject, hands the camera a frame and the generic HUD
a view model on every update, and releases it on exit. Provider methods are
synchronous and are called on the provider, so a class instance's methods keep
their `this`; one that throws or returns a promise is treated as having
failed. What a method returns may carry getters: one that throws when read
makes a candidate none, a frame malformed (the view ends) and a HUD view keep
the last; a worded member (labels, entry face, look) that throws keeps its
last good words.
```js
import { getLocale, onLocaleChange } from 'gods-eye-view/i18n';
import { pointAtS } from 'gods-eye-view/layers/rail/paths';
const words = {
en: { view: 'Ride view', exit: 'Exit ride view', exitText: 'EXIT RIDE' },
de: {
view: 'Fahrtansicht',
exit: 'Fahrtansicht verlassen',
exitText: 'VERLASSEN',
},
};
/** Ride the Rail layer's selected train; `rail` is its layer instance. */
const ride = {
kind: 'example-ride',
layerId: 'rail',
entryFace: { icon: 'train', label: 'RIDE' },
// A function is read again when the page's language changes.
labels: () => words[getLocale()] ?? words.en,
candidate() {
const train = rail.getSelectedTrain();
return (
train && {
id: train.key,
name: train.label,
selectedAt: train.selectedAtMs,
}
);
},
// Only a train bound to its line can be ridden: read() needs its path.
isEntryAllowed: () => Boolean(rail.getSelectedTrain()?.path),
hold: (id) => rail.setCockpitSubject(id),
read(nowMs, ctx) {
const train = rail.getCockpitSubject();
if (!train?.path) return null; // ends the view
const point = pointAtS(train.path.line, train.path.s);
return {
id: train.key,
latitude: point.lat,
longitude: point.lon,
heightM: train.position.railHeightM, // null stands it on the shared floor
name: train.label,
headingDeg:
train.path.direction < 0 ? point.bearingDeg + 180 : point.bearingDeg,
speedMps: train.motion.reportedSpeedMps ?? train.motion.derivedSpeedMps,
// The layer already moves the train smoothly: sit on it.
anchor: 'exact',
eye: { upM: 3.5 },
};
},
hud: (frame) => ({ title: frame.name }),
release(id, { restore }) {
rail.setCockpitSubject(null);
if (restore) rail.focusTrain(id);
},
};
createApplicationControls({ /* ... */ services: { cockpitSubjects: [ride] } });
```
The `./i18n` entry gives `getLocale()` and `onLocaleChange()`:
Core translates only its own strings, so a provider words its labels, entry
face and HUD for the page's language (labels may be functions, as above; the
HUD is read again on a change). `gods-eye-view/layers/rail/paths` is the
Rail layer's pure path module (`createRailPath`, `pointAtS`, `projectOnPath`)
for a provider placing a train along its line. The frame, HUD view and
candidate fields are documented in `src/cockpitSubjectProviders.js`; the Rail
and Transit read APIs in `src/layers/rail/cockpitSubject.js` and
`src/layers/transit/cockpitSubject.js`. A snapshot read again while nothing in
it changed is the one read before, so `read` may take one on every update; a
train's `path.line` is a frozen copy of its line, for `pointAtS` and
`projectOnPath`. The `layers/rail/paths` export and the
Rail and Transit read APIs (`getSelectedTrain`, `getSelectedVehicle`,
`getCockpitSubject` and the snapshots they return, `path.line` among them)
are experimental and may change without notice.
A candidate's `selectedAt` is a token that changes each time the user selects
the subject, even the same one again, so entry counts it as selected then; a
new `id` under the same token is the same selection (a train's selection
moving to another feed's copy of it). The Rail and Transit snapshots give one,
`selectedAtMs`: when the user last selected the train or vehicle by a click or
`focusTrain`/`focusVehicle`. It is null after a deselection and for one
selected only by a hold, since a hold is not the user's selection.
A frame with `heightM: null` stands on the shared floor, or where it last
stood while that floor is cold. If no height has been known since entry, the
view waits, showing ACQUIRING SURFACE, for at most five seconds
(`COCKPIT_GROUND_WAIT_TIMEOUT_MS`), then ends and releases the subject.
`hold(id)` returns `true` once it holds the subject, or `false` to refuse
entry; any other answer, `undefined` included, refuses entry too, with one
warning. A refused hold, and one that throws or returns a promise, is followed
by `release(id, { restore: false })`, again once a returned promise settles,
so `release` must be harmless for a subject it does not hold and when called
more than once.
Leaving the view does not track again an aircraft that was tracked before
entry, as with the bridge and orbital views: when the user leaves by the exit
control, the map goes back to the ridden subject through
`release(id, { restore: true })`.
+7 -8
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 })` and Compose `createRailCards({ container, document, onParams, onOpen })`. Rails
`createRailTimeline({ container, document, onCommit, onPreview, onStep, have no time controls of their own; the world timeline moves time. Cards
onLatest, onPlay })`. Cards reconcile `{ id, title, badge, open, compact, 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,10 +330,9 @@ 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 timeline and active radar, satellite and lightning cards. The group heading the frame time and active radar, satellite and lightning cards. The group
and scope name its active products. The timeline stays visible whenever any heading and scope name its active products. The frame time shows whenever any
observed product is enabled; fewer than two ticks disables transport. Pass observed product is enabled and has a frame; the world timeline moves time.
`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
@@ -341,7 +340,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/slider class names provide feature styling. Card titles use Optional card/badge 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
+383 -25
View File
@@ -365,6 +365,26 @@ 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
@@ -627,6 +647,139 @@ 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,
@@ -719,6 +872,8 @@ 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
@@ -1627,7 +1782,14 @@ 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. destroying the layer cancels pending work and ignores late results. While on,
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
@@ -1838,7 +2000,8 @@ 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. Records, absences and the Layers panel's age circle, never past the newer one, redrawn every frame with frames asked for
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
@@ -2208,6 +2371,83 @@ the subject (tracking stopped, Satellites off) or tracking another target
ends the view without refocusing. `src/ui/cockpitOrbitalView.test.mjs` ends the view without refocusing. `src/ui/cockpitOrbitalView.test.mjs`
covers entry, camera, zoom, look, HUD and exit. covers entry, camera, zoom, look, HUD and exit.
### Application subjects
An application can add its own first-person subjects beside the aircraft,
vessel and satellite: `services.cockpitSubjects` to `createApplicationControls`,
a list of subject providers (`src/cockpitSubjectProviders.js`, checked once by
the coordinator; a malformed one, or one with a member that throws when read,
is left out with a warning and the rest are kept). A provider names
its layer, an entry face and labels in the page's language, and supplies four
operations: `candidate()` (what entry would ride now), `hold(id)`,
`read(nowMs, ctx)` (a camera frame each update) and `hud(frame, ctx)` (a HUD
view model), with `release(id, {restore})` on exit. Entry needs the provider's
layer on, no Context change in flight and the provider's own
`isEntryAllowed(candidate)`; voice stays aircraft-only and is refused, with
the reason, while a provider's view is in use.
The built-in kinds keep their fixed entry order (a tracked aircraft, then the
tracked satellite, then the selected vessel), so without providers entry is
unchanged. A provider's candidate takes entry only when it was selected after
every built-in subject present, by when the Cockpit first saw it or saw it
selected again (its `selectedAt` changing): a train picked after an aircraft
was tracked takes entry, and an aircraft tracked after it takes it back.
Providers' candidates are ordered most recently selected first, then by
priority; one first seen together with a built-in subject comes after it. The
first in that order decides alone: when its gate is shut there is no entry
rather than the next subject's.
A frame places the eye `eye.upM` above `heightM` (the shared floor where the
frame gives none, or where it last stood while that floor is cold; with
neither, the camera stays where it is and the HUD reads ACQUIRING SURFACE for
at most `COCKPIT_GROUND_WAIT_TIMEOUT_MS`, after which the view ends and the
subject is released: no height stands in for a floor never read), slews toward `headingDeg` at the provider's rate, and may
turn off the floor clamps (`groundClamp: false`, for a subject inside a
structure) or keep the anchor exactly on the subject (`anchor: 'exact'`, for a
layer that already moves it smoothly; a frame with no `speedMps` is always
exact, since an inertial anchor runs on at the frame's speed); `cameraIntervalMs: 0` updates the
camera every rendered frame. A frame that is null, malformed or for another
subject ends the view (`restoreTracking: false`), as an aircraft tracked
meanwhile and the provider's layer turning off do; the exit control asks the
provider to restore its subject. The generic HUD shows the provider's kicker,
title, meta line, speed with its unit and tag, readout cards, always-visible
source rows, banner, route card and Live Signals rows, a veil that darkens the
picture under the HUD (with passing light streaks, which stop under reduced
motion), arrow-key look-around when the provider asks for it, and a progress
line along the bottom edge (`src/cockpitProgress.js`: close ticks merge,
labels never overlap). Core translates none of the provider's text: a host
words it for the page's language (`gods-eye-view/i18n`:
`getLocale`, `onLocaleChange`). Its entry face, labels and look may be
functions, read again when they are shown and on a change of language, as the
HUD view is.
The Rail layer offers a read API for such a view: `getSelectedTrain()` and
`getCockpitSubject()` (a snapshot of the train: where it is along its line,
which way it runs (`pathBinding.js` learns it), its rail height, speed as
reported or derived, position source, feed credit and stops, and
`selectedAtMs`, when the user last selected it by a click or `focusTrain`,
even again; null after a deselection and for a train only a hold selected),
`setCockpitSubject(key)` (its marker, 3D model and card hidden, the selection kept, its
feed held, clicks and Escape left to the view; the held copy stays the one
drawn of its train while held, another feed's copy hidden, and it keeps
moving, still selected, when its category is turned off) and
`focusTrain(key)` (false for a train the layer does not draw); its pure path
module is exported as `gods-eye-view/layers/rail/paths`. That export
and these read APIs are experimental and may change without notice. A snapshot read again while nothing in it changed is
the one read before, and its `path.line` is a frozen copy. Transit
offers the same for a vehicle (`getSelectedVehicle`, `getCockpitSubject`,
`setCockpitSubject`, `focusVehicle`): a held vehicle keeps playing when it is
out of the camera's view, and its sprite, 3D model, card, trail and detection
box are hidden; released, it is out of play until a visibility sweep finds it in
view. Its snapshot's `position.groundHeightM` is the floor where playback draws
it (the drawn marker's height while it is placed, else its segment's two
reports' floors at the playback fraction), not the newest report's, and null while that floor is unknown. Rail's `path.direction` is the
way the drawn train runs: once playback has placed it, a report that runs back
turns it only when playback reaches that report. `docs/APPLICATION.md` (Cockpit subjects) shows a minimal provider. `src/ui/cockpitSubjectView.test.mjs`,
`src/cockpitSubjectProviders.test.mjs`, `src/cockpitProgress.test.mjs`,
`src/layers/rail/cockpitSubject.test.mjs` and the Transit lifecycle tests
cover them.
## Context coordination ## Context coordination
Context controls own Contacts/Space Missions state, entry and exit transactions, Context controls own Contacts/Space Missions state, entry and exit transactions,
@@ -2229,6 +2469,17 @@ shown without a fade once it is decoded (or after one second if its decode never
settles), so a new camera's preview is blank only while its frame loads. Disposal settles), so a new camera's preview is blank only while its frame loads. Disposal
releases listeners, subscriptions, image handlers, summary timers and chip-hide timers. releases listeners, subscriptions, image handlers, summary timers and chip-hide timers.
The panel opens when a camera becomes active and collapses when the layer is
switched off, from its own switch or anywhere else. The panel chrome applies
the collapse, so a panel the desktop accordion only folded has its saved and
shared state closed too. Clear selected data layers and Context isolation
(Contacts, Space Missions) switch the layer off without the user asking: the
panel and its share preference stay as they are, and the layer comes back to an
open panel. Only the switch-off isolation makes counts (`suspendsLayer`, true
while `_clearLayersOutsideContextMode` switches the layer off): the user
switching CCTV off inside Contacts closes the panel as anywhere else. A panel opened by hand while the layer is off stays open, and its
meta line reads the catalog count, not the last active camera.
## UI disposal ## UI disposal
UI disposal releases the CCTV subscription, command-dock observer, legacy drag UI disposal releases the CCTV subscription, command-dock observer, legacy drag
@@ -2443,6 +2694,53 @@ future driver fix restores the effect with no code change; iOS/iPadOS platform
detection is the backstop for when no probe context can be created. Applied detection is the backstop for when no probe context can be created. Applied
during viewer construction, before any tile builds a draw command. during viewer construction, before any tile builds a draw command.
## Render failure notice
When the globe stops drawing, the scene chrome says so instead of leaving a
black globe behind working controls. `installRenderFailureNotice()`
(`src/ui/renderFailure.js`) listens for Cesium's `renderError` and the canvas'
`webglcontextlost`, logs the error, and publishes a notice that
`RenderFailure.vue` shows as an alert dialog with focus on its first action.
The app's viewer uses `showRenderLoopErrors: false`, so Cesium's dead-end
"Rendering has stopped" panel no longer appears; `applicationViewerOptions()`
keeps it by default for other callers.
- **Retry** restarts the render loop. The notice stays until a frame starts
and ends without an error: Cesium raises `postRender` after a failed frame
too.
- **Reload**, after a lost context, reloads the page; the address keeps the
view.
- **Reset view** opens `appRootUrl()`, without the hash or `/share/<code>`
path; the query, such as `?lang=`, is kept.
Pitfalls:
- An error thrown outside Scene's error handling (a frame listener, `onTick`,
a resize) stops the loop without a `renderError`. The notice wraps the
widget's `render()` and `resize()` and raises it as one, once even with
`rethrowRenderErrors`.
- A throwing listener leaves its Cesium event mid-raise, with listener changes
only queued. The wrapper finishes those raises first (`finishRaise()` over
`frameEvents()`), so a layer turned off or on meanwhile behaves.
- A render error marks the loop stopped while its next frame is still queued.
The wrapper keeps the loop marked running, so a Retry or a shown tab
(`src/app/tools.js`) before that frame starts no second loop.
- After a lost context, later render errors are only logged, so the notice
keeps saying why. The listener neither prevents nor restores the loss.
- When the globe draws again, focus goes back where it was, or to the globe's
canvas, which the map's keys read as the map; the canvas is focusable only
until focus leaves or the globe is pressed. Focus the user moved stays put.
- The notice sits above floating panels such as the Québec camera viewer, and
its buttons are 44 px tall on touch screens.
An inline conversation panel never reloads or navigates its host's page: it
offers Retry after a render error only, and no notice after a lost context,
where the panel runtime's own notice applies (`src/app/globePanelRuntime.js`).
Its timer (`keepPanelRendering()`) draws only while the render loop is on and
logs nothing itself, so each render error is logged once, by the notice. An
error a timer frame throws is caught there: it has already been raised as a
render error.
## Map Source control ownership ## Map Source control ownership
Map Source controls own chip listeners, source-state subscriptions and selection Map Source controls own chip listeners, source-state subscriptions and selection
@@ -3290,7 +3588,8 @@ 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`. The dev proxy never sets `at`. `window_end`, and card and detail ages count back from that time instead of
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.
@@ -4643,8 +4942,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 already-propagated point > ambient-label host source. Its getter reads the point's own position, which
> cache, preserving the 1 Hz fleet epoch and eliminating the former second ISS > moves every frame, 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
@@ -5733,7 +6032,26 @@ 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, trams, subways, trains, ferries and unclassified vehicles. identify buses, trolleybuses, trams, cable cars, subways, monorails, trains,
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
@@ -5799,9 +6117,10 @@ 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. Six modes, two sizes, three north; a vehicle switches to the pointed glyph when a course arrives. Eleven modes, two sizes, three
profiles and the two outlines cap the raster cache at 72 variants. Normal mode colours are bus `#5EF08A`, tram profiles and the two outlines cap the raster cache at 132 variants. Normal mode colours are bus `#5EF08A`,
`#FFC24A`, subway `#FF4538`, rail `#D9A6FF`, ferry `#5FD6FF`, and unknown trolleybus `#B6F04A`, tram `#FFC24A`, cable car `#FF8F4A`, subway `#FF4538`, monorail `#FF6FB8`, rail
`#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
@@ -6685,7 +7004,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**: 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**: 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.
- **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.
@@ -7294,9 +7613,9 @@ pins and share links already supply complete boxes.
normal four-samples-per-250-ms budget. Retry timestamps more than the maximum normal four-samples-per-250-ms budget. Retry timestamps more than the maximum
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, 121 GLBs, about 2 MB in all; `public/models/README.md`), chosen per aircraft by `aircraftModelSpec` (`src/data/fleetModels.js`): its ICAO type's entry in `types.json`, 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. `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/121 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. Textured civilians carry a HEAVY tint, not a light one: `MODEL_COLOR_BLEND_AMOUNT` is `0.94` in BOTH layers under Cesium's `ColorBlendMode.MIX`, so the class colour supplies 94% of the surface and the model's own vertex colour ~6%; military models are flat amber, or emergency red. IR boost raises the blend to a full `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. 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. - 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.
- **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). With the class colour supplying 94% of the surface, 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.
- **Consequence of the flip on the recorded tracked/fleet inversion:** the 150–800 km band where surrounding contacts draw as models while the SELECTED one is still a glyph is now what an operator sees WITHOUT arming anything. The inversion itself is unchanged and still deliberate (see `src/data/trackedModelRegime.js`); only its reachability changed. - **Consequence of the flip on the recorded tracked/fleet inversion:** the 150–800 km band where surrounding contacts draw as models while the SELECTED one is still a glyph is now what an operator sees WITHOUT arming anything. The inversion itself is unchanged and still deliberate (see `src/data/trackedModelRegime.js`); only its reachability changed.
@@ -7880,7 +8199,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 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. 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.
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.
@@ -8222,24 +8541,63 @@ 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 drives all enabled observation rows. Earlier/Later use the sorted union of clock moves all enabled observation rows with the world clock; the world
non-suspended products' advertised times; each product selects its nearest frame timeline is the only time control. Each product's advertised times span time on
at or before the requested UTC time, never a future frame or an interpolation. that timeline but are not marks on it: the timeline marks only events (launches,
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. A missing eligible frame hides that product 3 hours for hourly global infrared, but never less than one and a half of the
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.
Latest follows each product's own newest frame, so its timestamps can differ. Live follows each product's own newest frame, so its timestamps can differ.
Playback advances two seconds after all products settle, wraps, and stops with The timeline's Play runs the world clock at the chosen speed and stops at each
fewer than two union times or when all products are suspended. Hidden documents, product's next frame until that frame loads (for at most eight seconds), so
reduced motion and unavailable imagery hosts suspend products. Re-enabling or every frame shows. Hidden documents, reduced motion and unavailable imagery
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 catalog exposes `weatherClock` and destroys it with its lifetime signal. The Weather panel's frame time shows the newest frame and its age while live
(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, coverage navigation and transport controls live in the WEATHER Source, opacity and coverage navigation live in the WEATHER panel's cards, not
panel's cards and timeline, not in the rows. The viewer clock is untouched. in the rows; the panel shows the frame time and has no time controls. The
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,6 +83,21 @@ 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 `${…}`.
+5
View File
@@ -148,6 +148,7 @@ 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.
@@ -170,6 +171,10 @@ 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, card blocks and timeline (`src/ui/rail/`) render through the rail cards and card blocks (`src/ui/rail/`) render through
Vue's synchronous `render()` behind `createRailCards()`, Vue's synchronous `render()` behind `createRailCards()` and
`createRailCardBlocks()` and `createRailTimeline()`. Order: Scenes (done: `SceneOptions` and `createRailCardBlocks()`. 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,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" /> <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" />
</head> </head>
<body> <body>
<div class="gev-view" data-gev-view="scene-chrome"></div> <div class="gev-view" data-gev-view="scene-chrome"></div>
+1
View File
@@ -298,6 +298,7 @@
}, },
"./sources/rail-service": "./src/sources/railService.js", "./sources/rail-service": "./src/sources/railService.js",
"./layers/rail/source": "./src/layers/rail/source.js", "./layers/rail/source": "./src/layers/rail/source.js",
"./layers/rail/paths": "./src/data/railPaths.js",
"./sources/map-features": "./src/sources/overpassFeatures.js", "./sources/map-features": "./src/sources/overpassFeatures.js",
"./server/providers/mapillary": { "./server/providers/mapillary": {
"node": "./server/providers/mapillary.js" "node": "./server/providers/mapillary.js"
+6
View File
@@ -19,6 +19,12 @@ How the layers use them:
type's published length (a vessel to its reported AIS length and beam), so type's published length (a vessel to its reported AIS length and beam), so
every model is drawn life size. Ground objects, balloons and obstacles in every model is drawn life size. Ground objects, balloons and obstacles in
the `other` category are drawn at the size they are built. the `other` category are drawn at the size they are built.
- An aid to navigation (a vessel record whose type names a buoy, beacon, IALA
mark, RACON or light, or whose MMSI is an aid station's `99MIDxxxx`) is
drawn as the `buoys` model of its mark (cardinal north, east, south or west,
port or starboard hand, safe water, isolated danger, special mark), a red
can when its type names no mark, at its built size and with no heading
(`vesselModelSpec`, `aidToNavigationModel`).
- An aircraft model's origin is its axis; it stands that model's scaled - An aircraft model's origin is its axis; it stands that model's scaled
clearance above the ground. A ship's origin is its waterline, and a transit clearance above the ground. A ship's origin is its waterline, and a transit
or rail vehicle's the road or rail top (a monorail or gondola is raised by or rail vehicle's the road or rail top (a monorail or gondola is raised by
+53 -34
View File
@@ -726,18 +726,18 @@
"file": "other/car.glb", "file": "other/car.glb",
"min": [ "min": [
-2.31, -2.31,
-0.906, -1.075,
0.019 0.018
], ],
"max": [ "max": [
2.3, 2.31,
0.906, 1.075,
1.948 1.84
], ],
"length": 4.61, "length": 4.62,
"beam": 1.812, "beam": 2.15,
"clearance": 0.0, "clearance": 0.0,
"tris": 206, "tris": 736,
"icon": "vehicle" "icon": "vehicle"
}, },
"car_carrier": { "car_carrier": {
@@ -1124,19 +1124,19 @@
"category": "other", "category": "other",
"file": "other/fire_truck.glb", "file": "other/fire_truck.glb",
"min": [ "min": [
-5.51, -6.408,
-1.5, -1.507,
0.027 0.029
], ],
"max": [ "max": [
5.5, 5.51,
1.5, 1.507,
4.08 3.64
], ],
"length": 11.01, "length": 11.918,
"beam": 3.0, "beam": 3.014,
"clearance": 0.0, "clearance": 0.0,
"tris": 314, "tris": 1016,
"icon": "vehicle" "icon": "vehicle"
}, },
"fishing_boat": { "fishing_boat": {
@@ -1751,19 +1751,19 @@
"category": "other", "category": "other",
"file": "other/pushback.glb", "file": "other/pushback.glb",
"min": [ "min": [
-3.0, -3.45,
-1.45, -1.398,
0.024 0.025
], ],
"max": [ "max": [
3.0, 3.05,
1.45, 1.398,
2.1 2.14
], ],
"length": 6.0, "length": 6.5,
"beam": 2.9, "beam": 2.795,
"clearance": 0.0, "clearance": 0.0,
"tris": 176, "tris": 490,
"icon": "vehicle" "icon": "vehicle"
}, },
"r44": { "r44": {
@@ -1785,6 +1785,25 @@
"tris": 1172, "tris": 1172,
"icon": "heli" "icon": "heli"
}, },
"radio_mast": {
"category": "other",
"file": "other/radio_mast.glb",
"min": [
-0.7,
-0.815,
0.0
],
"max": [
1.2,
0.815,
11.8
],
"length": 1.9,
"beam": 1.63,
"clearance": 0.0,
"tris": 940,
"icon": "vehicle"
},
"regional_dmu": { "regional_dmu": {
"category": "transit", "category": "transit",
"file": "transit/regional_dmu.glb", "file": "transit/regional_dmu.glb",
@@ -2284,18 +2303,18 @@
"file": "other/van.glb", "file": "other/van.glb",
"min": [ "min": [
-2.76, -2.76,
-1.0, -1.22,
0.019 0.018
], ],
"max": [ "max": [
2.75, 2.76,
1.0, 1.22,
2.438 2.56
], ],
"length": 5.51, "length": 5.52,
"beam": 2.0, "beam": 2.44,
"clearance": 0.0, "clearance": 0.0,
"tris": 206, "tris": 736,
"icon": "vehicle" "icon": "vehicle"
} }
} }
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+44
View File
@@ -983,6 +983,14 @@
"SHIP": [ "SHIP": [
"blimp", "blimp",
75.0 75.0
],
"ZVEH": [
"car",
4.61
],
"GRND": [
"radio_mast",
1.9
] ]
}, },
"helicopters": { "helicopters": {
@@ -1143,12 +1151,41 @@
5.0 5.0
] ]
}, },
"aircraft_callsign": {
"ZVEH": [
{
"prefixes": [
"FIRE"
],
"model": "fire_truck",
"length": 11.01
},
{
"prefixes": [
"PUSH",
"TUG"
],
"model": "pushback",
"length": 6.0
},
{
"prefixes": [
"DEICER",
"SQUID",
"SQB"
],
"model": "van",
"length": 5.51
}
]
},
"vessels": [ "vessels": [
{ {
"types": [ "types": [
70, 70,
79 79
], ],
"typical_len": 300,
"rules": [ "rules": [
{ {
"min_len": 70, "min_len": 70,
@@ -1188,6 +1225,7 @@
80, 80,
89 89
], ],
"typical_len": 250,
"rules": [ "rules": [
{ {
"name_re": "\\bLNG\\b|\\bGAS\\b", "name_re": "\\bLNG\\b|\\bGAS\\b",
@@ -1214,6 +1252,7 @@
60, 60,
69 69
], ],
"typical_len": 140,
"rules": [ "rules": [
{ {
"min_len": 70, "min_len": 70,
@@ -1243,6 +1282,7 @@
37, 37,
37 37
], ],
"typical_len": 19,
"rules": [ "rules": [
{ {
"min_len": 35, "min_len": 35,
@@ -1273,6 +1313,7 @@
30, 30,
30 30
], ],
"typical_len": 24,
"rules": [ "rules": [
{ {
"min_len": 15, "min_len": 15,
@@ -1343,6 +1384,7 @@
55, 55,
55 55
], ],
"typical_len": 40,
"rules": [ "rules": [
{ {
"min_len": 25, "min_len": 25,
@@ -1358,6 +1400,7 @@
40, 40,
49 49
], ],
"typical_len": 40,
"rules": [ "rules": [
{ {
"min_len": 20, "min_len": 20,
@@ -1373,6 +1416,7 @@
33, 33,
33 33
], ],
"typical_len": 100,
"rules": [ "rules": [
{ {
"min_len": 50, "min_len": 50,
+455
View File
@@ -0,0 +1,455 @@
#!/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)}`);
+40 -3
View File
@@ -456,6 +456,7 @@
"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",
@@ -483,6 +484,8 @@
"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",
@@ -557,6 +560,13 @@
"src/ui/frameRateMonitor.js", "src/ui/frameRateMonitor.js",
"src/ui/frameRateMonitor.test.mjs", "src/ui/frameRateMonitor.test.mjs",
"src/ui/frameRateView.test.mjs", "src/ui/frameRateView.test.mjs",
"src/ui/renderFailure.test.mjs",
"src/ui/renderFailureView.test.mjs",
"src/ui/renderFailureLoopErrors.test.mjs",
"src/ui/renderFailureEventRetry.test.mjs",
"src/app/renderFailureInlineRetry.test.mjs",
"src/ui/renderFailureSecondLoop.test.mjs",
"src/ui/renderLoopFixture.mjs",
"src/layers/cctv/lifecycle.js", "src/layers/cctv/lifecycle.js",
"src/layers/cctv/controls.js", "src/layers/cctv/controls.js",
"src/layers/cctv/geometry.js", "src/layers/cctv/geometry.js",
@@ -929,8 +939,6 @@
"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",
@@ -1429,8 +1437,37 @@
"src/data/placePacksLoading.test.mjs", "src/data/placePacksLoading.test.mjs",
"src/ui/placePacksLoading.js", "src/ui/placePacksLoading.js",
"src/ui/placePacksLoading.test.mjs", "src/ui/placePacksLoading.test.mjs",
"src/cockpitSubjectProviders.js",
"src/cockpitSubjectProviders.test.mjs",
"src/cockpitProgress.js",
"src/cockpitProgress.test.mjs",
"src/ui/cockpitSubjectView.test.mjs",
"src/ui/styles/cockpitSubject.css",
"src/layers/rail/cockpitSubject.js",
"src/layers/rail/cockpitSubject.test.mjs",
"src/layers/transit/cockpitSubject.js",
"src/data/fleetModels.test.mjs", "src/data/fleetModels.test.mjs",
"src/data/fleetModelInstances.test.mjs", "src/data/fleetModelInstances.test.mjs",
"src/testSupport/fleetGlb.mjs", "src/testSupport/fleetGlb.mjs",
"scripts/measure-fleet-trail-anchors.mjs" "src/testSupport/fleetModelFakes.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"
] ]
+54 -12
View File
@@ -22,6 +22,9 @@
"src/ui/vueRuntime.js", "src/ui/vueRuntime.js",
"src/ui/views/index.js", "src/ui/views/index.js",
"src/ui/views/CockpitView.vue", "src/ui/views/CockpitView.vue",
"src/ui/views/panels/CockpitProgress.vue",
"src/cockpitProgress.js",
"src/cockpitSubjectProviders.js",
"src/ui/views/CommandDockView.vue", "src/ui/views/CommandDockView.vue",
"src/ui/views/ContextView.vue", "src/ui/views/ContextView.vue",
"src/ui/views/panels/RadioPanel.vue", "src/ui/views/panels/RadioPanel.vue",
@@ -99,6 +102,7 @@
"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",
@@ -109,6 +113,8 @@
"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",
@@ -151,6 +157,8 @@
"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",
@@ -163,6 +171,7 @@
"src/ui/kit/UiBadge.vue", "src/ui/kit/UiBadge.vue",
"src/ui/stores/globeActions.js", "src/ui/stores/globeActions.js",
"src/ui/views/panels/GlobeActions.vue", "src/ui/views/panels/GlobeActions.vue",
"src/ui/views/panels/RenderFailure.vue",
"src/ui/views/panels/GlobeCompass.vue", "src/ui/views/panels/GlobeCompass.vue",
"src/ui/stores/recording.js", "src/ui/stores/recording.js",
"src/ui/styleParameters.js", "src/ui/styleParameters.js",
@@ -247,6 +256,7 @@
"src/data/adsbLolFallback.js", "src/data/adsbLolFallback.js",
"src/data/aisStreamAdapter.js", "src/data/aisStreamAdapter.js",
"src/data/aisWatchdog.js", "src/data/aisWatchdog.js",
"src/data/vesselTypes.js",
"src/sources/httpBody.js", "src/sources/httpBody.js",
"src/i18n/marker.js", "src/i18n/marker.js",
"server/providers/acartia.js", "server/providers/acartia.js",
@@ -794,7 +804,6 @@
"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",
@@ -827,7 +836,6 @@
"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",
@@ -1009,6 +1017,8 @@
"src/ui/cockpitInput.js", "src/ui/cockpitInput.js",
"src/data/inputOwnership.js", "src/data/inputOwnership.js",
"src/ui/cockpitInstruments.js", "src/ui/cockpitInstruments.js",
"src/cockpitSubjectProviders.js",
"src/cockpitProgress.js",
"src/ui/cockpitLayout.js", "src/ui/cockpitLayout.js",
"src/ui/cockpitPresentation.js", "src/ui/cockpitPresentation.js",
"src/ui/cockpitSignals.js", "src/ui/cockpitSignals.js",
@@ -1129,6 +1139,7 @@
"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",
@@ -1174,6 +1185,8 @@
"src/ui/cockpitInput.js", "src/ui/cockpitInput.js",
"src/data/inputOwnership.js", "src/data/inputOwnership.js",
"src/ui/cockpitInstruments.js", "src/ui/cockpitInstruments.js",
"src/cockpitSubjectProviders.js",
"src/cockpitProgress.js",
"src/ui/cockpitLayout.js", "src/ui/cockpitLayout.js",
"src/ui/cockpitPresentation.js", "src/ui/cockpitPresentation.js",
"src/ui/cockpitSignals.js", "src/ui/cockpitSignals.js",
@@ -1202,7 +1215,6 @@
"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",
@@ -1297,7 +1309,6 @@
"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",
@@ -1356,6 +1367,7 @@
"src/ui/styles/status.css", "src/ui/styles/status.css",
"src/ui/styles/cockpit.css", "src/ui/styles/cockpit.css",
"src/ui/styles/cockpitBridge.css", "src/ui/styles/cockpitBridge.css",
"src/ui/styles/cockpitSubject.css",
"src/ui/styles/cockpitRadar.css", "src/ui/styles/cockpitRadar.css",
"src/ui/styles/overlays.css", "src/ui/styles/overlays.css",
"src/ui/styles/layers.css", "src/ui/styles/layers.css",
@@ -1505,6 +1517,7 @@
"exports": ["./layers/flights"], "exports": ["./layers/flights"],
"modules": [ "modules": [
"src/data/fleetModels.js", "src/data/fleetModels.js",
"src/data/vesselTypes.js",
"src/data/fleetTrailAnchors.js", "src/data/fleetTrailAnchors.js",
"src/data/fleetModelInstances.js", "src/data/fleetModelInstances.js",
"src/data/headingDegrees.js", "src/data/headingDegrees.js",
@@ -1574,7 +1587,8 @@
"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"]
}, },
@@ -1584,6 +1598,7 @@
"src/layers/flights/modelSpec.js", "src/layers/flights/modelSpec.js",
"src/layers/flights/policy.js", "src/layers/flights/policy.js",
"src/data/fleetModels.js", "src/data/fleetModels.js",
"src/data/vesselTypes.js",
"src/data/fleetTrailAnchors.js", "src/data/fleetTrailAnchors.js",
"src/data/fleetModelInstances.js", "src/data/fleetModelInstances.js",
"src/data/headingDegrees.js", "src/data/headingDegrees.js",
@@ -1646,7 +1661,8 @@
"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"]
}, },
@@ -1746,7 +1762,8 @@
"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"]
}, },
@@ -1837,6 +1854,7 @@
"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",
@@ -2955,6 +2973,7 @@
"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",
@@ -3075,6 +3094,7 @@
"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",
@@ -3115,6 +3135,8 @@
"src/ui/displayPanelScroll.js", "src/ui/displayPanelScroll.js",
"src/ui/cockpitInput.js", "src/ui/cockpitInput.js",
"src/ui/cockpitInstruments.js", "src/ui/cockpitInstruments.js",
"src/cockpitSubjectProviders.js",
"src/cockpitProgress.js",
"src/ui/cockpitLayout.js", "src/ui/cockpitLayout.js",
"src/ui/cockpitPresentation.js", "src/ui/cockpitPresentation.js",
"src/ui/cockpitSignals.js", "src/ui/cockpitSignals.js",
@@ -3132,6 +3154,7 @@
"src/ui/displayControls.js", "src/ui/displayControls.js",
"src/ui/effects.js", "src/ui/effects.js",
"src/ui/frameRateMonitor.js", "src/ui/frameRateMonitor.js",
"src/ui/renderFailure.js",
"src/ui/layerPanel.js", "src/ui/layerPanel.js",
"src/ui/weatherPanel.js", "src/ui/weatherPanel.js",
"src/ui/railCards.js", "src/ui/railCards.js",
@@ -3144,7 +3167,6 @@
"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",
@@ -3302,6 +3324,7 @@
"src/app/layers/rail.js", "src/app/layers/rail.js",
"src/data/railFeeds.js", "src/data/railFeeds.js",
"src/layers/rail/index.js", "src/layers/rail/index.js",
"src/layers/rail/cockpitSubject.js",
"src/layers/rail/source.js", "src/layers/rail/source.js",
"src/layers/rail/policy.js", "src/layers/rail/policy.js",
"src/layers/rail/ingestion.js", "src/layers/rail/ingestion.js",
@@ -3328,6 +3351,7 @@
"src/layers/transit/controls.js", "src/layers/transit/controls.js",
"src/layers/transit/height.js", "src/layers/transit/height.js",
"src/layers/transit/index.js", "src/layers/transit/index.js",
"src/layers/transit/cockpitSubject.js",
"src/layers/transit/ingestion.js", "src/layers/transit/ingestion.js",
"src/layers/transit/lifecycle.js", "src/layers/transit/lifecycle.js",
"src/layers/transit/policy.js", "src/layers/transit/policy.js",
@@ -3698,7 +3722,6 @@
"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",
@@ -3803,7 +3826,9 @@
"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",
@@ -4136,6 +4161,7 @@
"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",
@@ -4290,6 +4316,7 @@
"src/app/layers/rail.js", "src/app/layers/rail.js",
"src/data/railFeeds.js", "src/data/railFeeds.js",
"src/layers/rail/index.js", "src/layers/rail/index.js",
"src/layers/rail/cockpitSubject.js",
"src/layers/rail/source.js", "src/layers/rail/source.js",
"src/layers/rail/policy.js", "src/layers/rail/policy.js",
"src/layers/rail/ingestion.js", "src/layers/rail/ingestion.js",
@@ -4315,6 +4342,7 @@
"src/layers/transit/controls.js", "src/layers/transit/controls.js",
"src/layers/transit/height.js", "src/layers/transit/height.js",
"src/layers/transit/index.js", "src/layers/transit/index.js",
"src/layers/transit/cockpitSubject.js",
"src/layers/transit/ingestion.js", "src/layers/transit/ingestion.js",
"src/layers/transit/lifecycle.js", "src/layers/transit/lifecycle.js",
"src/layers/transit/policy.js", "src/layers/transit/policy.js",
@@ -4519,7 +4547,9 @@
"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",
@@ -4799,7 +4829,10 @@
"src/data/approxDistance.js", "src/data/approxDistance.js",
"src/data/renderAltitude.js", "src/data/renderAltitude.js",
"src/data/aircraftClass.js", "src/data/aircraftClass.js",
"src/data/aircraftEmergency.js" "src/data/aircraftEmergency.js",
"src/data/fleetModels.js",
"src/data/vesselTypes.js",
"src/data/fleetTrailAnchors.js"
], ],
"external": [] "external": []
}, },
@@ -5189,6 +5222,7 @@
"exports": ["./layers/transit/standalone"], "exports": ["./layers/transit/standalone"],
"modules": [ "modules": [
"src/data/fleetModels.js", "src/data/fleetModels.js",
"src/data/vesselTypes.js",
"src/data/aircraftClass.js", "src/data/aircraftClass.js",
"src/i18n/marker.js", "src/i18n/marker.js",
"src/data/fleetModelInstances.js", "src/data/fleetModelInstances.js",
@@ -5226,6 +5260,7 @@
"src/layers/transit/controls.js", "src/layers/transit/controls.js",
"src/layers/transit/height.js", "src/layers/transit/height.js",
"src/layers/transit/index.js", "src/layers/transit/index.js",
"src/layers/transit/cockpitSubject.js",
"src/layers/transit/ingestion.js", "src/layers/transit/ingestion.js",
"src/layers/transit/lifecycle.js", "src/layers/transit/lifecycle.js",
"src/layers/transit/movement.js", "src/layers/transit/movement.js",
@@ -5280,6 +5315,7 @@
"modules": [ "modules": [
"src/i18n/marker.js", "src/i18n/marker.js",
"src/data/fleetModels.js", "src/data/fleetModels.js",
"src/data/vesselTypes.js",
"src/data/fleetModelInstances.js", "src/data/fleetModelInstances.js",
"src/data/aircraftClass.js", "src/data/aircraftClass.js",
"src/data/aircraftModelLighting.js", "src/data/aircraftModelLighting.js",
@@ -5296,6 +5332,7 @@
"src/layers/transit/controls.js", "src/layers/transit/controls.js",
"src/layers/transit/height.js", "src/layers/transit/height.js",
"src/layers/transit/index.js", "src/layers/transit/index.js",
"src/layers/transit/cockpitSubject.js",
"src/layers/transit/ingestion.js", "src/layers/transit/ingestion.js",
"src/layers/transit/lifecycle.js", "src/layers/transit/lifecycle.js",
"src/layers/transit/policy.js", "src/layers/transit/policy.js",
@@ -5918,5 +5955,10 @@
"src/layers/wildlifeSightings/performance.js" "src/layers/wildlifeSightings/performance.js"
], ],
"external": [] "external": []
},
"rail-paths": {
"exports": ["./layers/rail/paths"],
"modules": ["src/data/railPaths.js"],
"external": []
} }
} }
+75
View File
@@ -339,6 +339,45 @@ try {
'#cctv-frame', '#cctv-frame',
(image) => !image.getAttribute('src') && image.dataset.cameraId === '', (image) => !image.getAttribute('src') && image.dataset.cameraId === '',
); );
// The address bar follows the share state after a short delay: wait for the
// CCTV panel token (1 collapsed, 0 open) the step expects.
const panelState = async (expectedToken) => {
const settled = await page
.waitForFunction(
(token) =>
new RegExp(`(?:^|_)v\\.c\\.${token}(?:_|$)`).test(
new URLSearchParams(location.hash.slice(1)).get('ui') || '',
),
{ timeout: 5000 },
expectedToken,
)
.then(
() => true,
() => false,
);
return page.evaluate(
(shared) => ({
collapsed: document
.getElementById('cctv-panel')
.classList.contains('collapsed'),
shared,
ui: new URLSearchParams(location.hash.slice(1)).get('ui'),
}),
settled ? expectedToken : null,
);
};
const closedOnDisable = await panelState('1');
check(
'disable collapses the panel and its share preference',
closedOnDisable.collapsed && closedOnDisable.shared === '1',
JSON.stringify(closedOnDisable),
);
// The switch sits inside the collapsed panel: reopen it from the right rail.
await page.click('[data-rail-item="cctv-panel"]');
await page.waitForFunction(
() =>
!document.getElementById('cctv-panel').classList.contains('collapsed'),
);
await page.click('#cctv-enable-btn'); await page.click('#cctv-enable-btn');
await page.waitForFunction( await page.waitForFunction(
() => () =>
@@ -351,6 +390,42 @@ try {
'disable clears the preview and re-enable reacquires the camera frame', 'disable clears the preview and re-enable reacquires the camera frame',
disabledCleanly, disabledCleanly,
); );
// Contacts switches CCTV off to isolate itself and restores it on the way
// out: the open panel and its share preference survive the round trip.
const cameraBeforeContacts = await page.evaluate(
() =>
window.__godsEyeView.styleManager._cctvControls.getState()
?.activeCameraId,
);
const entered = await page.evaluate(() =>
window.__godsEyeView.styleManager.setContextMode('flights'),
);
await page.waitForFunction(
() => !window.__godsEyeView.dataManager.isEnabled('cctv'),
);
const duringContacts = await panelState('0');
await page.evaluate(() =>
window.__godsEyeView.styleManager.setContextMode(null),
);
await page.waitForFunction(() =>
window.__godsEyeView.dataManager.isEnabled('cctv'),
);
const afterContacts = await panelState('0');
check(
'Contacts isolation keeps the open CCTV panel and restores its camera',
entered?.ok === true &&
!duringContacts.collapsed &&
duringContacts.shared === '0' &&
!afterContacts.collapsed &&
afterContacts.shared === '0' &&
(await page.evaluate(
(cameraId) =>
window.__godsEyeView.styleManager._cctvControls.getState()
?.activeCameraId === cameraId,
cameraBeforeContacts,
)),
JSON.stringify({ entered, duringContacts, afterContacts }),
);
await page.$eval('#cctv-frame', (image) => await page.$eval('#cctv-frame', (image) =>
image.scrollIntoView({ block: 'nearest' }), image.scrollIntoView({ block: 'nearest' }),
); );
+22
View File
@@ -202,3 +202,25 @@ 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');
});
+2
View File
@@ -27,6 +27,8 @@ const AISSTREAM_DEFAULT_MESSAGE_TYPES = [
'ExtendedClassBPositionReport', 'ExtendedClassBPositionReport',
'ShipStaticData', 'ShipStaticData',
'StaticDataReport', 'StaticDataReport',
// Buoys, beacons and other aids to navigation (AIS message 21).
'AidsToNavigationReport',
]; ];
// Watchdog budgets (policy lives in src/data/aisWatchdog.js). Silence is // Watchdog budgets (policy lives in src/data/aisWatchdog.js). Silence is
// REPORTED quickly and ACTED ON slowly: a dead feed must read as dead within // REPORTED quickly and ACTED ON slowly: a dead feed must read as dead within
+41 -6
View File
@@ -1,4 +1,8 @@
import { isRecognizedAisEnvelope } from '../../../src/data/aisStreamAdapter.js'; import { isRecognizedAisEnvelope } from '../../../src/data/aisStreamAdapter.js';
import {
aidToNavigationTypeName,
isAidToNavigation,
} from '../../../src/data/vesselTypes.js';
export const AISSTREAM_CACHE_MAX = 50000; export const AISSTREAM_CACHE_MAX = 50000;
export const AISSTREAM_STALE_MS = 30 * 60 * 1000; export const AISSTREAM_STALE_MS = 30 * 60 * 1000;
// Per-MMSI recent-path ring buffers (PRD WS-F F3). Float32 lat/lon (~1m // Per-MMSI recent-path ring buffers (PRD WS-F F3). Float32 lat/lon (~1m
@@ -112,13 +116,21 @@ export function ingestAisStreamEnvelope(envelope) {
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return true; if (!Number.isFinite(lat) || !Number.isFinite(lon)) return true;
const staticData = _aisStreamStatic.get(mmsi) || {}; const staticData = _aisStreamStatic.get(mmsi) || {};
const aidReport = messageType === 'AidsToNavigationReport';
_aisStreamVessels.set(mmsi, { _aisStreamVessels.set(mmsi, {
lat, lat,
lon, lon,
name: vesselNameFromAis(metadata, message, staticData) || `MMSI ${mmsi}`, name:
(aidReport && aidNameFromAis(message)) ||
vesselNameFromAis(metadata, message, staticData) ||
`MMSI ${mmsi}`,
mmsi, mmsi,
imo: stringValue(message.ImoNumber ?? message.IMO ?? staticData.imo), imo: stringValue(message.ImoNumber ?? message.IMO ?? staticData.imo),
type: vesselTypeFromAis(message, staticData), // An aid-to-navigation report's type is the kind of aid (0 to 31), not
// a ship type: it is carried as the aid's name ("Cardinal Mark N").
type: aidReport
? aidToNavigationTypeName(message.Type)
: vesselTypeFromAis(message, staticData),
destination: stringValue(message.Destination ?? staticData.destination), destination: stringValue(message.Destination ?? staticData.destination),
length: staticData.length ?? null, length: staticData.length ?? null,
beam: staticData.beam ?? null, beam: staticData.beam ?? null,
@@ -131,6 +143,9 @@ export function ingestAisStreamEnvelope(envelope) {
// Use the AIS message's own report time, not server ingest wall-clock — // Use the AIS message's own report time, not server ingest wall-clock —
// trail spacing and dead reckoning depend on true fix epochs. // trail spacing and dead reckoning depend on true fix epochs.
last_position_epoch: aisEpochSeconds(metadata.time_utc ?? metadata.TimeUtc), last_position_epoch: aisEpochSeconds(metadata.time_utc ?? metadata.TimeUtc),
// A virtual aid is broadcast where nothing physical stands. Only such a
// row carries the field, so ship rows stay their size.
...(aidReport && message.VirtualAtoN === true && { virtual: true }),
_updatedAt: Date.now(), _updatedAt: Date.now(),
}); });
@@ -276,6 +291,15 @@ function vesselNameFromAis(metadata, message, staticData = {}) {
); );
} }
/**
* An aid's whole name: an aid-to-navigation report carries the first 20
* characters in `Name` and the rest, up to 14 more, in `NameExtension`.
*/
function aidNameFromAis(message) {
const name = stringValue(message.Name);
return name ? stringValue(name + stringValue(message.NameExtension)) : '';
}
function vesselTypeFromAis(message, staticData = {}) { function vesselTypeFromAis(message, staticData = {}) {
return stringValue( return stringValue(
message.Type ?? message.Type ??
@@ -285,14 +309,25 @@ function vesselTypeFromAis(message, staticData = {}) {
); );
} }
/**
* The current rows, newest first, with every vessel ahead of every aid to
* navigation: a capped request never gives up a ship for a buoy.
*/
export function aisStreamRows(maxRows) { export function aisStreamRows(maxRows) {
const cutoff = Date.now() - AISSTREAM_STALE_MS; const cutoff = Date.now() - AISSTREAM_STALE_MS;
const rows = []; const vessels = [];
const aids = [];
for (const row of _aisStreamVessels.values()) { for (const row of _aisStreamVessels.values()) {
if (row._updatedAt >= cutoff) rows.push(row); if (row._updatedAt >= cutoff)
(isAidToNavigation(row) ? aids : vessels).push(row);
} }
rows.sort((a, b) => b._updatedAt - a._updatedAt); const newestFirst = (a, b) => b._updatedAt - a._updatedAt;
return rows.slice(0, maxRows).map(({ _updatedAt, ...row }) => row); vessels.sort(newestFirst);
if (vessels.length < maxRows) aids.sort(newestFirst);
return vessels
.concat(aids)
.slice(0, maxRows)
.map(({ _updatedAt, ...row }) => row);
} }
function pruneAisStreamCache() { function pruneAisStreamCache() {
@@ -400,6 +400,27 @@ test('Enter finishes from the canvas and the label field, never from a focused b
} }
}); });
test('Enter on the globe canvas finishes, as after the render failure notice hands focus back', async () => {
const h = harness();
try {
h.act('toggleDraw');
h.tool.setShape('area');
h.tool.addVertex(-97.75, 30.26);
h.tool.addVertex(-97.74, 30.26);
h.tool.addVertex(-97.74, 30.27);
// src/ui/views/panels/RenderFailure.vue focuses the canvas when the globe
// draws again and the control that had focus is gone.
h.documentDouble.emit('keydown', { key: 'Enter', target: h.viewer.scene.canvas });
await Promise.resolve();
await Promise.resolve();
assert.equal(h.annotations.calls.annotate.length, 1, 'Enter on the canvas finishes');
assert.equal(h.annotations.calls.annotate[0].specs[0].type, 'area');
} finally {
h.restore();
}
});
test('the live preview hangs at the height the click landed on', async () => { test('the live preview hangs at the height the click landed on', async () => {
const h = harness(); const h = harness();
try { try {
@@ -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, 'reticle rings are removed in place'); assert.equal(after.querySelector('.gev-anno-ring') === null, true, '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); assert.equal(document.querySelector('.gev-screen-whiteboard') === null, true);
}); });
// ── Partial-add unwind (review round 2) ─────────────────────────────────────── // ── Partial-add unwind (review round 2) ───────────────────────────────────────
+10
View File
@@ -6,6 +6,7 @@ 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';
@@ -63,6 +64,9 @@ 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,
@@ -74,6 +78,7 @@ 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');
@@ -97,17 +102,20 @@ 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 } : {}),
@@ -172,6 +180,7 @@ 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 } : {}),
}), }),
@@ -210,6 +219,7 @@ export function createApplicationCatalog({
createApplicationRail({ createApplicationRail({
surface, surface,
source: sources.rail, source: sources.rail,
placement: layerPlacement(placement, 'rail'),
displayParams: () => flights.getParams(), displayParams: () => flights.getParams(),
...(resolveAsset ? { resolveAsset } : {}), ...(resolveAsset ? { resolveAsset } : {}),
}), }),
+19 -3
View File
@@ -6,6 +6,11 @@ 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();
@@ -27,6 +32,7 @@ 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 = [];
@@ -53,7 +59,10 @@ 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);
await first.weatherClock.setTarget('2026-09-21T12:00:00.000Z'); // Time moves on the world clock, which the catalog's weather clock follows.
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/,
@@ -111,9 +120,16 @@ 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(
await first.weatherClock.setTarget('2026-09-21T13:00:00.000Z'), first.weatherClock.getState().target,
false, '2026-09-21T12:00:00.000Z',
'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);
+2
View File
@@ -11,6 +11,8 @@ import { t } from '../i18n/index.js';
* whose address does not carry one. `localePicker: false` removes the language * whose address does not carry one. `localePicker: false` removes the language
* picker, for a host that offers its own language choice (`switchLocale` from * picker, for a host that offers its own language choice (`switchLocale` from
* the `./i18n` entry). * the `./i18n` entry).
* `services.cockpitSubjects` adds the application's own first-person subjects
* to the Cockpit (src/cockpitSubjectProviders.js).
*/ */
export function createApplicationControls({ export function createApplicationControls({
scene: { viewer, mapStackController, operations }, scene: { viewer, mapStackController, operations },
+9 -26
View File
@@ -94,17 +94,6 @@ export function panelAwareVisibility(doc = globalThis.document) {
const PANEL_FRAME_MS = 33; const PANEL_FRAME_MS = 33;
const MISSED_FRAMES_MS = 250; const MISSED_FRAMES_MS = 250;
/** A render error's details, including the plain objects workers report. */
function describeError(error) {
if (error instanceof Error)
return `${error.name}: ${error.message}\n${error.stack}`;
try {
return JSON.stringify(error, Object.getOwnPropertyNames(error ?? {}));
} catch {
return String(error);
}
}
/** /**
* Keep the globe drawing in an inline panel. Some hosts report a panel on * Keep the globe drawing in an inline panel. Some hosts report a panel on
* screen as hidden, which stops the browser's animation frames and with them * screen as hidden, which stops the browser's animation frames and with them
@@ -122,33 +111,27 @@ export function keepPanelRendering(
frameRequest = windowRef.requestAnimationFrame(onFrame); frameRequest = windowRef.requestAnimationFrame(onFrame);
}; };
frameRequest = windowRef.requestAnimationFrame(onFrame); frameRequest = windowRef.requestAnimationFrame(onFrame);
// Cesium stops drawing after a render error, and so does the timer. // Cesium stops its render loop after a render error, and the timer stops
// Report the error in full: workers report plain objects, which Cesium // with it; Retry restarts both. The render failure notice logs the error
// prints as [object Object]. // (src/ui/renderFailure.js).
let failed = false;
const removeErrorListener = viewer.scene?.renderError?.addEventListener(
(_scene, error) => {
failed = true;
console.error(
"[God's Eye View panel] render error:",
describeError(error),
);
},
);
const timer = windowRef.setInterval(() => { const timer = windowRef.setInterval(() => {
if ( if (
failed || viewer.useDefaultRenderLoop === false ||
now() - lastFrame < MISSED_FRAMES_MS || now() - lastFrame < MISSED_FRAMES_MS ||
viewer.isDestroyed?.() viewer.isDestroyed?.()
) )
return; return;
try {
viewer.resize(); viewer.resize();
viewer.render(); viewer.render();
} catch {
// Raised as a render error by now (src/ui/renderFailure.js), which
// stops this timer; thrown on, it would only reach the host page.
}
}, PANEL_FRAME_MS); }, PANEL_FRAME_MS);
return () => { return () => {
windowRef.clearInterval(timer); windowRef.clearInterval(timer);
windowRef.cancelAnimationFrame(frameRequest); windowRef.cancelAnimationFrame(frameRequest);
removeErrorListener?.();
}; };
} }
+8 -21
View File
@@ -457,8 +457,7 @@ test('a panel keeps drawing from a timer while animation frames stop', () => {
assert.equal(frame, null); assert.equal(frame, null);
}); });
test('a panel reports a render error in full and stops drawing', () => { test('a panel stops drawing while its render loop is stopped', () => {
let listener = null;
let tick = null; let tick = null;
const windowRef = { const windowRef = {
requestAnimationFrame: () => 1, requestAnimationFrame: () => 1,
@@ -467,31 +466,19 @@ test('a panel reports a render error in full and stops drawing', () => {
clearInterval() {}, clearInterval() {},
}; };
const drawn = []; const drawn = [];
const viewer = { const viewer = { resize() {}, render: () => drawn.push('render') };
resize() {},
render: () => drawn.push('render'),
scene: {
renderError: {
addEventListener: (fn) => ((listener = fn), () => (listener = null)),
},
},
};
const logged = [];
const original = console.error;
console.error = (...args) => logged.push(args.join(' '));
try {
let clock = 0; let clock = 0;
const stop = keepPanelRendering(viewer, { windowRef, now: () => clock }); const stop = keepPanelRendering(viewer, { windowRef, now: () => clock });
listener(viewer.scene, { name: 'RuntimeError', message: 'worker failed' }); // Cesium stops its render loop after a render error.
assert.match(logged[0], /render error: .*"message":"worker failed"/); viewer.useDefaultRenderLoop = false;
clock = 1000; clock = 1000;
tick(); tick();
assert.deepEqual(drawn, []); assert.deepEqual(drawn, []);
// Retry restarts it, and the timer draws again.
viewer.useDefaultRenderLoop = true;
tick();
assert.deepEqual(drawn, ['render']);
stop(); stop();
assert.equal(listener, null);
} finally {
console.error = original;
}
}); });
test('a followed aircraft owns the camera, and cockpit view is entered after it', async () => { test('a followed aircraft owns the camera, and cockpit view is entered after it', async () => {
+3
View File
@@ -16,9 +16,11 @@ 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,
@@ -38,6 +40,7 @@ export function createApplicationVessels({
worldFocus, worldFocus,
render, render,
readouts, readouts,
placement,
fleet: fleetModelServices({ fleet: fleetModelServices({
display: displayParams, display: displayParams,
...(resolveAsset ? { resolveAsset } : {}), ...(resolveAsset ? { resolveAsset } : {}),
+4 -1
View File
@@ -12,11 +12,13 @@ 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(/^\//, '')}`,
}) { }) {
@@ -41,6 +43,7 @@ export function createApplicationFlights({
render, render,
groundSnap, groundSnap,
recession, recession,
placement,
}, },
}); });
} }
+4 -1
View File
@@ -12,11 +12,13 @@ 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(/^\//, '')}`,
}) { }) {
@@ -41,6 +43,7 @@ export function createApplicationMilitary({
render, render,
groundSnap, groundSnap,
recession, recession,
placement,
}, },
}); });
} }
+4 -1
View File
@@ -16,13 +16,15 @@ 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.
* @param {{surface: object, source?: object, * `placement` is a host-supplied placement for its live trains, or null.
* @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,
}) { }) {
@@ -36,6 +38,7 @@ 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,
+4 -1
View File
@@ -23,13 +23,15 @@ 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.
* *
* @param {{surface: object, source?: object, * `placement` is a host-supplied placement for its live vehicles, or null.
* @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,
}) { }) {
@@ -43,6 +45,7 @@ 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,
+120
View File
@@ -0,0 +1,120 @@
// Retry in an inline panel whose host stops animation frames, so the panel
// draws from keepPanelRendering's timer. Drives Cesium's real startRenderLoop
// and useDefaultRenderLoop setter (CesiumWidget.prototype).
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { keepPanelRendering } from './embed.js';
import { renderLoopFixture } from '../ui/renderLoopFixture.mjs';
function setup() {
let tick = null;
let clock = 0;
// The host reports the panel hidden: its own animation frames never arrive.
const windowRef = {
requestAnimationFrame: () => 1,
cancelAnimationFrame() {},
setInterval: (fn) => ((tick = fn), 1),
clearInterval() {},
};
const previousError = console.error;
console.error = () => {};
const state = { failure: null, drawn: 0 };
const scene = {
renderError: new Cesium.Event(),
preUpdate: new Cesium.Event(),
postRender: new Cesium.Event(),
requestRender() {},
initializeFrame() {},
// Scene.render: a failing primitive update raises renderError
// (tryAndCatchError) and returns; a failing listener throws.
render() {
state.drawn += 1;
this.preUpdate.raiseEvent(this);
if (state.failure === 'caught')
this.renderError.raiseEvent(this, new Error('boom'));
if (state.failure === 'thrown') throw new Error('boom');
this.postRender.raiseEvent(this);
},
};
const { frames, widget, viewer, store, runFrames, restore } =
renderLoopFixture(scene, {
viewer: {
isDestroyed: () => false,
render: () => widget.render(),
resize: () => widget.resize(),
},
notice: { allowReload: false },
});
const stop = keepPanelRendering(viewer, { windowRef, now: () => clock });
viewer.useDefaultRenderLoop = true;
const timerFrame = () => {
clock += 400;
tick();
};
return {
frames,
state,
store,
timerFrame,
animationFrames: runFrames,
restore: () => {
stop();
restore();
console.error = previousError;
},
};
}
for (const failure of ['caught', 'thrown']) {
test(`an error ${failure} in a frame the timer draws: Retry draws again with one render loop`, () => {
const { frames, state, store, timerFrame, animationFrames, restore } =
setup();
try {
// The host stops animation frames with the loop's next one queued.
assert.equal(frames.length, 1);
state.failure = failure;
timerFrame(); // an error thrown here does not reach the host page
assert.equal(store.renderFailure?.reason, 'render-error');
assert.equal(typeof store.renderFailure.retry, 'function');
state.failure = null;
store.renderFailure.retry();
const before = state.drawn;
timerFrame();
assert.ok(state.drawn > before, 'the panel draws again after Retry');
assert.equal(
store.renderFailure,
null,
'a clean frame clears the notice',
);
assert.equal(frames.length, 1, 'Retry starts no second loop');
// The host resumes frames: the globe draws once a frame.
state.drawn = 0;
animationFrames(3);
assert.equal(state.drawn, 3);
} finally {
restore();
}
});
}
test('an error Scene caught in an animation frame right before the host stopped frames: Retry keeps one render loop', () => {
const { frames, state, store, timerFrame, animationFrames, restore } =
setup();
try {
state.failure = 'caught';
animationFrames(1);
assert.equal(store.renderFailure?.reason, 'render-error');
assert.equal(frames.length, 1);
state.failure = null;
store.renderFailure.retry();
timerFrame();
assert.equal(store.renderFailure, null);
assert.equal(frames.length, 1, 'Retry starts no second loop');
state.drawn = 0;
animationFrames(3);
assert.equal(state.drawn, 3);
} finally {
restore();
}
});
+8
View File
@@ -15,6 +15,8 @@ import {
governorRequestRender, governorRequestRender,
} from '../renderGovernor.js'; } from '../renderGovernor.js';
import { describeError } from './errors.js'; import { describeError } from './errors.js';
import { isEmbeddedInline } from './embed.js';
import { installRenderFailureNotice } from '../ui/renderFailure.js';
import { worldClock, worldTimeFor } from '../time/worldClock.js'; import { worldClock, worldTimeFor } from '../time/worldClock.js';
import { followWorldTime } from '../time/viewerClock.js'; import { followWorldTime } from '../time/viewerClock.js';
import { onLocaleChange, t } from '../i18n/index.js'; import { onLocaleChange, t } from '../i18n/index.js';
@@ -55,11 +57,17 @@ export async function createApplicationScene({
const viewer = createApplicationViewer({ const viewer = createApplicationViewer({
container: 'cesiumContainer', container: 'cesiumContainer',
creditContainer, creditContainer,
// The render failure notice below replaces Cesium's dead-end panel.
showRenderLoopErrors: false,
}); });
defer(() => { defer(() => {
uninstallRenderGovernor(viewer); uninstallRenderGovernor(viewer);
if (!viewer.isDestroyed()) viewer.destroy(); if (!viewer.isDestroyed()) viewer.destroy();
}); });
// An inline panel must not reload or navigate its host's page.
defer(
installRenderFailureNotice(viewer, { allowReload: !isEmbeddedInline() }),
);
defer(installTrackpadPinchZoom(viewer)); defer(installTrackpadPinchZoom(viewer));
defer( defer(
followWorldTime(viewer, worldTimeFor('sky'), { followWorldTime(viewer, worldTimeFor('sky'), {
+16 -4
View File
@@ -375,8 +375,15 @@ export function installGlobeInViewRecovery(
}; };
} }
/** Cesium.Viewer options of the standard globe viewer. */ /**
export function applicationViewerOptions(creditContainer) { * Cesium.Viewer options of the standard globe viewer. `showRenderLoopErrors`
* false leaves render errors to the caller's own notice instead of Cesium's
* panel.
*/
export function applicationViewerOptions(
creditContainer,
{ showRenderLoopErrors = true } = {},
) {
return { return {
timeline: false, timeline: false,
animation: false, animation: false,
@@ -396,16 +403,21 @@ export function applicationViewerOptions(creditContainer) {
creditContainer, creditContainer,
msaaSamples: 4, msaaSamples: 4,
contextOptions: { webgl: { preserveDrawingBuffer: true } }, contextOptions: { webgl: { preserveDrawingBuffer: true } },
showRenderLoopErrors,
}; };
} }
/** Create the standard globe viewer in caller-owned, visible containers. */ /** Create the standard globe viewer in caller-owned, visible containers. */
export function createApplicationViewer({ container, creditContainer }) { export function createApplicationViewer({
container,
creditContainer,
showRenderLoopErrors,
}) {
if (!container || !creditContainer) if (!container || !creditContainer)
throw new TypeError('Viewer and credit containers are required'); throw new TypeError('Viewer and credit containers are required');
const viewer = new Cesium.Viewer( const viewer = new Cesium.Viewer(
container, container,
applicationViewerOptions(creditContainer), applicationViewerOptions(creditContainer, { showRenderLoopErrors }),
); );
try { try {
viewer.targetFrameRate = 60; viewer.targetFrameRate = 60;
+41 -20
View File
@@ -26,10 +26,7 @@ import {
setSignalCollapsed, setSignalCollapsed,
} from './ui/cockpitLayout.js'; } from './ui/cockpitLayout.js';
import { createBriefState } from './ui/cockpitBriefPages.js'; import { createBriefState } from './ui/cockpitBriefPages.js';
import { import { publishBriefView, setBriefAutoRotate } from './ui/cockpitBriefing.js';
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';
@@ -438,13 +435,10 @@ 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*<\/aside>/, /id="cockpit-radio-panel"[\s\S]*?<\/div>\s*<\/div>\s*<div class="cockpit-utility-rail/,
); );
assert.ok(cockpitRadio, 'Cockpit compact Radio controls are missing'); assert.ok(cockpitRadio, 'Cockpit compact Radio controls are missing');
assert.doesNotMatch( assert.doesNotMatch(cockpitRadio[0], /radio-filter|radio-tuner/);
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/,
@@ -530,12 +524,12 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls',
); );
}); });
test('Display orders 3D above Celestial, Clean UI below it, and Parameters below Detection', () => { test('Visual orders Parameters below Detection, 3D below them, and Clean UI above the Effects', () => {
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="celestial-toggle"[\s\S]*?id="clean-view-toggle"/, /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"/,
); );
assert.equal( assert.equal(
(html.match(/id="param-slider-panel"/g) || []).length, (html.match(/id="param-slider-panel"/g) || []).length,
@@ -811,18 +805,27 @@ 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(controls, /reset\(\) \{\s*if \(!this\.destroyed\) return this\.onReset\(\);/); assert.match(
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(globeActions, /id="reset-globe-view"[^>]*@click="act\('reset'\)"/); assert.match(
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(cockpitView, /id="cockpit-reset-globe"[^>]*@click="globe\.act\('reset'\)"/); assert.match(
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,/,
@@ -1193,7 +1196,10 @@ 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(match[0], /class="cockpit-card-kicker cockpit-context-kicker">CONTACT</); assert.match(
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],
@@ -1408,15 +1414,30 @@ 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) => ({ classList: { contains: (name) => classes.includes(name) } }); const panel = (...classes) => ({
classList: { contains: (name) => classes.includes(name) },
});
try { try {
const otherOpen = (id) => PanelChrome.prototype._otherCockpitLeftPanelOpen(id); const otherOpen = (id) =>
PanelChrome.prototype._otherCockpitLeftPanelOpen(id);
panels['data-panel'] = panel('collapsed'); panels['data-panel'] = panel('collapsed');
assert.equal(otherOpen('utilities-panel'), false, 'Data Layers closed: restore'); assert.equal(
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(otherOpen('utilities-panel'), true, 'Data Layers comes back: keep Contact folded'); assert.equal(
otherOpen('utilities-panel'),
true,
'Data Layers comes back: keep Contact folded',
);
panels['data-panel'] = panel(); panels['data-panel'] = panel();
assert.equal(otherOpen('utilities-panel'), true, 'Data Layers open: keep it folded'); assert.equal(
otherOpen('utilities-panel'),
true,
'Data Layers open: keep it folded',
);
} finally { } finally {
globalThis.document = previous; globalThis.document = previous;
} }
+68 -2
View File
@@ -32,8 +32,15 @@ export function slewHeading(current, target, maxStepDeg) {
* then uses this amount to converge on the layer's delayed display position. * then uses this amount to converge on the layer's delayed display position.
* Capping correction below forward speed prevents a late feed fix from * Capping correction below forward speed prevents a late feed fix from
* becoming a first-person surge or reversal while still removing drift. * becoming a first-person surge or reversal while still removing drift.
* @param {number} [maxDtSec] The longest step corrected for: 0.1 s, or an
* application subject's own (cockpitSubjectStepSec).
*/ */
export function cockpitAnchorCorrectionStep(distanceM, speedMps, dtSec) { export function cockpitAnchorCorrectionStep(
distanceM,
speedMps,
dtSec,
maxDtSec = 0.1,
) {
if ( if (
!Number.isFinite(distanceM) || !Number.isFinite(distanceM) ||
distanceM <= 0 || distanceM <= 0 ||
@@ -41,13 +48,36 @@ export function cockpitAnchorCorrectionStep(distanceM, speedMps, dtSec) {
dtSec <= 0 dtSec <= 0
) )
return 0; return 0;
const dt = Math.min(0.1, dtSec); const dt = Math.min(
Number.isFinite(maxDtSec) && maxDtSec > 0 ? maxDtSec : 0.1,
dtSec,
);
const speed = Number.isFinite(speedMps) ? Math.max(0, speedMps) : 0; const speed = Number.isFinite(speedMps) ? Math.max(0, speedMps) : 0;
const eased = distanceM * (1 - Math.exp(-1.25 * dt)); const eased = distanceM * (1 - Math.exp(-1.25 * dt));
const correctionRateMps = Math.max(0.75, speed * 0.22); const correctionRateMps = Math.max(0.75, speed * 0.22);
return Math.min(distanceM, eased, correctionRateMps * dt); return Math.min(distanceM, eased, correctionRateMps * dt);
} }
/**
* The seconds an application subject's camera update advances its heading
* slew, ground lift and anchor by: the time since the last update, at most
* its update interval plus 0.1 s. A subject updated every 500 ms then turns
* and eases at its configured rates, while a long stall (a hidden tab) still
* moves it no further than one late update would.
* @param {number} elapsedMs Time since the last camera update.
* @param {number} intervalMs The subject's update interval (0: every frame).
* @returns {number}
*/
export function cockpitSubjectStepSec(elapsedMs, intervalMs) {
const elapsedSec = Number.isFinite(elapsedMs)
? Math.max(0, elapsedMs) / 1000
: 0;
const intervalSec = Number.isFinite(intervalMs)
? Math.max(0, intervalMs) / 1000
: 0;
return Math.min(intervalSec + 0.1, elapsedSec);
}
/** Return whether a throttled cockpit presentation update is due. */ /** Return whether a throttled cockpit presentation update is due. */
export function cockpitUiUpdateDue(nowMs, lastUpdateMs, intervalMs) { export function cockpitUiUpdateDue(nowMs, lastUpdateMs, intervalMs) {
if (!Number.isFinite(nowMs) || !Number.isFinite(intervalMs) || intervalMs < 0) if (!Number.isFinite(nowMs) || !Number.isFinite(intervalMs) || intervalMs < 0)
@@ -68,6 +98,42 @@ export function cockpitSurfaceWaitExpired(nowMs, startedMs, timeoutMs = 5000) {
return nowMs - startedMs >= timeoutMs; return nowMs - startedMs >= timeoutMs;
} }
/**
* How fast the clearance a ground clamp adds under an application subject's
* eye comes and goes (m/s).
*/
export const COCKPIT_SUBJECT_LIFT_MPS = 1.5;
/**
* The lift the ground clamp gives an application subject's eye this update.
* The floor is read from coarse cells that refine as tiles load, so the lift
* it asks for jumps; a ridden eye takes the clearance part of it at
* `rateMps`, while what it needs to stay out of the floor itself (`hardM`)
* applies at once.
* @param {number|null} previousM The lift given last update; null on entry.
* @param {number} targetM The lift the floor and clearance ask for (m).
* @param {number} hardM The lift that keeps the eye out of the floor (m).
* @param {number} dtSec Seconds since the last update.
* @param {number} [rateMps]
* @returns {number} At least 0, at least `hardM`.
*/
export function easeCockpitLift(
previousM,
targetM,
hardM,
dtSec,
rateMps = COCKPIT_SUBJECT_LIFT_MPS,
) {
const target = Number.isFinite(targetM) ? Math.max(0, targetM) : 0;
const hard = Number.isFinite(hardM) ? Math.max(0, hardM) : 0;
if (!Number.isFinite(previousM)) return Math.max(target, hard);
const step =
Math.max(0, Number.isFinite(rateMps) ? rateMps : 0) *
Math.max(0, Number.isFinite(dtSec) ? dtSec : 0);
const eased = previousM + Math.max(-step, Math.min(step, target - previousM));
return Math.max(0, hard, eased);
}
/** /**
* Keep the cockpit camera above the shared rendered-surface floor. * Keep the cockpit camera above the shared rendered-surface floor.
* Unknown floors preserve the proposed camera height. * Unknown floors preserve the proposed camera height.
+52
View File
@@ -8,8 +8,11 @@ import {
cockpitAnchorCorrectionStep, cockpitAnchorCorrectionStep,
cockpitAltitudeDisplayFt, cockpitAltitudeDisplayFt,
cockpitGroundSafeHeight, cockpitGroundSafeHeight,
cockpitSubjectStepSec,
cockpitSurfaceWaitExpired, cockpitSurfaceWaitExpired,
cockpitUiUpdateDue, cockpitUiUpdateDue,
COCKPIT_SUBJECT_LIFT_MPS,
easeCockpitLift,
compassDivisions, compassDivisions,
compassMarkerSlot, compassMarkerSlot,
scrollingCompassDivisions, scrollingCompassDivisions,
@@ -56,6 +59,28 @@ test('cockpit anchor correction bounds late-fix catch-up and render stalls', ()
assert.equal(cockpitAnchorCorrectionStep(Number.NaN, 250, 0.1), 0); assert.equal(cockpitAnchorCorrectionStep(Number.NaN, 250, 0.1), 0);
}); });
test('an application subject on a slow cadence is corrected for its whole step', () => {
// The built-in views' step stays capped at 0.1 s...
const capped = cockpitAnchorCorrectionStep(100, 0, 0.5);
assert.equal(capped, cockpitAnchorCorrectionStep(100, 0, 0.1));
// ...while a subject updated every 500 ms is corrected for all of it.
const whole = cockpitAnchorCorrectionStep(100, 0, 0.5, 0.5);
assert.ok(Math.abs(whole / capped - 5) < 1e-9);
assert.equal(cockpitAnchorCorrectionStep(100, 0, 0.5, Number.NaN), capped);
});
test('an application subject steps by its interval, never by a long stall', () => {
assert.equal(cockpitSubjectStepSec(16, 0), 0.016);
assert.equal(cockpitSubjectStepSec(500, 500), 0.5);
assert.equal(cockpitSubjectStepSec(520, 500), 0.52);
// A hidden tab's minutes count as one late update.
assert.equal(cockpitSubjectStepSec(60_000, 500), 0.6);
assert.equal(cockpitSubjectStepSec(60_000, 0), 0.1);
assert.equal(cockpitSubjectStepSec(-5, 500), 0);
assert.equal(cockpitSubjectStepSec(Number.NaN, 500), 0);
assert.equal(cockpitSubjectStepSec(1000, Number.NaN), 0.1);
});
test('cockpit camera clearance never falls below the rendered ground floor', () => { test('cockpit camera clearance never falls below the rendered ground floor', () => {
assert.equal(cockpitGroundSafeHeight(47, 57, 12), 69); assert.equal(cockpitGroundSafeHeight(47, 57, 12), 69);
assert.equal(cockpitGroundSafeHeight(1200, 57, 12), 1200); assert.equal(cockpitGroundSafeHeight(1200, 57, 12), 1200);
@@ -435,3 +460,30 @@ test('cockpit emergency wording states the broadcast code, not a conclusion', ()
assert.equal(formatCockpitEmergency(null), null); assert.equal(formatCockpitEmergency(null), null);
assert.equal(formatCockpitEmergency({ label: '' }), null); assert.equal(formatCockpitEmergency({ label: '' }), null);
}); });
test('an application subject’s ground lift eases its clearance, never the floor', () => {
// On entry the lift asked for applies at once.
assert.equal(easeCockpitLift(null, 1.5, 0, 0.016), 1.5);
// A floor cell that rises by a metre is met at the lift rate...
let lift = 0;
const frames = [];
for (let i = 0; i < 60; i += 1) {
lift = easeCockpitLift(lift, 1, 0, 0.016);
frames.push(lift);
}
assert.ok(frames[0] <= COCKPIT_SUBJECT_LIFT_MPS * 0.016 + 1e-9);
for (let i = 1; i < frames.length; i += 1)
assert.ok(
frames[i] - frames[i - 1] <= COCKPIT_SUBJECT_LIFT_MPS * 0.016 + 1e-9,
);
assert.ok(Math.abs(frames.at(-1) - 1) < 1e-9, 'reached in under a second');
// ...and given back the same way.
assert.ok(Math.abs(easeCockpitLift(1, 0, 0, 0.1) - 0.85) < 1e-9);
// What keeps the eye out of the floor itself is never eased.
assert.equal(easeCockpitLift(0, 201.5, 200, 0.016), 200);
assert.equal(
easeCockpitLift(0.2, -3, -3, 0.016),
0.2 - COCKPIT_SUBJECT_LIFT_MPS * 0.016,
);
assert.equal(easeCockpitLift(0, Number.NaN, Number.NaN, 0.016), 0);
});
+182
View File
@@ -0,0 +1,182 @@
/**
* @module cockpitProgress
* @description Lay out a subject's progress line (cockpitSubjectProviders.js)
* at a width in pixels: where each tick, label and the marker go.
*
* Ticks sit at fractions of the line, 0 at the left. Ticks closer than
* `minTickPx` merge, so a long run still reads as a line: a labelled tick
* keeps its place and takes in the bare ticks beside it, and bare ticks merge
* with each other at their mean place. A merged tick takes the strongest tone
* of those it holds. Labels never overlap: where two would, the one of lower
* priority is left out (on a tie, the later one).
*
* Pure: no DOM.
*/
/** Bare ticks closer than this merge (px). */
export const COCKPIT_PROGRESS_MIN_TICK_PX = 6;
/** Room kept between two labels (px). */
export const COCKPIT_PROGRESS_LABEL_GAP_PX = 8;
/** Tones from weakest to strongest, for a merged tick. */
const TONE_RANK = Object.freeze([
'muted',
'normal',
'live',
'estimate',
'warning',
'alert',
]);
const stronger = (a, b) =>
TONE_RANK.indexOf(b) > TONE_RANK.indexOf(a) ? b : a;
/** A label's width estimate (px): its longer line in small capitals. */
export function estimateProgressLabelPx(label) {
const longest = Math.max(
String(label?.text ?? '').length,
String(label?.detail ?? '').length,
);
return longest * 6.4 + 8;
}
/**
* The progress line at `widthPx`, or null when there is nothing to draw.
* @param {{ticks: Array<{at: number, tone: string, label: object|null}>,
* marker: {at: number, dwell: boolean}|null, tag: string}|null} progress
* As cockpitSubjectHudView gives it: ticks sorted by `at`.
* @param {number} widthPx
* @param {{minTickPx?: number, gapPx?: number,
* measure?: (label: object) => number}} [options]
* @returns {{ticks: Array<{x: number, tone: string, count: number,
* labelled: boolean}>, labels: Array<{x: number, left: number,
* width: number, align: string, text: string, detail: string,
* tone: string}>, marker: {x: number, dwell: boolean}|null,
* travelledX: number, tag: string}|null}
*/
export function cockpitProgressLayout(
progress,
widthPx,
{
minTickPx = COCKPIT_PROGRESS_MIN_TICK_PX,
gapPx = COCKPIT_PROGRESS_LABEL_GAP_PX,
measure = estimateProgressLabelPx,
} = {},
) {
const ticks = progress?.ticks;
if (!Array.isArray(ticks) || ticks.length < 2) return null;
if (!Number.isFinite(widthPx) || widthPx <= 0) return null;
const placed = ticks.map((tick, order) => ({
x: tick.at * widthPx,
tone: tick.tone,
label: tick.label,
order,
}));
const labelled = placed.filter((tick) => tick.label);
// Bare ticks beside a labelled one go into it.
const kept = labelled.map((tick) => ({
x: tick.x,
tone: tick.tone,
count: 1,
labelled: true,
label: tick.label,
order: tick.order,
}));
const bare = [];
for (const tick of placed) {
if (tick.label) continue;
const host = kept.find((entry) => Math.abs(entry.x - tick.x) < minTickPx);
if (host) {
host.tone = stronger(host.tone, tick.tone);
host.count += 1;
} else bare.push(tick);
}
// Bare ticks in a chain closer than minTickPx merge at their mean place.
let cluster = null;
const flush = () => {
if (!cluster) return;
kept.push({
x: cluster.sum / cluster.count,
tone: cluster.tone,
count: cluster.count,
labelled: false,
label: null,
order: cluster.order,
});
cluster = null;
};
for (const tick of bare) {
if (cluster && tick.x - cluster.last < minTickPx) {
cluster.sum += tick.x;
cluster.count += 1;
cluster.last = tick.x;
cluster.tone = stronger(cluster.tone, tick.tone);
} else {
flush();
cluster = {
sum: tick.x,
count: 1,
last: tick.x,
tone: tick.tone,
order: tick.order,
};
}
}
flush();
kept.sort((a, b) => a.x - b.x || a.order - b.order);
// Labels by priority; one that would overlap a placed label is left out.
const boxes = [];
const candidates = kept
.filter((tick) => tick.labelled)
.sort((a, b) => b.label.priority - a.label.priority || a.order - b.order);
for (const tick of candidates) {
const width = Math.min(widthPx, Math.max(0, measure(tick.label)));
let left =
tick.label.align === 'start'
? tick.x
: tick.label.align === 'end'
? tick.x - width
: tick.x - width / 2;
left = Math.min(Math.max(0, left), widthPx - width);
const right = left + width;
if (
boxes.some((box) => left < box.right + gapPx && right > box.left - gapPx)
)
continue;
boxes.push({
x: tick.x,
left,
right,
width,
align: tick.label.align,
text: tick.label.text,
detail: tick.label.detail,
tone: tick.tone,
order: tick.order,
});
}
boxes.sort((a, b) => a.left - b.left);
const marker = progress.marker
? { x: progress.marker.at * widthPx, dwell: progress.marker.dwell }
: null;
return {
ticks: kept.map(({ x, tone, count, labelled: isLabelled }) => ({
x,
tone,
count,
labelled: isLabelled,
})),
labels: boxes.map(({ x, left, width, align, text, detail, tone }) => ({
x,
left,
width,
align,
text,
detail,
tone,
})),
marker,
travelledX: marker ? marker.x : 0,
tag: progress.tag || '',
};
}
+144
View File
@@ -0,0 +1,144 @@
// src/cockpitProgress.test.mjs — a subject's progress line laid out at a
// width: close bare ticks merge, labelled ticks keep their place, labels never
// overlap (the lower priority gives way) and the part before the marker is
// the travelled part.
import test from 'node:test';
import assert from 'node:assert/strict';
import { cockpitProgressLayout } from './cockpitProgress.js';
const tick = (at, label = null, tone = 'normal') => ({ at, tone, label });
const label = (text, priority, align = 'center', detail = '') => ({
text,
detail,
priority,
align,
});
/** Every label is 50 px wide. */
const measure = () => 50;
test('nothing is drawn without two ticks or a width', () => {
assert.equal(cockpitProgressLayout(null, 500), null);
assert.equal(cockpitProgressLayout({ ticks: [tick(0)] }, 500), null);
assert.equal(
cockpitProgressLayout({ ticks: [tick(0), tick(1)], marker: null }, 0),
null,
);
});
test('ticks closer than the minimum spacing merge, keeping the strongest tone', () => {
const layout = cockpitProgressLayout(
{
ticks: [
tick(0, label('A', 1, 'start')),
tick(0.1),
tick(0.101, null, 'warning'),
tick(0.102),
tick(0.5),
tick(1, label('Z', 1, 'end')),
],
marker: { at: 0.3, dwell: false },
tag: 'TIMETABLE',
},
1000,
{ measure },
);
assert.deepEqual(
layout.ticks.map(({ x, count, tone, labelled }) => [
Math.round(x),
count,
tone,
labelled,
]),
[
[0, 1, 'normal', true],
[101, 3, 'warning', false],
[500, 1, 'normal', false],
[1000, 1, 'normal', true],
],
);
assert.deepEqual(layout.marker, { x: 300, dwell: false });
assert.equal(layout.travelledX, 300);
assert.equal(layout.tag, 'TIMETABLE');
});
test('a labelled tick keeps its place and takes in the bare ticks beside it', () => {
const layout = cockpitProgressLayout(
{
ticks: [
tick(0, label('A', 1, 'start')),
tick(0.498),
tick(0.5, label('Next', 3), 'alert'),
tick(0.503, null, 'warning'),
tick(1, label('Z', 1, 'end')),
],
marker: null,
},
1000,
{ measure },
);
const next = layout.ticks.find(({ x }) => Math.round(x) === 500);
assert.equal(next.count, 3);
assert.equal(next.labelled, true);
assert.equal(next.tone, 'alert');
assert.equal(layout.ticks.length, 3);
assert.equal(layout.marker, null);
assert.equal(layout.travelledX, 0);
});
test('labels hang from their ticks inside the line, and overlaps give way by priority', () => {
const layout = cockpitProgressLayout(
{
ticks: [
tick(0, label('First', 1, 'start')),
tick(0.04, label('Previous', 2)),
tick(0.5, label('Next', 3)),
tick(0.52, label('Close', 0)),
tick(1, label('Last', 1, 'end')),
],
marker: { at: 0.2, dwell: true },
},
1000,
{ measure },
);
assert.deepEqual(
layout.labels.map(({ text, left }) => [text, Math.round(left)]),
[
['Previous', 15],
['Next', 475],
['Last', 950],
],
);
// The first stop's label would cover the previous stop's: it gives way.
assert.ok(!layout.labels.some(({ text }) => text === 'First'));
assert.ok(!layout.labels.some(({ text }) => text === 'Close'));
assert.deepEqual(layout.marker, { x: 200, dwell: true });
});
test('a centred label at an edge is clamped inside the line', () => {
const layout = cockpitProgressLayout(
{ ticks: [tick(0, label('Edge', 1)), tick(1)], marker: null },
300,
{ measure },
);
assert.equal(layout.labels[0].left, 0);
const right = cockpitProgressLayout(
{ ticks: [tick(0), tick(1, label('Edge', 1))], marker: null },
300,
{ measure },
);
assert.equal(right.labels[0].left, 250);
});
test('a long run of calls still lays out, every one a tick or part of one', () => {
const ticks = Array.from({ length: 60 }, (_, i) => tick(i / 59));
ticks[0].label = label('First', 1, 'start');
ticks[59].label = label('Last', 1, 'end');
const narrow = cockpitProgressLayout({ ticks, marker: null }, 200);
const wide = cockpitProgressLayout({ ticks, marker: null }, 2000);
const count = (layout) =>
layout.ticks.reduce((sum, entry) => sum + entry.count, 0);
assert.equal(count(narrow), 60);
assert.equal(count(wide), 60);
assert.ok(narrow.ticks.length < wide.ticks.length);
assert.equal(wide.ticks.length, 60);
});
+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); assert.equal(status.querySelector('button') === null, true);
}); });
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); assert.equal(f.signalTab().closest('[hidden]') === null, true);
// 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);
+644
View File
@@ -0,0 +1,644 @@
/**
* @module cockpitSubjectProviders
* @description Subject providers: kinds of subject an application adds to the
* Cockpit beside its own aircraft, satellite and vessel. A provider names the
* subject entry would take, holds it while the view rides it, hands the camera
* a frame each update and the HUD a view model, and releases it on exit. Core
* knows nothing else about it.
*
* Everything a provider returns is normalized here before the Cockpit uses
* it: a malformed provider is left out, a malformed frame ends the view, and a
* malformed HUD view is read field by field, never thrown on.
*
* Pure: no Cesium, no DOM.
*/
/** The kinds the Cockpit rides itself; no provider may take one. */
export const COCKPIT_BUILT_IN_KINDS = Object.freeze([
'aircraft',
'satellite',
'vessel',
]);
/** The tones a provider's HUD texts may take. */
export const COCKPIT_SUBJECT_TONES = Object.freeze([
'normal',
'muted',
'live',
'estimate',
'warning',
'alert',
]);
/**
* The heights a frame may give (m, ellipsoidal): below the deepest dry
* ground, above anything ridden near it; outside, the frame stands on the
* floor.
*/
export const COCKPIT_SUBJECT_MIN_HEIGHT_M = -1_000;
export const COCKPIT_SUBJECT_MAX_HEIGHT_M = 50_000;
/** The most readouts, sources and signals a HUD view shows. */
export const COCKPIT_SUBJECT_MAX_READOUTS = 4;
export const COCKPIT_SUBJECT_MAX_SOURCES = 4;
export const COCKPIT_SUBJECT_MAX_SIGNALS = 4;
/** The most ticks a progress line takes. */
export const COCKPIT_SUBJECT_MAX_TICKS = 512;
const KIND_PATTERN = /^[a-z][a-z0-9-]{1,31}$/;
const LAYER_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
const ICON_PATTERN = /^[a-z0-9_]{1,40}$/;
const ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,39}$/;
const REQUIRED_METHODS = Object.freeze([
'candidate',
'hold',
'read',
'hud',
'release',
]);
const RESERVED = new Set(COCKPIT_BUILT_IN_KINDS);
const TONES = new Set(COCKPIT_SUBJECT_TONES);
const isObject = (value) =>
value !== null && typeof value === 'object' && !Array.isArray(value);
const finite = (value) => typeof value === 'number' && Number.isFinite(value);
const clamp = (value, low, high) => Math.min(high, Math.max(low, value));
/**
* `value` as display text: trimmed, at most `max` characters (the last one
* an ellipsis when cut, the cut never inside a surrogate pair, so an emoji is
* kept whole or left out); '' for anything that is not a string or a number.
*/
export function cockpitSubjectText(value, max) {
if (typeof value !== 'string' && !finite(value)) return '';
const text = String(value).replace(/\s+/g, ' ').trim();
if (text.length <= max) return text;
let end = max - 1;
const last = text.charCodeAt(end - 1);
if (last >= 0xd800 && last <= 0xdbff) end -= 1;
return `${text.slice(0, end)}…`;
}
const tone = (value) => (TONES.has(value) ? value : 'normal');
/** A Material Symbols entry face, or null. */
function faceOf(raw) {
if (!isObject(raw)) return null;
const label = cockpitSubjectText(raw.label, 16);
if (typeof raw.icon !== 'string' || !ICON_PATTERN.test(raw.icon) || !label)
return null;
return Object.freeze({ icon: raw.icon, label });
}
/** The labels a provider gives, normalized, or null without the required. */
function labelsOf(raw) {
if (!isObject(raw)) return null;
const labels = {
view: cockpitSubjectText(raw.view, 48),
exit: cockpitSubjectText(raw.exit, 48),
exitText: cockpitSubjectText(raw.exitText, 24),
kicker: cockpitSubjectText(raw.kicker, 48),
};
return labels.view && labels.exit && labels.exitText
? Object.freeze(labels)
: null;
}
/** A provider's look, normalized, or null without an aheadLabel. */
function lookOf(raw) {
if (!isObject(raw)) return null;
const aheadLabel = cockpitSubjectText(raw.aheadLabel, 64);
if (!aheadLabel) return null;
return Object.freeze({
restPitchDeg: finite(raw.restPitchDeg)
? clamp(raw.restPitchDeg, -90, 90)
: 0,
aheadLabel,
});
}
/**
* A provider's worded member `key` as it reads now, normalized: the value,
* or what the function it is returns when called on `provider` (so it can
* follow the page's language); null when it is malformed or a read throws.
* Reading the member, and reading what it gives, runs provider code too (a
* getter), so both are guarded as the call is.
*/
function wordedNow(provider, key, normalize) {
try {
const value = provider[key];
return (
normalize(typeof value === 'function' ? value.call(provider) : value) ??
null
);
} catch {
return null;
}
}
/** A provider's name in a warning: its kind, cut, or its place in the list. */
function providerName(provider, order) {
try {
if (typeof provider?.kind === 'string') return provider.kind.slice(0, 32);
} catch {
// A kind getter that throws gives no name.
}
return `#${order}`;
}
/** Why `provider` cannot be kept, or null. */
function refusal(provider, kinds) {
if (!isObject(provider)) return 'not an object';
if (typeof provider.kind !== 'string' || !KIND_PATTERN.test(provider.kind))
return 'malformed kind';
if (RESERVED.has(provider.kind)) return 'reserved kind';
if (kinds.has(provider.kind)) return 'repeated kind';
if (
typeof provider.layerId !== 'string' ||
!LAYER_ID_PATTERN.test(provider.layerId)
)
return 'malformed layer id';
for (const method of REQUIRED_METHODS)
if (typeof provider[method] !== 'function') return `no ${method}()`;
if (
provider.isEntryAllowed !== undefined &&
typeof provider.isEntryAllowed !== 'function'
)
return 'isEntryAllowed is not a function';
if (!wordedNow(provider, 'entryFace', faceOf)) return 'malformed entry face';
if (!wordedNow(provider, 'labels', labelsOf))
return 'labels need view, exit and exitText';
if (provider.look != null && !wordedNow(provider, 'look', lookOf))
return 'look needs an aheadLabel';
return null;
}
/**
* The application's subject providers, checked once and frozen, in the order
* entry asks them: ascending `priority`, ties in list order. Each has
* - `kind`: /^[a-z][a-z0-9-]{1,31}$/, not a built-in kind, unique;
* - `layerId`: the catalog layer the subject belongs to (entry needs it on);
* - `priority`: a finite number (default 0);
* - `entryFace`: `{icon, label}`, a Material Symbols name and a label of at
* most 16 characters, in the page's language;
* - `labels`: `{view, exit, exitText, kicker?}`, the HUD's and the exit
* button's accessible names, the exit button's text and the default kicker,
* in the page's language;
* - `look` (optional): `{restPitchDeg?, aheadLabel}`, arrow-key look-around as
* on a bridge, resting at `restPitchDeg` (default 0, -90..90), its Look
* ahead button named `aheadLabel`;
* `entryFace`, `labels` and `look` may each be a function returning the
* object: it is called on the provider again whenever they are shown and
* when the page's language changes, so its words can follow it
* (`./i18n`). A read that throws or is malformed keeps the last
* good one; `look` is on or off and `restPitchDeg` fixed as first read;
* - `cameraIntervalMs` (optional): 0..1000, null for the Cockpit's default; 0
* updates the camera every rendered frame;
* - `headingSlewDps` (optional): how fast the view turns to a new heading,
* positive or Infinity, null for the Cockpit's default;
* - `candidate()`, `isEntryAllowed(candidate)` (optional), `hold(id)`,
* `read(nowMs, ctx)`, `hud(frame, ctx)` and `release(id, {restore})`, all
* synchronous (createCockpitSubjectCaller) and called on the provider, so
* a class instance's methods keep their `this`. `isEntryAllowed` allows
* entry only by returning `true`. `hold` returns `true` once it holds the
* subject, or `false` to refuse entry; any other answer refuses it too,
* with one warning. A refused hold, and one that throws or returns a
* promise, is followed by `release(id, {restore: false})`, and by another
* once that promise settles; `release` must therefore be harmless for a
* subject it does not hold or already gave back.
*
* A provider that is not so, or whose members throw when read (a getter), is
* left out with one warning naming it; the providers after it are kept.
* @param {unknown} list
* @param {{warn?: (message: string) => void}} [options]
* @returns {ReadonlyArray<object>}
*/
export function cockpitSubjectProviders(
list = [],
{ warn = (message) => console.warn(message) } = {},
) {
const kept = [];
const kinds = new Set();
(Array.isArray(list) ? list : []).forEach((provider, order) => {
let reason;
let copy = null;
try {
reason = refusal(provider, kinds);
if (!reason) copy = keptProvider(provider, order);
} catch {
reason = 'a member threw when read';
}
if (reason) {
warn(
`[Cockpit] subject provider ${providerName(provider, order)} left out: ${reason}`,
);
return;
}
kinds.add(copy.kind);
kept.push(copy);
});
kept.sort((a, b) => a.priority - b.priority || a.order - b.order);
return Object.freeze(kept);
}
/** The frozen copy the Cockpit keeps of a provider `refusal` passed. */
function keptProvider(provider, order) {
// Worded members are read again on each use (see above); the last good
// read stands in for one that is malformed or throws.
let face = wordedNow(provider, 'entryFace', faceOf);
let labels = wordedNow(provider, 'labels', labelsOf);
// On or off, and its rest pitch, as first read.
let look = wordedNow(provider, 'look', lookOf);
const restPitchDeg = look?.restPitchDeg ?? 0;
return Object.freeze({
kind: provider.kind,
layerId: provider.layerId,
priority: finite(provider.priority) ? provider.priority : 0,
order,
get entryFace() {
face = wordedNow(provider, 'entryFace', faceOf) ?? face;
return face;
},
get labels() {
labels = wordedNow(provider, 'labels', labelsOf) ?? labels;
return labels;
},
get look() {
if (!look) return null;
const now = wordedNow(provider, 'look', lookOf);
if (now)
look = Object.freeze({ restPitchDeg, aheadLabel: now.aheadLabel });
return look;
},
cameraIntervalMs:
finite(provider.cameraIntervalMs) &&
provider.cameraIntervalMs >= 0 &&
provider.cameraIntervalMs <= 1000
? provider.cameraIntervalMs
: null,
headingSlewDps:
provider.headingSlewDps === Infinity ||
(finite(provider.headingSlewDps) && provider.headingSlewDps > 0)
? provider.headingSlewDps
: null,
// Bound, so a method that uses `this` (a class instance's, say) still
// reads its own provider when called on this copy.
candidate: provider.candidate.bind(provider),
isEntryAllowed: provider.isEntryAllowed
? provider.isEntryAllowed.bind(provider)
: () => true,
hold: provider.hold.bind(provider),
read: provider.read.bind(provider),
hud: provider.hud.bind(provider),
release: provider.release.bind(provider),
});
}
/**
* A provider's candidate: `{id, name, face, selectedAt}`, or null without a
* usable id. `name` falls back to the id; `face` is null unless well formed;
* `selectedAt` (a number or a short string, else null) is a token the
* provider changes each time its subject is selected, even again: a
* subject selected after the built-in ones takes entry (entryCandidates).
* Null too when reading `raw` throws (a getter runs provider code).
* @param {unknown} raw What `candidate()` returned.
*/
export function cockpitSubjectCandidate(raw) {
try {
return candidateOf(raw);
} catch {
// A getter on what candidate() returned threw: no candidate.
return null;
}
}
function candidateOf(raw) {
if (!isObject(raw) || typeof raw.id !== 'string') return null;
const id = raw.id.trim();
if (!id || id.length > 128) return null;
const selectedAt =
finite(raw.selectedAt) ||
(typeof raw.selectedAt === 'string' && raw.selectedAt.length <= 64)
? raw.selectedAt
: null;
return Object.freeze({
id,
name: cockpitSubjectText(raw.name, 64) || cockpitSubjectText(id, 64),
face: faceOf(raw.face),
selectedAt,
});
}
/**
* One camera frame from a provider, normalized, or null when it cannot place
* the camera (the view then ends): not an object, an id other than `heldId`,
* a position that is not finite, an eye with no valid `upM` (0..1000), or a
* read of it that throws (a getter runs provider code).
* A `heightM` that is not finite or lies outside
* COCKPIT_SUBJECT_MIN_HEIGHT_M..COCKPIT_SUBJECT_MAX_HEIGHT_M reads as null:
* the eye then stands on the shared floor, or where it last stood while that
* is cold. The anchor is `'inertial'` only when asked for and the frame
* gives a speed; otherwise it is `'exact'`. An id is compared with `heldId`
* trimmed, as `cockpitSubjectCandidate` trims it.
* @param {unknown} raw What `read()` returned.
* @param {string} heldId The subject the view holds.
* @returns {object|null} `{id, name, latitude, longitude, heightM, headingDeg,
* courseDeg, speedMps, stale, groundClamp, anchor, eye: {upM, forwardM,
* pitchDeg, minClearanceM}}`.
*/
export function cockpitSubjectFrame(raw, heldId) {
try {
return frameOf(raw, heldId);
} catch {
// A getter on what read() returned threw: as a malformed frame.
return null;
}
}
function frameOf(raw, heldId) {
if (!isObject(raw) || typeof raw.id !== 'string' || raw.id.trim() !== heldId)
return null;
const latitude = raw.latitude;
const longitude = raw.longitude;
if (!finite(latitude) || !finite(longitude) || Math.abs(latitude) > 90)
return null;
const eye = isObject(raw.eye) ? raw.eye : null;
if (!eye || !finite(eye.upM) || eye.upM < 0 || eye.upM > 1000) return null;
const heading = (value) =>
finite(value) ? ((value % 360) + 360) % 360 : null;
const headingDeg = heading(raw.headingDeg);
const speedMps =
finite(raw.speedMps) && raw.speedMps >= 0 ? raw.speedMps : null;
return {
id: heldId,
name: cockpitSubjectText(raw.name, 64) || cockpitSubjectText(heldId, 64),
latitude,
longitude: ((((longitude + 180) % 360) + 360) % 360) - 180,
heightM:
finite(raw.heightM) &&
raw.heightM >= COCKPIT_SUBJECT_MIN_HEIGHT_M &&
raw.heightM <= COCKPIT_SUBJECT_MAX_HEIGHT_M
? raw.heightM
: null,
headingDeg,
courseDeg: heading(raw.courseDeg) ?? headingDeg,
speedMps,
stale: raw.stale === true,
groundClamp: raw.groundClamp !== false,
// An inertial anchor runs on at the frame's speed; with none it would
// close on a moving subject at the correction's floor and fall behind.
anchor: raw.anchor === 'exact' || speedMps === null ? 'exact' : 'inertial',
eye: {
upM: eye.upM,
forwardM:
finite(eye.forwardM) && Math.abs(eye.forwardM) <= 500
? eye.forwardM
: 0,
pitchDeg: finite(eye.pitchDeg) ? clamp(eye.pitchDeg, -90, 90) : 0,
minClearanceM:
finite(eye.minClearanceM) && eye.minClearanceM >= 0
? eye.minClearanceM
: 0,
},
};
}
/** A list of `{id, ...}` rows, each read by `row`, at most `max` kept. */
function rowsOf(raw, max, row) {
if (!Array.isArray(raw)) return [];
const rows = [];
const ids = new Set();
for (const entry of raw) {
if (rows.length >= max) break;
if (!isObject(entry)) continue;
const id = typeof entry.id === 'string' ? entry.id : '';
if (!ID_PATTERN.test(id) || ids.has(id)) continue;
const read = row(entry);
if (!read) continue;
ids.add(id);
rows.push({ id, ...read });
}
return rows;
}
/** A progress line, or null without at least two ticks. */
function progressOf(raw) {
if (!isObject(raw) || !Array.isArray(raw.ticks)) return null;
const ticks = [];
for (const tick of raw.ticks) {
if (ticks.length >= COCKPIT_SUBJECT_MAX_TICKS) break;
if (!isObject(tick) || !finite(tick.at) || tick.at < 0 || tick.at > 1)
continue;
let label = null;
if (isObject(tick.label)) {
const text = cockpitSubjectText(tick.label.text, 40);
if (text)
label = {
text,
detail: cockpitSubjectText(tick.label.detail, 64),
priority: finite(tick.label.priority)
? clamp(Math.round(tick.label.priority), 0, 9)
: 0,
align: ['start', 'center', 'end'].includes(tick.label.align)
? tick.label.align
: 'center',
};
}
ticks.push({ at: tick.at, tone: tone(tick.tone), label });
}
if (ticks.length < 2) return null;
ticks.sort((a, b) => a.at - b.at);
const marker =
isObject(raw.marker) && finite(raw.marker.at)
? { at: clamp(raw.marker.at, 0, 1), dwell: raw.marker.dwell === true }
: null;
return {
ticks,
marker,
tag: cockpitSubjectText(raw.tag, 16),
summary: cockpitSubjectText(raw.summary, 160),
};
}
/**
* A provider's HUD view model, normalized; never throws. Texts are coerced
* and cut to their caps, lists to their lengths, tones to
* COCKPIT_SUBJECT_TONES, numbers clamped; a malformed member reads as absent.
* Null when reading `raw` throws (a getter runs provider code), so the caller
* keeps the view it has, as when `hud()` itself throws.
* All text is shown as given: the provider words it in the page's language.
* @param {unknown} raw What `hud()` returned.
* @returns {object|null} `{kicker, title, meta: {text, tone}, speed: {value,
* unit, tag, tone}|null, readouts: [{id, label, value, detail, tone}],
* sources: [{id, label, text, tone}],
* route: {title, from, to, status}|null,
* progress: {ticks: [{at, tone, label: {text, detail, priority,
* align}|null}], marker: {at, dwell}|null, tag, summary}|null,
* banner: {title, detail, tone}|null, veil: {level, streaks}|null,
* lookReadout, signals: [{id, tone, title, detail}]}`.
*/
export function cockpitSubjectHudView(raw) {
try {
return hudViewOf(raw);
} catch {
// A getter on what hud() returned threw: the caller keeps its last view.
return null;
}
}
function hudViewOf(raw) {
const view = isObject(raw) ? raw : {};
const meta = isObject(view.meta) ? view.meta : {};
const speed =
isObject(view.speed) &&
(view.speed.value === null ||
(finite(view.speed.value) && view.speed.value >= 0))
? {
value:
view.speed.value === null
? null
: Math.min(view.speed.value, 99_999),
unit: cockpitSubjectText(view.speed.unit, 12),
tag: cockpitSubjectText(view.speed.tag, 8),
tone: tone(view.speed.tone),
}
: null;
const route = isObject(view.route)
? {
title: cockpitSubjectText(view.route.title, 32),
from: cockpitSubjectText(view.route.from, 48),
to: cockpitSubjectText(view.route.to, 48),
status: cockpitSubjectText(view.route.status, 64),
}
: null;
const bannerTitle = isObject(view.banner)
? cockpitSubjectText(view.banner.title, 48)
: '';
const veil =
isObject(view.veil) && finite(view.veil.level)
? {
level: clamp(view.veil.level, 0, 1),
streaks: view.veil.streaks === true,
}
: null;
return {
kicker: cockpitSubjectText(view.kicker, 48),
title: cockpitSubjectText(view.title, 64),
meta: { text: cockpitSubjectText(meta.text, 96), tone: tone(meta.tone) },
speed,
readouts: rowsOf(view.readouts, COCKPIT_SUBJECT_MAX_READOUTS, (row) => {
const label = cockpitSubjectText(row.label, 24);
const value = cockpitSubjectText(row.value, 48);
return label || value
? {
label,
value,
detail: cockpitSubjectText(row.detail, 80),
tone: tone(row.tone),
}
: null;
}),
sources: rowsOf(view.sources, COCKPIT_SUBJECT_MAX_SOURCES, (row) => {
const text = cockpitSubjectText(row.text, 120);
return text
? {
label: cockpitSubjectText(row.label, 16),
text,
tone: tone(row.tone),
}
: null;
}),
route: route && (route.from || route.to) ? route : null,
progress: progressOf(view.progress),
banner: bannerTitle
? {
title: bannerTitle,
detail: cockpitSubjectText(view.banner.detail, 96),
tone: tone(view.banner.tone),
}
: null,
veil: veil && veil.level > 0 ? veil : null,
lookReadout: cockpitSubjectText(view.lookReadout, 48),
signals: rowsOf(view.signals, COCKPIT_SUBJECT_MAX_SIGNALS, (row) => {
const title = cockpitSubjectText(row.title, 48);
return title
? {
title,
detail: cockpitSubjectText(row.detail, 96),
tone: tone(row.tone),
}
: null;
}),
};
}
/**
* A guarded caller for provider methods: `call(provider, method, args,
* fallback)` returns what the method returns, or `fallback` when it throws,
* warning once per provider and method.
*
* Provider methods are synchronous: the Cockpit calls them on its render
* cadence and uses the answer at once. One that returns a promise (an async
* function, say) breaks that contract: the promise is refused as a throw
* would be (its rejection, if any, is swallowed), with one warning, and
* `onSettled`, when given, runs once the promise has settled, so a caller
* can undo work that lands late.
*
* `call.warnOnce(provider, method, message, error)` warns on the same
* once-per-provider-and-method footing, for an answer the caller finds
* malformed.
* @param {{warn?: (message: string, error: unknown) => void}} [options]
*/
export function createCockpitSubjectCaller({
warn = (message, error) => console.warn(message, error),
} = {}) {
const warned = new Set();
const warnOnce = (provider, method, message, error) => {
const key = `${provider.kind}:${method}`;
if (warned.has(key)) return;
warned.add(key);
warn(
`[Cockpit] subject provider ${provider.kind} ${method}() ${message}`,
error,
);
};
function call(
provider,
method,
args = [],
fallback = null,
onSettled = null,
) {
let result;
let thenable;
try {
result = provider[method](...args);
// Reading `then` runs provider code too (a getter may throw), so it is
// guarded as the call is.
thenable =
result !== null &&
(typeof result === 'object' || typeof result === 'function') &&
typeof result.then === 'function';
} catch (error) {
warnOnce(provider, method, 'failed', error);
return fallback;
}
if (thenable) {
// Its work may still land: `onSettled` runs once it has, either way.
const settled = () => {
if (typeof onSettled === 'function') onSettled();
};
Promise.resolve(result).then(settled, settled);
warnOnce(
provider,
method,
'returned a promise; provider methods are synchronous',
null,
);
return fallback;
}
return result;
}
call.warnOnce = warnOnce;
return call;
}
+698
View File
@@ -0,0 +1,698 @@
// src/cockpitSubjectProviders.test.mjs — an application's subject providers
// are checked once and what they return is normalized before the Cockpit
// uses it: a malformed provider is left out, a malformed frame ends the view,
// and a malformed HUD view is read field by field and never thrown on.
import test from 'node:test';
import assert from 'node:assert/strict';
import {
COCKPIT_BUILT_IN_KINDS,
COCKPIT_SUBJECT_MAX_READOUTS,
COCKPIT_SUBJECT_MAX_TICKS,
cockpitSubjectCandidate,
cockpitSubjectFrame,
cockpitSubjectHudView,
cockpitSubjectProviders,
cockpitSubjectText,
createCockpitSubjectCaller,
} from './cockpitSubjectProviders.js';
const noop = () => null;
/** A well-formed provider, with `overrides`. */
const provider = (overrides = {}) => ({
kind: 'example-kind',
layerId: 'example-layer',
entryFace: { icon: 'commute', label: 'RIDE' },
labels: {
view: 'First-person view',
exit: 'Exit first-person view',
exitText: 'EXIT VIEW',
},
candidate: noop,
hold: () => true,
read: noop,
hud: noop,
release: noop,
...overrides,
});
const keep = (list) => {
const warnings = [];
const kept = cockpitSubjectProviders(list, {
warn: (message) => warnings.push(message),
});
return { kept, warnings };
};
test('well-formed providers are kept, frozen, in priority then list order', () => {
const { kept, warnings } = keep([
provider({ kind: 'late', priority: 5 }),
provider({ kind: 'first-of-two', priority: 1 }),
provider({ kind: 'second-of-two', priority: 1 }),
provider({ kind: 'default-priority' }),
]);
assert.deepEqual(warnings, []);
assert.deepEqual(
kept.map(({ kind }) => kind),
['default-priority', 'first-of-two', 'second-of-two', 'late'],
);
assert.ok(Object.isFrozen(kept));
assert.ok(Object.isFrozen(kept[0]));
const [first] = kept;
assert.equal(first.priority, 0);
assert.equal(first.look, null);
assert.equal(first.cameraIntervalMs, null);
assert.equal(first.headingSlewDps, null);
assert.equal(first.isEntryAllowed(), true, 'no rule of its own allows');
assert.deepEqual(first.labels, {
view: 'First-person view',
exit: 'Exit first-person view',
exitText: 'EXIT VIEW',
kicker: '',
});
});
test('malformed, reserved and repeated providers are left out with one warning each', () => {
const cases = [
[null, /not an object/],
[provider({ kind: 'X' }), /malformed kind/],
[provider({ kind: 'a' }), /malformed kind/],
[provider({ layerId: 'Bad Layer' }), /malformed layer id/],
[provider({ hold: undefined }), /no hold\(\)/],
[provider({ read: 'read' }), /no read\(\)/],
[provider({ isEntryAllowed: true }), /isEntryAllowed/],
[provider({ entryFace: { icon: 'Bad Icon', label: 'X' } }), /entry face/],
[provider({ entryFace: { icon: 'commute', label: '' } }), /entry face/],
[provider({ labels: { view: 'v', exit: 'e' } }), /labels/],
[provider({ look: { restPitchDeg: -3 } }), /aheadLabel/],
];
for (const [entry, reason] of cases) {
const { kept, warnings } = keep([entry]);
assert.equal(kept.length, 0, String(reason));
assert.equal(warnings.length, 1, String(reason));
assert.match(warnings[0], reason);
}
for (const kind of COCKPIT_BUILT_IN_KINDS) {
const { kept, warnings } = keep([provider({ kind })]);
assert.equal(kept.length, 0, kind);
assert.match(warnings[0], /reserved kind/);
}
const { kept, warnings } = keep([provider(), provider()]);
assert.equal(kept.length, 1);
assert.match(warnings[0], /repeated kind/);
assert.deepEqual(keep('not a list').kept, []);
});
test('optional settings are kept when in range and defaulted otherwise', () => {
const [ranged] = keep([
provider({
priority: -2,
look: { restPitchDeg: -120, aheadLabel: 'Look ahead' },
cameraIntervalMs: 0,
headingSlewDps: Infinity,
labels: {
view: 'v',
exit: 'e',
exitText: 'x',
kicker: 'K'.repeat(80),
},
}),
]).kept;
assert.equal(ranged.priority, -2);
assert.deepEqual(ranged.look, {
restPitchDeg: -90,
aheadLabel: 'Look ahead',
});
assert.equal(ranged.cameraIntervalMs, 0);
assert.equal(ranged.headingSlewDps, Infinity);
assert.equal(ranged.labels.kicker.length, 48);
assert.ok(ranged.labels.kicker.endsWith('…'));
const [defaulted] = keep([
provider({
priority: NaN,
cameraIntervalMs: 5000,
headingSlewDps: -1,
}),
]).kept;
assert.equal(defaulted.priority, 0);
assert.equal(defaulted.cameraIntervalMs, null);
assert.equal(defaulted.headingSlewDps, null);
});
test('a candidate needs an id; its name and face are read with care', () => {
assert.equal(cockpitSubjectCandidate(null), null);
assert.equal(cockpitSubjectCandidate({ id: 7 }), null);
assert.equal(cockpitSubjectCandidate({ id: ' ' }), null);
assert.equal(cockpitSubjectCandidate({ id: 'x'.repeat(129) }), null);
assert.deepEqual(cockpitSubjectCandidate({ id: ' a1 ' }), {
id: 'a1',
name: 'a1',
face: null,
selectedAt: null,
});
// A selection token is a number or a short string.
assert.equal(
cockpitSubjectCandidate({ id: 'a1', selectedAt: 5 }).selectedAt,
5,
);
assert.equal(
cockpitSubjectCandidate({ id: 'a1', selectedAt: 'click-2' }).selectedAt,
'click-2',
);
for (const selectedAt of [Number.NaN, {}, 'x'.repeat(65)])
assert.equal(
cockpitSubjectCandidate({ id: 'a1', selectedAt }).selectedAt,
null,
);
const read = cockpitSubjectCandidate({
id: 'a1',
name: ' Example 12 ',
face: { icon: 'tram', label: 'RIDE' },
});
assert.equal(read.name, 'Example 12');
assert.deepEqual(read.face, { icon: 'tram', label: 'RIDE' });
assert.equal(
cockpitSubjectCandidate({ id: 'a1', face: { icon: 'BAD' } }).face,
null,
);
});
test('a frame is the held subject at a finite place with an eye height, else null', () => {
const frame = (overrides = {}) => ({
id: 'a1',
latitude: 47,
longitude: 8,
eye: { upM: 3.5 },
...overrides,
});
const read = cockpitSubjectFrame(frame(), 'a1');
assert.deepEqual(read, {
id: 'a1',
name: 'a1',
latitude: 47,
longitude: 8,
heightM: null,
headingDeg: null,
courseDeg: null,
speedMps: null,
stale: false,
groundClamp: true,
// No speed to run an inertial anchor on: it sits on the subject.
anchor: 'exact',
eye: { upM: 3.5, forwardM: 0, pitchDeg: 0, minClearanceM: 0 },
});
assert.equal(
cockpitSubjectFrame(frame({ speedMps: 30 }), 'a1').anchor,
'inertial',
'with a speed it runs on',
);
// A held id is trimmed (cockpitSubjectCandidate), and so is a frame's.
assert.equal(cockpitSubjectFrame(frame({ id: ' a1 ' }), 'a1')?.id, 'a1');
for (const bad of [
null,
frame({ id: 'other' }),
frame({ latitude: 91 }),
frame({ latitude: NaN }),
frame({ longitude: Infinity }),
frame({ eye: null }),
frame({ eye: { upM: -1 } }),
frame({ eye: { upM: 2000 } }),
frame({ eye: { upM: '3' } }),
])
assert.equal(cockpitSubjectFrame(bad, 'a1'), null, JSON.stringify(bad));
const full = cockpitSubjectFrame(
frame({
name: 'Example',
longitude: 190,
heightM: 412.5,
headingDeg: -10,
courseDeg: 725,
speedMps: 22,
stale: true,
groundClamp: false,
anchor: 'exact',
eye: { upM: 2, forwardM: 600, pitchDeg: -100, minClearanceM: 1.5 },
}),
'a1',
);
assert.equal(full.longitude, -170);
assert.equal(full.heightM, 412.5);
// A height outside any plausible ground reads as none.
for (const heightM of [-6_400_000, 1e9, Infinity])
assert.equal(cockpitSubjectFrame(frame({ heightM }), 'a1').heightM, null);
assert.equal(
cockpitSubjectFrame(frame({ heightM: -400 }), 'a1').heightM,
-400,
);
assert.equal(full.headingDeg, 350);
assert.equal(full.courseDeg, 5);
assert.equal(full.speedMps, 22);
assert.equal(full.stale, true);
assert.equal(full.groundClamp, false);
assert.equal(full.anchor, 'exact');
assert.deepEqual(full.eye, {
upM: 2,
forwardM: 0,
pitchDeg: -90,
minClearanceM: 1.5,
});
// Only exactly false turns the ground clamp off; course falls back to heading.
const loose = cockpitSubjectFrame(
frame({ groundClamp: 0, anchor: 'EXACT', headingDeg: 90, speedMps: -1 }),
'a1',
);
assert.equal(loose.groundClamp, true);
assert.equal(
cockpitSubjectFrame(frame({ anchor: 'EXACT', speedMps: 5 }), 'a1').anchor,
'inertial',
);
assert.equal(loose.courseDeg, 90);
assert.equal(loose.speedMps, null);
});
test('a HUD view is read field by field: caps, tones, clamps, never a throw', () => {
const empty = cockpitSubjectHudView(null);
assert.deepEqual(empty, {
kicker: '',
title: '',
meta: { text: '', tone: 'normal' },
speed: null,
readouts: [],
sources: [],
route: null,
progress: null,
banner: null,
veil: null,
lookReadout: '',
signals: [],
});
for (const odd of [42, 'text', [], { readouts: 'x', progress: { ticks: 3 } }])
assert.doesNotThrow(() => cockpitSubjectHudView(odd));
const view = cockpitSubjectHudView({
kicker: 'LINE · OPERATOR',
title: 'Name → Destination',
meta: { text: 'LIVE', tone: 'bogus' },
speed: { value: 84.4, unit: 'km/h', tag: 'LIVE', tone: 'live' },
readouts: [
...Array.from({ length: 6 }, (_, i) => ({
id: `r${i}`,
label: 'NEXT',
value: `Stop ${i}`,
tone: 'warning',
})),
],
sources: [
{ id: 'track', label: 'TRACK', text: 'operator shape' },
{ id: 'track', label: 'TRACK', text: 'repeated id' },
{ id: 'Bad Id', text: 'refused' },
{ id: 'empty', label: 'X', text: '' },
],
route: { title: 'ROUTE', from: 'A', to: 'B', status: '3 stops' },
banner: { title: 'BANNER', detail: 'detail', tone: 'alert' },
veil: { level: 4, streaks: 1 },
lookReadout: 'LOOK RIGHT',
signals: [{ id: 's1', title: 'T', detail: 'D', tone: 'warning' }],
});
assert.equal(view.meta.tone, 'normal');
assert.deepEqual(view.speed, {
value: 84.4,
unit: 'km/h',
tag: 'LIVE',
tone: 'live',
});
assert.equal(view.readouts.length, COCKPIT_SUBJECT_MAX_READOUTS);
assert.equal(view.readouts[0].tone, 'warning');
assert.deepEqual(
view.sources.map(({ id }) => id),
['track'],
);
assert.deepEqual(view.route, {
title: 'ROUTE',
from: 'A',
to: 'B',
status: '3 stops',
});
assert.equal(view.banner.tone, 'alert');
assert.deepEqual(view.veil, { level: 1, streaks: false });
assert.equal(view.lookReadout, 'LOOK RIGHT');
assert.equal(view.signals[0].tone, 'warning');
// A veil at 0, a speed below 0 and a route with no ends are absent.
const absent = cockpitSubjectHudView({
veil: { level: 0 },
speed: { value: -3 },
route: { from: '', to: '' },
});
assert.equal(absent.veil, null);
assert.equal(absent.speed, null);
assert.equal(absent.route, null);
// A speed of null is a speed not yet known.
assert.equal(
cockpitSubjectHudView({ speed: { value: null } }).speed.value,
null,
);
});
test('a progress line keeps ticks in 0..1, sorted and capped, and needs two', () => {
const read = cockpitSubjectHudView({
progress: {
ticks: [
{ at: 1, label: { text: 'End', align: 'end', priority: 1 } },
{ at: 0.5, tone: 'warning' },
{ at: 2 },
{ at: -0.1 },
{ at: 0, label: { text: 'Start', align: 'start', priority: 12 } },
{ at: 0.25, label: { text: '', detail: 'no text, no label' } },
],
marker: { at: 1.4, dwell: true },
tag: 'TIMETABLE',
summary: 'From Start to End',
},
}).progress;
assert.deepEqual(
read.ticks.map(({ at }) => at),
[0, 0.25, 0.5, 1],
);
assert.equal(read.ticks[0].label.priority, 9);
assert.equal(read.ticks[0].label.align, 'start');
assert.equal(read.ticks[1].label, null);
assert.equal(read.ticks[2].tone, 'warning');
assert.deepEqual(read.marker, { at: 1, dwell: true });
assert.equal(read.tag, 'TIMETABLE');
assert.equal(read.summary, 'From Start to End');
assert.equal(
cockpitSubjectHudView({ progress: { ticks: [{ at: 0.5 }] } }).progress,
null,
);
const many = cockpitSubjectHudView({
progress: {
ticks: Array.from({ length: COCKPIT_SUBJECT_MAX_TICKS + 40 }, (_, i) => ({
at: i / (COCKPIT_SUBJECT_MAX_TICKS + 40),
})),
},
}).progress;
assert.equal(many.ticks.length, COCKPIT_SUBJECT_MAX_TICKS);
});
test('texts are trimmed, collapsed and cut with an ellipsis', () => {
assert.equal(cockpitSubjectText(' a \n b ', 10), 'a b');
assert.equal(cockpitSubjectText('abcdef', 4), 'abc…');
assert.equal(cockpitSubjectText(12.5, 10), '12.5');
assert.equal(cockpitSubjectText({}, 10), '');
assert.equal(cockpitSubjectText(NaN, 10), '');
});
test('a cut never splits a surrogate pair', () => {
// The cut would fall between the emoji's halves: it is left out whole.
assert.equal(cockpitSubjectText('abcd😀efgh', 6), 'abcd…');
// One that fits before the cut is kept whole.
assert.equal(cockpitSubjectText('abc😀efgh', 6), 'abc😀…');
const lone =
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
for (let max = 2; max <= 24; max += 1)
assert.equal(
lone.test(cockpitSubjectText('🚆 Example 12 🚉 Central', max)),
false,
`cut at ${max}`,
);
});
test('a provider method that throws returns the fallback and warns once', () => {
const warnings = [];
const call = createCockpitSubjectCaller({
warn: (message) => warnings.push(message),
});
const failing = {
kind: 'example-kind',
read() {
throw new Error('boom');
},
hud: (value) => value * 2,
};
assert.equal(call(failing, 'read', [], 'fallback'), 'fallback');
assert.equal(call(failing, 'read', [], 'fallback'), 'fallback');
assert.equal(call(failing, 'hud', [21]), 42);
assert.equal(warnings.length, 1);
assert.match(warnings[0], /example-kind read\(\) failed/);
});
test('a provider method that returns a promise is refused as a throw is, warning once', async () => {
const warnings = [];
const call = createCockpitSubjectCaller({
warn: (message) => warnings.push(message),
});
const asynchronous = {
kind: 'example-kind',
hold: async () => true,
read: () => Promise.reject(new Error('late')),
};
assert.equal(call(asynchronous, 'hold', ['a1'], 'refused'), 'refused');
assert.equal(call(asynchronous, 'hold', ['a1'], 'refused'), 'refused');
assert.equal(call(asynchronous, 'read', [], null), null);
assert.equal(warnings.length, 2, 'once per method');
assert.match(warnings[0], /hold\(\) returned a promise/);
// The rejected promise is not left unhandled.
await new Promise((resolve) => setTimeout(resolve, 0));
});
test('an answer whose `then` throws when read is handled as a throw', () => {
const warnings = [];
const call = createCockpitSubjectCaller({
warn: (message) => warnings.push(message),
});
const tricky = {
kind: 'example-kind',
hold: () => ({
get then() {
throw new Error('no then');
},
}),
};
assert.equal(call(tricky, 'hold', ['a1'], 'refused'), 'refused');
assert.equal(warnings.length, 1);
assert.match(warnings[0], /example-kind hold\(\) failed/);
});
test('the caller’s warnOnce shares its once-per-provider-and-method record', () => {
const warnings = [];
const call = createCockpitSubjectCaller({
warn: (message) => warnings.push(message),
});
const ride = {
kind: 'example-kind',
hold() {
throw new Error('boom');
},
};
call.warnOnce(ride, 'read', 'is odd');
call.warnOnce(ride, 'read', 'is odd again');
call(ride, 'hold', ['a1']);
call.warnOnce(ride, 'hold', 'is odd');
assert.deepEqual(warnings, [
'[Cockpit] subject provider example-kind read() is odd',
'[Cockpit] subject provider example-kind hold() failed',
]);
});
test('a class-based provider keeps its `this`, in its methods and its worded members', () => {
class Ride {
#word = 'RIDE';
#held = null;
kind = 'example-kind';
layerId = 'example-layer';
speak(word) {
this.#word = word;
}
entryFace() {
return { icon: 'commute', label: this.#word };
}
get labels() {
return {
view: `${this.#word} view`,
exit: 'Exit ride view',
exitText: 'EXIT RIDE',
};
}
look() {
return { aheadLabel: `Look ahead, ${this.#word}` };
}
candidate() {
return { id: 'a1', name: this.#word };
}
isEntryAllowed(candidate) {
return candidate.id === 'a1' && this.#held === null;
}
hold(id) {
this.#held = id;
return true;
}
read() {
return this.#held;
}
hud(frame) {
return { title: `${this.#word} ${frame}` };
}
release() {
this.#held = null;
return 'released';
}
}
const instance = new Ride();
const { kept, warnings } = keep([instance]);
assert.deepEqual(warnings, []);
const [ride] = kept;
assert.equal(ride.entryFace.label, 'RIDE');
assert.equal(ride.labels.view, 'RIDE view');
assert.equal(ride.look.aheadLabel, 'Look ahead, RIDE');
instance.speak('FAHRT');
assert.equal(ride.entryFace.label, 'FAHRT', 'read again, on the instance');
assert.equal(ride.look.aheadLabel, 'Look ahead, FAHRT');
// Called on the kept copy, as the Cockpit calls them.
const failures = [];
const call = createCockpitSubjectCaller({
warn: (message) => failures.push(message),
});
assert.deepEqual(call(ride, 'candidate'), { id: 'a1', name: 'FAHRT' });
assert.equal(ride.isEntryAllowed({ id: 'a1' }), true);
assert.equal(call(ride, 'hold', ['a1']), true);
assert.equal(ride.isEntryAllowed({ id: 'a1' }), false);
assert.equal(call(ride, 'read', [0, {}]), 'a1');
assert.deepEqual(call(ride, 'hud', ['a1', {}]), { title: 'FAHRT a1' });
assert.equal(call(ride, 'release', ['a1', { restore: true }]), 'released');
assert.equal(call(ride, 'read', [0, {}]), null);
assert.deepEqual(failures, []);
});
test('labels, entry face and look given as functions are read again on each use', () => {
let lang = 'en';
const words = {
en: { view: 'Ride view', exit: 'Exit ride view', exitText: 'EXIT RIDE' },
de: { view: 'Fahrtansicht', exit: 'Verlassen', exitText: 'VERLASSEN' },
};
const { kept, warnings } = keep([
provider({
labels: () => words[lang],
entryFace: () => ({
icon: 'commute',
label: { en: 'RIDE', de: 'FAHRT' }[lang],
}),
look: () => ({
restPitchDeg: lang === 'de' ? 40 : -3,
aheadLabel: lang === 'de' ? 'Nach vorn' : 'Look ahead',
}),
}),
]);
assert.deepEqual(warnings, []);
const [ride] = kept;
assert.equal(ride.labels.exitText, 'EXIT RIDE');
assert.equal(ride.entryFace.label, 'RIDE');
assert.equal(ride.look.aheadLabel, 'Look ahead');
lang = 'de';
assert.equal(ride.labels.exitText, 'VERLASSEN');
assert.equal(ride.entryFace.label, 'FAHRT');
assert.equal(ride.look.aheadLabel, 'Nach vorn');
assert.equal(ride.look.restPitchDeg, -3, 'the rest pitch is as first read');
// A read that fails or is malformed keeps the last good one.
lang = 'xx';
assert.equal(ride.labels.exitText, 'VERLASSEN');
assert.equal(ride.entryFace.label, 'FAHRT');
// Functions that are malformed from the start leave the provider out.
const bad = keep([
provider({ kind: 'bad-labels', labels: () => null }),
provider({
kind: 'thrown-face',
entryFace: () => {
throw new Error('no face');
},
}),
]);
assert.deepEqual(bad.kept, []);
assert.equal(bad.warnings.length, 2);
});
/** `target` with a getter for `key` that throws (provider code runs on a read). */
const throwingGetter = (target, key) =>
Object.defineProperty(target, key, {
enumerable: true,
get() {
throw new Error(`${key} unavailable`);
},
});
test('a provider whose getter throws is left out with one warning; the next is kept', () => {
const { kept, warnings } = keep([
throwingGetter(provider({ kind: 'thrown-face' }), 'entryFace'),
throwingGetter(provider({ kind: 'thrown-labels' }), 'labels'),
throwingGetter(provider({ kind: 'thrown-look' }), 'look'),
throwingGetter(provider(), 'kind'),
throwingGetter(provider({ kind: 'thrown-method' }), 'read'),
// Read only once the provider is accepted, so its kind must stay free.
throwingGetter(provider({ kind: 'example-kind' }), 'priority'),
provider({ kind: 'example-kind' }),
]);
assert.deepEqual(
kept.map(({ kind }) => kind),
['example-kind'],
);
assert.deepEqual(warnings, [
'[Cockpit] subject provider thrown-face left out: malformed entry face',
'[Cockpit] subject provider thrown-labels left out: labels need view, exit and exitText',
'[Cockpit] subject provider thrown-look left out: a member threw when read',
'[Cockpit] subject provider #3 left out: a member threw when read',
'[Cockpit] subject provider thrown-method left out: a member threw when read',
'[Cockpit] subject provider example-kind left out: a member threw when read',
]);
});
test('a worded getter that starts throwing keeps the last good words', () => {
let failing = false;
const words = (value) => () => {
if (failing) throw new Error('translations unavailable');
return value;
};
const definition = provider();
for (const [key, value] of Object.entries({
labels: { view: 'Ride view', exit: 'Exit ride view', exitText: 'EXIT' },
entryFace: { icon: 'commute', label: 'RIDE' },
look: { restPitchDeg: 10, aheadLabel: 'Look ahead' },
}))
Object.defineProperty(definition, key, { get: words(value) });
const { kept, warnings } = keep([definition]);
assert.deepEqual(warnings, []);
const [ride] = kept;
failing = true;
assert.equal(ride.labels.view, 'Ride view');
assert.equal(ride.entryFace.label, 'RIDE');
assert.deepEqual(ride.look, { restPitchDeg: 10, aheadLabel: 'Look ahead' });
// So does a worded object whose own member throws when read.
failing = false;
Object.defineProperty(definition, 'labels', {
get: () => throwingGetter({ view: 'v', exit: 'e', exitText: 'x' }, 'view'),
});
assert.equal(ride.labels.view, 'Ride view');
});
test('a candidate, frame or HUD view whose getter throws reads as null', () => {
assert.equal(
cockpitSubjectCandidate(throwingGetter({ name: 'Example' }, 'id')),
null,
);
const frame = { id: 'a1', latitude: 1, longitude: 2, eye: { upM: 3 } };
assert.ok(cockpitSubjectFrame(frame, 'a1'), 'well formed');
assert.equal(
cockpitSubjectFrame(throwingGetter({ ...frame }, 'latitude'), 'a1'),
null,
);
assert.equal(
cockpitSubjectFrame({ ...frame, eye: throwingGetter({}, 'upM') }, 'a1'),
null,
);
assert.equal(
cockpitSubjectHudView(throwingGetter({ title: 'T' }, 'meta')),
null,
);
assert.equal(
cockpitSubjectHudView({ title: 'T', readouts: [throwingGetter({}, 'id')] }),
null,
);
// Nothing to read is still the empty view, never null.
assert.equal(cockpitSubjectHudView(null).title, '');
});
+18
View File
@@ -481,6 +481,24 @@ export function orbitEntryAllowed({
); );
} }
/**
* Whether an application subject provider's candidate can be ridden. Like the
* bridge and orbit it needs only its own layer on, and waits out a Context
* change in flight; the provider then has its own say (`isEntryAllowed`).
* @param {{layerEnabled: boolean, contextModeChanging: boolean,
* providerAllows: boolean}} state
* @returns {boolean}
*/
export function subjectEntryAllowed({
layerEnabled,
contextModeChanging,
providerAllows,
}) {
return (
Boolean(layerEnabled) && !contextModeChanging && providerAllows === true
);
}
/** /**
* Internal context-mode id → the word the voice tools accept for it. * Internal context-mode id → the word the voice tools accept for it.
* *
+127 -36
View File
@@ -4,18 +4,31 @@ 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(new URL('./ui/radioBindings.js', import.meta.url), 'utf8'); const radioBindings = readRadioSource(
const contextView = readRadioSource(new URL('./ui/views/ContextView.vue', import.meta.url), 'utf8'); new URL('./ui/radioBindings.js', import.meta.url),
'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(readFileSync(new URL('../index.html', import.meta.url), 'utf8')); const html = await renderApplicationHtml(
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 ['global-context-flights-btn', 'global-context-missions-btn']) { for (const id of [
const button = html.match(new RegExp(`<button id="${id}"[\\s\\S]*?</button>`)); 'global-context-flights-btn',
'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"/);
@@ -23,7 +36,10 @@ 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 ['global-context-flights-btn', 'global-context-missions-btn']) { for (const id of [
'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/);
@@ -34,9 +50,22 @@ 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((init.match(/if \(\s*this\.destroyed\s*\|\|\s*this\._contextModeChanging\s*\|\|\s*this\._clearSelectedLayersPromise\s*\)\s*return;/g) || []).length, 2); assert.equal(
assert.doesNotMatch(select, /_globalContext(?:Flights|Missions)Btn\.disabled\s*=\s*true/); (
assert.doesNotMatch(clear, /_globalContext(?:Flights|Missions)Btn\.disabled\s*=\s*true/); 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/,
);
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', () => {
@@ -45,40 +74,62 @@ 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(initSource, /contextTabs\[nextIndex\]\.focus\(\{ preventScroll: true \}\)/); assert.match(
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(([, selector, body]) => ( const focusRule = rules.find(
selector.trim().endsWith('.context-mode-button:focus-visible') ([, selector, body]) =>
&& /outline:\s*2px solid var\(--text-primary\)/.test(body) selector.trim().endsWith('.context-mode-button:focus-visible') &&
)); /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]) => selector.trim().endsWith('.context-mode-button.active')); const activeRule = rules.find(([, selector]) =>
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(radioBindings.indexOf('const toggleRadio = async (trigger) => {'), radioBindings.indexOf('this._storeToken = this.store?.connect?.(')); const radio = radioBindings.slice(
const radioPanel = readFileSync(new URL('./ui/views/panels/RadioPanel.vue', import.meta.url), 'utf8'); radioBindings.indexOf('const toggleRadio = async (trigger) => {'),
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(/<button id="installations-search-btn"[^>]*>/)?.[0]; const search = contextView.match(
/<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(new URL('./ui/views/panels/CockpitUtilityControls.vue', import.meta.url), 'utf8'); const cockpitRadio = readFileSync(
const cockpitEnable = cockpitRadio.match(/<UiButton id="cockpit-radio-enable-btn"[^>]*>/)?.[0]; new URL('./ui/views/panels/CockpitUtilityControls.vue', import.meta.url),
'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=/);
@@ -89,44 +140,84 @@ 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(new URL('./ui/views/panels/GlobeActions.vue', import.meta.url), 'utf8'); const globeActions = readFileSync(
const clearButton = globeActions.match(/<button id="clear-selected-layers"[^>]*>/)?.[0]; new URL('./ui/views/panels/GlobeActions.vue', import.meta.url),
'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 and collapse control', () => { test('Context header holds only its title, edge switch and collapse control', () => {
const panel = html.match(/<div id="global-context-panel"[\s\S]*?<div class="cyber-panel-body[ "]/); const panel = html.match(
/<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(/<div class="ui-panel__head panel-header">([\s\S]*?)<\/div>\s*<div class="cyber-panel-body[ "]/); const header = panel[0].match(
/<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 = [...header[1].matchAll(/<(\w+)\s+(?:type="button"\s+)?class="([^"]+)"/g)].map(([, tag, cls]) => const tags = [
`${tag}.${cls.split(' ').filter((name) => !/^ui-/.test(name)).join(' ')}`); ...header[1].matchAll(/<(\w+)\s+(?:type="button"\s+)?class="([^"]+)"/g),
assert.deepEqual(tags, ['span.panel-title', 'span.panel-divider', 'button.panel-collapse-btn']); ].map(
([, 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) => selector.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\s+/g, ' ').trim(); const clean = (selector) =>
const rule = (selector) => rules.find(([, s]) => clean(s) === selector)?.[2] || ''; selector
const px = (body, prop) => Number(body.match(new RegExp(`${prop}:[^;]*?(\\d+(?:\\.\\d+)?)px`))?.[1] || 0); .replace(/\/\*[\s\S]*?\*\//g, '')
.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(px(rule('.context-mode-button'), 'font') >= 10, 'mode labels must stay at least 10px'); assert.ok(
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(px(standby, 'font') >= 10, 'standby guidance must stay at least 10px'); assert.ok(
px(standby, 'font') >= 10,
'standby guidance must stay at least 10px',
);
assert.doesNotMatch(standby, /dashed/); assert.doesNotMatch(standby, /dashed/);
assert.ok(px(rule('.global-context-actions .panel-layer-toggle'), 'font-size') >= 10); assert.ok(
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(":root:not([data-ui-theme='cyber']) #global-context-panel:not(.collapsed) .panel-title"), rule(
":root:not([data-ui-theme='cyber']) #global-context-panel:not(.collapsed) .panel-title",
),
'', '',
); );
}); });
+5 -1
View File
@@ -64,7 +64,11 @@ export function normalizeAdsbLolAircraftState(aircraft, nowSeconds) {
const geometricFeet = finiteNumber(aircraft?.alt_geom); const geometricFeet = finiteNumber(aircraft?.alt_geom);
const groundSpeedKnots = finiteNumber(aircraft?.gs); const groundSpeedKnots = finiteNumber(aircraft?.gs);
const verticalRateFpm = readAdsbVerticalRateFpm(aircraft); const verticalRateFpm = readAdsbVerticalRateFpm(aircraft);
const track = finiteNumber(aircraft?.track); // A parked or slow aircraft reports no ground track, but often its true
// heading (surface messages): the way its nose points, which is what an
// aircraft standing still is drawn by. Magnetic heading is not true north.
const track =
finiteNumber(aircraft?.track) ?? finiteNumber(aircraft?.true_heading);
return [ return [
hex, hex,
+12
View File
@@ -49,3 +49,15 @@ test('keeps grounded fallback contacts and rejects rows without positions', () =
assert.equal(normalized.states[0][7], null); assert.equal(normalized.states[0][7], null);
assert.equal(normalized.states[0][8], true); assert.equal(normalized.states[0][8], true);
}); });
test('an aircraft with no track takes its true heading, never its magnetic one', () => {
const at = (extra) =>
normalizeAdsbLolAircraftState(
{ hex: 'abc123', lat: 37.7, lon: -122.2, alt_baro: 'ground', ...extra },
1000,
)[10];
assert.equal(at({ track: 56.25, true_heading: 40 }), 56.25, 'track first');
assert.equal(at({ true_heading: 244.69 }), 244.69, 'true heading');
assert.equal(at({ mag_heading: 278.44 }), null, 'not magnetic');
assert.equal(at({}), null, 'none');
});
+2 -1
View File
@@ -458,7 +458,8 @@ test('tint priorities cover normal, priority, emergency, tracking and Cockpit do
); );
flightState._trackedIcao = null; flightState._trackedIcao = null;
flightState.records.data.set('civil', { emergency: null }); flightState.records.data.set('civil', { emergency: null });
assert.equal(rendering._modelColor('civil'), normal); // An ordinary model draws its mesh's own colours, whatever the billboard's.
assert.equal(rendering._modelColor('civil'), Cesium.Color.WHITE);
} }
}); });
+19 -5
View File
@@ -1,3 +1,5 @@
import * as Cesium from 'cesium';
/** /**
* Lighting options shared by every aircraft 3D model (Flights, Military * Lighting options shared by every aircraft 3D model (Flights, Military
* Flights, Local ADS-B). * Flights, Local ADS-B).
@@ -6,12 +8,24 @@
* the model and reads the result back from the GPU (`readPixels`) to light it, * the model and reads the result back from the GPU (`readPixels`) to light it,
* when the model is first placed and again when it moves more than 1 km, when * when the model is first placed and again when it moves more than 1 km, when
* the scene's atmosphere settings change, or every scene hour under sunlight * the scene's atmosphere settings change, or every scene hour under sunlight
* lighting. Aircraft models take 94% of their surface from the class colour * lighting. Without it they are lit from Cesium's default spherical
* (`colorBlendAmount`), so that lighting contributes little; without it they * harmonics. Measured 2026-10-24 (Intel Arc, Google 3D, ~150 models in the
* are lit from Cesium's default spherical harmonics. Measured 2026-10-24 * scene): GPU per frame at rest 14.3-14.8 -> 12.1-12.5 ms.
* (Intel Arc, Google 3D, ~150 models in the scene): GPU per frame at rest
* 14.3-14.8 -> 12.1-12.5 ms.
*/ */
export const AIRCRAFT_ENVIRONMENT_MAP_OPTIONS = Object.freeze({ export const AIRCRAFT_ENVIRONMENT_MAP_OPTIONS = Object.freeze({
enabled: false, enabled: false,
}); });
/**
* How an aircraft model's colour meets its mesh. HIGHLIGHT multiplies, so
* white draws the mesh's own colours as built, and the tracked or emergency
* colour tints the mesh without hiding its paint. The IR boost draws the
* model flat white instead (MIX at 1, with its unlit shader).
* @param {boolean} irBoost
* @returns {{colorBlendMode: Cesium.ColorBlendMode, colorBlendAmount: number}}
*/
export function aircraftModelBlend(irBoost) {
return irBoost
? { colorBlendMode: Cesium.ColorBlendMode.MIX, colorBlendAmount: 1 }
: { colorBlendMode: Cesium.ColorBlendMode.HIGHLIGHT, colorBlendAmount: 0 };
}
+1 -1
View File
@@ -243,7 +243,7 @@ export function applyAircraftBillboardTreatment({
/** /**
* Apply layer-owned hue and composed aircraft alpha to an ambient glTF model without * Apply layer-owned hue and composed aircraft alpha to an ambient glTF model without
* disturbing Cesium's existing MIX/colorBlendAmount presentation semantics. * changing the model's colour blend mode or amount.
* @param {object} input * @param {object} input
* @param {object} input.model * @param {object} input.model
* @param {{withAlpha:(alpha:number)=>object}} input.baseColor * @param {{withAlpha:(alpha:number)=>object}} input.baseColor
+54
View File
@@ -1882,3 +1882,57 @@ test('detection objects name the vessel card their callout gives way to', () =>
_setVesselStateForTest({ enabled: false }); _setVesselStateForTest({ enabled: false });
} }
}); });
test('an aid to navigation card shows its kind, never a speed or heading', () => {
const aid = makeRecord({
name: 'SF APPROACH N',
mmsi: '993672001',
type: 'Cardinal Mark N',
speed: 0,
course: 135,
heading: 135,
});
const card = buildVesselCard(aid);
assert.deepEqual(card.details, ['CARDINAL MARK N']);
assert.equal(card.accent, '217, 210, 192');
const selected = buildSelectedVesselCard(aid);
assert.equal(selected.details[0], 'CARDINAL MARK N');
assert.equal(selected.accent, '217, 210, 192');
// Known only by its MMSI, an aid is named as one.
const unnamed = buildSelectedVesselCard({ ...aid, type: '' });
assert.equal(unnamed.details[0], 'AID TO NAVIGATION');
assert.deepEqual(buildVesselCard({ ...aid, type: '' }).details, []);
});
test('the HUD of a selected aid to navigation shows no speed or heading', () => {
const harness = installWireHarness(undefined, { selectedRecord: null });
try {
Object.assign(harness.record, {
type: 'Port hand Mark',
speed: 0,
heading: 135,
course: 135,
});
assert.equal(aisLiveVesselsLayer.selectById(harness.record.mmsi), true);
assert.match(harness.hud.readout.text, /\nPort hand Mark\n/);
assert.doesNotMatch(harness.hud.readout.text, /SPD|HDG|KT/);
harness.record.virtual = true;
aisLiveVesselsLayer.selectById(harness.record.mmsi);
assert.match(harness.hud.readout.text, /\nPort hand Mark · VIRTUAL\n/);
} finally {
harness.cleanup();
}
});
test('a virtual aid to navigation card says it is virtual', () => {
const aid = makeRecord({
mmsi: '993672001',
type: 'Isolated danger',
virtual: true,
});
assert.deepEqual(buildVesselCard(aid).details, ['ISOLATED DANGER · VIRTUAL']);
assert.equal(
buildSelectedVesselCard(aid).details[0],
'ISOLATED DANGER · VIRTUAL',
);
});
+94
View File
@@ -316,3 +316,97 @@ test('a Class B report carries its dimensions in part B', () => {
assert.equal(row.length, 12); assert.equal(row.length, 12);
assert.equal(row.beam, 4); assert.equal(row.beam, 4);
}); });
test('an aid-to-navigation report is a row named by its kind of aid, not a ship type', () => {
assert.equal(
ingestAisStreamEnvelope({
MessageType: 'AidsToNavigationReport',
Message: {
AidsToNavigationReport: {
UserID: 993672001,
Type: 20,
Name: 'SF APPROACH N',
},
},
MetaData: {
MMSI: '993672001',
latitude: 37.79,
longitude: -122.52,
time_utc: '2026-09-17 20:00:00.000000000 +0000 UTC',
},
}),
true,
);
const row = rowFor('993672001');
assert.equal(row.type, 'Cardinal Mark N');
assert.equal(row.name, 'SF APPROACH N');
assert.equal(row.speed, null);
});
test("an aid's name runs on into its name extension", () => {
ingestAisStreamEnvelope({
MessageType: 'AidsToNavigationReport',
Message: {
AidsToNavigationReport: {
UserID: 993672002,
Type: 13,
Name: 'SAN FRANCISCO MAIN S',
NameExtension: 'HIP CHANNEL 2',
},
},
MetaData: {
MMSI: '993672002',
ShipName: 'SAN FRANCISCO MAIN S',
latitude: 37.79,
longitude: -122.52,
time_utc: '2026-09-17 20:00:00.000000000 +0000 UTC',
},
});
assert.equal(rowFor('993672002').name, 'SAN FRANCISCO MAIN SHIP CHANNEL 2');
});
test('a capped row request keeps every vessel ahead of aids to navigation', () => {
// The aid reports last, so it is the newest row.
ingestAisStreamEnvelope(positionReport('244060901', { Sog: 3 }));
ingestAisStreamEnvelope({
MessageType: 'AidsToNavigationReport',
Message: { AidsToNavigationReport: { UserID: 993672003, Type: 24 } },
MetaData: {
MMSI: '993672003',
latitude: 37.79,
longitude: -122.52,
time_utc: '2026-09-17 20:00:00.000000000 +0000 UTC',
},
});
const rows = aisStreamRows(50000);
const firstAid = rows.findIndex((row) => row.mmsi.startsWith('99'));
const lastVessel = rows.findLastIndex((row) => !row.mmsi.startsWith('99'));
assert.ok(firstAid > lastVessel, `${firstAid} > ${lastVessel}`);
const vesselCount =
rows.length - rows.filter((row) => row.mmsi.startsWith('99')).length;
assert.ok(
aisStreamRows(vesselCount).every((row) => !row.mmsi.startsWith('99')),
);
});
test('a virtual aid to navigation is marked virtual; a real one carries no flag', () => {
const aidReport = (mmsi, VirtualAtoN) => ({
MessageType: 'AidsToNavigationReport',
Message: {
AidsToNavigationReport: { UserID: Number(mmsi), Type: 28, VirtualAtoN },
},
MetaData: {
MMSI: mmsi,
latitude: 37.79,
longitude: -122.52,
time_utc: '2026-09-17 20:00:00.000000000 +0000 UTC',
},
});
ingestAisStreamEnvelope(aidReport('993672004', true));
ingestAisStreamEnvelope(aidReport('993672005', false));
assert.equal(rowFor('993672004').virtual, true);
assert.equal('virtual' in rowFor('993672005'), false);
// A later real report of the same aid drops the flag.
ingestAisStreamEnvelope(aidReport('993672004', false));
assert.equal('virtual' in rowFor('993672004'), false);
});
+9
View File
@@ -700,6 +700,15 @@ 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 = {
+157 -23
View File
@@ -11,6 +11,9 @@
* vehicle's road or rail top), and is drawn life size: a type's published * vehicle's road or rail top), and is drawn life size: a type's published
* length over the model's built length is its scale. * length over the model's built length is its scale.
* *
* An aid to navigation (a buoy, beacon, mark, RACON or light) is drawn as
* the set's buoy of its IALA mark, by the mark its type names.
*
* The resolvers are plain functions over the parsed JSON; `loadFleetModelSet` * The resolvers are plain functions over the parsed JSON; `loadFleetModelSet`
* fetches the JSON and every GLB once, up front, so no model is fetched * fetches the JSON and every GLB once, up front, so no model is fetched
* mid-session. * mid-session.
@@ -18,6 +21,7 @@
import { classifyAircraft } from './aircraftClass.js'; import { classifyAircraft } from './aircraftClass.js';
import { FLEET_TRAIL_ANCHORS } from './fleetTrailAnchors.js'; import { FLEET_TRAIL_ANCHORS } from './fleetTrailAnchors.js';
import { isAidToNavigation } from './vesselTypes.js';
/** Where the model set is served, relative to the application base. */ /** Where the model set is served, relative to the application base. */
export const FLEET_MODEL_BASE = '/models/fleet/'; export const FLEET_MODEL_BASE = '/models/fleet/';
@@ -85,16 +89,62 @@ const TEXT_SHIP_TYPES = [
]; ];
/** /**
* The model for an aircraft of ICAO type `type` (A320, B738, R44) and ADS-B * The buoy an aid to navigation is drawn as, by the mark its type names
* emitter category `category`: the type's, else the category's, else * ("Cardinal Mark N", "Beacon, Port hand", "Safe Water"): the first pattern
* `default_aircraft`. * that matches. Beacons, lights, RACONs and reference points with no mark of
* their own are the special mark.
*/
const AID_MODELS = [
[/cardinal\b.*\bn(orth)?$/i, 'buoy_cardinal_north'],
[/cardinal\b.*\be(ast)?$/i, 'buoy_cardinal_east'],
[/cardinal\b.*\bs(outh)?$/i, 'buoy_cardinal_south'],
[/cardinal\b.*\bw(est)?$/i, 'buoy_cardinal_west'],
[/isolated danger/i, 'buoy_isolated_danger'],
[/safe water/i, 'buoy_safe_water'],
[/port hand/i, 'buoy_lateral_port'],
[/starboard hand/i, 'buoy_lateral_starboard'],
[/special mark|light vessel|lanby/i, 'buoy_special'],
[
/^(beacon|light\b|leading light|racon|reference point|navigation aid)/i,
'buoy_special',
],
];
/** The buoy an aid to navigation whose type names no mark is drawn as. */
export const DEFAULT_AID_MODEL = 'can_red';
/**
* The buoy model of an aid to navigation's type, else undefined when the
* type names no mark.
* @param {string|null} [type]
* @returns {string|undefined}
*/
export function aidToNavigationModel(type) {
const text = String(type ?? '').trim();
for (const [pattern, model] of AID_MODELS)
if (pattern.test(text)) return model;
return undefined;
}
/**
* The model for an aircraft of ICAO type `type` (A320, B738, R44), ADS-B
* emitter category `category` and callsign `callsign`: the first of the
* type's callsign rules (`aircraft_callsign`) the callsign matches, else the
* type's, else the category's, else `default_aircraft`. An airport vehicle
* (type ZVEH) is a fire truck, tug or van by its callsign, else a car.
* @param {object} types Parsed `types.json`. * @param {object} types Parsed `types.json`.
* @param {string} [type] * @param {string} [type]
* @param {string} [category] * @param {string} [category]
* @param {string} [callsign]
* @returns {{model: string, length: number|null}} * @returns {{model: string, length: number|null}}
*/ */
export function resolveAircraft(types, type, category) { export function resolveAircraft(types, type, category, callsign) {
const t = type?.trim().toUpperCase(); const t = type?.trim().toUpperCase();
const index = aircraftCallsignRule(types, t, callsign);
if (index >= 0) {
const rule = types.aircraft_callsign[t][index];
return { model: rule.model, length: rule.length };
}
const [model, length] = const [model, length] =
(t && (types.aircraft[t] ?? types.helicopters[t])) || (t && (types.aircraft[t] ?? types.helicopters[t])) ||
(category && types.categories[category]) || (category && types.categories[category]) ||
@@ -102,6 +152,51 @@ export function resolveAircraft(types, type, category) {
return { model, length }; return { model, length };
} }
/**
* A callsign as the rules read it: upper case, spaces removed.
* @param {string} callsign
* @returns {string}
*/
function callsignKey(callsign) {
return String(callsign).toUpperCase().replace(/\s+/g, '');
}
/**
* Which of type `type`'s callsign rules (`types.json`'s
* `aircraft_callsign[type]`, each `{prefixes, model, length}`, first match
* wins) `callsign` matches: its index, -1 when none does or the type has
* none. A callsign matches a rule when, upper-cased with spaces removed, it
* starts with one of the rule's prefixes.
* @param {object} types Parsed `types.json`.
* @param {string|null} [type] ICAO type.
* @param {string|null} [callsign]
* @returns {number}
*/
export function aircraftCallsignRule(types, type, callsign) {
const t = String(type ?? '')
.trim()
.toUpperCase();
const rules = t ? types.aircraft_callsign?.[t] : undefined;
if (!rules || !callsign) return -1;
const c = callsignKey(callsign);
return rules.findIndex((rule) =>
rule.prefixes.some((prefix) => c.startsWith(callsignKey(prefix))),
);
}
/**
* Whether an aircraft of ICAO type `type` picks its model by callsign.
* @param {object} types Parsed `types.json`.
* @param {string|null} [type]
* @returns {boolean}
*/
export function aircraftModelByCallsign(types, type) {
const t = String(type ?? '')
.trim()
.toUpperCase();
return Boolean(t && types.aircraft_callsign?.[t]);
}
/** /**
* A reported length as one a hull can have, else undefined (drawn as built). * A reported length as one a hull can have, else undefined (drawn as built).
* @param {number} [length] * @param {number} [length]
@@ -133,8 +228,9 @@ export function hullBeam(beam) {
/** /**
* The model for a vessel of AIS ship type `shipType` and the reported length * The model for a vessel of AIS ship type `shipType` and the reported length
* and beam (metres) and name: the first rule that matches in the first type * and beam (metres) and name: the first rule that matches in the first type
* range that has one, else `default_vessel`. Drawn at the reported length, as * range that has one, else `default_vessel`. Drawn at the reported length;
* built when it is unknown. * when it is unknown (most live feeds send none), chosen and drawn at the
* range's `typical_len`, else as built.
* @param {object} types Parsed `types.json`. * @param {object} types Parsed `types.json`.
* @param {number} [shipType] * @param {number} [shipType]
* @param {number} [length] * @param {number} [length]
@@ -145,13 +241,14 @@ export function hullBeam(beam) {
export function resolveVessel(types, shipType, length, beam, name) { export function resolveVessel(types, shipType, length, beam, name) {
const L = hullLength(length) ?? 0; const L = hullLength(length) ?? 0;
const B = hullBeam(beam) ?? 0; const B = hullBeam(beam) ?? 0;
const drawn = L > 0 ? L : null;
if (shipType !== undefined && shipType !== null) { if (shipType !== undefined && shipType !== null) {
for (const group of types.vessels) { for (const group of types.vessels) {
if (!(shipType >= group.types[0] && shipType <= group.types[1])) continue; if (!(shipType >= group.types[0] && shipType <= group.types[1])) continue;
const len = L > 0 ? L : (group.typical_len ?? 0);
const drawn = len > 0 ? len : null;
for (const rule of group.rules) { for (const rule of group.rules) {
if (rule.min_len && L < rule.min_len) continue; if (rule.min_len && len < rule.min_len) continue;
if (rule.max_len && L > rule.max_len) continue; if (rule.max_len && len > rule.max_len) continue;
if (rule.name_re && !new RegExp(rule.name_re).test(name ?? '')) if (rule.name_re && !new RegExp(rule.name_re).test(name ?? ''))
continue; continue;
if (rule.max_bl && !(B > 0 && L > 0 && B / L <= rule.max_bl)) continue; if (rule.max_bl && !(B > 0 && L > 0 && B / L <= rule.max_bl)) continue;
@@ -159,7 +256,7 @@ export function resolveVessel(types, shipType, length, beam, name) {
} }
} }
} }
return { model: types.default_vessel, length: drawn }; return { model: types.default_vessel, length: L > 0 ? L : null };
} }
/** /**
@@ -304,24 +401,32 @@ function specOf(set, resolved, fallback, options) {
} }
/** /**
* The model an aircraft is drawn with. The type's entry first; then, for a * The model an aircraft is drawn with. The type's callsign rules (an airport
* vehicle by its callsign), then the type's entry; then, for a
* type the map lacks, the model of its class (a fast jet, a helicopter, a * type the map lacks, the model of its class (a fast jet, a helicopter, a
* glider) when the type names one; then the emitter category's; then * glider) when the type names one; then the emitter category's; then
* `default_aircraft`. * `default_aircraft`.
* @param {{catalog: object, types: object}} set * @param {{catalog: object, types: object}} set
* @param {{typeCode?: string|null, type?: string|null, * @param {{typeCode?: string|null, type?: string|null,
* category?: string|number|null}} aircraft * category?: string|number|null, callsign?: string|null}} aircraft
* @returns {object} {@link fleetModelSpec} * @returns {object} {@link fleetModelSpec}
*/ */
export function aircraftModelSpec(set, { typeCode, type, category } = {}) { export function aircraftModelSpec(
set,
{ typeCode, type, category, callsign } = {},
) {
const { types, catalog } = set; const { types, catalog } = set;
const code = String(typeCode ?? type ?? '') const code = String(typeCode ?? type ?? '')
.trim() .trim()
.toUpperCase(); .toUpperCase();
const emitter = emitterCategory(category); const emitter = emitterCategory(category);
let resolved = resolveAircraft(types, code, emitter); const sign = callsign ? String(callsign) : undefined;
let resolved = resolveAircraft(types, code, emitter, sign);
// A callsign rule names the model outright; a type's class only stands in
// for a type the map lacks.
const ruled = aircraftCallsignRule(types, code, sign) >= 0;
const typed = code && (types.aircraft[code] || types.helicopters[code]); const typed = code && (types.aircraft[code] || types.helicopters[code]);
if (!typed && code) { if (!ruled && !typed && code) {
const model = CLASS_MODELS[classifyAircraft({ typeCode: code })]; const model = CLASS_MODELS[classifyAircraft({ typeCode: code })];
if (model && catalog[model]) if (model && catalog[model])
resolved = { model, length: catalog[model].length }; resolved = { model, length: catalog[model].length };
@@ -334,25 +439,49 @@ export function aircraftModelSpec(set, { typeCode, type, category } = {}) {
/** /**
* The model a vessel is drawn with, from its AIS ship type (a code or a text * The model a vessel is drawn with, from its AIS ship type (a code or a text
* family), reported length and beam (metres) and name. * family), reported length and beam (metres) and name. An aid to navigation
* ({@link isAidToNavigation}: its type names a buoy, beacon, mark, RACON or
* light, or its MMSI is an aid's) is the buoy of its mark
* ({@link aidToNavigationModel}), else {@link DEFAULT_AID_MODEL}, drawn as
* built and flagged `fixed`: it has no heading to turn to. A virtual aid
* (`virtual`) is broadcast where nothing physical stands, so it has no model.
* @param {{catalog: object, types: object}} set * @param {{catalog: object, types: object}} set
* @param {{type?: string|number|null, length?: number|null, * @param {{type?: string|number|null, mmsi?: string|number|null,
* beam?: number|null, name?: string|null}} vessel * virtual?: boolean, length?: number|null, beam?: number|null,
* @returns {object} {@link fleetModelSpec} * name?: string|null}} vessel
* @returns {object|null} {@link fleetModelSpec}, with `fixed` for an aid;
* null for a virtual aid
*/ */
export function vesselModelSpec(set, { type, length, beam, name } = {}) { export function vesselModelSpec(
set,
{ type, mmsi, virtual, length, beam, name } = {},
) {
if (virtual === true) return null;
if (isAidToNavigation({ type, mmsi })) {
const model = aidToNavigationModel(type);
const known = model && set.catalog[model] ? model : DEFAULT_AID_MODEL;
if (set.catalog[known])
return Object.freeze({
...fleetModelSpec(set, known, null),
fixed: true,
});
}
// A beam counts only beside a reported length: a vessel drawn at its type's
// typical length keeps the model's proportions.
const reported = hullLength(length ?? undefined);
const hullWidth = reported === undefined ? undefined : (beam ?? undefined);
const resolved = resolveVessel( const resolved = resolveVessel(
set.types, set.types,
aisShipTypeCode(type), aisShipTypeCode(type),
length ?? undefined, reported,
beam ?? undefined, hullWidth,
name ?? undefined, name ?? undefined,
); );
return specOf( return specOf(
set, set,
resolved, resolved,
(r) => ({ model: set.types.default_vessel, length: r.length }), (r) => ({ model: set.types.default_vessel, length: r.length }),
{ beam: beam ?? undefined }, { beam: hullWidth },
); );
} }
@@ -381,6 +510,9 @@ export function namedFleetModels(types) {
...pairs(types.aircraft), ...pairs(types.aircraft),
...pairs(types.helicopters), ...pairs(types.helicopters),
...pairs(types.categories), ...pairs(types.categories),
...Object.values(types.aircraft_callsign ?? {}).flatMap((rules) =>
rules.map((rule) => rule.model),
),
...types.vessels.flatMap((group) => group.rules.map((rule) => rule.model)), ...types.vessels.flatMap((group) => group.rules.map((rule) => rule.model)),
types.default_aircraft[0], types.default_aircraft[0],
types.default_vessel, types.default_vessel,
@@ -390,6 +522,8 @@ export function namedFleetModels(types) {
transit.default[0], transit.default[0],
...Object.keys(transit.elevation), ...Object.keys(transit.elevation),
...Object.values(CLASS_MODELS), ...Object.values(CLASS_MODELS),
...AID_MODELS.map(([, model]) => model),
DEFAULT_AID_MODEL,
]); ]);
} }
+202
View File
@@ -4,7 +4,9 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { import {
DEFAULT_AID_MODEL,
FLEET_MODEL_BASE, FLEET_MODEL_BASE,
aidToNavigationModel,
aircraftModelSpec, aircraftModelSpec,
aisShipTypeCode, aisShipTypeCode,
emitterCategory, emitterCategory,
@@ -23,6 +25,7 @@ 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,
@@ -51,12 +54,22 @@ const sliceTypes = {
A21N: ['a320', 44.51], A21N: ['a320', 44.51],
A319: ['a320', 33.84], A319: ['a320', 33.84],
B38M: ['b738', 39.52], B38M: ['b738', 39.52],
ZVEH: ['car', 4.61],
GRND: ['radio_mast', 1.9],
}, },
helicopters: { EC35: ['ec35', 10.2] }, helicopters: { EC35: ['ec35', 10.2] },
aircraft_callsign: {
ZVEH: [
{ prefixes: ['FIRE'], model: 'fire_truck', length: 11.01 },
{ prefixes: ['PUSH', 'TUG'], model: 'pushback', length: 6 },
{ prefixes: ['DEICER', 'SQUID', 'SQB'], model: 'van', length: 5.51 },
],
},
categories: { A1: ['c172', 8.3] }, categories: { A1: ['c172', 8.3] },
vessels: [ vessels: [
{ {
types: [70, 79], types: [70, 79],
typical_len: 300,
rules: [ rules: [
{ min_len: 70, max_len: 140, max_bl: 0.12, model: 'inland_cargo' }, { min_len: 70, max_len: 140, max_bl: 0.12, model: 'inland_cargo' },
{ {
@@ -111,6 +124,33 @@ test('aircraft resolve by type, then emitter category, then the generic plane',
}); });
}); });
test('airport vehicles (ZVEH) by callsign, first rule wins, else the type: spaces and case ignored', () => {
const zveh = (callsign) =>
resolveAircraft(sliceTypes, 'ZVEH', undefined, callsign);
assert.deepEqual(zveh('FIRE 2'), { model: 'fire_truck', length: 11.01 });
assert.deepEqual(resolveAircraft(sliceTypes, 'zveh', undefined, ' fire2 '), {
model: 'fire_truck',
length: 11.01,
});
assert.equal(zveh('PUSH 14').model, 'pushback');
assert.equal(zveh('TUG3').model, 'pushback');
assert.deepEqual(zveh('DEICER 4'), { model: 'van', length: 5.51 });
assert.equal(zveh('SQUID20').model, 'van');
assert.equal(zveh('SQB9').model, 'van');
assert.equal(zveh('S Q B 9').model, 'van');
assert.deepEqual(zveh('LEADER 7'), { model: 'car', length: 4.61 });
assert.deepEqual(zveh(undefined), { model: 'car', length: 4.61 });
// Callsign rules belong to their type: a fire-named airliner is still an airliner.
assert.equal(
resolveAircraft(sliceTypes, 'A320', undefined, 'FIRE1').model,
'a320',
);
assert.deepEqual(resolveAircraft(sliceTypes, 'GRND', undefined, 'RX5'), {
model: 'radio_mast',
length: 1.9,
});
});
test('vessels: the first matching rule of the first type range with one, else the generic vessel', () => { test('vessels: the first matching rule of the first type range with one, else the generic vessel', () => {
assert.equal(resolveVessel(sliceTypes, 70, 366, 51).model, 'container_ship'); assert.equal(resolveVessel(sliceTypes, 70, 366, 51).model, 'container_ship');
assert.equal( assert.equal(
@@ -133,6 +173,29 @@ test('vessels: the first matching rule of the first type range with one, else th
}); });
}); });
test("vessels of unknown length are chosen and drawn at their range's typical length", () => {
assert.deepEqual(resolveVessel(sliceTypes, 70), {
model: 'container_ship',
length: 300,
});
assert.deepEqual(resolveVessel(sliceTypes, 70, 511, 126), {
model: 'container_ship',
length: 300,
});
assert.deepEqual(resolveVessel(sliceTypes, 70, 90, 15), {
model: 'general_cargo',
length: 90,
});
assert.deepEqual(resolveVessel(sliceTypes, 31), {
model: 'tug',
length: null,
});
// Every passenger, cargo and tanker range of the set has one.
for (const code of [60, 70, 80]) {
assert.ok(resolveVessel(types, code).length > 100, String(code));
}
});
test("vessels: AIS's out-of-range sizes (511 m long, 126 m abeam) are unknown, drawn as built", () => { test("vessels: AIS's out-of-range sizes (511 m long, 126 m abeam) are unknown, drawn as built", () => {
assert.deepEqual(resolveVessel(sliceTypes, 52, 511), { assert.deepEqual(resolveVessel(sliceTypes, 52, 511), {
model: 'tug', model: 'tug',
@@ -201,6 +264,46 @@ test('transit bodies mark the share of the route colour they take; other models
} }
}); });
test('aircraft specs: airport vehicles by callsign, ground stations as a mast, as the set says', () => {
const zveh = (callsign) =>
aircraftModelSpec(set, { typeCode: 'ZVEH', category: 'UNKNOWN', callsign })
.model;
assert.equal(zveh('FIRE 2'), 'fire_truck');
assert.equal(zveh('PUSHBACK1'), 'pushback');
assert.equal(zveh('TUG 4'), 'pushback');
assert.equal(zveh('DEICER 4'), 'van');
assert.equal(zveh('SQUID20'), 'van');
assert.equal(zveh('SQB9'), 'van');
assert.equal(zveh('LEADER 7'), 'car');
assert.equal(zveh('AOPS'), 'car');
assert.equal(zveh(null), 'car');
const mast = aircraftModelSpec(set, { typeCode: 'GRND', callsign: 'RX5' });
assert.equal(mast.model, 'radio_mast');
assert.equal(mast.scale, 1);
});
test('a callsign rule outranks the class model of a type the map lacks', () => {
const ruled = {
...set,
types: {
...set.types,
aircraft_callsign: {
...set.types.aircraft_callsign,
PUMA: [{ prefixes: ['FIRE'], model: 'fire_truck', length: 11.01 }],
},
},
};
assert.equal(
aircraftModelSpec(ruled, { typeCode: 'PUMA', callsign: 'FIRE 1' }).model,
'fire_truck',
);
// No rule matched: the class model, as before.
assert.equal(
aircraftModelSpec(ruled, { typeCode: 'PUMA', callsign: 'RESCUE1' }).model,
'generic_heli',
);
});
test('aircraft specs: by type, then class for an unlisted type, then emitter category', () => { test('aircraft specs: by type, then class for an unlisted type, then emitter category', () => {
assert.equal(aircraftModelSpec(set, { typeCode: 'A320' }).model, 'a320'); assert.equal(aircraftModelSpec(set, { typeCode: 'A320' }).model, 'a320');
assert.equal(aircraftModelSpec(set, { type: 'b744' }).model, 'b744'); assert.equal(aircraftModelSpec(set, { type: 'b744' }).model, 'b744');
@@ -267,6 +370,12 @@ test('vessel specs read AIS codes and text families, stretched to the reported b
const [sx, sy, sz] = box.scaleXYZ; const [sx, sy, sz] = box.scaleXYZ;
assert.ok(Math.abs(sy - 51 / catalog.container_ship.beam) < 1e-12); assert.ok(Math.abs(sy - 51 / catalog.container_ship.beam) < 1e-12);
assert.ok(Math.abs(sz - (sx + sy) / 2) < 1e-12); assert.ok(Math.abs(sz - (sx + sy) / 2) < 1e-12);
// A beam with no reported length: the type's typical length, in the model's
// own proportions, not a typical-length hull squeezed to that beam.
const unsized = vesselModelSpec(set, { type: '70', length: 0, beam: 14 });
assert.equal(unsized.model, 'container_ship');
const [ux, uy, uz] = unsized.scaleXYZ;
assert.ok(Math.abs(uy - ux) < 1e-12 && Math.abs(uz - ux) < 1e-12);
assert.equal(vesselModelSpec(set, { type: 'Tug', length: 30 }).model, 'tug'); assert.equal(vesselModelSpec(set, { type: 'Tug', length: 30 }).model, 'tug');
assert.equal(vesselModelSpec(set, { type: '36' }).model, 'sailboat'); assert.equal(vesselModelSpec(set, { type: '36' }).model, 'sailboat');
// No type, no size: the generic vessel as built. // No type, no size: the generic vessel as built.
@@ -275,6 +384,99 @@ test('vessel specs read AIS codes and text families, stretched to the reported b
assert.equal(unknown.scale, 1); assert.equal(unknown.scale, 1);
}); });
test('aids to navigation are the buoy of the mark their type names', () => {
const cases = [
['Cardinal Mark N', 'buoy_cardinal_north'],
['Beacon, Cardinal E', 'buoy_cardinal_east'],
['Cardinal Mark S', 'buoy_cardinal_south'],
['Beacon, Cardinal W', 'buoy_cardinal_west'],
['Port Hand Mark', 'buoy_lateral_port'],
['Beacon, Port hand', 'buoy_lateral_port'],
['Starboard Hand Mark', 'buoy_lateral_starboard'],
['Preferred Channel Starboard hand', 'buoy_lateral_starboard'],
['Safe Water', 'buoy_safe_water'],
['Isolated danger', 'buoy_isolated_danger'],
['Beacon, Special Mark', 'buoy_special'],
['Special Mark', 'buoy_special'],
['Light Vessel / LANBY / Rigs', 'buoy_special'],
['RACON', 'buoy_special'],
['Light, with Sectors', 'buoy_special'],
['Reference point', 'buoy_special'],
['Navigation aid', 'buoy_special'],
];
for (const [type, model] of cases) {
assert.equal(aidToNavigationModel(type), model, type);
assert.ok(catalog[model], model);
const spec = vesselModelSpec(set, { type, mmsi: '993672001' });
assert.equal(spec.model, model, type);
assert.equal(spec.category, 'buoys', type);
assert.equal(spec.fixed, true, type);
// A buoy is drawn as built, whatever length the report gives.
assert.equal(spec.scale, 1, type);
}
assert.equal(
vesselModelSpec(set, { type: 'Safe Water', length: 120, beam: 30 }).scale,
1,
);
// An aid of no stated kind, known only by its MMSI, is the default can.
assert.ok(catalog[DEFAULT_AID_MODEL]);
const unnamed = vesselModelSpec(set, { type: '', mmsi: '993660123' });
assert.equal(unnamed.model, DEFAULT_AID_MODEL);
assert.equal(unnamed.fixed, true);
assert.equal(aidToNavigationModel('Fixed structure off shore'), undefined);
// A virtual aid is broadcast where nothing stands: it keeps its disc.
assert.equal(
vesselModelSpec(set, {
type: 'Cardinal Mark N',
mmsi: '993672001',
virtual: true,
}),
null,
);
// Vessels, including those that work on buoys, keep their hulls.
for (const vessel of [
{ type: 'Buoy Tender', mmsi: '366999001', length: 60 },
{ type: '70', mmsi: '366999002' },
{ type: 'Tug', mmsi: '' },
]) {
const spec = vesselModelSpec(set, vessel);
assert.notEqual(spec.category, 'buoys', vessel.type);
assert.equal(spec.fixed, undefined, vessel.type);
}
});
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',
+5 -4
View File
@@ -32,7 +32,7 @@ export const FLEET_TRAIL_ANCHORS = Object.freeze({
c172: Object.freeze([4.0507, 0.4481, 0]), c172: Object.freeze([4.0507, 0.4481, 0]),
c30j: Object.freeze([13.568, 0.9917, 0]), c30j: Object.freeze([13.568, 0.9917, 0]),
c56x: Object.freeze([7.4627, 0.4526, 0]), c56x: Object.freeze([7.4627, 0.4526, 0]),
car: Object.freeze([2.3, 0.418, 0]), car: Object.freeze([2.1851, 0.301, 0]),
cl35: Object.freeze([10.4607, 0.8247, 0]), cl35: Object.freeze([10.4607, 0.8247, 0]),
crj9: Object.freeze([17.8413, 0.8624, 0]), crj9: Object.freeze([17.8413, 0.8624, 0]),
dh8d: Object.freeze([15.7902, 0.7501, 0]), dh8d: Object.freeze([15.7902, 0.7501, 0]),
@@ -40,7 +40,7 @@ export const FLEET_TRAIL_ANCHORS = Object.freeze({
e55p: Object.freeze([7.6566, 0.503, 0]), e55p: Object.freeze([7.6566, 0.503, 0]),
ec35: Object.freeze([5.1003, 0.1471, 0]), ec35: Object.freeze([5.1003, 0.1471, 0]),
ec45: Object.freeze([5.8004, 0.555, 0]), ec45: Object.freeze([5.8004, 0.555, 0]),
fire_truck: Object.freeze([5.5, 0.6051, 0]), fire_truck: Object.freeze([5.39, 0.4227, 0]),
generic_heli: Object.freeze([5.4997, 0.1604, 0]), generic_heli: Object.freeze([5.4997, 0.1604, 0]),
generic_plane: Object.freeze([16.5228, 0.712, 0]), generic_plane: Object.freeze([16.5228, 0.712, 0]),
glf4: Object.freeze([13.4603, 0.8243, 0]), glf4: Object.freeze([13.4603, 0.8243, 0]),
@@ -53,8 +53,9 @@ export const FLEET_TRAIL_ANCHORS = Object.freeze({
mq9: Object.freeze([5.8498, -0.062, 0]), mq9: Object.freeze([5.8498, -0.062, 0]),
p28a: Object.freeze([3.3695, 0.3959, 0]), p28a: Object.freeze([3.3695, 0.3959, 0]),
pc12: Object.freeze([6.6416, 0.5288, 0]), pc12: Object.freeze([6.6416, 0.5288, 0]),
pushback: Object.freeze([3, 0.2499, 0]), pushback: Object.freeze([2.9536, 0.388, 0]),
r44: Object.freeze([4.4219, -0.3001, 0]), r44: Object.freeze([4.4219, -0.3001, 0]),
radio_mast: Object.freeze([1.2, 0, 0]),
s76: Object.freeze([6.6001, 0.1915, 0]), s76: Object.freeze([6.6001, 0.1915, 0]),
sr22: Object.freeze([3.6762, 0.2376, 0]), sr22: Object.freeze([3.6762, 0.2376, 0]),
t38: Object.freeze([7.0246, -0.3791, 0]), t38: Object.freeze([7.0246, -0.3791, 0]),
@@ -62,5 +63,5 @@ export const FLEET_TRAIL_ANCHORS = Object.freeze({
tower: Object.freeze([1.2952, -0.0007, 0]), tower: Object.freeze([1.2952, -0.0007, 0]),
ultralight: Object.freeze([3.2271, 0.3259, 0]), ultralight: Object.freeze([3.2271, 0.3259, 0]),
v22: Object.freeze([7.9868, 0.2956, 0]), v22: Object.freeze([7.9868, 0.2956, 0]),
van: Object.freeze([2.75, 0.418, 0]), van: Object.freeze([2.695, 0.2812, 0]),
}); });
@@ -0,0 +1,42 @@
# 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.
@@ -0,0 +1 @@
{"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
+46 -1
View File
@@ -57,7 +57,14 @@ test('every typed aircraft renders at its published length, a real-world aircraf
); );
// A grounded aircraft stands on its wheels: its origin the scaled // A grounded aircraft stands on its wheels: its origin the scaled
// clearance above the ground, never below it. // clearance above the ground, never below it.
assert.ok(spec.clearanceM > 0, `${type}: clearance ${spec.clearanceM}`); // A model built standing on its origin (a ground vehicle, a ground
// station's mast: its lowest point at z 0) has no clearance.
if (set.catalog[model].min[2] > -0.05)
assert.ok(
spec.clearanceM >= 0 && spec.clearanceM < 0.05 * spec.scale,
`${type}: built standing, clearance ${spec.clearanceM}`,
);
else assert.ok(spec.clearanceM > 0, `${type}: clearance ${spec.clearanceM}`);
assert.ok(spec.clearanceM < radius, `${type}: clearance larger than the aircraft`); assert.ok(spec.clearanceM < radius, `${type}: clearance larger than the aircraft`);
} }
} }
@@ -296,3 +303,41 @@ test('a layer asking every tick gets one cached spec per type and category', asy
assert.notEqual(aircraftLayerModelSpec({ typeCode: 'B744' }, set), a); assert.notEqual(aircraftLayerModelSpec({ typeCode: 'B744' }, set), a);
assert.ok(Object.isFrozen(a)); assert.ok(Object.isFrozen(a));
}); });
test('an airport vehicle is cached by its callsign; other aircraft by type and category only', async () => {
const { aircraftLayerModelSpec, aircraftModelSpecCacheKey } = await import(
'../layers/flights/modelSpec.js'
);
const tender = aircraftLayerModelSpec(
{ typeCode: 'ZVEH', callsign: 'FIRE 2' },
set,
);
const car = aircraftLayerModelSpec(
{ typeCode: 'ZVEH', callsign: 'LEADER 7' },
set,
);
assert.equal(tender.model, 'fire_truck');
assert.equal(car.model, 'car');
assert.equal(
aircraftModelSpecCacheKey({ typeCode: 'A320', callsign: 'BAW1' }, set),
aircraftModelSpecCacheKey({ typeCode: 'A320', callsign: 'DLH2' }, set),
);
assert.notEqual(
aircraftModelSpecCacheKey({ typeCode: 'ZVEH', callsign: 'FIRE 2' }, set),
aircraftModelSpecCacheKey({ typeCode: 'ZVEH', callsign: 'LEADER 7' }, set),
);
// Keyed by the rule matched, not the callsign: at most one entry per rule
// and one for the type's own model.
assert.equal(
aircraftModelSpecCacheKey({ typeCode: 'ZVEH', callsign: 'FIRE 2' }, set),
aircraftModelSpecCacheKey({ typeCode: 'ZVEH', callsign: 'FIRE 9' }, set),
);
assert.equal(
aircraftModelSpecCacheKey({ typeCode: 'ZVEH', callsign: 'LEADER 7' }, set),
aircraftModelSpecCacheKey({ typeCode: 'ZVEH', callsign: 'ASD' }, set),
);
assert.equal(
aircraftLayerModelSpec({ typeCode: 'ZVEH', callsign: 'FIRE 9' }, set),
tender,
);
});
+131
View File
@@ -0,0 +1,131 @@
/**
* 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
@@ -0,0 +1,108 @@
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'));
});
+9 -1
View File
@@ -1244,7 +1244,15 @@ 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);
assert.deepEqual(worldClock.coverage(), { start: at, end: at, marks: [at] }); // The launch is a mark labelled with its mission's name.
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;
+1 -3
View File
@@ -291,7 +291,7 @@ test('tracking layers write gevLabelModel and expose only their cached display p
assert.equal(sources['satellites.js'].includes('_trackedEntity.label.text'), false); assert.equal(sources['satellites.js'].includes('_trackedEntity.label.text'), false);
}); });
test('civilian and military trail heads use the lower-centre model anchor and weak-texture tint', async () => { test('civilian and military trail heads use the lower-centre model anchor', async () => {
const files = await Promise.all(['flights.js', 'militaryFlights.js'].map(async (name) => ( const files = await Promise.all(['flights.js', 'militaryFlights.js'].map(async (name) => (
[name, readLayerSource(new URL(`./${name}`, import.meta.url))] [name, readLayerSource(new URL(`./${name}`, import.meta.url))]
))); )));
@@ -300,7 +300,5 @@ test('civilian and military trail heads use the lower-centre model anchor and we
(/const\s*head\s*=\s*(?:parts\.motion\.)?_trackedTrailCached\(\s*,?\s*\)\s*\|\|\s*(?:parts\.motion\.)?_trackedDisplayPosition\(\s*(?:flightState\.)?_trackedIcao,?\s*\);/).test(source), (/const\s*head\s*=\s*(?:parts\.motion\.)?_trackedTrailCached\(\s*,?\s*\)\s*\|\|\s*(?:parts\.motion\.)?_trackedDisplayPosition\(\s*(?:flightState\.)?_trackedIcao,?\s*\);/).test(source),
`${name} trail head uses the dedicated lower-centre model anchor`, `${name} trail head uses the dedicated lower-centre model anchor`,
); );
assert.ok(source.includes('const MODEL_COLOR_BLEND_AMOUNT = 0.94;'),
`${name} keeps diffuse texture contribution weak through code-side MIX`);
} }
}); });
+239 -16
View File
@@ -29,12 +29,21 @@ 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',
]); ]);
@@ -42,13 +51,81 @@ 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
@@ -443,7 +520,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 'rail'; if (line === 'FUNI') return 'funicular';
return /^[AB]$/.test(line) ? 'tram' : 'bus'; return /^[AB]$/.test(line) ? 'tram' : 'bus';
} }
@@ -470,10 +547,14 @@ 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
@@ -487,7 +568,9 @@ 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: 'rail', label: 'LD' }; if (n === 49) return { mode: 'funicular', 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}` };
@@ -512,6 +595,102 @@ 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.
@@ -571,6 +750,7 @@ 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,
* }>>} * }>>}
@@ -579,6 +759,9 @@ 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({
@@ -1264,6 +1447,7 @@ 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',
@@ -1296,6 +1480,7 @@ 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({
@@ -1401,6 +1586,7 @@ 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',
@@ -1567,6 +1753,8 @@ 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',
@@ -1664,54 +1852,89 @@ export function transitFeedsInRange(lat, lon, slackKm = 0) {
} }
/** /**
* The feed's own hint for a vehicle: its fleet number when the feed maps * What settles a vehicle's mode, most specific first: the feed's fleet hint
* those (`vehicleMode`), else its route (`routeMode`). Null when neither knows. * (`vehicleMode`, which knows a bus standing in on a tram route), else the
* 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) { function hintedMode(feed, routeId, vehicleId, routeType) {
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: the feed's fleet or route hint when it has one, else * Mode for a vehicle: its fleet hint, route type or route hint when one
* its default. * knows ({@link hintedMode}), else its feed's 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(feed, routeId, vehicleId = null) { export function transitModeFor(
const hinted = hintedMode(feed, routeId, vehicleId); feed,
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 route id rather than the feed's default. A * Whether the mode came from the vehicle, its route type or its route id
* default is a guess about a fleet — TransLink's is "bus" while it publishes * rather than the feed's default. A default is a guess about a fleet —
* rail in the same feed — so a consumer judging physical plausibility must * TransLink's is "bus" while it publishes rail in the same feed — so a
* not hold a defaulted vehicle to a bus's limits. * consumer judging physical plausibility must not hold a defaulted vehicle
* 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(feed, routeId, vehicleId = null) { export function transitModeResolved(
const hinted = hintedMode(feed, routeId, vehicleId); feed,
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
+120 -3
View File
@@ -11,9 +11,13 @@ 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';
@@ -729,8 +733,10 @@ 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'), ['bus', '58'], 'trolleybus'); assert.deepEqual(read('L58'), ['trolleybus', '58']);
assert.deepEqual(read('L49'), ['rail', 'LD'], 'Petřín funicular'); assert.deepEqual(read('L55'), ['bus', '55'], 'not a PID trolleybus line');
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']);
@@ -767,7 +773,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'), 'rail'); assert.equal(transitModeFor(lia, '8-FUNI'), 'funicular');
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');
@@ -778,3 +784,114 @@ 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');
});
+80 -1
View File
@@ -21,7 +21,9 @@ 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. * hull with a bow. A trolleybus trails its two poles, a cable car is a short
* 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
@@ -103,6 +105,49 @@ 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: `
@@ -146,6 +191,40 @@ 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"
+8 -2
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('all 72 variants retain the specified final halo at both display sizes', async () => { test('every variant retains 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,7 +97,8 @@ test('all 72 variants retain the specified final halo at both display sizes', as
} }
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 });
assert.equal(transitIconCacheSize(), 72); // Every kind at two sizes, three profiles, with and without a course.
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 () => {
@@ -197,6 +198,11 @@ 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]) {
+11 -1
View File
@@ -60,9 +60,14 @@ 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]),
}), }),
@@ -72,9 +77,14 @@ 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',
]); ]);
@@ -128,7 +138,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 these six, so the bracket colour is per mode in every * table carries one per mode, 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,6 +19,7 @@ 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;
+9
View File
@@ -46,6 +46,15 @@ const TYPE_STYLES = [
]; ];
const DEFAULT_STYLE = { css: '#39d5ff', accent: '57, 213, 255' }; const DEFAULT_STYLE = { css: '#39d5ff', accent: '57, 213, 255' };
/**
* An aid to navigation's disc and card accent: a bone white apart from every
* ship type's colour.
*/
export const AID_TO_NAVIGATION_STYLE = Object.freeze({
css: '#d9d2c0',
accent: '217, 210, 192',
});
// Card text for the type tokens above. The tokens stay English: TYPE_STYLES // Card text for the type tokens above. The tokens stay English: TYPE_STYLES
// and tests match on them. // and tests match on them.
const TYPE_DISPLAY_KEYS = Object.freeze({ const TYPE_DISPLAY_KEYS = Object.freeze({
+107
View File
@@ -45,3 +45,110 @@ export function normalizeVesselType(type) {
if (NUMERIC_TYPE_SPECIALS[code]) return NUMERIC_TYPE_SPECIALS[code]; if (NUMERIC_TYPE_SPECIALS[code]) return NUMERIC_TYPE_SPECIALS[code];
return NUMERIC_TYPE_FAMILIES[Math.floor(code / 10)] || 'OTHER'; return NUMERIC_TYPE_FAMILIES[Math.floor(code / 10)] || 'OTHER';
} }
/**
* The kind of aid to navigation an AIS aid-to-navigation report (message 21)
* names by its type code, 0 to 31 (ITU-R M.1371, IALA marks): beacons and
* floating marks by their cardinal quadrant, lateral side, safe water,
* isolated danger or special mark, and the lights, RACONs and reference
* points around them. Code 0 names no kind.
*/
const AID_TYPE_NAMES = Object.freeze([
'Navigation aid',
'Reference point',
'RACON',
'Fixed structure off shore',
'Emergency wreck marking buoy',
'Light, without sectors',
'Light, with sectors',
'Leading light front',
'Leading light rear',
'Beacon, Cardinal N',
'Beacon, Cardinal E',
'Beacon, Cardinal S',
'Beacon, Cardinal W',
'Beacon, Port hand',
'Beacon, Starboard hand',
'Beacon, Preferred channel port hand',
'Beacon, Preferred channel starboard hand',
'Beacon, Isolated danger',
'Beacon, Safe water',
'Beacon, Special mark',
'Cardinal Mark N',
'Cardinal Mark E',
'Cardinal Mark S',
'Cardinal Mark W',
'Port hand Mark',
'Starboard hand Mark',
'Preferred Channel Port hand',
'Preferred Channel Starboard hand',
'Isolated danger',
'Safe Water',
'Special Mark',
'Light Vessel / LANBY / Rigs',
]);
/**
* The kind an AIS aid-to-navigation type code names ("19" → "Beacon, Special
* mark"); "Navigation aid" for code 0 and for a code outside 0 to 31.
* @param {string|number|null|undefined} code
* @returns {string}
*/
export function aidToNavigationTypeName(code) {
const text = String(code ?? '').trim();
const index = /^\d+$/.test(text) ? Number(text) : -1;
return AID_TYPE_NAMES[index] ?? AID_TYPE_NAMES[0];
}
/** Every kind {@link aidToNavigationTypeName} names, lowercased. */
const AID_TYPE_NAME_SET = new Set(
AID_TYPE_NAMES.map((name) => name.toLowerCase()),
);
/**
* Words a vessel type uses for an aid to navigation. A vessel that works on
* one ("Buoy Tender", "Buoy-Laying Vessel", "Buoy Layer") is a ship underway,
* not a mark on the water.
*/
const AID_TYPE_TEXT =
/navaid|\baton\b|aids? to nav|nav(igation)? aid|racon|lighthouse|lightship|light vessel|reference point|hand mark|cardinal|safe water|preferred channel|isolated danger|special mark|beacon|buoy/i;
const AID_WORKER_TEXT = /tender|lay(ing|er)\b/i;
/** ITU MID 99: the MMSI of an AIS aid-to-navigation station (99MIDxxxx). */
const AID_MMSI = /^99\d{7}$/;
/**
* Whether each type text seen names an aid. Vessel layers ask per vessel on
* their draw passes, and a feed uses few distinct type texts, so each is
* matched once; the memo is bounded against a feed of free-form types.
*/
const AID_TYPE_MEMO = new Map();
const AID_TYPE_MEMO_MAX = 1024;
function typeNamesAid(text) {
let aid = AID_TYPE_MEMO.get(text);
if (aid !== undefined) return aid;
aid =
AID_TYPE_NAME_SET.has(text.toLowerCase()) ||
(AID_TYPE_TEXT.test(text) && !AID_WORKER_TEXT.test(text));
if (AID_TYPE_MEMO.size >= AID_TYPE_MEMO_MAX) AID_TYPE_MEMO.clear();
AID_TYPE_MEMO.set(text, aid);
return aid;
}
/**
* Whether a vessel record is an aid to navigation (a buoy, beacon, mark,
* RACON or light) rather than a vessel: its type is one of the kinds an aid
* report names or uses a word for one, or its MMSI is an aid-to-navigation
* station's, or it is a virtual aid. A numeric AIS ship type never names one.
* @param {{type?: string|number|null, mmsi?: string|number|null,
* virtual?: boolean}} vessel
* @returns {boolean}
*/
export function isAidToNavigation({ type, mmsi, virtual } = {}) {
if (virtual === true) return true;
const text = typeof type === 'string' ? type.trim() : String(type ?? '');
if (text && typeNamesAid(text)) return true;
if (mmsi === null || mmsi === undefined || mmsi === '') return false;
return AID_MMSI.test(typeof mmsi === 'string' ? mmsi.trim() : String(mmsi));
}
+61
View File
@@ -0,0 +1,61 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { aidToNavigationTypeName, isAidToNavigation } from './vesselTypes.js';
test('an aid-to-navigation type code names its kind of aid', () => {
assert.equal(aidToNavigationTypeName(20), 'Cardinal Mark N');
assert.equal(aidToNavigationTypeName('13'), 'Beacon, Port hand');
assert.equal(aidToNavigationTypeName(2), 'RACON');
assert.equal(aidToNavigationTypeName(29), 'Safe Water');
assert.equal(aidToNavigationTypeName(31), 'Light Vessel / LANBY / Rigs');
// Code 0, a code past 31 and no code name no kind.
for (const code of [0, 32, -1, '', null, undefined, 'x'])
assert.equal(aidToNavigationTypeName(code), 'Navigation aid', code);
});
test('aids to navigation are known by their type, else their MMSI', () => {
for (const type of [
'Beacon, Special Mark',
'Cardinal Mark N',
'Port Hand Mark',
'Starboard Hand Mark',
'Safe Water',
'Isolated danger',
'Preferred Channel Port hand',
'RACON',
'Light Vessel / LANBY / Rigs',
'Reference point',
'Navigation aid',
'AtoN',
'Lighthouse',
'Buoy',
// Every kind an aid report names is an aid, whatever its MMSI.
'Light, with sectors',
'Leading light rear',
'Fixed structure off shore',
'Emergency wreck marking buoy',
])
assert.equal(isAidToNavigation({ type, mmsi: '' }), true, type);
// A virtual aid is an aid whatever its type or MMSI.
assert.equal(
isAidToNavigation({ type: '', mmsi: '', virtual: true }),
true,
);
// AIS aid-to-navigation stations are numbered 99MIDxxxx.
assert.equal(isAidToNavigation({ type: '', mmsi: '993672001' }), true);
assert.equal(isAidToNavigation({ type: '0', mmsi: ' 992351234 ' }), true);
for (const vessel of [
{ type: 'Buoy Tender', mmsi: '366999001' },
{ type: 'Buoy-Laying Vessel', mmsi: '' },
{ type: 'Buoy Layer', mmsi: '' },
{ type: 'Lighter', mmsi: '' },
{ type: 'Flight deck', mmsi: '' },
{ type: 70, mmsi: 366999002 },
{ type: '70', mmsi: '366999002' },
{ type: '0', mmsi: '' },
{ type: 'Cargo', mmsi: '99' },
{ type: 'Passenger', mmsi: '9936720011' },
{},
])
assert.equal(isAidToNavigation(vessel), false, JSON.stringify(vessel));
});
Loaded 100 of 338 files, more files were not shown because too many files have changed in this diff. Show more