Compare commits

...
Author SHA1 Message Date
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
111 changed files with 4021 additions and 171 deletions

No files matched your search

@@ -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).
+81 -7
View File
@@ -365,6 +365,25 @@ 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 at most every
250 ms, with a frame asked for each 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 +646,36 @@ 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.
## 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.
## 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,
@@ -3425,7 +3474,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.
@@ -5868,7 +5918,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
@@ -5934,9 +6003,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
@@ -7430,7 +7500,7 @@ pins and share links already supply complete boxes.
30-second backoff ahead of the current clock are ignored. A smaller rollback 30-second backoff ahead of the current clock are ignored. A smaller rollback
can retain a bounded retry delay of up to 30 seconds. can retain a bounded retry delay of up to 30 seconds.
- Commercial and military aircraft use the same high-level FLEET model regime: 2D billboards when zoomed out, optional glTF models when closer, controlled by the DISPLAY rail `3D` toggle and `Proximity` / `All` modes. Every model is one of the project's procedural fleet models (`public/models/fleet/`, MIT, 122 GLBs, about 2 MB in all; `public/models/README.md`), chosen per aircraft by `aircraftModelSpec` (`src/data/fleetModels.js`): the first of its ICAO type's callsign rules in `types.json` (`aircraft_callsign`) its callsign matches, else its type's entry, else the model of its `classifyAircraft` class for a type the map lacks, else its ADS-B emitter category's, else the default airliner. It is drawn LIFE SIZE: the type's published length over the model's built length (`src/layers/flights/modelSpec.js`, shared by Flights, Military Flights and Local ADS-B), and a grounded aircraft stands the model's scaled clearance above the ground. An airport ground vehicle (feed type `ZVEH`) is a fire truck (`FIRE…`), pushback tug (`PUSH…`, `TUG…`), van (`DEICER…`, `SQUID…`, `SQB…`) or else a car by its callsign, and a ground station (`GRND`) is an 11.8 m radio mast; these models (catalog category `other`) are drawn at their built size, and such a contact with no altitude stands on the ground instead of at the airborne default (`src/layers/flights/records.js`). `classifyAircraft` still picks the 2D icons. The whole set loads once, up front, when a layer that draws it starts (`loadFleetModelSet`, which logs `[Fleet] N/122 models (X MB) loaded in N ms`); models are built from those bytes, never fetched mid-session, and an aircraft stays its billboard until the set has loaded. Every aircraft model draws its mesh's own colours (`aircraftModelBlend`, `src/data/aircraftModelLighting.js`): Cesium's `ColorBlendMode.HIGHLIGHT` with white, in Flights, Military Flights and Local ADS-B alike; the tracked colour (cyan, or amber in Military Flights) and the emergency red multiply into the mesh, so its paint still shows. IR boost draws them flat white (`MIX` at `1.0`). Under NVG/FLIR (map preset or Cockpit vision) models render unlit flat-white at full alpha and scene fog is disabled (fog otherwise blacks out distant models with the globe hidden); state restores on exit. - Commercial and military aircraft use the same high-level FLEET model regime: 2D billboards when zoomed out, optional glTF models when closer, controlled by the DISPLAY rail `3D` toggle and `Proximity` / `All` modes. Every model is one of the project's procedural fleet models (`public/models/fleet/`, MIT, 122 GLBs, about 2 MB in all; `public/models/README.md`), chosen per aircraft by `aircraftModelSpec` (`src/data/fleetModels.js`): the first of its ICAO type's callsign rules in `types.json` (`aircraft_callsign`) its callsign matches, else its type's entry, else the model of its `classifyAircraft` class for a type the map lacks, else its ADS-B emitter category's, else the default airliner. It is drawn LIFE SIZE: the type's published length over the model's built length (`src/layers/flights/modelSpec.js`, shared by Flights, Military Flights and Local ADS-B), and a grounded aircraft stands the model's scaled clearance above the ground. An airport ground vehicle (feed type `ZVEH`) is a fire truck (`FIRE…`), pushback tug (`PUSH…`, `TUG…`), van (`DEICER…`, `SQUID…`, `SQB…`) or else a car by its callsign, and a ground station (`GRND`) is an 11.8 m radio mast; these models (catalog category `other`) are drawn at their built size, and such a contact with no altitude stands on the ground instead of at the airborne default (`src/layers/flights/records.js`). `classifyAircraft` still picks the 2D icons. The whole set loads once, up front, when a layer that draws it starts (`loadFleetModelSet`, which logs `[Fleet] N/122 models (X MB) loaded in N ms`); models are built from those bytes, never fetched mid-session, and an aircraft stays its billboard until the set has loaded. Every aircraft model draws its mesh's own colours (`aircraftModelBlend`, `src/data/aircraftModelLighting.js`): Cesium's `ColorBlendMode.HIGHLIGHT` with white, in Flights, Military Flights and Local ADS-B alike; the tracked colour (cyan, or amber in Military Flights) and the emergency red multiply into the mesh, so its paint still shows. IR boost draws them flat white (`MIX` at `1.0`). Under NVG/FLIR (map preset or Cockpit vision) models render unlit flat-white at full alpha and scene fog is disabled (fog otherwise blacks out distant models with the globe hidden); state restores on exit.
- Vessels, Transit and Rail draw fleet models under the same DISPLAY rail `3D` toggle, at the same altitude ceiling, caps and add/keep radii as the aircraft (`FLEET_MODEL_POLICY`, `src/layers/flights/policy.js`; `createFleetModelLayer`, `src/data/fleetModelInstances.js`). A vessel's model follows its AIS ship type and is drawn at its reported length and beam (AIS static dimensions A+B and C+D, now carried on vessel rows as `length` and `beam`), standing at the sea surface, heading by true heading then course. An aid to navigation is not a hull: a vessel record whose type names a buoy, beacon, mark, RACON or light, or whose MMSI is an aid-to-navigation station's (`99MIDxxxx`), is `isAidToNavigation` (`src/data/vesselTypes.js`) and is drawn as the set's `buoys` model of the IALA mark its type names (`aidToNavigationModel`, `src/data/fleetModels.js`: cardinal N/E/S/W, port or starboard hand, preferred channel by its side, safe water, isolated danger, special mark; a beacon, light, light vessel, RACON or reference point with no mark of its own is the special mark), else a red can (`DEFAULT_AID_MODEL`), at its built size and facing north whatever heading it reports. A type is an aid's when it is one of the 32 kinds an aid report names or uses a word for one (buoy, beacon, cardinal, safe water, RACON, lighthouse and so on); a vessel that works on aids (`Buoy Tender`, `Buoy Layer`) is not one. Its icon is a bone-white ringed disc (`AID_TO_NAVIGATION_STYLE`, `src/data/vesselLabels.js`) instead of a chevron, never rotated, and its card, selected card and HUD readout (`src/layers/vessels/cards.js`) show its kind alone, whole, in that colour: no speed or heading, since a fixed mark has neither. AISStream subscribes to `AidsToNavigationReport` (message 21) by default, and the server names each report's aid type code (0 to 31) as its kind (`aidToNavigationTypeName`: `20` is `Cardinal Mark N`) rather than reading it as a ship type, and its name runs on into the report's `NameExtension`. A virtual aid (the report's `VirtualAtoN` flag: a mark broadcast where nothing physical stands) carries `virtual: true` on its row, and on its vessel source record (the live-source contract's `virtual`, `src/sources/live/contract.js`, false unless a source says true; any source may set it). It keeps its disc but never gets a model (`vesselModelSpec` returns null for it, and the vessel layer gathers no model item for it), and its card and readout add `VIRTUAL` after its kind. `aisStreamRows` (`server/providers/vessels/ais-store.js`) puts every vessel ahead of every aid, so a capped request (the layer asks for 12,000 rows) never gives up a ship for a buoy. The `vessels_in_area` tool (and the brief built on it) counts ships only: it leaves aids out of its rows, counts, breakdowns and speed stats. A transit vehicle's model follows its 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). 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.
@@ -8361,7 +8431,11 @@ clock drives all enabled observation rows. Earlier/Later use the sorted union of
non-suspended products' advertised times; each product selects its nearest frame non-suspended products' advertised times; each product selects its nearest frame
at or before the requested UTC time, never a future frame or an interpolation. at or before the requested UTC 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.
+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,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,raven,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,set_meal,settings,skip_next,skip_previous,streetview,timeline,traffic,tune,umbrella,upload_file,videocam,view_in_ar,visibility,visibility_off,water_drop,zoom_in_map" rel="stylesheet" />
</head> </head>
<body> <body>
<div class="gev-view" data-gev-view="scene-chrome"></div> <div class="gev-view" data-gev-view="scene-chrome"></div>
+11 -1
View File
@@ -1449,5 +1449,15 @@
"src/data/fleetModelInstances.test.mjs", "src/data/fleetModelInstances.test.mjs",
"src/testSupport/fleetGlb.mjs", "src/testSupport/fleetGlb.mjs",
"src/testSupport/fleetModelFakes.mjs", "src/testSupport/fleetModelFakes.mjs",
"scripts/measure-fleet-trail-anchors.mjs" "scripts/measure-fleet-trail-anchors.mjs",
"src/ui/panelEdge.js",
"src/ui/railEdgeScroll.js",
"src/ui/railEdgeScroll.test.mjs",
"src/ui/kit/UiPanelEdge.vue",
"src/ui/styles/rail-edge.css",
"src/ui/styles/rail-split.css",
"src/ui/styles/left-tools.css",
"src/ui/styles/panel-edge.css",
"src/data/placementProvider.test.mjs",
"src/layers/vessels/placement.test.mjs"
] ]
+14 -4
View File
@@ -154,6 +154,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",
@@ -1136,6 +1138,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",
@@ -1585,7 +1588,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"]
}, },
@@ -1658,7 +1662,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"]
}, },
@@ -3087,6 +3092,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",
@@ -3820,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",
@@ -4538,7 +4546,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",
+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');
});
+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 } : {}),
}), }),
+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,
+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",
),
'', '',
); );
}); });
+33
View File
@@ -25,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,
@@ -444,6 +445,38 @@ test('aids to navigation are the buoy of the mark their type names', () => {
} }
}); });
test('every transit mode but unknown draws its own model, and a route type a finer one', () => {
for (const mode of TRANSIT_MODES.filter((m) => m !== 'unknown')) {
assert.ok(types.transit.modes[mode], `${mode} has a model`);
assert.equal(
resolveTransit(types, { mode }).model,
types.transit.modes[mode][0],
);
}
for (const [mode, model] of [
['trolleybus', 'trolleybus'],
['cable_tram', 'cable_tram'],
['aerial_lift', 'gondola'],
['funicular', 'funicular'],
['monorail', 'monorail'],
])
assert.equal(resolveTransit(types, { mode }).model, model, mode);
// A route type outranks the mode: a coach and a high-speed set are still
// the bus and rail modes.
for (const [routeType, mode, model] of [
[200, 'bus', 'coach'],
[101, 'rail', 'high_speed'],
[106, 'rail', 'regional_dmu'],
[710, 'bus', 'double_decker'],
])
assert.equal(resolveTransit(types, { routeType, mode }).model, model);
// A type the table lacks falls back to the mode.
assert.equal(
resolveTransit(types, { routeType: 300, mode: 'rail' }).model,
'commuter_emu',
);
});
test('transit specs: rail category and mode, then route type, then mode, then the default', () => { test('transit specs: rail category and mode, then route type, then mode, then the default', () => {
assert.deepEqual(resolveTransit(types, { railCategory: 'national' }), { assert.deepEqual(resolveTransit(types, { railCategory: 'national' }), {
model: 'intercity', model: 'intercity',
+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'));
});
+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;
+8 -1
View File
@@ -3962,5 +3962,12 @@
"Reset view reloads the app without the view restored from the address bar.": "تعيد «إعادة ضبط العرض» تحميل التطبيق دون العرض المستعاد من شريط العنوان.", "Reset view reloads the app without the view restored from the address bar.": "تعيد «إعادة ضبط العرض» تحميل التطبيق دون العرض المستعاد من شريط العنوان.",
"Reload": "إعادة التحميل", "Reload": "إعادة التحميل",
"Retry": "إعادة المحاولة", "Retry": "إعادة المحاولة",
"Reset view": "إعادة ضبط العرض" "Reset view": "إعادة ضبط العرض",
"Trolleybus": "ترولي باص",
"Cable car": "ترام كابلي",
"Monorail": "قطار أحادي السكة",
"Funicular": "قطار جبلي مائل",
"Gondola": "تلفريك",
"Move panels back beside the rail": "إعادة اللوحات إلى جانب الشريط",
"Move panels over the rail, to the edge": "نقل اللوحات فوق الشريط إلى الحافة"
} }
+8 -1
View File
@@ -3614,5 +3614,12 @@
"Reset view reloads the app without the view restored from the address bar.": "Ansicht zurücksetzen lädt die App ohne die aus der Adressleiste wiederhergestellte Ansicht neu.", "Reset view reloads the app without the view restored from the address bar.": "Ansicht zurücksetzen lädt die App ohne die aus der Adressleiste wiederhergestellte Ansicht neu.",
"Reload": "Neu laden", "Reload": "Neu laden",
"Retry": "Erneut versuchen", "Retry": "Erneut versuchen",
"Reset view": "Ansicht zurücksetzen" "Reset view": "Ansicht zurücksetzen",
"Trolleybus": "Oberleitungsbus",
"Cable car": "Kabelstraßenbahn",
"Monorail": "Einschienenbahn",
"Funicular": "Standseilbahn",
"Gondola": "Gondelbahn",
"Move panels back beside the rail": "Bedienfelder zurück neben die Leiste",
"Move panels over the rail, to the edge": "Bedienfelder über die Leiste an den Rand verschieben"
} }
+8 -1
View File
@@ -3701,5 +3701,12 @@
"Reset view reloads the app without the view restored from the address bar.": "Restablecer la vista recarga la app sin la vista restaurada desde la barra de direcciones.", "Reset view reloads the app without the view restored from the address bar.": "Restablecer la vista recarga la app sin la vista restaurada desde la barra de direcciones.",
"Reload": "Recargar", "Reload": "Recargar",
"Retry": "Reintentar", "Retry": "Reintentar",
"Reset view": "Restablecer la vista" "Reset view": "Restablecer la vista",
"Trolleybus": "Trolebús",
"Cable car": "Tranvía de cable",
"Monorail": "Monorraíl",
"Funicular": "Funicular",
"Gondola": "Góndola",
"Move panels back beside the rail": "Devolver los paneles junto a la barra",
"Move panels over the rail, to the edge": "Mover los paneles sobre la barra, hasta el borde"
} }
+8 -1
View File
@@ -3701,5 +3701,12 @@
"Reset view reloads the app without the view restored from the address bar.": "Réinitialiser la vue recharge l’app sans la vue restaurée depuis la barre d’adresse.", "Reset view reloads the app without the view restored from the address bar.": "Réinitialiser la vue recharge l’app sans la vue restaurée depuis la barre d’adresse.",
"Reload": "Recharger", "Reload": "Recharger",
"Retry": "Réessayer", "Retry": "Réessayer",
"Reset view": "Réinitialiser la vue" "Reset view": "Réinitialiser la vue",
"Trolleybus": "Trolleybus",
"Cable car": "Tramway à câble",
"Monorail": "Monorail",
"Funicular": "Funiculaire",
"Gondola": "Télécabine",
"Move panels back beside the rail": "Remettre les panneaux à côté de la barre",
"Move panels over the rail, to the edge": "Déplacer les panneaux par-dessus la barre, jusqu’au bord"
} }
+8 -1
View File
@@ -3701,5 +3701,12 @@
"Reset view reloads the app without the view restored from the address bar.": "\"איפוס תצוגה\" טוען מחדש את האפליקציה בלי התצוגה ששוחזרה משורת הכתובת.", "Reset view reloads the app without the view restored from the address bar.": "\"איפוס תצוגה\" טוען מחדש את האפליקציה בלי התצוגה ששוחזרה משורת הכתובת.",
"Reload": "טען מחדש", "Reload": "טען מחדש",
"Retry": "נסה שוב", "Retry": "נסה שוב",
"Reset view": "איפוס תצוגה" "Reset view": "איפוס תצוגה",
"Trolleybus": "טרוליבוס",
"Cable car": "חשמלית כבלים",
"Monorail": "מונורייל",
"Funicular": "פוניקולר",
"Gondola": "רכבל",
"Move panels back beside the rail": "החזרת הלוחות לצד הסרגל",
"Move panels over the rail, to the edge": "הזזת הלוחות מעל הסרגל, אל הקצה"
} }
+8 -1
View File
@@ -3701,5 +3701,12 @@
"Reset view reloads the app without the view restored from the address bar.": "Ripristina vista ricarica l’app senza la vista ripristinata dalla barra degli indirizzi.", "Reset view reloads the app without the view restored from the address bar.": "Ripristina vista ricarica l’app senza la vista ripristinata dalla barra degli indirizzi.",
"Reload": "Ricarica", "Reload": "Ricarica",
"Retry": "Riprova", "Retry": "Riprova",
"Reset view": "Ripristina vista" "Reset view": "Ripristina vista",
"Trolleybus": "Filobus",
"Cable car": "Tram a fune",
"Monorail": "Monorotaia",
"Funicular": "Funicolare",
"Gondola": "Cabinovia",
"Move panels back beside the rail": "Riporta i pannelli accanto alla barra",
"Move panels over the rail, to the edge": "Sposta i pannelli sopra la barra, fino al bordo"
} }
+8 -1
View File
@@ -3527,5 +3527,12 @@
"Reset view reloads the app without the view restored from the address bar.": "「ビューをリセット」は、アドレスバーから復元したビューを使わずにアプリを再読み込みします。", "Reset view reloads the app without the view restored from the address bar.": "「ビューをリセット」は、アドレスバーから復元したビューを使わずにアプリを再読み込みします。",
"Reload": "再読み込み", "Reload": "再読み込み",
"Retry": "再試行", "Retry": "再試行",
"Reset view": "ビューをリセット" "Reset view": "ビューをリセット",
"Trolleybus": "トロリーバス",
"Cable car": "ケーブルトラム",
"Monorail": "モノレール",
"Funicular": "ケーブルカー",
"Gondola": "ゴンドラ",
"Move panels back beside the rail": "パネルをレールの横に戻す",
"Move panels over the rail, to the edge": "パネルをレールの上、画面端へ移動"
} }
+8 -1
View File
@@ -3701,5 +3701,12 @@
"Reset view reloads the app without the view restored from the address bar.": "Redefinir vista recarrega o app sem a vista restaurada da barra de endereços.", "Reset view reloads the app without the view restored from the address bar.": "Redefinir vista recarrega o app sem a vista restaurada da barra de endereços.",
"Reload": "Recarregar", "Reload": "Recarregar",
"Retry": "Tentar novamente", "Retry": "Tentar novamente",
"Reset view": "Redefinir vista" "Reset view": "Redefinir vista",
"Trolleybus": "Trólebus",
"Cable car": "Bonde a cabo",
"Monorail": "Monotrilho",
"Funicular": "Funicular",
"Gondola": "Teleférico",
"Move panels back beside the rail": "Voltar os painéis para junto da barra",
"Move panels over the rail, to the edge": "Mover os painéis sobre a barra, até a borda"
} }
+8 -1
View File
@@ -3788,5 +3788,12 @@
"Reset view reloads the app without the view restored from the address bar.": "«Сбросить вид» перезагружает приложение без вида, восстановленного из адресной строки.", "Reset view reloads the app without the view restored from the address bar.": "«Сбросить вид» перезагружает приложение без вида, восстановленного из адресной строки.",
"Reload": "Перезагрузить", "Reload": "Перезагрузить",
"Retry": "Повторить", "Retry": "Повторить",
"Reset view": "Сбросить вид" "Reset view": "Сбросить вид",
"Trolleybus": "Троллейбус",
"Cable car": "Канатный трамвай",
"Monorail": "Монорельс",
"Funicular": "Фуникулёр",
"Gondola": "Канатная дорога",
"Move panels back beside the rail": "Вернуть панели к боковой панели",
"Move panels over the rail, to the edge": "Переместить панели поверх боковой панели к краю"
} }
+8 -1
View File
@@ -3614,5 +3614,12 @@
"Reset view reloads the app without the view restored from the address bar.": "”Återställ vy” laddar om appen utan vyn som återställdes från adressfältet.", "Reset view reloads the app without the view restored from the address bar.": "”Återställ vy” laddar om appen utan vyn som återställdes från adressfältet.",
"Reload": "Ladda om", "Reload": "Ladda om",
"Retry": "Försök igen", "Retry": "Försök igen",
"Reset view": "Återställ vy" "Reset view": "Återställ vy",
"Trolleybus": "Trådbuss",
"Cable car": "Kabelspårvagn",
"Monorail": "Enskensbana",
"Funicular": "Bergbana",
"Gondola": "Kabinbana",
"Move panels back beside the rail": "Flytta tillbaka panelerna bredvid listen",
"Move panels over the rail, to the edge": "Flytta panelerna över listen, till kanten"
} }
+8 -1
View File
@@ -3788,5 +3788,12 @@
"Reset view reloads the app without the view restored from the address bar.": "«Скинути вигляд» перезавантажує застосунок без вигляду, відновленого з адресного рядка.", "Reset view reloads the app without the view restored from the address bar.": "«Скинути вигляд» перезавантажує застосунок без вигляду, відновленого з адресного рядка.",
"Reload": "Перезавантажити", "Reload": "Перезавантажити",
"Retry": "Повторити", "Retry": "Повторити",
"Reset view": "Скинути вигляд" "Reset view": "Скинути вигляд",
"Trolleybus": "Тролейбус",
"Cable car": "Канатний трамвай",
"Monorail": "Монорейка",
"Funicular": "Фунікулер",
"Gondola": "Канатна дорога",
"Move panels back beside the rail": "Повернути панелі до бічної смуги",
"Move panels over the rail, to the edge": "Перемістити панелі поверх бічної смуги до краю"
} }
+8 -1
View File
@@ -3527,5 +3527,12 @@
"Reset view reloads the app without the view restored from the address bar.": "“重置视图”会重新加载应用,不使用从地址栏恢复的视图。", "Reset view reloads the app without the view restored from the address bar.": "“重置视图”会重新加载应用,不使用从地址栏恢复的视图。",
"Reload": "重新加载", "Reload": "重新加载",
"Retry": "重试", "Retry": "重试",
"Reset view": "重置视图" "Reset view": "重置视图",
"Trolleybus": "无轨电车",
"Cable car": "缆索电车",
"Monorail": "单轨列车",
"Funicular": "缆索铁路",
"Gondola": "缆车",
"Move panels back beside the rail": "将面板移回侧栏旁",
"Move panels over the rail, to the edge": "将面板移到侧栏上方,贴靠边缘"
} }
+8 -1
View File
@@ -3527,5 +3527,12 @@
"Reset view reloads the app without the view restored from the address bar.": "「重置視圖」會重新加載應用,不使用從地址欄恢復的視圖。", "Reset view reloads the app without the view restored from the address bar.": "「重置視圖」會重新加載應用,不使用從地址欄恢復的視圖。",
"Reload": "重新加載", "Reload": "重新加載",
"Retry": "重試", "Retry": "重試",
"Reset view": "重置視圖" "Reset view": "重置視圖",
"Trolleybus": "無軌電車",
"Cable car": "纜索電車",
"Monorail": "單軌列車",
"Funicular": "纜索鐵路",
"Gondola": "纜車",
"Move panels back beside the rail": "將面板移回側欄旁",
"Move panels over the rail, to the edge": "將面板移到側欄上方,貼齊邊緣"
} }
+4
View File
@@ -291,6 +291,7 @@
"FOV {value}°", "FOV {value}°",
"FT", "FT",
"Folk", "Folk",
"Funicular",
"Funk", "Funk",
"GEO", "GEO",
"GOOGLE STREET VIEW", "GOOGLE STREET VIEW",
@@ -501,6 +502,7 @@
"MINI", "MINI",
"MISSION", "MISSION",
"MM", "MM",
"Monorail",
"MSG/S", "MSG/S",
"Metal", "Metal",
"Minimal", "Minimal",
@@ -571,6 +573,7 @@
"TAGS · {tags}", "TAGS · {tags}",
"TEMP", "TEMP",
"TLE", "TLE",
"Trolleybus",
"TYPE", "TYPE",
"Tallinn", "Tallinn",
"Techno", "Techno",
@@ -950,6 +953,7 @@
"FOV {value}°", "FOV {value}°",
"FT", "FT",
"Folk", "Folk",
"Funicular",
"Funk", "Funk",
"GEO", "GEO",
"GOOGLE STREET VIEW", "GOOGLE STREET VIEW",
+2 -1
View File
@@ -31,7 +31,8 @@ export function createCards({
function rebuildAmbientLabels() { function rebuildAmbientLabels() {
if (!layerState._viewer) return; if (!layerState._viewer) return;
const scene = layerState._viewer.scene; const scene = layerState._viewer.scene;
const now = Date.now(); // Ages count back from the time shown: the window's end, or now when live.
const now = components.model.ageReferenceMs();
const entries = []; const entries = [];
/** @type {Array<{x: number, y: number}>} Screen positions of accepted labels. */ /** @type {Array<{x: number, y: number}>} Screen positions of accepted labels. */
const accepted = []; const accepted = [];
+19 -4
View File
@@ -343,7 +343,7 @@ export function createModel({
* (on top) and never distance-faded by the overlay. * (on top) and never distance-faded by the overlay.
* Exported for unit tests. * Exported for unit tests.
* @param {Object} fire - Detection record. * @param {Object} fire - Detection record.
* @param {number} nowMs - Current epoch milliseconds. * @param {number} nowMs - Epoch milliseconds ages count back from.
* @returns {Object} firmsLabels entry. * @returns {Object} firmsLabels entry.
*/ */
@@ -384,9 +384,10 @@ export function createModel({
* Card model for an ambient fire detection, e.g. title "▲ 47 MW", detail * Card model for an ambient fire detection, e.g. title "▲ 47 MW", detail
* "high · 14h · N20". Ages are computed against each detection's acquisition * "high · 14h · N20". Ages are computed against each detection's acquisition
* time — with the live feed everything reads under 24 h (and a stale cache * time — with the live feed everything reads under 24 h (and a stale cache
* reads truthfully old). Exported for unit tests. * reads truthfully old); `nowMs` is the time ages count back from, the end
* of the window shown (see {@link ageReferenceMs}). Exported for unit tests.
* @param {{fire: Object, position: Cesium.Cartesian3}} candidate - Label candidate. * @param {{fire: Object, position: Cesium.Cartesian3}} candidate - Label candidate.
* @param {number} nowMs - Current epoch milliseconds. * @param {number} nowMs - Epoch milliseconds ages count back from.
* @returns {Object} firmsLabels entry. * @returns {Object} firmsLabels entry.
*/ */
@@ -418,7 +419,7 @@ export function createModel({
* "max 210 MW · new 3h". Accent comes from the candidate (renderHeat sets * "max 210 MW · new 3h". Accent comes from the candidate (renderHeat sets
* it with {@link cellCardAccent}). Exported for unit tests. * it with {@link cellCardAccent}). Exported for unit tests.
* @param {{cell: Object, position: Cesium.Cartesian3, accent: string}} candidate * @param {{cell: Object, position: Cesium.Cartesian3, accent: string}} candidate
* @param {number} nowMs - Current epoch milliseconds. * @param {number} nowMs - Epoch milliseconds ages count back from.
* @returns {Object} firmsLabels entry. * @returns {Object} firmsLabels entry.
*/ */
@@ -549,6 +550,19 @@ export function createModel({
return { unit: 'd', count: Math.round(hours / 24) }; return { unit: 'd', count: Math.round(hours / 24) };
} }
/**
* The time a card's ages count back from: the end of the 24 hours shown
* when the snapshot ends at a set time, now while it runs to now. A window
* that ended months ago then reads its fires as hours old, as they were.
* @param {number} [nowMs] - Current epoch milliseconds.
* @returns {number}
*/
function ageReferenceMs(nowMs = Date.now()) {
const at = layerState?._at;
return Number.isFinite(at) ? at : nowMs;
}
/** Millisecond delta → "<1h" / "Xh" / "Xd", or '' for invalid input. */ /** Millisecond delta → "<1h" / "Xh" / "Xd", or '' for invalid input. */
function formatAge(deltaMs) { function formatAge(deltaMs) {
@@ -624,6 +638,7 @@ export function createModel({
cellCardAccent, cellCardAccent,
formatLatLon, formatLatLon,
formatFrp, formatFrp,
ageReferenceMs,
formatAge, formatAge,
formatAgeLabel, formatAgeLabel,
formatAgoMinutes, formatAgoMinutes,
+3 -1
View File
@@ -202,7 +202,9 @@ export function createSelection({
confidence: components.model.confidenceBucket(fire.confidence), confidence: components.model.confidenceBucket(fire.confidence),
age: age:
fire.acqMs > 0 fire.acqMs > 0
? components.model.formatAge(Date.now() - fire.acqMs) ? components.model.formatAge(
components.model.ageReferenceMs() - fire.acqMs,
)
: 'unknown', : 'unknown',
sensor: fire.sensor || 'unknown', sensor: fire.sensor || 'unknown',
}, },
+53
View File
@@ -1,8 +1,10 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { getLocale, setLocale } from '../../i18n/index.js'; import { getLocale, setLocale } from '../../i18n/index.js';
import { createModel } from './model.js'; import { createModel } from './model.js';
import { createQueries } from './queries.js'; import { createQueries } from './queries.js';
import { createCards } from './cards.js';
test('a FIRMS snapshot that ends at a set time names it instead of LIVE', (context) => { test('a FIRMS snapshot that ends at a set time names it instead of LIVE', (context) => {
const now = 1800000000000; const now = 1800000000000;
@@ -42,3 +44,54 @@ test('a FIRMS snapshot that ends at a set time names it instead of LIVE', (conte
setLocale(previousLocale); setLocale(previousLocale);
} }
}); });
test('fire cards count ages from the end of the window shown, and from now when live', (context) => {
const now = Date.UTC(2026, 10, 14, 12);
context.mock.method(Date, 'now', () => now);
// A window that ended months before now: its newest fire three hours earlier.
const at = Date.UTC(2026, 7, 1, 12);
const layerState = { _at: at };
const model = createModel({ layerState, services: { anchors: {} } });
const cell = {
count: 4,
maxFrp: 210,
newestAcqMs: at - 3 * 3_600_000,
latCell: 1,
lonCell: 2,
};
const previousLocale = getLocale();
try {
setLocale('en');
assert.equal(model.ageReferenceMs(), at);
const card = model.buildCellCard({ cell, position: {} }, model.ageReferenceMs());
assert.equal(card.details[0], 'max 210 MW · new 3h', 'not months old');
// The cards the scene shows use the same time.
const entries = [];
context.mock.method(Cesium.SceneTransforms, 'worldToWindowCoordinates', () => ({ x: 400, y: 300 }));
layerState._viewer = { scene: { canvas: { clientWidth: 800, clientHeight: 600 } } };
layerState._fireByCardId = new Map();
layerState._selectedFire = null;
layerState._labelCandidates = [{ cell, position: {} }];
layerState._labelLodDistance = 0;
const { rebuildAmbientLabels } = createCards({
layerState,
services: {},
components: { model, rendering: { fireHorizonOccluder: () => null } },
config: { overlayHost: { setEntries: (source, shown) => entries.push(...shown) } },
});
rebuildAmbientLabels();
assert.equal(entries.length, 1);
assert.equal(entries[0].details[0], 'max 210 MW · new 3h');
// Live, they count from now.
layerState._at = null;
cell.newestAcqMs = now - 5 * 3_600_000;
assert.equal(model.ageReferenceMs(), now);
entries.length = 0;
rebuildAmbientLabels();
assert.equal(entries[0].details[0], 'max 210 MW · new 5h');
} finally {
setLocale(previousLocale);
}
});
+7 -1
View File
@@ -1,14 +1,20 @@
import { forgetPlacedAircraft } from './placed.js';
/** /**
* What the layer drops when the world clock moves it to another time (its * What the layer drops when the world clock moves it to another time (its
* source declares a timeline; see src/time/snapshotTimeline.js). * source declares a timeline; see src/time/snapshotTimeline.js).
*/ */
export function createClockFollow({ flightState, parts }) { export function createClockFollow({ flightState, services, parts }) {
/** /**
* Forget how every contact moved, before the snapshot at another time takes * Forget how every contact moved, before the snapshot at another time takes
* the place of every contact: their fixes, courses, floors and the tracked * the place of every contact: their fixes, courses, floors and the tracked
* trail belong to the time the clock left. * trail belong to the time the clock left.
*/ */
function resetMotion() { function resetMotion() {
forgetPlacedAircraft(
services?.placement,
flightState._positionHistory.keys(),
);
flightState.records.reset(); flightState.records.reset();
flightState._positionHistory.clear(); flightState._positionHistory.clear();
flightState._displayCourse.clear(); flightState._displayCourse.clear();
+19
View File
@@ -217,6 +217,25 @@ test('a jump forgets how contacts moved; clearing drops every contact', () => {
assert.ok(calls.includes('model:abc123')); assert.ok(calls.includes('model:abc123'));
}); });
test('leaving the live edge forgets every contact a host placement was told of', () => {
const forgotten = [];
const flightState = {
records: records(),
_positionHistory: new Map([
['abc123', []],
['def456', []],
]),
_displayCourse: new Map(),
_groundSnap: { clear() {} },
_displayFloorState: new Map(),
};
const parts = { tracking: { _clearTrail() {} } };
const services = { placement: { forget: (id) => forgotten.push(id) } };
createClockFollow({ flightState, services, parts }).resetMotion();
assert.deepEqual(forgotten, ['abc123', 'def456']);
assert.equal(flightState._positionHistory.size, 0);
});
test('a snapshot at a time the source drops for a newer one sets no error', async () => { test('a snapshot at a time the source drops for a newer one sets no error', async () => {
const probe = ingestion({ const probe = ingestion({
async getSnapshot() { async getSnapshot() {
+1
View File
@@ -35,6 +35,7 @@ export function createCivilFlightLayer({
parts.clockFollow = createClockFollow(context); parts.clockFollow = createClockFollow(context);
const applySnapshot = createFlightSnapshotRenderer({ const applySnapshot = createFlightSnapshotRenderer({
flightState, flightState,
placement: services.placement ?? null,
records: flightState.records, records: flightState.records,
militaryRegistry: services.militaryRegistry, militaryRegistry: services.militaryRegistry,
groundFloor: services.groundFloor, groundFloor: services.groundFloor,
+9
View File
@@ -1,5 +1,6 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { loadFleetModelSet } from '../../data/fleetModels.js'; import { loadFleetModelSet } from '../../data/fleetModels.js';
import { forgetPlacedAircraft } from './placed.js';
export function createLifecycle({ export function createLifecycle({
flightState, flightState,
@@ -72,6 +73,10 @@ export function createLifecycle({
flightState._cullPositions.clear(); flightState._cullPositions.clear();
flightState._detectionObjects = new Map(); flightState._detectionObjects = new Map();
flightState.records.data = new Map(); flightState.records.data = new Map();
forgetPlacedAircraft(
services.placement,
flightState._positionHistory.keys(),
);
flightState._positionHistory = new Map(); flightState._positionHistory = new Map();
flightState._displayCourse.clear(); flightState._displayCourse.clear();
flightState._groundSnap.clear(); flightState._groundSnap.clear();
@@ -291,6 +296,10 @@ export function createLifecycle({
flightState._cullPositions.clear(); flightState._cullPositions.clear();
flightState._detectionObjects.clear(); flightState._detectionObjects.clear();
flightState.records.data.clear(); flightState.records.data.clear();
forgetPlacedAircraft(
services.placement,
flightState._positionHistory.keys(),
);
flightState._positionHistory.clear(); flightState._positionHistory.clear();
flightState._displayCourse.clear(); flightState._displayCourse.clear();
flightState._groundSnap.clear(); flightState._groundSnap.clear();
+14
View File
@@ -16,6 +16,7 @@ import {
COAST_MAX_SPEED_MPS, COAST_MAX_SPEED_MPS,
} from '../../data/motionModel.js'; } from '../../data/motionModel.js';
import { CLASS_SCALE_2D } from '../../data/aircraftClass.js'; import { CLASS_SCALE_2D } from '../../data/aircraftClass.js';
import { placeLiveAircraft } from './placed.js';
import { import {
modelVisualAnchor, modelVisualAnchor,
modelAnchorWorld, modelAnchorWorld,
@@ -173,6 +174,19 @@ export function createMotion({
} }
const out = result || new Cesium.Cartesian3(); const out = result || new Cesium.Cartesian3();
// A host-supplied placement decides where a live contact is drawn; with
// none, or no answer for this contact, the layer's own motion below does.
if (
placeLiveAircraft(
flightState,
services.placement,
icao24,
info,
history,
out,
)
)
return out;
// Render one poll interval behind real time so we interpolate between // Render one poll interval behind real time so we interpolate between
// two KNOWN fixes whenever possible (see RENDER_DELAY_SEC rationale). // two KNOWN fixes whenever possible (see RENDER_DELAY_SEC rationale).
const renderTime = const renderTime =
+191 -2
View File
@@ -3,8 +3,11 @@ import assert from 'node:assert/strict';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { createMotion } from './motion.js'; import { createMotion } from './motion.js';
import { arcOffsetEnu } from '../../data/motionModel.js'; import { arcOffsetEnu } from '../../data/motionModel.js';
import { createMotion as createMilitaryMotion } from '../military/motion.js';
import { layerPlacement } from '../../data/placementProvider.js';
import { observeLiveAircraft } from './placed.js';
function fixture() { function fixture(placement = null) {
const flightState = { const flightState = {
records: { data: new Map() }, records: { data: new Map() },
_positionHistory: new Map(), _positionHistory: new Map(),
@@ -16,7 +19,7 @@ function fixture() {
}; };
const motion = createMotion({ const motion = createMotion({
flightState, flightState,
services: { groundFloor: {}, camera: {}, focus: {} }, services: { groundFloor: {}, camera: {}, focus: {}, placement },
parts: {}, parts: {},
}); });
return { flightState, motion }; return { flightState, motion };
@@ -184,3 +187,189 @@ test('tracked callers still sample a fresh delayed time without a fleet epoch',
); );
assert.equal(reads, 2); assert.equal(reads, 2);
}); });
/** Two fixes a minute apart, read across the minute from a clock at `start`. */
function placedScenario(create, placement, record) {
const flightState = {
records: { data: new Map() },
_positionHistory: new Map(),
_focusEvidenceIds: new Set(),
_scratchArc: {},
_scratchOffset: new Cesium.Cartesian3(),
_scratchRenderTime: new Cesium.JulianDate(),
now: () => 0,
};
const motion = create({
flightState,
services: { groundFloor: {}, camera: {}, focus: {}, placement },
parts: {},
});
const start = Date.parse('2026-10-24T12:00:00Z');
const at = (seconds) =>
Cesium.JulianDate.fromDate(new Date(start + seconds * 1000));
flightState.records.data.set('abc123', record);
flightState._positionHistory.set('abc123', [
{
time: at(0),
position: Cesium.Cartesian3.fromDegrees(-97, 30, 1000),
track: 90,
velocity: 100,
},
{
time: at(60),
position: Cesium.Cartesian3.fromDegrees(-96.99, 30, 1000),
track: 90,
velocity: 100,
},
]);
const drawn = [];
for (let seconds = 0; seconds <= 150; seconds += 7.5) {
flightState.now = () => start + seconds * 1000;
const position = motion._deadReckon('abc123', new Cesium.Cartesian3());
drawn.push({
position,
course: flightState._drCourseDeg,
speed: flightState._drSpeedMps,
hold: flightState._drCourseHold,
});
}
return { flightState, motion, drawn, start };
}
const declining = (asked) =>
layerPlacement(
{
layer: () => ({
place: (id, wallMs) => {
asked.push([id, wallMs]);
return null;
},
}),
},
'aircraft',
);
// Each kind's record carries only its own kinematics fields.
for (const [name, create, record] of [
[
'civil',
createMotion,
{ velocity: 100, true_track: 90, renderAltitudeM: 1234 },
],
[
'military',
createMilitaryMotion,
{ speedMps: 100, track: 90, renderAltitudeM: 1234 },
],
]) {
test(`${name} aircraft move as before without a placement or with one that places nothing`, () => {
const own = placedScenario(create, null, record).drawn;
const asked = [];
const declined = placedScenario(create, declining(asked), record).drawn;
assert.equal(asked.length, own.length);
assert.equal(declined.length, own.length);
for (let i = 0; i < own.length; i++) {
assert.ok(
Cesium.Cartesian3.equals(declined[i].position, own[i].position),
);
assert.equal(declined[i].course, own[i].course);
assert.equal(declined[i].speed, own[i].speed);
assert.equal(declined[i].hold, own[i].hold);
}
});
test(`${name} aircraft are drawn where a placement places them, live only`, () => {
const asked = [];
const placement = layerPlacement(
{
layer: () => ({
place(id, wallMs) {
asked.push(wallMs);
return wallMs % 2 === 0
? {
lat: 30.5,
lon: -96.5,
altitudeM: 2000,
courseDeg: 45,
speedMps: 0.5,
extrapolating: true,
}
: { lat: 30.6, lon: -96.4 };
},
}),
},
'aircraft',
);
const { flightState, motion, start } = placedScenario(
create,
placement,
record,
);
flightState.now = () => start + 2000;
const out = motion._deadReckon('abc123', new Cesium.Cartesian3());
close(out, Cesium.Cartesian3.fromDegrees(-96.5, 30.5, 2000));
assert.equal(flightState._drCourseDeg, 45);
assert.equal(flightState._drSpeedMps, 0.5);
assert.equal(flightState._drCourseHold, true);
// No height, course or speed placed: the record's drawn height and kinematics.
flightState.now = () => start + 2001;
close(
motion._deadReckon('abc123', new Cesium.Cartesian3()),
Cesium.Cartesian3.fromDegrees(-96.4, 30.6, 1234),
);
assert.equal(flightState._drCourseDeg, 90);
assert.equal(flightState._drSpeedMps, 100);
// Following the world clock the layer's own motion places it, unasked.
const before = asked.length;
flightState.timeline = { active: () => true };
const own = motion._deadReckon('abc123', new Cesium.Cartesian3());
assert.equal(asked.length, before);
assert.ok(Cesium.Cartesian3.distance(own, out) > 1000);
});
}
test('an aircraft fix is told to the placement live, with its drawn height', () => {
const told = [];
const placement = layerPlacement(
{
layer: () => ({
place: () => null,
observe: (id, fix) => told.push([id, fix]),
}),
},
'aircraft',
);
const state = { timeline: { active: () => false } };
const meta = { rawLat: 1, rawLon: 2, renderAltitudeM: 300, onGround: false };
observeLiveAircraft(state, placement, 'abc123', 5000, meta, 80, 270);
observeLiveAircraft(
state,
placement,
'abc123',
6000,
{ ...meta, renderAltitudeInvented: true, onGround: true },
null,
undefined,
);
observeLiveAircraft(state, null, 'abc123', 7000, meta, 80, 270);
state.timeline.active = () => true;
observeLiveAircraft(state, placement, 'abc123', 8000, meta, 80, 270);
assert.equal(told.length, 2);
assert.deepEqual(told[0], [
'abc123',
{
timeMs: 5000,
lat: 1,
lon: 2,
altitudeM: 300,
speedMps: 80,
courseDeg: 270,
headingDeg: NaN,
onGround: false,
},
]);
assert.ok(Number.isNaN(told[1][1].altitudeM));
assert.ok(Number.isNaN(told[1][1].speedMps));
assert.ok(Number.isNaN(told[1][1].courseDeg));
assert.equal(told[1][1].onGround, true);
});
+112
View File
@@ -0,0 +1,112 @@
import * as Cesium from 'cesium';
import { COURSE_HOLD_SPEED_MPS } from '../../data/motionModel.js';
import { followsClock } from './policy.js';
/** Scratch for a placed contact's held height. */
const _placedCarto = new Cesium.Cartographic();
/**
* Where the host-supplied placement (`services.placement`,
* ../../data/placementProvider.js) draws a live aircraft now, written into
* `out` with the course scratches `_deadReckon` sets on `flightState`; false
* when there is no placement, the layer follows the world clock, or it has
* no answer for this aircraft, and the layer's own motion places it. A
* placement without a height keeps the aircraft's newest drawn height.
* @param {object} flightState The civil or military flight state.
* @param {object|null|undefined} placement
* @param {string} icao24
* @param {object|undefined} info The aircraft's record.
* @param {{position: Cesium.Cartesian3}[]} history Its fixes, newest last.
* @param {Cesium.Cartesian3} out
* @returns {boolean}
*/
export function placeLiveAircraft(
flightState,
placement,
icao24,
info,
history,
out,
) {
if (!placement || followsClock(flightState)) return false;
const placed = placement.place(icao24, flightState.now());
if (!placed) return false;
let heightM = placed.altitudeM;
if (!Number.isFinite(heightM)) heightM = info?.renderAltitudeM;
if (!Number.isFinite(heightM))
heightM = Cesium.Cartographic.fromCartesian(
history[history.length - 1].position,
Cesium.Ellipsoid.WGS84,
_placedCarto,
).height;
const speed = Number.isFinite(placed.speedMps)
? placed.speedMps
: // Civil records carry velocity and true_track, military speedMps and track.
Number.isFinite(info?.velocity)
? info.velocity
: info?.speedMps || 0;
flightState._drCourseDeg = Number.isFinite(placed.courseDeg)
? placed.courseDeg
: Number.isFinite(info?.true_track)
? info.true_track
: info?.track || 0;
flightState._drSpeedMps = speed;
flightState._drCourseHold = speed < COURSE_HOLD_SPEED_MPS;
flightState._drExtrapolating = placed.extrapolating;
Cesium.Cartesian3.fromDegrees(
placed.lon,
placed.lat,
heightM,
Cesium.Ellipsoid.WGS84,
out,
);
return true;
}
/**
* Tell the host-supplied placement, if any, that the aircraft `ids` left the
* layer (removed, the layer off, or the world clock moved it to another time).
* @param {object|null|undefined} placement
* @param {Iterable<string>} ids
*/
export function forgetPlacedAircraft(placement, ids) {
if (!placement) return;
for (const id of ids) placement.forget(id);
}
/**
* Tell the host-supplied placement an aircraft's fix as the layer took it
* (nothing while the layer follows the world clock): its own time, its
* position, the height the layer draws it at (NaN when that height is
* invented), and its reported speed, course and ground state.
* @param {object} flightState
* @param {object|null|undefined} placement
* @param {string} icao24
* @param {number} fixEpochMs
* @param {{rawLat: number, rawLon: number, renderAltitudeM: number,
* renderAltitudeInvented?: boolean, onGround?: boolean}} meta
* @param {number|null|undefined} speedMps
* @param {number|null|undefined} courseDeg
*/
export function observeLiveAircraft(
flightState,
placement,
icao24,
fixEpochMs,
meta,
speedMps,
courseDeg,
) {
if (!placement || followsClock(flightState)) return;
placement.observe(icao24, {
timeMs: fixEpochMs,
lat: meta.rawLat,
lon: meta.rawLon,
altitudeM:
meta.renderAltitudeInvented === true ? NaN : meta.renderAltitudeM,
speedMps: Number.isFinite(speedMps) ? speedMps : NaN,
courseDeg: Number.isFinite(courseDeg) ? courseDeg : NaN,
headingDeg: NaN,
onGround: meta.onGround === true,
});
}
+15
View File
@@ -9,10 +9,12 @@ import {
import { aircraftIcon } from '../../data/aircraftIcons.js'; import { aircraftIcon } from '../../data/aircraftIcons.js';
import { POSITION_HISTORY_LIMIT } from './recordPolicy.js'; import { POSITION_HISTORY_LIMIT } from './recordPolicy.js';
import { followsClock } from './policy.js'; import { followsClock } from './policy.js';
import { observeLiveAircraft } from './placed.js';
/** Apply reconciled aircraft records to Cesium resources and follow state. */ /** Apply reconciled aircraft records to Cesium resources and follow state. */
export function createFlightSnapshotRenderer({ export function createFlightSnapshotRenderer({
flightState, flightState,
placement = null,
records, records,
militaryRegistry, militaryRegistry,
groundFloor, groundFloor,
@@ -65,6 +67,7 @@ export function createFlightSnapshotRenderer({
flightState._billboards.delete(icao24); flightState._billboards.delete(icao24);
rendering._releaseModel(icao24); // military-suppression: drop any 3D model too rendering._releaseModel(icao24); // military-suppression: drop any 3D model too
records.forget(icao24); records.forget(icao24);
placement?.forget(icao24);
flightState._cullPositions.delete(icao24); flightState._cullPositions.delete(icao24);
flightState._positionHistory.delete(icao24); flightState._positionHistory.delete(icao24);
flightState._displayCourse.delete(icao24); flightState._displayCourse.delete(icao24);
@@ -98,6 +101,17 @@ export function createFlightSnapshotRenderer({
); );
const isTracked = icao24 === flightState._trackedIcao; const isTracked = icao24 === flightState._trackedIcao;
// A host-supplied placement, if any, is told every fix as taken.
observeLiveAircraft(
flightState,
placement,
icao24,
fixEpochMs,
meta,
meta.velocity,
meta.true_track,
);
// Append to position history stamped with the FEED's fix epoch // Append to position history stamped with the FEED's fix epoch
// (time_position), not client receipt time — OpenSky positions arrive // (time_position), not client receipt time — OpenSky positions arrive
// 5-15s stale and receipt-time stamping is what caused the // 5-15s stale and receipt-time stamping is what caused the
@@ -311,6 +325,7 @@ export function createFlightSnapshotRenderer({
flightState._billboards.delete(icao24); flightState._billboards.delete(icao24);
rendering._releaseModel(icao24); // aged-out aircraft: drop its 3D model (no orphan / cap leak) rendering._releaseModel(icao24); // aged-out aircraft: drop its 3D model (no orphan / cap leak)
records.forget(icao24); records.forget(icao24);
placement?.forget(icao24);
flightState._cullPositions.delete(icao24); flightState._cullPositions.delete(icao24);
flightState._positionHistory.delete(icao24); flightState._positionHistory.delete(icao24);
flightState._displayCourse.delete(icao24); flightState._displayCourse.delete(icao24);
+1
View File
@@ -1289,6 +1289,7 @@ export function createTracking({
flightState._billboards.delete(icao24); flightState._billboards.delete(icao24);
parts.rendering._releaseModel(icao24); // military-suppression: drop any 3D model too parts.rendering._releaseModel(icao24); // military-suppression: drop any 3D model too
flightState.records.data.delete(icao24); flightState.records.data.delete(icao24);
services.placement?.forget(icao24);
flightState._cullPositions.delete(icao24); flightState._cullPositions.delete(icao24);
flightState._positionHistory.delete(icao24); flightState._positionHistory.delete(icao24);
flightState._displayCourse.delete(icao24); flightState._displayCourse.delete(icao24);
+7 -1
View File
@@ -1,14 +1,20 @@
import { forgetPlacedAircraft } from '../flights/placed.js';
/** /**
* What the layer drops when the world clock moves it to another time (its * What the layer drops when the world clock moves it to another time (its
* source declares a timeline; see src/time/snapshotTimeline.js). * source declares a timeline; see src/time/snapshotTimeline.js).
*/ */
export function createClockFollow({ flightState, parts }) { export function createClockFollow({ flightState, services, parts }) {
/** /**
* Forget how every contact moved, before the snapshot at another time takes * Forget how every contact moved, before the snapshot at another time takes
* the place of every contact: their fixes, courses and the tracked trail * the place of every contact: their fixes, courses and the tracked trail
* belong to the time the clock left. * belong to the time the clock left.
*/ */
function resetMotion() { function resetMotion() {
forgetPlacedAircraft(
services?.placement,
flightState._positionHistory.keys(),
);
flightState.records.reset(); flightState.records.reset();
flightState._positionHistory.clear(); flightState._positionHistory.clear();
flightState._displayCourse.clear(); flightState._displayCourse.clear();
+1
View File
@@ -31,6 +31,7 @@ export function createMilitaryFlightLayer({
parts.clockFollow = createClockFollow(context); parts.clockFollow = createClockFollow(context);
const applySnapshot = createMilitarySnapshotRenderer({ const applySnapshot = createMilitarySnapshotRenderer({
flightState, flightState,
placement: services.placement ?? null,
records: flightState.records, records: flightState.records,
groundFloor: services.groundFloor, groundFloor: services.groundFloor,
meshFloor: services.meshFloor, meshFloor: services.meshFloor,
+9
View File
@@ -1,5 +1,6 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { loadFleetModelSet } from '../../data/fleetModels.js'; import { loadFleetModelSet } from '../../data/fleetModels.js';
import { forgetPlacedAircraft } from '../flights/placed.js';
export function createLifecycle({ export function createLifecycle({
flightState, flightState,
@@ -66,6 +67,10 @@ export function createLifecycle({
flightState._cullPositions.clear(); flightState._cullPositions.clear();
flightState._detectionObjects = new Map(); flightState._detectionObjects = new Map();
flightState.records.data = new Map(); flightState.records.data = new Map();
forgetPlacedAircraft(
services.placement,
flightState._positionHistory.keys(),
);
flightState._positionHistory = new Map(); flightState._positionHistory = new Map();
flightState._displayCourse.clear(); flightState._displayCourse.clear();
flightState._groundSnap.clear(); flightState._groundSnap.clear();
@@ -264,6 +269,10 @@ export function createLifecycle({
flightState._cullPositions.clear(); flightState._cullPositions.clear();
flightState._detectionObjects.clear(); flightState._detectionObjects.clear();
flightState.records.data.clear(); flightState.records.data.clear();
forgetPlacedAircraft(
services.placement,
flightState._positionHistory.keys(),
);
flightState._positionHistory.clear(); flightState._positionHistory.clear();
flightState._displayCourse.clear(); flightState._displayCourse.clear();
flightState._groundSnap.clear(); flightState._groundSnap.clear();
+14
View File
@@ -15,6 +15,7 @@ import {
COAST_MAX_SPEED_MPS, COAST_MAX_SPEED_MPS,
} from '../../data/motionModel.js'; } from '../../data/motionModel.js';
import { CLASS_SCALE_2D } from '../../data/aircraftClass.js'; import { CLASS_SCALE_2D } from '../../data/aircraftClass.js';
import { placeLiveAircraft } from '../flights/placed.js';
import { import {
modelVisualAnchor, modelVisualAnchor,
modelAnchorWorld, modelAnchorWorld,
@@ -70,6 +71,19 @@ export function createMotion({
} }
const out = result || new Cesium.Cartesian3(); const out = result || new Cesium.Cartesian3();
// A host-supplied placement decides where a live contact is drawn; with
// none, or no answer for this contact, the layer's own motion below does.
if (
placeLiveAircraft(
flightState,
services.placement,
icao24,
info,
history,
out,
)
)
return out;
// Render one poll interval behind real time so we interpolate between // Render one poll interval behind real time so we interpolate between
// two KNOWN fixes whenever possible (see RENDER_DELAY_SEC rationale). // two KNOWN fixes whenever possible (see RENDER_DELAY_SEC rationale).
const renderTime = Cesium.JulianDate.addSeconds( const renderTime = Cesium.JulianDate.addSeconds(
+14
View File
@@ -15,10 +15,12 @@ import {
TRACKED_ICON_COLOR, TRACKED_ICON_COLOR,
followsClock, followsClock,
} from './policy.js'; } from './policy.js';
import { observeLiveAircraft } from '../flights/placed.js';
/** Apply military record changes to Cesium resources and existing follow operations. */ /** Apply military record changes to Cesium resources and existing follow operations. */
export function createMilitarySnapshotRenderer({ export function createMilitarySnapshotRenderer({
flightState, flightState,
placement = null,
records, records,
groundFloor, groundFloor,
meshFloor, meshFloor,
@@ -73,6 +75,17 @@ export function createMilitarySnapshotRenderer({
); );
const isTracked = icao24 === flightState._trackedIcao; const isTracked = icao24 === flightState._trackedIcao;
// A host-supplied placement, if any, is told every fix as taken.
observeLiveAircraft(
flightState,
placement,
icao24,
fixEpochMs,
meta,
meta.speedMps,
meta.track,
);
// Append to position history stamped with the FEED's fix epoch. // Append to position history stamped with the FEED's fix epoch.
// adsb.lol's seen_pos is the AGE in seconds of the last position // adsb.lol's seen_pos is the AGE in seconds of the last position
// report, so the fix epoch is receipt time minus that age. Only // report, so the fix epoch is receipt time minus that age. Only
@@ -324,6 +337,7 @@ export function createMilitarySnapshotRenderer({
flightState._billboards.delete(icao24); flightState._billboards.delete(icao24);
rendering._releaseModel(icao24); // aged-out aircraft: drop its 3D model (no orphan / cap leak) rendering._releaseModel(icao24); // aged-out aircraft: drop its 3D model (no orphan / cap leak)
records.forget(icao24); records.forget(icao24);
placement?.forget(icao24);
flightState._cullPositions.delete(icao24); flightState._cullPositions.delete(icao24);
flightState._positionHistory.delete(icao24); flightState._positionHistory.delete(icao24);
flightState._displayCourse.delete(icao24); flightState._displayCourse.delete(icao24);
+29
View File
@@ -19,6 +19,7 @@ import { RAIL_SELECTED_OVERLAY_SOURCE_ID } from './policy.js';
import { publicRailCatalog } from '../../data/railFeeds.js'; import { publicRailCatalog } from '../../data/railFeeds.js';
import { pointAtS } from '../../data/railPaths.js'; import { pointAtS } from '../../data/railPaths.js';
import { fleetModelFakes } from '../../testSupport/fleetModelFakes.mjs'; import { fleetModelFakes } from '../../testSupport/fleetModelFakes.mjs';
import { PLACED_WAKE_MS } from '../transit/movement.js';
const M_PER_DEG = 111_320; const M_PER_DEG = 111_320;
const LAT = 42.36; const LAT = 42.36;
@@ -502,3 +503,31 @@ test('turning the layer off ends the hold', async (t) => {
assert.equal(app.state()._cockpitLostFeedId, null); assert.equal(app.state()._cockpitLostFeedId, null);
assert.equal(app.layer.getCockpitSubject(), null); assert.equal(app.layer.getCockpitSubject(), null);
}); });
test('a train a placement holds still stops animating, and moves on when the placement does', async (t) => {
let spot = { lat: LAT, lon: LON0 + 3000 / KX };
const app = await onTheLine(t, {
placement: {
layer: () => ({
place: (id) => (id === T1 ? { ...spot, speedMps: 0 } : null),
}),
},
});
app.run(1_000);
const entry = app.entry(T1);
assert.equal(entry.placed, true);
assert.equal(app.state()._moving.has(entry), false, 'parked: not animating');
assert.notEqual(entry.wakeTimer, null, 'a wake asks the placement again');
assert.ok(app.state()._moving.has(app.entry(T2)), 'its own motion runs on');
spot = { lat: LAT, lon: spot.lon + 10 / KX };
t.mock.timers.tick(PLACED_WAKE_MS);
assert.ok(app.state()._moving.has(entry), 'moving again');
for (let i = 0; i < 3; i += 1) {
spot = { lat: LAT, lon: spot.lon + 1 / KX };
app.run(250);
assert.ok(app.state()._moving.has(entry), 'while it moves');
}
app.run(250);
assert.equal(app.state()._moving.has(entry), false, 'stopped again');
});
+3
View File
@@ -82,6 +82,7 @@ export function createIngestion({ state, services, parts, source }) {
state._moving.delete(entry); state._moving.delete(entry);
parts.rendering.cancelWake(entry); parts.rendering.cancelWake(entry);
destroyTrack(entry.track); destroyTrack(entry.track);
entry.placement?.forget(key);
state._trains.delete(key); state._trains.delete(key);
state._revision += 1; state._revision += 1;
// A copy it hid may now be the one to draw. // A copy it hid may now be the one to draw.
@@ -274,6 +275,8 @@ export function createIngestion({ state, services, parts, source }) {
const entry = { const entry = {
key, key,
feedId: feed.id, feedId: feed.id,
// A host-supplied placement, if any, draws it while live.
placement: services.placement ?? null,
marker: null, marker: null,
category: record.category, category: record.category,
railMode: record.mode, railMode: record.mode,
+1
View File
@@ -35,6 +35,7 @@ export function createLifecycle({ state, services, parts, timetable }) {
for (const entry of state._trains.values()) { for (const entry of state._trains.values()) {
parts.rendering.cancelWake(entry); parts.rendering.cancelWake(entry);
destroyTrack(entry.track); destroyTrack(entry.track);
entry.placement?.forget(entry.key);
} }
state._trains.clear(); state._trains.clear();
state._moving.clear(); state._moving.clear();
+30 -10
View File
@@ -1,5 +1,10 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { applyDisplayCourse, updatePlayback } from '../transit/movement.js'; import {
PLACED_WAKE_MS,
applyDisplayCourse,
playbackMoving,
updatePlayback,
} from '../transit/movement.js';
import { HEIGHT_SAMPLE_MAX_ALTITUDE_M } from '../transit/policy.js'; import { HEIGHT_SAMPLE_MAX_ALTITUDE_M } from '../transit/policy.js';
import { import {
COLD_CELL_FLUSH_MS, COLD_CELL_FLUSH_MS,
@@ -551,8 +556,11 @@ export function createRendering({ state, services, parts }) {
*/ */
function place(entry) { function place(entry) {
const sample = entry.sample; const sample = entry.sample;
// Placed by a host-supplied placement, it is drawn where that puts it.
const onPath = const onPath =
entry.path && placeOnPath(entry.path, sample, entry.segment, pathPoint); !entry.placed &&
entry.path &&
placeOnPath(entry.path, sample, entry.segment, pathPoint);
const lat = onPath const lat = onPath
? pathPoint.lat ? pathPoint.lat
: Number.isFinite(sample?.lat) : Number.isFinite(sample?.lat)
@@ -629,16 +637,26 @@ export function createRendering({ state, services, parts }) {
if (!entry.marker) return; if (!entry.marker) return;
place(entry); place(entry);
const sample = entry.sample; const sample = entry.sample;
const moving = const moving = playing(entry) && playbackMoving(entry);
playing(entry) &&
sample?.phase === 'playing' &&
sample.segmentSpeedMps > 0;
if (moving) { if (moving) {
state._moving.add(entry); state._moving.add(entry);
return; return;
} }
state._moving.delete(entry); state._moving.delete(entry);
if (playing(entry) && Number.isFinite(sample?.nextWakeMonoMs)) { if (!playing(entry)) return;
// A train a host-supplied placement holds still is asked again every
// PLACED_WAKE_MS: it moves on once the placement does.
const monoNow = performance.now();
const placedWake = entry.placed === true;
const boundary = sample?.nextWakeMonoMs;
const wakeMonoMs = placedWake
? Math.min(
Number.isFinite(boundary) ? boundary : Infinity,
monoNow + PLACED_WAKE_MS,
)
: boundary;
if (Number.isFinite(wakeMonoMs)) {
const atBoundary = !placedWake || boundary <= wakeMonoMs;
entry.wakeTimer = setTimeout( entry.wakeTimer = setTimeout(
() => { () => {
entry.wakeTimer = null; entry.wakeTimer = null;
@@ -646,9 +664,10 @@ export function createRendering({ state, services, parts }) {
updatePlayback(entry, Date.now(), performance.now()); updatePlayback(entry, Date.now(), performance.now());
schedulePlayback(entry); schedulePlayback(entry);
syncRenderHold(); syncRenderHold();
governorRequestRender('rail-motion-boundary'); if (atBoundary || state._moving.has(entry))
governorRequestRender('rail-motion-boundary');
}, },
Math.max(1, sample.nextWakeMonoMs - performance.now()), Math.max(1, wakeMonoMs - monoNow),
); );
} }
} }
@@ -851,7 +870,8 @@ export function createRendering({ state, services, parts }) {
place(entry); place(entry);
if ( if (
entry.sample.phase !== 'playing' || entry.sample.phase !== 'playing' ||
entry.sample.segmentSpeedMps === 0 entry.sample.segmentSpeedMps === 0 ||
(entry.placed === true && entry.placedMoving !== true)
) )
schedulePlayback(entry); schedulePlayback(entry);
} }
+15 -1
View File
@@ -16,6 +16,7 @@ import {
import { import {
transitModeFor, transitModeFor,
transitModeResolved, transitModeResolved,
transitModelRouteType,
} from '../../data/transitFeeds.js'; } from '../../data/transitFeeds.js';
import { import {
attachFloorToPlace, attachFloorToPlace,
@@ -85,6 +86,7 @@ export function createIngestion({ state, services, parts, source }) {
state._heightDirty.delete(entry); state._heightDirty.delete(entry);
parts.rendering.cancelWake(entry); parts.rendering.cancelWake(entry);
destroyTrack(entry.track); destroyTrack(entry.track);
entry.placement?.forget(key);
state._vehicles.delete(key); state._vehicles.delete(key);
state._detectRevision += 1; state._detectRevision += 1;
} }
@@ -222,12 +224,19 @@ export function createIngestion({ state, services, parts, source }) {
for (const record of records) { for (const record of records) {
if (isStaleVehicleFix(record, now, fetchedAt)) continue; if (isStaleVehicleFix(record, now, fetchedAt)) continue;
const key = transitVehicleKey(feed.id, record.id); const key = transitVehicleKey(feed.id, record.id);
const mode = transitModeFor(feed, record.routeId, record.id); const mode = transitModeFor(
feed,
record.routeId,
record.id,
record.routeType,
);
const modeInferred = !transitModeResolved( const modeInferred = !transitModeResolved(
feed, feed,
record.routeId, record.routeId,
record.id, record.id,
record.routeType,
); );
const routeType = transitModelRouteType(mode, record.routeType);
// A stamp ahead of the fetch is a clock fault; the fetch time bounds it. // A stamp ahead of the fetch is a clock fault; the fetch time bounds it.
const stamped = vehicleReportTimeMs(record, fetchedAt); const stamped = vehicleReportTimeMs(record, fetchedAt);
const reportAt = const reportAt =
@@ -267,6 +276,7 @@ export function createIngestion({ state, services, parts, source }) {
continue; continue;
} }
if (entry.mode !== mode) parts.rendering.paintMode(entry, mode); if (entry.mode !== mode) parts.rendering.paintMode(entry, mode);
entry.routeType = routeType;
entry.modeInferred = modeInferred; entry.modeInferred = modeInferred;
updatePlayback(entry, now, monoNow); updatePlayback(entry, now, monoNow);
parts.rendering.schedulePlayback(entry); parts.rendering.schedulePlayback(entry);
@@ -318,8 +328,12 @@ export function createIngestion({ state, services, parts, source }) {
entry = { entry = {
key, key,
feedId: feed.id, feedId: feed.id,
// A host-supplied placement, if any, draws it while live.
placement: services.placement ?? null,
marker: null, marker: null,
mode, mode,
// The GTFS route type its model is chosen by, when it has one.
routeType,
modeInferred, modeInferred,
heightM: prior === null ? 0 : prior, heightM: prior === null ? 0 : prior,
heightCell: height.cell, heightCell: height.cell,
+2
View File
@@ -147,6 +147,7 @@ export function createLifecycle({ state, services, parts }) {
entry.detectContact = null; entry.detectContact = null;
parts.rendering.cancelWake(entry); parts.rendering.cancelWake(entry);
destroyTrack(entry.track); destroyTrack(entry.track);
entry.placement?.forget(entry.key);
} }
state._vehicles.clear(); state._vehicles.clear();
parts.height.clear(); parts.height.clear();
@@ -242,6 +243,7 @@ export function createLifecycle({ state, services, parts }) {
entry.detectContact = null; entry.detectContact = null;
parts.rendering.cancelWake(entry); parts.rendering.cancelWake(entry);
destroyTrack(entry.track); destroyTrack(entry.track);
entry.placement?.forget(entry.key);
} }
state._vehicles.clear(); state._vehicles.clear();
parts.rendering.stopModels(); parts.rendering.stopModels();
+109
View File
@@ -12,6 +12,8 @@ import {
} from './policy.js'; } from './policy.js';
import { FLOOR_WARM_PER_POLL } from './policy.js'; import { FLOOR_WARM_PER_POLL } from './policy.js';
import { fleetModelFakes } from '../../testSupport/fleetModelFakes.mjs'; import { fleetModelFakes } from '../../testSupport/fleetModelFakes.mjs';
import { layerPlacement } from '../../data/placementProvider.js';
import { PLACED_WAKE_MS } from './movement.js';
import { import {
FUTURE_STAMP_TOLERANCE_MS, FUTURE_STAMP_TOLERANCE_MS,
ORDER_REJECTS_BEFORE_RESET, ORDER_REJECTS_BEFORE_RESET,
@@ -183,6 +185,8 @@ function harness(
// Fleet-model services (testSupport/fleetModelFakes.mjs), with a camera // Fleet-model services (testSupport/fleetModelFakes.mjs), with a camera
// looking straight down for them; without them vehicles stay icons. // looking straight down for them; without them vehicles stay icons.
fleet = null, fleet = null,
// A host-supplied placement (../../data/placementProvider.js), or none.
placement = null,
} = {}, } = {},
) { ) {
const viewRect = { ...view }; const viewRect = { ...view };
@@ -291,6 +295,7 @@ function harness(
source, source,
services: { services: {
...(fleet ? { fleet } : {}), ...(fleet ? { fleet } : {}),
...(placement ? { placement: layerPlacement(placement, 'transit') } : {}),
ground, ground,
mesh: { mesh: {
sampleMeshFloorCells(scene, points, options) { sampleMeshFloorCells(scene, points, options) {
@@ -1156,6 +1161,110 @@ test('a parked fleet that keeps reporting the same position stops asking for fra
'only the vehicle that actually moved is animating', 'only the vehicle that actually moved is animating',
); );
}); });
test('a vehicle a placement holds still stops asking for frames, and moves on when the placement does', async (t) => {
// The placement parks bus-1 at a terminal until `spot` moves.
let spot = { lat: 42.3605, lon: -71.0605 };
let asked = 0;
const app = harness(t, {
placement: {
layer: () => ({
place: () => {
asked += 1;
return { ...spot, courseDeg: 0, speedMps: 0 };
},
}),
},
});
app.serve('mbta', () => ({
status: 200,
body: snapshot(
'mbta',
'MBTA',
[vehicle('bus-1', 42.36, -71.06, reported())],
{ fetchedAt: Date.now() },
),
}));
app.layer.enable(app.viewer);
await app.layer.update();
app.settle();
const entry = app.vehicles()[0];
assert.equal(entry.placed, true);
for (let i = 0; i < 3; i += 1) {
app.advance(16);
app.frame();
}
assert.equal(app.state()._moving.has(entry), false, 'parked: not animating');
assert.equal(app.holds().includes('transit'), false, 'and the loop idles');
assert.notEqual(entry.wakeTimer, null, 'a wake asks the placement again');
assert.ok(Math.abs(lat(entry) - 42.3605) < 1e-6, 'drawn where placed');
// Asked again while it stays put: still idle, the wake re-armed.
const before = asked;
app.advance(PLACED_WAKE_MS);
assert.ok(asked > before, 'the wake asked the placement');
assert.equal(app.state()._moving.has(entry), false);
assert.equal(app.holds().includes('transit'), false);
// The placement moves it: the next wake picks that up and the loop runs.
spot = { lat: 42.361, lon: -71.0605 };
app.advance(PLACED_WAKE_MS);
assert.equal(app.state()._moving.has(entry), true, 'moving again');
assert.ok(app.holds().includes('transit'), 'and holds the loop');
assert.ok(Math.abs(lat(entry) - 42.361) < 1e-6);
for (let i = 0; i < 3; i += 1) {
spot = { lat: spot.lat + 1e-5, lon: spot.lon };
app.advance(16);
app.frame();
assert.equal(app.state()._moving.has(entry), true, 'while it moves');
}
// It stops again: the next frame that sees it unchanged lets go.
app.advance(16);
app.frame();
assert.equal(app.state()._moving.has(entry), false, 'stopped again');
assert.equal(app.holds().includes('transit'), false);
});
test('a vehicle a placement draws is shown from a high camera, as one it does not', async (t) => {
// Far above the ground no floor is asked for, so the fixes carry no height
// and the vehicle stands on the ellipsoid, placed or not.
const app = harness(t, {
at: { lat: 52.09, lon: 5.12 },
altitude: 1_500_000,
view: { west: -78.084, south: 20, east: 88.084, north: 84 },
placement: {
layer: () => ({
place: (id) =>
id.endsWith('nl-1')
? { lat: 52.091, lon: 5.12, courseDeg: 0, speedMps: 0 }
: null,
}),
},
});
const now = Date.now();
app.serve('ovapi-nl', () => ({
status: 200,
body: snapshot(
'ovapi-nl',
'OVapi',
[
vehicle('nl-1', 52.09, 5.12, now / 1000),
vehicle('nl-2', 51.92, 4.48, now / 1000),
],
{ fetchedAt: Date.now() },
),
}));
app.layer.enable(app.viewer);
await app.layer.update();
app.advance(250);
app.frame();
const placed = app.vehicles().find((entry) => entry.placed === true);
assert.ok(placed, 'one vehicle is placed');
const shown = app.vehicles().filter((entry) => entry.marker?.show !== false);
assert.equal(shown.length, 2, 'both vehicles are drawn');
assert.ok(Math.abs(lat(placed) - 52.091) < 1e-6, 'where placed');
});
test('the last vehicle leaving mid-segment takes the render hold with it', async (t) => { test('the last vehicle leaving mid-segment takes the render hold with it', async (t) => {
const app = harness(t); const app = harness(t);
const fetchedAt = Date.now(); const fetchedAt = Date.now();
+59
View File
@@ -11,9 +11,14 @@ import {
} from '../../data/contactPlayback.js'; } from '../../data/contactPlayback.js';
const MODE_MAX_SPEED_MPS = Object.freeze({ const MODE_MAX_SPEED_MPS = Object.freeze({
bus: 36, // ~130 km/h — a coach on a motorway bus: 36, // ~130 km/h — a coach on a motorway
trolleybus: 28, // ~100 km/h — wired to the street
tram: 28, // ~100 km/h — interurban light rail tram: 28, // ~100 km/h — interurban light rail
cable_tram: 12, // ~43 km/h — the cable runs at a walking-pace multiple
subway: 42, // ~150 km/h subway: 42, // ~150 km/h
monorail: 42, // ~150 km/h
rail: 111, // ~400 km/h — high-speed services exist on some feeds rail: 111, // ~400 km/h — high-speed services exist on some feeds
funicular: 16, // ~58 km/h — the fastest lines
aerial_lift: 15, // ~54 km/h — the fastest gondolas and trams
ferry: 25, // ~90 km/h — fast catamarans ferry: 25, // ~90 km/h — fast catamarans
unknown: 56, // ~200 km/h unknown: 56, // ~200 km/h
}); });
@@ -181,13 +186,57 @@ export function recordFix(entry, fix, observation = {}) {
); );
entry.currentEpoch = latest.epoch; entry.currentEpoch = latest.epoch;
if (context) entry.track.epochs.set(latest.epoch, context); if (context) entry.track.epochs.set(latest.epoch, context);
entry.placement?.observe(entry.key, {
timeMs: fix.t,
lat: fix.lat,
lon: fix.lon,
altitudeM: NaN,
speedMps: NaN,
courseDeg: Number.isFinite(fix.bearingDeg) ? fix.bearingDeg : NaN,
headingDeg: NaN,
feedId: entry.feedId,
});
} }
return result; return result;
} }
/**
* How often a vehicle a host-supplied placement holds still is asked again
* whether it moves, so it resumes without the frame loop running for it.
*/
export const PLACED_WAKE_MS = 1000;
/**
* Advance a vehicle's playback to now. With a host-supplied placement
* (`entry.placement`, ../../data/placementProvider.js) that places it, it is
* drawn there, its course and speed the placement's (`entry.placed`); the
* playback's own phase, motion and segment stand. `entry.placedMoving` says
* whether the placed position changed since the placement was last asked at
* an earlier time (true for its first answer, so one more frame can tell).
*/
export function updatePlayback(entry, wallNowMs, monoNowMs) { export function updatePlayback(entry, wallNowMs, monoNowMs) {
entry.clocks.wallNowMs = wallNowMs; entry.clocks.wallNowMs = wallNowMs;
entry.clocks.monoNowMs = monoNowMs; entry.clocks.monoNowMs = monoNowMs;
advance(entry.track, entry.clocks, entry.sample); advance(entry.track, entry.clocks, entry.sample);
const placed = entry.placement?.place(entry.key, wallNowMs) ?? null;
const wasPlaced = entry.placed === true;
entry.placed = placed !== null;
if (!placed) entry.placedMoving = false;
else if (!wasPlaced) entry.placedMoving = true;
else if (wallNowMs !== entry.placedAtWallMs)
entry.placedMoving =
placed.lat !== entry.placedLat || placed.lon !== entry.placedLon;
if (placed) {
entry.placedLat = placed.lat;
entry.placedLon = placed.lon;
entry.placedAtWallMs = wallNowMs;
const sample = entry.sample;
sample.lat = placed.lat;
sample.lon = placed.lon;
if (Number.isFinite(placed.courseDeg))
sample.segmentCourseDeg = placed.courseDeg;
if (Number.isFinite(placed.speedMps))
sample.segmentSpeedMps = placed.speedMps;
}
entry.resets = entry.track.resets; entry.resets = entry.track.resets;
const segment = entry.segment; const segment = entry.segment;
readFix( readFix(
@@ -212,6 +261,16 @@ export function syncPlayback(entry, wallNowMs, monoNowMs = performance.now()) {
} }
updatePlayback(entry, wallNowMs, monoNowMs); updatePlayback(entry, wallNowMs, monoNowMs);
} }
/**
* Whether a vehicle's drawn position changes on its own: a vehicle a
* host-supplied placement draws moves while that placement's position
* changes; any other while its playback glides along a segment.
*/
export function playbackMoving(entry) {
if (entry.placed === true) return entry.placedMoving === true;
const sample = entry.sample;
return sample?.phase === 'playing' && sample.segmentSpeedMps > 0;
}
export function playbackPosition(entry) { export function playbackPosition(entry) {
if (!entry?.track) return null; if (!entry?.track) return null;
return { return {
+99
View File
@@ -10,6 +10,7 @@ import {
} from './movement.js'; } from './movement.js';
import { buildTransitSelectionCopy } from './policy.js'; import { buildTransitSelectionCopy } from './policy.js';
import { sampleAt } from '../../data/contactPlayback.js'; import { sampleAt } from '../../data/contactPlayback.js';
import { layerPlacement } from '../../data/placementProvider.js';
const entry = () => { const entry = () => {
const e = { mode: 'bus', modeInferred: false, courseDeg: null }; const e = { mode: 'bus', modeInferred: false, courseDeg: null };
initializePlayback(e); initializePlayback(e);
@@ -163,3 +164,101 @@ test('course fallback prefers held course, then finite reported bearing, then un
assert.equal(e.courseDeg, expected); assert.equal(e.courseDeg, expected);
} }
}); });
const placedEntry = (placement) => {
const e = {
key: 'feed:7',
feedId: 'feed',
mode: 'bus',
modeInferred: false,
courseDeg: null,
placement,
};
initializePlayback(e);
return e;
};
const drive = (e) => {
recordFix(e, { ...fix(0), bearingDeg: 10 });
recordFix(e, { ...fix(15000, 42.001), bearingDeg: 20 });
recordFix(e, fix(30000, 42.002));
const samples = [];
for (let wall = 0; wall <= 120000; wall += 2500) {
updatePlayback(e, wall, wall);
samples.push({ ...e.sample });
}
return samples;
};
test('without a placement, or one that places nothing, playback is the layer own', () => {
const own = drive(placedEntry(null));
const declined = [];
const placement = layerPlacement(
{
layer: () => ({
observe: (id, f) => declined.push([id, f.timeMs]),
place: () => null,
}),
},
'transit',
);
const e = placedEntry(placement);
assert.deepEqual(drive(e), own);
assert.equal(e.placed, false);
assert.deepEqual(declined, [
['feed:7', 0],
['feed:7', 15000],
['feed:7', 30000],
]);
});
test('a placement draws the vehicle where it places it, keeping the playback state', () => {
const own = drive(placedEntry(null));
const e = placedEntry(
layerPlacement(
{
layer: () => ({
place: (id, wallMs) =>
wallMs >= 60000
? { lat: 1, lon: 2, courseDeg: 45, speedMps: 7 }
: { lat: 3, lon: 4 },
}),
},
'transit',
),
);
const placed = drive(e);
assert.equal(e.placed, true);
for (let i = 0; i < own.length; i++) {
const late = i * 2500 >= 60000;
assert.equal(placed[i].lat, late ? 1 : 3);
assert.equal(placed[i].lon, late ? 2 : 4);
assert.equal(placed[i].phase, own[i].phase);
assert.equal(placed[i].motion, own[i].motion);
if (late) {
assert.equal(placed[i].segmentCourseDeg, 45);
assert.equal(placed[i].segmentSpeedMps, 7);
} else {
// No course or speed placed: the playback's own stand.
assert.deepEqual(
[placed[i].segmentCourseDeg, placed[i].segmentSpeedMps],
[own[i].segmentCourseDeg, own[i].segmentSpeedMps],
);
}
}
});
test('a refused fix is never told to the placement', () => {
const told = [];
const e = placedEntry(
layerPlacement(
{
layer: () => ({
observe: (id, f) => told.push(f.lat),
place: () => null,
}),
},
'transit',
),
);
recordFix(e, fix(0));
recordFix(e, fix(15000, 42.001));
assert.equal(recordFix(e, fix(30000, 43)).accepted, false);
assert.deepEqual(told, [42, 42.001]);
});
+15
View File
@@ -189,9 +189,14 @@ export const SELECTED_CARD_REFRESH_MS = 250;
/** Mode palette: distinct at a glance, none reused by flights (white/cyan), military (amber), or vessels. */ /** Mode palette: distinct at a glance, none reused by flights (white/cyan), military (amber), or vessels. */
export const TRANSIT_MODE_COLORS = Object.freeze({ export const TRANSIT_MODE_COLORS = Object.freeze({
bus: '#5EF08A', bus: '#5EF08A',
trolleybus: '#B6F04A',
tram: '#FFC24A', tram: '#FFC24A',
cable_tram: '#FF8F4A',
subway: '#FF4538', subway: '#FF4538',
monorail: '#FF6FB8',
rail: '#D9A6FF', rail: '#D9A6FF',
funicular: '#E8C98A',
aerial_lift: '#7FA8FF',
ferry: '#5FD6FF', ferry: '#5FD6FF',
unknown: '#D8DDE5', unknown: '#D8DDE5',
}); });
@@ -526,9 +531,14 @@ const UNDERGROUND_MODES = new Set(['subway']);
/** The mode in plain words, for a card a person reads rather than scans. */ /** The mode in plain words, for a card a person reads rather than scans. */
const TRANSIT_MODE_WORD = Object.freeze({ const TRANSIT_MODE_WORD = Object.freeze({
bus: msg('Bus'), bus: msg('Bus'),
trolleybus: msg('Trolleybus'),
tram: msg('Tram'), tram: msg('Tram'),
cable_tram: msg('Cable car'),
subway: msg('Subway'), subway: msg('Subway'),
monorail: msg('Monorail'),
rail: msg('Train'), rail: msg('Train'),
funicular: msg('Funicular'),
aerial_lift: msg('Gondola'),
ferry: msg('Ferry'), ferry: msg('Ferry'),
unknown: msg('Transit vehicle'), unknown: msg('Transit vehicle'),
}); });
@@ -536,9 +546,14 @@ const TRANSIT_MODE_WORD = Object.freeze({
/** Short, fixed-width mode word for the detection card's class field. */ /** Short, fixed-width mode word for the detection card's class field. */
const TRANSIT_MODE_ABBR = Object.freeze({ const TRANSIT_MODE_ABBR = Object.freeze({
bus: 'BUS', bus: 'BUS',
trolleybus: 'TROLLEY',
tram: 'TRAM', tram: 'TRAM',
cable_tram: 'CABLE',
subway: 'METRO', subway: 'METRO',
monorail: 'MONO',
rail: 'RAIL', rail: 'RAIL',
funicular: 'FUNI',
aerial_lift: 'GONDOLA',
ferry: 'FERRY', ferry: 'FERRY',
unknown: 'TRANSIT', unknown: 'TRANSIT',
}); });
+29 -7
View File
@@ -4,8 +4,10 @@ import {
perspectiveProjectedRotation, perspectiveProjectedRotation,
} from '../../data/iconOrientation.js'; } from '../../data/iconOrientation.js';
import { import {
PLACED_WAKE_MS,
updatePlayback, updatePlayback,
applyDisplayCourse, applyDisplayCourse,
playbackMoving,
syncPlayback, syncPlayback,
} from './movement.js'; } from './movement.js';
import { import {
@@ -165,7 +167,7 @@ export function createRendering({ state, services, parts }) {
migrateMarker(entry, false); migrateMarker(entry, false);
return; return;
} }
const positional = sample.phase === 'playing' && sample.segmentSpeedMps > 0; const positional = playbackMoving(entry);
const turning = const turning =
Number.isFinite(sample.segmentCourseDeg) && Number.isFinite(sample.segmentCourseDeg) &&
Number.isFinite(entry.courseDeg) && Number.isFinite(entry.courseDeg) &&
@@ -178,7 +180,19 @@ export function createRendering({ state, services, parts }) {
} else { } else {
state._moving.delete(entry); state._moving.delete(entry);
migrateMarker(entry, false); migrateMarker(entry, false);
if (Number.isFinite(sample.nextWakeMonoMs)) { // A vehicle a host-supplied placement holds still is asked again every
// PLACED_WAKE_MS: it moves on once the placement does.
const monoNow = performance.now();
const placedWake = entry.placed === true;
const boundary = sample.nextWakeMonoMs;
const wakeMonoMs = placedWake
? Math.min(
Number.isFinite(boundary) ? boundary : Infinity,
monoNow + PLACED_WAKE_MS,
)
: boundary;
if (Number.isFinite(wakeMonoMs)) {
const atBoundary = !placedWake || boundary <= wakeMonoMs;
entry.wakeTimer = setTimeout( entry.wakeTimer = setTimeout(
() => { () => {
entry.wakeTimer = null; entry.wakeTimer = null;
@@ -187,9 +201,10 @@ export function createRendering({ state, services, parts }) {
placeSample(entry); placeSample(entry);
schedulePlayback(entry); schedulePlayback(entry);
syncRenderHold(); syncRenderHold();
governorRequestRender('transit-motion-boundary'); if (atBoundary || state._moving.has(entry))
governorRequestRender('transit-motion-boundary');
}, },
Math.max(1, sample.nextWakeMonoMs - performance.now()), Math.max(1, wakeMonoMs - monoNow),
); );
} }
} }
@@ -441,7 +456,8 @@ export function createRendering({ state, services, parts }) {
if ( if (
!entry.surfaceReady || !entry.surfaceReady ||
entry.sample.phase !== 'playing' || entry.sample.phase !== 'playing' ||
entry.sample.segmentSpeedMps === 0 entry.sample.segmentSpeedMps === 0 ||
(entry.placed === true && entry.placedMoving !== true)
) )
schedulePlayback(entry); schedulePlayback(entry);
if (!state._moving.has(entry)) settleSelectedCard(entry); if (!state._moving.has(entry)) settleSelectedCard(entry);
@@ -531,9 +547,15 @@ export function createRendering({ state, services, parts }) {
groundLiftM, groundLiftM,
}; };
} }
if (item.mode !== entry.mode) { if (item.mode !== entry.mode || item.routeType !== entry.routeType) {
item.mode = entry.mode; item.mode = entry.mode;
item.spec = transitModelSpec(set, { mode: entry.mode }); item.routeType = entry.routeType;
// The route type picks the finer model (a coach, a high-speed set);
// the mode is the fallback for a type the model table lacks.
item.spec = transitModelSpec(set, {
routeType: entry.routeType,
mode: entry.mode,
});
item.tint = item.tint =
MODE_CESIUM_COLORS[entry.mode] || MODE_CESIUM_COLORS.unknown; MODE_CESIUM_COLORS[entry.mode] || MODE_CESIUM_COLORS.unknown;
} }
+20 -9
View File
@@ -8,16 +8,9 @@ import {
} from '../../data/contactPlayback.js'; } from '../../data/contactPlayback.js';
import { createContactTrailRenderer } from '../../data/contactTrailRenderer.js'; import { createContactTrailRenderer } from '../../data/contactTrailRenderer.js';
import { fixDistanceM } from './movement.js'; import { fixDistanceM } from './movement.js';
import { TRANSIT_MODE_COLORS } from './policy.js';
import { transitStyleProfile } from '../../data/transitPresetStyle.js'; import { transitStyleProfile } from '../../data/transitPresetStyle.js';
const MODE_COLOR = {
bus: '#5EF08A',
tram: '#FFC24A',
subway: '#FF4538',
rail: '#D9A6FF',
ferry: '#5FD6FF',
unknown: '#D8DDE5',
};
// Final Cesium batching uses 3,152 bytes per two-lane edge. // Final Cesium batching uses 3,152 bytes per two-lane edge.
// The 1,278-instance body plus 64 bytes/instance reserve stays below 2 MiB. // The 1,278-instance body plus 64 bytes/instance reserve stays below 2 MiB.
export const TRAIL_VERTEX_LIMIT = 640; export const TRAIL_VERTEX_LIMIT = 640;
@@ -41,7 +34,7 @@ export function createTrails({ state, services, parts, source }) {
renderer?.setStyle( renderer?.setStyle(
transitStyleProfile(state._stylePreset) === 'mono' transitStyleProfile(state._stylePreset) === 'mono'
? '#FFFFFF' ? '#FFFFFF'
: MODE_COLOR[selected?.mode] || MODE_COLOR.unknown, : TRANSIT_MODE_COLORS[selected?.mode] || TRANSIT_MODE_COLORS.unknown,
); );
} }
function releaseSelectionGeometry(entry) { function releaseSelectionGeometry(entry) {
@@ -205,6 +198,24 @@ export function createTrails({ state, services, parts, source }) {
nearGround = parts.height.nearGround(), nearGround = parts.height.nearGround(),
) { ) {
pruneMarkerPaths(entry, sample); pruneMarkerPaths(entry, sample);
// Placed by a host-supplied placement, it is drawn at the placed point,
// at the height its own playback has reached; far above the ground, where
// no floor is asked for, on the ellipsoid as an unplaced vehicle is.
if (entry.placed === true) {
const heightM = Number.isFinite(sample.heightM)
? sample.heightM
: nearGround
? NaN
: 0;
if (!Number.isFinite(heightM)) return null;
return Cesium.Cartesian3.fromDegrees(
sample.lon,
sample.lat,
heightM,
undefined,
out,
);
}
if (!nearGround) { if (!nearGround) {
const a = entry.fromCart, const a = entry.fromCart,
b = entry.toCart, b = entry.toCart,
+1
View File
@@ -31,6 +31,7 @@ export function createVesselLayer({ source, services, options = {} } = {}) {
parts.queries = createQueries(context); parts.queries = createQueries(context);
parts.snapshots = createVesselSnapshotRenderer({ parts.snapshots = createVesselSnapshotRenderer({
state: vesselState.state, state: vesselState.state,
placement: services?.placement ?? null,
records: vesselState.state.records, records: vesselState.state.records,
rendering: parts.rendering, rendering: parts.rendering,
tracking: parts.tracking, tracking: parts.tracking,
+4
View File
@@ -115,6 +115,8 @@ export function createLifecycle({
state.feed.firstConnectTimer = null; state.feed.firstConnectTimer = null;
state.feed.abort = null; state.feed.abort = null;
state.billboardCollection = null; state.billboardCollection = null;
if (services.placement)
for (const id of state.records.byId.keys()) services.placement.forget(id);
state.records.all = []; state.records.all = [];
state.records.byId = new Map(); state.records.byId = new Map();
state.records.unkeyed = []; state.records.unkeyed = [];
@@ -211,6 +213,7 @@ export function createLifecycle({
invalidateAisSession(); invalidateAisSession();
releaseContinuousRender('ais-vessels'); releaseContinuousRender('ais-vessels');
components.rendering.cancelTrailingVisibilityPass(); components.rendering.cancelTrailingVisibilityPass();
components.rendering.cancelPlacementFrame();
unregisterPickOwner('ais-live-vessels'); unregisterPickOwner('ais-live-vessels');
components.rendering.setVisible(false); components.rendering.setVisible(false);
vesselState._vesselOverlayHost.clearSource(VESSEL_OVERLAY_SOURCE_ID); vesselState._vesselOverlayHost.clearSource(VESSEL_OVERLAY_SOURCE_ID);
@@ -232,6 +235,7 @@ export function createLifecycle({
invalidateAisSession(); invalidateAisSession();
releaseContinuousRender('ais-vessels'); // direct-destroy path (perf wave 2 fix) releaseContinuousRender('ais-vessels'); // direct-destroy path (perf wave 2 fix)
components.rendering.cancelTrailingVisibilityPass(); components.rendering.cancelTrailingVisibilityPass();
components.rendering.cancelPlacementFrame();
if (state.feed.abort) state.feed.abort.abort(); if (state.feed.abort) state.feed.abort.abort();
unregisterPickOwner('ais-live-vessels'); unregisterPickOwner('ais-live-vessels');
components.selection.clearVesselInspection(); components.selection.clearVesselInspection();
+106
View File
@@ -0,0 +1,106 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { VesselRecords } from './records.js';
import { createVesselSnapshotRenderer } from './snapshotRenderer.js';
import { layerPlacement } from '../../data/placementProvider.js';
const row = (id, fields = {}) => ({
id,
mmsi: id,
lat: 51.93,
lon: 4.05,
speed: 10,
course: 90,
heading: 92,
last_position_epoch: 1_000,
...fields,
});
function harness({ placement = null, following = false } = {}) {
const visuals = [];
const state = { selectedRecord: null, trailId: null };
const records = new VesselRecords({ now: () => 5_000_000 });
const rendering = {
ensureCollections() {},
makeOccluder: () => null,
renderRowLimit: () => Infinity,
prepareRecordVisual(record) {
visuals.push(record.id);
return { billboard: null };
},
addRecordPrimitives() {},
removeRecordPrimitives() {},
shipIcon: () => 'icon',
shipScale: () => 1,
updateVisibility() {},
};
const renderer = createVesselSnapshotRenderer({
state,
placement,
records,
rendering,
tracking: {},
selection: {},
cards: {},
followsClock: () => following,
});
return {
visuals,
reconcile: (rows, options) => renderer.reconcileVessels({}, rows, options),
};
}
function recording() {
const told = [];
const forgotten = [];
const placement = layerPlacement(
{
layer: () => ({
observe: (id, fix) => told.push([id, fix]),
place: () => null,
forget: (id) => forgotten.push(id),
}),
},
'vessels',
);
return { told, forgotten, placement };
}
test('vessels reconcile as before with no placement', () => {
const own = harness();
own.reconcile([row('a'), row('b')]);
own.reconcile([row('a', { lat: 52, last_position_epoch: 1_060 })]);
const { placement } = recording();
const placed = harness({ placement });
placed.reconcile([row('a'), row('b')]);
placed.reconcile([row('a', { lat: 52, last_position_epoch: 1_060 })]);
assert.deepEqual(placed.visuals, own.visuals);
});
test('each live vessel fix is told to the placement, in m/s, and a vessel that leaves is forgotten', () => {
const { told, forgotten, placement } = recording();
const probe = harness({ placement });
probe.reconcile([row('a'), row('b', { speed: null, course: null })]);
probe.reconcile([row('a', { lat: 52, last_position_epoch: 1_060 })]);
assert.deepEqual(
told.map(([id, fix]) => [id, fix.timeMs, fix.lat]),
[
['a', 1_000_000, 51.93],
['b', 1_000_000, 51.93],
['a', 1_060_000, 52],
],
);
assert.ok(Math.abs(told[0][1].speedMps - 10 * (1852 / 3600)) < 1e-12);
assert.equal(told[0][1].courseDeg, 90);
assert.equal(told[0][1].headingDeg, 92);
assert.ok(Number.isNaN(told[1][1].speedMps));
assert.ok(Number.isNaN(told[1][1].courseDeg));
assert.deepEqual(forgotten, ['b']);
});
test('following the world clock, or rows at another time, tell the placement nothing', () => {
const { told, placement } = recording();
harness({ placement, following: true }).reconcile([row('a')]);
harness({ placement }).reconcile([row('a')], { replace: true });
assert.deepEqual(told, []);
});
+61 -1
View File
@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { createVesselLayer } from './index.js'; import { createVesselLayer } from './index.js';
import { VISIBILITY_UPDATE_MS } from './policy.js'; import { VISIBILITY_UPDATE_MS } from './policy.js';
import { layerPlacement } from '../../data/placementProvider.js';
// Vessels move only when a poll lands, so the layer must let the scene idle: // Vessels move only when a poll lands, so the layer must let the scene idle:
// it asks for frames for forced passes and one trailing frame after the camera // it asks for frames for forced passes and one trailing frame after the camera
@@ -9,7 +10,7 @@ import { VISIBILITY_UPDATE_MS } from './policy.js';
const noop = () => {}; const noop = () => {};
function setup() { function setup({ placement = null } = {}) {
let clock = 10_000; let clock = 10_000;
const requests = []; const requests = [];
const holds = new Set(); const holds = new Set();
@@ -46,6 +47,7 @@ function setup() {
releaseContinuousRender: (id) => holds.delete(id), releaseContinuousRender: (id) => holds.delete(id),
governorRequestRender: (reason) => requests.push(reason), governorRequestRender: (reason) => requests.push(reason),
}, },
...(placement ? { placement: layerPlacement(placement, 'vessels') } : {}),
}, },
}); });
const camera = { const camera = {
@@ -136,3 +138,61 @@ test('destroy cancels a pending trailing frame', (t) => {
advance(VISIBILITY_UPDATE_MS); advance(VISIBILITY_UPDATE_MS);
assert.deepEqual(requests, []); assert.deepEqual(requests, []);
}); });
test('a placement that places nothing asks for no frames; one that places a drawn vessel moves it in place', (t) => {
let mono = 0;
t.mock.method(performance, 'now', () => mono);
const originalDocument = globalThis.document;
globalThis.document = { getElementById: () => null };
t.after(() => {
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
});
let spot = null;
const { layer, camera, requests, timers, advance } = setup({
placement: { layer: () => ({ place: () => spot }) },
});
const record = {
mmsi: '111',
lat: 51.93,
lon: 4.05,
billboard: { show: true, position: null },
};
layer.testing._setVesselStateForTest({
viewer: { camera, scene: {} },
records: [record],
});
const visual = layer.testing._prepareVesselVisualForTest(record);
const position = visual.position;
const before = { ...position };
// Nothing placed: no frame is asked for, and the vessel stays put.
for (let i = 0; i < 8; i += 1) {
mono += 250;
layer.testing._runPlacementPassForTest();
advance(250);
}
assert.equal(timers.size, 0);
assert.deepEqual(requests, []);
assert.deepEqual({ ...visual.position }, before);
// Placed: moved in place, no new positions, and a frame asked for.
spot = { lat: 51.94, lon: 4.05 };
mono += 250;
layer.testing._runPlacementPassForTest();
assert.equal(visual.position, position, 'written in place');
assert.notDeepEqual({ ...visual.position }, before);
assert.equal(record.billboard.position, position);
assert.equal(timers.size, 1);
advance(1_000);
assert.deepEqual(requests, ['ais-vessels-placement']);
// Turning the layer off drops the pending frame.
mono += 250;
layer.testing._runPlacementPassForTest();
assert.equal(timers.size, 1);
layer.destroy();
assert.equal(timers.size, 0);
advance(1_000);
assert.deepEqual(requests, ['ais-vessels-placement']);
});
+94 -2
View File
@@ -33,6 +33,8 @@ import { isAidToNavigation } from '../../data/vesselTypes.js';
/** How often vessels move on as the world clock plays (wall ms). */ /** How often vessels move on as the world clock plays (wall ms). */
const FOLLOW_POSITION_MS = 250; const FOLLOW_POSITION_MS = 250;
/** A frame is asked for this often while a placement moves live vessels. */
const PLACEMENT_FRAME_MS = 1000;
/** How often the vessels near enough for a model are gathered again (wall ms). */ /** How often the vessels near enough for a model are gathered again (wall ms). */
const MODEL_CANDIDATES_MS = 500; const MODEL_CANDIDATES_MS = 500;
@@ -58,10 +60,12 @@ export function createRendering({
visual = { billboard: record.billboard || null }; visual = { billboard: record.billboard || null };
visualRecords.set(record, visual); visualRecords.set(record, visual);
} }
// Following the world clock, a vessel is drawn where it was at the time. // Following the world clock, a vessel is drawn where it was at the time;
// live, where a host-supplied placement draws it, if one does.
const { lat, lon } = followsClock(vesselState) const { lat, lon } = followsClock(vesselState)
? vesselPositionAt(record, vesselState.worldNow()) ? vesselPositionAt(record, vesselState.worldNow())
: record; : (services.placement?.place(record.id, vesselState._aisRuntime.now()) ??
record);
const heightM = components.queries.vesselDatumHeightM( const heightM = components.queries.vesselDatumHeightM(
components.tracking.currentGeoidN(lat, lon), components.tracking.currentGeoidN(lat, lon),
VESSEL_LIFT_M, VESSEL_LIFT_M,
@@ -78,9 +82,50 @@ export function createRendering({
visual.position, visual.position,
new Cesium.Cartesian3(), new Cesium.Cartesian3(),
); );
if (services.placement) {
visual.drawnLat = lat;
visual.drawnLon = lon;
}
return visual; return visual;
} }
/**
* Move a prepared vessel visual to `lat`/`lon`, writing into its own
* positions rather than allocating new ones: the placement pass runs for
* every drawn vessel several times a second.
*/
function moveRecordVisual(visual, lat, lon) {
const heightM = components.queries.vesselDatumHeightM(
components.tracking.currentGeoidN(lat, lon),
VESSEL_LIFT_M,
);
const ellipsoid = Cesium.Ellipsoid.WGS84;
Cesium.Cartesian3.fromDegrees(
lon,
lat,
heightM,
ellipsoid,
visual.position,
);
Cesium.Cartesian3.fromDegrees(
lon,
lat,
heightM - VESSEL_LIFT_M,
ellipsoid,
visual.waterline,
);
Cesium.Cartesian3.fromDegrees(
lon,
lat,
0,
ellipsoid,
visual.surfacePosition,
);
ellipsoid.geodeticSurfaceNormal(visual.position, visual.normal);
visual.drawnLat = lat;
visual.drawnLon = lon;
}
function resetRecordVisuals() { function resetRecordVisuals() {
visualRecords = new WeakMap(); visualRecords = new WeakMap();
} }
@@ -391,6 +436,7 @@ export function createRendering({
if (state.preRenderRemover || !viewer) return; if (state.preRenderRemover || !viewer) return;
state.preRenderRemover = viewer.scene.preRender.addEventListener(() => { state.preRenderRemover = viewer.scene.preRender.addEventListener(() => {
if (followsClock(vesselState)) followClockPositions(); if (followsClock(vesselState)) followClockPositions();
else if (services.placement && state.feed.enabled) followPlacements();
updateVisibility(); updateVisibility();
if (state.feed.enabled) updateModels(); if (state.feed.enabled) updateModels();
}); });
@@ -413,6 +459,50 @@ export function createRendering({
} }
} }
// Live with a host-supplied placement, vessels move on between refreshes:
// drawn where it places them at most every FOLLOW_POSITION_MS, with a frame
// asked for every PLACEMENT_FRAME_MS while it places any drawn vessel, so
// they move while the scene idles.
let placedAtWallMs = -Infinity;
let placing = false;
let placementFrameTimer = null;
function followPlacements() {
const wallMs = performance.now();
if (wallMs - placedAtWallMs >= FOLLOW_POSITION_MS) {
placedAtWallMs = wallMs;
placing = false;
const nowMs = vesselState._aisRuntime.now();
// Drawn vessels only, as a sprite or a model: a hidden one is placed
// again when it is drawn.
for (const record of state.records.all) {
const visual = visualRecords.get(record);
if (!visual?.position) continue;
if (!visual.billboard?.show && !visual.modelOwnsVisual) continue;
const placed = services.placement.place(record.id, nowMs);
if (placed) placing = true;
const { lat, lon } = placed ?? record;
if (lat === visual.drawnLat && lon === visual.drawnLon) continue;
moveRecordVisual(visual, lat, lon);
if (visual.billboard) visual.billboard.position = visual.position;
}
}
if (placing && placementFrameTimer === null)
placementFrameTimer = vesselState._aisRuntime.setTimeout(() => {
placementFrameTimer = null;
if (state.feed.enabled && !followsClock(vesselState))
governorRequestRender('ais-vessels-placement');
}, PLACEMENT_FRAME_MS);
}
/** Stop asking for placement frames (the layer turned off or went away). */
function cancelPlacementFrame() {
placedAtWallMs = -Infinity;
placing = false;
if (placementFrameTimer === null) return;
vesselState._aisRuntime.clearTimeout(placementFrameTimer);
placementFrameTimer = null;
}
function scheduleTrailingVisibilityPass(now) { function scheduleTrailingVisibilityPass(now) {
if (state.trailingVisibilityTimer) return; if (state.trailingVisibilityTimer) return;
const waitMs = Math.max( const waitMs = Math.max(
@@ -787,6 +877,8 @@ export function createRendering({
installRuntime, installRuntime,
updateVisibility, updateVisibility,
cancelTrailingVisibilityPass, cancelTrailingVisibilityPass,
cancelPlacementFrame,
followPlacements,
applyVesselFocusDeemphasis, applyVesselFocusDeemphasis,
makeOccluder, makeOccluder,
isVisible, isVisible,
+35 -1
View File
@@ -1,6 +1,30 @@
/** Connect portable vessel reconciliation to scene and selection operations. */ const KNOT_MPS = 1852 / 3600;
const finiteOrNaN = (value) =>
typeof value === 'number' && Number.isFinite(value) ? value : NaN;
/**
* A vessel record's fix as a host-supplied placement takes it: its own time
* (when it was received, if the row gives none), position, speed in m/s
* (rows give knots), course and heading.
*/
function placementFix(record) {
const epoch = record.lastPositionEpoch;
return {
timeMs: Number.isFinite(epoch) ? epoch * 1000 : record.receivedAtMs,
lat: record.lat,
lon: record.lon,
altitudeM: NaN,
speedMps: Number.isFinite(record.speed) ? record.speed * KNOT_MPS : NaN,
courseDeg: finiteOrNaN(record.course),
headingDeg: finiteOrNaN(record.heading),
};
}
/** Connect portable vessel reconciliation to scene and selection operations.
* `placement`, a host-supplied placement, is told each live fix. */
export function createVesselSnapshotRenderer({ export function createVesselSnapshotRenderer({
state, state,
placement = null,
records, records,
rendering, rendering,
tracking, tracking,
@@ -17,6 +41,13 @@ export function createVesselSnapshotRenderer({
rendering.ensureCollections(viewer); rendering.ensureCollections(viewer);
const occluder = rendering.makeOccluder(); const occluder = rendering.makeOccluder();
const follow = followsClock(); const follow = followsClock();
// Live fixes only: following the clock, the layer places every vessel.
const observe =
placement && !follow && !replace
? (record) => {
if (record.id) placement.observe(record.id, placementFix(record));
}
: () => {};
records.reconcile( records.reconcile(
rows, rows,
{ {
@@ -28,6 +59,7 @@ export function createVesselSnapshotRenderer({
}, },
{ {
add(record) { add(record) {
observe(record);
rendering.prepareRecordVisual(record); rendering.prepareRecordVisual(record);
rendering.addRecordPrimitives(record, occluder); rendering.addRecordPrimitives(record, occluder);
}, },
@@ -35,6 +67,7 @@ export function createVesselSnapshotRenderer({
return rendering.shipIcon(record, record === state.selectedRecord); return rendering.shipIcon(record, record === state.selectedRecord);
}, },
updated(record, prevIcon) { updated(record, prevIcon) {
observe(record);
const selected = record === state.selectedRecord; const selected = record === state.selectedRecord;
const visual = rendering.prepareRecordVisual(record); const visual = rendering.prepareRecordVisual(record);
if (visual.billboard) { if (visual.billboard) {
@@ -56,6 +89,7 @@ export function createVesselSnapshotRenderer({
rendering.removeRecordPrimitives(record); rendering.removeRecordPrimitives(record);
}, },
removed(id) { removed(id) {
placement?.forget(id);
if (state.trailId === id) tracking.clearSelectedVesselTrail(); if (state.trailId === id) tracking.clearSelectedVesselTrail();
}, },
staleSelected(record) { staleSelected(record) {
+14
View File
@@ -162,6 +162,18 @@ export function createTesting({
components.rendering.updateVisibility(force); components.rendering.updateVisibility(force);
} }
/** Draw a record's visual where it stands now. Test-only seam. */
function _prepareVesselVisualForTest(record) {
return components.rendering.prepareRecordVisual(record);
}
/** Run one live placement pass (a host-supplied placement). Test-only seam. */
function _runPlacementPassForTest() {
components.rendering.followPlacements();
}
/** True while a trailing visibility frame is scheduled. Test-only seam. */ /** True while a trailing visibility frame is scheduled. Test-only seam. */
function _hasTrailingVisibilityPassForTest() { function _hasTrailingVisibilityPassForTest() {
@@ -194,6 +206,8 @@ export function createTesting({
_getVesselFeedStateForTest, _getVesselFeedStateForTest,
_getVesselStateForTest, _getVesselStateForTest,
_runVisibilityPassForTest, _runVisibilityPassForTest,
_prepareVesselVisualForTest,
_runPlacementPassForTest,
_hasTrailingVisibilityPassForTest, _hasTrailingVisibilityPassForTest,
}; };
} }
+5
View File
@@ -73,6 +73,11 @@ export function createTracking({
heightM, heightM,
); );
if (visual.billboard) visual.billboard.position = visual.position; if (visual.billboard) visual.billboard.position = visual.position;
// A placed vessel is drawn again at its placement on the next pass.
if (services.placement) {
visual.drawnLat = NaN;
visual.drawnLon = NaN;
}
} }
} }
+46
View File
@@ -4,6 +4,52 @@ export const RADAR_MAX_GAP_MS = 30 * 60_000;
export const REGIONAL_INFRARED_MAX_GAP_MS = 30 * 60_000; export const REGIONAL_INFRARED_MAX_GAP_MS = 30 * 60_000;
export const GLOBAL_INFRARED_MAX_GAP_MS = 3 * 60 * 60_000; export const GLOBAL_INFRARED_MAX_GAP_MS = 3 * 60 * 60_000;
export const LIGHTNING_MAX_GAP_MS = 30 * 60_000; export const LIGHTNING_MAX_GAP_MS = 30 * 60_000;
/** How many of a feed's latest frames say how far apart its frames come. */
const SPACING_FRAMES = 12;
/** A frame holds this many of its feed's frame spacings before it lapses. */
const SPACING_SLACK = 1.5;
/**
* How far apart a feed's frames come: the median spacing of its latest
* frames, in ms, or null with fewer than two. Takes UTC times or epoch ms.
* @param {Array<string|number>} times
* @returns {number|null}
*/
export function frameSpacingMs(times) {
const at = [
...new Set(
(times ?? [])
.map((time) => (typeof time === 'number' ? time : Date.parse(time)))
.filter(Number.isFinite),
),
]
.sort((a, b) => a - b)
.slice(-(SPACING_FRAMES + 1));
if (at.length < 2) return null;
const gaps = at
.slice(1)
.map((time, i) => time - at[i])
.sort((a, b) => a - b);
const middle = gaps.length >> 1;
return gaps.length % 2 ? gaps[middle] : (gaps[middle - 1] + gaps[middle]) / 2;
}
/**
* A product's least time, `leastMs`, held to its feed's own frame spacing:
* never less than one and a half spacings, so a feed whose frames come an
* hour apart covers the whole hour between them, while one with frames
* minutes apart keeps `leastMs`.
* @param {number} leastMs
* @param {Array<string|number>} times
* @returns {number}
*/
export function atLeastFrameSpacing(leastMs, times) {
const spacing = frameSpacingMs(times);
return spacing === null
? leastMs
: Math.max(leastMs, spacing * SPACING_SLACK);
}
/** The longest play waits on a frame before it goes on without it. */ /** The longest play waits on a frame before it goes on without it. */
export const PLAY_WAIT_MS = 8_000; export const PLAY_WAIT_MS = 8_000;
// Playing on its own, the world does not wait on the frame it is at: that // Playing on its own, the world does not wait on the frame it is at: that
+64
View File
@@ -7,6 +7,8 @@ import {
GLOBAL_INFRARED_MAX_GAP_MS, GLOBAL_INFRARED_MAX_GAP_MS,
LIGHTNING_MAX_GAP_MS, LIGHTNING_MAX_GAP_MS,
PLAY_WAIT_MS, PLAY_WAIT_MS,
frameSpacingMs,
atLeastFrameSpacing,
} from './clock.js'; } from './clock.js';
import { createWorldClock } from '../../time/worldClock.js'; import { createWorldClock } from '../../time/worldClock.js';
@@ -94,6 +96,68 @@ test('union excludes suspended products; nearest selection never uses future or
assert.equal(LIGHTNING_MAX_GAP_MS, 30 * 60_000); assert.equal(LIGHTNING_MAX_GAP_MS, 30 * 60_000);
}); });
test('a feed’s frame spacing is the median of its latest frames', () => {
assert.equal(frameSpacingMs([]), null);
assert.equal(frameSpacingMs([time(0)]), null);
assert.equal(frameSpacingMs([0, 10, 20, 30].map(time)), 10 * 60_000);
// Out of order, repeated, unparseable, and one late frame among many.
assert.equal(
frameSpacingMs([
time(60),
time(0),
'never',
time(0),
time(10),
time(20),
time(30),
]),
10 * 60_000,
);
assert.equal(frameSpacingMs([0, 60, 120].map(time)), 60 * 60_000);
assert.equal(frameSpacingMs([0, 10, 70, 130]), 60);
// Only the latest frames count: an hourly start gives way to ten minutes.
const times = [
...[-600, -540, -480, -420, -360, -300, -240, -180, -120, -60],
...[0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120],
].map(time);
assert.equal(frameSpacingMs(times), 10 * 60_000);
});
test('a product’s gap is at least one and a half of its feed’s frame spacings', (t) => {
const tenMinutes = [0, 10, 20, 30].map(time);
const hourly = [0, 60, 120].map(time);
assert.equal(
atLeastFrameSpacing(REGIONAL_INFRARED_MAX_GAP_MS, tenMinutes),
REGIONAL_INFRARED_MAX_GAP_MS,
'frames minutes apart keep the product’s own gap',
);
assert.equal(
atLeastFrameSpacing(REGIONAL_INFRARED_MAX_GAP_MS, hourly),
90 * 60_000,
);
assert.equal(
atLeastFrameSpacing(REGIONAL_INFRARED_MAX_GAP_MS, [time(0)]),
REGIONAL_INFRARED_MAX_GAP_MS,
);
assert.equal(
atLeastFrameSpacing(GLOBAL_INFRARED_MAX_GAP_MS, hourly),
GLOBAL_INFRARED_MAX_GAP_MS,
);
// Selected against it, an hourly feed has a frame all through each hour.
const { clock } = harness(t);
const clouds = product(
'clouds',
[0, 60, 120],
atLeastFrameSpacing(REGIONAL_INFRARED_MAX_GAP_MS, hourly),
);
clock.register(clouds);
assert.equal(clock.selectFor('clouds', time(45)), time(0));
assert.equal(clock.selectFor('clouds', time(119)), time(60));
assert.equal(clock.selectFor('clouds', time(210)), time(120));
assert.equal(clock.selectFor('clouds', time(211)), null);
});
test('history applies per-product selections, including null; latest restores unequal newest times', async (t) => { test('history applies per-product selections, including null; latest restores unequal newest times', async (t) => {
const { clock } = harness(t); const { clock } = harness(t);
const radar = product('radar', [0, 10, 20]); const radar = product('radar', [0, 10, 20]);
+26 -6
View File
@@ -8,6 +8,7 @@ import {
REGIONAL_INFRARED_MAX_GAP_MS, REGIONAL_INFRARED_MAX_GAP_MS,
GLOBAL_INFRARED_MAX_GAP_MS, GLOBAL_INFRARED_MAX_GAP_MS,
LIGHTNING_MAX_GAP_MS, LIGHTNING_MAX_GAP_MS,
atLeastFrameSpacing,
} from './clock.js'; } from './clock.js';
import { formatUtcDayMonth, t, msg } from '../../i18n/index.js'; import { formatUtcDayMonth, t, msg } from '../../i18n/index.js';
import { formatScaleLabel } from '../../ui/unitLabels.js'; import { formatScaleLabel } from '../../ui/unitLabels.js';
@@ -96,7 +97,10 @@ export function createWeatherLayer({
let unregisterClock = null; let unregisterClock = null;
let frameRequest = null; let frameRequest = null;
let noFrame = false; let noFrame = false;
const maxGap = () => // A product's own gap, and the delay after which its latest frame reads
// late. A feed whose frames come further apart holds each frame, and waits
// for the next, as long as its frames are apart (see atLeastFrameSpacing).
const productGap = () =>
radar radar
? RADAR_MAX_GAP_MS ? RADAR_MAX_GAP_MS
: lightning : lightning
@@ -104,6 +108,23 @@ export function createWeatherLayer({
: product === 'clouds' : product === 'clouds'
? GLOBAL_INFRARED_MAX_GAP_MS ? GLOBAL_INFRARED_MAX_GAP_MS
: REGIONAL_INFRARED_MAX_GAP_MS; : REGIONAL_INFRARED_MAX_GAP_MS;
const productDelay = () =>
(product === 'clouds' ? 240 : lightning ? 45 : 20) * 60_000;
// Read for every frame a selection weighs, so worked out once per product
// and frame list.
let spaced = null;
const spacedTimes = () => {
const times = manifest?.times ?? [];
if (spaced?.times !== times || spaced?.product !== product)
spaced = {
times,
product,
gap: atLeastFrameSpacing(productGap(), times),
delay: atLeastFrameSpacing(productDelay(), times),
};
return spaced;
};
const maxGap = () => spacedTimes().gap;
const isLatest = () => const isLatest = () =>
clock ? clock.getState().mode === 'latest' : followLatest; clock ? clock.getState().mode === 'latest' : followLatest;
const getHost = () => const getHost = () =>
@@ -117,8 +138,7 @@ export function createWeatherLayer({
}); });
const observationDelayed = () => const observationDelayed = () =>
manifest?.latest && manifest?.latest &&
Date.now() - Date.parse(manifest.latest) > Date.now() - Date.parse(manifest.latest) > spacedTimes().delay;
(product === 'clouds' ? 240 : lightning ? 45 : 20) * 60_000;
const stop = () => { const stop = () => {
if (clock) return; if (clock) return;
playing = false; playing = false;
@@ -544,11 +564,11 @@ export function createWeatherLayer({
shared.products.find((entry) => entry.id === id)?.selected === null shared.products.find((entry) => entry.id === id)?.selected === null
? maxGap() / 60_000 < 60 ? maxGap() / 60_000 < 60
? t('No frame within {gap} min of {time}', { ? t('No frame within {gap} min of {time}', {
gap: maxGap() / 60_000, gap: Math.round(maxGap() / 60_000),
time: utc(shared.target), time: utc(shared.target),
}) })
: t('No frame within {gap} h of {time}', { : t('No frame within {gap} h of {time}', {
gap: maxGap() / 3600_000, gap: Math.round(maxGap() / 360_000) / 10,
time: utc(shared.target), time: utc(shared.target),
}) })
: null; : null;
@@ -688,7 +708,7 @@ export function createWeatherLayer({
? t('Global · 60°S–60°N') ? t('Global · 60°S–60°N')
: t('North America')), : t('North America')),
shownTime: time, shownTime: time,
maxGapMinutes: maxGap() / 60_000, maxGapMinutes: Math.round(maxGap() / 60_000),
detail, detail,
status: status:
missing || missing ||
+57
View File
@@ -1949,6 +1949,63 @@ test('satellite global selection uses the three-hour gap and preserves the share
assert.equal(params.target, undefined); assert.equal(params.target, undefined);
}); });
test('satellite clouds from an hourly feed draw all through each hour and read late only past it', async (t) => {
const clock = createWeatherClock();
const now = Date.now();
const hour = 3_600_000;
const top = Math.floor(now / hour) * hour;
// Frames an hour apart, the latest forty minutes old.
const hourly = [3, 2, 1].map((back) =>
new Date(top - back * hour + 20 * 60_000).toISOString(),
);
const latest = hourly.at(-1);
const h = layerHarness({
clock,
id: 'weather-satellite',
feed: {
getSnapshot: async ({ product }) => ({
...snapshot,
product,
times: hourly,
latest,
}),
},
});
t.after(() => {
h.layer.destroy();
clock.destroy();
});
t.mock.method(Date, 'now', () => Date.parse(latest) + 40 * 60_000);
const update = h.layer.update();
await flush();
h.stages.at(-1).finish();
await update;
assert.equal(h.layer.getParams().product, 'clouds-regional');
assert.equal(h.layer.getRowControls().summary.maxGapMinutes, 90);
assert.equal(
h.layer.getStats().stale,
false,
'forty minutes is within the hour',
);
// Forty-five minutes past a frame, between it and the next, it still shows.
const between = clock.setTarget(
new Date(Date.parse(hourly[0]) + 45 * 60_000).toISOString(),
);
await flush();
h.stages.at(-1).finish();
await between;
assert.equal(h.layer.getDiagnostics().hidden, false);
assert.equal(h.stages.at(-1).time, hourly[0]);
t.mock.method(Date, 'now', () => Date.parse(latest) + 100 * 60_000);
assert.equal(
h.layer.getStats().stale,
true,
'past one and a half hours, late',
);
});
test('switching to history cancels a still-loading latest refresh and a later target cancels the first history load', async (t) => { test('switching to history cancels a still-loading latest refresh and a later target cancels the first history load', async (t) => {
const clock = createWeatherClock(); const clock = createWeatherClock();
const h = layerHarness({ clock }); const h = layerHarness({ clock });
+20
View File
@@ -139,9 +139,14 @@ export const DETECTION_THEME_MAP = Object.freeze({
veh_free: '#00ff66', veh_free: '#00ff66',
veh_nodata: '#c9c9c9', veh_nodata: '#c9c9c9',
transit_bus: '#5EF08A', transit_bus: '#5EF08A',
transit_trolleybus: '#B6F04A',
transit_tram: '#FFC24A', transit_tram: '#FFC24A',
transit_cable_tram: '#FF8F4A',
transit_subway: '#FF4538', transit_subway: '#FF4538',
transit_monorail: '#FF6FB8',
transit_rail: '#D9A6FF', transit_rail: '#D9A6FF',
transit_funicular: '#E8C98A',
transit_aerial_lift: '#7FA8FF',
transit_ferry: '#5FD6FF', transit_ferry: '#5FD6FF',
transit_unknown: '#D8DDE5', transit_unknown: '#D8DDE5',
}, },
@@ -169,9 +174,14 @@ export const DETECTION_THEME_MAP = Object.freeze({
veh_free: '#45d8ff', veh_free: '#45d8ff',
veh_nodata: '#dcdcdc', veh_nodata: '#dcdcdc',
transit_bus: '#5EF08A', transit_bus: '#5EF08A',
transit_trolleybus: '#B6F04A',
transit_tram: '#FFC24A', transit_tram: '#FFC24A',
transit_cable_tram: '#FF8F4A',
transit_subway: '#FF4538', transit_subway: '#FF4538',
transit_monorail: '#FF6FB8',
transit_rail: '#D9A6FF', transit_rail: '#D9A6FF',
transit_funicular: '#E8C98A',
transit_aerial_lift: '#7FA8FF',
transit_ferry: '#5FD6FF', transit_ferry: '#5FD6FF',
transit_unknown: '#D8DDE5', transit_unknown: '#D8DDE5',
}, },
@@ -199,9 +209,14 @@ export const DETECTION_THEME_MAP = Object.freeze({
veh_free: '#2ecc71', veh_free: '#2ecc71',
veh_nodata: '#d8d8d8', veh_nodata: '#d8d8d8',
transit_bus: '#5EF08A', transit_bus: '#5EF08A',
transit_trolleybus: '#B6F04A',
transit_tram: '#FFC24A', transit_tram: '#FFC24A',
transit_cable_tram: '#FF8F4A',
transit_subway: '#FF4538', transit_subway: '#FF4538',
transit_monorail: '#FF6FB8',
transit_rail: '#D9A6FF', transit_rail: '#D9A6FF',
transit_funicular: '#E8C98A',
transit_aerial_lift: '#7FA8FF',
transit_ferry: '#5FD6FF', transit_ferry: '#5FD6FF',
transit_unknown: '#D8DDE5', transit_unknown: '#D8DDE5',
}, },
@@ -229,9 +244,14 @@ export const DETECTION_THEME_MAP = Object.freeze({
veh_free: '#2ecc71', veh_free: '#2ecc71',
veh_nodata: '#c9c9c9', veh_nodata: '#c9c9c9',
transit_bus: '#5EF08A', transit_bus: '#5EF08A',
transit_trolleybus: '#B6F04A',
transit_tram: '#FFC24A', transit_tram: '#FFC24A',
transit_cable_tram: '#FF8F4A',
transit_subway: '#FF4538', transit_subway: '#FF4538',
transit_monorail: '#FF6FB8',
transit_rail: '#D9A6FF', transit_rail: '#D9A6FF',
transit_funicular: '#E8C98A',
transit_aerial_lift: '#7FA8FF',
transit_ferry: '#5FD6FF', transit_ferry: '#5FD6FF',
transit_unknown: '#D8DDE5', transit_unknown: '#D8DDE5',
}, },
+7 -6
View File
@@ -1,3 +1,4 @@
import { transitModeFor } from '../data/transitFeeds.js';
import { validTransitIdentifier } from './transitHistory.js'; import { validTransitIdentifier } from './transitHistory.js';
export { validTransitIdentifier } from './transitHistory.js'; export { validTransitIdentifier } from './transitHistory.js';
/** Bounded, process-local operator position history. No upstream or scene access. */ /** Bounded, process-local operator position history. No upstream or scene access. */
@@ -328,12 +329,12 @@ export function createTransitHistory({
t > now + 10000 t > now + 10000
) )
continue; continue;
const byVehicle = feed.vehicleMode?.(record.id); const mode = transitModeFor(
const mode = feed,
(byVehicle !== 'unknown' && byVehicle) || record.routeId,
feed.routeMode?.(record.routeId) || record.id,
feed.defaultMode || record.routeType,
'unknown'; );
const context = contextOf(record, mode); const context = contextOf(record, mode);
if (!context) continue; if (!context) continue;
const key = `${feed.id}\u0000${record.id}`; const key = `${feed.id}\u0000${record.id}`;
+7 -2
View File
@@ -4,6 +4,7 @@
// height, layer stats, selected route lines). // height, layer stats, selected route lines).
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { createRailLayer } from '../layers/rail/index.js'; import { createRailLayer } from '../layers/rail/index.js';
import { layerPlacement } from '../data/placementProvider.js';
import { CAMERA_DEBOUNCE_MS } from '../layers/rail/policy.js'; import { CAMERA_DEBOUNCE_MS } from '../layers/rail/policy.js';
import { WORLD_FOCUS_FRAMING } from '../worldFocus.js'; import { WORLD_FOCUS_FRAMING } from '../worldFocus.js';
import { publicRailCatalog } from '../data/railFeeds.js'; import { publicRailCatalog } from '../data/railFeeds.js';
@@ -82,7 +83,8 @@ const headersOf = (values = {}) => ({
* catalog?: object[]|Promise<object[]>|Function|false, * catalog?: object[]|Promise<object[]>|Function|false,
* ground?: object, * ground?: object,
* stops?: object|null, scheduleStops?: boolean, * stops?: object|null, scheduleStops?: boolean,
* paths?: Record<string, object>|null, fleet?: object|null}} [options] * paths?: Record<string, object>|null, fleet?: object|null,
* placement?: object|null}} [options]
* `ground`: a floor service in place of the always-cold stub; `catalog`: * `ground`: a floor service in place of the always-cold stub; `catalog`:
* the server's feed catalog (it may list fewer feeds than are bundled; a * the server's feed catalog (it may list fewer feeds than are bundled; a
* promise answers when it settles, a function is called per request and * promise answers when it settles, a function is called per request and
@@ -94,7 +96,8 @@ const headersOf = (values = {}) => ({
* failure status it answers with (404 for any other feed; without it the * failure status it answers with (404 for any other feed; without it the
* source has no path route at all); `fleet`: the fleet-model services * source has no path route at all); `fleet`: the fleet-model services
* (testSupport/fleetModelFakes.mjs), with a camera that looks straight * (testSupport/fleetModelFakes.mjs), with a camera that looks straight
* down for them (none by default: trains stay icons). * down for them (none by default: trains stay icons); `placement`: a
* host-supplied placement provider (../data/placementProvider.js).
*/ */
export function railLayerHarness( export function railLayerHarness(
t, t,
@@ -107,6 +110,7 @@ export function railLayerHarness(
scheduleStops = false, scheduleStops = false,
paths = null, paths = null,
fleet = null, fleet = null,
placement = null,
} = {}, } = {},
) { ) {
const T0 = RAIL_HARNESS_T0; const T0 = RAIL_HARNESS_T0;
@@ -332,6 +336,7 @@ export function railLayerHarness(
warmGroundFloor() {}, warmGroundFloor() {},
}, },
...(fleet ? { fleet } : {}), ...(fleet ? { fleet } : {}),
...(placement ? { placement: layerPlacement(placement, 'rail') } : {}),
camera: { camera: {
framing: WORLD_FOCUS_FRAMING.train, framing: WORLD_FOCUS_FRAMING.train,
requestWorldFocus(detail) { requestWorldFocus(detail) {
+4
View File
@@ -65,6 +65,7 @@ import { useHudStore } from './stores/hud.js';
import { useDisplayStore } from './stores/display.js'; import { useDisplayStore } from './stores/display.js';
import { useLocaleStore } from './stores/locale.js'; import { useLocaleStore } from './stores/locale.js';
import { useLocationStore } from './stores/location.js'; import { useLocationStore } from './stores/location.js';
import { installRailEdgeScroll } from './railEdgeScroll.js';
import { useCockpitStore } from './stores/cockpit.js'; import { useCockpitStore } from './stores/cockpit.js';
import { useGlobeActionsStore } from './stores/globeActions.js'; import { useGlobeActionsStore } from './stores/globeActions.js';
import { useRecordingStore } from './stores/recording.js'; import { useRecordingStore } from './stores/recording.js';
@@ -164,6 +165,8 @@ export class StyleManager extends ShellFacade {
}); });
// Vue views share this runtime and its stores; dispose unmounts them. // Vue views share this runtime and its stores; dispose unmounts them.
this._ui = createUiRuntime({ pinia: applicationPinia() }); this._ui = createUiRuntime({ pinia: applicationPinia() });
// Scrolling at a screen edge steps through that side's rail panels.
this._removeRailEdgeScroll = installRailEdgeScroll();
this._feedback = new ShellFeedback({ this._feedback = new ShellFeedback({
readLayers: () => withPlacePacks(this._dataManager?.getAll?.() || []), readLayers: () => withPlacePacks(this._dataManager?.getAll?.() || []),
}); });
@@ -1856,6 +1859,7 @@ export class StyleManager extends ShellFacade {
const { destroyTrackedReadout, destroyWorldOverlay, destroyDetection } = const { destroyTrackedReadout, destroyWorldOverlay, destroyDetection } =
this.services; this.services;
if (this._disposed) return; if (this._disposed) return;
this._removeRailEdgeScroll?.();
this._shareRestoration.destroy(); this._shareRestoration.destroy();
this._feedback.clearLoadingStatus(); this._feedback.clearLoadingStatus();
this._disposed = true; this._disposed = true;
+2
View File
@@ -1,5 +1,6 @@
<script setup> <script setup>
import { vClassToggle } from '../rail/classToggle.js'; import { vClassToggle } from '../rail/classToggle.js';
import UiPanelEdge from './UiPanelEdge.vue';
import UiPanelToggle from './UiPanelToggle.vue'; import UiPanelToggle from './UiPanelToggle.vue';
// Core's panel, with a name, title, slots and v-model:collapsed. It draws Core's panel markup // Core's panel, with a name, title, slots and v-model:collapsed. It draws Core's panel markup
@@ -57,6 +58,7 @@ const emit = defineEmits(['update:collapsed']);
<slot name="badge" /> <slot name="badge" />
<span class="panel-divider"></span> <span class="panel-divider"></span>
<slot name="actions" /> <slot name="actions" />
<UiPanelEdge v-if="props.collapsible" />
<UiPanelToggle <UiPanelToggle
v-if="props.collapsible" v-if="props.collapsible"
:target="props.id" :target="props.id"
+48
View File
@@ -0,0 +1,48 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { t } from '../../i18n/index.js';
import { panelEdge, panelSide, togglePanelEdge } from '../panelEdge.js';
// A panel header's edge switch: it slides this side's panels over their rail
// to the screen edge, and back. The side comes from the column the panel is in.
const button = ref(null);
const side = ref('left');
const on = computed(() => panelEdge.value[side.value]);
function toggle() {
side.value = panelSide(button.value);
togglePanelEdge(side.value);
}
function sync() {
side.value = panelSide(button.value);
}
// The layout moves some panels to the right column after they mount, so the
// side is read again once that has happened.
onMounted(() => {
sync();
globalThis.requestAnimationFrame?.(sync);
});
const icon = computed(() =>
side.value === 'right' ? 'align_horizontal_right' : 'align_horizontal_left',
);
const label = computed(() =>
on.value
? t('Move panels back beside the rail')
: t('Move panels over the rail, to the edge'),
);
</script>
<template>
<button
ref="button"
type="button"
class="ui-panel-edge panel-edge-btn"
:aria-pressed="String(on)"
:aria-label="label"
:title="label"
@pointerenter="sync"
@focus="sync"
@click="toggle"
>
<span class="material-symbols-outlined" aria-hidden="true">{{ icon }}</span>
</button>
</template>
+11 -1
View File
@@ -60,6 +60,7 @@ export function layoutLeftPanelRail({
stack.style.removeProperty('--left-stack-safe-bottom'); stack.style.removeProperty('--left-stack-safe-bottom');
stack.style.removeProperty('--left-stack-centered-height'); stack.style.removeProperty('--left-stack-centered-height');
stack.style.removeProperty('--panel-rail-offset'); stack.style.removeProperty('--panel-rail-offset');
stack.style.removeProperty('--panel-rail-lift');
stack.classList.remove('layout-exclusive'); stack.classList.remove('layout-exclusive');
stack.dataset.layoutMode = 'mobile'; stack.dataset.layoutMode = 'mobile';
settleSideAccordion({ panels, enabled: false, onCollapse }); settleSideAccordion({ panels, enabled: false, onCollapse });
@@ -282,10 +283,19 @@ export function layoutLeftPanelRail({
getComputedStyle(stack) getComputedStyle(stack)
.getPropertyValue?.('--panel-stack-top-pinned') .getPropertyValue?.('--panel-stack-top-pinned')
?.trim() === '1'; ?.trim() === '1';
const railOffset = `${(topPinned ? 0 : safeTop - layoutTop).toFixed(1)}px`; // The stack itself rises by the lift below, by a different amount for a
// tall panel than at rest; the rail takes up the difference.
const lift = (top) => Math.min(0, obstacleSafeTop - top);
const railOffset = `${(topPinned ? 0 : safeTop + lift(safeTop) - layoutTop - lift(layoutTop)).toFixed(1)}px`;
if (stack.style.getPropertyValue('--panel-rail-offset') !== railOffset) { if (stack.style.getPropertyValue('--panel-rail-offset') !== railOffset) {
stack.style.setProperty('--panel-rail-offset', railOffset); stack.style.setProperty('--panel-rail-offset', railOffset);
} }
// How far the column can rise before it meets the HUD readout, so the rail
// and any open panel sit just under it, whether or not a panel is open.
const railLift = `${(topPinned ? 0 : lift(layoutTop)).toFixed(1)}px`;
if (stack.style.getPropertyValue('--panel-rail-lift') !== railLift) {
stack.style.setProperty('--panel-rail-lift', railLift);
}
stack.style.removeProperty('--left-stack-centered-height'); stack.style.removeProperty('--left-stack-centered-height');
for (const panel of panels) for (const panel of panels)
panel.style.removeProperty('--left-panel-allocated-height'); panel.style.removeProperty('--left-panel-allocated-height');
+2 -1
View File
@@ -368,7 +368,8 @@ export function initMobileShell({
: sheet === 'search' : sheet === 'search'
? [dock] ? [dock]
: sheet === 'more' : sheet === 'more'
? [element('right-context-rail'), dock] ? // Visual lives in the left stack; the dock holds the presets.
[element('left-panel-stack'), dock]
: [element('right-context-rail')]; : [element('right-context-rail')];
return roots.find((root) => root?.contains(target)); return roots.find((root) => root?.contains(target));
}; };
+1 -1
View File
@@ -623,7 +623,7 @@ test('the phone shell keeps the required attribution visible', () => {
); );
const openDock = rule('body.mobile-shell[data-mobile-sheet] #command-dock'); const openDock = rule('body.mobile-shell[data-mobile-sheet] #command-dock');
const railSheet = rule( const railSheet = rule(
"body.mobile-shell[data-mobile-sheet='more'] #right-context-rail", "body.mobile-shell[data-mobile-sheet='more'] #left-panel-stack",
); );
assert.ok(z(sheetCredit) > z(openDock)); assert.ok(z(sheetCredit) > z(openDock));
assert.ok(z(sheetCredit) > z(railSheet)); assert.ok(z(sheetCredit) > z(railSheet));
+1
View File
@@ -141,6 +141,7 @@ test("UiPanel draws Core's panel markup and asks for the other state", async (t)
'demo-badge', 'demo-badge',
'panel-divider', 'panel-divider',
'demo-action', 'demo-action',
'panel-edge-btn',
'panel-collapse-btn', 'panel-collapse-btn',
], ],
); );
+42
View File
@@ -0,0 +1,42 @@
import { ref } from 'vue';
// Panels can slide over their rail to the screen edge, one setting per side:
// the left column moves onto the left rail, the right column onto the right
// rail. Each panel header has the switch (UiPanelEdge); the choice is kept
// for the next visit and shows as a body class the stylesheet reads.
const KEY = 'gev.panelEdge';
function read() {
try {
const saved = JSON.parse(globalThis.localStorage?.getItem(KEY) || '{}');
return { left: saved.left === true, right: saved.right === true };
} catch {
return { left: false, right: false };
}
}
export const panelEdge = ref(read());
function apply() {
const body = globalThis.document?.body;
if (!body) return;
body.classList.toggle('panels-edge-left', panelEdge.value.left);
body.classList.toggle('panels-edge-right', panelEdge.value.right);
}
apply();
/** Which side a panel element sits on, from the column it is in. */
export function panelSide(element) {
return element?.closest?.('#right-context-rail, #cockpit-utility-controls')
? 'right'
: 'left';
}
export function togglePanelEdge(side) {
panelEdge.value = { ...panelEdge.value, [side]: !panelEdge.value[side] };
try {
globalThis.localStorage?.setItem(KEY, JSON.stringify(panelEdge.value));
} catch {
// Without storage the choice lasts for this visit.
}
apply();
}
+8 -1
View File
@@ -189,7 +189,11 @@ export class PanelLayoutController {
const stack = this._rightPanelStack; const stack = this._rightPanelStack;
if (!stack || !this._ppToggles) return; if (!stack || !this._ppToggles) return;
stack.prepend(this._ppToggles); // Visual (presets and display settings) is the last panel of the left
// column, after the other tools, and opens from the left rail like them.
if (typeof this._leftPanelStack?.append === 'function')
this._leftPanelStack.append(this._ppToggles);
else stack.prepend(this._ppToggles);
const globalContextPanel = document.getElementById('global-context-panel'); const globalContextPanel = document.getElementById('global-context-panel');
for (const panel of [ for (const panel of [
this._cctvPanel, this._cctvPanel,
@@ -201,6 +205,9 @@ export class PanelLayoutController {
if (!panel) continue; if (!panel) continue;
stack.insertBefore(panel, globalContextPanel); stack.insertBefore(panel, globalContextPanel);
} }
// Context leads the right column: it is the panel for whatever is picked,
// and the camera panels follow it.
if (globalContextPanel) stack.prepend(globalContextPanel);
if (typeof ResizeObserver !== 'undefined') { if (typeof ResizeObserver !== 'undefined') {
this._rightStackResizeObserver = new ResizeObserver(() => { this._rightStackResizeObserver = new ResizeObserver(() => {
this._scheduleRightPanelLayout(); this._scheduleRightPanelLayout();
+12 -1
View File
@@ -6,9 +6,20 @@
* @returns {number} Natural height in CSS pixels. * @returns {number} Natural height in CSS pixels.
*/ */
export function measurePanelNaturalHeight(panel, getComputedStyle) { export function measurePanelNaturalHeight(panel, getComputedStyle) {
const inner = [...panel.children].find( const content = [...panel.children].filter(
(child) => !child.classList.contains('panel-glow'), (child) => !child.classList.contains('panel-glow'),
); );
// A panel built as a header row and a body (Visual) scrolls as a whole, so
// its full scroll height is its natural height.
if (content.length > 1) {
const panelStyle = getComputedStyle(panel);
return Math.ceil(
panel.scrollHeight +
(parseFloat(panelStyle.borderTopWidth) || 0) +
(parseFloat(panelStyle.borderBottomWidth) || 0),
);
}
const inner = content[0];
if (!inner) if (!inner)
return Math.ceil( return Math.ceil(
panel.scrollHeight || panel.getBoundingClientRect().height, panel.scrollHeight || panel.getBoundingClientRect().height,
+1 -1
View File
@@ -19,7 +19,7 @@ export const PANEL_RAIL_ITEMS = Object.freeze({
}, },
'utilities-panel': { icon: 'handyman', label: msg('UTILITIES') }, 'utilities-panel': { icon: 'handyman', label: msg('UTILITIES') },
'scene-panel': { icon: 'movie', label: msg('AUTHORING') }, 'scene-panel': { icon: 'movie', label: msg('AUTHORING') },
'pp-toggles': { icon: 'tune', label: msg('DISPLAY') }, 'pp-toggles': { icon: 'palette', label: msg('VISUAL') },
'cctv-panel': { icon: 'videocam', label: msg('CCTV') }, 'cctv-panel': { icon: 'videocam', label: msg('CCTV') },
'weather-panel': { icon: 'partly_cloudy_day', label: msg('WEATHER') }, 'weather-panel': { icon: 'partly_cloudy_day', label: msg('WEATHER') },
'news-panel': { icon: 'public', label: msg('NEWS') }, 'news-panel': { icon: 'public', label: msg('NEWS') },
+33 -8
View File
@@ -359,14 +359,23 @@ test('right rail aligns to the left rail buttons while the left stack is offset'
assert.equal(f.stack.dataset.safeTop, '286.7'); assert.equal(f.stack.dataset.safeTop, '286.7');
}); });
test('left rail buttons hold still while a tall panel moves the stack', () => { test('left rail buttons hold still whether the open panel is short or tall', () => {
// Where the rail sits: the stack's top, the stack's rise, and the rail's
// own offset inside the stack.
const railTop = (stack) =>
(parseFloat(stack.style.getPropertyValue('--left-stack-safe-top')) * 900) /
100 +
parseFloat(stack.style.getPropertyValue('--panel-rail-lift')) +
parseFloat(stack.style.getPropertyValue('--panel-rail-offset'));
const short = fixture('left');
short.expand(short.first, 250);
short.run();
const f = fixture('left'); const f = fixture('left');
f.expand(f.first, 900); f.expand(f.first, 900);
f.run(); f.run();
assert.equal(f.stack.dataset.layoutMode, 'focus'); assert.equal(f.stack.dataset.layoutMode, 'focus');
assert.ok( assert.notEqual(short.stack.dataset.layoutMode, 'focus');
parseFloat(f.stack.style.getPropertyValue('--panel-rail-offset')) > 0, assert.ok(Math.abs(railTop(f.stack) - railTop(short.stack)) < 0.2);
);
// A theme that pins the stack's top (Cyber) leaves nothing to offset. // A theme that pins the stack's top (Cyber) leaves nothing to offset.
f.stack.computed.getPropertyValue = (name) => f.stack.computed.getPropertyValue = (name) =>
@@ -447,13 +456,17 @@ test('natural height includes visible content, margins and wrapper chrome, exclu
// Outside measurement, CCTV is 391 px in focus and 629 px in normal mode; // Outside measurement, CCTV is 391 px in focus and 629 px in normal mode;
// Removing only the allocation leaves focus CSS active: its next reading is // Removing only the allocation leaves focus CSS active: its next reading is
// 422 px, so the old decision exits focus and then re-enters on the 629 px read. // 422 px, so the old decision exits focus and then re-enters on the 629 px read.
function thrashingRightRail({ focused = false, weatherOpen = false } = {}) { function thrashingRightRail({
focused = false,
weatherOpen = false,
tallId = 'cctv-panel',
} = {}) {
const f = fixture('right'); const f = fixture('right');
f.options.windowRef.innerHeight = 920; f.options.windowRef.innerHeight = 920;
f.options.leftStack.rect.top = 439.6; f.options.leftStack.rect.top = 439.6;
f.stack.computed.rowGap = '8px'; f.stack.computed.rowGap = '8px';
const display = element('pp-toggles', { collapsed: true }); const display = element('pp-toggles', { collapsed: true });
const cctv = element('cctv-panel', { height: 629 }); const cctv = element(tallId, { height: 629 });
const weather = element('weather-panel', { const weather = element('weather-panel', {
height: 444, height: 444,
collapsed: !weatherOpen, collapsed: !weatherOpen,
@@ -524,8 +537,10 @@ for (const focused of [false, true]) {
}); });
} }
// The hysteresis applies to panels that size to their content; CCTV and
// Context take the column's full height whenever they are open.
test('right rail keeps focus through 30 px content growth inside the hysteresis band', () => { test('right rail keeps focus through 30 px content growth inside the hysteresis band', () => {
const f = thrashingRightRail(); const f = thrashingRightRail({ tallId: 'news-panel' });
f.run(); f.run();
// Header + gap contribute 52 px: need moves 420 -> 450 -> 420. // Header + gap contribute 52 px: need moves 420 -> 450 -> 420.
for (const height of [368, 398, 368, 398, 368]) { for (const height of [368, 398, 368, 398, 368]) {
@@ -542,7 +557,7 @@ test('right rail keeps focus through 30 px content growth inside the hysteresis
}); });
test('right rail enters focus only above available height and leaves below the full deadband', () => { test('right rail enters focus only above available height and leaves below the full deadband', () => {
const f = thrashingRightRail(); const f = thrashingRightRail({ tallId: 'news-panel' });
f.options.windowRef.innerHeight = 1000; f.options.windowRef.innerHeight = 1000;
f.options.leftStack.rect.top = 500; f.options.leftStack.rect.top = 500;
f.cctv.intrinsicHeight = 408; // Need equals available (460 px). f.cctv.intrinsicHeight = 408; // Need equals available (460 px).
@@ -559,6 +574,16 @@ test('right rail enters focus only above available height and leaves below the f
assert.equal(f.stack.classList.contains('layout-focus'), false); assert.equal(f.stack.classList.contains('layout-focus'), false);
}); });
test('an open CCTV panel takes the full column however short its content', () => {
const f = thrashingRightRail();
f.cctv.intrinsicHeight = 200;
f.run();
assert.equal(f.stack.classList.contains('layout-focus'), true);
f.cctv.classList.add('collapsed');
f.run();
assert.equal(f.stack.classList.contains('layout-focus'), false);
});
test('right rail settles a second tall panel through the accordion in one pass', () => { test('right rail settles a second tall panel through the accordion in one pass', () => {
const f = thrashingRightRail({ weatherOpen: true }); const f = thrashingRightRail({ weatherOpen: true });
const modes = []; const modes = [];
+393
View File
@@ -0,0 +1,393 @@
// Edge scrolling for the side rails. While the pointer is in a screen edge's
// strip (from the edge to just past its rail), that rail's border lights up,
// and the scroll wheel steps to the next or previous panel on that side
// instead of zooming the globe, wrapping round at either end. The open item's
// highlight slides between items however the panel changes, and a panel
// opened by the wheel slides in from the direction of travel.
/** How far past the rail the strip reaches, in pixels. */
const STRIP_MARGIN = 16;
/** Wheel travel (pixels) that counts as one step. */
const STEP_DELTA = 40;
/**
* A pause this long (ms) with no wheel events ends a scroll gesture. One
* gesture moves one step, however long it runs, trackpad coasting included.
*/
const GESTURE_GAP_MS = 180;
/** After a step, how long (ms) before the same motion can count as a new flick. */
const STEP_COOLDOWN_MS = 220;
/**
* The least time (ms) between two steps, whatever the wheel does. A motion
* that would step sooner waits and steps once the gap has passed (one step
* held at most), so panels never switch back to back and no step is lost.
*/
const STEP_GAP_MS = 500;
/** A wheel event this large (pixels) is a mouse wheel notch, not a trackpad. */
const NOTCH_DELTA = 100;
/** The widest the strip gets, measured from the screen edge, whatever shape the rail takes. */
const STRIP_MAX = 84;
/**
* After a step, how long (ms) the rest of that gesture is still swallowed once
* the pointer has left the strip, so trackpad coasting does not go on to zoom
* the globe. Past it, the wheel belongs to whatever is under the pointer.
*/
const COAST_MAX_MS = 800;
/** Pixels per wheel line, for mice that report lines rather than pixels. */
const LINE_PX = 16;
/**
* Things in the strip that keep their own pointer and wheel behaviour: any
* control, and every panel in either side's stack (a panel scrolls itself).
*/
const OWN_CONTROLS =
'a, button, input, select, textarea, dialog, [role="dialog"], .ui-panel, #left-panel-stack > :not(.gev-panel-rail), #right-context-rail > :not(.gev-panel-rail), #first-run-launcher, #cesium-credits, #world-timeline';
/**
* @param {Document} [doc]
* @returns {() => void} removes the listeners and observers
*/
export function installRailEdgeScroll(doc = globalThis.document) {
const win = doc?.defaultView;
if (!doc || !win) return () => {};
const coarse = win.matchMedia?.('(pointer: coarse)');
let hot = null;
let travel = 0;
let lastWheel = 0;
let lastSpeed = 0;
let stepped = false;
/** When the last step was taken, the fastest the gesture has been, and whether it has slowed since. */
let steppedAt = 0;
/** A step waiting for the gap to pass, if any. */
let heldStep = 0;
let peakSpeed = 0;
let slowed = false;
let frame = 0;
/** Set once removed, so frames already queued do nothing. */
let removed = false;
// Clean view and recording hide the rails with visibility and opacity,
// which keep their boxes, so a box alone does not mean the rail shows.
const showing = (node) =>
node.getBoundingClientRect().width > 0 &&
(typeof node.checkVisibility !== 'function' ||
node.checkVisibility({
visibilityProperty: true,
opacityProperty: true,
}));
// Which screen edge a rail is on comes from where it is, not its name: in a
// right-to-left language the left stack sits on the right.
const rails = () =>
['left', 'right']
.map((name) => doc.querySelector(`.ui-rail--${name}`))
.filter((rail) => rail && showing(rail))
.map((rail) => {
const box = rail.getBoundingClientRect();
const middle = doc.documentElement.clientWidth / 2;
return {
side: box.left + box.width / 2 < middle ? 'left' : 'right',
rail,
};
});
const items = (rail) =>
// Only items that show: a theme or mode may hide some, and the wheel must
// not open those.
[...rail.querySelectorAll('.ui-rail__item')].filter(
(item) => !item.disabled && showing(item),
);
// ── The sliding highlight ──
// Each rail draws it as a ::before under its items, placed by custom
// properties; the open item's own background steps aside while it shows.
// Vue rewrites a rail's class list when it folds its labels, so whether the
// highlight is showing is kept here, and the classes are put back each time.
const gliding = new WeakSet();
function glide(rail) {
if (removed) return;
rail.classList.toggle('ui-rail--edge-hot', rail === hot);
const on = rail.querySelector('.ui-rail__item[aria-pressed="true"]');
if (!on?.offsetWidth) {
gliding.delete(rail);
rail.classList.remove('ui-rail--glide');
return;
}
const railBox = rail.getBoundingClientRect();
const box = on.getBoundingClientRect();
const shown = gliding.has(rail);
// A rail opening its first panel folds its labelled pills to icons, the
// buttons shrinking over a few frames; the highlight waits until the open
// button is an icon again (square) rather than fading in at a pill's
// width and then shrinking.
if (
!shown &&
(rail.classList.contains('ui-rail--expanded') ||
box.width > box.height + 2)
)
return;
// Appearing from nothing: jump into place, then fade in.
rail.classList.toggle('ui-rail--glide-jump', !shown);
rail.style.setProperty(
'--rail-glide-x',
`${box.left - railBox.left - rail.clientLeft}px`,
);
rail.style.setProperty(
'--rail-glide-y',
`${box.top - railBox.top - rail.clientTop}px`,
);
rail.style.setProperty('--rail-glide-w', `${box.width}px`);
rail.style.setProperty('--rail-glide-h', `${box.height}px`);
rail.classList.add('ui-rail--glide');
gliding.add(rail);
if (!shown)
win.requestAnimationFrame(() =>
rail.classList.remove('ui-rail--glide-jump'),
);
}
// Follow the rails for a few frames after any change, as their items
// resize while labels fold away.
function follow(frames = 24) {
win.cancelAnimationFrame(frame);
let left = frames;
const tick = () => {
if (removed) return;
watchRails();
doc.querySelectorAll('.ui-rail').forEach(glide);
if ((left -= 1) > 0) frame = win.requestAnimationFrame(tick);
};
tick();
}
const observer =
typeof win.MutationObserver === 'function'
? new win.MutationObserver((records) => {
// A rail item opened or closed. Nothing here writes aria-pressed,
// and the work waits for the next frame, so this never feeds itself.
if (
records.some((record) =>
record.target.classList?.contains('ui-rail__item'),
)
)
win.requestAnimationFrame(() => follow());
})
: null;
observer?.observe(doc.body, {
subtree: true,
attributes: true,
attributeFilter: ['aria-pressed'],
});
// A rail also changes shape with nothing pressed or released: hidden and
// shown again (Context docked and undocked), or turned from a column to a
// row (panels moved to the screen edge). Its highlight is placed again
// whenever its size changes, so it is never left where an icon was.
const sized =
typeof win.ResizeObserver === 'function'
? new win.ResizeObserver(() => follow(2))
: null;
const watched = new WeakSet();
const watchRails = () => {
if (!sized) return;
doc.querySelectorAll('.ui-rail').forEach((rail) => {
if (watched.has(rail)) return;
watched.add(rail);
sized.observe(rail);
});
};
watchRails();
// ── The edge strip ──
/** The rail whose strip holds the point, unless a control there owns it. */
function railAt(x, target) {
if (doc.body.classList.contains('mobile-shell') || coarse?.matches)
return null;
for (const { side, rail } of rails()) {
const box = rail.getBoundingClientRect();
// From the screen edge to just past the rail, but never wider than a
// rail's own column: a rail laid out as a row across the top of its
// panels would otherwise claim most of the panel's width.
const width = doc.documentElement.clientWidth;
const inStrip =
side === 'left'
? x <= Math.min(box.right + STRIP_MARGIN, STRIP_MAX)
: x >= Math.max(box.left - STRIP_MARGIN, width - STRIP_MAX);
if (!inStrip) continue;
if (rail.contains(target)) return rail;
if (target?.closest?.(OWN_CONTROLS)) return null;
return rail;
}
return null;
}
function setHot(rail) {
if (rail === hot) return;
// A held step belongs to the strip it was made in; leaving drops it, so
// coming back later does not set it off with no wheel turned.
if (heldStep) {
win.clearTimeout(heldStep);
heldStep = 0;
}
hot?.classList.remove('ui-rail--edge-hot');
hot = rail;
travel = 0;
hot?.classList.add('ui-rail--edge-hot');
follow(2);
}
function onMove(event) {
setHot(railAt(event.clientX, event.target));
}
function onLeave(event) {
if (!event.relatedTarget) setHot(null);
}
function step(direction) {
const list = items(hot);
if (!list.length) return;
const current = list.findIndex(
(item) => item.getAttribute('aria-pressed') === 'true',
);
// Past either end, carry on from the other.
const nextIndex =
current < 0
? direction > 0
? 0
: list.length - 1
: (current + direction + list.length) % list.length;
if (nextIndex === current) return;
const target = list[nextIndex];
target.click();
const panel = doc.getElementById(target.dataset.railItem);
if (panel) {
panel.classList.remove('rail-switch-down', 'rail-switch-up');
void panel.offsetWidth;
panel.classList.add(
direction > 0 ? 'rail-switch-down' : 'rail-switch-up',
);
// The classes come off when the slide ends, or after a moment where
// there is no slide to end (reduced motion).
const settle = () => {
win.clearTimeout(timer);
panel.removeEventListener('animationend', settle);
panel.classList.remove('rail-switch-down', 'rail-switch-up');
};
const timer = win.setTimeout(settle, 600);
panel.addEventListener('animationend', settle);
}
follow();
}
function onWheel(event) {
// Pinch zoom, and sideways or empty moves (trackpads send them mid-swipe),
// are left alone: they neither step nor turn a gesture round.
if (event.ctrlKey || !event.deltaY) return;
const now = win.performance?.now?.() ?? Date.now();
const inGesture = now - lastWheel <= GESTURE_GAP_MS;
// Recheck: the pointer may not have moved since the panels changed.
const stillHot =
Boolean(hot) && railAt(event.clientX, event.target) === hot;
if (!stillHot) {
setHot(null);
// The rest of a gesture that has already stepped is used up here, so
// its coasting does not go on to zoom the globe or scroll what opened
// under the pointer.
if (stepped && inGesture && now - steppedAt <= COAST_MAX_MS) {
event.preventDefault();
event.stopPropagation();
lastWheel = now;
}
return;
}
event.preventDefault();
event.stopPropagation();
// Line- and page-based wheels report far smaller numbers than pixels.
const deltaY =
event.deltaMode === 1
? event.deltaY * LINE_PX
: event.deltaMode === 2
? event.deltaY * win.innerHeight
: event.deltaY;
const speed = Math.abs(deltaY);
// A new gesture: after a pause, after turning round, a mouse wheel notch,
// or a fresh flick while the last one is still coasting. A flick is a
// rise in speed after the gesture has clearly slowed, and not in the
// moment after a step: a swipe is still speeding up when it first steps.
const turned = travel && Math.sign(deltaY) !== Math.sign(travel);
const cooled = now - steppedAt > STEP_COOLDOWN_MS;
// Every event from a line- or page-based wheel is a notch.
const notch =
stepped && cooled && (speed >= NOTCH_DELTA || event.deltaMode > 0);
const flick =
stepped && cooled && slowed && speed > Math.max(8, lastSpeed * 1.8);
if (!inGesture || turned || notch || flick) {
// A held step belongs to the motion it came from; a reversal or a new
// flick replaces it rather than queueing behind it.
if (turned && heldStep) {
win.clearTimeout(heldStep);
heldStep = 0;
}
travel = 0;
stepped = false;
peakSpeed = 0;
slowed = false;
}
lastWheel = now;
lastSpeed = speed;
peakSpeed = Math.max(peakSpeed, speed);
if (stepped) {
if (speed < peakSpeed * 0.5) slowed = true;
return;
}
travel += deltaY;
if (Math.abs(travel) < STEP_DELTA) return;
stepped = true;
const direction = Math.sign(travel);
const wait = STEP_GAP_MS - (now - steppedAt);
if (wait <= 0) {
steppedAt = now;
step(direction);
return;
}
// Too soon after the last step: hold this one until the gap has passed.
if (heldStep) return;
const heldRail = hot;
heldStep = win.setTimeout(() => {
heldStep = 0;
// The pointer may have left the strip, or moved to the other rail,
// meanwhile; then nothing steps.
if (!hot || hot !== heldRail) return;
steppedAt = win.performance?.now?.() ?? Date.now();
step(direction);
}, wait);
}
const onResize = () => follow(2);
doc.addEventListener('pointermove', onMove, { passive: true });
doc.addEventListener('pointerout', onLeave, { passive: true });
win.addEventListener('wheel', onWheel, { capture: true, passive: false });
win.addEventListener('resize', onResize, { passive: true });
follow();
return () => {
removed = true;
win.clearTimeout(heldStep);
setHot(null);
observer?.disconnect();
sized?.disconnect();
win.cancelAnimationFrame(frame);
doc.removeEventListener('pointermove', onMove);
doc.removeEventListener('pointerout', onLeave);
win.removeEventListener('wheel', onWheel, { capture: true });
win.removeEventListener('resize', onResize);
doc.querySelectorAll('.ui-rail').forEach((rail) => {
rail.classList.remove(
'ui-rail--glide',
'ui-rail--glide-jump',
'ui-rail--edge-hot',
);
});
};
}
+177
View File
@@ -0,0 +1,177 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { Window } from 'happy-dom';
import { installRailEdgeScroll } from './railEdgeScroll.js';
// A page with a left and a right rail of three items each, laid out by hand:
// happy-dom does no layout, so boxes and the clock are stubbed.
function page({ rtl = false } = {}) {
const window = new Window({ width: 1600, height: 900 });
const doc = window.document;
let now = 1000;
const timers = new Map();
let nextTimer = 1;
window.performance.now = () => now;
window.setTimeout = (fn, ms) => {
const id = nextTimer++;
timers.set(id, { fn, at: now + ms });
return id;
};
window.clearTimeout = (id) => timers.delete(id);
window.requestAnimationFrame = () => 0;
window.cancelAnimationFrame = () => {};
Object.defineProperty(doc.documentElement, 'clientWidth', { value: 1600 });
const box = (el, left, top, width, height) => {
el.getBoundingClientRect = () => ({
left,
top,
width,
height,
right: left + width,
bottom: top + height,
});
Object.defineProperty(el, 'offsetWidth', { get: () => width });
};
const clicks = [];
const rail = (name, left) => {
const el = doc.createElement('div');
el.className = `ui-rail ui-rail--${name}`;
box(el, left, 200, 40, 120);
for (let i = 0; i < 3; i++) {
const item = doc.createElement('button');
item.className = 'ui-rail__item';
item.dataset.railItem = `${name}-${i}`;
item.setAttribute('aria-pressed', i === 0 ? 'true' : 'false');
box(item, left + 4, 204 + i * 40, 32, 32);
item.addEventListener('click', () => {
clicks.push(item.dataset.railItem);
for (const other of el.children)
other.setAttribute('aria-pressed', String(other === item));
});
el.append(item);
}
doc.body.append(el);
return el;
};
// In a right-to-left language the left stack's rail is on the right edge.
const left = rail('left', rtl ? 1550 : 10);
const right = rail('right', rtl ? 10 : 1550);
const remove = installRailEdgeScroll(doc);
const wheel = (x, deltaY, { deltaMode = 0, target = doc.body } = {}) => {
// happy-dom leaves clientX off these events, so it is set by hand.
const at = (event) => Object.defineProperty(event, 'clientX', { value: x });
target.dispatchEvent(
at(new window.PointerEvent('pointermove', { bubbles: true })),
);
const event = at(
new window.WheelEvent('wheel', {
bubbles: true,
cancelable: true,
deltaY,
deltaMode,
}),
);
target.dispatchEvent(event);
return event;
};
const advance = (ms) => {
now += ms;
for (const [id, timer] of [...timers]) {
if (timer.at > now) continue;
timers.delete(id);
timer.fn();
}
};
return { window, doc, left, right, clicks, wheel, advance, remove };
}
test('a wheel in the left strip steps that rail and keeps the globe from zooming', () => {
const p = page();
const event = p.wheel(20, 120);
assert.equal(event.defaultPrevented, true);
assert.deepEqual(p.clicks, ['left-1']);
p.remove();
});
test('a line-based mouse wheel steps one panel per notch', () => {
const p = page();
p.wheel(20, 3, { deltaMode: 1 });
p.advance(600);
p.wheel(20, 3, { deltaMode: 1 });
assert.deepEqual(p.clicks, ['left-1', 'left-2']);
p.remove();
});
test('a rail hidden by clean view or recording leaves the wheel to the globe', () => {
const p = page();
p.left.checkVisibility = () => false;
const event = p.wheel(20, 120);
assert.equal(event.defaultPrevented, false);
assert.deepEqual(p.clicks, []);
p.remove();
});
test('right to left: the wheel at each screen edge steps the rail on that edge', () => {
const p = page({ rtl: true });
p.wheel(1580, 120);
p.advance(600);
p.wheel(20, 120);
assert.deepEqual(p.clicks, ['left-1', 'right-1']);
p.remove();
});
test('after a step, the wheel goes back to the page once the coasting is over', () => {
const p = page();
p.wheel(20, 120);
// The pointer moves off the strip while the gesture still coasts.
let event;
for (let i = 0; i < 20; i++) {
p.advance(100);
event = p.wheel(800, 30);
}
assert.equal(event.defaultPrevented, false);
assert.deepEqual(p.clicks, ['left-1']);
p.remove();
});
test('a held step is dropped when the pointer leaves the strip, even if it comes back', () => {
const p = page();
p.wheel(20, 120);
p.advance(250);
p.wheel(20, -120); // turns round: a second step, held for the gap
p.wheel(800, 0); // leaves the strip
p.wheel(20, 0); // and returns before the gap ends
p.advance(600);
assert.deepEqual(p.clicks, ['left-1']);
p.remove();
});
test('removing it stops edge scrolling', () => {
const p = page();
p.remove();
const event = p.wheel(20, 120);
assert.equal(event.defaultPrevented, false);
assert.deepEqual(p.clicks, []);
});
test('a sideways move mid-swipe neither turns the gesture round nor steps again', () => {
const p = page();
p.wheel(20, 60);
p.advance(50);
const sideways = p.wheel(20, 0);
p.advance(50);
p.wheel(20, 40); // the same swipe coasting on
assert.equal(sideways.defaultPrevented, false);
assert.deepEqual(p.clicks, ['left-1']);
p.remove();
});
test('an open dialog near the edge keeps its own wheel', () => {
const p = page();
const dialog = p.doc.createElement('dialog');
p.doc.body.append(dialog);
const event = p.wheel(20, 120, { target: dialog });
assert.equal(event.defaultPrevented, false);
assert.deepEqual(p.clicks, []);
p.remove();
});
+45 -6
View File
@@ -31,6 +31,20 @@ function settledLeftRailTop(leftStack, viewportHeight, getComputedStyle) {
return Number.isFinite(targetPct) ? (targetPct * viewportHeight) / 100 : top; return Number.isFinite(targetPct) ? (targetPct * viewportHeight) / 100 : top;
} }
/**
* An element's CSS `top` in pixels where it will settle: the end of a running
* `top` transition, else its computed value. NaN when it cannot be read.
*/
function settledCssTop(element, getComputedStyle) {
if (!element) return NaN;
const transition = element
.getAnimations?.()
.find((animation) => animation.transitionProperty === 'top');
const target = parseFloat(transition?.effect?.getKeyframes?.().at(-1)?.top);
if (Number.isFinite(target)) return target;
return parseFloat(getComputedStyle(element).top);
}
/** /**
* Measure and place the right panel rail for one synchronous layout pass. * Measure and place the right panel rail for one synchronous layout pass.
* The caller owns scheduling, obstacle selection, disclosure preferences and * The caller owns scheduling, obstacle selection, disclosure preferences and
@@ -126,9 +140,20 @@ export function layoutRightPanelRail({
viewportHeight, viewportHeight,
getComputedStyle, getComputedStyle,
); );
// Align with the left rail's buttons, which hold still while its stack moves. // Align with the left rail's buttons, which hold still while its stack moves:
const leftRailOffset = // the rail's own settled `top` inside the stack, so every left layout is
parseFloat(leftStack?.style.getPropertyValue('--panel-rail-offset')) || 0; // followed (a layout may pin the rail to the stack's top whatever the
// offset says). The stack's offset is the fallback when there is no rail.
const leftRailInset = settledCssTop(
leftStack?.querySelector?.('.gev-panel-rail') ?? null,
getComputedStyle,
);
// A rail that sits lower in its stack has only made room above itself for
// the open panel's own icon, which stays at the stack's top: the column's
// top has not moved, so that inset is not followed.
const leftRailOffset = Number.isFinite(leftRailInset)
? Math.min(0, leftRailInset)
: parseFloat(leftStack?.style.getPropertyValue('--panel-rail-offset')) || 0;
const alignedTop = Number.isFinite(leftStackTop) const alignedTop = Number.isFinite(leftStackTop)
? leftStackTop + leftRailOffset ? leftStackTop + leftRailOffset
: viewportHeight * 0.26; : viewportHeight * 0.26;
@@ -206,6 +231,18 @@ export function layoutRightPanelRail({
0, 0,
) + ) +
gap * Math.max(0, measuredPanels.length - 1); gap * Math.max(0, measuredPanels.length - 1);
// An open Context panel takes the column's full height: up to just under
// the HUD readouts at the top of the screen, down to the usual bottom.
// CCTV does the same, so its feed and controls have the room.
const FULL_HEIGHT = new Set(['global-context-panel', 'cctv-panel']);
const contextOpen = panels.some(
(panel) =>
FULL_HEIGHT.has(panel.id) && !panel.classList.contains('collapsed'),
);
// Every panel starts level with the left rail, open or not, so the two
// rails line up in every HUD layout (Minimal has no top-right readout to
// push this column down) and switching between Context, CCTV and Street
// Level does not move it. Only the two above also stretch to the bottom.
const layout = resolveHudRailLayout({ const layout = resolveHudRailLayout({
viewportHeight, viewportHeight,
panelHeight: naturalHeight, panelHeight: naturalHeight,
@@ -221,9 +258,11 @@ export function layoutRightPanelRail({
const { safeTop, safeBottom, maxHeight: availableHeight } = layout; const { safeTop, safeBottom, maxHeight: availableHeight } = layout;
const stabilityBand = Math.max(48, viewportHeight * 0.06); const stabilityBand = Math.max(48, viewportHeight * 0.06);
const wasFocused = stack.classList.contains('layout-focus'); const wasFocused = stack.classList.contains('layout-focus');
const shouldFocus = wasFocused const shouldFocus =
? naturalHeight >= availableHeight - stabilityBand contextOpen ||
: naturalHeight > availableHeight; (wasFocused
? naturalHeight >= availableHeight - stabilityBand
: naturalHeight > availableHeight);
const layoutTop = shouldFocus ? safeTop : layout.top; const layoutTop = shouldFocus ? safeTop : layout.top;
const collapsedHeight = visiblePanels.reduce( const collapsedHeight = visiblePanels.reduce(
(total, panel) => (total, panel) =>
+2 -2
View File
@@ -259,7 +259,7 @@ test('recording restores the original HUD after repeated entry and revokes retai
} }
}); });
test('weather rail orders between CCTV and Context, observes and presents chrome', (t) => { test('Context leads the right rail, weather follows CCTV, observes and presents chrome', (t) => {
const f = fixture(); const f = fixture();
const observed = new Set(); const observed = new Set();
const previousResizeObserver = globalThis.ResizeObserver; const previousResizeObserver = globalThis.ResizeObserver;
@@ -319,7 +319,7 @@ test('weather rail orders between CCTV and Context, observes and presents chrome
owner._initRightPanelAdaptiveLayout(); owner._initRightPanelAdaptiveLayout();
assert.deepEqual( assert.deepEqual(
stack.children.map((panel) => panel.id), stack.children.map((panel) => panel.id),
['pp-toggles', 'cctv-panel', 'weather-panel', 'global-context-panel'], ['global-context-panel', 'pp-toggles', 'cctv-panel', 'weather-panel'],
); );
assert.ok(observed.has(weather)); assert.ok(observed.has(weather));
// Moving a panel into the rail leaves its state as it was. // Moving a panel into the rail leaves its state as it was.
Loaded 100 of 111 files, more files were not shown because too many files have changed in this diff. Show more