97ff22027c
ci/woodpecker/push/woodpecker Pipeline was successful
- Nouveau type TransactionArc + buildCorridors + computeFlowStats - FlowMap : SVG overlay Leaflet, arcs bezier, flèches de direction, nœuds de villes cliquables - Clic sur une ville : arcs sortants orange, entrants teal, reste grisé - DataService : résolution géo des destinataires (toId) dans le même appel Cesium+ - useAnimation : expose visibleArcs filtré par frame - PeriodSelector : bouton toggle Heatmap / Flux - StatsPanel : stats flux (volume, top émetteurs, top récepteurs, balance nette) - App : state viewMode + focusCity, FlowMap conditionnel Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
210 lines
7.5 KiB
TypeScript
210 lines
7.5 KiB
TypeScript
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<HTMLDivElement>(null);
|
|
const mapRef = useRef<L.Map | null>(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: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
|
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<string, { lat: number; lng: number; emitted: number; received: number }>();
|
|
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 (
|
|
<div className="w-full h-full relative" style={{ minHeight: 0 }}>
|
|
<div ref={containerRef} className="absolute inset-0" />
|
|
|
|
{mapReady && svgElements && (
|
|
<svg
|
|
className="absolute inset-0 w-full h-full"
|
|
style={{ zIndex: 500, pointerEvents: 'none' }}
|
|
>
|
|
{/* Dégradés or→orange pour les arcs (aucune ville sélectionnée) */}
|
|
<defs>
|
|
{svgElements.arcElems.map(a => (
|
|
<linearGradient
|
|
key={`grad${a.idx}`}
|
|
id={`grad${a.idx}`}
|
|
x1={a.p1.x} y1={a.p1.y}
|
|
x2={a.p2.x} y2={a.p2.y}
|
|
gradientUnits="userSpaceOnUse"
|
|
>
|
|
<stop offset="0%" stopColor="#d4a843" />
|
|
<stop offset="100%" stopColor="#e65100" />
|
|
</linearGradient>
|
|
))}
|
|
</defs>
|
|
|
|
{/* Arcs bezier */}
|
|
{svgElements.arcElems.map(a => (
|
|
<g key={`${a.c.fromCity}-${a.c.toCity}`} opacity={a.opacity}>
|
|
<path
|
|
d={a.path}
|
|
fill="none"
|
|
stroke={a.stroke}
|
|
strokeWidth={a.strokeW}
|
|
strokeLinecap="round"
|
|
/>
|
|
<polygon points={a.arrowPts} fill={a.arrowFill} />
|
|
</g>
|
|
))}
|
|
|
|
{/* Nœuds de villes (pointer-events activés uniquement ici) */}
|
|
<g style={{ pointerEvents: 'all' }}>
|
|
{svgElements.nodeElems.map(node => (
|
|
<circle
|
|
key={node.name}
|
|
cx={node.p.x}
|
|
cy={node.p.y}
|
|
r={node.r}
|
|
fill={node.isSelected ? '#ffffff' : '#d4a843'}
|
|
stroke={node.isSelected ? '#d4a843' : '#0a0b0f'}
|
|
strokeWidth={1.5}
|
|
style={{ cursor: 'pointer' }}
|
|
onClick={() => onCityClick(focusCity === node.name ? null : node.name)}
|
|
/>
|
|
))}
|
|
</g>
|
|
</svg>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|