plan/react-rewrite #1

Merged
gnezim merged 138 commits from plan/react-rewrite into main 2026-04-15 12:21:16 +03:00
2 changed files with 325 additions and 0 deletions
Showing only changes of commit dc030aceea - Show all commits
@@ -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"
/>
);
};