From b6cb0af7229632d4ca7119780051dfa9fda5894e Mon Sep 17 00:00:00 2001 From: syoul Date: Tue, 24 Mar 2026 01:20:00 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20clustering=20g=C3=A9ographique=20des=20?= =?UTF-8?q?villes=20dans=20la=20vue=20Flux?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/components/FlowMap.tsx | 215 +++++++++++++++++++++++++++---------- 1 file changed, 159 insertions(+), 56 deletions(-) diff --git a/src/components/FlowMap.tsx b/src/components/FlowMap.tsx index d857193..ea76337 100644 --- a/src/components/FlowMap.tsx +++ b/src/components/FlowMap.tsx @@ -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; + } + const clusters: Cluster[] = []; + const cityClusterIdx = new Map(); // 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(); + 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 opacity = 0.35 + 0.55 * ratio; + 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})` - : isFocusFrom ? '#ff8f00' - : isFocusTo ? '#00acc1' - : '#2e2f3a'; - const arrowFill = !focusCity ? '#e65100' - : isFocusFrom ? '#ff8f00' - : isFocusTo ? '#00acc1' - : '#2e2f3a'; + 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 = 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 (
@@ -205,7 +296,7 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) { {/* Arcs bezier */} {svgElements.arcElems.map(a => ( - + ))} - {/* Nœuds de villes (pointer-events activés uniquement ici) */} + {/* Nœuds de clusters (pointer-events activés uniquement ici) */} {svgElements.nodeElems.map(node => ( - onCityClick(focusCity === node.name ? null : node.name)} - /> + handleNodeClick(node.idx)} style={{ cursor: 'pointer' }}> + + {node.cityCount > 1 && ( + 9 ? 9 : 7} + fontWeight="bold" + fill={node.isSelected ? node.fillColor : '#0a0b0f'} + style={{ pointerEvents: 'none', userSelect: 'none' }} + > + {node.label} + + )} + ))}