From 655cc7cf5e7ee08a60151b25dc1bd70b997bbd7a Mon Sep 17 00:00:00 2001 From: Timothy Lin <55767165+Deodat-Lawson@users.noreply.github.com> Date: Mon, 9 Feb 2026 10:40:09 -0500 Subject: [PATCH 01/10] Refactor GlobalMap components to streamline route handling and improve ship initialization logic. Reduced the number of ships to spawn and updated data fetching methods to handle routes dynamically via props. Enhanced path rendering with improved angle calculations for ship icons. --- frontend/src/components/GlobalMap2D.tsx | 154 ++++++++++++------------ frontend/src/components/GlobalMap3D.tsx | 153 +++++++++++------------ 2 files changed, 147 insertions(+), 160 deletions(-) diff --git a/frontend/src/components/GlobalMap2D.tsx b/frontend/src/components/GlobalMap2D.tsx index 5ef8e6f8f..f0bd04c57 100644 --- a/frontend/src/components/GlobalMap2D.tsx +++ b/frontend/src/components/GlobalMap2D.tsx @@ -50,29 +50,28 @@ const COLOR_LABEL_PRIMARY: [number, number, number, number] = [180, 185, 195, 25 const COLOR_LABEL_SECONDARY: [number, number, number, number] = [140, 150, 165, 220]; // Secondary labels const COLOR_GRATICULE: [number, number, number, number] = [60, 70, 85, 30]; // Very subtle grid -const NUM_SHIPS = 500; +const NUM_SHIPS = 4; // Props Interface to match existing usage interface GlobalMap2DProps { origin?: any; destination?: any; onRouteSelect?: (route: Route) => void; - onRoutesCalculated?: (routes: Route[]) => void; selectedRouteFromParent?: Route | null; currentTime?: number; onShipSelect?: (ship: Ship) => void; + routes?: Route[]; } export function GlobalMap2D({ origin, destination, onRouteSelect, - onRoutesCalculated, selectedRouteFromParent, currentTime = 0, - onShipSelect + onShipSelect, + routes }: GlobalMap2DProps) { - const [landData, setLandData] = useState([]); const [routeData, setRouteData] = useState([]); const [paths, setPaths] = useState({}); const [ships, setShips] = useState([]); @@ -110,17 +109,9 @@ export function GlobalMap2D({ shipSpeedRef.current = shipSpeed; }, [shipSpeed]); - useEffect(() => { - // Fetch all data - fetch('/data/land_cells.json') - .then(resp => resp.json()) - .then(data => { - const list = Object.keys(data).map(h => ({ - hex: h, type: data[h].t, country: data[h].c - })); - setLandData(list); - }); + useEffect(() => { + // 1. Static Data Fetches (Run once) fetch('/data/route_cells.json') .then(resp => resp.json()) .then(data => { @@ -128,13 +119,6 @@ export function GlobalMap2D({ setRouteData(list); }); - fetch('/data/paths.json') - .then(resp => resp.json()) - .then(pathData => { - setPaths(pathData); - initShips(pathData); - }); - fetch('/data/country_labels.json') .then(resp => resp.json()) .then(data => setLabels(data)); @@ -176,11 +160,37 @@ export function GlobalMap2D({ }; }, []); + // 2. Route & Ship Logic — routes are computed once in DemoPage and passed via props + useEffect(() => { + if (routes && routes.length > 0) { + const newPaths: any = {}; + routes.forEach(r => { + newPaths[r.id] = r.waypoints; + }); + setPaths(newPaths); + // Only spawn ships on the SELECTED route if one is selected, else on all + if (selectedRouteFromParent) { + initShips({ [selectedRouteFromParent.id]: selectedRouteFromParent.waypoints }); + } else { + initShips(newPaths); + } + } else { + // Default Mode: No routes from parent — show all global routes + fetch('/data/paths.json') + .then(resp => resp.json()) + .then(pathData => { + setPaths(pathData); + initShips(pathData); + }); + } + }, [routes, selectedRouteFromParent]); + + const initShips = (pathData: any) => { const pathKeys = Object.keys(pathData); if (pathKeys.length === 0) return; - const newShips = []; + const newShips: any[] = []; for (let i = 0; i < NUM_SHIPS; i++) { const randomPathKey = pathKeys[Math.floor(Math.random() * pathKeys.length)]; const path = pathData[randomPathKey]; @@ -198,7 +208,7 @@ export function GlobalMap2D({ // Generate Graticule Data (Memoized) const graticuleData = React.useMemo(() => { - const lines = []; + const lines: any[] = []; // Longitude lines for (let lon = -180; lon <= 180; lon += 10) { lines.push({ @@ -208,7 +218,7 @@ export function GlobalMap2D({ } // Latitude lines for (let lat = -80; lat <= 80; lat += 10) { - const coords = []; + const coords: number[][] = []; for (let lon = -180; lon <= 180; lon += 5) { coords.push([lon, lat]); } @@ -221,7 +231,7 @@ export function GlobalMap2D({ }, []); const graticuleLabels = React.useMemo(() => { - const labels = []; + const labels: any[] = []; // Longitude labels (at Equator) for (let lon = -180; lon <= 180; lon += 20) { // Every 20 deg for labels to avoid clutter if (lon === -180) continue; // Avoid overlap @@ -280,7 +290,20 @@ export function GlobalMap2D({ const lon = p1[0] + (p2[0] - p1[0]) * ratio; const lat = p1[1] + (p2[1] - p1[1]) * ratio; - return { position: [lon, lat], pathId: ship.pathId }; + // Calculate Bearing for Arrow + const dLon = p2[0] - p1[0]; + const dLat = p2[1] - p1[1]; + const angle = Math.atan2(dLat, dLon); // Radians + + // Convert atan2 angle to deck.gl icon rotation (clockwise degrees from north) + const angleDeg = 90 - (angle * 180 / Math.PI); + + return { + position: [lon, lat], + pathId: ship.pathId, + angle, + angleDeg, + }; }).filter(s => s !== null); }; @@ -294,6 +317,8 @@ export function GlobalMap2D({ return labels.filter((l: any) => (l.size || 0) > minSize); }, [labels, currentZoom]); + const currentShipData = getShipData(); + const layers = [ // 1. Background Water (Google Maps dark style) new SolidPolygonLayer({ @@ -344,39 +369,32 @@ export function GlobalMap2D({ lineWidthMaxPixels: 1.5, }), - // 3. Shipping Routes (PathLayer - clean lines like Google Maps) - ...Object.entries(paths).map(([routeName, pathCoords]: [string, any]) => - new PathLayer({ + // 3. Shipping Routes (PathLayer) + ...Object.entries(paths).map(([routeName, pathCoords]: [string, any]) => { + const isSelected = selectedRouteFromParent ? selectedRouteFromParent.id === routeName : true; + return new PathLayer({ id: `route-${routeName.replace(/\s+/g, '-')}`, data: [{ path: pathCoords, name: routeName }], getPath: (d: any) => d.path, - getColor: COLOR_ROUTE_DEFAULT, - getWidth: 2, - widthMinPixels: 1.5, - widthMaxPixels: 4, + getColor: isSelected ? COLOR_ROUTE_ACTIVE : COLOR_ROUTE_DEFAULT, + getWidth: isSelected ? 2 : 1, + widthMinPixels: 1, + widthMaxPixels: 3, capRounded: true, jointRounded: true, - pickable: true, + pickable: true, wrapLongitude: true, - }) - ), - - // 4. Port markers (glow effect layer - rendered first) - new ScatterplotLayer({ - id: 'port-glow-layer', - data: portLabels, - pickable: false, - opacity: 0.4, - stroked: false, - filled: true, - radiusScale: 1, - radiusMinPixels: 8, - radiusMaxPixels: 16, - getPosition: (d: any) => d.coordinates, - getFillColor: COLOR_PORT_GLOW, + // @ts-ignore + onClick: () => { + if (onRouteSelect) { + const routeObj = routes?.find(r => r.id === routeName); + if (routeObj) onRouteSelect(routeObj); + } + } + }); }), - // 4b. Port markers (core dots) + // 4. Port markers new ScatterplotLayer({ id: 'port-markers', data: portLabels, @@ -393,7 +411,7 @@ export function GlobalMap2D({ getLineColor: [28, 33, 40, 255], }), - // 5. Strait markers (diamond-like points for key passages) + // 5. Strait markers new ScatterplotLayer({ id: 'strait-markers', data: straitLabels, @@ -440,7 +458,7 @@ export function GlobalMap2D({ // 7. Ships with glow effect new ScatterplotLayer({ id: 'ship-glow-layer', - data: getShipData(), + data: currentShipData, pickable: false, opacity: 0.5, stroked: false, @@ -466,40 +484,24 @@ export function GlobalMap2D({ } }), - // 7b. Ships (core markers) + // 7b. Ships (white dots) new ScatterplotLayer({ - id: 'ship-layer', - data: getShipData(), + id: 'ship-dots', + data: currentShipData, pickable: true, opacity: 1, - stroked: true, + stroked: false, filled: true, radiusScale: 1, radiusMinPixels: 2 + shipSize, - radiusMaxPixels: 4 + shipSize * 1.5, - lineWidthMinPixels: 0.5, + radiusMaxPixels: 4 + shipSize * 2, getPosition: (d: any) => d.position, - getFillColor: (d: any) => { - if (d.pathId) { - for (const key of Object.keys(activeCrises)) { - if (activeCrises[key] && allCrisisData[key]) { - const affected = allCrisisData[key].affected_routes || []; - const isAffected = affected.some((r: any) => d.pathId.includes(r)); - if (isAffected) return COLOR_CRISIS; - } - } - } - return COLOR_SHIP; - }, - getLineColor: [28, 33, 40, 200], + getFillColor: COLOR_SHIP, // @ts-ignore onClick: ({ object }) => { if (onShipSelect && object) { onShipSelect(object as any); } - }, - updateTriggers: { - getFillColor: [activeCrises, allCrisisData] } }), diff --git a/frontend/src/components/GlobalMap3D.tsx b/frontend/src/components/GlobalMap3D.tsx index 464ca6a60..3df318ac7 100644 --- a/frontend/src/components/GlobalMap3D.tsx +++ b/frontend/src/components/GlobalMap3D.tsx @@ -52,18 +52,17 @@ interface GlobalMap3DProps { origin?: any; destination?: any; onRouteSelect?: (route: Route) => void; - onRoutesCalculated?: (routes: Route[]) => void; selectedRouteFromParent?: Route | null; + routes?: Route[]; } export function GlobalMap3D({ origin, destination, onRouteSelect, - onRoutesCalculated, - selectedRouteFromParent + selectedRouteFromParent, + routes }: GlobalMap3DProps) { - const [landData, setLandData] = useState([]); const [routeData, setRouteData] = useState([]); const [paths, setPaths] = useState({}); const [ships, setShips] = useState([]); @@ -104,19 +103,7 @@ export function GlobalMap3D({ }, [shipSpeed]); useEffect(() => { - // 1. Fetch Land Cells - fetch('/data/land_cells.json') - .then(resp => resp.json()) - .then(data => { - const list = Object.keys(data).map(h => ({ - hex: h, - type: data[h].t, - country: data[h].c - })); - setLandData(list); - }); - - // 2. Fetch Route Cells + // 1. Static Data Fetches fetch('/data/route_cells.json') .then(resp => resp.json()) .then(data => { @@ -127,20 +114,10 @@ export function GlobalMap3D({ setRouteData(list); }); - // 3. Fetch Paths and Init Ships - fetch('/data/paths.json') - .then(resp => resp.json()) - .then(pathData => { - setPaths(pathData); - initShips(pathData); - }); - - // 4. Fetch Labels fetch('/data/country_labels.json') .then(resp => resp.json()) .then(data => setLabels(data)); - // 5. Fetch Straits/Canals fetch('/data/straits_cells.json') .then(resp => resp.json()) .then(data => { @@ -151,12 +128,10 @@ export function GlobalMap3D({ setStraitsData(list); }); - // 6. Fetch Strait Labels fetch('/data/strait_labels.json') .then(resp => resp.json()) .then(data => setStraitLabels(data)); - // 7. Fetch Port Cells fetch('/data/ports_cells.json') .then(resp => resp.json()) .then(data => { @@ -167,12 +142,10 @@ export function GlobalMap3D({ setPortsData(list); }); - // 8. Fetch Port Labels fetch('/data/port_labels.json') .then(resp => resp.json()) .then(data => setPortLabels(data)); - // 9. Fetch All Crisis Zones fetch('/data/all_crisis_zones.json') .then(resp => resp.json()) .then(data => setAllCrisisData(data)); @@ -189,6 +162,31 @@ export function GlobalMap3D({ }; }, []); + // 2. Dynamic Route Calculation — routes are computed once in DemoPage and passed via props + useEffect(() => { + if (routes && routes.length > 0) { + const newPaths: any = {}; + routes.forEach(r => { + newPaths[r.id] = r.waypoints; + }); + setPaths(newPaths); + // Only spawn ships on the SELECTED route if one is selected, else on all + if (selectedRouteFromParent) { + initShips({ [selectedRouteFromParent.id]: selectedRouteFromParent.waypoints }); + } else { + initShips(newPaths); + } + } else { + // Default Mode: No routes from parent — show all global routes + fetch('/data/paths.json') + .then(resp => resp.json()) + .then(pathData => { + setPaths(pathData); + initShips(pathData); + }); + } + }, [routes, selectedRouteFromParent]); + const initShips = (pathData: any) => { const pathKeys = Object.keys(pathData); if (pathKeys.length === 0) return; @@ -252,11 +250,19 @@ export function GlobalMap3D({ const lon = p1[0] + (p2[0] - p1[0]) * ratio; const lat = p1[1] + (p2[1] - p1[1]) * ratio; + // Calculate Bearing for Arrow + const dLon = p2[0] - p1[0]; + const dLat = p2[1] - p1[1]; + const angle = Math.atan2(dLat, dLon); // Radians + + // Convert atan2 angle to deck.gl icon rotation (clockwise degrees from north) + const angleDeg = 90 - (angle * 180 / Math.PI); + return { position: [lon, lat], - pathId: ship.pathId, // Pass route name for crisis coloring - color: [255, 255, 0], - angle: 0 // Optional: compute heading + pathId: ship.pathId, + angle, + angleDeg, }; }).filter(s => s !== null); }; @@ -377,38 +383,31 @@ export function GlobalMap3D({ lineWidthMaxPixels: 1.5, }), - // 3. Shipping Routes (PathLayer - clean lines) - ...Object.entries(paths).map(([routeName, pathCoords]: [string, any]) => - new PathLayer({ + // 3. Shipping Routes (PathLayer) + ...Object.entries(paths).map(([routeName, pathCoords]: [string, any]) => { + const isSelected = selectedRouteFromParent ? selectedRouteFromParent.id === routeName : true; + return new PathLayer({ id: `route-${routeName.replace(/\s+/g, '-')}`, data: [{ path: pathCoords, name: routeName }], getPath: (d: any) => d.path, - getColor: COLOR_ROUTE_DEFAULT, - getWidth: 30000, - widthMinPixels: 1.5, - widthMaxPixels: 4, + getColor: isSelected ? COLOR_ROUTE_ACTIVE : COLOR_ROUTE_DEFAULT, + getWidth: isSelected ? 30000 : 15000, + widthMinPixels: 1, + widthMaxPixels: 3, capRounded: true, jointRounded: true, - pickable: true, - }) - ), - - // 4. Port markers (glow effect) - new ScatterplotLayer({ - id: 'port-glow-layer', - data: portLabels, - pickable: false, - opacity: 0.4, - stroked: false, - filled: true, - radiusScale: 1, - radiusMinPixels: 8, - radiusMaxPixels: 16, - getPosition: (d: any) => d.coordinates, - getFillColor: COLOR_PORT_GLOW, + pickable: true, + // @ts-ignore + onClick: () => { + if (onRouteSelect) { + const routeObj = routes?.find(r => r.id === routeName); + if (routeObj) onRouteSelect(routeObj); + } + } + }); }), - // 4b. Port markers (core dots) + // 4. Port markers new ScatterplotLayer({ id: 'port-markers', data: portLabels, @@ -441,7 +440,7 @@ export function GlobalMap3D({ getFillColor: COLOR_STRAIT, getLineColor: [28, 33, 40, 255], }), - ], [paths, portLabels, straitLabels]); // Dependencies for static layers + ], [paths, portLabels, straitLabels, selectedRouteFromParent, routes, onRouteSelect]); // Dependencies for static layers // Memoize Text Layers separately (depend on labels, labelScale, and currentZoom) const textLayers = useMemo(() => [ @@ -566,6 +565,8 @@ export function GlobalMap3D({ ], [visibleLabels, straitLabels, portLabels, labelScale, currentZoom]); // Dynamic Layers (Recreate on every render mainly due to animTime/ships update) + const currentShipData = getShipData(); + const dynamicLayers = [ // 6. Crisis Zone Overlay (Translucent pulsing area) new H3HexagonLayer({ @@ -598,7 +599,7 @@ export function GlobalMap3D({ // 7. Ships glow effect new ScatterplotLayer({ id: 'ship-glow-layer', - data: getShipData(), + data: currentShipData, pickable: false, opacity: 0.5, stroked: false, @@ -624,35 +625,19 @@ export function GlobalMap3D({ } }), - // 7b. Ships (core markers) + // 7b. Ships (white dots) new ScatterplotLayer({ - id: 'ship-layer', - data: getShipData(), + id: 'ship-dots', + data: currentShipData, pickable: true, opacity: 1, - stroked: true, + stroked: false, filled: true, - radiusScale: 8000 * (shipSize / 5), + radiusScale: 15000 * (shipSize / 5), radiusMinPixels: 2 + shipSize, - radiusMaxPixels: 4 + shipSize * 1.5, - lineWidthMinPixels: 0.5, + radiusMaxPixels: 4 + shipSize * 2, getPosition: (d: any) => d.position, - getFillColor: (d: any) => { - if (d.pathId) { - for (const key of Object.keys(activeCrises)) { - if (activeCrises[key] && allCrisisData[key]) { - const affected = allCrisisData[key].affected_routes || []; - const isAffected = affected.some((r: any) => d.pathId.includes(r)); - if (isAffected) return COLOR_CRISIS; - } - } - } - return COLOR_SHIP; - }, - getLineColor: [28, 33, 40, 200], - updateTriggers: { - getFillColor: [activeCrises, allCrisisData] - } + getFillColor: COLOR_SHIP, }) ]; From af840596dfe9e720fd58b444425ffc32c0386fda Mon Sep 17 00:00:00 2001 From: Timothy Lin <55767165+Deodat-Lawson@users.noreply.github.com> Date: Mon, 9 Feb 2026 10:40:14 -0500 Subject: [PATCH 02/10] Enhance GlobalMap component with improved ship initialization and dynamic route handling. Updated data fetching methods and optimized path rendering for better visual accuracy of ship icons. --- frontend/src/components/AgentCoTPanel.tsx | 18 + frontend/src/components/AgentWorkflow.tsx | 20 + frontend/src/components/CompliancePanel.tsx | 748 ++++++++++++++++++++ frontend/src/components/RouteSelector.tsx | 475 ++++++------- frontend/src/components/VisualRiskPanel.tsx | 26 +- frontend/src/pages/DemoPage.tsx | 157 ++-- frontend/src/utils/routeCalculator.ts | 711 ++++++++++--------- frontend/src/utils/routeData.ts | 272 +++++-- 8 files changed, 1706 insertions(+), 721 deletions(-) create mode 100644 frontend/src/components/CompliancePanel.tsx diff --git a/frontend/src/components/AgentCoTPanel.tsx b/frontend/src/components/AgentCoTPanel.tsx index 5fabef772..3f99bbeef 100644 --- a/frontend/src/components/AgentCoTPanel.tsx +++ b/frontend/src/components/AgentCoTPanel.tsx @@ -129,6 +129,7 @@ interface AgentCoTPanelProps { executionSummary?: ExecutionSummary | null; awaitingConfirmation?: boolean; onConfirmDecision?: (action: string) => void; + selectedRoute?: { name: string; distance: number; estimatedTime: number; riskLevel: string; waypointNames: string[]; description: string } | null; } type TabType = "stream" | "debate" | "decision" | "execution"; @@ -148,6 +149,7 @@ export function AgentCoTPanel({ executionSummary = null, awaitingConfirmation = false, onConfirmDecision, + selectedRoute = null, }: AgentCoTPanelProps) { const [activeTab, setActiveTab] = useState("stream"); const [isCollapsed, setIsCollapsed] = useState(false); @@ -291,6 +293,22 @@ export function AgentCoTPanel({ ))} + {/* Active Route Context */} + {selectedRoute && ( +
+
+
+
+ {selectedRoute.name} +
+
+ {selectedRoute.distance.toLocaleString()} nm + ~{selectedRoute.estimatedTime}d +
+
+
+ )} + {/* Content */}
void; + selectedRoute?: { name: string; distance: number; estimatedTime: number; riskLevel: string; waypointNames: string[]; description: string } | null; } export const AgentWorkflow: React.FC = ({ @@ -53,6 +54,7 @@ export const AgentWorkflow: React.FC = ({ marketSentinelLoading, marketSentinelError, onRunMarketSentinel, + selectedRoute, }) => { const [agents, setAgents] = useState(INITIAL_AGENTS); @@ -216,6 +218,24 @@ export const AgentWorkflow: React.FC = ({ )}
+ {/* Active Route Context */} + {selectedRoute && ( +
+
+
+ Active Route +
+

{selectedRoute.name}

+
+ {selectedRoute.distance.toLocaleString()} nm + ~{selectedRoute.estimatedTime}d + + {selectedRoute.riskLevel} risk + +
+
+ )} + {/* Signal Alert Banner */} {marketSentinelData?.signal_packet && (
= { + China: "CN", Singapore: "SG", Netherlands: "NL", Germany: "DE", + USA: "US", UK: "GB", Belgium: "BE", Spain: "ES", France: "FR", + Italy: "IT", Japan: "JP", "South Korea": "KR", India: "IN", + UAE: "AE", "Saudi Arabia": "SA", Malaysia: "MY", Thailand: "TH", + Vietnam: "VN", Indonesia: "ID", Philippines: "PH", Taiwan: "TW", + Australia: "AU", "New Zealand": "NZ", Brazil: "BR", Mexico: "MX", + Canada: "CA", Argentina: "AR", Chile: "CL", Colombia: "CO", + Peru: "PE", Panama: "PA", Egypt: "EG", Turkey: "TR", + "South Africa": "ZA", Kenya: "KE", Morocco: "MA", Nigeria: "NG", + Ghana: "GH", "Ivory Coast": "CI", Senegal: "SN", Tanzania: "TZ", + Djibouti: "DJ", Oman: "OM", Kuwait: "KW", Israel: "IL", + Greece: "GR", Poland: "PL", Sweden: "SE", Denmark: "DK", + Finland: "FI", Norway: "NO", Estonia: "EE", Latvia: "LV", + Russia: "RU", Ukraine: "UA", Romania: "RO", Ireland: "IE", + Portugal: "PT", Bangladesh: "BD", Pakistan: "PK", "Sri Lanka": "LK", + Malta: "MT", Jamaica: "JM", Bahamas: "BS", "Puerto Rico": "PR", + Uruguay: "UY", Ecuador: "EC", Iran: "IR", Mauritius: "MU", +}; + +interface PortEntry { + id: number; + name: string; + country: string; + region: string; + un_locode: string; + latitude: number; + longitude: number; +} + +function buildPortEntries(): PortEntry[] { + return MAJOR_PORTS.map((port, idx) => { + const cc = COUNTRY_CODE_MAP[port.country] || port.country.substring(0, 2).toUpperCase(); + const pc = port.name.replace(/\s+/g, "").substring(0, 3).toUpperCase(); + return { + id: idx + 1, + name: port.name, + country: port.country, + region: port.region, + un_locode: `${cc}${pc}`, + latitude: port.coordinates[1], + longitude: port.coordinates[0], + }; + }); +} + +// ---------- component ---------- + +export interface CompliancePanelProps { + originPort?: GlobalPort | null; + destinationPort?: GlobalPort | null; + activeMapRoute?: { name: string; distance: number; estimatedTime: number; riskLevel: string; waypointNames: string[]; description: string } | null; +} + +export function CompliancePanel({ originPort, destinationPort, activeMapRoute }: CompliancePanelProps) { + const { user } = useUser(); + + // identity + const [customerId, setCustomerId] = useState(null); + const [vesselId, setVesselId] = useState(null); + + // routes & docs + const [vesselRoutes, setVesselRoutes] = useState([]); + const [selectedRoute, setSelectedRoute] = useState(null); + const [vesselDocuments, setVesselDocuments] = useState([]); + + // port selector + const [selectedRoutePorts, setSelectedRoutePorts] = useState([]); + const [portSearchQuery, setPortSearchQuery] = useState(""); + const [showPortDropdown, setShowPortDropdown] = useState(false); + + // route creation + const [newRouteName, setNewRouteName] = useState(""); + const [isCreatingRoute, setIsCreatingRoute] = useState(false); + + // analysis + const [isAnalyzing, setIsAnalyzing] = useState(false); + const [analysisError, setAnalysisError] = useState(null); + const [missingDocsResult, setMissingDocsResult] = useState(null); + + // loading state + const [isLoading, setIsLoading] = useState(false); + + // upload state + const [isUploadModalOpen, setIsUploadModalOpen] = useState(false); + const [uploadFile, setUploadFile] = useState(null); + const [uploadDescription, setUploadDescription] = useState(""); + const [isUploading, setIsUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(0); + const fileInputRef = useRef(null); + + const allPorts = useMemo(buildPortEntries, []); + + // ---- provision user ---- + useEffect(() => { + if (!user) return; + const provision = async () => { + try { + const res = await documentAPI.provisionUser({ + clerk_id: user.id, + email: user.primaryEmailAddress?.emailAddress || "", + name: user.fullName || undefined, + }); + setCustomerId(res.customer_id); + if (res.vessel_id) setVesselId(res.vessel_id); + } catch { + // fallback + setCustomerId(1); + setVesselId(1); + } + }; + provision(); + }, [user]); + + // ---- pre-populate ports from DemoPage origin/destination ---- + useEffect(() => { + if (selectedRoutePorts.length > 0) return; // user already picked ports + const initial: PortEntry[] = []; + if (originPort) { + const found = allPorts.find((p) => p.name === originPort.name); + if (found) initial.push(found); + } + if (destinationPort) { + const found = allPorts.find((p) => p.name === destinationPort.name); + if (found && !initial.find((p) => p.un_locode === found.un_locode)) initial.push(found); + } + if (initial.length > 0) setSelectedRoutePorts(initial); + }, [originPort, destinationPort]); // eslint-disable-line react-hooks/exhaustive-deps + + // ---- load compliance data once provisioned ---- + useEffect(() => { + if (!customerId) return; + const load = async () => { + setIsLoading(true); + try { + if (vesselId) { + const routes = await documentAPI.getVesselRoutes(vesselId); + setVesselRoutes(routes); + const active = routes.find((r) => r.is_active); + if (active) setSelectedRoute(active); + else if (routes.length > 0) setSelectedRoute(routes[0]); + } + const docs = await documentAPI.getCustomerDocuments(customerId); + setVesselDocuments(docs); + } catch { + // silent + } finally { + setIsLoading(false); + } + }; + load(); + }, [customerId, vesselId]); + + // ---- handlers ---- + + const handleCreateRoute = useCallback(async () => { + if (!newRouteName.trim() || selectedRoutePorts.length === 0 || !vesselId) return; + setIsCreatingRoute(true); + setAnalysisError(null); + try { + const portCodes = selectedRoutePorts.map((p) => p.un_locode); + const created = await documentAPI.createRoute(vesselId, { + route_name: newRouteName, + port_codes: portCodes, + set_active: true, + }); + setVesselRoutes((prev) => [created, ...prev]); + setSelectedRoute(created); + setNewRouteName(""); + } catch { + setAnalysisError("Failed to create route."); + } finally { + setIsCreatingRoute(false); + } + }, [newRouteName, selectedRoutePorts, vesselId]); + + const handleRunAnalysis = useCallback(async () => { + const hasRoute = selectedRoute && selectedRoute.port_codes?.length > 0; + const hasPorts = selectedRoutePorts.length > 0; + if (!customerId || (!hasRoute && !hasPorts)) { + setAnalysisError("Select a route or add ports first."); + return; + } + setIsAnalyzing(true); + setAnalysisError(null); + setMissingDocsResult(null); + try { + const portCodes = hasRoute ? selectedRoute!.port_codes : selectedRoutePorts.map((p) => p.un_locode); + const result = await documentAPI.detectMissingDocuments({ port_codes: portCodes, customer_id: customerId }); + setMissingDocsResult(result); + } catch (err: any) { + setAnalysisError(err?.response?.data?.detail || err?.message || "Analysis failed."); + } finally { + setIsAnalyzing(false); + } + }, [selectedRoute, selectedRoutePorts, customerId]); + + const refreshData = useCallback(async () => { + if (!customerId) return; + setIsLoading(true); + try { + const promises: Promise[] = [documentAPI.getCustomerDocuments(customerId)]; + if (vesselId) promises.unshift(documentAPI.getVesselRoutes(vesselId)); + const results = await Promise.all(promises); + if (vesselId) { + setVesselRoutes(results[0]); + setVesselDocuments(results[1]); + } else { + setVesselDocuments(results[0]); + } + } catch { + // silent + } finally { + setIsLoading(false); + } + }, [customerId, vesselId]); + + const handleFileSelect = (e: React.ChangeEvent) => { + if (e.target.files && e.target.files[0]) { + setUploadFile(e.target.files[0]); + setUploadDescription(""); + // setIsUploadModalOpen(true); // Modal should already be open if triggered from button + e.target.value = ""; + } + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + const file = e.dataTransfer.files?.[0]; + if (file) { + setUploadFile(file); + setUploadDescription(""); + } + }; + + const handleUploadConfirm = async () => { + if (!uploadFile || !customerId || !vesselId) return; + setIsUploading(true); + setUploadProgress(0); + setAnalysisError(null); + + // Progress simulation + const interval = setInterval(() => { + setUploadProgress((prev) => { + if (prev >= 90) { + clearInterval(interval); + return 90; + } + return prev + 10; + }); + }, 300); + + try { + await documentAPI.uploadDocument({ + customer_id: customerId, + vessel_id: vesselId, + document_type: "User Upload", + title: uploadDescription.trim() || uploadFile.name, + file: uploadFile, + }); + + setUploadProgress(100); + clearInterval(interval); + + // Short delay before closing + await new Promise((resolve) => setTimeout(resolve, 800)); + + setIsUploadModalOpen(false); + setUploadFile(null); + setUploadDescription(""); + refreshData(); + } catch (err) { + console.error("Upload failed", err); + clearInterval(interval); + setAnalysisError("Failed to upload document."); + setIsUploading(false); // Stop uploading state on error so user can retry + setUploadProgress(0); + } finally { + // setIsUploading(false); // Only set false on error or close + } + }; + + // ---- derived ---- + + const validCount = vesselDocuments.filter((d) => { + if (!d.expiry_date) return true; + return new Date(d.expiry_date) > new Date(); + }).length; + + const expiringCount = vesselDocuments.filter((d) => { + if (!d.expiry_date) return false; + const days = Math.ceil((new Date(d.expiry_date).getTime() - Date.now()) / 86_400_000); + return days > 0 && days <= 30; + }).length; + + const expiredCount = vesselDocuments.filter((d) => { + if (!d.expiry_date) return false; + return new Date(d.expiry_date) <= new Date(); + }).length; + + // filtered ports for dropdown + const filteredPorts = useMemo(() => { + const q = portSearchQuery.toLowerCase(); + return allPorts + .filter((p) => { + if (!q) return true; + return p.name.toLowerCase().includes(q) || p.country.toLowerCase().includes(q) || p.un_locode.toLowerCase().includes(q); + }) + .filter((p) => !selectedRoutePorts.find((sp) => sp.un_locode === p.un_locode)) + .slice(0, 12); + }, [allPorts, portSearchQuery, selectedRoutePorts]); + + // ---- render ---- + + return ( +
+ {/* Header row */} +
+ Compliance +
+ + + +
+
+ + {/* ---- Active Route Context ---- */} + {activeMapRoute && ( +
+
+ + Active Route +
+

{activeMapRoute.name}

+
+ {activeMapRoute.distance.toLocaleString()} nm + ~{activeMapRoute.estimatedTime}d + + {activeMapRoute.riskLevel} + +
+ {activeMapRoute.waypointNames.length > 0 && ( +
+ {activeMapRoute.waypointNames.slice(0, 5).map((wp, idx) => ( + + {wp} + {idx < Math.min(activeMapRoute.waypointNames.length, 5) - 1 && } + + ))} + {activeMapRoute.waypointNames.length > 5 && ( + +{activeMapRoute.waypointNames.length - 5} more + )} +
+ )} +
+ )} + + {/* ---- Route Selection ---- */} + {vesselRoutes.length > 0 && ( +
+ + + + {selectedRoute && ( +
+ {selectedRoute.port_codes.map((pc, idx) => ( + + {pc} + {idx < selectedRoute.port_codes.length - 1 && } + + ))} +
+ )} +
+ )} + + {/* ---- Port Selector ---- */} +
+
+ + {selectedRoutePorts.length > 0 && ( + + )} +
+ +
+ { setPortSearchQuery(e.target.value); setShowPortDropdown(true); }} + onFocus={() => setShowPortDropdown(true)} + onBlur={() => setTimeout(() => setShowPortDropdown(false), 200)} + placeholder="Search ports..." + className="w-full bg-[#0a0e1a] border border-white/10 rounded-lg px-3 py-2 text-xs focus:border-blue-500/50 outline-none transition-all placeholder:text-white/20" + /> + {showPortDropdown && filteredPorts.length > 0 && ( +
+ {filteredPorts.map((port) => ( + + ))} +
+ )} +
+ + {/* Selected port chips */} + {selectedRoutePorts.length > 0 && ( +
+ {selectedRoutePorts.map((port, idx) => ( +
+
+ {idx + 1} + {port.name} + {port.un_locode} +
+ +
+ ))} +
+ )} +
+ + {/* ---- Save as Route ---- */} + {vesselId && selectedRoutePorts.length > 0 && ( +
+ setNewRouteName(e.target.value)} + placeholder="Route name (optional, to save)" + className="w-full bg-[#0a0e1a] border border-white/10 rounded-lg px-3 py-2 text-xs focus:border-blue-500/50 outline-none transition-all placeholder:text-white/20" + /> + {newRouteName.trim() && ( + + )} +
+ )} + + {/* ---- Document Summary ---- */} + {vesselDocuments.length > 0 && ( +
+ + + +
+ )} + + {/* ---- Run Analysis Button ---- */} +
+ {analysisError && ( +
+ + {analysisError} +
+ )} + +
+ + {/* ---- Analysis Results ---- */} + {missingDocsResult && ( +
+ +
+ )} + + {/* ---- Upload Modal ---- */} + + {isUploadModalOpen && ( + + + {/* Decorative Elements */} +
+ + + +
+
+ +
+

Upload Document

+

Upload vessel certificates or shipping docs for compliance analysis

+
+ + {!isUploading ? ( +
+ {!uploadFile ? ( +
fileInputRef.current?.click()} + onDrop={handleDrop} + onDragOver={(e) => e.preventDefault()} + className="border-2 border-dashed border-white/10 rounded-2xl p-8 text-center hover:border-blue-500/40 hover:bg-blue-500/5 transition-all cursor-pointer group" + > + +

Drag and Drop Files Here

+

Supports PDF, PNG, JPG (Max 50MB)

+
+ +
+
+ ) : ( +
+
+ +
+

{uploadFile.name}

+

{(uploadFile.size / 1024).toFixed(0)} KB

+
+ +
+ +
+ +