'use client' /** * VesselTrackingMap - Interactive map showing vessel position and trace. * * Features: * - Real-time vessel position with heading-rotated ship icon * - Trace polyline limited to viewport bounds (debounced fetch) * - Vessel info overlay with status and destination * - Auto-refresh vessel position every 30 seconds */ import * as React from 'react' import { useState, useCallback, useMemo, useEffect } from 'react' import { GoogleMap, useJsApiLoader, MarkerF, PolylineF, CircleF, InfoWindowF } from '@react-google-maps/api' import { Ship, Navigation, Anchor, MapPin, Clock, Building2, Ruler, Info, X } from 'lucide-react' import { useVesselTracking } from '../hooks/useVesselTracking' import { Spinner } from '@freighttech/ui/primitives/spinner' import { useT, useLocale } from '@open-mercato/shared/lib/i18n/context' import type { VesselTrackingStatus } from '../lib/current-vessel' import type { VesselInfo, PoiInfo, PoiType } from '../lib/vessel-api' // ─── Types ─────────────────────────────────────────────────── // eslint-disable-next-line @typescript-eslint/no-explicit-any type GoogleMapInstance = any export interface VesselTrackingMapProps { /** Vessel IMO number */ vesselImo: string | null | undefined /** Vessel name (fallback if API doesn't return name) */ vesselName?: string | null /** Map height (CSS value, default: '250px') */ height?: string /** Auto-refresh vessel position (default: true) */ autoRefresh?: boolean /** Vessel refresh interval in ms (default: 30000) */ refreshInterval?: number /** Show vessel info overlay (default: true) */ showInfoOverlay?: boolean /** Show points of interest (ports/terminals/waypoints) as clickable circles (default: true) */ showPois?: boolean /** Container tracking status - affects display messaging */ containerStatus?: VesselTrackingStatus /** Port where container currently is (for at_port/not_departed status) */ currentPort?: string /** Whether this is a planned vessel (not yet carrying the container) */ isPlannedVessel?: boolean /** Current leg info (for in_transit status) */ currentLeg?: { fromPort: string toPort: string } /** Start date for trace (ISO 8601) - limits trace to current leg */ traceFrom?: string } // ─── Map Configuration ─────────────────────────────────────── const containerStyle: React.CSSProperties = { width: '100%', height: '100%', } // Map options - typed as Record to avoid google namespace dependency at compile time const mapOptions: Record = { zoomControl: true, mapTypeControl: false, scaleControl: false, streetViewControl: false, rotateControl: false, fullscreenControl: false, disableDefaultUI: false, gestureHandling: 'cooperative', styles: [ { featureType: 'water', elementType: 'geometry', stylers: [{ color: '#bfdbfe' }], // Light blue for water }, { featureType: 'landscape', elementType: 'geometry', stylers: [{ color: '#f5f3ff' }], // Light purple for land }, { featureType: 'road', elementType: 'geometry', stylers: [{ color: '#e0e7ff' }], // Light indigo for roads }, ], } // Default center: Baltic Sea const defaultCenter = { lat: 54.5, lng: 18.5 } // Below this zoom the viewport is too large — too many POIs to be useful or performant, // so we skip fetching/drawing them entirely (keeps the map uncluttered when zoomed out). const MIN_POI_ZOOM = 6 // Subtle per-type colors for POI circles. Kept faint (low opacity) so they don't // compete with the vessel marker and trace. const POI_COLORS: Record = { PORT: '#2563eb', // blue TERMINAL: '#059669', // emerald WAYPOINT: '#d97706', // amber PORT_GEOMETRIC_CENTER: '#64748b', // slate } // ─── Helpers ───────────────────────────────────────────────── /** * Resolve a POI's human-friendly primary name, following the same per-type fallback the * backend uses: terminals prefer the terminal name, ports prefer the city, waypoints use * the localized maritime region. Falls back to LOCODE / code when nothing else is set. */ function getPoiPrimaryName(poi: PoiInfo, locale: string): string { if (poi.type === 'TERMINAL') { return poi.terminalName || poi.portName || poi.city || poi.locode || poi.code } if (poi.type === 'PORT' || poi.type === 'PORT_GEOMETRIC_CENTER') { return poi.city || poi.portName || poi.locode || poi.code } // WAYPOINT — localized region name const region = locale === 'pl' ? (poi.regionNamePl || poi.regionNameEn) : (poi.regionNameEn || poi.regionNamePl) return region || poi.locode || poi.code } function getPoiTypeLabel(type: PoiType, t: ReturnType): string { switch (type) { case 'PORT': return t('shipment_tracking.map.poi.port', 'Port') case 'TERMINAL': return t('shipment_tracking.map.poi.terminal', 'Terminal') case 'WAYPOINT': return t('shipment_tracking.map.poi.waypoint', 'Waypoint') case 'PORT_GEOMETRIC_CENTER': return t('shipment_tracking.map.poi.portCenter', 'Port center') default: return type } } /** * Calculate vessel dimensions from AIS dimension fields. * a = bow to reference, b = reference to stern → length = a + b * c = port to reference, d = reference to starboard → width = c + d */ function calculateVesselDimensions(vessel: VesselInfo | null | undefined): { length: number width: number hasValidDimensions: boolean } { const dims = vessel?.dimensions if (!dims) { return { length: 0, width: 0, hasValidDimensions: false } } const length = (dims.a ?? 0) + (dims.b ?? 0) const width = (dims.c ?? 0) + (dims.d ?? 0) // Only consider valid if both length and width are positive const hasValidDimensions = length > 0 && width > 0 return { length, width, hasValidDimensions } } /** * Format relative time for last update (e.g., "2 min ago", "1 hour ago"). */ function formatRelativeTime(dateString: string | null | undefined, t: ReturnType): string { if (!dateString) return '' const date = new Date(dateString) if (Number.isNaN(date.getTime())) return '' const now = new Date() const diffMs = now.getTime() - date.getTime() const diffMinutes = Math.floor(diffMs / 60000) const diffHours = Math.floor(diffMinutes / 60) const diffDays = Math.floor(diffHours / 24) if (diffMinutes < 1) { return t('shipment_tracking.map.justNow', 'Just now') } if (diffMinutes < 60) { return t('shipment_tracking.map.minutesAgo', '{{count}} min ago', { count: diffMinutes }) } if (diffHours < 24) { return t('shipment_tracking.map.hoursAgo', '{{count}}h ago', { count: diffHours }) } return t('shipment_tracking.map.daysAgo', '{{count}}d ago', { count: diffDays }) } // ─── Vessel Info Card ──────────────────────────────────────── interface VesselInfoCardProps { vessel: VesselInfo | null | undefined displayName: string isPlannedVessel: boolean containerStatus?: VesselTrackingStatus currentPort?: string currentLeg?: { fromPort: string; toPort: string } t: ReturnType } function VesselInfoCard({ vessel, displayName, isPlannedVessel, containerStatus, currentPort, currentLeg, t, }: VesselInfoCardProps) { const { length, width, hasValidDimensions } = calculateVesselDimensions(vessel) const lastUpdate = formatRelativeTime(vessel?.updatedAt, t) // Determine vessel status display const getVesselStatusDisplay = () => { if (vessel?.status === 'IN_PORT') { return { icon: , text: vessel.currentPort?.name || t('shipment_tracking.map.inPort', 'In Port'), color: 'text-green-600 dark:text-green-400', } } if (vessel?.status === 'AT_SEA') { const destination = vessel.lastDestinationPort?.name || vessel.lastDestination return { icon: , text: destination ? t('shipment_tracking.map.atSeaTo', 'At Sea -> {{destination}}', { destination }) : t('shipment_tracking.map.atSea', 'At Sea'), color: 'text-blue-600 dark:text-blue-400', } } return { icon: , text: t('shipment_tracking.map.unknown', 'Unknown'), color: 'text-muted-foreground', } } const statusDisplay = getVesselStatusDisplay() return (
{/* Header: Vessel name + planned badge */}

{displayName}

{isPlannedVessel && ( {t('shipment_tracking.map.planned', 'Planned')} )}
{/* IMO number */} {vessel?.imo && (
IMO {vessel.imo}
)} {/* Vessel details section */}
{/* Status row */}
{t('shipment_tracking.map.status', 'Status')}
{statusDisplay.icon} {statusDisplay.text}
{/* Ship type row */} {vessel?.shipType && (
{t('shipment_tracking.map.type', 'Type')} {vessel.shipType}
)} {/* Size row */} {hasValidDimensions && (
{t('shipment_tracking.map.size', 'Size')}
{length}m x {width}m
)} {/* Operator row */} {vessel?.operatorName && (
{t('shipment_tracking.map.operator', 'Operator')}
{vessel.operatorName}
)} {/* Last update row */} {lastUpdate && (
{t('shipment_tracking.map.updated', 'Updated')}
{lastUpdate}
)}
{/* Container status section (when container tracking is active) */} {containerStatus && containerStatus !== 'delivered' && (
{containerStatus === 'in_transit' && currentLeg ? (
{t('shipment_tracking.map.inTransitTo', 'In transit to {{destination}}', { destination: currentLeg.toPort, })}
) : containerStatus === 'at_port' && currentPort ? (
{t('shipment_tracking.map.containerAtPort', 'Container at {{port}}', { port: currentPort, })}
{isPlannedVessel && (
{t('shipment_tracking.map.awaitingVessel', 'Awaiting this vessel')}
)}
) : containerStatus === 'not_departed' ? (
{t('shipment_tracking.map.awaitingDeparture', 'Awaiting departure')}
) : null}
)}
) } // ─── Main Component ────────────────────────────────────────── export function VesselTrackingMap({ vesselImo, vesselName, height = '250px', autoRefresh = true, refreshInterval = 30_000, showInfoOverlay = true, showPois = true, containerStatus, currentPort, isPlannedVessel = false, currentLeg, traceFrom, }: VesselTrackingMapProps) { const t = useT() const locale = useLocale() // Load Google Maps API const { isLoaded, loadError } = useJsApiLoader({ id: 'google-map-script', googleMapsApiKey: process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || '', }) // Map instance reference const [map, setMap] = useState(null) // Current zoom (gates POI fetching) and the POI selected for the info window const [zoom, setZoom] = useState(8) const [selectedPoi, setSelectedPoi] = useState(null) // Only fetch POIs once zoomed in enough to keep the count and clutter manageable const enablePois = showPois && zoom >= MIN_POI_ZOOM // Drop the details popup when POIs are hidden (e.g. zoomed out) to avoid an orphan window useEffect(() => { if (!enablePois) setSelectedPoi(null) }, [enablePois]) // Vessel tracking with debounced bounds const { vessel, isVesselLoading, vesselError, trace, isTraceLoading, isDebouncing, setBounds, pois, } = useVesselTracking(vesselImo, { autoRefresh, vesselRefreshInterval: refreshInterval, boundsDebounceDelay: 500, traceLimit: 5000, traceFrom, enablePois, }) // Handle map load const onMapLoad = useCallback((mapInstance: GoogleMapInstance) => { setMap(mapInstance) }, []) // Handle map idle (fires after load and after pan/zoom completes) const handleMapIdle = useCallback(() => { if (!map) return const bounds = map.getBounds() if (!bounds) return // Track zoom so POI fetching can be gated on it const currentZoom = map.getZoom() if (typeof currentZoom === 'number') setZoom(currentZoom) const ne = bounds.getNorthEast() const sw = bounds.getSouthWest() // Add fixed margin (~30-35km) to fetch trace points outside visible viewport // This ensures smooth curves at map edges instead of straight lines const MARGIN_DEGREES = 0.3 setBounds({ north: Math.min(90, ne.lat() + MARGIN_DEGREES), south: Math.max(-90, sw.lat() - MARGIN_DEGREES), east: Math.min(180, ne.lng() + MARGIN_DEGREES), west: Math.max(-180, sw.lng() - MARGIN_DEGREES), }) }, [map, setBounds]) // Create container ship icon with heading rotation // Ship silhouette viewed from above: pointed bow (top), wide stern (bottom), bridge at rear const shipIcon = useMemo(() => { if (!isLoaded || typeof window === 'undefined') return undefined // eslint-disable-next-line @typescript-eslint/no-explicit-any const google = (window as any).google if (!google) return undefined const heading = vessel?.lastHeading ?? 0 // Container ship silhouette (top-down view, pointing up/north) // Elongated shape with slightly pointed bow - typical container ship proportions // Features: tapered bow (not too sharp), narrow hull, bridge at stern const containerShipPath = ` M 12 2 L 8 7 L 8 30 Q 8 32, 10 32 L 10 34 L 14 34 L 14 32 Q 16 32, 16 30 L 16 7 L 12 2 Z M 10 32 L 10 36 L 14 36 L 14 32 Z ` return { path: containerShipPath, fillColor: '#0f172a', // Dark slate hull fillOpacity: 0.95, strokeColor: '#ffffff', strokeWeight: 1.5, scale: 1.0, rotation: heading, anchor: new google.maps.Point(12, 18), // Center point for rotation } }, [isLoaded, vessel?.lastHeading]) // Factory for the small clickable POI center dot. Markers live in a higher map pane than // circles, so these stay above the translucent circles and are the precise click target — // the circles themselves are non-clickable, so overlapping circles never steal a click. // Pixel-scaled (not meters), so the dot stays a consistent small size at any zoom. const poiDotIcon = useMemo(() => { if (!isLoaded || typeof window === 'undefined') return null // eslint-disable-next-line @typescript-eslint/no-explicit-any const google = (window as any).google if (!google) return null return (color: string, selected: boolean) => ({ path: google.maps.SymbolPath.CIRCLE, scale: selected ? 6 : 4, fillColor: color, fillOpacity: 0.95, strokeColor: '#ffffff', strokeWeight: 1.5, }) }, [isLoaded]) // Options for the POI info window: drop Google's default header row (the empty top bar + // default close button that crowds the content), nudge the bubble just above the center // dot, and cap its width. We render our own close button instead. const infoWindowOptions = useMemo(() => { if (!isLoaded || typeof window === 'undefined') return undefined // eslint-disable-next-line @typescript-eslint/no-explicit-any const google = (window as any).google if (!google) return undefined return { headerDisabled: true, pixelOffset: new google.maps.Size(0, -4), maxWidth: 260, } }, [isLoaded]) // Center map on vessel position const center = useMemo(() => { if (vessel?.lastPosition) { return { lat: vessel.lastPosition.lat, lng: vessel.lastPosition.lng } } return defaultCenter }, [vessel?.lastPosition]) // Filter trace points to only position updates for cleaner polyline const tracePath = useMemo(() => { if (!trace?.trace) return [] return trace.trace .filter((p) => p.lat && p.lng && p.eventType === 'POSITION_UPDATE') .map((p) => ({ lat: p.lat, lng: p.lng })) }, [trace?.trace]) // ─── Loading State ───────────────────────────────────────── if (loadError) { return (

{t('shipment_tracking.map.loadError', 'Failed to load map')}

) } if (!isLoaded || (isVesselLoading && !vessel)) { return (
{t('shipment_tracking.map.loading', 'Loading map...')}
) } // ─── No Vessel Data ──────────────────────────────────────── if (!vessel && !isVesselLoading) { return (

{vesselError ? t('shipment_tracking.map.vesselError', 'Failed to load vessel data') : t('shipment_tracking.map.vesselNotFound', 'Vessel not found')}

) } // ─── Map Render ──────────────────────────────────────────── const displayName = vesselName || vessel?.name || 'Unknown Vessel' return (
{/* Vessel Info Card - 1/3 width */} {showInfoOverlay && (
)} {/* Map - 2/3 width (or full if no info overlay) */}
{/* Vessel Marker */} {vessel?.lastPosition && ( )} {/* Trace Polyline */} {tracePath.length > 1 && ( )} {/* POIs — faint, non-intrusive circles (visual only) plus a small clickable center dot. Making the circles non-clickable means a large translucent circle can never swallow clicks for a small port/terminal sitting inside it; the dot is always the precise, overlap-resistant click target. */} {enablePois && pois?.map((poi) => { const color = POI_COLORS[poi.type] ?? '#64748b' const isSelected = selectedPoi?.code === poi.code return ( setSelectedPoi(poi)} zIndex={isSelected ? 1000 : 500} /> ) })} {/* POI details popup. Position-anchored: rendering InfoWindowF inside MarkerF does NOT inject the marker anchor in this version of @react-google-maps/api, which throws "must provide either an anchor or a position" on open. The pixelOffset in infoWindowOptions lifts the bubble just above the center dot. */} {selectedPoi && ( setSelectedPoi(null)} options={infoWindowOptions} >
{/* Custom close button (Google's default header is disabled) */}
{getPoiTypeLabel(selectedPoi.type, t)}
{getPoiPrimaryName(selectedPoi, locale)}
{selectedPoi.terminalName && selectedPoi.portName && (
{selectedPoi.portName}
)} {(selectedPoi.city || selectedPoi.country) && (
{[selectedPoi.city, selectedPoi.country].filter(Boolean).join(', ')}
)}
{selectedPoi.locode || selectedPoi.code}
)}
{/* Loading Indicator for Trace */} {(isTraceLoading || isDebouncing) && (
{t('shipment_tracking.map.loadingTrace', 'Loading...')}
)}
) } export default VesselTrackingMap