feat: vue flux — arcs dirigés entre villes géolocalisées
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:
syoul
2026-03-24 00:21:03 +01:00
parent ab72d8218b
commit 97ff22027c
7 changed files with 647 additions and 112 deletions
+190 -78
View File
@@ -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 &nbsp;
<span className="text-[#00acc1]"></span> entrants
</p>
</div>
)}
</div>
) : (
<p className="text-[#4b5563] text-xs">Aucun arc à afficher.</p>
)}
</>
)}
{/* Footer */}