feat: clustering géographique des villes dans la vue Flux
ci/woodpecker/push/woodpecker Pipeline was successful

Regroupe les villes proches visuellement (CLUSTER_RADIUS = 38px) en un
seul nœud dont la couleur reflète la balance nette agrégée du groupe.
Affiche +N à l'intérieur des cercles multi-villes. Les arcs intra-cluster
sont ignorés. Le clustering se recalcule dynamiquement à chaque zoom/pan.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
syoul
2026-03-24 01:20:00 +01:00
parent 78b4762c88
commit b6cb0af722
+146 -43
View File
@@ -20,6 +20,8 @@ const COLOR_NEUTRAL = '#d4a843'; // or Ğ1
const COLOR_NEG = '#ff6d00'; // orange vif
const COLOR_POS = '#00c853'; // vert vif
const NEUTRAL_THRESHOLD = 0.05; // ±5 % → couleur neutre
const CLUSTER_RADIUS = 38; // pixels — distance max pour regrouper deux villes
import type { TransactionArc } from '../data/arcData';
import { buildCorridors } from '../data/arcData';
@@ -91,7 +93,7 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
return map;
}, [corridors]);
// Projection SVG (recalculée sur chaque tick, changement d'arcs ou de focusCity)
// Projection SVG + clustering (recalculée sur chaque tick, changement d'arcs ou de focusCity)
const svgElements = useMemo(() => {
const m = mapRef.current;
if (!m || !mapReady) return null;
@@ -101,16 +103,104 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
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);
const maxAbsNet = Math.max(...[...cityNodes.values()].map(d => Math.abs(d.received - d.emitted)), 1);
// --- 1. Projeter toutes les villes en pixels, triées par volume desc ---
type CityPx = {
name: string; lat: number; lng: number;
x: number; y: number;
emitted: number; received: number; vol: number;
};
const cityList: CityPx[] = [...cityNodes.entries()].map(([name, d]) => {
const p = proj(d.lat, d.lng);
return { name, lat: d.lat, lng: d.lng, x: p.x, y: p.y, emitted: d.emitted, received: d.received, vol: d.emitted + d.received };
}).sort((a, b) => b.vol - a.vol);
// ---- Arcs ----
const arcElems = corridors.map((c, idx) => {
const p1 = proj(c.fromLat, c.fromLng);
const p2 = proj(c.toLat, c.toLng);
// --- 2. Clustering glouton par distance pixel ---
interface Cluster {
cx: number; cy: number; // centroïde pondéré (pixels)
lat: number; lng: number; // centroïde géo (pour debug éventuel)
totalVol: number;
emitted: number; received: number;
cities: Set<string>;
}
const clusters: Cluster[] = [];
const cityClusterIdx = new Map<string, number>(); // nom ville → index cluster
for (const city of cityList) {
let bestIdx = -1;
let bestDist = Infinity;
for (let i = 0; i < clusters.length; i++) {
const cl = clusters[i];
const dx = city.x - cl.cx;
const dy = city.y - cl.cy;
const d = Math.sqrt(dx * dx + dy * dy);
if (d < CLUSTER_RADIUS && d < bestDist) {
bestDist = d;
bestIdx = i;
}
}
if (bestIdx === -1) {
// Nouvelle graine
clusters.push({
cx: city.x, cy: city.y,
lat: city.lat, lng: city.lng,
totalVol: city.vol,
emitted: city.emitted, received: city.received,
cities: new Set([city.name]),
});
cityClusterIdx.set(city.name, clusters.length - 1);
} else {
// Fusionner dans le cluster existant (centroïde pondéré)
const cl = clusters[bestIdx];
const newVol = cl.totalVol + city.vol;
cl.cx = (cl.cx * cl.totalVol + city.x * city.vol) / newVol;
cl.cy = (cl.cy * cl.totalVol + city.y * city.vol) / newVol;
cl.totalVol = newVol;
cl.emitted += city.emitted;
cl.received += city.received;
cl.cities.add(city.name);
cityClusterIdx.set(city.name, bestIdx);
}
}
// --- 3. Agréger les corridors en arcs inter-clusters ---
interface ClusterArc {
fromIdx: number; toIdx: number;
totalVolume: number; count: number;
}
const clArcMap = new Map<string, ClusterArc>();
for (const c of corridors) {
const fi = cityClusterIdx.get(c.fromCity);
const ti = cityClusterIdx.get(c.toCity);
if (fi === undefined || ti === undefined || fi === ti) continue; // intra-cluster → ignoré
const key = `${fi}||${ti}`;
if (!clArcMap.has(key)) clArcMap.set(key, { fromIdx: fi, toIdx: ti, totalVolume: 0, count: 0 });
const ca = clArcMap.get(key)!;
ca.totalVolume += c.totalVolume;
ca.count += c.count;
}
const clusterArcs = [...clArcMap.values()].sort((a, b) => b.totalVolume - a.totalVolume);
// --- 4. Couleur de balance par cluster ---
const maxAbsNet = Math.max(...clusters.map(cl => Math.abs(cl.received - cl.emitted)), 1);
const clusterColors = clusters.map(cl => {
const net = cl.received - cl.emitted;
const t = net / maxAbsNet;
if (Math.abs(t) < NEUTRAL_THRESHOLD) return COLOR_NEUTRAL;
return t < 0
? lerpColor(COLOR_NEUTRAL, COLOR_NEG, -t)
: lerpColor(COLOR_NEUTRAL, COLOR_POS, t);
});
// Cluster de la ville focus
const focusClusterIdx = focusCity !== null ? (cityClusterIdx.get(focusCity) ?? -1) : -1;
// --- 5. Éléments SVG des arcs ---
const maxVol = Math.max(...clusterArcs.map(a => a.totalVolume), 1);
const arcElems = clusterArcs.map((ca, idx) => {
const p1 = { x: clusters[ca.fromIdx].cx, y: clusters[ca.fromIdx].cy };
const p2 = { x: clusters[ca.toIdx].cx, y: clusters[ca.toIdx].cy };
// 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;
@@ -119,13 +209,12 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
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 nx = dx / tln; const ny = dy / tln;
const px = -ny; const py = nx;
const AR = 5;
const arrowPts = [
`${ax + nx*AR},${ay + ny*AR}`,
@@ -133,44 +222,37 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
`${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 ratio = ca.totalVolume / maxVol;
const strokeW = Math.max(1, 1.5 + Math.log1p(ca.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})`
const isFocusFrom = focusClusterIdx !== -1 && ca.fromIdx === focusClusterIdx;
const isFocusTo = focusClusterIdx !== -1 && ca.toIdx === focusClusterIdx;
const stroke = focusClusterIdx === -1 ? `url(#grad${idx})`
: isFocusFrom ? '#ff8f00'
: isFocusTo ? '#00acc1'
: '#2e2f3a';
const arrowFill = !focusCity ? '#e65100'
const arrowFill = focusClusterIdx === -1 ? '#e65100'
: isFocusFrom ? '#ff8f00'
: isFocusTo ? '#00acc1'
: '#2e2f3a';
return {
idx, c, p1, p2, cx, cy, arrowPts, strokeW, opacity, stroke, arrowFill,
idx, ca, 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)));
// Couleur selon balance nette normalisée
const net = data.received - data.emitted;
const t = net / maxAbsNet; // ∈ [-1, 1]
const fillColor = Math.abs(t) < NEUTRAL_THRESHOLD
? COLOR_NEUTRAL
: t < 0
? lerpColor(COLOR_NEUTRAL, COLOR_NEG, -t)
: lerpColor(COLOR_NEUTRAL, COLOR_POS, t);
return { name, p, r, fillColor, isSelected: focusCity === name };
// --- 6. Éléments SVG des nœuds de clusters ---
const maxClVol = Math.max(...clusters.map(cl => cl.totalVol), 1);
const nodeElems = clusters.map((cl, idx) => {
const r = Math.max(4, Math.min(18, 4 + 11 * (cl.totalVol / maxClVol)));
const fillColor = clusterColors[idx];
const isSelected = focusClusterIdx === idx;
const cityCount = cl.cities.size;
// Nom affiché : ville principale (la première dans l'itération = la plus volumineuse)
const label = cityCount > 1 ? `+${cityCount}` : [...cl.cities][0];
return { idx, cl, r, fillColor, isSelected, cityCount, label };
});
return { arcElems, nodeElems };
@@ -178,6 +260,15 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [corridors, cityNodes, focusCity, tick, mapReady]);
// Handler de clic : on transmet la première ville du cluster cliqué
const handleNodeClick = (nodeIdx: number) => {
if (!svgElements) return;
const node = svgElements.nodeElems[nodeIdx];
const firstCity = [...node.cl.cities][0];
const isCurrentFocus = node.cl.cities.has(focusCity ?? '');
onCityClick(isCurrentFocus ? null : firstCity);
};
return (
<div className="w-full h-full relative" style={{ minHeight: 0 }}>
<div ref={containerRef} className="absolute inset-0" />
@@ -205,7 +296,7 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
{/* Arcs bezier */}
{svgElements.arcElems.map(a => (
<g key={`${a.c.fromCity}-${a.c.toCity}`} opacity={a.opacity}>
<g key={`${a.ca.fromIdx}-${a.ca.toIdx}`} opacity={a.opacity}>
<path
d={a.path}
fill="none"
@@ -217,20 +308,32 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
</g>
))}
{/* Nœuds de villes (pointer-events activés uniquement ici) */}
{/* Nœuds de clusters (pointer-events activés uniquement ici) */}
<g style={{ pointerEvents: 'all' }}>
{svgElements.nodeElems.map(node => (
<g key={node.idx} onClick={() => handleNodeClick(node.idx)} style={{ cursor: 'pointer' }}>
<circle
key={node.name}
cx={node.p.x}
cy={node.p.y}
cx={node.cl.cx}
cy={node.cl.cy}
r={node.r}
fill={node.isSelected ? '#ffffff' : node.fillColor}
stroke={node.isSelected ? node.fillColor : '#0a0b0f'}
strokeWidth={1.5}
style={{ cursor: 'pointer' }}
onClick={() => onCityClick(focusCity === node.name ? null : node.name)}
/>
{node.cityCount > 1 && (
<text
x={node.cl.cx}
y={node.cl.cy + 3.5}
textAnchor="middle"
fontSize={node.r > 9 ? 9 : 7}
fontWeight="bold"
fill={node.isSelected ? node.fillColor : '#0a0b0f'}
style={{ pointerEvents: 'none', userSelect: 'none' }}
>
{node.label}
</text>
)}
</g>
))}
</g>
</svg>