plan/react-rewrite #1
@@ -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}</>;
|
||||
}
|
||||
@@ -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() + <ClientOnly> 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<MarkerStyle, L.IconOptions> = {
|
||||
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<PolylineStyle, L.PolylineOptions> = {
|
||||
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
|
||||
* <ClientOnly> for SSR safety.
|
||||
*/
|
||||
export const MapCanvas: FC<MapCanvasProps> = ({
|
||||
markers,
|
||||
polylines,
|
||||
popups,
|
||||
tileUrl,
|
||||
center = [53, 45],
|
||||
zoom = 5,
|
||||
minZoom = 3,
|
||||
maxZoom = 6,
|
||||
onMarkerClick,
|
||||
className,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<L.Map | null>(null);
|
||||
const markersLayerRef = useRef<L.LayerGroup | null>(null);
|
||||
const polylinesLayerRef = useRef<L.LayerGroup | null>(null);
|
||||
const popupsLayerRef = useRef<L.LayerGroup | null>(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 (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={className}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
data-testid="map-canvas"
|
||||
/>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user