import { useEffect, useRef, useState, useMemo } from 'react'; import L from 'leaflet'; import type { TransactionArc } from '../data/arcData'; import { buildCorridors } from '../data/arcData'; // Leaflet default marker fix (Vite asset pipeline) import iconUrl from 'leaflet/dist/images/marker-icon.png'; import iconShadowUrl from 'leaflet/dist/images/marker-shadow.png'; L.Icon.Default.mergeOptions({ iconUrl, shadowUrl: iconShadowUrl }); interface FlowMapProps { arcs: TransactionArc[]; focusCity: string | null; onCityClick: (city: string | null) => void; } export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) { const containerRef = useRef(null); const mapRef = useRef(null); const [mapReady, setMapReady] = useState(false); const [tick, setTick] = useState(0); // incrémenté sur moveend/zoomend → re-render // Initialisation Leaflet useEffect(() => { if (!containerRef.current || mapRef.current) return; const map = L.map(containerRef.current, { center: [46.8, 2.35], zoom: 6, zoomControl: false, attributionControl: true, }); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap', maxZoom: 18, }).addTo(map); L.control.zoom({ position: 'bottomright' }).addTo(map); mapRef.current = map; setMapReady(true); return () => { map.remove(); mapRef.current = null; setMapReady(false); }; }, []); // Re-projette le SVG à chaque déplacement/zoom useEffect(() => { if (!mapReady || !mapRef.current) return; const onMove = () => setTick(t => t + 1); mapRef.current.on('moveend zoomend', onMove); return () => { mapRef.current?.off('moveend zoomend', onMove); }; }, [mapReady]); // Agrégation en corridors const corridors = useMemo(() => buildCorridors(arcs), [arcs]); // Nœuds de villes (volume entrant + sortant) const cityNodes = useMemo(() => { const map = new Map(); for (const c of corridors) { if (!map.has(c.fromCity)) map.set(c.fromCity, { lat: c.fromLat, lng: c.fromLng, emitted: 0, received: 0 }); map.get(c.fromCity)!.emitted += c.totalVolume; if (!map.has(c.toCity)) map.set(c.toCity, { lat: c.toLat, lng: c.toLng, emitted: 0, received: 0 }); map.get(c.toCity)!.received += c.totalVolume; } return map; }, [corridors]); // Projection SVG (recalculée sur chaque tick, changement d'arcs ou de focusCity) const svgElements = useMemo(() => { const m = mapRef.current; if (!m || !mapReady) return null; const proj = (lat: number, lng: number) => { const p = m.latLngToContainerPoint([lat, lng]); return { x: p.x, y: p.y }; }; const maxVol = Math.max(...corridors.map(c => c.totalVolume), 1); const maxNodeVol = Math.max(...[...cityNodes.values()].map(c => c.emitted + c.received), 1); // ---- Arcs ---- const arcElems = corridors.map((c, idx) => { const p1 = proj(c.fromLat, c.fromLng); const p2 = proj(c.toLat, c.toLng); // Point de contrôle bezier quadratique : décalage perpendiculaire au milieu const mx = (p1.x + p2.x) / 2; const my = (p1.y + p2.y) / 2; const dx = p2.x - p1.x; const dy = p2.y - p1.y; const CURVE = 0.28; const cx = mx - dy * CURVE; const cy = my + dx * CURVE; // Flèche de direction au milieu (t = 0.5) du bezier const t = 0.5; const ax = (1-t)*(1-t)*p1.x + 2*(1-t)*t*cx + t*t*p2.x; const ay = (1-t)*(1-t)*p1.y + 2*(1-t)*t*cy + t*t*p2.y; const tln = Math.sqrt(dx*dx + dy*dy) || 1; const nx = dx / tln; const ny = dy / tln; // tangente normalisée const px = -ny; const py = nx; // perpendiculaire const AR = 5; const arrowPts = [ `${ax + nx*AR},${ay + ny*AR}`, `${ax - nx*AR*0.6 + px*AR*0.5},${ay - ny*AR*0.6 + py*AR*0.5}`, `${ax - nx*AR*0.6 - px*AR*0.5},${ay - ny*AR*0.6 - py*AR*0.5}`, ].join(' '); const ratio = c.totalVolume / maxVol; const strokeW = Math.max(1, 1.5 + Math.log1p(c.totalVolume) * 0.8); const opacity = 0.35 + 0.55 * ratio; // Couleur selon focusCity const isFocusFrom = focusCity && c.fromCity === focusCity; const isFocusTo = focusCity && c.toCity === focusCity; const stroke = !focusCity ? `url(#grad${idx})` : isFocusFrom ? '#ff8f00' : isFocusTo ? '#00acc1' : '#2e2f3a'; const arrowFill = !focusCity ? '#e65100' : isFocusFrom ? '#ff8f00' : isFocusTo ? '#00acc1' : '#2e2f3a'; return { idx, c, p1, p2, cx, cy, arrowPts, strokeW, opacity, stroke, arrowFill, path: `M ${p1.x},${p1.y} Q ${cx},${cy} ${p2.x},${p2.y}`, }; }); // ---- Nœuds ---- const nodeElems = [...cityNodes.entries()].map(([name, data]) => { const p = proj(data.lat, data.lng); const vol = data.emitted + data.received; const r = Math.max(3, Math.min(14, 3 + 9 * (vol / maxNodeVol))); return { name, p, r, isSelected: focusCity === name }; }); return { arcElems, nodeElems }; // tick en dep pour re-projeter sur pan/zoom // eslint-disable-next-line react-hooks/exhaustive-deps }, [corridors, cityNodes, focusCity, tick, mapReady]); return (
{mapReady && svgElements && ( {/* Dégradés or→orange pour les arcs (aucune ville sélectionnée) */} {svgElements.arcElems.map(a => ( ))} {/* Arcs bezier */} {svgElements.arcElems.map(a => ( ))} {/* Nœuds de villes (pointer-events activés uniquement ici) */} {svgElements.nodeElems.map(node => ( onCityClick(focusCity === node.name ? null : node.name)} /> ))} )}
); }