feat: bouton toggle Clusters / Villes dans la vue Flux
ci/woodpecker/push/woodpecker Pipeline was successful

Permet de basculer entre la vue groupée (clustering glouton, défaut)
et la vue individuelle (une ville = un nœud). Le bouton est positionné
en bas à droite de la carte.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
syoul
2026-03-24 11:49:06 +01:00
parent 851dc46394
commit c51bb251e3
+22 -8
View File
@@ -40,7 +40,8 @@ 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
const [tick, setTick] = useState(0);
const [clustered, setClustered] = useState(true);
// Initialisation Leaflet
useEffect(() => {
@@ -114,19 +115,21 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
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);
// --- 2. Clustering glouton par distance pixel ---
// --- 2. Clustering glouton par distance pixel (ou 1 ville = 1 cluster) ---
interface Cluster {
cx: number; cy: number; // centroïde pondéré (pixels)
lat: number; lng: number; // centroïde géo (pour debug éventuel)
cx: number; cy: number;
lat: number; lng: number;
totalVol: number;
emitted: number; received: number;
cities: Set<string>;
}
const clusters: Cluster[] = [];
const cityClusterIdx = new Map<string, number>(); // nom ville → index cluster
const cityClusterIdx = new Map<string, number>();
for (const city of cityList) {
let bestIdx = -1;
if (clustered) {
let bestDist = Infinity;
for (let i = 0; i < clusters.length; i++) {
const cl = clusters[i];
@@ -138,9 +141,9 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
bestIdx = i;
}
}
}
if (bestIdx === -1) {
// Nouvelle graine
clusters.push({
cx: city.x, cy: city.y,
lat: city.lat, lng: city.lng,
@@ -150,7 +153,6 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
});
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;
@@ -258,7 +260,7 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
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]);
}, [corridors, cityNodes, focusCity, tick, mapReady, clustered]);
const [popupIdx, setPopupIdx] = useState<number | null>(null);
@@ -345,6 +347,18 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
</svg>
)}
{/* Bouton cluster / villes */}
<button
onClick={() => setClustered(c => !c)}
className={`absolute bottom-10 right-4 z-[600] px-3 py-1.5 rounded-lg text-xs font-medium border transition-all duration-200 ${
clustered
? 'bg-[#d4a843] text-[#0a0b0f] border-[#d4a843] shadow-[0_0_10px_rgba(212,168,67,0.35)]'
: 'bg-[#0a0b0f]/90 text-[#6b7280] border-[#2e2f3a] hover:text-[#d4a843] hover:border-[#d4a843]'
}`}
>
{clustered ? '⬡ Clusters' : '· Villes'}
</button>
{/* Popup cluster */}
{mapReady && svgElements && popupIdx !== null && (() => {
const node = svgElements.nodeElems[popupIdx];