diff --git a/.env.example b/.env.example index 04149eb9..d954c544 100644 --- a/.env.example +++ b/.env.example @@ -94,6 +94,17 @@ WAZUH_PASSWORD= # ── OPTIONAL: MAPS ─────────────────────────────────────────────────────────── # Google Maps is proxied through Manus — no API key required. # The VITE_FRONTEND_FORGE_API_KEY handles map authentication automatically. +# VITE_GOOGLE_MAPS_MAP_ID — Google mapId (defaults to DEMO_MAP_ID when unset). +# +# Phase 17 — bundled MapLibre/Cesium geospatial portal (/app/geo/portal): +# VITE_MAP_STYLE_URL=https://tiles.openfreemap.org/styles/liberty # 2D vector style (or your same-origin tile proxy) +# VITE_CESIUM_TOKEN= # Cesium Ion token; UNSET = Ion-free OSM imagery + ellipsoid terrain (no Ion requests) +# VITE_FLUVIO_WS_URL= # AIS/declaration WS feed; unset = wss:///ws; "off" = disabled +# +# Production CSP (fail-closed by default). Whitelist ONLY the tile origins in use: +# CSP_CONNECT_SRC_EXTRA=https://tiles.openfreemap.org,https://tile.openstreetmap.org +# CSP_SCRIPT_SRC_EXTRA= # only if a maps script must load from another origin +# CSP_IMG_SRC_EXTRA= # raster tile origins if img-src is ever tightened # ── NODE ENVIRONMENT ───────────────────────────────────────────────────────── NODE_ENV=production diff --git a/client/public/sw.js b/client/public/sw.js index 0c0fa01f..e0abd182 100644 --- a/client/public/sw.js +++ b/client/public/sw.js @@ -23,7 +23,7 @@ self.addEventListener('install', (event) => { self.addEventListener('activate', (event) => { event.waitUntil( caches.keys().then((keys) => - Promise.all(keys.filter((k) => k !== CACHE_NAME && k !== OFFLINE_QUEUE_NAME).map((k) => caches.delete(k))) + Promise.all(keys.filter((k) => ![CACHE_NAME, OFFLINE_QUEUE_NAME, TILE_CACHE_NAME].includes(k)).map((k) => caches.delete(k))) ).then(() => self.clients.claim()) ); }); @@ -36,10 +36,53 @@ self.addEventListener('message', (event) => { }); // ─── FETCH ─────────────────────────────────────────────────────────────────── +// Phase 17 (G10): CacheFirst map-tile caching (pattern from hydrogenTransport +// PWA). Only well-known open tile origins are cached — no arbitrary origins. +const TILE_CACHE_NAME = 'tradegateway-tiles-v1'; +const TILE_ORIGINS = [ + 'https://tile.openstreetmap.org', + 'https://tiles.openfreemap.org', + 'https://basemaps.cartocdn.com', +]; +const TILE_CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days + self.addEventListener('fetch', (event) => { const { request } = event; const url = new URL(request.url); + // ── Map tiles/styles/glyphs: cache-first with freshness cap ────────────── + if (request.method === 'GET' && TILE_ORIGINS.some((o) => url.origin === o)) { + event.respondWith( + caches.open(TILE_CACHE_NAME).then(async (cache) => { + const cached = await cache.match(request); + if (cached) { + const fetchedAt = Number(cached.headers.get('X-SW-Cached-At') || 0); + if (Date.now() - fetchedAt < TILE_CACHE_MAX_AGE_MS) return cached; + } + try { + const response = await fetch(request); + if (response.ok) { + const headers = new Headers(response.headers); + headers.set('X-SW-Cached-At', String(Date.now())); + const stamped = new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); + cache.put(request, stamped.clone()); + return stamped; + } + // Network answered but failed: fall back to stale tile if present + return cached || response; + } catch { + // Offline: serve the cached tile or an honest 503 — never a fake tile + return cached || new Response('tile unavailable offline', { status: 503 }); + } + }) + ); + return; + } + // Skip cross-origin requests if (url.origin !== self.location.origin) return; diff --git a/client/src/App.tsx b/client/src/App.tsx index afe237b9..58c2f4bd 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -28,6 +28,8 @@ import AdminKYCReview from "./pages/app/AdminKYCReview"; import PortHeatmap from "./pages/app/PortHeatmap"; // Lazy-load the specification page (it's large)) import { lazy, Suspense } from "react"; +// Phase 17 (G2): Geospatial Portal — lazy, routed (previously dead code) +const GeospatialPortal = lazy(() => import("./pages/geo/GeospatialPortal")); const Specification = lazy(() => import("./pages/Specification")); // Lazy-load heavy pages const SanctionsScreening = lazy(() => import("./pages/app/SanctionsScreening")); @@ -279,6 +281,9 @@ function Router() { {/* Geospatial */} + + }> + }> diff --git a/client/src/components/Map.test.tsx b/client/src/components/Map.test.tsx new file mode 100644 index 00000000..b6e2003a --- /dev/null +++ b/client/src/components/Map.test.tsx @@ -0,0 +1,25 @@ +// @vitest-environment jsdom +/** + * Phase 17 (G8) — MapView smoke test: without a configured Google Maps key + * the component must fail fast into the honest "Map unavailable" fallback + * instead of hanging on a spinner (and must never attempt a script injection). + */ +import { describe, it, expect } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { MapView } from "./Map"; + +describe("MapView", () => { + it("renders the honest fallback when no maps API key is configured", async () => { + render(); + await waitFor(() => { + expect(screen.getByText("Map unavailable")).toBeTruthy(); + }); + // Fail-closed: no runtime CDN script was injected into the document + expect(document.querySelector("script[src*='maps/api/js']")).toBeNull(); + }); + + it("exposes an accessible map region while loading", () => { + const { container } = render(); + expect(container.querySelector('[role="region"][aria-label="Interactive map"]')).toBeTruthy(); + }); +}); diff --git a/client/src/components/Map.tsx b/client/src/components/Map.tsx index d3c2465c..265f2af5 100644 --- a/client/src/components/Map.tsx +++ b/client/src/components/Map.tsx @@ -37,7 +37,7 @@ * - Standalone service; manually apply results to map. * const geocoder = new google.maps.Geocoder(); * geocoder.geocode({ address: "New York" }, (results, status) => { - * if (status === "OK" && results[0]) { + * if (status === "OK" && results[0].geometry) { * map.setCenter(results[0].geometry.location); * new google.maps.marker.AdvancedMarkerElement({ * map, @@ -101,7 +101,10 @@ function loadMapScript(): Promise { return; } const script = document.createElement("script"); - script.src = `${MAPS_PROXY_URL}/maps/api/js?key=${API_KEY}&v=weekly&libraries=marker,places,geocoding,geometry`; + // visualization is required by the heatmap layers in PortHeatmap / + // CargoTrackingMap — it was missing, so google.maps.visualization was + // undefined at runtime. + script.src = `${MAPS_PROXY_URL}/maps/api/js?key=${API_KEY}&v=weekly&libraries=marker,places,geocoding,geometry,visualization`; script.async = true; script.crossOrigin = "anonymous"; script.onload = () => { @@ -152,7 +155,8 @@ export function MapView({ fullscreenControl: true, zoomControl: true, streetViewControl: true, - mapId: "DEMO_MAP_ID", + // G9: mapId is operator-configurable; DEMO_MAP_ID only as a dev default. + mapId: import.meta.env.VITE_GOOGLE_MAPS_MAP_ID || "DEMO_MAP_ID", }); if (onMapReady) { onMapReady(map.current); @@ -173,6 +177,12 @@ export function MapView({ } return ( -
+ // G12: expose the map region to assistive technology. +
); } diff --git a/client/src/hooks/useFluvioFeed.test.ts b/client/src/hooks/useFluvioFeed.test.ts new file mode 100644 index 00000000..f11f8ff4 --- /dev/null +++ b/client/src/hooks/useFluvioFeed.test.ts @@ -0,0 +1,33 @@ +/** + * Phase 17 (G4/G8) — WS URL resolution tests for useFluvioFeed. + */ +import { describe, it, expect } from "vitest"; +import { resolveFluvioWsUrl } from "./useFluvioFeed"; + +const httpsLoc = { protocol: "https:", host: "trade.gov.ng" }; +const httpLoc = { protocol: "http:", host: "localhost:5173" }; + +describe("resolveFluvioWsUrl", () => { + it("honours an explicit env URL", () => { + expect(resolveFluvioWsUrl("wss://fluvio.internal/ws", httpsLoc)).toBe("wss://fluvio.internal/ws"); + }); + + it('"off" disables the feed (honest disabled state)', () => { + expect(resolveFluvioWsUrl("off", httpsLoc)).toBeNull(); + expect(resolveFluvioWsUrl("OFF", httpsLoc)).toBeNull(); + }); + + it("defaults to same-origin wss on https (never ws://localhost)", () => { + expect(resolveFluvioWsUrl(undefined, httpsLoc)).toBe("wss://trade.gov.ng/ws"); + }); + + it("uses ws: only on http dev origins", () => { + expect(resolveFluvioWsUrl("", httpLoc)).toBe("ws://localhost:5173/ws"); + }); + + it("never returns a hardcoded localhost URL in production", () => { + const url = resolveFluvioWsUrl(undefined, httpsLoc); + expect(url).not.toContain("localhost"); + expect(url!.startsWith("wss://")).toBe(true); + }); +}); diff --git a/client/src/hooks/useFluvioFeed.ts b/client/src/hooks/useFluvioFeed.ts index 71cf50cc..d1dc6263 100644 --- a/client/src/hooks/useFluvioFeed.ts +++ b/client/src/hooks/useFluvioFeed.ts @@ -42,11 +42,31 @@ export interface FluvioEvent { payload: VesselPosition | Record; } -export type FeedStatus = "connecting" | "connected" | "paused" | "reconnecting" | "error"; +export type FeedStatus = "connecting" | "connected" | "paused" | "reconnecting" | "error" | "disabled"; // ── Constants ───────────────────────────────────────────────────────────────── -const FLUVIO_WS_URL = "ws://localhost:8085/ws"; +/** + * Phase 17 (G4): the Fluvio WS endpoint is env-driven, never hardcoded to + * localhost. Resolution order: + * 1. VITE_FLUVIO_WS_URL (full ws:// or wss:// URL; "off" disables the feed) + * 2. same-origin default: wss:///ws (ws: on http: dev origins) + * When disabled, the hook reports status "disabled" and never opens a socket. + */ +export function resolveFluvioWsUrl( + envValue: string | undefined, + locationLike?: { protocol: string; host: string }, +): string | null { + const raw = (envValue ?? "").trim(); + if (/^off$/i.test(raw)) return null; + if (/^wss?:\/\/.+/.test(raw)) return raw; + const loc = locationLike ?? (typeof window !== "undefined" ? window.location : undefined); + if (!loc) return null; + const scheme = loc.protocol === "https:" ? "wss:" : "ws:"; + return `${scheme}//${loc.host}/ws`; +} + +const FLUVIO_WS_URL = resolveFluvioWsUrl(import.meta.env.VITE_FLUVIO_WS_URL); const MAX_EVENTS = 500; // ring buffer size const RECONNECT_DELAY_MS = 3000; // 3 s between reconnect attempts const MAX_RECONNECT_ATTEMPTS = 10; @@ -65,7 +85,7 @@ export function useFluvioFeed(options?: { } = options ?? {}; const [events, setEvents] = useState([]); - const [status, setStatus] = useState("connecting"); + const [status, setStatus] = useState(FLUVIO_WS_URL ? "connecting" : "disabled"); const [lastUpdated, setLastUpdated] = useState(null); const [reconnectCount, setReconnectCount] = useState(0); @@ -77,6 +97,11 @@ export function useFluvioFeed(options?: { const connect = useCallback(() => { if (!mountedRef.current) return; if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) return; + if (!FLUVIO_WS_URL) { + // Honest disabled state: feed not configured for this deployment. + setStatus("disabled"); + return; + } setStatus("connecting"); @@ -168,6 +193,7 @@ export function useFluvioFeed(options?: { events, vesselPositions, status, + feedUrl: FLUVIO_WS_URL, lastUpdated, reconnectCount, pause, diff --git a/client/src/lib/geo.test.ts b/client/src/lib/geo.test.ts new file mode 100644 index 00000000..bb5b090d --- /dev/null +++ b/client/src/lib/geo.test.ts @@ -0,0 +1,116 @@ +/** + * Phase 17 (G8) — unit tests for the shared geospatial helpers that back the + * map components (GeospatialPortal 2D/3D, AIS layer, track replay, heatmap). + */ +import { describe, it, expect } from "vitest"; +import { + toMapVessel, + vesselsToGeoJSON, + trackToLineString, + isFeedStale, + lowDataRasterStyle, + resolveMapStyleUrl, + resolveCesiumIonToken, + VesselTrackingRow, +} from "./geo"; + +const row: VesselTrackingRow = { + id: 1, + mmsi: "657123456", + vesselName: "MT LAGOS STAR", + imoNumber: "9074729", + latitude: 6.42, + longitude: 3.31, + speed: 12.5, + heading: 270, + destinationPort: "NGAPP", + eta: new Date("2026-01-01T00:00:00Z"), + cargoType: "container", + flagCountry: "NGA", + recordedAt: new Date("2025-12-31T12:00:00Z"), +}; + +describe("toMapVessel", () => { + it("maps a DB row to a GeoVessel with ISO eta", () => { + const v = toMapVessel(row); + expect(v).not.toBeNull(); + expect(v!.mmsi).toBe("657123456"); + expect(v!.lat).toBeCloseTo(6.42); + expect(v!.eta).toBe("2026-01-01T00:00:00.000Z"); + }); + + it("drops rows with out-of-range coordinates (fail-closed)", () => { + expect(toMapVessel({ ...row, latitude: 91 })).toBeNull(); + expect(toMapVessel({ ...row, longitude: -181 })).toBeNull(); + expect(toMapVessel({ ...row, latitude: NaN })).toBeNull(); + }); +}); + +describe("vesselsToGeoJSON", () => { + it("builds a FeatureCollection keyed [lng, lat]", () => { + const fc = vesselsToGeoJSON([toMapVessel(row)!]); + expect(fc.type).toBe("FeatureCollection"); + expect(fc.features[0].geometry.coordinates).toEqual([3.31, 6.42]); + expect(fc.features[0].properties.mmsi).toBe("657123456"); + }); +}); + +describe("trackToLineString", () => { + it("returns a LineString for 2+ valid points", () => { + const line = trackToLineString([ + { latitude: 6.0, longitude: 3.0 }, + { latitude: 6.4, longitude: 3.3 }, + { latitude: NaN, longitude: 3.4 }, // dropped + { latitude: 6.45, longitude: 3.39 }, + ]); + expect(line).not.toBeNull(); + expect(line!.geometry.coordinates).toHaveLength(3); + }); + + it("returns null for fewer than 2 valid points (honest no-track)", () => { + expect(trackToLineString([{ latitude: 6.0, longitude: 3.0 }])).toBeNull(); + expect(trackToLineString([])).toBeNull(); + }); +}); + +describe("isFeedStale", () => { + const now = Date.parse("2026-01-01T00:00:00Z"); + it("flags missing/old data as stale", () => { + expect(isFeedStale(null, now)).toBe(true); + expect(isFeedStale("2025-12-31T23:57:00Z", now)).toBe(true); + }); + it("accepts fresh data", () => { + expect(isFeedStale("2025-12-31T23:59:30Z", now)).toBe(false); + }); +}); + +describe("lowDataRasterStyle", () => { + it("is a self-contained raster style (no remote style JSON fetch)", () => { + const style = lowDataRasterStyle(); + expect(style.version).toBe(8); + expect(style.sources.osm.type).toBe("raster"); + expect(style.layers).toHaveLength(1); + }); +}); + +describe("resolveMapStyleUrl", () => { + it("uses the env URL when it is a valid http(s) URL", () => { + expect(resolveMapStyleUrl("https://tiles.internal/styles/liberty")).toBe( + "https://tiles.internal/styles/liberty" + ); + }); + it("falls back to OpenFreeMap for empty/garbage values", () => { + expect(resolveMapStyleUrl(undefined)).toContain("openfreemap"); + expect(resolveMapStyleUrl("javascript:alert(1)")).toContain("openfreemap"); + }); +}); + +describe("resolveCesiumIonToken", () => { + it("returns null when unset — never an empty-string token", () => { + expect(resolveCesiumIonToken(undefined)).toBeNull(); + expect(resolveCesiumIonToken(" ")).toBeNull(); + }); + it("returns a configured token", () => { + expect(resolveCesiumIonToken(" tok123 ")).toBe("tok123"); + }); +}); diff --git a/client/src/lib/geo.ts b/client/src/lib/geo.ts new file mode 100644 index 00000000..e7b39736 --- /dev/null +++ b/client/src/lib/geo.ts @@ -0,0 +1,126 @@ +/** + * Phase 17 — shared geospatial helpers (pure, unit-tested). + * + * Fail-closed doctrine: every helper returns explicit "unavailable" signals + * (null / empty / stale flags) instead of fabricating coordinates. Map style + * and tile origins are env-driven so operators can point at a same-origin + * tile proxy in sovereign deployments (see CSP_*_EXTRA server env vars). + */ + +export interface GeoVessel { + id: string; + name: string; + mmsi: string; + imo: string; + lat: number; + lng: number; + heading: number; + speed: number; + status: string; + cargo_type: string; + declaration_ref?: string; + eta?: string; + destination_port?: string; +} + +/** Row shape returned by trpc.geospatial.listVessels / getVesselTrack. */ +export interface VesselTrackingRow { + id: number; + mmsi: string; + vesselName: string | null; + imoNumber: string | null; + latitude: number; + longitude: number; + speed: number | null; + heading: number | null; + destinationPort: string | null; + eta: Date | string | null; + cargoType: string | null; + flagCountry: string | null; + recordedAt: Date | string; +} + +/** Convert a DB vessel row to a map vessel; invalid coordinates are dropped. */ +export function toMapVessel(row: VesselTrackingRow): GeoVessel | null { + if (!Number.isFinite(row.latitude) || !Number.isFinite(row.longitude)) return null; + if (Math.abs(row.latitude) > 90 || Math.abs(row.longitude) > 180) return null; + return { + id: String(row.id), + name: row.vesselName ?? row.mmsi, + mmsi: row.mmsi, + imo: row.imoNumber ?? "", + lat: row.latitude, + lng: row.longitude, + heading: row.heading ?? 0, + speed: row.speed ?? 0, + status: "underway", + cargo_type: row.cargoType ?? "unknown", + declaration_ref: row.destinationPort ?? undefined, + destination_port: row.destinationPort ?? undefined, + eta: row.eta ? new Date(row.eta).toISOString() : undefined, + }; +} + +export function vesselsToGeoJSON(vessels: GeoVessel[]) { + return { + type: "FeatureCollection" as const, + features: vessels.map(v => ({ + type: "Feature" as const, + geometry: { type: "Point" as const, coordinates: [v.lng, v.lat] }, + properties: { ...v }, + })), + }; +} + +/** Vessel track (oldest → newest) as a GeoJSON LineString for replay overlays. */ +export function trackToLineString( + rows: Array<{ latitude: number; longitude: number }>, +) { + const coords = rows + .filter(r => Number.isFinite(r.latitude) && Number.isFinite(r.longitude)) + .map(r => [r.longitude, r.latitude] as [number, number]); + if (coords.length < 2) return null; + return { + type: "Feature" as const, + geometry: { type: "LineString" as const, coordinates: coords }, + properties: {}, + }; +} + +/** Honest staleness: true when the last AIS update is older than maxAgeMs. */ +export function isFeedStale(lastUpdated: Date | string | null, now = Date.now(), maxAgeMs = 90_000): boolean { + if (!lastUpdated) return true; + const t = new Date(lastUpdated).getTime(); + if (!Number.isFinite(t)) return true; + return now - t > maxAgeMs; +} + +/** A raster-only MapLibre style for low-bandwidth / reduced-data mode. */ +export function lowDataRasterStyle(tileUrl = "https://tile.openstreetmap.org/{z}/{x}/{y}.png") { + return { + version: 8 as const, + name: "low-data-raster", + sources: { + osm: { + type: "raster" as const, + tiles: [tileUrl], + tileSize: 256, + attribution: "© OpenStreetMap contributors", + }, + }, + layers: [{ id: "osm", type: "raster" as const, source: "osm" }], + }; +} + +/** Resolve the 2D vector style URL; env first, OpenFreeMap liberty as default. */ +export function resolveMapStyleUrl(envValue: string | undefined): string { + const raw = (envValue ?? "").trim(); + if (/^https?:\/\/.+/.test(raw)) return raw; + return "https://tiles.openfreemap.org/styles/liberty"; +} + +/** Resolve the Cesium Ion token; empty means "Ion disabled" — never send empty-token requests. */ +export function resolveCesiumIonToken(envValue: string | undefined): string | null { + const raw = (envValue ?? "").trim(); + return raw.length > 0 ? raw : null; +} diff --git a/client/src/pages/app/CargoTrackingMap.tsx b/client/src/pages/app/CargoTrackingMap.tsx index 2c915fd2..c1d14a51 100644 --- a/client/src/pages/app/CargoTrackingMap.tsx +++ b/client/src/pages/app/CargoTrackingMap.tsx @@ -295,7 +295,11 @@ export default function CargoTrackingMap() { data: points, radius: 40, }); - heatmapLayerRef.current.setMap((window as any).__map__); + // Phase 17 (G6): attach to the real map instance from onMapReady — + // (window).__map__ was never set by Map.tsx, so this silently no-op'd. + if (mapRef.current) { + heatmapLayerRef.current.setMap(mapRef.current); + } } else if (!showHeatmap && heatmapLayerRef.current) { heatmapLayerRef.current.setMap(null); heatmapLayerRef.current = null; diff --git a/client/src/pages/app/PortHeatmap.tsx b/client/src/pages/app/PortHeatmap.tsx index 792959cf..27335ae2 100644 --- a/client/src/pages/app/PortHeatmap.tsx +++ b/client/src/pages/app/PortHeatmap.tsx @@ -312,7 +312,9 @@ export default function PortHeatmap() { const [selectedPort, setSelectedPort] = useState(null); const [mapReady, setMapReady] = useState(false); const mapRef = useRef(null); - const heatmapRef = useRef(null); + // @types/google.maps stubs HeatmapLayer (no ctor opts/setMap) — use a + // structural type matching the runtime API. + const heatmapRef = useRef<{ setMap(m: google.maps.Map | null): void } | null>(null); const markersRef = useRef([]); const infoWindowRef = useRef(null); @@ -407,7 +409,7 @@ export default function PortHeatmap() { weight: p.weight * 10, })); - heatmapRef.current = new google.maps.visualization.HeatmapLayer({ + heatmapRef.current = new (google.maps.visualization as any).HeatmapLayer({ data: heatmapPoints, map: mapRef.current, radius: 40, @@ -461,10 +463,15 @@ export default function PortHeatmap() { }); }, [heatmapData]); - // Trigger render when map and data are both ready - if (mapReady && heatmapData && markersRef.current.length === 0) { - renderHeatmap(); - } + // Phase 17 (G7): heatmap/marker rendering is a side effect — it must live in + // useEffect keyed on [mapReady, heatmapData], never in the render body, and + // it must re-run on every data refresh (the previous markersRef.length===0 + // guard left the heatmap stale until remount). + useEffect(() => { + if (mapReady && heatmapData) { + renderHeatmap(); + } + }, [mapReady, heatmapData, renderHeatmap]); const stats = heatmapData ? { clear: heatmapData.filter(p => p.congestionStatus === "clear").length, diff --git a/client/src/pages/geo/GeospatialPortal.tsx b/client/src/pages/geo/GeospatialPortal.tsx index ddddaa58..708558ab 100644 --- a/client/src/pages/geo/GeospatialPortal.tsx +++ b/client/src/pages/geo/GeospatialPortal.tsx @@ -1,76 +1,40 @@ /** * TradeGateway Geospatial Portal - * ================================ - * Implements item 51: MapLibre, GeoLibre, and CesiumJS full integration + * ============================== + * Phase 17 rewrite — unified 2D/3D map surface (innovation #1). * - * Features: - * - MapLibre GL JS: 2D cargo tracking, vessel positions, port overlays - * - GeoLibre: Open-source geospatial data layers (OpenFreeMap tiles) - * - CesiumJS: 3D port visualization, vessel approach paths, geofencing - * - Real-time vessel tracking via tRPC geospatial router - * - Geofence breach alerts - * - Apache Sedona spatial queries via backend + * - 2D: MapLibre GL JS bundled as a pinned npm dependency (no runtime CDN). + * - 3D: CesiumJS bundled via vite-plugin-cesium; Ion-free by default + * (OSM imagery + ellipsoid terrain). Cesium Ion world terrain is enabled + * ONLY when import.meta.env.VITE_CESIUM_TOKEN is set — an absent token + * renders an honest notice instead of firing empty-token Ion requests. + * - Live AIS vessel layer + track replay from trpc.geospatial.listVessels / + * getVesselTrack with honest stale indicators. + * - Declaration/congestion heatmap layer from trpc.geospatial.heatmapData. + * - Route/ETA overlay: selected vessel → destination port leg + ETA panel. + * - Low-bandwidth mode: raster-only tiles + reduced motion. + * + * Tiles/styles/glyphs are env-driven (VITE_MAP_STYLE_URL); production CSP + * origins are whitelisted via server CSP_CONNECT_SRC_EXTRA (see .env.example). */ -import React, { useEffect, useRef, useState, useCallback } from "react"; +import { useEffect, useRef, useState, useCallback } from "react"; +import "maplibre-gl/dist/maplibre-gl.css"; import { trpc } from "@/lib/trpc"; - -// Dynamic imports to avoid SSR issues with map libraries -let maplibregl: any = null; -let Cesium: any = null; +import { + GeoVessel, + VesselTrackingRow, + toMapVessel, + vesselsToGeoJSON, + trackToLineString, + isFeedStale, + lowDataRasterStyle, + resolveMapStyleUrl, + resolveCesiumIonToken, +} from "@/lib/geo"; // ─── Types ──────────────────────────────────────────────────────────────────── -interface Vessel { - id: string; - name: string; - mmsi: string; - imo: string; - lat: number; - lng: number; - heading: number; - speed: number; - status: string; - cargo_type: string; - declaration_ref?: string; - eta?: string; - destination_port?: string; -} - -type VesselTrackingRow = { - id: number; - mmsi: string; - vesselName: string | null; - imoNumber: string | null; - latitude: number; - longitude: number; - speed: number | null; - heading: number | null; - destinationPort: string | null; - eta: Date | null; - cargoType: string | null; - flagCountry: string | null; - recordedAt: Date; -}; - -function toMapVessel(row: VesselTrackingRow): Vessel { - return { - id: String(row.id), - name: row.vesselName ?? row.mmsi, - mmsi: row.mmsi, - imo: row.imoNumber ?? "", - lat: row.latitude, - lng: row.longitude, - heading: row.heading ?? 0, - speed: row.speed ?? 0, - status: "underway", - cargo_type: row.cargoType ?? "unknown", - declaration_ref: row.destinationPort ?? undefined, - destination_port: row.destinationPort ?? undefined, - eta: row.eta?.toISOString(), - }; -} - interface Port { id: string; name: string; @@ -81,19 +45,8 @@ interface Port { type: string; } -interface Geofence { - id: string; - name: string; - type: "circle" | "polygon"; - center?: [number, number]; - radius?: number; - coordinates?: [number, number][]; - alert_on_entry: boolean; - alert_on_exit: boolean; -} - type MapMode = "2d" | "3d"; -type MapLayer = "vessels" | "ports" | "geofences" | "cargo-routes" | "risk-zones"; +type MapLayer = "vessels" | "ports" | "geofences" | "congestion-heatmap"; // ─── Nigerian Ports ─────────────────────────────────────────────────────────── @@ -107,6 +60,9 @@ const NIGERIAN_PORTS: Port[] = [ { id: "kano-air", name: "Mallam Aminu Kano Airport", unlocode: "NGKAN", lat: 12.0476, lng: 8.5246, country: "NG", type: "airport" }, ]; +const CESIUM_ION_TOKEN = resolveCesiumIonToken(import.meta.env.VITE_CESIUM_TOKEN); +const MAP_STYLE_URL = resolveMapStyleUrl(import.meta.env.VITE_MAP_STYLE_URL); + // ─── Component ──────────────────────────────────────────────────────────────── export default function GeospatialPortal() { @@ -114,171 +70,276 @@ export default function GeospatialPortal() { const mapContainer3D = useRef(null); const map2DRef = useRef(null); const viewerRef = useRef(null); + const cesiumRef = useRef(null); const [mapMode, setMapMode] = useState("2d"); const [activeLayers, setActiveLayers] = useState>( new Set(["vessels", "ports", "geofences"]) ); - const [selectedVessel, setSelectedVessel] = useState(null); - const [alerts, setAlerts] = useState([]); + const [selectedVessel, setSelectedVessel] = useState(null); const [mapLoaded, setMapLoaded] = useState(false); const [cesiumLoaded, setCesiumLoaded] = useState(false); - - // tRPC data + const [mapError, setMapError] = useState(null); + const [cesiumError, setCesiumError] = useState(null); + // #8 low-bandwidth / accessibility mode + const [lowData, setLowData] = useState(false); + const reducedMotion = + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + + // tRPC data — real router data, 30 s polling const vessels = trpc.geospatial.listVessels.useQuery( { destinationPort: "NGAPP", limit: 200 }, { refetchInterval: 30_000 } ); - const geofences = trpc.geofences.list.useQuery({ status: "active" }); + const heatmap = trpc.geospatial.heatmapData.useQuery(undefined, { refetchInterval: 30_000 }); + const track = trpc.geospatial.getVesselTrack.useQuery( + { mmsi: selectedVessel?.mmsi ?? "", limit: 100 }, + { enabled: !!selectedVessel?.mmsi } + ); + + // #2 honest staleness: data older than 90 s is flagged, never presented as live + const feedStale = isFeedStale(vessels.dataUpdatedAt ? new Date(vessels.dataUpdatedAt) : null); + const mapVessels: GeoVessel[] = (vessels.data as VesselTrackingRow[] | undefined ?? []) + .map(toMapVessel) + .filter((v): v is GeoVessel => v !== null); - // ─── Load MapLibre GL JS ─────────────────────────────────────────────────── + // ─── Load MapLibre GL JS (bundled, pinned) ──────────────────────────────── useEffect(() => { if (typeof window === "undefined") return; + let cancelled = false; const loadMapLibre = async () => { - if (!maplibregl) { - // Load MapLibre from CDN (GeoLibre-compatible) - await loadScript("https://unpkg.com/maplibre-gl@4.1.3/dist/maplibre-gl.js"); - await loadCSS("https://unpkg.com/maplibre-gl@4.1.3/dist/maplibre-gl.css"); - maplibregl = (window as any).maplibregl; - } + try { + const maplibregl = (await import("maplibre-gl")).default; + if (cancelled || !mapContainer2D.current || map2DRef.current) return; + + map2DRef.current = new maplibregl.Map({ + container: mapContainer2D.current, + style: lowData ? (lowDataRasterStyle() as any) : MAP_STYLE_URL, + center: [3.3903, 6.4474], // Apapa Port, Lagos + zoom: 8, + pitch: 0, + bearing: 0, + attributionControl: {}, + }); - if (!mapContainer2D.current || map2DRef.current) return; - - // Initialize MapLibre with GeoLibre OpenFreeMap tiles - map2DRef.current = new maplibregl.Map({ - container: mapContainer2D.current, - // GeoLibre / OpenFreeMap style — open-source alternative to Mapbox - style: "https://tiles.openfreemap.org/styles/liberty", - center: [3.3903, 6.4474], // Apapa Port, Lagos - zoom: 8, - pitch: 0, - bearing: 0, - }); + map2DRef.current.on("load", () => { + if (cancelled) return; + setMapLoaded(true); + addPortLayers(); + addVesselLayers(); + addGeofenceLayers(); + addCongestionHeatmapLayer(); + addTrackLayers(); + }); - map2DRef.current.on("load", () => { - setMapLoaded(true); - addPortLayers(); - addVesselLayers(); - addGeofenceLayers(); - }); + map2DRef.current.on("error", (e: any) => { + if (cancelled) return; + setMapError( + `Map tiles unavailable — ${e?.error?.message ?? "tile/style fetch failed"}. ` + + "Check CSP_CONNECT_SRC_EXTRA whitelist for the configured tile origin." + ); + }); - // Click handler for vessel selection - map2DRef.current.on("click", "vessels-layer", (e: any) => { - const feature = e.features?.[0]; - if (feature) { - setSelectedVessel(feature.properties as Vessel); + map2DRef.current.on("click", "vessels-layer", (e: any) => { + const feature = e.features?.[0]; + if (feature) setSelectedVessel(feature.properties as GeoVessel); + }); + map2DRef.current.on("mouseenter", "vessels-layer", () => { + if (map2DRef.current) map2DRef.current.getCanvas().style.cursor = "pointer"; + }); + map2DRef.current.on("mouseleave", "vessels-layer", () => { + if (map2DRef.current) map2DRef.current.getCanvas().style.cursor = ""; + }); + } catch (err) { + if (!cancelled) { + setMapError(err instanceof Error ? err.message : "Map engine failed to load"); } - }); - - map2DRef.current.on("mouseenter", "vessels-layer", () => { - map2DRef.current.getCanvas().style.cursor = "pointer"; - }); - map2DRef.current.on("mouseleave", "vessels-layer", () => { - map2DRef.current.getCanvas().style.cursor = ""; - }); + } }; loadMapLibre(); return () => { + cancelled = true; if (map2DRef.current) { map2DRef.current.remove(); map2DRef.current = null; + setMapLoaded(false); } }; - }, []); + // lowData switches require a full style reload → remount the map + }, [lowData]); - // ─── Load CesiumJS for 3D View ──────────────────────────────────────────── + // ─── Load CesiumJS for 3D port-approach view (#3) ───────────────────────── useEffect(() => { if (mapMode !== "3d" || typeof window === "undefined") return; + let cancelled = false; const loadCesium = async () => { - if (!Cesium) { - await loadScript("https://cesium.com/downloads/cesiumjs/releases/1.117/Build/Cesium/Cesium.js"); - await loadCSS("https://cesium.com/downloads/cesiumjs/releases/1.117/Build/Cesium/Widgets/widgets.css"); - Cesium = (window as any).Cesium; - // Use free Cesium Ion token (or anonymous) - Cesium.Ion.defaultAccessToken = process.env.VITE_CESIUM_TOKEN || ""; - } + try { + // Lazy-load the SAME-ORIGIN prebuilt Cesium bundle (copied into + // dist/cesium at build time — CSP script-src 'self' compatible, no + // runtime CDN). Only fetched when the user opens the 3D view. + await loadScript("/cesium/Cesium.js"); + await loadStylesheet("/cesium/Widgets/widgets.css"); + const Cesium = (window as any).Cesium; + if (!Cesium) throw new Error("Cesium bundle loaded but global Cesium is missing"); + if (cancelled) return; + cesiumRef.current = Cesium; + + // G3: Ion is opt-in. No token → honest Ion-free terrain, never an + // empty-token request to ion.cesium.com. + if (CESIUM_ION_TOKEN) { + Cesium.Ion.defaultAccessToken = CESIUM_ION_TOKEN; + } - if (!mapContainer3D.current || viewerRef.current) return; - - viewerRef.current = new Cesium.Viewer(mapContainer3D.current, { - terrainProvider: await Cesium.createWorldTerrainAsync(), - baseLayerPicker: false, - geocoder: false, - homeButton: false, - sceneModePicker: false, - navigationHelpButton: false, - animation: false, - timeline: false, - fullscreenButton: false, - imageryProvider: new Cesium.OpenStreetMapImageryProvider({ - url: "https://tile.openstreetmap.org/", - }), - }); + if (!mapContainer3D.current || viewerRef.current) return; + + viewerRef.current = new Cesium.Viewer(mapContainer3D.current, { + // Ellipsoid terrain by default; Ion world terrain only when configured. + terrainProvider: CESIUM_ION_TOKEN + ? await Cesium.createWorldTerrainAsync() + : new Cesium.EllipsoidTerrainProvider(), + baseLayerPicker: false, + geocoder: false, + homeButton: false, + sceneModePicker: false, + navigationHelpButton: false, + animation: false, + timeline: false, + fullscreenButton: false, + // OSM imagery as base layer (Cesium 1.117: baseLayer, not imageryProvider) + baseLayer: new Cesium.ImageryLayer( + new Cesium.OpenStreetMapImageryProvider({ + url: "https://tile.openstreetmap.org/", + }) + ), + }); - // Fly to Lagos / Apapa Port - viewerRef.current.camera.flyTo({ - destination: Cesium.Cartesian3.fromDegrees(3.3903, 6.4474, 50000), - orientation: { heading: 0, pitch: Cesium.Math.toRadians(-45), roll: 0 }, - duration: 2, - }); + viewerRef.current.camera.flyTo({ + destination: Cesium.Cartesian3.fromDegrees(3.3903, 6.4474, 50000), + orientation: { heading: 0, pitch: Cesium.Math.toRadians(-45), roll: 0 }, + duration: reducedMotion ? 0 : 2, + }); - // Add Nigerian ports as 3D billboards - NIGERIAN_PORTS.forEach(port => { - viewerRef.current.entities.add({ - id: `port-${port.id}`, - name: port.name, - position: Cesium.Cartesian3.fromDegrees(port.lng, port.lat), - billboard: { - image: port.type === "airport" - ? "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZmlsbD0iI2ZmYTUwMCIgZD0iTTIxIDMuNWwtOS45IDkuOUwzIDcuNWwxLjUtMS41IDYuNSA0LjUgOC41LTguNXoiLz48L3N2Zz4=" - : "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PGNpcmNsZSBjeD0iMTIiIGN5PSIxMiIgcj0iOCIgZmlsbD0iIzAwN2JmZiIvPjwvc3ZnPg==", - width: 32, - height: 32, - }, - label: { - text: port.name, - font: "12px sans-serif", - fillColor: Cesium.Color.WHITE, - outlineColor: Cesium.Color.BLACK, - outlineWidth: 2, - style: Cesium.LabelStyle.FILL_AND_OUTLINE, - verticalOrigin: Cesium.VerticalOrigin.BOTTOM, - pixelOffset: new Cesium.Cartesian2(0, -40), - }, + NIGERIAN_PORTS.forEach(port => { + viewerRef.current.entities.add({ + id: `port-${port.id}`, + name: port.name, + position: Cesium.Cartesian3.fromDegrees(port.lng, port.lat), + billboard: { + image: port.type === "airport" + ? "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZmlsbD0iI2ZmYTUwMCIgZD0iTTIxIDMuNWwtOS45IDkuOUwzIDcuNWwxLjUtMS41IDYuNSA0LjUgOC41LTguNXoiLz48L3N2Zz4=" + : "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PGNpcmNsZSBjeD0iMTIiIGN5PSIxMiIgcj0iOCIgZmlsbD0iIzAwN2JmZiIvPjwvc3ZnPg==", + width: 32, + height: 32, + }, + label: { + text: port.name, + font: "12px sans-serif", + fillColor: Cesium.Color.WHITE, + outlineColor: Cesium.Color.BLACK, + outlineWidth: 2, + style: Cesium.LabelStyle.FILL_AND_OUTLINE, + verticalOrigin: Cesium.VerticalOrigin.BOTTOM, + pixelOffset: new Cesium.Cartesian2(0, -40), + }, + }); }); - }); - setCesiumLoaded(true); - add3DVessels(); + if (!cancelled) { + setCesiumLoaded(true); + add3DVessels(); + } + } catch (err) { + if (!cancelled) { + setCesiumError(err instanceof Error ? err.message : "3D engine failed to load"); + } + } }; loadCesium(); return () => { + cancelled = true; if (viewerRef.current) { viewerRef.current.destroy(); viewerRef.current = null; setCesiumLoaded(false); } }; - }, [mapMode]); + }, [mapMode, reducedMotion]); - // ─── Update vessel positions on data change ──────────────────────────────── + // ─── Update vessel positions on data change ─────────────────────────────── useEffect(() => { - if (!mapLoaded || !map2DRef.current || !vessels.data) return; - updateVesselLayer((vessels.data as VesselTrackingRow[]).map(toMapVessel)); - }, [vessels.data, mapLoaded]); + if (!mapLoaded || !map2DRef.current) return; + const source = map2DRef.current.getSource("vessels"); + if (source) source.setData(vesselsToGeoJSON(mapVessels)); + }, [vessels.data, mapLoaded]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { - if (!cesiumLoaded || !viewerRef.current || !vessels.data) return; + if (!cesiumLoaded || !viewerRef.current) return; add3DVessels(); - }, [vessels.data, cesiumLoaded]); + }, [vessels.data, cesiumLoaded]); // eslint-disable-line react-hooks/exhaustive-deps + + // #4 congestion heatmap data → MapLibre heatmap source + useEffect(() => { + if (!mapLoaded || !map2DRef.current || !heatmap.data) return; + const source = map2DRef.current.getSource("congestion"); + if (!source) return; + source.setData({ + type: "FeatureCollection", + features: heatmap.data.map(p => ({ + type: "Feature" as const, + geometry: { type: "Point" as const, coordinates: [p.lng, p.lat] }, + properties: { weight: p.weight, portName: p.portName, status: p.congestionStatus }, + })), + }); + }, [heatmap.data, mapLoaded]); + + // #2/#7 track replay + route overlay when a vessel is selected + useEffect(() => { + if (!mapLoaded || !map2DRef.current) return; + const map = map2DRef.current; + + const trackSource = map.getSource("vessel-track"); + const rows = (track.data as VesselTrackingRow[] | undefined) ?? []; + // getVesselTrack returns newest-first; reverse to oldest-first for replay + const line = trackToLineString([...rows].reverse()); + if (trackSource) { + trackSource.setData( + line ?? { type: "FeatureCollection", features: [] } + ); + } + + // Route/ETA overlay: leg from current position to destination port + const routeSource = map.getSource("vessel-route"); + if (routeSource) { + const dest = selectedVessel?.destination_port + ? NIGERIAN_PORTS.find(p => p.unlocode === selectedVessel.destination_port) + : undefined; + routeSource.setData( + selectedVessel && dest + ? { + type: "Feature", + geometry: { + type: "LineString", + coordinates: [ + [selectedVessel.lng, selectedVessel.lat], + [dest.lng, dest.lat], + ], + }, + properties: { destination: dest.name }, + } + : { type: "FeatureCollection", features: [] } + ); + } + }, [track.data, selectedVessel, mapLoaded]); // ─── MapLibre Layer Functions ───────────────────────────────────────────── @@ -316,7 +377,6 @@ export default function GeospatialPortal() { source: "ports", layout: { "text-field": ["get", "name"], - "text-font": ["Open Sans Regular"], "text-size": 11, "text-offset": [0, 1.5], "text-anchor": "top", @@ -340,17 +400,24 @@ export default function GeospatialPortal() { map.addLayer({ id: "vessels-layer", + type: "circle", + source: "vessels", + paint: { + "circle-radius": 6, + "circle-color": "#28a745", + "circle-stroke-width": 2, + "circle-stroke-color": "#ffffff", + }, + }); + + map.addLayer({ + id: "vessels-labels", type: "symbol", source: "vessels", layout: { - "icon-image": "marker-15", - "icon-size": 1.5, - "icon-rotate": ["get", "heading"], - "icon-rotation-alignment": "map", "text-field": ["get", "name"], - "text-font": ["Open Sans Regular"], "text-size": 10, - "text-offset": [0, 1.5], + "text-offset": [0, 1.4], "text-anchor": "top", }, paint: { @@ -365,7 +432,6 @@ export default function GeospatialPortal() { if (!map2DRef.current) return; const map = map2DRef.current; - // Nigerian EEZ (200 nautical miles from baseline) map.addSource("geofences", { type: "geojson", data: { @@ -425,32 +491,83 @@ export default function GeospatialPortal() { }); }, []); - const updateVesselLayer = useCallback((vesselData: Vessel[]) => { + const addCongestionHeatmapLayer = useCallback(() => { if (!map2DRef.current) return; - const source = map2DRef.current.getSource("vessels"); - if (!source) return; + const map = map2DRef.current; - source.setData({ - type: "FeatureCollection", - features: vesselData.map(v => ({ - type: "Feature", - geometry: { type: "Point", coordinates: [v.lng, v.lat] }, - properties: v, - })), + map.addSource("congestion", { + type: "geojson", + data: { type: "FeatureCollection", features: [] }, + }); + + map.addLayer({ + id: "congestion-heatmap-layer", + type: "heatmap", + source: "congestion", + paint: { + "heatmap-weight": ["interpolate", ["linear"], ["get", "weight"], 0, 0, 1, 1], + "heatmap-intensity": ["interpolate", ["linear"], ["zoom"], 0, 1, 9, 3], + "heatmap-radius": ["interpolate", ["linear"], ["zoom"], 0, 10, 9, 40], + "heatmap-opacity": 0.6, + "heatmap-color": [ + "interpolate", ["linear"], ["heatmap-density"], + 0, "rgba(0,255,0,0)", + 0.3, "rgba(0,255,0,1)", + 0.6, "rgba(255,255,0,1)", + 0.8, "rgba(255,165,0,1)", + 1, "rgba(255,0,0,1)", + ], + }, + }); + }, []); + + const addTrackLayers = useCallback(() => { + if (!map2DRef.current) return; + const map = map2DRef.current; + + map.addSource("vessel-track", { + type: "geojson", + data: { type: "FeatureCollection", features: [] }, + }); + map.addLayer({ + id: "vessel-track-layer", + type: "line", + source: "vessel-track", + paint: { + "line-color": "#007bff", + "line-width": 2, + "line-dasharray": [2, 2], + }, + }); + + map.addSource("vessel-route", { + type: "geojson", + data: { type: "FeatureCollection", features: [] }, + }); + map.addLayer({ + id: "vessel-route-layer", + type: "line", + source: "vessel-route", + paint: { + "line-color": "#ff9800", + "line-width": 2, + "line-dasharray": [6, 4], + }, }); }, []); // ─── CesiumJS 3D Vessel Functions ───────────────────────────────────────── const add3DVessels = useCallback(() => { - if (!viewerRef.current || !vessels.data) return; + if (!viewerRef.current || !cesiumRef.current) return; const viewer = viewerRef.current; + const Cesium = cesiumRef.current; + const rows = (vessels.data as VesselTrackingRow[] | undefined) ?? []; - // Remove existing vessel entities const toRemove = viewer.entities.values.filter((e: any) => e.id?.startsWith("vessel-")); toRemove.forEach((e: any) => viewer.entities.remove(e)); - (vessels.data as VesselTrackingRow[]).map(toMapVessel).forEach(vessel => { + rows.map(toMapVessel).filter((v): v is GeoVessel => v !== null).forEach(vessel => { viewer.entities.add({ id: `vessel-${vessel.id}`, name: vessel.name, @@ -488,14 +605,12 @@ export default function GeospatialPortal() { next.add(layer); } - // Toggle MapLibre layer visibility if (map2DRef.current && mapLoaded) { const layerMap: Record = { - vessels: ["vessels-layer"], + vessels: ["vessels-layer", "vessels-labels"], ports: ["ports-layer", "ports-labels"], geofences: ["geofences-fill", "geofences-outline"], - "cargo-routes": ["cargo-routes-layer"], - "risk-zones": ["risk-zones-layer"], + "congestion-heatmap": ["congestion-heatmap-layer"], }; const visibility = next.has(layer) ? "visible" : "none"; layerMap[layer]?.forEach(id => { @@ -510,50 +625,74 @@ export default function GeospatialPortal() { // ─── Helpers ────────────────────────────────────────────────────────────── - const loadScript = (src: string): Promise => { - return new Promise((resolve, reject) => { - if (document.querySelector(`script[src="${src}"]`)) { - resolve(); - return; - } + // Same-origin script/stylesheet loaders with error propagation (CSP-safe). + const loadScript = (src: string): Promise => + new Promise((resolve, reject) => { + if (document.querySelector(`script[src="${src}"]`)) return resolve(); const script = document.createElement("script"); script.src = src; script.onload = () => resolve(); - script.onerror = reject; + script.onerror = () => reject(new Error(`Failed to load ${src}`)); document.head.appendChild(script); }); - }; - const loadCSS = (href: string): Promise => { - return new Promise(resolve => { - if (document.querySelector(`link[href="${href}"]`)) { - resolve(); - return; - } + const loadStylesheet = (href: string): Promise => + new Promise((resolve, reject) => { + if (document.querySelector(`link[href="${href}"]`)) return resolve(); const link = document.createElement("link"); link.rel = "stylesheet"; link.href = href; link.onload = () => resolve(); + link.onerror = () => reject(new Error(`Failed to load ${href}`)); document.head.appendChild(link); - resolve(); }); - }; // ─── Render ─────────────────────────────────────────────────────────────── + const LAYER_LABELS: Record = { + vessels: "Live vessels (AIS)", + ports: "Ports", + geofences: "Geofence zones", + "congestion-heatmap": "Congestion heatmap", + }; + return (
{/* Header */}

TradeGateway Geospatial Portal

-

MapLibre GL JS + GeoLibre + CesiumJS 3D

+

MapLibre GL JS (2D) + CesiumJS (3D) — bundled, no runtime CDN

+ {/* #2 honest stale indicator */} + {vessels.data && feedStale && ( + + AIS data stale (>90 s) — positions may be outdated + + )} + {vessels.isError && ( + + Vessel feed unavailable + + )} + + {/* #8 low-bandwidth toggle (2D) */} + + {/* Mode Toggle */} -
+
{/* Vessel count */} -
- {(vessels.data as Vessel[] | undefined)?.length ?? 0} vessels tracked +
+ {mapVessels.length} vessels tracked
@@ -581,15 +721,16 @@ export default function GeospatialPortal() { {/* Layer Controls Sidebar */}

Layers

- {(["vessels", "ports", "geofences", "cargo-routes", "risk-zones"] as MapLayer[]).map(layer => ( + {(Object.keys(LAYER_LABELS) as MapLayer[]).map(layer => ( ))} @@ -600,11 +741,15 @@ export default function GeospatialPortal() { key={port.id} onClick={() => { if (mapMode === "2d" && map2DRef.current) { - map2DRef.current.flyTo({ center: [port.lng, port.lat], zoom: 13, duration: 1500 }); - } else if (viewerRef.current) { + map2DRef.current.flyTo({ + center: [port.lng, port.lat], + zoom: 13, + duration: reducedMotion ? 0 : 1500, + }); + } else if (viewerRef.current && cesiumRef.current) { viewerRef.current.camera.flyTo({ - destination: Cesium.Cartesian3.fromDegrees(port.lng, port.lat, 5000), - duration: 2, + destination: cesiumRef.current.Cartesian3.fromDegrees(port.lng, port.lat, 5000), + duration: reducedMotion ? 0 : 2, }); } }} @@ -614,18 +759,6 @@ export default function GeospatialPortal() { ))}
- - {/* Alerts */} - {alerts.length > 0 && ( -
-

Alerts

- {alerts.map((alert, i) => ( -
- {alert} -
- ))} -
- )}
{/* Map Area */} @@ -633,67 +766,105 @@ export default function GeospatialPortal() { {/* 2D MapLibre Map */}
{/* 3D CesiumJS Viewer */}
- {/* Loading overlay */} - {!mapLoaded && mapMode === "2d" && ( -
+ {/* 2D error / loading overlays */} + {mapError && mapMode === "2d" && ( +
+
+

Map unavailable

+

{mapError}

+
+
+ )} + {!mapLoaded && !mapError && mapMode === "2d" && ( +
-

Loading MapLibre GL JS + GeoLibre tiles...

+

Loading MapLibre GL JS…

+
+
+ )} + + {/* 3D error / Ion notice overlays */} + {cesiumError && mapMode === "3d" && ( +
+
+

3D view unavailable

+

{cesiumError}

)} + {!cesiumError && !CESIUM_ION_TOKEN && mapMode === "3d" && cesiumLoaded && ( +
+ Cesium Ion token not configured — using open OSM imagery and ellipsoid terrain. +
+ )} - {/* Selected Vessel Panel */} + {/* Empty-vessel honesty state */} + {mapLoaded && vessels.data && mapVessels.length === 0 && !vessels.isError && ( +
+ No live AIS positions for the selected filter. +
+ )} + + {/* Selected Vessel Panel (#2 track + #7 ETA) */} {selectedVessel && (

⚓ {selectedVessel.name}

- {[ + {([ ["MMSI", selectedVessel.mmsi], - ["IMO", selectedVessel.imo], + ["IMO", selectedVessel.imo || "—"], ["Status", selectedVessel.status], ["Speed", `${selectedVessel.speed} kn`], ["Heading", `${selectedVessel.heading}°`], ["Cargo", selectedVessel.cargo_type], ["Destination", selectedVessel.destination_port ?? "—"], - ["ETA", selectedVessel.eta ?? "—"], - ["Declaration", selectedVessel.declaration_ref ?? "—"], - ].map(([label, value]) => ( + ["ETA", selectedVessel.eta ? new Date(selectedVessel.eta).toLocaleString() : "—"], + ] as [string, string][]).map(([label, value]) => (
{label}: {value}
))}
- {selectedVessel.declaration_ref && ( - - )} +

+ {track.isLoading + ? "Loading track history…" + : track.data && track.data.length > 1 + ? `Track replay: ${track.data.length} recorded positions (dashed blue).` + : "No recorded track for this vessel."} +

)} {/* Map Attribution */} -
+
{mapMode === "2d" - ? "© OpenFreeMap (GeoLibre) | MapLibre GL JS | © OpenStreetMap contributors" + ? lowData + ? "© OpenStreetMap contributors (low-data raster)" + : "© OpenFreeMap | MapLibre GL JS | © OpenStreetMap contributors" : "© CesiumJS | © OpenStreetMap contributors"}
diff --git a/package.json b/package.json index 2c01d780..418426a9 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "@types/ws": "^8.18.1", "axios": "^1.16.0", "bcryptjs": "^3.0.3", + "cesium": "1.117.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -103,6 +104,7 @@ "jose": "6.1.0", "kafkajs": "^2.2.4", "lucide-react": "^0.453.0", + "maplibre-gl": "4.7.1", "multer": "^2.2.0", "nanoid": "^5.1.16", "next-themes": "^0.4.6", @@ -139,6 +141,8 @@ "@playwright/test": "^1.58.2", "@tailwindcss/typography": "^0.5.15", "@tailwindcss/vite": "^4.3.3", + "@testing-library/dom": "10.4.1", + "@testing-library/react": "16.3.3", "@types/cors": "^2.8.19", "@types/express": "4.17.21", "@types/google.maps": "^3.58.1", @@ -156,6 +160,7 @@ "drizzle-kit": "^0.31.4", "esbuild": "^0.28.1", "js-yaml": "^5.0.0", + "jsdom": "26.1.0", "playwright": "^1.58.2", "pnpm": "^11.20.0", "postcss": "^8.5.20", @@ -165,7 +170,8 @@ "tw-animate-css": "^1.4.0", "typescript": "5.9.3", "vite": "^8.0.16", + "vite-plugin-cesium": "1.2.23", "vitest": "^4.1.5" }, "packageManager": "pnpm@11.20.0" -} \ No newline at end of file +} diff --git a/server/_core/csp.test.ts b/server/_core/csp.test.ts new file mode 100644 index 00000000..e84434fd --- /dev/null +++ b/server/_core/csp.test.ts @@ -0,0 +1,29 @@ +/** + * Phase 17 (G1/G8) — CSP origin whitelist parsing tests. + */ +import { describe, it, expect } from "vitest"; +import { parseCspOrigins } from "./csp"; + +describe("parseCspOrigins", () => { + it("parses comma-separated https origins", () => { + expect(parseCspOrigins("https://tiles.openfreemap.org, https://tile.openstreetmap.org")).toEqual([ + "https://tiles.openfreemap.org", + "https://tile.openstreetmap.org", + ]); + }); + + it("returns empty for unset/blank env (fail-closed)", () => { + expect(parseCspOrigins(undefined)).toEqual([]); + expect(parseCspOrigins(" ")).toEqual([]); + }); + + it("drops wildcards, http, and malformed entries — a typo can never widen CSP", () => { + expect(parseCspOrigins("*, https://ok.example.com, http://insecure.example.com, notaurl")).toEqual([ + "https://ok.example.com", + ]); + }); + + it("allows data:/blob: tokens for inline assets", () => { + expect(parseCspOrigins("data:,blob:")).toEqual(["data:", "blob:"]); + }); +}); diff --git a/server/_core/csp.ts b/server/_core/csp.ts new file mode 100644 index 00000000..c94092ea --- /dev/null +++ b/server/_core/csp.ts @@ -0,0 +1,16 @@ +/** + * Phase 17 (G1) — env-driven CSP origin whitelist parsing. + * + * Production CSP stays fail-closed (same-origin only) unless operators + * explicitly whitelist tile/style/glyph origins via: + * CSP_SCRIPT_SRC_EXTRA / CSP_CONNECT_SRC_EXTRA / CSP_IMG_SRC_EXTRA + * (comma-separated https origins). Malformed entries are dropped so a typo + * can never widen the policy to a wildcard. + */ + +export function parseCspOrigins(raw: string | undefined): string[] { + return (raw ?? "") + .split(",") + .map(s => s.trim()) + .filter(s => /^https:\/\/[a-z0-9.-]+(?::\d+)?(\/[^\s,]*)?$/i.test(s) || s === "data:" || s === "blob:"); +} diff --git a/server/_core/index.ts b/server/_core/index.ts index 02f8f164..236f2159 100644 --- a/server/_core/index.ts +++ b/server/_core/index.ts @@ -25,6 +25,7 @@ import { createExpressMiddleware } from "@trpc/server/adapters/express"; import { registerOpenApiRoute } from "../openapi"; import { metricsRegistry } from "./metrics"; import { registerHealthRoutes } from "../routes/health"; +import { parseCspOrigins } from "./csp"; import { appRouter } from "../routers"; import { createContext } from "./context"; import { serveStatic, setupVite } from "./vite"; @@ -1297,24 +1298,37 @@ async function startServer() { maxAge: 86400, })); // ── Security headers (helmet) ───────────────────────────────────────────── + // Phase 17 (G1): map engines/tiles are config-driven, never CDN-wide-open. + // Operators whitelist ONLY the tile/style/glyph origins actually in use via + // env (comma-separated origins): + // CSP_SCRIPT_SRC_EXTRA — e.g. a same-origin maps bootstrap proxy + // CSP_CONNECT_SRC_EXTRA — e.g. https://tiles.openfreemap.org,https://tile.openstreetmap.org + // CSP_IMG_SRC_EXTRA — raster tile origins if img-src https: is ever tightened + // Defaults stay fail-closed (same-origin only) when the env vars are unset. + const cspScriptExtra = parseCspOrigins(process.env.CSP_SCRIPT_SRC_EXTRA); + const cspConnectExtra = parseCspOrigins(process.env.CSP_CONNECT_SRC_EXTRA); + const cspImgExtra = parseCspOrigins(process.env.CSP_IMG_SRC_EXTRA); app.use(helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], // Tighten CSP in production: remove unsafe-inline/eval scriptSrc: process.env.NODE_ENV === 'production' - ? ["'self'", "https://fonts.googleapis.com"] + ? ["'self'", "https://fonts.googleapis.com", ...cspScriptExtra] : ["'self'", "'unsafe-inline'", "'unsafe-eval'", "https://fonts.googleapis.com"], styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"], fontSrc: ["'self'", "https://fonts.gstatic.com"], - imgSrc: ["'self'", "data:", "blob:", "https:"], + imgSrc: ["'self'", "data:", "blob:", "https:", ...cspImgExtra], // SW-S11-3: production connect-src is same-origin + websockets only — // the previous `https:` allowed exfiltration to any HTTPS endpoint. + // Map tile/style/glyph fetches require explicit CSP_CONNECT_SRC_EXTRA origins. connectSrc: process.env.NODE_ENV === 'production' - ? ["'self'", "wss:"] + ? ["'self'", "wss:", ...cspConnectExtra] : ["'self'", "wss:", "https:"], frameSrc: ["'none'"], objectSrc: ["'none'"], + // MapLibre GL / Cesium create WebGL workers from blob: URLs. + workerSrc: ["'self'", "blob:"], upgradeInsecureRequests: process.env.NODE_ENV === 'production' ? [] : null, }, }, diff --git a/vite.config.ts b/vite.config.ts index e621b450..da8a1b13 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,6 +2,38 @@ import react from "@vitejs/plugin-react"; import path from "node:path"; import { defineConfig, type PluginOption } from "vite"; import tailwindcss from "@tailwindcss/vite"; +// Phase 17 (G5): Cesium is a pinned npm dependency bundled at build time +// (static assets copied into dist) — no runtime cesium.com CDN injection. +import cesium from "vite-plugin-cesium"; +import fs from "node:fs"; + +// Strip vite-plugin-cesium's eager