feat: vue flux — arcs dirigés entre villes géolocalisées
ci/woodpecker/push/woodpecker Pipeline was successful
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>
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,8 @@ interface PeriodSelectorProps {
|
||||
onChange: (days: number) => void;
|
||||
animationActive: boolean;
|
||||
onAnimate: () => void;
|
||||
viewMode: 'heatmap' | 'flow';
|
||||
onViewModeChange: (mode: 'heatmap' | 'flow') => void;
|
||||
}
|
||||
|
||||
const PERIODS = [
|
||||
@@ -15,7 +17,7 @@ const PERIODS = [
|
||||
|
||||
const PRESET_DAYS = new Set([1, 7, 30]);
|
||||
|
||||
export function PeriodSelector({ value, onChange, animationActive, onAnimate }: PeriodSelectorProps) {
|
||||
export function PeriodSelector({ value, onChange, animationActive, onAnimate, viewMode, onViewModeChange }: PeriodSelectorProps) {
|
||||
const [customOpen, setCustomOpen] = useState(false);
|
||||
const [inputVal, setInputVal] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -107,6 +109,21 @@ export function PeriodSelector({ value, onChange, animationActive, onAnimate }:
|
||||
>
|
||||
▶ Animer
|
||||
</button>
|
||||
|
||||
<div className="w-px mx-1 bg-[#2e2f3a] self-stretch" />
|
||||
|
||||
<button
|
||||
onClick={() => onViewModeChange(viewMode === 'heatmap' ? 'flow' : 'heatmap')}
|
||||
className={`
|
||||
px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer
|
||||
${viewMode === 'flow'
|
||||
? 'bg-[#d4a843] text-[#0a0b0f] shadow-[0_0_12px_rgba(212,168,67,0.4)]'
|
||||
: 'text-[#6b7280] hover:text-[#d4a843] hover:bg-[#1a1b23]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{viewMode === 'flow' ? '⊙ Heatmap' : '◉ Flux'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+190
-78
@@ -1,5 +1,6 @@
|
||||
import { useRef } from 'react';
|
||||
import type { PeriodStats } from '../services/DataService';
|
||||
import type { FlowStats } from '../data/arcData';
|
||||
|
||||
interface StatsPanelProps {
|
||||
stats: PeriodStats | null;
|
||||
@@ -8,6 +9,9 @@ interface StatsPanelProps {
|
||||
source: 'live' | 'mock';
|
||||
currentUD: number;
|
||||
animationLabel?: string;
|
||||
viewMode?: 'heatmap' | 'flow';
|
||||
flowStats?: FlowStats | null;
|
||||
focusCity?: string | null;
|
||||
}
|
||||
|
||||
const MEDALS = ['🥇', '🥈', '🥉'];
|
||||
@@ -33,7 +37,28 @@ function formatDU(g1: number, ud: number): string {
|
||||
return `≈ ${Math.round(du).toLocaleString('fr-FR')} DU`;
|
||||
}
|
||||
|
||||
export function StatsPanel({ stats, loading, periodDays, source, currentUD, animationLabel }: StatsPanelProps) {
|
||||
function CityRow({ city, volume, count, countryCode, accent }: {
|
||||
city: string; volume: number; count: number; countryCode: string; accent?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-1.5 text-white text-xs font-medium truncate">
|
||||
{countryCode && (
|
||||
<span className="text-[10px] font-bold bg-[#1e1f2a] text-[#6b7280] rounded px-1 py-0.5 leading-none shrink-0">
|
||||
{countryCode}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate">{city}</span>
|
||||
</span>
|
||||
<span className={`text-xs font-mono shrink-0 ml-1 ${accent ?? 'text-[#d4a843]'}`}>
|
||||
{volume.toLocaleString('fr-FR', { maximumFractionDigits: 0 })} Ğ1
|
||||
<span className="text-[#4b5563] ml-0.5">· {count}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatsPanel({ stats, loading, periodDays, source, currentUD, animationLabel, viewMode = 'heatmap', flowStats, focusCity }: StatsPanelProps) {
|
||||
const periodLabel = periodDays === 1 ? '24 dernières heures' : `${periodDays} derniers jours`;
|
||||
const prevStats = useRef<PeriodStats | null>(null);
|
||||
|
||||
@@ -85,84 +110,171 @@ export function StatsPanel({ stats, loading, periodDays, source, currentUD, anim
|
||||
}
|
||||
</p>
|
||||
|
||||
{/* Stats */}
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2].map((i) => (
|
||||
<div key={i} className="bg-[#0f1016] border border-[#2e2f3a] rounded-xl p-4 h-20 animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : stats ? (
|
||||
<div className="space-y-3">
|
||||
<StatCard
|
||||
label="Volume total"
|
||||
value={`${stats.totalVolume.toLocaleString('fr-FR', { maximumFractionDigits: 2 })} Ğ1`}
|
||||
sub={formatDU(stats.totalVolume, currentUD)}
|
||||
delta={prevVolume !== null ? (stats.totalVolume > prevVolume ? 'up' : stats.totalVolume < prevVolume ? 'down' : null) : null}
|
||||
/>
|
||||
<StatCard
|
||||
label="Transactions"
|
||||
value={stats.transactionCount.toLocaleString('fr-FR')}
|
||||
sub={(() => {
|
||||
const avg = stats.totalVolume / (stats.transactionCount || 1);
|
||||
return `≈ ${avg.toFixed(2)} Ğ1 / tx · ${formatDU(avg, currentUD)} / tx`;
|
||||
})()}
|
||||
delta={prevTxCount !== null ? (stats.transactionCount > prevTxCount ? 'up' : stats.transactionCount < prevTxCount ? 'down' : null) : null}
|
||||
/>
|
||||
{/* Couverture géo — transactionCount inclut le total réel de la frame */}
|
||||
{source === 'live' && stats.transactionCount > 0 && (() => {
|
||||
const pct = Math.round((stats.geoCount / stats.transactionCount) * 100);
|
||||
return (
|
||||
<div className="bg-[#0f1016] border border-[#2e2f3a] rounded-xl p-3">
|
||||
<div className="flex justify-between items-center mb-1.5">
|
||||
<p className="text-[#4b5563] text-xs uppercase tracking-widest">Géolocalisées</p>
|
||||
<p className="text-[#6b7280] text-xs">{stats.geoCount} / {stats.transactionCount}</p>
|
||||
</div>
|
||||
<div className="w-full bg-[#1e1f2a] rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-[#d4a843] h-1.5 rounded-full transition-all duration-500"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[#4b5563] text-xs mt-1 text-right">{pct}% via Cesium+</p>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Top cities */}
|
||||
{!loading && stats && stats.topCities.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[#4b5563] text-xs uppercase tracking-widest border-t border-[#1e1f2a] pt-3">
|
||||
Top villes
|
||||
</p>
|
||||
{stats.topCities.map((city, i) => (
|
||||
<div
|
||||
key={city.name}
|
||||
className="bg-[#0f1016] border border-[#2e2f3a] rounded-lg px-3 py-2.5 flex gap-2.5"
|
||||
>
|
||||
<span className="text-base shrink-0 mt-0.5">{MEDALS[i]}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white text-xs font-medium leading-snug">{city.name}</p>
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<span className="flex items-center gap-1.5 text-[#6b7280] text-xs">
|
||||
{city.countryCode && (
|
||||
<span className="text-[10px] font-bold bg-[#1e1f2a] text-[#6b7280] rounded px-1 py-0.5 leading-none">
|
||||
{city.countryCode}
|
||||
</span>
|
||||
)}
|
||||
{city.count} tx
|
||||
</span>
|
||||
<span className="text-[#d4a843] text-xs font-mono flex items-center gap-1">
|
||||
{city.volume.toLocaleString('fr-FR', { maximumFractionDigits: 0 })} Ğ1
|
||||
{delta(city.volume, prevCityVolume, city.name)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* ---- Vue HEATMAP ---- */}
|
||||
{viewMode === 'heatmap' && (
|
||||
<>
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2].map((i) => (
|
||||
<div key={i} className="bg-[#0f1016] border border-[#2e2f3a] rounded-xl p-4 h-20 animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : stats ? (
|
||||
<div className="space-y-3">
|
||||
<StatCard
|
||||
label="Volume total"
|
||||
value={`${stats.totalVolume.toLocaleString('fr-FR', { maximumFractionDigits: 2 })} Ğ1`}
|
||||
sub={formatDU(stats.totalVolume, currentUD)}
|
||||
delta={prevVolume !== null ? (stats.totalVolume > prevVolume ? 'up' : stats.totalVolume < prevVolume ? 'down' : null) : null}
|
||||
/>
|
||||
<StatCard
|
||||
label="Transactions"
|
||||
value={stats.transactionCount.toLocaleString('fr-FR')}
|
||||
sub={(() => {
|
||||
const avg = stats.totalVolume / (stats.transactionCount || 1);
|
||||
return `≈ ${avg.toFixed(2)} Ğ1 / tx · ${formatDU(avg, currentUD)} / tx`;
|
||||
})()}
|
||||
delta={prevTxCount !== null ? (stats.transactionCount > prevTxCount ? 'up' : stats.transactionCount < prevTxCount ? 'down' : null) : null}
|
||||
/>
|
||||
{/* Couverture géo — transactionCount inclut le total réel de la frame */}
|
||||
{source === 'live' && stats.transactionCount > 0 && (() => {
|
||||
const pct = Math.round((stats.geoCount / stats.transactionCount) * 100);
|
||||
return (
|
||||
<div className="bg-[#0f1016] border border-[#2e2f3a] rounded-xl p-3">
|
||||
<div className="flex justify-between items-center mb-1.5">
|
||||
<p className="text-[#4b5563] text-xs uppercase tracking-widest">Géolocalisées</p>
|
||||
<p className="text-[#6b7280] text-xs">{stats.geoCount} / {stats.transactionCount}</p>
|
||||
</div>
|
||||
<div className="w-full bg-[#1e1f2a] rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-[#d4a843] h-1.5 rounded-full transition-all duration-500"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[#4b5563] text-xs mt-1 text-right">{pct}% via Cesium+</p>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Top cities */}
|
||||
{!loading && stats && stats.topCities.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[#4b5563] text-xs uppercase tracking-widest border-t border-[#1e1f2a] pt-3">
|
||||
Top villes
|
||||
</p>
|
||||
{stats.topCities.map((city, i) => (
|
||||
<div
|
||||
key={city.name}
|
||||
className="bg-[#0f1016] border border-[#2e2f3a] rounded-lg px-3 py-2.5 flex gap-2.5"
|
||||
>
|
||||
<span className="text-base shrink-0 mt-0.5">{MEDALS[i]}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white text-xs font-medium leading-snug">{city.name}</p>
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<span className="flex items-center gap-1.5 text-[#6b7280] text-xs">
|
||||
{city.countryCode && (
|
||||
<span className="text-[10px] font-bold bg-[#1e1f2a] text-[#6b7280] rounded px-1 py-0.5 leading-none">
|
||||
{city.countryCode}
|
||||
</span>
|
||||
)}
|
||||
{city.count} tx
|
||||
</span>
|
||||
<span className="text-[#d4a843] text-xs font-mono flex items-center gap-1">
|
||||
{city.volume.toLocaleString('fr-FR', { maximumFractionDigits: 0 })} Ğ1
|
||||
{delta(city.volume, prevCityVolume, city.name)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ---- Vue FLUX ---- */}
|
||||
{viewMode === 'flow' && (
|
||||
<>
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="bg-[#0f1016] border border-[#2e2f3a] rounded-xl p-4 h-16 animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : flowStats ? (
|
||||
<div className="space-y-3">
|
||||
<StatCard
|
||||
label="Volume des flux"
|
||||
value={`${flowStats.totalVolume.toLocaleString('fr-FR', { maximumFractionDigits: 2 })} Ğ1`}
|
||||
sub={formatDU(flowStats.totalVolume, currentUD)}
|
||||
/>
|
||||
<StatCard
|
||||
label="Arcs géolocalisés"
|
||||
value={flowStats.arcCount.toLocaleString('fr-FR')}
|
||||
sub={flowStats.arcCount > 0
|
||||
? `≈ ${(flowStats.totalVolume / flowStats.arcCount).toFixed(2)} Ğ1 / arc`
|
||||
: undefined}
|
||||
/>
|
||||
|
||||
{/* Top émetteurs */}
|
||||
{flowStats.topEmitters.length > 0 && (
|
||||
<div className="bg-[#0f1016] border border-[#2e2f3a] rounded-xl p-3 space-y-2">
|
||||
<p className="text-[#4b5563] text-xs uppercase tracking-widest">Top émetteurs</p>
|
||||
{flowStats.topEmitters.map((c, i) => (
|
||||
<div key={c.city} className="flex items-center gap-2">
|
||||
<span className="text-sm shrink-0">{MEDALS[i]}</span>
|
||||
<CityRow city={c.city} volume={c.volume} count={c.count} countryCode={c.countryCode} accent="text-[#ff8f00]" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top récepteurs */}
|
||||
{flowStats.topReceivers.length > 0 && (
|
||||
<div className="bg-[#0f1016] border border-[#2e2f3a] rounded-xl p-3 space-y-2">
|
||||
<p className="text-[#4b5563] text-xs uppercase tracking-widest">Top récepteurs</p>
|
||||
{flowStats.topReceivers.map((c, i) => (
|
||||
<div key={c.city} className="flex items-center gap-2">
|
||||
<span className="text-sm shrink-0">{MEDALS[i]}</span>
|
||||
<CityRow city={c.city} volume={c.volume} count={c.count} countryCode={c.countryCode} accent="text-[#00acc1]" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Balance nette */}
|
||||
{flowStats.netBalance.length > 0 && (
|
||||
<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>
|
||||
{flowStats.netBalance.map((c) => (
|
||||
<div key={c.city} className="flex items-center justify-between">
|
||||
<span className="text-white text-xs truncate">{c.city}</span>
|
||||
<span className={`text-xs font-mono shrink-0 ml-2 ${c.net >= 0 ? 'text-[#00acc1]' : 'text-[#ff8f00]'}`}>
|
||||
{c.net >= 0 ? '+' : ''}{Math.round(c.net).toLocaleString('fr-FR')} Ğ1
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ville focus */}
|
||||
{focusCity && (
|
||||
<div className="bg-[#0f1016] border border-[#d4a843]/30 rounded-xl p-3">
|
||||
<p className="text-[#4b5563] text-xs uppercase tracking-widest mb-1">Ville sélectionnée</p>
|
||||
<p className="text-[#d4a843] text-sm font-medium">{focusCity}</p>
|
||||
<p className="text-[#4b5563] text-xs mt-0.5">
|
||||
<span className="text-[#ff8f00]">■</span> sortants
|
||||
<span className="text-[#00acc1]">■</span> entrants
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[#4b5563] text-xs">Aucun arc à afficher.</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
|
||||
Reference in New Issue
Block a user