diff --git a/src/features/flights-map/components/ClientOnly.tsx b/src/features/flights-map/components/ClientOnly.tsx new file mode 100644 index 00000000..f6207142 --- /dev/null +++ b/src/features/flights-map/components/ClientOnly.tsx @@ -0,0 +1,29 @@ +/** + * SSR-safe wrapper that renders children only on the client. + * + * Returns `null` during SSR. After hydration, renders children. + * Used to wrap components that depend on browser APIs (e.g. Leaflet). + * + * @module + */ + +import { useState, useEffect, type ReactNode } from "react"; + +export interface ClientOnlyProps { + children: ReactNode; + fallback?: ReactNode; +} + +/** + * Renders children only after component has mounted in the browser. + * Returns `fallback` (default: null) during SSR and initial render. + */ +export function ClientOnly({ children, fallback = null }: ClientOnlyProps): JSX.Element { + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + return <>{mounted ? children : fallback}; +} diff --git a/src/features/flights-map/components/MapCanvas.tsx b/src/features/flights-map/components/MapCanvas.tsx new file mode 100644 index 00000000..d88836e9 --- /dev/null +++ b/src/features/flights-map/components/MapCanvas.tsx @@ -0,0 +1,296 @@ +/** + * Leaflet map wrapper for the Flights Map feature. + * + * **This is the ONLY file in the codebase that imports `leaflet`.** + * Per design spec, all Leaflet usage is encapsulated here. + * + * Wrapped in React.lazy() + by consumers for SSR safety. + * Accepts markers, polylines, popups as props -- does not own state. + * + * @module + */ + +import { useRef, useEffect, useCallback, type FC } from "react"; +import L from "leaflet"; +import "leaflet/dist/leaflet.css"; +import type { IMapMarker, IMapPolyline, IMapPopup, MarkerStyle, PolylineStyle } from "../types.js"; + +// --------------------------------------------------------------------------- +// Marker icons +// --------------------------------------------------------------------------- + +const MARKER_ICONS: Record = { + blue: { + iconUrl: "/assets/img/leaflet/marker-blue.png", + iconSize: [15, 15] as L.PointExpression, + iconAnchor: [7, 7] as L.PointExpression, + popupAnchor: [0, -10] as L.PointExpression, + }, + "blue-small": { + iconUrl: "/assets/img/leaflet/marker-blue-small.png", + iconSize: [11, 11] as L.PointExpression, + iconAnchor: [5, 5] as L.PointExpression, + popupAnchor: [0, -10] as L.PointExpression, + }, + orange: { + iconUrl: "/assets/img/leaflet/marker-orange.png", + iconSize: [20, 20] as L.PointExpression, + iconAnchor: [10, 10] as L.PointExpression, + popupAnchor: [0, -20] as L.PointExpression, + }, +}; + +function getIcon(style: MarkerStyle): L.Icon { + const opts = MARKER_ICONS[style]; + return L.icon(opts); +} + +// --------------------------------------------------------------------------- +// Polyline styles +// --------------------------------------------------------------------------- + +const POLYLINE_STYLES: Record = { + direct: { + color: "#2457ff", + weight: 1, + opacity: 1, + }, + connecting: { + color: "#2433ff", + weight: 1, + opacity: 1, + dashArray: "4 14", + }, +}; + +// --------------------------------------------------------------------------- +// Great-circle arc calculation +// --------------------------------------------------------------------------- + +function deg2rad(deg: number): number { + return (deg * Math.PI) / 180; +} + +function rad2deg(rad: number): number { + return (rad * 180) / Math.PI; +} + +function buildGreatCircleArc( + from: L.LatLng, + to: L.LatLng, + segments = 64, +): L.LatLng[] { + const phi1 = deg2rad(from.lat); + const lam1 = deg2rad(from.lng); + const phi2 = deg2rad(to.lat); + const lam2 = deg2rad(to.lng); + + const delta = + 2 * + Math.asin( + Math.sqrt( + Math.sin((phi2 - phi1) / 2) ** 2 + + Math.cos(phi1) * + Math.cos(phi2) * + Math.sin((lam2 - lam1) / 2) ** 2, + ), + ); + + if (delta === 0) return [from, to]; + + const points: L.LatLng[] = []; + + for (let i = 0; i <= segments; i++) { + const f = i / segments; + const A = Math.sin((1 - f) * delta) / Math.sin(delta); + const B = Math.sin(f * delta) / Math.sin(delta); + + const x = + A * Math.cos(phi1) * Math.cos(lam1) + + B * Math.cos(phi2) * Math.cos(lam2); + const y = + A * Math.cos(phi1) * Math.sin(lam1) + + B * Math.cos(phi2) * Math.sin(lam2); + const z = A * Math.sin(phi1) + B * Math.sin(phi2); + + const lat = rad2deg(Math.atan2(z, Math.sqrt(x * x + y * y))); + const lng = rad2deg(Math.atan2(y, x)); + + points.push(L.latLng(lat, lng)); + } + + return points; +} + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- + +export interface MapCanvasProps { + markers: IMapMarker[]; + polylines: IMapPolyline[]; + popups?: IMapPopup[]; + tileUrl: string; + center?: [number, number]; + zoom?: number; + minZoom?: number; + maxZoom?: number; + onMarkerClick?: (markerId: string) => void; + className?: string; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +/** + * Leaflet map canvas. Must be loaded via React.lazy() and wrapped in + * for SSR safety. + */ +export const MapCanvas: FC = ({ + markers, + polylines, + popups, + tileUrl, + center = [53, 45], + zoom = 5, + minZoom = 3, + maxZoom = 6, + onMarkerClick, + className, +}) => { + const containerRef = useRef(null); + const mapRef = useRef(null); + const markersLayerRef = useRef(null); + const polylinesLayerRef = useRef(null); + const popupsLayerRef = useRef(null); + + const onMarkerClickRef = useRef(onMarkerClick); + onMarkerClickRef.current = onMarkerClick; + + // --- Initialize map --- + useEffect(() => { + if (!containerRef.current || mapRef.current) return; + + const southWest: L.LatLngExpression = [-70, -185]; + const northEast: L.LatLngExpression = [80, 200]; + + const map = L.map(containerRef.current, { + center, + zoom, + attributionControl: false, + maxBounds: [southWest, northEast], + maxBoundsViscosity: 1, + }); + + L.tileLayer(tileUrl, { + maxZoom, + minZoom, + }).addTo(map); + + markersLayerRef.current = L.layerGroup().addTo(map); + polylinesLayerRef.current = L.layerGroup().addTo(map); + popupsLayerRef.current = L.layerGroup().addTo(map); + + mapRef.current = map; + + return () => { + map.remove(); + mapRef.current = null; + markersLayerRef.current = null; + polylinesLayerRef.current = null; + popupsLayerRef.current = null; + }; + // Only run once on mount + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // --- Sync markers --- + const syncMarkers = useCallback(() => { + const layer = markersLayerRef.current; + if (!layer) return; + + layer.clearLayers(); + + for (const m of markers) { + const marker = L.marker([m.lat, m.lng], { + icon: getIcon(m.style), + title: m.id, + }); + + if (m.label) { + marker.bindTooltip(m.label, { + permanent: m.tooltipPermanent ?? false, + direction: "top", + className: "city-label", + }); + } + + marker.on("click", () => { + onMarkerClickRef.current?.(m.id); + }); + + marker.addTo(layer); + } + }, [markers]); + + useEffect(() => { + syncMarkers(); + }, [syncMarkers]); + + // --- Sync polylines --- + useEffect(() => { + const layer = polylinesLayerRef.current; + if (!layer) return; + + layer.clearLayers(); + + for (const pl of polylines) { + const style = POLYLINE_STYLES[pl.style]; + + // Build great-circle arcs between consecutive points + const arcPoints: L.LatLng[] = []; + for (let i = 0; i < pl.points.length - 1; i++) { + const from = L.latLng(pl.points[i]!.lat, pl.points[i]!.lng); + const to = L.latLng(pl.points[i + 1]!.lat, pl.points[i + 1]!.lng); + const arc = buildGreatCircleArc(from, to); + arcPoints.push(...(i === 0 ? arc : arc.slice(1))); + } + + if (arcPoints.length >= 2) { + L.polyline(arcPoints, style).addTo(layer); + } + } + }, [polylines]); + + // --- Sync popups --- + useEffect(() => { + const layer = popupsLayerRef.current; + const map = mapRef.current; + if (!layer || !map) return; + + layer.clearLayers(); + + if (!popups) return; + + for (const p of popups) { + L.popup({ + closeButton: true, + autoClose: false, + closeOnClick: false, + }) + .setLatLng([p.lat, p.lng]) + .setContent(p.content) + .addTo(layer); + } + }, [popups]); + + return ( +
+ ); +};