mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-14 23:32:20 +00:00
Compare commits
35
Commits
No files matched your search
+1
-1
@@ -176,7 +176,7 @@ VITE_AIS_LIVE_LABEL_MAX_ROWS=900
|
|||||||
AISSTREAM_API_KEY=
|
AISSTREAM_API_KEY=
|
||||||
# Optional AISStream subscription filters.
|
# Optional AISStream subscription filters.
|
||||||
# AISSTREAM_BOUNDING_BOXES=[[[-90,-180],[90,180]]]
|
# AISSTREAM_BOUNDING_BOXES=[[[-90,-180],[90,180]]]
|
||||||
# AISSTREAM_MESSAGE_TYPES=PositionReport,StandardClassBPositionReport,ExtendedClassBPositionReport,ShipStaticData,StaticDataReport
|
# AISSTREAM_MESSAGE_TYPES=PositionReport,StandardClassBPositionReport,ExtendedClassBPositionReport,ShipStaticData,StaticDataReport,AidsToNavigationReport
|
||||||
# Feed watchdog: silence (ms) after which the feed is REPORTED stale; the socket
|
# Feed watchdog: silence (ms) after which the feed is REPORTED stale; the socket
|
||||||
# is recycled at 2.5x that. Default 120000. The watchdog self-arms only for the
|
# is recycled at 2.5x that. Default 120000. The watchdog self-arms only for the
|
||||||
# default worldwide subscription — a narrow filter above can be legitimately
|
# default worldwide subscription — a narrow filter above can be legitimately
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
- Draw aids to navigation as buoys instead of ships: a buoy, beacon, RACON,
|
||||||
|
light or mark from the vessel feed (or any AIS station numbered 99MIDxxxx)
|
||||||
|
is now the fleet set's buoy of its IALA mark (cardinal, port or starboard
|
||||||
|
hand, safe water, isolated danger or special mark), standing still with no
|
||||||
|
heading, and its icon is a disc rather than a chevron. Its card and readout
|
||||||
|
show its kind of mark with no speed or heading. AISStream now subscribes to
|
||||||
|
aid-to-navigation reports and names each by its kind of mark and its full
|
||||||
|
name; the feed lists every ship ahead of any aid, and `vessels_in_area`
|
||||||
|
counts ships only. A virtual aid (a mark broadcast where nothing physical
|
||||||
|
stands) keeps its disc, is never drawn as a buoy, and its card says it is
|
||||||
|
virtual; vessel sources may mark any record `virtual` (samehkhamis, #1733).
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
- Draw transit vehicles as their kind, not one of five: trolleybuses, cable
|
||||||
|
cars, funiculars, gondolas and monorails are modes of their own, with their
|
||||||
|
own icon, colour, card word and fleet model. A vehicle record may carry its
|
||||||
|
route's GTFS `route_type` (`routeType`), which settles its mode before the
|
||||||
|
feed's route hints and picks the finer model (a coach for 200, a high-speed
|
||||||
|
set for 101). Prague's trolleybuses and Petřín funicular, and Bucharest,
|
||||||
|
Cluj and Turin's trams, trolleybuses and metro lines, now read as such from
|
||||||
|
the route ids in each operator's routes.txt. Le Havre's funicular, which its
|
||||||
|
routes.txt types as a bus, is a named correction that outranks the route
|
||||||
|
type (samehkhamis, #1734).
|
||||||
@@ -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).
|
||||||
@@ -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
@@ -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. 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
@@ -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>
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ How the layers use them:
|
|||||||
type's published length (a vessel to its reported AIS length and beam), so
|
type's published length (a vessel to its reported AIS length and beam), so
|
||||||
every model is drawn life size. Ground objects, balloons and obstacles in
|
every model is drawn life size. Ground objects, balloons and obstacles in
|
||||||
the `other` category are drawn at the size they are built.
|
the `other` category are drawn at the size they are built.
|
||||||
|
- An aid to navigation (a vessel record whose type names a buoy, beacon, IALA
|
||||||
|
mark, RACON or light, or whose MMSI is an aid station's `99MIDxxxx`) is
|
||||||
|
drawn as the `buoys` model of its mark (cardinal north, east, south or west,
|
||||||
|
port or starboard hand, safe water, isolated danger, special mark), a red
|
||||||
|
can when its type names no mark, at its built size and with no heading
|
||||||
|
(`vesselModelSpec`, `aidToNavigationModel`).
|
||||||
- An aircraft model's origin is its axis; it stands that model's scaled
|
- An aircraft model's origin is its axis; it stands that model's scaled
|
||||||
clearance above the ground. A ship's origin is its waterline, and a transit
|
clearance above the ground. A ship's origin is its waterline, and a transit
|
||||||
or rail vehicle's the road or rail top (a monorail or gondola is raised by
|
or rail vehicle's the road or rail top (a monorail or gondola is raised by
|
||||||
|
|||||||
@@ -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"
|
||||||
]
|
]
|
||||||
@@ -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",
|
||||||
@@ -251,6 +253,7 @@
|
|||||||
"src/data/adsbLolFallback.js",
|
"src/data/adsbLolFallback.js",
|
||||||
"src/data/aisStreamAdapter.js",
|
"src/data/aisStreamAdapter.js",
|
||||||
"src/data/aisWatchdog.js",
|
"src/data/aisWatchdog.js",
|
||||||
|
"src/data/vesselTypes.js",
|
||||||
"src/sources/httpBody.js",
|
"src/sources/httpBody.js",
|
||||||
"src/i18n/marker.js",
|
"src/i18n/marker.js",
|
||||||
"server/providers/acartia.js",
|
"server/providers/acartia.js",
|
||||||
@@ -1135,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",
|
||||||
@@ -1514,6 +1518,7 @@
|
|||||||
"exports": ["./layers/flights"],
|
"exports": ["./layers/flights"],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/data/fleetModels.js",
|
"src/data/fleetModels.js",
|
||||||
|
"src/data/vesselTypes.js",
|
||||||
"src/data/fleetTrailAnchors.js",
|
"src/data/fleetTrailAnchors.js",
|
||||||
"src/data/fleetModelInstances.js",
|
"src/data/fleetModelInstances.js",
|
||||||
"src/data/headingDegrees.js",
|
"src/data/headingDegrees.js",
|
||||||
@@ -1583,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"]
|
||||||
},
|
},
|
||||||
@@ -1593,6 +1599,7 @@
|
|||||||
"src/layers/flights/modelSpec.js",
|
"src/layers/flights/modelSpec.js",
|
||||||
"src/layers/flights/policy.js",
|
"src/layers/flights/policy.js",
|
||||||
"src/data/fleetModels.js",
|
"src/data/fleetModels.js",
|
||||||
|
"src/data/vesselTypes.js",
|
||||||
"src/data/fleetTrailAnchors.js",
|
"src/data/fleetTrailAnchors.js",
|
||||||
"src/data/fleetModelInstances.js",
|
"src/data/fleetModelInstances.js",
|
||||||
"src/data/headingDegrees.js",
|
"src/data/headingDegrees.js",
|
||||||
@@ -1655,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"]
|
||||||
},
|
},
|
||||||
@@ -3084,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",
|
||||||
@@ -3817,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",
|
||||||
@@ -4535,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",
|
||||||
@@ -4817,6 +4830,7 @@
|
|||||||
"src/data/aircraftClass.js",
|
"src/data/aircraftClass.js",
|
||||||
"src/data/aircraftEmergency.js",
|
"src/data/aircraftEmergency.js",
|
||||||
"src/data/fleetModels.js",
|
"src/data/fleetModels.js",
|
||||||
|
"src/data/vesselTypes.js",
|
||||||
"src/data/fleetTrailAnchors.js"
|
"src/data/fleetTrailAnchors.js"
|
||||||
],
|
],
|
||||||
"external": []
|
"external": []
|
||||||
@@ -5207,6 +5221,7 @@
|
|||||||
"exports": ["./layers/transit/standalone"],
|
"exports": ["./layers/transit/standalone"],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/data/fleetModels.js",
|
"src/data/fleetModels.js",
|
||||||
|
"src/data/vesselTypes.js",
|
||||||
"src/data/aircraftClass.js",
|
"src/data/aircraftClass.js",
|
||||||
"src/i18n/marker.js",
|
"src/i18n/marker.js",
|
||||||
"src/data/fleetModelInstances.js",
|
"src/data/fleetModelInstances.js",
|
||||||
@@ -5299,6 +5314,7 @@
|
|||||||
"modules": [
|
"modules": [
|
||||||
"src/i18n/marker.js",
|
"src/i18n/marker.js",
|
||||||
"src/data/fleetModels.js",
|
"src/data/fleetModels.js",
|
||||||
|
"src/data/vesselTypes.js",
|
||||||
"src/data/fleetModelInstances.js",
|
"src/data/fleetModelInstances.js",
|
||||||
"src/data/aircraftClass.js",
|
"src/data/aircraftClass.js",
|
||||||
"src/data/aircraftModelLighting.js",
|
"src/data/aircraftModelLighting.js",
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -27,6 +27,8 @@ const AISSTREAM_DEFAULT_MESSAGE_TYPES = [
|
|||||||
'ExtendedClassBPositionReport',
|
'ExtendedClassBPositionReport',
|
||||||
'ShipStaticData',
|
'ShipStaticData',
|
||||||
'StaticDataReport',
|
'StaticDataReport',
|
||||||
|
// Buoys, beacons and other aids to navigation (AIS message 21).
|
||||||
|
'AidsToNavigationReport',
|
||||||
];
|
];
|
||||||
// Watchdog budgets (policy lives in src/data/aisWatchdog.js). Silence is
|
// Watchdog budgets (policy lives in src/data/aisWatchdog.js). Silence is
|
||||||
// REPORTED quickly and ACTED ON slowly: a dead feed must read as dead within
|
// REPORTED quickly and ACTED ON slowly: a dead feed must read as dead within
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { isRecognizedAisEnvelope } from '../../../src/data/aisStreamAdapter.js';
|
import { isRecognizedAisEnvelope } from '../../../src/data/aisStreamAdapter.js';
|
||||||
|
import {
|
||||||
|
aidToNavigationTypeName,
|
||||||
|
isAidToNavigation,
|
||||||
|
} from '../../../src/data/vesselTypes.js';
|
||||||
export const AISSTREAM_CACHE_MAX = 50000;
|
export const AISSTREAM_CACHE_MAX = 50000;
|
||||||
export const AISSTREAM_STALE_MS = 30 * 60 * 1000;
|
export const AISSTREAM_STALE_MS = 30 * 60 * 1000;
|
||||||
// Per-MMSI recent-path ring buffers (PRD WS-F F3). Float32 lat/lon (~1m
|
// Per-MMSI recent-path ring buffers (PRD WS-F F3). Float32 lat/lon (~1m
|
||||||
@@ -112,13 +116,21 @@ export function ingestAisStreamEnvelope(envelope) {
|
|||||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return true;
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return true;
|
||||||
|
|
||||||
const staticData = _aisStreamStatic.get(mmsi) || {};
|
const staticData = _aisStreamStatic.get(mmsi) || {};
|
||||||
|
const aidReport = messageType === 'AidsToNavigationReport';
|
||||||
_aisStreamVessels.set(mmsi, {
|
_aisStreamVessels.set(mmsi, {
|
||||||
lat,
|
lat,
|
||||||
lon,
|
lon,
|
||||||
name: vesselNameFromAis(metadata, message, staticData) || `MMSI ${mmsi}`,
|
name:
|
||||||
|
(aidReport && aidNameFromAis(message)) ||
|
||||||
|
vesselNameFromAis(metadata, message, staticData) ||
|
||||||
|
`MMSI ${mmsi}`,
|
||||||
mmsi,
|
mmsi,
|
||||||
imo: stringValue(message.ImoNumber ?? message.IMO ?? staticData.imo),
|
imo: stringValue(message.ImoNumber ?? message.IMO ?? staticData.imo),
|
||||||
type: vesselTypeFromAis(message, staticData),
|
// An aid-to-navigation report's type is the kind of aid (0 to 31), not
|
||||||
|
// a ship type: it is carried as the aid's name ("Cardinal Mark N").
|
||||||
|
type: aidReport
|
||||||
|
? aidToNavigationTypeName(message.Type)
|
||||||
|
: vesselTypeFromAis(message, staticData),
|
||||||
destination: stringValue(message.Destination ?? staticData.destination),
|
destination: stringValue(message.Destination ?? staticData.destination),
|
||||||
length: staticData.length ?? null,
|
length: staticData.length ?? null,
|
||||||
beam: staticData.beam ?? null,
|
beam: staticData.beam ?? null,
|
||||||
@@ -131,6 +143,9 @@ export function ingestAisStreamEnvelope(envelope) {
|
|||||||
// Use the AIS message's own report time, not server ingest wall-clock —
|
// Use the AIS message's own report time, not server ingest wall-clock —
|
||||||
// trail spacing and dead reckoning depend on true fix epochs.
|
// trail spacing and dead reckoning depend on true fix epochs.
|
||||||
last_position_epoch: aisEpochSeconds(metadata.time_utc ?? metadata.TimeUtc),
|
last_position_epoch: aisEpochSeconds(metadata.time_utc ?? metadata.TimeUtc),
|
||||||
|
// A virtual aid is broadcast where nothing physical stands. Only such a
|
||||||
|
// row carries the field, so ship rows stay their size.
|
||||||
|
...(aidReport && message.VirtualAtoN === true && { virtual: true }),
|
||||||
_updatedAt: Date.now(),
|
_updatedAt: Date.now(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -276,6 +291,15 @@ function vesselNameFromAis(metadata, message, staticData = {}) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An aid's whole name: an aid-to-navigation report carries the first 20
|
||||||
|
* characters in `Name` and the rest, up to 14 more, in `NameExtension`.
|
||||||
|
*/
|
||||||
|
function aidNameFromAis(message) {
|
||||||
|
const name = stringValue(message.Name);
|
||||||
|
return name ? stringValue(name + stringValue(message.NameExtension)) : '';
|
||||||
|
}
|
||||||
|
|
||||||
function vesselTypeFromAis(message, staticData = {}) {
|
function vesselTypeFromAis(message, staticData = {}) {
|
||||||
return stringValue(
|
return stringValue(
|
||||||
message.Type ??
|
message.Type ??
|
||||||
@@ -285,14 +309,25 @@ function vesselTypeFromAis(message, staticData = {}) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current rows, newest first, with every vessel ahead of every aid to
|
||||||
|
* navigation: a capped request never gives up a ship for a buoy.
|
||||||
|
*/
|
||||||
export function aisStreamRows(maxRows) {
|
export function aisStreamRows(maxRows) {
|
||||||
const cutoff = Date.now() - AISSTREAM_STALE_MS;
|
const cutoff = Date.now() - AISSTREAM_STALE_MS;
|
||||||
const rows = [];
|
const vessels = [];
|
||||||
|
const aids = [];
|
||||||
for (const row of _aisStreamVessels.values()) {
|
for (const row of _aisStreamVessels.values()) {
|
||||||
if (row._updatedAt >= cutoff) rows.push(row);
|
if (row._updatedAt >= cutoff)
|
||||||
|
(isAidToNavigation(row) ? aids : vessels).push(row);
|
||||||
}
|
}
|
||||||
rows.sort((a, b) => b._updatedAt - a._updatedAt);
|
const newestFirst = (a, b) => b._updatedAt - a._updatedAt;
|
||||||
return rows.slice(0, maxRows).map(({ _updatedAt, ...row }) => row);
|
vessels.sort(newestFirst);
|
||||||
|
if (vessels.length < maxRows) aids.sort(newestFirst);
|
||||||
|
return vessels
|
||||||
|
.concat(aids)
|
||||||
|
.slice(0, maxRows)
|
||||||
|
.map(({ _updatedAt, ...row }) => row);
|
||||||
}
|
}
|
||||||
|
|
||||||
function pruneAisStreamCache() {
|
function pruneAisStreamCache() {
|
||||||
|
|||||||
@@ -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 } : {}),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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 } : {}),
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
),
|
||||||
'',
|
'',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -1882,3 +1882,57 @@ test('detection objects name the vessel card their callout gives way to', () =>
|
|||||||
_setVesselStateForTest({ enabled: false });
|
_setVesselStateForTest({ enabled: false });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('an aid to navigation card shows its kind, never a speed or heading', () => {
|
||||||
|
const aid = makeRecord({
|
||||||
|
name: 'SF APPROACH N',
|
||||||
|
mmsi: '993672001',
|
||||||
|
type: 'Cardinal Mark N',
|
||||||
|
speed: 0,
|
||||||
|
course: 135,
|
||||||
|
heading: 135,
|
||||||
|
});
|
||||||
|
const card = buildVesselCard(aid);
|
||||||
|
assert.deepEqual(card.details, ['CARDINAL MARK N']);
|
||||||
|
assert.equal(card.accent, '217, 210, 192');
|
||||||
|
const selected = buildSelectedVesselCard(aid);
|
||||||
|
assert.equal(selected.details[0], 'CARDINAL MARK N');
|
||||||
|
assert.equal(selected.accent, '217, 210, 192');
|
||||||
|
// Known only by its MMSI, an aid is named as one.
|
||||||
|
const unnamed = buildSelectedVesselCard({ ...aid, type: '' });
|
||||||
|
assert.equal(unnamed.details[0], 'AID TO NAVIGATION');
|
||||||
|
assert.deepEqual(buildVesselCard({ ...aid, type: '' }).details, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the HUD of a selected aid to navigation shows no speed or heading', () => {
|
||||||
|
const harness = installWireHarness(undefined, { selectedRecord: null });
|
||||||
|
try {
|
||||||
|
Object.assign(harness.record, {
|
||||||
|
type: 'Port hand Mark',
|
||||||
|
speed: 0,
|
||||||
|
heading: 135,
|
||||||
|
course: 135,
|
||||||
|
});
|
||||||
|
assert.equal(aisLiveVesselsLayer.selectById(harness.record.mmsi), true);
|
||||||
|
assert.match(harness.hud.readout.text, /\nPort hand Mark\n/);
|
||||||
|
assert.doesNotMatch(harness.hud.readout.text, /SPD|HDG|KT/);
|
||||||
|
harness.record.virtual = true;
|
||||||
|
aisLiveVesselsLayer.selectById(harness.record.mmsi);
|
||||||
|
assert.match(harness.hud.readout.text, /\nPort hand Mark · VIRTUAL\n/);
|
||||||
|
} finally {
|
||||||
|
harness.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a virtual aid to navigation card says it is virtual', () => {
|
||||||
|
const aid = makeRecord({
|
||||||
|
mmsi: '993672001',
|
||||||
|
type: 'Isolated danger',
|
||||||
|
virtual: true,
|
||||||
|
});
|
||||||
|
assert.deepEqual(buildVesselCard(aid).details, ['ISOLATED DANGER · VIRTUAL']);
|
||||||
|
assert.equal(
|
||||||
|
buildSelectedVesselCard(aid).details[0],
|
||||||
|
'ISOLATED DANGER · VIRTUAL',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -316,3 +316,97 @@ test('a Class B report carries its dimensions in part B', () => {
|
|||||||
assert.equal(row.length, 12);
|
assert.equal(row.length, 12);
|
||||||
assert.equal(row.beam, 4);
|
assert.equal(row.beam, 4);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('an aid-to-navigation report is a row named by its kind of aid, not a ship type', () => {
|
||||||
|
assert.equal(
|
||||||
|
ingestAisStreamEnvelope({
|
||||||
|
MessageType: 'AidsToNavigationReport',
|
||||||
|
Message: {
|
||||||
|
AidsToNavigationReport: {
|
||||||
|
UserID: 993672001,
|
||||||
|
Type: 20,
|
||||||
|
Name: 'SF APPROACH N',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MetaData: {
|
||||||
|
MMSI: '993672001',
|
||||||
|
latitude: 37.79,
|
||||||
|
longitude: -122.52,
|
||||||
|
time_utc: '2026-09-17 20:00:00.000000000 +0000 UTC',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const row = rowFor('993672001');
|
||||||
|
assert.equal(row.type, 'Cardinal Mark N');
|
||||||
|
assert.equal(row.name, 'SF APPROACH N');
|
||||||
|
assert.equal(row.speed, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an aid's name runs on into its name extension", () => {
|
||||||
|
ingestAisStreamEnvelope({
|
||||||
|
MessageType: 'AidsToNavigationReport',
|
||||||
|
Message: {
|
||||||
|
AidsToNavigationReport: {
|
||||||
|
UserID: 993672002,
|
||||||
|
Type: 13,
|
||||||
|
Name: 'SAN FRANCISCO MAIN S',
|
||||||
|
NameExtension: 'HIP CHANNEL 2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MetaData: {
|
||||||
|
MMSI: '993672002',
|
||||||
|
ShipName: 'SAN FRANCISCO MAIN S',
|
||||||
|
latitude: 37.79,
|
||||||
|
longitude: -122.52,
|
||||||
|
time_utc: '2026-09-17 20:00:00.000000000 +0000 UTC',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(rowFor('993672002').name, 'SAN FRANCISCO MAIN SHIP CHANNEL 2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a capped row request keeps every vessel ahead of aids to navigation', () => {
|
||||||
|
// The aid reports last, so it is the newest row.
|
||||||
|
ingestAisStreamEnvelope(positionReport('244060901', { Sog: 3 }));
|
||||||
|
ingestAisStreamEnvelope({
|
||||||
|
MessageType: 'AidsToNavigationReport',
|
||||||
|
Message: { AidsToNavigationReport: { UserID: 993672003, Type: 24 } },
|
||||||
|
MetaData: {
|
||||||
|
MMSI: '993672003',
|
||||||
|
latitude: 37.79,
|
||||||
|
longitude: -122.52,
|
||||||
|
time_utc: '2026-09-17 20:00:00.000000000 +0000 UTC',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const rows = aisStreamRows(50000);
|
||||||
|
const firstAid = rows.findIndex((row) => row.mmsi.startsWith('99'));
|
||||||
|
const lastVessel = rows.findLastIndex((row) => !row.mmsi.startsWith('99'));
|
||||||
|
assert.ok(firstAid > lastVessel, `${firstAid} > ${lastVessel}`);
|
||||||
|
const vesselCount =
|
||||||
|
rows.length - rows.filter((row) => row.mmsi.startsWith('99')).length;
|
||||||
|
assert.ok(
|
||||||
|
aisStreamRows(vesselCount).every((row) => !row.mmsi.startsWith('99')),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a virtual aid to navigation is marked virtual; a real one carries no flag', () => {
|
||||||
|
const aidReport = (mmsi, VirtualAtoN) => ({
|
||||||
|
MessageType: 'AidsToNavigationReport',
|
||||||
|
Message: {
|
||||||
|
AidsToNavigationReport: { UserID: Number(mmsi), Type: 28, VirtualAtoN },
|
||||||
|
},
|
||||||
|
MetaData: {
|
||||||
|
MMSI: mmsi,
|
||||||
|
latitude: 37.79,
|
||||||
|
longitude: -122.52,
|
||||||
|
time_utc: '2026-09-17 20:00:00.000000000 +0000 UTC',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
ingestAisStreamEnvelope(aidReport('993672004', true));
|
||||||
|
ingestAisStreamEnvelope(aidReport('993672005', false));
|
||||||
|
assert.equal(rowFor('993672004').virtual, true);
|
||||||
|
assert.equal('virtual' in rowFor('993672005'), false);
|
||||||
|
// A later real report of the same aid drops the flag.
|
||||||
|
ingestAisStreamEnvelope(aidReport('993672004', false));
|
||||||
|
assert.equal('virtual' in rowFor('993672004'), false);
|
||||||
|
});
|
||||||
+69
-5
@@ -11,6 +11,9 @@
|
|||||||
* vehicle's road or rail top), and is drawn life size: a type's published
|
* vehicle's road or rail top), and is drawn life size: a type's published
|
||||||
* length over the model's built length is its scale.
|
* length over the model's built length is its scale.
|
||||||
*
|
*
|
||||||
|
* An aid to navigation (a buoy, beacon, mark, RACON or light) is drawn as
|
||||||
|
* the set's buoy of its IALA mark, by the mark its type names.
|
||||||
|
*
|
||||||
* The resolvers are plain functions over the parsed JSON; `loadFleetModelSet`
|
* The resolvers are plain functions over the parsed JSON; `loadFleetModelSet`
|
||||||
* fetches the JSON and every GLB once, up front, so no model is fetched
|
* fetches the JSON and every GLB once, up front, so no model is fetched
|
||||||
* mid-session.
|
* mid-session.
|
||||||
@@ -18,6 +21,7 @@
|
|||||||
|
|
||||||
import { classifyAircraft } from './aircraftClass.js';
|
import { classifyAircraft } from './aircraftClass.js';
|
||||||
import { FLEET_TRAIL_ANCHORS } from './fleetTrailAnchors.js';
|
import { FLEET_TRAIL_ANCHORS } from './fleetTrailAnchors.js';
|
||||||
|
import { isAidToNavigation } from './vesselTypes.js';
|
||||||
|
|
||||||
/** Where the model set is served, relative to the application base. */
|
/** Where the model set is served, relative to the application base. */
|
||||||
export const FLEET_MODEL_BASE = '/models/fleet/';
|
export const FLEET_MODEL_BASE = '/models/fleet/';
|
||||||
@@ -84,6 +88,44 @@ const TEXT_SHIP_TYPES = [
|
|||||||
[/pleasure|yacht/i, 37],
|
[/pleasure|yacht/i, 37],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The buoy an aid to navigation is drawn as, by the mark its type names
|
||||||
|
* ("Cardinal Mark N", "Beacon, Port hand", "Safe Water"): the first pattern
|
||||||
|
* that matches. Beacons, lights, RACONs and reference points with no mark of
|
||||||
|
* their own are the special mark.
|
||||||
|
*/
|
||||||
|
const AID_MODELS = [
|
||||||
|
[/cardinal\b.*\bn(orth)?$/i, 'buoy_cardinal_north'],
|
||||||
|
[/cardinal\b.*\be(ast)?$/i, 'buoy_cardinal_east'],
|
||||||
|
[/cardinal\b.*\bs(outh)?$/i, 'buoy_cardinal_south'],
|
||||||
|
[/cardinal\b.*\bw(est)?$/i, 'buoy_cardinal_west'],
|
||||||
|
[/isolated danger/i, 'buoy_isolated_danger'],
|
||||||
|
[/safe water/i, 'buoy_safe_water'],
|
||||||
|
[/port hand/i, 'buoy_lateral_port'],
|
||||||
|
[/starboard hand/i, 'buoy_lateral_starboard'],
|
||||||
|
[/special mark|light vessel|lanby/i, 'buoy_special'],
|
||||||
|
[
|
||||||
|
/^(beacon|light\b|leading light|racon|reference point|navigation aid)/i,
|
||||||
|
'buoy_special',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
/** The buoy an aid to navigation whose type names no mark is drawn as. */
|
||||||
|
export const DEFAULT_AID_MODEL = 'can_red';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The buoy model of an aid to navigation's type, else undefined when the
|
||||||
|
* type names no mark.
|
||||||
|
* @param {string|null} [type]
|
||||||
|
* @returns {string|undefined}
|
||||||
|
*/
|
||||||
|
export function aidToNavigationModel(type) {
|
||||||
|
const text = String(type ?? '').trim();
|
||||||
|
for (const [pattern, model] of AID_MODELS)
|
||||||
|
if (pattern.test(text)) return model;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The model for an aircraft of ICAO type `type` (A320, B738, R44), ADS-B
|
* The model for an aircraft of ICAO type `type` (A320, B738, R44), ADS-B
|
||||||
* emitter category `category` and callsign `callsign`: the first of the
|
* emitter category `category` and callsign `callsign`: the first of the
|
||||||
@@ -397,13 +439,33 @@ export function aircraftModelSpec(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The model a vessel is drawn with, from its AIS ship type (a code or a text
|
* The model a vessel is drawn with, from its AIS ship type (a code or a text
|
||||||
* family), reported length and beam (metres) and name.
|
* family), reported length and beam (metres) and name. An aid to navigation
|
||||||
|
* ({@link isAidToNavigation}: its type names a buoy, beacon, mark, RACON or
|
||||||
|
* light, or its MMSI is an aid's) is the buoy of its mark
|
||||||
|
* ({@link aidToNavigationModel}), else {@link DEFAULT_AID_MODEL}, drawn as
|
||||||
|
* built and flagged `fixed`: it has no heading to turn to. A virtual aid
|
||||||
|
* (`virtual`) is broadcast where nothing physical stands, so it has no model.
|
||||||
* @param {{catalog: object, types: object}} set
|
* @param {{catalog: object, types: object}} set
|
||||||
* @param {{type?: string|number|null, length?: number|null,
|
* @param {{type?: string|number|null, mmsi?: string|number|null,
|
||||||
* beam?: number|null, name?: string|null}} vessel
|
* virtual?: boolean, length?: number|null, beam?: number|null,
|
||||||
* @returns {object} {@link fleetModelSpec}
|
* name?: string|null}} vessel
|
||||||
|
* @returns {object|null} {@link fleetModelSpec}, with `fixed` for an aid;
|
||||||
|
* null for a virtual aid
|
||||||
*/
|
*/
|
||||||
export function vesselModelSpec(set, { type, length, beam, name } = {}) {
|
export function vesselModelSpec(
|
||||||
|
set,
|
||||||
|
{ type, mmsi, virtual, length, beam, name } = {},
|
||||||
|
) {
|
||||||
|
if (virtual === true) return null;
|
||||||
|
if (isAidToNavigation({ type, mmsi })) {
|
||||||
|
const model = aidToNavigationModel(type);
|
||||||
|
const known = model && set.catalog[model] ? model : DEFAULT_AID_MODEL;
|
||||||
|
if (set.catalog[known])
|
||||||
|
return Object.freeze({
|
||||||
|
...fleetModelSpec(set, known, null),
|
||||||
|
fixed: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
// A beam counts only beside a reported length: a vessel drawn at its type's
|
// A beam counts only beside a reported length: a vessel drawn at its type's
|
||||||
// typical length keeps the model's proportions.
|
// typical length keeps the model's proportions.
|
||||||
const reported = hullLength(length ?? undefined);
|
const reported = hullLength(length ?? undefined);
|
||||||
@@ -460,6 +522,8 @@ export function namedFleetModels(types) {
|
|||||||
transit.default[0],
|
transit.default[0],
|
||||||
...Object.keys(transit.elevation),
|
...Object.keys(transit.elevation),
|
||||||
...Object.values(CLASS_MODELS),
|
...Object.values(CLASS_MODELS),
|
||||||
|
...AID_MODELS.map(([, model]) => model),
|
||||||
|
DEFAULT_AID_MODEL,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import fs from 'node:fs';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import {
|
import {
|
||||||
|
DEFAULT_AID_MODEL,
|
||||||
FLEET_MODEL_BASE,
|
FLEET_MODEL_BASE,
|
||||||
|
aidToNavigationModel,
|
||||||
aircraftModelSpec,
|
aircraftModelSpec,
|
||||||
aisShipTypeCode,
|
aisShipTypeCode,
|
||||||
emitterCategory,
|
emitterCategory,
|
||||||
@@ -23,6 +25,7 @@ import {
|
|||||||
vesselModelSpec,
|
vesselModelSpec,
|
||||||
} from './fleetModels.js';
|
} from './fleetModels.js';
|
||||||
import { FLEET_TRAIL_ANCHORS } from './fleetTrailAnchors.js';
|
import { FLEET_TRAIL_ANCHORS } from './fleetTrailAnchors.js';
|
||||||
|
import { TRANSIT_MODES } from './transitFeeds.js';
|
||||||
import {
|
import {
|
||||||
centrelineSegments,
|
centrelineSegments,
|
||||||
distanceToSurface,
|
distanceToSurface,
|
||||||
@@ -381,6 +384,99 @@ test('vessel specs read AIS codes and text families, stretched to the reported b
|
|||||||
assert.equal(unknown.scale, 1);
|
assert.equal(unknown.scale, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('aids to navigation are the buoy of the mark their type names', () => {
|
||||||
|
const cases = [
|
||||||
|
['Cardinal Mark N', 'buoy_cardinal_north'],
|
||||||
|
['Beacon, Cardinal E', 'buoy_cardinal_east'],
|
||||||
|
['Cardinal Mark S', 'buoy_cardinal_south'],
|
||||||
|
['Beacon, Cardinal W', 'buoy_cardinal_west'],
|
||||||
|
['Port Hand Mark', 'buoy_lateral_port'],
|
||||||
|
['Beacon, Port hand', 'buoy_lateral_port'],
|
||||||
|
['Starboard Hand Mark', 'buoy_lateral_starboard'],
|
||||||
|
['Preferred Channel Starboard hand', 'buoy_lateral_starboard'],
|
||||||
|
['Safe Water', 'buoy_safe_water'],
|
||||||
|
['Isolated danger', 'buoy_isolated_danger'],
|
||||||
|
['Beacon, Special Mark', 'buoy_special'],
|
||||||
|
['Special Mark', 'buoy_special'],
|
||||||
|
['Light Vessel / LANBY / Rigs', 'buoy_special'],
|
||||||
|
['RACON', 'buoy_special'],
|
||||||
|
['Light, with Sectors', 'buoy_special'],
|
||||||
|
['Reference point', 'buoy_special'],
|
||||||
|
['Navigation aid', 'buoy_special'],
|
||||||
|
];
|
||||||
|
for (const [type, model] of cases) {
|
||||||
|
assert.equal(aidToNavigationModel(type), model, type);
|
||||||
|
assert.ok(catalog[model], model);
|
||||||
|
const spec = vesselModelSpec(set, { type, mmsi: '993672001' });
|
||||||
|
assert.equal(spec.model, model, type);
|
||||||
|
assert.equal(spec.category, 'buoys', type);
|
||||||
|
assert.equal(spec.fixed, true, type);
|
||||||
|
// A buoy is drawn as built, whatever length the report gives.
|
||||||
|
assert.equal(spec.scale, 1, type);
|
||||||
|
}
|
||||||
|
assert.equal(
|
||||||
|
vesselModelSpec(set, { type: 'Safe Water', length: 120, beam: 30 }).scale,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
// An aid of no stated kind, known only by its MMSI, is the default can.
|
||||||
|
assert.ok(catalog[DEFAULT_AID_MODEL]);
|
||||||
|
const unnamed = vesselModelSpec(set, { type: '', mmsi: '993660123' });
|
||||||
|
assert.equal(unnamed.model, DEFAULT_AID_MODEL);
|
||||||
|
assert.equal(unnamed.fixed, true);
|
||||||
|
assert.equal(aidToNavigationModel('Fixed structure off shore'), undefined);
|
||||||
|
// A virtual aid is broadcast where nothing stands: it keeps its disc.
|
||||||
|
assert.equal(
|
||||||
|
vesselModelSpec(set, {
|
||||||
|
type: 'Cardinal Mark N',
|
||||||
|
mmsi: '993672001',
|
||||||
|
virtual: true,
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
// Vessels, including those that work on buoys, keep their hulls.
|
||||||
|
for (const vessel of [
|
||||||
|
{ type: 'Buoy Tender', mmsi: '366999001', length: 60 },
|
||||||
|
{ type: '70', mmsi: '366999002' },
|
||||||
|
{ type: 'Tug', mmsi: '' },
|
||||||
|
]) {
|
||||||
|
const spec = vesselModelSpec(set, vessel);
|
||||||
|
assert.notEqual(spec.category, 'buoys', vessel.type);
|
||||||
|
assert.equal(spec.fixed, undefined, vessel.type);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every transit mode but unknown draws its own model, and a route type a finer one', () => {
|
||||||
|
for (const mode of TRANSIT_MODES.filter((m) => m !== 'unknown')) {
|
||||||
|
assert.ok(types.transit.modes[mode], `${mode} has a model`);
|
||||||
|
assert.equal(
|
||||||
|
resolveTransit(types, { mode }).model,
|
||||||
|
types.transit.modes[mode][0],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const [mode, model] of [
|
||||||
|
['trolleybus', 'trolleybus'],
|
||||||
|
['cable_tram', 'cable_tram'],
|
||||||
|
['aerial_lift', 'gondola'],
|
||||||
|
['funicular', 'funicular'],
|
||||||
|
['monorail', 'monorail'],
|
||||||
|
])
|
||||||
|
assert.equal(resolveTransit(types, { mode }).model, model, mode);
|
||||||
|
// A route type outranks the mode: a coach and a high-speed set are still
|
||||||
|
// the bus and rail modes.
|
||||||
|
for (const [routeType, mode, model] of [
|
||||||
|
[200, 'bus', 'coach'],
|
||||||
|
[101, 'rail', 'high_speed'],
|
||||||
|
[106, 'rail', 'regional_dmu'],
|
||||||
|
[710, 'bus', 'double_decker'],
|
||||||
|
])
|
||||||
|
assert.equal(resolveTransit(types, { routeType, mode }).model, model);
|
||||||
|
// A type the table lacks falls back to the mode.
|
||||||
|
assert.equal(
|
||||||
|
resolveTransit(types, { routeType: 300, mode: 'rail' }).model,
|
||||||
|
'commuter_emu',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('transit specs: rail category and mode, then route type, then mode, then the default', () => {
|
test('transit specs: rail category and mode, then route type, then mode, then the default', () => {
|
||||||
assert.deepEqual(resolveTransit(types, { railCategory: 'national' }), {
|
assert.deepEqual(resolveTransit(types, { railCategory: 'national' }), {
|
||||||
model: 'intercity',
|
model: 'intercity',
|
||||||
|
|||||||
@@ -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.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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]) {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -46,6 +46,15 @@ const TYPE_STYLES = [
|
|||||||
];
|
];
|
||||||
const DEFAULT_STYLE = { css: '#39d5ff', accent: '57, 213, 255' };
|
const DEFAULT_STYLE = { css: '#39d5ff', accent: '57, 213, 255' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An aid to navigation's disc and card accent: a bone white apart from every
|
||||||
|
* ship type's colour.
|
||||||
|
*/
|
||||||
|
export const AID_TO_NAVIGATION_STYLE = Object.freeze({
|
||||||
|
css: '#d9d2c0',
|
||||||
|
accent: '217, 210, 192',
|
||||||
|
});
|
||||||
|
|
||||||
// Card text for the type tokens above. The tokens stay English: TYPE_STYLES
|
// Card text for the type tokens above. The tokens stay English: TYPE_STYLES
|
||||||
// and tests match on them.
|
// and tests match on them.
|
||||||
const TYPE_DISPLAY_KEYS = Object.freeze({
|
const TYPE_DISPLAY_KEYS = Object.freeze({
|
||||||
|
|||||||
@@ -45,3 +45,110 @@ export function normalizeVesselType(type) {
|
|||||||
if (NUMERIC_TYPE_SPECIALS[code]) return NUMERIC_TYPE_SPECIALS[code];
|
if (NUMERIC_TYPE_SPECIALS[code]) return NUMERIC_TYPE_SPECIALS[code];
|
||||||
return NUMERIC_TYPE_FAMILIES[Math.floor(code / 10)] || 'OTHER';
|
return NUMERIC_TYPE_FAMILIES[Math.floor(code / 10)] || 'OTHER';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The kind of aid to navigation an AIS aid-to-navigation report (message 21)
|
||||||
|
* names by its type code, 0 to 31 (ITU-R M.1371, IALA marks): beacons and
|
||||||
|
* floating marks by their cardinal quadrant, lateral side, safe water,
|
||||||
|
* isolated danger or special mark, and the lights, RACONs and reference
|
||||||
|
* points around them. Code 0 names no kind.
|
||||||
|
*/
|
||||||
|
const AID_TYPE_NAMES = Object.freeze([
|
||||||
|
'Navigation aid',
|
||||||
|
'Reference point',
|
||||||
|
'RACON',
|
||||||
|
'Fixed structure off shore',
|
||||||
|
'Emergency wreck marking buoy',
|
||||||
|
'Light, without sectors',
|
||||||
|
'Light, with sectors',
|
||||||
|
'Leading light front',
|
||||||
|
'Leading light rear',
|
||||||
|
'Beacon, Cardinal N',
|
||||||
|
'Beacon, Cardinal E',
|
||||||
|
'Beacon, Cardinal S',
|
||||||
|
'Beacon, Cardinal W',
|
||||||
|
'Beacon, Port hand',
|
||||||
|
'Beacon, Starboard hand',
|
||||||
|
'Beacon, Preferred channel port hand',
|
||||||
|
'Beacon, Preferred channel starboard hand',
|
||||||
|
'Beacon, Isolated danger',
|
||||||
|
'Beacon, Safe water',
|
||||||
|
'Beacon, Special mark',
|
||||||
|
'Cardinal Mark N',
|
||||||
|
'Cardinal Mark E',
|
||||||
|
'Cardinal Mark S',
|
||||||
|
'Cardinal Mark W',
|
||||||
|
'Port hand Mark',
|
||||||
|
'Starboard hand Mark',
|
||||||
|
'Preferred Channel Port hand',
|
||||||
|
'Preferred Channel Starboard hand',
|
||||||
|
'Isolated danger',
|
||||||
|
'Safe Water',
|
||||||
|
'Special Mark',
|
||||||
|
'Light Vessel / LANBY / Rigs',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The kind an AIS aid-to-navigation type code names ("19" → "Beacon, Special
|
||||||
|
* mark"); "Navigation aid" for code 0 and for a code outside 0 to 31.
|
||||||
|
* @param {string|number|null|undefined} code
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function aidToNavigationTypeName(code) {
|
||||||
|
const text = String(code ?? '').trim();
|
||||||
|
const index = /^\d+$/.test(text) ? Number(text) : -1;
|
||||||
|
return AID_TYPE_NAMES[index] ?? AID_TYPE_NAMES[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every kind {@link aidToNavigationTypeName} names, lowercased. */
|
||||||
|
const AID_TYPE_NAME_SET = new Set(
|
||||||
|
AID_TYPE_NAMES.map((name) => name.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Words a vessel type uses for an aid to navigation. A vessel that works on
|
||||||
|
* one ("Buoy Tender", "Buoy-Laying Vessel", "Buoy Layer") is a ship underway,
|
||||||
|
* not a mark on the water.
|
||||||
|
*/
|
||||||
|
const AID_TYPE_TEXT =
|
||||||
|
/navaid|\baton\b|aids? to nav|nav(igation)? aid|racon|lighthouse|lightship|light vessel|reference point|hand mark|cardinal|safe water|preferred channel|isolated danger|special mark|beacon|buoy/i;
|
||||||
|
const AID_WORKER_TEXT = /tender|lay(ing|er)\b/i;
|
||||||
|
|
||||||
|
/** ITU MID 99: the MMSI of an AIS aid-to-navigation station (99MIDxxxx). */
|
||||||
|
const AID_MMSI = /^99\d{7}$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether each type text seen names an aid. Vessel layers ask per vessel on
|
||||||
|
* their draw passes, and a feed uses few distinct type texts, so each is
|
||||||
|
* matched once; the memo is bounded against a feed of free-form types.
|
||||||
|
*/
|
||||||
|
const AID_TYPE_MEMO = new Map();
|
||||||
|
const AID_TYPE_MEMO_MAX = 1024;
|
||||||
|
|
||||||
|
function typeNamesAid(text) {
|
||||||
|
let aid = AID_TYPE_MEMO.get(text);
|
||||||
|
if (aid !== undefined) return aid;
|
||||||
|
aid =
|
||||||
|
AID_TYPE_NAME_SET.has(text.toLowerCase()) ||
|
||||||
|
(AID_TYPE_TEXT.test(text) && !AID_WORKER_TEXT.test(text));
|
||||||
|
if (AID_TYPE_MEMO.size >= AID_TYPE_MEMO_MAX) AID_TYPE_MEMO.clear();
|
||||||
|
AID_TYPE_MEMO.set(text, aid);
|
||||||
|
return aid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a vessel record is an aid to navigation (a buoy, beacon, mark,
|
||||||
|
* RACON or light) rather than a vessel: its type is one of the kinds an aid
|
||||||
|
* report names or uses a word for one, or its MMSI is an aid-to-navigation
|
||||||
|
* station's, or it is a virtual aid. A numeric AIS ship type never names one.
|
||||||
|
* @param {{type?: string|number|null, mmsi?: string|number|null,
|
||||||
|
* virtual?: boolean}} vessel
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function isAidToNavigation({ type, mmsi, virtual } = {}) {
|
||||||
|
if (virtual === true) return true;
|
||||||
|
const text = typeof type === 'string' ? type.trim() : String(type ?? '');
|
||||||
|
if (text && typeNamesAid(text)) return true;
|
||||||
|
if (mmsi === null || mmsi === undefined || mmsi === '') return false;
|
||||||
|
return AID_MMSI.test(typeof mmsi === 'string' ? mmsi.trim() : String(mmsi));
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { aidToNavigationTypeName, isAidToNavigation } from './vesselTypes.js';
|
||||||
|
|
||||||
|
test('an aid-to-navigation type code names its kind of aid', () => {
|
||||||
|
assert.equal(aidToNavigationTypeName(20), 'Cardinal Mark N');
|
||||||
|
assert.equal(aidToNavigationTypeName('13'), 'Beacon, Port hand');
|
||||||
|
assert.equal(aidToNavigationTypeName(2), 'RACON');
|
||||||
|
assert.equal(aidToNavigationTypeName(29), 'Safe Water');
|
||||||
|
assert.equal(aidToNavigationTypeName(31), 'Light Vessel / LANBY / Rigs');
|
||||||
|
// Code 0, a code past 31 and no code name no kind.
|
||||||
|
for (const code of [0, 32, -1, '', null, undefined, 'x'])
|
||||||
|
assert.equal(aidToNavigationTypeName(code), 'Navigation aid', code);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('aids to navigation are known by their type, else their MMSI', () => {
|
||||||
|
for (const type of [
|
||||||
|
'Beacon, Special Mark',
|
||||||
|
'Cardinal Mark N',
|
||||||
|
'Port Hand Mark',
|
||||||
|
'Starboard Hand Mark',
|
||||||
|
'Safe Water',
|
||||||
|
'Isolated danger',
|
||||||
|
'Preferred Channel Port hand',
|
||||||
|
'RACON',
|
||||||
|
'Light Vessel / LANBY / Rigs',
|
||||||
|
'Reference point',
|
||||||
|
'Navigation aid',
|
||||||
|
'AtoN',
|
||||||
|
'Lighthouse',
|
||||||
|
'Buoy',
|
||||||
|
// Every kind an aid report names is an aid, whatever its MMSI.
|
||||||
|
'Light, with sectors',
|
||||||
|
'Leading light rear',
|
||||||
|
'Fixed structure off shore',
|
||||||
|
'Emergency wreck marking buoy',
|
||||||
|
])
|
||||||
|
assert.equal(isAidToNavigation({ type, mmsi: '' }), true, type);
|
||||||
|
// A virtual aid is an aid whatever its type or MMSI.
|
||||||
|
assert.equal(
|
||||||
|
isAidToNavigation({ type: '', mmsi: '', virtual: true }),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
// AIS aid-to-navigation stations are numbered 99MIDxxxx.
|
||||||
|
assert.equal(isAidToNavigation({ type: '', mmsi: '993672001' }), true);
|
||||||
|
assert.equal(isAidToNavigation({ type: '0', mmsi: ' 992351234 ' }), true);
|
||||||
|
for (const vessel of [
|
||||||
|
{ type: 'Buoy Tender', mmsi: '366999001' },
|
||||||
|
{ type: 'Buoy-Laying Vessel', mmsi: '' },
|
||||||
|
{ type: 'Buoy Layer', mmsi: '' },
|
||||||
|
{ type: 'Lighter', mmsi: '' },
|
||||||
|
{ type: 'Flight deck', mmsi: '' },
|
||||||
|
{ type: 70, mmsi: 366999002 },
|
||||||
|
{ type: '70', mmsi: '366999002' },
|
||||||
|
{ type: '0', mmsi: '' },
|
||||||
|
{ type: 'Cargo', mmsi: '99' },
|
||||||
|
{ type: 'Passenger', mmsi: '9936720011' },
|
||||||
|
{},
|
||||||
|
])
|
||||||
|
assert.equal(isAidToNavigation(vessel), false, JSON.stringify(vessel));
|
||||||
|
});
|
||||||
@@ -776,6 +776,8 @@
|
|||||||
"TANKER": "ناقلة",
|
"TANKER": "ناقلة",
|
||||||
"OTHER": "أخرى",
|
"OTHER": "أخرى",
|
||||||
"VESSEL": "سفينة",
|
"VESSEL": "سفينة",
|
||||||
|
"AID TO NAVIGATION": "مساعدة ملاحية",
|
||||||
|
"VIRTUAL": "افتراضي",
|
||||||
"POS: LIVE": "الموضع: مباشر",
|
"POS: LIVE": "الموضع: مباشر",
|
||||||
"POS: {time}Z": "الموضع: {time}Z",
|
"POS: {time}Z": "الموضع: {time}Z",
|
||||||
"AIS: --": "AIS: --",
|
"AIS: --": "AIS: --",
|
||||||
@@ -3960,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": "نقل اللوحات فوق الشريط إلى الحافة"
|
||||||
}
|
}
|
||||||
@@ -416,6 +416,8 @@
|
|||||||
"AIS: {name}": "AIS: {name}",
|
"AIS: {name}": "AIS: {name}",
|
||||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
||||||
"VESSEL": "SCHIFF",
|
"VESSEL": "SCHIFF",
|
||||||
|
"AID TO NAVIGATION": "SEEZEICHEN",
|
||||||
|
"VIRTUAL": "VIRTUELL",
|
||||||
"POS: LIVE": "POS: LIVE",
|
"POS: LIVE": "POS: LIVE",
|
||||||
"POS: {time}Z": "POS: {time}Z",
|
"POS: {time}Z": "POS: {time}Z",
|
||||||
"awaiting first AIS position…": "warten auf erste AIS-Position…",
|
"awaiting first AIS position…": "warten auf erste AIS-Position…",
|
||||||
@@ -3612,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"
|
||||||
}
|
}
|
||||||
@@ -650,6 +650,8 @@
|
|||||||
"TANKER": "PETROLERO",
|
"TANKER": "PETROLERO",
|
||||||
"OTHER": "OTRO",
|
"OTHER": "OTRO",
|
||||||
"VESSEL": "BUQUE",
|
"VESSEL": "BUQUE",
|
||||||
|
"AID TO NAVIGATION": "AYUDA A LA NAVEGACIÓN",
|
||||||
|
"VIRTUAL": "VIRTUAL",
|
||||||
"POS: LIVE": "POS: EN VIVO",
|
"POS: LIVE": "POS: EN VIVO",
|
||||||
"POS: {time}Z": "POS: {time}Z",
|
"POS: {time}Z": "POS: {time}Z",
|
||||||
"AIS: --": "AIS: --",
|
"AIS: --": "AIS: --",
|
||||||
@@ -3699,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"
|
||||||
}
|
}
|
||||||
@@ -1291,6 +1291,8 @@
|
|||||||
"AIS: {name}": "AIS : {name}",
|
"AIS: {name}": "AIS : {name}",
|
||||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD : {speed} HDG : {heading}",
|
"{type} SPD: {speed} HDG: {heading}": "{type} SPD : {speed} HDG : {heading}",
|
||||||
"VESSEL": "NAVIRE",
|
"VESSEL": "NAVIRE",
|
||||||
|
"AID TO NAVIGATION": "AIDE À LA NAVIGATION",
|
||||||
|
"VIRTUAL": "VIRTUEL",
|
||||||
"{speed}KT": "{speed} ND",
|
"{speed}KT": "{speed} ND",
|
||||||
"{heading}DEG": "{heading}°",
|
"{heading}DEG": "{heading}°",
|
||||||
"POS: LIVE": "POS : DIRECT",
|
"POS: LIVE": "POS : DIRECT",
|
||||||
@@ -3699,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"
|
||||||
}
|
}
|
||||||
@@ -1149,6 +1149,8 @@
|
|||||||
"AIS: {name}": "AIS: {name}",
|
"AIS: {name}": "AIS: {name}",
|
||||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
||||||
"VESSEL": "אונייה",
|
"VESSEL": "אונייה",
|
||||||
|
"AID TO NAVIGATION": "עזר ניווט",
|
||||||
|
"VIRTUAL": "וירטואלי",
|
||||||
"{speed}KT": "{speed} קשר",
|
"{speed}KT": "{speed} קשר",
|
||||||
"{heading}DEG": "{heading}°",
|
"{heading}DEG": "{heading}°",
|
||||||
"POS: LIVE": "POS: חי",
|
"POS: LIVE": "POS: חי",
|
||||||
@@ -3699,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": "הזזת הלוחות מעל הסרגל, אל הקצה"
|
||||||
}
|
}
|
||||||
@@ -1291,6 +1291,8 @@
|
|||||||
"AIS: {name}": "AIS: {name}",
|
"AIS: {name}": "AIS: {name}",
|
||||||
"{type} SPD: {speed} HDG: {heading}": "{type} VEL: {speed} HDG: {heading}",
|
"{type} SPD: {speed} HDG: {heading}": "{type} VEL: {speed} HDG: {heading}",
|
||||||
"VESSEL": "NAVE",
|
"VESSEL": "NAVE",
|
||||||
|
"AID TO NAVIGATION": "AIUTO ALLA NAVIGAZIONE",
|
||||||
|
"VIRTUAL": "VIRTUALE",
|
||||||
"{speed}KT": "{speed}KT",
|
"{speed}KT": "{speed}KT",
|
||||||
"{heading}DEG": "{heading}°",
|
"{heading}DEG": "{heading}°",
|
||||||
"POS: LIVE": "POS: LIVE",
|
"POS: LIVE": "POS: LIVE",
|
||||||
@@ -3699,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"
|
||||||
}
|
}
|
||||||
@@ -1253,6 +1253,8 @@
|
|||||||
"AIS: {name}": "AIS: {name}",
|
"AIS: {name}": "AIS: {name}",
|
||||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
||||||
"VESSEL": "船舶",
|
"VESSEL": "船舶",
|
||||||
|
"AID TO NAVIGATION": "航路標識",
|
||||||
|
"VIRTUAL": "仮想",
|
||||||
"{speed}KT": "{speed} kt",
|
"{speed}KT": "{speed} kt",
|
||||||
"{heading}DEG": "{heading}°",
|
"{heading}DEG": "{heading}°",
|
||||||
"POS: LIVE": "POS: ライブ",
|
"POS: LIVE": "POS: ライブ",
|
||||||
@@ -3525,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": "パネルをレールの上、画面端へ移動"
|
||||||
}
|
}
|
||||||
@@ -1289,6 +1289,8 @@
|
|||||||
"AIS: {name}": "AIS: {name}",
|
"AIS: {name}": "AIS: {name}",
|
||||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
||||||
"VESSEL": "EMBARCAÇÃO",
|
"VESSEL": "EMBARCAÇÃO",
|
||||||
|
"AID TO NAVIGATION": "AUXÍLIO À NAVEGAÇÃO",
|
||||||
|
"VIRTUAL": "VIRTUAL",
|
||||||
"{speed}KT": "{speed} KT",
|
"{speed}KT": "{speed} KT",
|
||||||
"{heading}DEG": "{heading}°",
|
"{heading}DEG": "{heading}°",
|
||||||
"POS: LIVE": "POS: AO VIVO",
|
"POS: LIVE": "POS: AO VIVO",
|
||||||
@@ -3699,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"
|
||||||
}
|
}
|
||||||
@@ -394,6 +394,8 @@
|
|||||||
"AIS: {name}": "AIS: {name}",
|
"AIS: {name}": "AIS: {name}",
|
||||||
"{type} SPD: {speed} HDG: {heading}": "{type} СКОР: {speed} КУРС: {heading}",
|
"{type} SPD: {speed} HDG: {heading}": "{type} СКОР: {speed} КУРС: {heading}",
|
||||||
"VESSEL": "СУДНО",
|
"VESSEL": "СУДНО",
|
||||||
|
"AID TO NAVIGATION": "СРЕДСТВО НАВИГАЦИИ",
|
||||||
|
"VIRTUAL": "ВИРТУАЛЬНЫЙ",
|
||||||
"AIS: --": "AIS: --",
|
"AIS: --": "AIS: --",
|
||||||
"awaiting first AIS position…": "ожидание первой позиции AIS…",
|
"awaiting first AIS position…": "ожидание первой позиции AIS…",
|
||||||
"awaiting usable AIS positions…": "ожидание пригодных позиций AIS…",
|
"awaiting usable AIS positions…": "ожидание пригодных позиций AIS…",
|
||||||
@@ -3786,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": "Переместить панели поверх боковой панели к краю"
|
||||||
}
|
}
|
||||||
@@ -382,6 +382,8 @@
|
|||||||
"AIS: {name}": "AIS: {name}",
|
"AIS: {name}": "AIS: {name}",
|
||||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
||||||
"VESSEL": "FARTYG",
|
"VESSEL": "FARTYG",
|
||||||
|
"AID TO NAVIGATION": "SJÖMÄRKE",
|
||||||
|
"VIRTUAL": "VIRTUELL",
|
||||||
"POS: LIVE": "POS: LIVE",
|
"POS: LIVE": "POS: LIVE",
|
||||||
"POS: {time}Z": "POS: {time}Z",
|
"POS: {time}Z": "POS: {time}Z",
|
||||||
"awaiting first AIS position…": "väntar på första AIS-positionen …",
|
"awaiting first AIS position…": "väntar på första AIS-positionen …",
|
||||||
@@ -3612,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"
|
||||||
}
|
}
|
||||||
@@ -1309,6 +1309,8 @@
|
|||||||
"AIS: {name}": "AIS: {name}",
|
"AIS: {name}": "AIS: {name}",
|
||||||
"{type} SPD: {speed} HDG: {heading}": "{type} ШВИД: {speed} КУРС: {heading}",
|
"{type} SPD: {speed} HDG: {heading}": "{type} ШВИД: {speed} КУРС: {heading}",
|
||||||
"VESSEL": "СУДНО",
|
"VESSEL": "СУДНО",
|
||||||
|
"AID TO NAVIGATION": "ЗАСІБ НАВІГАЦІЇ",
|
||||||
|
"VIRTUAL": "ВІРТУАЛЬНИЙ",
|
||||||
"{speed}KT": "{speed} ВУЗ",
|
"{speed}KT": "{speed} ВУЗ",
|
||||||
"{heading}DEG": "{heading}°",
|
"{heading}DEG": "{heading}°",
|
||||||
"POS: LIVE": "ПОЗ.: ОНЛАЙН",
|
"POS: LIVE": "ПОЗ.: ОНЛАЙН",
|
||||||
@@ -3786,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": "Перемістити панелі поверх бічної смуги до краю"
|
||||||
}
|
}
|
||||||
@@ -1229,6 +1229,8 @@
|
|||||||
"AIS: {name}": "AIS: {name}",
|
"AIS: {name}": "AIS: {name}",
|
||||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
||||||
"VESSEL": "船舶",
|
"VESSEL": "船舶",
|
||||||
|
"AID TO NAVIGATION": "助航标志",
|
||||||
|
"VIRTUAL": "虚拟",
|
||||||
"{speed}KT": "{speed} kn",
|
"{speed}KT": "{speed} kn",
|
||||||
"{heading}DEG": "{heading}°",
|
"{heading}DEG": "{heading}°",
|
||||||
"POS: LIVE": "POS:实时",
|
"POS: LIVE": "POS:实时",
|
||||||
@@ -3525,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": "将面板移到侧栏上方,贴靠边缘"
|
||||||
}
|
}
|
||||||
@@ -1224,6 +1224,8 @@
|
|||||||
"AIS: {name}": "AIS: {name}",
|
"AIS: {name}": "AIS: {name}",
|
||||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
||||||
"VESSEL": "船舶",
|
"VESSEL": "船舶",
|
||||||
|
"AID TO NAVIGATION": "助航標誌",
|
||||||
|
"VIRTUAL": "虛擬",
|
||||||
"{speed}KT": "{speed} kn",
|
"{speed}KT": "{speed} kn",
|
||||||
"{heading}DEG": "{heading}°",
|
"{heading}DEG": "{heading}°",
|
||||||
"POS: LIVE": "POS:實時",
|
"POS: LIVE": "POS:實時",
|
||||||
@@ -3525,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": "將面板移到側欄上方,貼齊邊緣"
|
||||||
}
|
}
|
||||||
@@ -291,6 +291,7 @@
|
|||||||
"FOV {value}°",
|
"FOV {value}°",
|
||||||
"FT",
|
"FT",
|
||||||
"Folk",
|
"Folk",
|
||||||
|
"Funicular",
|
||||||
"Funk",
|
"Funk",
|
||||||
"GEO",
|
"GEO",
|
||||||
"GOOGLE STREET VIEW",
|
"GOOGLE STREET VIEW",
|
||||||
@@ -411,7 +412,8 @@
|
|||||||
"MMSI: {id}",
|
"MMSI: {id}",
|
||||||
"NORAD: {id}",
|
"NORAD: {id}",
|
||||||
"Regional",
|
"Regional",
|
||||||
"{datasets} · {tiles} · {packs}"
|
"{datasets} · {tiles} · {packs}",
|
||||||
|
"VIRTUAL"
|
||||||
],
|
],
|
||||||
"fr": [
|
"fr": [
|
||||||
"1 min/s",
|
"1 min/s",
|
||||||
@@ -500,6 +502,7 @@
|
|||||||
"MINI",
|
"MINI",
|
||||||
"MISSION",
|
"MISSION",
|
||||||
"MM",
|
"MM",
|
||||||
|
"Monorail",
|
||||||
"MSG/S",
|
"MSG/S",
|
||||||
"Metal",
|
"Metal",
|
||||||
"Minimal",
|
"Minimal",
|
||||||
@@ -570,6 +573,7 @@
|
|||||||
"TAGS · {tags}",
|
"TAGS · {tags}",
|
||||||
"TEMP",
|
"TEMP",
|
||||||
"TLE",
|
"TLE",
|
||||||
|
"Trolleybus",
|
||||||
"TYPE",
|
"TYPE",
|
||||||
"Tallinn",
|
"Tallinn",
|
||||||
"Techno",
|
"Techno",
|
||||||
@@ -949,6 +953,7 @@
|
|||||||
"FOV {value}°",
|
"FOV {value}°",
|
||||||
"FT",
|
"FT",
|
||||||
"Folk",
|
"Folk",
|
||||||
|
"Funicular",
|
||||||
"Funk",
|
"Funk",
|
||||||
"GEO",
|
"GEO",
|
||||||
"GOOGLE STREET VIEW",
|
"GOOGLE STREET VIEW",
|
||||||
@@ -1067,7 +1072,8 @@
|
|||||||
"ICAO: {id}",
|
"ICAO: {id}",
|
||||||
"MMSI: {id}",
|
"MMSI: {id}",
|
||||||
"NORAD: {id}",
|
"NORAD: {id}",
|
||||||
"Regional"
|
"Regional",
|
||||||
|
"VIRTUAL"
|
||||||
],
|
],
|
||||||
"ru": [
|
"ru": [
|
||||||
"1900–1945",
|
"1900–1945",
|
||||||
|
|||||||
@@ -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 = [];
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
if (atBoundary || state._moving.has(entry))
|
||||||
governorRequestRender('rail-motion-boundary');
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
if (atBoundary || state._moving.has(entry))
|
||||||
governorRequestRender('transit-motion-boundary');
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+45
-10
@@ -1,8 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
|
AID_TO_NAVIGATION_STYLE,
|
||||||
accentForVesselType,
|
accentForVesselType,
|
||||||
displayVesselType,
|
displayVesselType,
|
||||||
normalizeVesselType,
|
normalizeVesselType,
|
||||||
} from '../../data/vesselLabels.js';
|
} from '../../data/vesselLabels.js';
|
||||||
|
import { isAidToNavigation } from '../../data/vesselTypes.js';
|
||||||
import { formatNumber, t } from '../../i18n/index.js';
|
import { formatNumber, t } from '../../i18n/index.js';
|
||||||
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
|
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
|
||||||
|
|
||||||
@@ -16,10 +18,17 @@ export function createCards({
|
|||||||
function updateSelectedVesselHud(record) {
|
function updateSelectedVesselHud(record) {
|
||||||
// Pinned vessels missing from recent refreshes get a stale marker
|
// Pinned vessels missing from recent refreshes get a stale marker
|
||||||
const stale = (record.missedRefreshes || 0) > 0;
|
const stale = (record.missedRefreshes || 0) > 0;
|
||||||
|
// An aid to navigation is a fixed mark: it has no speed or heading to show.
|
||||||
|
const aid = isAidToNavigation(record);
|
||||||
services.readouts?.presentSelectedVessel({
|
services.readouts?.presentSelectedVessel({
|
||||||
text: [
|
text: [
|
||||||
t('AIS: {name}', { name: trimHudValue(record.name, 32) }),
|
t('AIS: {name}', { name: trimHudValue(record.name, 32) }),
|
||||||
t('{type} SPD: {speed} HDG: {heading}', {
|
aid
|
||||||
|
? aidKind(
|
||||||
|
trimHudValue(record.type || t('AID TO NAVIGATION'), 24),
|
||||||
|
record,
|
||||||
|
)
|
||||||
|
: t('{type} SPD: {speed} HDG: {heading}', {
|
||||||
type: trimHudValue(record.type || t('VESSEL'), 24),
|
type: trimHudValue(record.type || t('VESSEL'), 24),
|
||||||
speed: formatSpeed(record.speed),
|
speed: formatSpeed(record.speed),
|
||||||
heading: formatHeading(record.heading ?? record.course),
|
heading: formatHeading(record.heading ?? record.course),
|
||||||
@@ -43,20 +52,22 @@ export function createCards({
|
|||||||
/**
|
/**
|
||||||
* Card model for an ambient (decluttered-in) vessel — name title plus one
|
* Card model for an ambient (decluttered-in) vessel — name title plus one
|
||||||
* compact type/speed/heading detail line, anchored at the record's current
|
* compact type/speed/heading detail line, anchored at the record's current
|
||||||
* rendered position (height-datum caveat: no datum work here). Pure —
|
* rendered position (height-datum caveat: no datum work here). An aid to
|
||||||
* exported for unit tests.
|
* navigation's line is its kind alone. Pure — exported for unit tests.
|
||||||
* @param {Object} record - Vessel record.
|
* @param {Object} record - Vessel record.
|
||||||
* @returns {Object} vesselLabels entry.
|
* @returns {Object} vesselLabels entry.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function buildVesselCard(record) {
|
function buildVesselCard(record) {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
|
const aid = isAidToNavigation(record);
|
||||||
const type = vesselTypeShort(record);
|
const type = vesselTypeShort(record);
|
||||||
if (type) parts.push(type);
|
if (type) parts.push(type);
|
||||||
if (record.speed !== null && record.speed !== undefined)
|
if (record.virtual === true) parts.push(t('VIRTUAL'));
|
||||||
|
if (!aid && record.speed !== null && record.speed !== undefined)
|
||||||
parts.push(formatSpeed(record.speed));
|
parts.push(formatSpeed(record.speed));
|
||||||
const direction = record.heading ?? record.course;
|
const direction = record.heading ?? record.course;
|
||||||
if (Number.isFinite(direction))
|
if (!aid && Number.isFinite(direction))
|
||||||
parts.push(`${roundHeadingDegrees(direction)}°`);
|
parts.push(`${roundHeadingDegrees(direction)}°`);
|
||||||
return {
|
return {
|
||||||
id: vesselOverlayEntryId(record),
|
id: vesselOverlayEntryId(record),
|
||||||
@@ -65,7 +76,7 @@ export function createCards({
|
|||||||
components.rendering.getVisual(record).billboard?.position ||
|
components.rendering.getVisual(record).billboard?.position ||
|
||||||
components.rendering.getVisual(record).position,
|
components.rendering.getVisual(record).position,
|
||||||
gapPx: 10,
|
gapPx: 10,
|
||||||
accent: accentForVesselType(record.type),
|
accent: vesselAccent(record, aid),
|
||||||
title: trimHudValue(displayVesselName(record), 26),
|
title: trimHudValue(displayVesselName(record), 26),
|
||||||
details: parts.length ? [parts.join(' · ')] : [],
|
details: parts.length ? [parts.join(' · ')] : [],
|
||||||
selected: false,
|
selected: false,
|
||||||
@@ -76,16 +87,20 @@ export function createCards({
|
|||||||
/**
|
/**
|
||||||
* Card model for the click-selected vessel — the full-detail card, drawn last
|
* Card model for the click-selected vessel — the full-detail card, drawn last
|
||||||
* (on top) and never distance-faded by the overlay. Pinned-but-vanished
|
* (on top) and never distance-faded by the overlay. Pinned-but-vanished
|
||||||
* vessels carry a STALE marker (mirrors the HUD readout). Pure — exported
|
* vessels carry a STALE marker (mirrors the HUD readout). An aid to
|
||||||
* for unit tests.
|
* navigation shows its kind with no speed or heading. Pure — exported for
|
||||||
|
* unit tests.
|
||||||
* @param {Object} record - Selected vessel record.
|
* @param {Object} record - Selected vessel record.
|
||||||
* @returns {Object} vesselLabels entry.
|
* @returns {Object} vesselLabels entry.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function buildSelectedVesselCard(record) {
|
function buildSelectedVesselCard(record) {
|
||||||
|
const aid = isAidToNavigation(record);
|
||||||
const direction = record.heading ?? record.course;
|
const direction = record.heading ?? record.course;
|
||||||
const details = [
|
const details = [
|
||||||
[
|
aid
|
||||||
|
? aidKind(vesselTypeShort(record) || t('AID TO NAVIGATION'), record)
|
||||||
|
: [
|
||||||
vesselTypeShort(record) || t('VESSEL'),
|
vesselTypeShort(record) || t('VESSEL'),
|
||||||
formatSpeed(record.speed),
|
formatSpeed(record.speed),
|
||||||
Number.isFinite(direction)
|
Number.isFinite(direction)
|
||||||
@@ -106,7 +121,7 @@ export function createCards({
|
|||||||
components.rendering.getVisual(record).billboard?.position ||
|
components.rendering.getVisual(record).billboard?.position ||
|
||||||
components.rendering.getVisual(record).position,
|
components.rendering.getVisual(record).position,
|
||||||
gapPx: 12,
|
gapPx: 12,
|
||||||
accent: accentForVesselType(record.type),
|
accent: vesselAccent(record, aid),
|
||||||
title: trimHudValue(displayVesselName(record), 32),
|
title: trimHudValue(displayVesselName(record), 32),
|
||||||
details,
|
details,
|
||||||
selected: true,
|
selected: true,
|
||||||
@@ -114,6 +129,20 @@ export function createCards({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** An aid's kind, marked virtual when nothing physical stands there. */
|
||||||
|
|
||||||
|
function aidKind(kind, record) {
|
||||||
|
return record.virtual === true ? `${kind} · ${t('VIRTUAL')}` : kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A card's accent: an aid's disc colour, else its ship type's. */
|
||||||
|
|
||||||
|
function vesselAccent(record, aid) {
|
||||||
|
return aid
|
||||||
|
? AID_TO_NAVIGATION_STYLE.accent
|
||||||
|
: accentForVesselType(record.type);
|
||||||
|
}
|
||||||
|
|
||||||
/** Stable overlay identity for keyed and source-retained unkeyed rows. */
|
/** Stable overlay identity for keyed and source-retained unkeyed rows. */
|
||||||
|
|
||||||
function vesselOverlayEntryId(record) {
|
function vesselOverlayEntryId(record) {
|
||||||
@@ -129,9 +158,15 @@ export function createCards({
|
|||||||
* Uppercased AIS type for the card (empty string when unknown). Free feed
|
* Uppercased AIS type for the card (empty string when unknown). Free feed
|
||||||
* text is bounded to the card width; a translated type token is a short
|
* text is bounded to the card width; a translated type token is a short
|
||||||
* catalog word and shows whole, never cut mid-word ("AUXILIAR DE PUERTO").
|
* catalog word and shows whole, never cut mid-word ("AUXILIAR DE PUERTO").
|
||||||
|
* An aid's kind is the whole line, so it keeps the room speed and heading
|
||||||
|
* would take ("CARDINAL MARK N", not "CARDINAL MARK ").
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function vesselTypeShort(record) {
|
function vesselTypeShort(record) {
|
||||||
|
if (isAidToNavigation(record)) {
|
||||||
|
const kind = String(record.type || '').trim();
|
||||||
|
return kind ? trimHudValue(kind.toUpperCase(), 26) : '';
|
||||||
|
}
|
||||||
const feedType = normalizeVesselType(record.type);
|
const feedType = normalizeVesselType(record.type);
|
||||||
const shown = displayVesselType(record.type);
|
const shown = displayVesselType(record.type);
|
||||||
return shown === feedType
|
return shown === feedType
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -218,6 +218,7 @@ export function createIngestion({
|
|||||||
speed: record.speedMps == null ? null : record.speedMps / 0.514444,
|
speed: record.speedMps == null ? null : record.speedMps / 0.514444,
|
||||||
course: record.courseDeg,
|
course: record.courseDeg,
|
||||||
heading: record.headingDeg,
|
heading: record.headingDeg,
|
||||||
|
virtual: record.virtual === true,
|
||||||
last_position_epoch:
|
last_position_epoch:
|
||||||
record.observedAtMs == null ? null : record.observedAtMs / 1000,
|
record.observedAtMs == null ? null : record.observedAtMs / 1000,
|
||||||
last_position_UTC:
|
last_position_UTC:
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -515,3 +515,50 @@ test('AIS feed rows stay keyed by their MMSI', async () => {
|
|||||||
['235000001', '235000001'],
|
['235000001', '235000001'],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('an aid to navigation is drawn as a disc, a vessel as its chevron', async () => {
|
||||||
|
const aid = {
|
||||||
|
...observation('993672001'),
|
||||||
|
mmsi: '993672001',
|
||||||
|
type: 'Port Hand Mark',
|
||||||
|
speedMps: 0,
|
||||||
|
};
|
||||||
|
const ship = { ...observation('366999002'), type: 'Cargo' };
|
||||||
|
const { layer, primitives } = setup({
|
||||||
|
async getSnapshot() {
|
||||||
|
return snapshot([aid, ship]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await layer.update();
|
||||||
|
const svgOf = (id) => {
|
||||||
|
const primitive = [...primitives].find((p) => p.id?.id === id);
|
||||||
|
assert.ok(primitive, id);
|
||||||
|
return atob(primitive.image.split(',')[1]);
|
||||||
|
};
|
||||||
|
assert.match(svgOf('993672001'), /<circle/);
|
||||||
|
// The source's virtual flag reaches the layer's record.
|
||||||
|
const recordOf = (id) => [...primitives].find((p) => p.id?.id === id).id;
|
||||||
|
assert.equal(recordOf('993672001').virtual, false);
|
||||||
|
assert.equal(recordOf('366999002').virtual, false);
|
||||||
|
assert.doesNotMatch(svgOf('993672001'), /<path/);
|
||||||
|
assert.match(svgOf('366999002'), /<path/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a virtual aid to navigation keeps its disc and its virtual flag', async () => {
|
||||||
|
const aid = {
|
||||||
|
...observation('993672009'),
|
||||||
|
mmsi: '993672009',
|
||||||
|
type: 'Isolated danger',
|
||||||
|
virtual: true,
|
||||||
|
};
|
||||||
|
const { layer, primitives } = setup({
|
||||||
|
async getSnapshot() {
|
||||||
|
return snapshot([aid]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await layer.update();
|
||||||
|
const primitive = [...primitives].find((p) => p.id?.id === '993672009');
|
||||||
|
assert.ok(primitive);
|
||||||
|
assert.equal(primitive.id.virtual, true);
|
||||||
|
assert.match(atob(primitive.image.split(',')[1]), /<circle/);
|
||||||
|
});
|
||||||
@@ -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, []);
|
||||||
|
});
|
||||||
@@ -31,6 +31,8 @@ export function normalizeVessel(row) {
|
|||||||
speed: finiteNumber(row.speed),
|
speed: finiteNumber(row.speed),
|
||||||
course: finiteNumber(row.course),
|
course: finiteNumber(row.course),
|
||||||
heading: finiteNumber(row.heading),
|
heading: finiteNumber(row.heading),
|
||||||
|
// A virtual aid to navigation: a mark with nothing physical there.
|
||||||
|
virtual: row.virtual === true,
|
||||||
lastPositionUtc: String(row.last_position_UTC || ''),
|
lastPositionUtc: String(row.last_position_UTC || ''),
|
||||||
lastPositionEpoch: finiteNumber(row.last_position_epoch),
|
lastPositionEpoch: finiteNumber(row.last_position_epoch),
|
||||||
missedRefreshes: 0,
|
missedRefreshes: 0,
|
||||||
@@ -160,6 +162,7 @@ export class VesselRecords {
|
|||||||
record.speed = next.speed;
|
record.speed = next.speed;
|
||||||
record.course = next.course;
|
record.course = next.course;
|
||||||
record.heading = next.heading;
|
record.heading = next.heading;
|
||||||
|
record.virtual = next.virtual;
|
||||||
record.lastPositionUtc = next.lastPositionUtc;
|
record.lastPositionUtc = next.lastPositionUtc;
|
||||||
record.lastPositionEpoch = next.lastPositionEpoch;
|
record.lastPositionEpoch = next.lastPositionEpoch;
|
||||||
record.missedRefreshes = 0;
|
record.missedRefreshes = 0;
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
});
|
||||||
+129
-13
@@ -1,6 +1,7 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { t } from '../../i18n/index.js';
|
import { t } from '../../i18n/index.js';
|
||||||
import {
|
import {
|
||||||
|
AID_TO_NAVIGATION_STYLE,
|
||||||
vesselTypeCss,
|
vesselTypeCss,
|
||||||
vesselOverlayCohortLimit,
|
vesselOverlayCohortLimit,
|
||||||
VESSEL_OVERLAY_SOURCE_ID,
|
VESSEL_OVERLAY_SOURCE_ID,
|
||||||
@@ -28,9 +29,12 @@ import {
|
|||||||
vesselModelSpec,
|
vesselModelSpec,
|
||||||
} from '../../data/fleetModels.js';
|
} from '../../data/fleetModels.js';
|
||||||
import { createFleetModelLayer } from '../../data/fleetModelInstances.js';
|
import { createFleetModelLayer } from '../../data/fleetModelInstances.js';
|
||||||
|
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;
|
||||||
@@ -56,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,
|
||||||
@@ -76,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();
|
||||||
}
|
}
|
||||||
@@ -182,12 +229,14 @@ export function createRendering({
|
|||||||
* Best available real-world direction of travel for a vessel, degrees
|
* Best available real-world direction of travel for a vessel, degrees
|
||||||
* clockwise from north (true heading preferred, course-over-ground fallback).
|
* clockwise from north (true heading preferred, course-over-ground fallback).
|
||||||
* Screen rotation is computed from this by the shared projected-rotation
|
* Screen rotation is computed from this by the shared projected-rotation
|
||||||
* pass in updateVisibility — never directly from the compass value.
|
* pass in updateVisibility — never directly from the compass value. An aid
|
||||||
|
* to navigation is fixed, so it has none.
|
||||||
* @param {Object} record - Vessel record.
|
* @param {Object} record - Vessel record.
|
||||||
* @returns {number} Course in degrees (0 when unknown).
|
* @returns {number} Course in degrees (0 when unknown).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function vesselCourseDeg(record) {
|
function vesselCourseDeg(record) {
|
||||||
|
if (isAidToNavigation(record)) return 0;
|
||||||
const direction = record.heading ?? record.course;
|
const direction = record.heading ?? record.course;
|
||||||
return Number.isFinite(direction) ? direction : 0;
|
return Number.isFinite(direction) ? direction : 0;
|
||||||
}
|
}
|
||||||
@@ -195,6 +244,7 @@ export function createRendering({
|
|||||||
/**
|
/**
|
||||||
* Build (and cache) a chevron/delta-wing SVG data URL tinted for the vessel.
|
* Build (and cache) a chevron/delta-wing SVG data URL tinted for the vessel.
|
||||||
* The shape points north (up) so billboard rotation maps directly to heading.
|
* The shape points north (up) so billboard rotation maps directly to heading.
|
||||||
|
* An aid to navigation, which has no heading, is a ringed disc instead.
|
||||||
* One icon is generated per color+variant and reused across all billboards.
|
* One icon is generated per color+variant and reused across all billboards.
|
||||||
* @param {Object} record - Vessel record (drives per-type tint).
|
* @param {Object} record - Vessel record (drives per-type tint).
|
||||||
* @param {boolean} selected - True for the white/brighter selected variant.
|
* @param {boolean} selected - True for the white/brighter selected variant.
|
||||||
@@ -202,16 +252,25 @@ export function createRendering({
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
function shipIcon(record, selected) {
|
function shipIcon(record, selected) {
|
||||||
const cssColor = selected ? '#ffffff' : vesselTypeCss(record.type);
|
const aid = isAidToNavigation(record);
|
||||||
const key = `${cssColor}:${selected ? 'selected' : 'normal'}`;
|
const cssColor = selected
|
||||||
|
? '#ffffff'
|
||||||
|
: aid
|
||||||
|
? AID_TO_NAVIGATION_STYLE.css
|
||||||
|
: vesselTypeCss(record.type);
|
||||||
|
const key = `${cssColor}:${selected ? 'selected' : 'normal'}:${aid ? 'aid' : 'ship'}`;
|
||||||
if (vesselState.shipIconCache.has(key))
|
if (vesselState.shipIconCache.has(key))
|
||||||
return vesselState.shipIconCache.get(key);
|
return vesselState.shipIconCache.get(key);
|
||||||
|
|
||||||
const stroke = selected ? 'rgba(6,26,32,0.95)' : 'rgba(4,18,24,0.9)';
|
const stroke = selected ? 'rgba(6,26,32,0.95)' : 'rgba(4,18,24,0.9)';
|
||||||
const strokeWidth = selected ? 1.1 : 0.7;
|
const strokeWidth = selected ? 1.1 : 0.7;
|
||||||
|
const shape = aid
|
||||||
|
? `<circle r="8" fill="${cssColor}" stroke="${stroke}" stroke-width="${strokeWidth * 2}"/>
|
||||||
|
<circle r="3" fill="${stroke}"/>`
|
||||||
|
: `<path d="M0,-14 L11,10 L4,7 L0,14 L-4,7 L-11,10 Z" fill="${cssColor}" stroke="${stroke}" stroke-width="${strokeWidth}" stroke-linejoin="round"/>`;
|
||||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||||
<g transform="translate(16,16)">
|
<g transform="translate(16,16)">
|
||||||
<path d="M0,-14 L11,10 L4,7 L0,14 L-4,7 L-11,10 Z" fill="${cssColor}" stroke="${stroke}" stroke-width="${strokeWidth}" stroke-linejoin="round"/>
|
${shape}
|
||||||
</g>
|
</g>
|
||||||
</svg>`;
|
</svg>`;
|
||||||
const icon = 'data:image/svg+xml;base64,' + btoa(svg);
|
const icon = 'data:image/svg+xml;base64,' + btoa(svg);
|
||||||
@@ -260,18 +319,24 @@ export function createRendering({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The model item for `record`, its spec rebuilt only when its type or size changes. */
|
/**
|
||||||
|
* The model item for `record`, its spec rebuilt only when its type or size
|
||||||
|
* changes; null for a virtual aid to navigation, which keeps its disc: no
|
||||||
|
* buoy stands there (vesselModelSpec).
|
||||||
|
*/
|
||||||
function modelItem(record, set) {
|
function modelItem(record, set) {
|
||||||
|
if (record.virtual === true) return null;
|
||||||
let item = modelItems.get(record.id);
|
let item = modelItems.get(record.id);
|
||||||
if (!item || item.record !== record)
|
if (!item || item.record !== record)
|
||||||
item = { record, pickId: record, tint: null, modelOwnsVisual: false };
|
item = { record, pickId: record, tint: null, modelOwnsVisual: false };
|
||||||
const specKey = `${record.type}|${record.length}|${record.beam}|${record.name}`;
|
const specKey = `${record.type}|${record.mmsi}|${record.length}|${record.beam}|${record.name}`;
|
||||||
if (item.specKey !== specKey) {
|
if (item.specKey !== specKey) {
|
||||||
item.specKey = specKey;
|
item.specKey = specKey;
|
||||||
item.spec = vesselModelSpec(set, record);
|
item.spec = vesselModelSpec(set, record);
|
||||||
}
|
}
|
||||||
item.position = getVisual(record).waterline;
|
item.position = getVisual(record).waterline;
|
||||||
item.headingDeg = vesselCourseDeg(record);
|
// An aid to navigation is a fixed mark: it does not turn to a heading.
|
||||||
|
item.headingDeg = item.spec?.fixed ? 0 : vesselCourseDeg(record);
|
||||||
item.show = record.id !== state.cockpitSubjectId;
|
item.show = record.id !== state.cockpitSubjectId;
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
@@ -294,7 +359,8 @@ export function createRendering({
|
|||||||
limitSq
|
limitSq
|
||||||
)
|
)
|
||||||
continue;
|
continue;
|
||||||
into.set(record.id, modelItem(record, set));
|
const item = modelItem(record, set);
|
||||||
|
if (item) into.set(record.id, item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,8 +396,10 @@ export function createRendering({
|
|||||||
gatherModelItems(set, next);
|
gatherModelItems(set, next);
|
||||||
} else {
|
} else {
|
||||||
for (const [id, item] of modelItems)
|
for (const [id, item] of modelItems)
|
||||||
if (state.records.byId.get(id) === item.record)
|
if (state.records.byId.get(id) === item.record) {
|
||||||
next.set(id, modelItem(item.record, set));
|
const kept = modelItem(item.record, set);
|
||||||
|
if (kept) next.set(id, kept);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for (const [id, item] of modelItems)
|
for (const [id, item] of modelItems)
|
||||||
if (next.get(id) !== item) handBackVisual(item.record, occluder);
|
if (next.get(id) !== item) handBackVisual(item.record, occluder);
|
||||||
@@ -368,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();
|
||||||
});
|
});
|
||||||
@@ -390,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(
|
||||||
@@ -465,7 +578,8 @@ export function createRendering({
|
|||||||
visible &&
|
visible &&
|
||||||
record.id !== state.cockpitSubjectId &&
|
record.id !== state.cockpitSubjectId &&
|
||||||
!visual.modelOwnsVisual;
|
!visual.modelOwnsVisual;
|
||||||
if (visible && doRotations && scene) {
|
// An aid's disc is never turned (vesselCourseDeg).
|
||||||
|
if (visible && doRotations && scene && !isAidToNavigation(record)) {
|
||||||
const rot = screenProjectedRotation(
|
const rot = screenProjectedRotation(
|
||||||
scene,
|
scene,
|
||||||
visual.position,
|
visual.position,
|
||||||
@@ -763,6 +877,8 @@ export function createRendering({
|
|||||||
installRuntime,
|
installRuntime,
|
||||||
updateVisibility,
|
updateVisibility,
|
||||||
cancelTrailingVisibilityPass,
|
cancelTrailingVisibilityPass,
|
||||||
|
cancelPlacementFrame,
|
||||||
|
followPlacements,
|
||||||
applyVesselFocusDeemphasis,
|
applyVesselFocusDeemphasis,
|
||||||
makeOccluder,
|
makeOccluder,
|
||||||
isVisible,
|
isVisible,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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 ||
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,7 +12,9 @@
|
|||||||
* snapshots. A vessel record's `id` is whatever its source gives, such as a
|
* snapshots. A vessel record's `id` is whatever its source gives, such as a
|
||||||
* provider id; its `mmsi` is an optional attribute ('' or absent when
|
* provider id; its `mmsi` is an optional attribute ('' or absent when
|
||||||
* unknown), never its key, since vessels can share an MMSI. A source that
|
* unknown), never its key, since vessels can share an MMSI. A source that
|
||||||
* only has MMSIs uses the MMSI as the id.
|
* only has MMSIs uses the MMSI as the id. A vessel record's `virtual` is true
|
||||||
|
* for a virtual aid to navigation, a mark broadcast where nothing physical
|
||||||
|
* stands (AIS message 21's virtual flag), else false; any source may set it.
|
||||||
*
|
*
|
||||||
* A source may also declare a timeline (see readSnapshotTimeline): it can
|
* A source may also declare a timeline (see readSnapshotTimeline): it can
|
||||||
* answer a snapshot at a given time, so the layer follows the world clock.
|
* answer a snapshot at a given time, so the layer follows the world clock.
|
||||||
|
|||||||
@@ -221,6 +221,18 @@ test('vessel identity, opaque reference and heading/course remain distinct, unkn
|
|||||||
assert.equal(row.altitudeDatum, 'sea-surface');
|
assert.equal(row.altitudeDatum, 'sea-surface');
|
||||||
assert.equal(row.observedAtMs, now);
|
assert.equal(row.observedAtMs, now);
|
||||||
assert.equal('extra' in row, false);
|
assert.equal('extra' in row, false);
|
||||||
|
// Only a source's own true marks a virtual aid to navigation.
|
||||||
|
assert.equal(row.virtual, false);
|
||||||
|
const base = { mmsi: '993672001', lat: 30, lon: -97 };
|
||||||
|
assert.equal(
|
||||||
|
normalizeVesselObservation({ ...base, virtual: true }).virtual,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
for (const virtual of ['true', 1, false, null])
|
||||||
|
assert.equal(
|
||||||
|
normalizeVesselObservation({ ...base, virtual }).virtual,
|
||||||
|
false,
|
||||||
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
normalizeVesselObservation({ mmsi: 'x', lat: null, lon: null }),
|
normalizeVesselObservation({ mmsi: 'x', lat: null, lon: null }),
|
||||||
null,
|
null,
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ export function normalizeVesselObservation(row, reference = null, id = null) {
|
|||||||
epoch(row.last_position_epoch, 1000) ??
|
epoch(row.last_position_epoch, 1000) ??
|
||||||
epoch(Date.parse(row.last_position_UTC)),
|
epoch(Date.parse(row.last_position_UTC)),
|
||||||
altitudeDatum: 'sea-surface',
|
altitudeDatum: 'sea-surface',
|
||||||
|
// A virtual aid to navigation: a mark broadcast with nothing there.
|
||||||
|
virtual: row.virtual === true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loaded 100 of 131 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user