mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* feat(energy-atlas): live tanker map layer + contract (PR 3, plan U7-U8)
Lands the third and final parity-push surface — per-vessel tanker positions
inside chokepoint bounding boxes, refreshed every 60s. Closes the visual
gap with peer reference energy-intel sites for the live AIS tanker view.
Per docs/plans/2026-04-25-003-feat-energy-parity-pushup-plan.md PR 3.
Codex-approved through 8 review rounds against origin/main @ 050073354.
U7 — Contract changes (relay + handler + proto + gateway + rate-limit + test):
- scripts/ais-relay.cjs: parallel `tankerReports` Map populated for AIS
ship type 80-89 (tanker class) per ITU-R M.1371. SEPARATE from the
existing `candidateReports` Map (military-only) so the existing
military-detection consumer's contract stays unchanged. Snapshot
endpoint extended to accept `bbox=swLat,swLon,neLat,neLon` + `tankers=true`
query params, with bbox-filtering applied server-side. Tanker reports
cleaned up on the same retention window as candidate reports; capped
at 200 per response (10× headroom for global storage).
- proto/worldmonitor/maritime/v1/{get_,}vessel_snapshot.proto:
- new `bool include_tankers = 6` request field
- new `repeated SnapshotCandidateReport tanker_reports = 7` response
field (reuses existing message shape; parallel to candidate_reports)
- server/worldmonitor/maritime/v1/get-vessel-snapshot.ts: REPLACES the
prior 5-minute `with|without` cache with a request-keyed cache —
(includeCandidates, includeTankers, quantizedBbox) — at 60s TTL for
the live-tanker path and 5min TTL for the existing density/disruption
consumers. Also adds 1° bbox quantization for cache-key reuse and a
10° max-bbox guard (BboxTooLargeError) to prevent malicious clients
from pulling all tankers through one query.
- server/gateway.ts: NEW `'live'` cache tier. CacheTier union extended;
TIER_HEADERS + TIER_CDN_CACHE both gain entries with `s-maxage=60,
stale-while-revalidate=60`. RPC_CACHE_TIER maps the maritime endpoint
from `'no-store'` to `'live'` so the CDN absorbs concurrent identical
requests across all viewers (without this, N viewers × 6 chokepoints
hit AISStream upstream linearly).
- server/_shared/rate-limit.ts: ENDPOINT_RATE_POLICIES entry for the
maritime endpoint at 60 req/min/IP — enough headroom for one user's
6-chokepoint tab plus refreshes; flags only true scrape-class traffic.
- tests/route-cache-tier.test.mjs: regex extended to include `live` so
the every-route-has-an-explicit-tier check still recognises the new
mapping. Without this, the new tier would silently drop the maritime
route from the validator's route map.
U8 — LiveTankersLayer consumer:
- src/services/live-tankers.ts: per-chokepoint fetcher with 60s in-memory
cache. Promise.allSettled — never .all — so one chokepoint failing
doesn't blank the whole layer (failed zones serve last-known data).
Sources bbox centroids from src/config/chokepoint-registry.ts
(CORRECT location — server/.../_chokepoint-ids.ts strips lat/lon).
Default chokepoint set: hormuz_strait, suez, bab_el_mandeb,
malacca_strait, panama, bosphorus.
- src/components/DeckGLMap.ts: new `createLiveTankersLayer()` ScatterplotLayer
styled by speed (anchored amber when speed < 0.5 kn, underway cyan,
unknown gray); new `loadLiveTankers()` async loader with abort-controller
cancellation. Layer instantiated when `mapLayers.liveTankers && this.liveTankers.length > 0`.
- src/config/map-layer-definitions.ts: `LayerDefinition` for `liveTankers`
with `renderers: ['flat'], deckGLOnly: true` (matches existing
storageFacilities/fuelShortages pattern). Added to `VARIANT_LAYER_ORDER.energy`
near `ais` so getLayersForVariant() and sanitizeLayersForVariant()
include it on the energy variant — without this addition the layer
would be silently stripped even when toggled on.
- src/types/index.ts: `liveTankers?: boolean` on the MapLayers union.
- src/config/panels.ts: ENERGY_MAP_LAYERS + ENERGY_MOBILE_MAP_LAYERS
both gain `liveTankers: true`. Default `false` everywhere else.
- src/services/maritime/index.ts: existing snapshot consumer pinned to
`includeTankers: false` to satisfy the proto's new required field;
preserves identical behavior for the AIS-density / military-detection
surfaces.
Tests:
- npm run typecheck clean.
- 5 unit tests in tests/live-tankers-service.test.mjs cover the default
chokepoint set (rejects ids that aren't in CHOKEPOINT_REGISTRY), the
60s cache TTL pin (must match gateway 'live' tier s-maxage), and bbox
derivation (±2° padding, total span under the 10° handler guard).
- tests/route-cache-tier.test.mjs continues to pass after the regex
extension; the new maritime tier is correctly extracted.
Defense in depth:
- THREE-layer cache (CDN 'live' tier → handler bbox-keyed 60s → service
in-memory 60s) means concurrent users hit the relay sub-linearly.
- Server-side 200-vessel cap on tanker_reports + client-side cap;
protects layer render perf even on a runaway relay payload.
- Bbox-size guard (10° max) prevents a single global-bbox query from
exfiltrating every tanker.
- Per-IP rate limit at 60/min covers normal use; flags scrape-class only.
- Existing military-detection contract preserved: `candidate_reports`
field semantics unchanged; consumers self-select via include_tankers
vs include_candidates rather than the response field changing meaning.
* fix(energy-atlas): wire LiveTankers loop + 400 bbox-range guard (PR3 review)
Three findings from review of #3402:
P1 — loadLiveTankers() was never called (DeckGLMap.ts:2999):
- Add ensureLiveTankersLoop() / stopLiveTankersLoop() helpers paired with
the layer-enabled / layer-disabled branches in updateLayers(). The
ensure helper kicks an immediate load + a 60s setInterval; idempotent
so calling it on every layers update is safe.
- Wire stopLiveTankersLoop() into destroy() and into the layer-disabled
branch so we don't hammer the relay when the layer is off.
- Layer factory now runs only when liveTankers.length > 0; ensureLoop
fires on every observed-enabled tick so first-paint kicks the load
even before the first tanker arrives.
P1 — bbox lat/lon range guard (get-vessel-snapshot.ts:253):
- Out-of-range bboxes (e.g. ne_lat=200) previously passed the size
guard (200-195=5° < 10°) but failed at the relay, which silently
drops the bbox param and returns a global capped subset — making
the layer appear to "work" with stale phantom data.
- Add isValidLatLon() check inside extractAndValidateBbox(): every
corner must satisfy [-90, 90] / [-180, 180] before the size guard
runs. Failure throws BboxValidationError.
P2 — BboxTooLargeError surfaced as 500 instead of 400:
- server/error-mapper.ts maps errors to HTTP status by checking
`'statusCode' in error`. The previous BboxTooLargeError extended
Error without that property, so the mapper fell through to
"unhandled error" → 500.
- Rename to BboxValidationError, add `readonly statusCode = 400`.
Mapper now surfaces it as HTTP 400 with a descriptive reason.
- Keep BboxTooLargeError as a backwards-compat alias so existing
imports / tests don't break.
Tests:
- Updated tests/server-handlers.test.mjs structural test to pin the
new class name + statusCode + lat/lon range checks. 24 tests pass.
- typecheck (src + api) clean.
* fix(energy-atlas): thread AbortSignal through fetchLiveTankers (PR3 review #2)
P2 — AbortController was created + aborted but signal was never passed
into the actual fetch path (DeckGLMap.ts:3048 / live-tankers.ts:100):
- Toggling the layer off, destroying the map, or starting a new refresh
did not actually cancel in-flight network work. A slow older refresh
could complete after a newer one and overwrite this.liveTankers with
stale data.
Threading:
- fetchLiveTankers() now accepts `options.signal: AbortSignal`. Signal
is passed through to client.getVesselSnapshot() per chokepoint via
the Connect-RPC client's standard `{ signal }` option.
- Per-zone abort handling: bail early if signal is already aborted
before the fetch starts (saves a wasted RPC + cache write); re-check
after the fetch resolves so a slow resolver can't clobber cache
after the caller cancelled.
Stale-result race guard in DeckGLMap.loadLiveTankers:
- Capture controller in a local before storing on this.liveTankersAbort.
- After fetchLiveTankers resolves, drop the result if EITHER:
- controller.signal is now aborted (newer load cancelled this one)
- this.liveTankersAbort points to a different controller (a newer
load already started + replaced us in the field)
- Without these guards, an older fetch that completed despite
signal.aborted could still write to this.liveTankers and call
updateLayers, racing with the newer load.
Tests: 1 new signature-pin test in tests/live-tankers-service.test.mts
verifies fetchLiveTankers accepts options.signal — guards against future
edits silently dropping the parameter and re-introducing the race.
6 tests pass. typecheck clean.
* fix(energy-atlas): bound vessel-snapshot cache via LRU eviction (PR3 review)
Greptile P2 finding: the in-process cache Map grows unbounded across the
serverless instance lifetime. Each distinct (includeCandidates,
includeTankers, quantizedBbox) triple creates a slot that's never evicted.
With 1° quantization and a misbehaving client the keyspace is ~64,000
entries — realistic load is ~12, so a 128-slot cap leaves 10x headroom
while making OOM impossible.
Implementation:
- SNAPSHOT_CACHE_MAX_SLOTS = 128.
- evictIfNeeded() walks insertion order and evicts the first slot whose
inFlight is null. Slots with active fetches are skipped to avoid
orphaning awaiting callers; we accept brief over-cap growth until
in-flight settles.
- touchSlot() re-inserts a slot at the end of Map insertion order on
hit / in-flight join / fresh write so it counts as most-recently-used.
1 line
12 KiB
JSON
1 line
12 KiB
JSON
{"components":{"schemas":{"AisDensityZone":{"description":"AisDensityZone represents a zone of concentrated vessel traffic.","properties":{"deltaPct":{"description":"Change from baseline as a percentage.","format":"double","type":"number"},"id":{"description":"Zone identifier.","minLength":1,"type":"string"},"intensity":{"description":"Traffic intensity score (0-100).","format":"double","maximum":100,"minimum":0,"type":"number"},"location":{"$ref":"#/components/schemas/GeoCoordinates"},"name":{"description":"Zone name (e.g., \"Strait of Malacca\").","type":"string"},"note":{"description":"Analyst note.","type":"string"},"shipsPerDay":{"description":"Estimated ships per day.","format":"int32","type":"integer"}},"required":["id"],"type":"object"},"AisDisruption":{"description":"AisDisruption represents a detected anomaly in AIS vessel tracking data.","properties":{"changePct":{"description":"Percentage change from normal.","format":"double","type":"number"},"darkShips":{"description":"Number of dark ships (AIS off) detected.","format":"int32","type":"integer"},"description":{"description":"Human-readable description.","type":"string"},"id":{"description":"Disruption identifier.","minLength":1,"type":"string"},"location":{"$ref":"#/components/schemas/GeoCoordinates"},"name":{"description":"Descriptive name.","type":"string"},"region":{"description":"Region name.","type":"string"},"severity":{"description":"AisDisruptionSeverity represents the severity of an AIS disruption.","enum":["AIS_DISRUPTION_SEVERITY_UNSPECIFIED","AIS_DISRUPTION_SEVERITY_LOW","AIS_DISRUPTION_SEVERITY_ELEVATED","AIS_DISRUPTION_SEVERITY_HIGH"],"type":"string"},"type":{"description":"AisDisruptionType represents the type of AIS tracking anomaly.\n Maps to TS union: 'gap_spike' | 'chokepoint_congestion'.","enum":["AIS_DISRUPTION_TYPE_UNSPECIFIED","AIS_DISRUPTION_TYPE_GAP_SPIKE","AIS_DISRUPTION_TYPE_CHOKEPOINT_CONGESTION"],"type":"string"},"vesselCount":{"description":"Number of vessels in the affected area.","format":"int32","type":"integer"},"windowHours":{"description":"Analysis window in hours.","format":"int32","type":"integer"}},"required":["id"],"type":"object"},"AisSnapshotStatus":{"description":"AisSnapshotStatus reports relay health at the time of the snapshot.","properties":{"connected":{"description":"Whether the relay WebSocket is connected to the AIS provider.","type":"boolean"},"messages":{"description":"Total AIS messages processed in the current session.","format":"int32","type":"integer"},"vessels":{"description":"Number of vessels currently tracked by the relay.","format":"int32","type":"integer"}},"type":"object"},"Error":{"description":"Error is returned when a handler encounters an error. It contains a simple error message that the developer can customize.","properties":{"message":{"description":"Error message (e.g., 'user not found', 'database connection failed')","type":"string"}},"type":"object"},"FieldViolation":{"description":"FieldViolation describes a single validation error for a specific field.","properties":{"description":{"description":"Human-readable description of the validation violation (e.g., 'must be a valid email address', 'required field missing')","type":"string"},"field":{"description":"The field path that failed validation (e.g., 'user.email' for nested fields). For header validation, this will be the header name (e.g., 'X-API-Key')","type":"string"}},"required":["field","description"],"type":"object"},"GeoCoordinates":{"description":"GeoCoordinates represents a geographic location using WGS84 coordinates.","properties":{"latitude":{"description":"Latitude in decimal degrees (-90 to 90).","format":"double","maximum":90,"minimum":-90,"type":"number"},"longitude":{"description":"Longitude in decimal degrees (-180 to 180).","format":"double","maximum":180,"minimum":-180,"type":"number"}},"type":"object"},"GetVesselSnapshotRequest":{"description":"GetVesselSnapshotRequest specifies filters for the vessel snapshot.","properties":{"includeCandidates":{"description":"When true, populate VesselSnapshot.candidate_reports with per-vessel\n position reports. Clients with no position callbacks should leave this\n false to keep responses small.","type":"boolean"},"includeTankers":{"description":"When true, populate VesselSnapshot.tanker_reports with per-vessel\n position reports for AIS ship-type 80-89 (tanker class). Used by the\n Energy Atlas live-tanker map layer. Stored separately from\n candidate_reports (which is military-only) so consumers self-select\n via this flag rather than the response field changing meaning.","type":"boolean"},"neLat":{"description":"North-east corner latitude of bounding box.","format":"double","type":"number"},"neLon":{"description":"North-east corner longitude of bounding box.","format":"double","type":"number"},"swLat":{"description":"South-west corner latitude of bounding box.","format":"double","type":"number"},"swLon":{"description":"South-west corner longitude of bounding box.","format":"double","type":"number"}},"type":"object"},"GetVesselSnapshotResponse":{"description":"GetVesselSnapshotResponse contains the vessel traffic snapshot.","properties":{"snapshot":{"$ref":"#/components/schemas/VesselSnapshot"}},"type":"object"},"ListNavigationalWarningsRequest":{"description":"ListNavigationalWarningsRequest specifies filters for retrieving NGA warnings.","properties":{"area":{"description":"Optional area filter (e.g., \"NAVAREA IV\", \"Persian Gulf\").","type":"string"},"cursor":{"description":"Cursor for next page.","type":"string"},"pageSize":{"description":"Maximum items per page (1-100).","format":"int32","type":"integer"}},"type":"object"},"ListNavigationalWarningsResponse":{"description":"ListNavigationalWarningsResponse contains navigational warnings matching the request.","properties":{"pagination":{"$ref":"#/components/schemas/PaginationResponse"},"warnings":{"items":{"$ref":"#/components/schemas/NavigationalWarning"},"type":"array"}},"type":"object"},"NavigationalWarning":{"description":"NavigationalWarning represents a maritime safety warning from NGA.","properties":{"area":{"description":"Geographic area affected.","type":"string"},"authority":{"description":"Warning source authority.","type":"string"},"expiresAt":{"description":"Warning expiry date, as Unix epoch milliseconds.. Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"id":{"description":"Warning identifier.","type":"string"},"issuedAt":{"description":"Warning issue date, as Unix epoch milliseconds.. Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"location":{"$ref":"#/components/schemas/GeoCoordinates"},"text":{"description":"Full warning text.","type":"string"},"title":{"description":"Warning title.","type":"string"}},"type":"object"},"PaginationResponse":{"description":"PaginationResponse contains pagination metadata returned alongside list results.","properties":{"nextCursor":{"description":"Cursor for fetching the next page. Empty string indicates no more pages.","type":"string"},"totalCount":{"description":"Total count of items matching the query, if known. Zero if the total is unknown.","format":"int32","type":"integer"}},"type":"object"},"SnapshotCandidateReport":{"description":"SnapshotCandidateReport is a per-vessel position report attached to a\n snapshot. Used to drive the client-side position callback system.","properties":{"course":{"description":"Course over ground in degrees.","format":"int32","type":"integer"},"heading":{"description":"Heading in degrees (0-359, or 511 for unavailable).","format":"int32","type":"integer"},"lat":{"description":"Latitude in decimal degrees.","format":"double","type":"number"},"lon":{"description":"Longitude in decimal degrees.","format":"double","type":"number"},"mmsi":{"description":"Maritime Mobile Service Identity.","type":"string"},"name":{"description":"Vessel name (may be empty if unknown).","type":"string"},"shipType":{"description":"AIS ship type code (0 if unknown).","format":"int32","type":"integer"},"speed":{"description":"Speed over ground in knots.","format":"double","type":"number"},"timestamp":{"description":"Report timestamp, as Unix epoch milliseconds.. Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"}},"type":"object"},"ValidationError":{"description":"ValidationError is returned when request validation fails. It contains a list of field violations describing what went wrong.","properties":{"violations":{"description":"List of validation violations","items":{"$ref":"#/components/schemas/FieldViolation"},"type":"array"}},"required":["violations"],"type":"object"},"VesselSnapshot":{"description":"VesselSnapshot represents a point-in-time view of civilian AIS vessel data.","properties":{"candidateReports":{"items":{"$ref":"#/components/schemas/SnapshotCandidateReport"},"type":"array"},"densityZones":{"items":{"$ref":"#/components/schemas/AisDensityZone"},"type":"array"},"disruptions":{"items":{"$ref":"#/components/schemas/AisDisruption"},"type":"array"},"sequence":{"description":"Monotonic sequence number from the relay. Clients use this to detect stale\n responses during polling.","format":"int32","type":"integer"},"snapshotAt":{"description":"Snapshot timestamp, as Unix epoch milliseconds.. Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"status":{"$ref":"#/components/schemas/AisSnapshotStatus"},"tankerReports":{"items":{"$ref":"#/components/schemas/SnapshotCandidateReport"},"type":"array"}},"type":"object"}}},"info":{"title":"MaritimeService API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/maritime/v1/get-vessel-snapshot":{"get":{"description":"GetVesselSnapshot retrieves a point-in-time view of AIS vessel traffic and disruptions.","operationId":"GetVesselSnapshot","parameters":[{"description":"North-east corner latitude of bounding box.","in":"query","name":"ne_lat","required":false,"schema":{"format":"double","type":"number"}},{"description":"North-east corner longitude of bounding box.","in":"query","name":"ne_lon","required":false,"schema":{"format":"double","type":"number"}},{"description":"South-west corner latitude of bounding box.","in":"query","name":"sw_lat","required":false,"schema":{"format":"double","type":"number"}},{"description":"South-west corner longitude of bounding box.","in":"query","name":"sw_lon","required":false,"schema":{"format":"double","type":"number"}},{"description":"When true, populate VesselSnapshot.candidate_reports with per-vessel\n position reports. Clients with no position callbacks should leave this\n false to keep responses small.","in":"query","name":"include_candidates","required":false,"schema":{"type":"boolean"}},{"description":"When true, populate VesselSnapshot.tanker_reports with per-vessel\n position reports for AIS ship-type 80-89 (tanker class). Used by the\n Energy Atlas live-tanker map layer. Stored separately from\n candidate_reports (which is military-only) so consumers self-select\n via this flag rather than the response field changing meaning.","in":"query","name":"include_tankers","required":false,"schema":{"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetVesselSnapshotResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetVesselSnapshot","tags":["MaritimeService"]}},"/api/maritime/v1/list-navigational-warnings":{"get":{"description":"ListNavigationalWarnings retrieves active maritime safety warnings from NGA.","operationId":"ListNavigationalWarnings","parameters":[{"description":"Maximum items per page (1-100).","in":"query","name":"page_size","required":false,"schema":{"format":"int32","type":"integer"}},{"description":"Cursor for next page.","in":"query","name":"cursor","required":false,"schema":{"type":"string"}},{"description":"Optional area filter (e.g., \"NAVAREA IV\", \"Persian Gulf\").","in":"query","name":"area","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListNavigationalWarningsResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"ListNavigationalWarnings","tags":["MaritimeService"]}}}} |