- feat: coloration nœuds par balance nette (orange émetteur / vert récepteur) - feat: clustering géographique des villes dans la vue Flux - feat: adaptation mobile — drawer bottom + layout responsive
This commit is contained in:
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "g1flux",
|
"name": "g1flux",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.0",
|
"version": "1.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+61
-15
@@ -11,6 +11,7 @@ import type { TransactionArc } from './data/arcData';
|
|||||||
import { computeStats } from './data/mockData';
|
import { computeStats } from './data/mockData';
|
||||||
import { computeFlowStats } from './data/arcData';
|
import { computeFlowStats } from './data/arcData';
|
||||||
import { useAnimation } from './hooks/useAnimation';
|
import { useAnimation } from './hooks/useAnimation';
|
||||||
|
import { useMediaQuery } from './hooks/useMediaQuery';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [periodDays, setPeriodDays] = useState(7);
|
const [periodDays, setPeriodDays] = useState(7);
|
||||||
@@ -25,6 +26,8 @@ export default function App() {
|
|||||||
const [allTimestamps, setAllTimestamps] = useState<number[]>([]);
|
const [allTimestamps, setAllTimestamps] = useState<number[]>([]);
|
||||||
const [viewMode, setViewMode] = useState<'heatmap' | 'flow'>('heatmap');
|
const [viewMode, setViewMode] = useState<'heatmap' | 'flow'>('heatmap');
|
||||||
const [focusCity, setFocusCity] = useState<string | null>(null);
|
const [focusCity, setFocusCity] = useState<string | null>(null);
|
||||||
|
const [panelOpen, setPanelOpen] = useState(false);
|
||||||
|
const isMobile = useMediaQuery('(max-width: 639px)');
|
||||||
|
|
||||||
const animation = useAnimation(transactions, arcs, periodDays, allTimestamps);
|
const animation = useAnimation(transactions, arcs, periodDays, allTimestamps);
|
||||||
|
|
||||||
@@ -86,20 +89,22 @@ export default function App() {
|
|||||||
[arcs, animation.visibleArcs, animation.active],
|
[arcs, animation.visibleArcs, animation.active],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const statsPanelProps = {
|
||||||
|
stats: visibleStats,
|
||||||
|
loading,
|
||||||
|
periodDays,
|
||||||
|
source,
|
||||||
|
currentUD,
|
||||||
|
animationLabel: animation.active ? (animation.currentFrame?.label ?? undefined) : undefined,
|
||||||
|
viewMode,
|
||||||
|
flowStats,
|
||||||
|
focusCity,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-svh w-full overflow-hidden bg-[#0a0b0f] text-white">
|
<div className="flex h-svh w-full overflow-hidden bg-[#0a0b0f] text-white">
|
||||||
{/* Side panel */}
|
{/* Side panel — desktop uniquement */}
|
||||||
<StatsPanel
|
{!isMobile && <StatsPanel {...statsPanelProps} />}
|
||||||
stats={visibleStats}
|
|
||||||
loading={loading}
|
|
||||||
periodDays={periodDays}
|
|
||||||
source={source}
|
|
||||||
currentUD={currentUD}
|
|
||||||
animationLabel={animation.active ? (animation.currentFrame?.label ?? undefined) : undefined}
|
|
||||||
viewMode={viewMode}
|
|
||||||
flowStats={flowStats}
|
|
||||||
focusCity={focusCity}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Map area */}
|
{/* Map area */}
|
||||||
<div className="relative flex-1 min-w-0">
|
<div className="relative flex-1 min-w-0">
|
||||||
@@ -113,8 +118,19 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Bouton menu — mobile uniquement */}
|
||||||
|
{isMobile && (
|
||||||
|
<button
|
||||||
|
onClick={() => setPanelOpen(true)}
|
||||||
|
className="absolute top-4 left-4 z-[1001] w-10 h-10 bg-[#0a0b0f]/90 backdrop-blur-sm border border-[#2e2f3a] rounded-xl flex items-center justify-center text-[#d4a843] text-lg"
|
||||||
|
aria-label="Ouvrir le panneau"
|
||||||
|
>
|
||||||
|
☰
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Period selector — floating over map */}
|
{/* Period selector — floating over map */}
|
||||||
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-[1000]">
|
<div className={`absolute top-4 z-[1000] ${isMobile ? 'left-16 right-4' : 'left-1/2 -translate-x-1/2'}`}>
|
||||||
<PeriodSelector
|
<PeriodSelector
|
||||||
value={periodDays}
|
value={periodDays}
|
||||||
onChange={handlePeriodChange}
|
onChange={handlePeriodChange}
|
||||||
@@ -125,8 +141,16 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Transaction count + source badge (masqués en mode animation) */}
|
{/* Badge ville focus — mobile uniquement */}
|
||||||
{!loading && !animation.active && (
|
{isMobile && focusCity && (
|
||||||
|
<div className="absolute top-20 left-1/2 -translate-x-1/2 z-[1001] flex items-center gap-2 bg-[#0a0b0f]/90 backdrop-blur-sm border border-[#d4a843]/40 rounded-full px-3 py-1.5">
|
||||||
|
<span className="text-[#d4a843] text-xs font-medium">{focusCity}</span>
|
||||||
|
<button onClick={() => setFocusCity(null)} className="text-[#4b5563] hover:text-white text-xs leading-none">✕</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Transaction count + source badge (masqués sur mobile et en mode animation) */}
|
||||||
|
{!loading && !animation.active && !isMobile && (
|
||||||
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 z-[1000] flex items-center gap-2">
|
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 z-[1000] flex items-center gap-2">
|
||||||
<div className="bg-[#0a0b0f]/80 backdrop-blur-sm border border-[#2e2f3a] rounded-full px-4 py-1.5 text-xs text-[#6b7280]">
|
<div className="bg-[#0a0b0f]/80 backdrop-blur-sm border border-[#2e2f3a] rounded-full px-4 py-1.5 text-xs text-[#6b7280]">
|
||||||
<span className="text-[#d4a843] font-medium">{transactions.length}</span> transactions affichées
|
<span className="text-[#d4a843] font-medium">{transactions.length}</span> transactions affichées
|
||||||
@@ -168,6 +192,28 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom drawer — mobile uniquement */}
|
||||||
|
{isMobile && (
|
||||||
|
<>
|
||||||
|
{/* Overlay */}
|
||||||
|
{panelOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[1009] bg-black/50"
|
||||||
|
onClick={() => setPanelOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* Drawer */}
|
||||||
|
<div
|
||||||
|
className={`fixed bottom-0 left-0 right-0 z-[1010] h-[85vh] flex flex-col transition-transform duration-300 ${panelOpen ? 'translate-y-0' : 'translate-y-full'}`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-center pt-2 pb-1 bg-[#0a0b0f] rounded-t-2xl border-t border-x border-[#2e2f3a] shrink-0">
|
||||||
|
<div className="w-10 h-1 rounded-full bg-[#2e2f3a]" />
|
||||||
|
</div>
|
||||||
|
<StatsPanel {...statsPanelProps} onClose={() => setPanelOpen(false)} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ export function AnimationPlayer({
|
|||||||
const frame = frames[currentIndex];
|
const frame = frames[currentIndex];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-[1001] w-[min(640px,90vw)]">
|
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-[1001] w-[min(640px,calc(100vw-1rem))]">
|
||||||
<div className="bg-[#0a0b0f]/90 backdrop-blur-sm border border-[#2e2f3a] rounded-2xl px-5 py-3 flex flex-col gap-2.5 shadow-xl">
|
<div className="bg-[#0a0b0f]/90 backdrop-blur-sm border border-[#2e2f3a] rounded-2xl px-4 py-3 flex flex-col gap-2.5 shadow-xl">
|
||||||
|
|
||||||
{/* Frame label + position */}
|
{/* Frame label + position */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -50,7 +50,7 @@ export function AnimationPlayer({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Controls row */}
|
{/* Controls row */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-wrap items-center justify-between gap-y-2">
|
||||||
|
|
||||||
{/* Playback buttons */}
|
{/* Playback buttons */}
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
|||||||
+179
-45
@@ -1,5 +1,27 @@
|
|||||||
import { useEffect, useRef, useState, useMemo } from 'react';
|
import { useEffect, useRef, useState, useMemo } from 'react';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
|
|
||||||
|
/** Interpolation RGB linéaire entre deux couleurs hex, t ∈ [0, 1]. */
|
||||||
|
function lerpColor(hex1: string, hex2: string, t: number): string {
|
||||||
|
const parse = (h: string) => [
|
||||||
|
parseInt(h.slice(1, 3), 16),
|
||||||
|
parseInt(h.slice(3, 5), 16),
|
||||||
|
parseInt(h.slice(5, 7), 16),
|
||||||
|
];
|
||||||
|
const [r1, g1, b1] = parse(hex1);
|
||||||
|
const [r2, g2, b2] = parse(hex2);
|
||||||
|
const r = Math.round(r1 + (r2 - r1) * t);
|
||||||
|
const g = Math.round(g1 + (g2 - g1) * t);
|
||||||
|
const b = Math.round(b1 + (b2 - b1) * t);
|
||||||
|
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 type { TransactionArc } from '../data/arcData';
|
||||||
import { buildCorridors } from '../data/arcData';
|
import { buildCorridors } from '../data/arcData';
|
||||||
|
|
||||||
@@ -71,7 +93,7 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
|
|||||||
return map;
|
return map;
|
||||||
}, [corridors]);
|
}, [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 svgElements = useMemo(() => {
|
||||||
const m = mapRef.current;
|
const m = mapRef.current;
|
||||||
if (!m || !mapReady) return null;
|
if (!m || !mapReady) return null;
|
||||||
@@ -81,15 +103,104 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
|
|||||||
return { x: p.x, y: p.y };
|
return { x: p.x, y: p.y };
|
||||||
};
|
};
|
||||||
|
|
||||||
const maxVol = Math.max(...corridors.map(c => c.totalVolume), 1);
|
// --- 1. Projeter toutes les villes en pixels, triées par volume desc ---
|
||||||
const maxNodeVol = Math.max(...[...cityNodes.values()].map(c => c.emitted + c.received), 1);
|
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 ----
|
// --- 2. Clustering glouton par distance pixel ---
|
||||||
const arcElems = corridors.map((c, idx) => {
|
interface Cluster {
|
||||||
const p1 = proj(c.fromLat, c.fromLng);
|
cx: number; cy: number; // centroïde pondéré (pixels)
|
||||||
const p2 = proj(c.toLat, c.toLng);
|
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 mx = (p1.x + p2.x) / 2;
|
||||||
const my = (p1.y + p2.y) / 2;
|
const my = (p1.y + p2.y) / 2;
|
||||||
const dx = p2.x - p1.x;
|
const dx = p2.x - p1.x;
|
||||||
@@ -98,13 +209,12 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
|
|||||||
const cx = mx - dy * CURVE;
|
const cx = mx - dy * CURVE;
|
||||||
const cy = my + dx * CURVE;
|
const cy = my + dx * CURVE;
|
||||||
|
|
||||||
// Flèche de direction au milieu (t = 0.5) du bezier
|
|
||||||
const t = 0.5;
|
const t = 0.5;
|
||||||
const ax = (1-t)*(1-t)*p1.x + 2*(1-t)*t*cx + t*t*p2.x;
|
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 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 tln = Math.sqrt(dx*dx + dy*dy) || 1;
|
||||||
const nx = dx / tln; const ny = dy / tln; // tangente normalisée
|
const nx = dx / tln; const ny = dy / tln;
|
||||||
const px = -ny; const py = nx; // perpendiculaire
|
const px = -ny; const py = nx;
|
||||||
const AR = 5;
|
const AR = 5;
|
||||||
const arrowPts = [
|
const arrowPts = [
|
||||||
`${ax + nx*AR},${ay + ny*AR}`,
|
`${ax + nx*AR},${ay + ny*AR}`,
|
||||||
@@ -112,34 +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}`,
|
`${ax - nx*AR*0.6 - px*AR*0.5},${ay - ny*AR*0.6 - py*AR*0.5}`,
|
||||||
].join(' ');
|
].join(' ');
|
||||||
|
|
||||||
const ratio = c.totalVolume / maxVol;
|
const ratio = ca.totalVolume / maxVol;
|
||||||
const strokeW = Math.max(1, 1.5 + Math.log1p(c.totalVolume) * 0.8);
|
const strokeW = Math.max(1, 1.5 + Math.log1p(ca.totalVolume) * 0.8);
|
||||||
const opacity = 0.35 + 0.55 * ratio;
|
const opacity = 0.35 + 0.55 * ratio;
|
||||||
|
|
||||||
// Couleur selon focusCity
|
const isFocusFrom = focusClusterIdx !== -1 && ca.fromIdx === focusClusterIdx;
|
||||||
const isFocusFrom = focusCity && c.fromCity === focusCity;
|
const isFocusTo = focusClusterIdx !== -1 && ca.toIdx === focusClusterIdx;
|
||||||
const isFocusTo = focusCity && c.toCity === focusCity;
|
const stroke = focusClusterIdx === -1 ? `url(#grad${idx})`
|
||||||
const stroke = !focusCity ? `url(#grad${idx})`
|
: isFocusFrom ? '#ff8f00'
|
||||||
: isFocusFrom ? '#ff8f00'
|
: isFocusTo ? '#00acc1'
|
||||||
: isFocusTo ? '#00acc1'
|
: '#2e2f3a';
|
||||||
: '#2e2f3a';
|
const arrowFill = focusClusterIdx === -1 ? '#e65100'
|
||||||
const arrowFill = !focusCity ? '#e65100'
|
: isFocusFrom ? '#ff8f00'
|
||||||
: isFocusFrom ? '#ff8f00'
|
: isFocusTo ? '#00acc1'
|
||||||
: isFocusTo ? '#00acc1'
|
: '#2e2f3a';
|
||||||
: '#2e2f3a';
|
|
||||||
|
|
||||||
return {
|
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}`,
|
path: `M ${p1.x},${p1.y} Q ${cx},${cy} ${p2.x},${p2.y}`,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- Nœuds ----
|
// --- 6. Éléments SVG des nœuds de clusters ---
|
||||||
const nodeElems = [...cityNodes.entries()].map(([name, data]) => {
|
const maxClVol = Math.max(...clusters.map(cl => cl.totalVol), 1);
|
||||||
const p = proj(data.lat, data.lng);
|
const nodeElems = clusters.map((cl, idx) => {
|
||||||
const vol = data.emitted + data.received;
|
const r = Math.max(4, Math.min(18, 4 + 11 * (cl.totalVol / maxClVol)));
|
||||||
const r = Math.max(3, Math.min(14, 3 + 9 * (vol / maxNodeVol)));
|
const fillColor = clusterColors[idx];
|
||||||
return { name, p, r, isSelected: focusCity === name };
|
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 };
|
return { arcElems, nodeElems };
|
||||||
@@ -147,6 +260,15 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [corridors, cityNodes, focusCity, tick, mapReady]);
|
}, [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 (
|
return (
|
||||||
<div className="w-full h-full relative" style={{ minHeight: 0 }}>
|
<div className="w-full h-full relative" style={{ minHeight: 0 }}>
|
||||||
<div ref={containerRef} className="absolute inset-0" />
|
<div ref={containerRef} className="absolute inset-0" />
|
||||||
@@ -174,7 +296,7 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
|
|||||||
|
|
||||||
{/* Arcs bezier */}
|
{/* Arcs bezier */}
|
||||||
{svgElements.arcElems.map(a => (
|
{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
|
<path
|
||||||
d={a.path}
|
d={a.path}
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -186,20 +308,32 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
|
|||||||
</g>
|
</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' }}>
|
<g style={{ pointerEvents: 'all' }}>
|
||||||
{svgElements.nodeElems.map(node => (
|
{svgElements.nodeElems.map(node => (
|
||||||
<circle
|
<g key={node.idx} onClick={() => handleNodeClick(node.idx)} style={{ cursor: 'pointer' }}>
|
||||||
key={node.name}
|
<circle
|
||||||
cx={node.p.x}
|
cx={node.cl.cx}
|
||||||
cy={node.p.y}
|
cy={node.cl.cy}
|
||||||
r={node.r}
|
r={node.r}
|
||||||
fill={node.isSelected ? '#ffffff' : '#d4a843'}
|
fill={node.isSelected ? '#ffffff' : node.fillColor}
|
||||||
stroke={node.isSelected ? '#d4a843' : '#0a0b0f'}
|
stroke={node.isSelected ? node.fillColor : '#0a0b0f'}
|
||||||
strokeWidth={1.5}
|
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>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@@ -41,13 +41,13 @@ export function PeriodSelector({ value, onChange, animationActive, onAnimate, vi
|
|||||||
const isCustomActive = !PRESET_DAYS.has(value);
|
const isCustomActive = !PRESET_DAYS.has(value);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-1 bg-[#0f1016] border border-[#2e2f3a] rounded-lg p-1 items-center">
|
<div className="flex flex-wrap gap-1 bg-[#0f1016] border border-[#2e2f3a] rounded-lg p-1 items-center max-w-[calc(100vw-2rem)]">
|
||||||
{PERIODS.map(({ label, days }) => (
|
{PERIODS.map(({ label, days }) => (
|
||||||
<button
|
<button
|
||||||
key={days}
|
key={days}
|
||||||
onClick={() => { onChange(days); setCustomOpen(false); }}
|
onClick={() => { onChange(days); setCustomOpen(false); }}
|
||||||
className={`
|
className={`
|
||||||
px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer
|
px-3 py-2.5 sm:py-1.5 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer
|
||||||
${value === days && !customOpen
|
${value === days && !customOpen
|
||||||
? 'bg-[#d4a843] text-[#0a0b0f] shadow-[0_0_12px_rgba(212,168,67,0.4)]'
|
? 'bg-[#d4a843] text-[#0a0b0f] shadow-[0_0_12px_rgba(212,168,67,0.4)]'
|
||||||
: 'text-[#6b7280] hover:text-[#d4a843] hover:bg-[#1a1b23]'
|
: 'text-[#6b7280] hover:text-[#d4a843] hover:bg-[#1a1b23]'
|
||||||
@@ -84,7 +84,7 @@ export function PeriodSelector({ value, onChange, animationActive, onAnimate, vi
|
|||||||
<button
|
<button
|
||||||
onClick={openCustom}
|
onClick={openCustom}
|
||||||
className={`
|
className={`
|
||||||
px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer
|
px-3 py-2.5 sm:py-1.5 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer
|
||||||
${isCustomActive
|
${isCustomActive
|
||||||
? 'bg-[#d4a843] text-[#0a0b0f] shadow-[0_0_12px_rgba(212,168,67,0.4)]'
|
? 'bg-[#d4a843] text-[#0a0b0f] shadow-[0_0_12px_rgba(212,168,67,0.4)]'
|
||||||
: 'text-[#6b7280] hover:text-[#d4a843] hover:bg-[#1a1b23]'
|
: 'text-[#6b7280] hover:text-[#d4a843] hover:bg-[#1a1b23]'
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ interface StatsPanelProps {
|
|||||||
viewMode?: 'heatmap' | 'flow';
|
viewMode?: 'heatmap' | 'flow';
|
||||||
flowStats?: FlowStats | null;
|
flowStats?: FlowStats | null;
|
||||||
focusCity?: string | null;
|
focusCity?: string | null;
|
||||||
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MEDALS = ['🥇', '🥈', '🥉'];
|
const MEDALS = ['🥇', '🥈', '🥉'];
|
||||||
@@ -58,7 +59,7 @@ function CityRow({ city, volume, count, countryCode, accent }: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatsPanel({ stats, loading, periodDays, source, currentUD, animationLabel, viewMode = 'heatmap', flowStats, focusCity }: StatsPanelProps) {
|
export function StatsPanel({ stats, loading, periodDays, source, currentUD, animationLabel, viewMode = 'heatmap', flowStats, focusCity, onClose }: StatsPanelProps) {
|
||||||
const periodLabel = periodDays === 1 ? '24 dernières heures' : `${periodDays} derniers jours`;
|
const periodLabel = periodDays === 1 ? '24 dernières heures' : `${periodDays} derniers jours`;
|
||||||
const prevStats = useRef<PeriodStats | null>(null);
|
const prevStats = useRef<PeriodStats | null>(null);
|
||||||
|
|
||||||
@@ -82,7 +83,7 @@ export function StatsPanel({ stats, loading, periodDays, source, currentUD, anim
|
|||||||
if (stats && !loading) prevStats.current = stats;
|
if (stats && !loading) prevStats.current = stats;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="w-72 shrink-0 flex flex-col gap-4 bg-[#0a0b0f]/95 backdrop-blur-sm border-r border-[#1e1f2a] p-5 overflow-y-auto">
|
<aside className="w-full lg:w-72 shrink-0 flex flex-col gap-4 bg-[#0a0b0f]/95 backdrop-blur-sm border-r border-[#1e1f2a] p-5 overflow-y-auto h-full">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="w-8 h-8 rounded-full bg-[#d4a843] flex items-center justify-center text-[#0a0b0f] font-bold text-sm shadow-[0_0_16px_rgba(212,168,67,0.5)]">
|
<div className="w-8 h-8 rounded-full bg-[#d4a843] flex items-center justify-center text-[#0a0b0f] font-bold text-sm shadow-[0_0_16px_rgba(212,168,67,0.5)]">
|
||||||
@@ -95,6 +96,15 @@ export function StatsPanel({ stats, loading, periodDays, source, currentUD, anim
|
|||||||
</h1>
|
</h1>
|
||||||
<p className="text-[#4b5563] text-xs">Monnaie libre · Flux géo</p>
|
<p className="text-[#4b5563] text-xs">Monnaie libre · Flux géo</p>
|
||||||
</div>
|
</div>
|
||||||
|
{onClose && (
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="ml-auto text-[#4b5563] hover:text-white transition-colors p-1 text-lg leading-none"
|
||||||
|
aria-label="Fermer"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
@@ -247,7 +257,13 @@ export function StatsPanel({ stats, loading, periodDays, source, currentUD, anim
|
|||||||
{/* Balance nette */}
|
{/* Balance nette */}
|
||||||
{flowStats.netBalance.length > 0 && (
|
{flowStats.netBalance.length > 0 && (
|
||||||
<div className="bg-[#0f1016] border border-[#2e2f3a] rounded-xl p-3 space-y-1.5">
|
<div className="bg-[#0f1016] border border-[#2e2f3a] rounded-xl p-3 space-y-1.5">
|
||||||
<p className="text-[#4b5563] text-xs uppercase tracking-widest">Balance nette</p>
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-[#4b5563] text-xs uppercase tracking-widest">Balance nette</p>
|
||||||
|
<p className="text-[10px] text-[#4b5563] flex items-center gap-1.5">
|
||||||
|
<span style={{ color: '#ff6d00' }}>●</span>émetteur
|
||||||
|
<span style={{ color: '#00c853' }}>●</span>récepteur
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
{flowStats.netBalance.map((c) => (
|
{flowStats.netBalance.map((c) => (
|
||||||
<div key={c.city} className="flex items-center justify-between">
|
<div key={c.city} className="flex items-center justify-between">
|
||||||
<span className="text-white text-xs truncate">{c.city}</span>
|
<span className="text-white text-xs truncate">{c.city}</span>
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export function useMediaQuery(query: string): boolean {
|
||||||
|
const [matches, setMatches] = useState(() => window.matchMedia(query).matches);
|
||||||
|
useEffect(() => {
|
||||||
|
const mq = window.matchMedia(query);
|
||||||
|
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
|
||||||
|
mq.addEventListener('change', handler);
|
||||||
|
return () => mq.removeEventListener('change', handler);
|
||||||
|
}, [query]);
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user