diff --git a/package.json b/package.json index 4c4a36e..603c2f6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "g1flux", "private": true, - "version": "1.0.0", + "version": "1.1.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src/App.tsx b/src/App.tsx index ac163dc..90e5ee2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,17 +1,21 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { StatsPanel } from './components/StatsPanel'; import { PeriodSelector } from './components/PeriodSelector'; import { HeatMap } from './components/HeatMap'; +import { FlowMap } from './components/FlowMap'; import { AnimationPlayer } from './components/AnimationPlayer'; import { fetchData } from './services/DataService'; import type { PeriodStats } from './services/DataService'; import type { Transaction } from './data/mockData'; +import type { TransactionArc } from './data/arcData'; import { computeStats } from './data/mockData'; +import { computeFlowStats } from './data/arcData'; import { useAnimation } from './hooks/useAnimation'; export default function App() { const [periodDays, setPeriodDays] = useState(7); const [transactions, setTransactions] = useState([]); + const [arcs, setArcs] = useState([]); const [stats, setStats] = useState(null); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); @@ -19,14 +23,21 @@ export default function App() { const [source, setSource] = useState<'live' | 'mock'>('mock'); const [currentUD, setCurrentUD] = useState(11.78); const [allTimestamps, setAllTimestamps] = useState([]); + const [viewMode, setViewMode] = useState<'heatmap' | 'flow'>('heatmap'); + const [focusCity, setFocusCity] = useState(null); - const animation = useAnimation(transactions, periodDays, allTimestamps); + const animation = useAnimation(transactions, arcs, periodDays, allTimestamps); const handlePeriodChange = (days: number) => { animation.deactivate(); setPeriodDays(days); }; + const handleViewModeChange = (mode: 'heatmap' | 'flow') => { + setViewMode(mode); + setFocusCity(null); + }; + useEffect(() => { let cancelled = false; @@ -34,9 +45,10 @@ export default function App() { if (showLoading) setLoading(true); else setRefreshing(true); fetchData(periodDays) - .then(({ transactions, stats, source, currentUD, allTimestamps }) => { + .then(({ transactions, arcs, stats, source, currentUD, allTimestamps }) => { if (!cancelled) { setTransactions(transactions); + setArcs(arcs); setStats(stats); setSource(source); setCurrentUD(currentUD); @@ -56,16 +68,24 @@ export default function App() { return () => { cancelled = true; clearInterval(interval); }; }, [periodDays]); - // Stats calculées sur la fenêtre courante en mode animation + // Stats heatmap sur la fenêtre courante en mode animation const visibleStats: PeriodStats | null = animation.active ? { ...computeStats(animation.visibleTransactions), geoCount: animation.visibleTransactions.length, - // frameTotalCount = total réel (géo + non-géo) dans cette frame transactionCount: animation.frameTotalCount ?? animation.visibleTransactions.length, } : stats; + // Stats flux (recalculées sur les arcs visibles) + const flowStats = useMemo( + () => { + const activeArcs = animation.active ? animation.visibleArcs : arcs; + return activeArcs.length > 0 ? computeFlowStats(activeArcs) : null; + }, + [arcs, animation.visibleArcs, animation.active], + ); + return (
{/* Side panel */} @@ -76,11 +96,22 @@ export default function App() { source={source} currentUD={currentUD} animationLabel={animation.active ? (animation.currentFrame?.label ?? undefined) : undefined} + viewMode={viewMode} + flowStats={flowStats} + focusCity={focusCity} /> {/* Map area */}
- + {viewMode === 'heatmap' ? ( + + ) : ( + + )} {/* Period selector — floating over map */}
@@ -89,6 +120,8 @@ export default function App() { onChange={handlePeriodChange} animationActive={animation.active} onAnimate={() => animation.active ? animation.deactivate() : animation.activate()} + viewMode={viewMode} + onViewModeChange={handleViewModeChange} />
diff --git a/src/components/FlowMap.tsx b/src/components/FlowMap.tsx new file mode 100644 index 0000000..fb2bc21 --- /dev/null +++ b/src/components/FlowMap.tsx @@ -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(null); + const mapRef = useRef(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: '© OpenStreetMap', + 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(); + 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 ( +
+
+ + {mapReady && svgElements && ( + + {/* Dégradés or→orange pour les arcs (aucune ville sélectionnée) */} + + {svgElements.arcElems.map(a => ( + + + + + ))} + + + {/* Arcs bezier */} + {svgElements.arcElems.map(a => ( + + + + + ))} + + {/* Nœuds de villes (pointer-events activés uniquement ici) */} + + {svgElements.nodeElems.map(node => ( + onCityClick(focusCity === node.name ? null : node.name)} + /> + ))} + + + )} +
+ ); +} diff --git a/src/components/HeatMap.tsx b/src/components/HeatMap.tsx index 9a24a03..3229410 100644 --- a/src/components/HeatMap.tsx +++ b/src/components/HeatMap.tsx @@ -63,7 +63,40 @@ export function HeatMap({ transactions }: HeatMapProps) { mapRef.current = map; heatRef.current = heat; + // Pendant zoom/pan : cache les overlays → le canvas live est visible directement. + // Après zoom/pan : resynchronise le snapshot sur le canvas redesssiné. + const hideOverlays = () => { + const prev = prevRef.current; + const next = nextRef.current; + if (prev) { prev.style.transition = 'none'; prev.style.opacity = '0'; } + if (next) { next.style.transition = 'none'; next.style.opacity = '0'; } + currentSrcRef.current = ''; + }; + + const syncAfterMove = () => { + const canvas = (heat as unknown as { _canvas?: HTMLCanvasElement })._canvas; + const next = nextRef.current; + if (!canvas || !next) return; + // Double RAF : leaflet.heat redessine en interne après l'événement + requestAnimationFrame(() => { + requestAnimationFrame(() => { + try { + const src = canvas.toDataURL(); + currentSrcRef.current = src; + next.src = src; + next.style.transition = 'none'; + next.style.opacity = '1'; + } catch { /* map torn down */ } + }); + }); + }; + + map.on('zoomstart movestart', hideOverlays); + map.on('zoomend moveend', syncAfterMove); + return () => { + map.off('zoomstart movestart', hideOverlays); + map.off('zoomend moveend', syncAfterMove); map.remove(); mapRef.current = null; heatRef.current = null; diff --git a/src/components/PeriodSelector.tsx b/src/components/PeriodSelector.tsx index 91169cb..ee5e847 100644 --- a/src/components/PeriodSelector.tsx +++ b/src/components/PeriodSelector.tsx @@ -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(null); @@ -107,6 +109,21 @@ export function PeriodSelector({ value, onChange, animationActive, onAnimate }: > ▶ Animer + +
+ +
); } diff --git a/src/components/StatsPanel.tsx b/src/components/StatsPanel.tsx index 970c850..5157c17 100644 --- a/src/components/StatsPanel.tsx +++ b/src/components/StatsPanel.tsx @@ -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 ( +
+ + {countryCode && ( + + {countryCode} + + )} + {city} + + + {volume.toLocaleString('fr-FR', { maximumFractionDigits: 0 })} Ğ1 + · {count} + +
+ ); +} + +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(null); @@ -85,84 +110,171 @@ export function StatsPanel({ stats, loading, periodDays, source, currentUD, anim }

- {/* Stats */} - {loading ? ( -
- {[1, 2].map((i) => ( -
- ))} -
- ) : stats ? ( -
- prevVolume ? 'up' : stats.totalVolume < prevVolume ? 'down' : null) : null} - /> - { - 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 ( -
-
-

Géolocalisées

-

{stats.geoCount} / {stats.transactionCount}

-
-
-
-
-

{pct}% via Cesium+

-
- ); - })()} -
- ) : null} - - {/* Top cities */} - {!loading && stats && stats.topCities.length > 0 && ( -
-

- Top villes -

- {stats.topCities.map((city, i) => ( -
- {MEDALS[i]} -
-

{city.name}

-
- - {city.countryCode && ( - - {city.countryCode} - - )} - {city.count} tx - - - {city.volume.toLocaleString('fr-FR', { maximumFractionDigits: 0 })} Ğ1 - {delta(city.volume, prevCityVolume, city.name)} - -
-
+ {/* ---- Vue HEATMAP ---- */} + {viewMode === 'heatmap' && ( + <> + {loading ? ( +
+ {[1, 2].map((i) => ( +
+ ))}
- ))} -
+ ) : stats ? ( +
+ prevVolume ? 'up' : stats.totalVolume < prevVolume ? 'down' : null) : null} + /> + { + 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 ( +
+
+

Géolocalisées

+

{stats.geoCount} / {stats.transactionCount}

+
+
+
+
+

{pct}% via Cesium+

+
+ ); + })()} +
+ ) : null} + + {/* Top cities */} + {!loading && stats && stats.topCities.length > 0 && ( +
+

+ Top villes +

+ {stats.topCities.map((city, i) => ( +
+ {MEDALS[i]} +
+

{city.name}

+
+ + {city.countryCode && ( + + {city.countryCode} + + )} + {city.count} tx + + + {city.volume.toLocaleString('fr-FR', { maximumFractionDigits: 0 })} Ğ1 + {delta(city.volume, prevCityVolume, city.name)} + +
+
+
+ ))} +
+ )} + + )} + + {/* ---- Vue FLUX ---- */} + {viewMode === 'flow' && ( + <> + {loading ? ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ ) : flowStats ? ( +
+ + 0 + ? `≈ ${(flowStats.totalVolume / flowStats.arcCount).toFixed(2)} Ğ1 / arc` + : undefined} + /> + + {/* Top émetteurs */} + {flowStats.topEmitters.length > 0 && ( +
+

Top émetteurs

+ {flowStats.topEmitters.map((c, i) => ( +
+ {MEDALS[i]} + +
+ ))} +
+ )} + + {/* Top récepteurs */} + {flowStats.topReceivers.length > 0 && ( +
+

Top récepteurs

+ {flowStats.topReceivers.map((c, i) => ( +
+ {MEDALS[i]} + +
+ ))} +
+ )} + + {/* Balance nette */} + {flowStats.netBalance.length > 0 && ( +
+

Balance nette

+ {flowStats.netBalance.map((c) => ( +
+ {c.city} + = 0 ? 'text-[#00acc1]' : 'text-[#ff8f00]'}`}> + {c.net >= 0 ? '+' : ''}{Math.round(c.net).toLocaleString('fr-FR')} Ğ1 + +
+ ))} +
+ )} + + {/* Ville focus */} + {focusCity && ( +
+

Ville sélectionnée

+

{focusCity}

+

+ sortants   + entrants +

+
+ )} +
+ ) : ( +

Aucun arc à afficher.

+ )} + )} {/* Footer */} diff --git a/src/data/arcData.ts b/src/data/arcData.ts new file mode 100644 index 0000000..589a2f1 --- /dev/null +++ b/src/data/arcData.ts @@ -0,0 +1,120 @@ +import type { Transaction } from './mockData'; + +export interface TransactionArc { + id: string; + timestamp: number; // Unix ms + amount: number; // Ğ1 + fromLat: number; + fromLng: number; + fromCity: string; + fromCountry: string; + fromKey: string; + toLat: number; + toLng: number; + toCity: string; + toCountry: string; + toKey: string; +} + +/** Corridor agrégé par paire de villes (fromCity → toCity). */ +export interface Corridor { + fromCity: string; + fromLat: number; + fromLng: number; + fromCountry: string; + toCity: string; + toLat: number; + toLng: number; + toCountry: string; + totalVolume: number; + count: number; +} + +export interface FlowStats { + totalVolume: number; + arcCount: number; + topEmitters: { city: string; volume: number; count: number; countryCode: string }[]; + topReceivers: { city: string; volume: number; count: number; countryCode: string }[]; + netBalance: { city: string; net: number; countryCode: string }[]; +} + +/** Agrège les arcs individuels en corridors ville→ville, triés par volume. */ +export function buildCorridors(arcs: TransactionArc[]): Corridor[] { + const map = new Map(); + for (const arc of arcs) { + const key = `${arc.fromCity}||${arc.toCity}`; + if (!map.has(key)) { + map.set(key, { + fromCity: arc.fromCity, fromLat: arc.fromLat, fromLng: arc.fromLng, fromCountry: arc.fromCountry, + toCity: arc.toCity, toLat: arc.toLat, toLng: arc.toLng, toCountry: arc.toCountry, + totalVolume: 0, count: 0, + }); + } + const c = map.get(key)!; + c.totalVolume += arc.amount; + c.count++; + } + return [...map.values()].sort((a, b) => b.totalVolume - a.totalVolume); +} + +export function computeFlowStats(arcs: TransactionArc[]): FlowStats { + const emitters = new Map(); + const receivers = new Map(); + + for (const arc of arcs) { + if (!emitters.has(arc.fromCity)) emitters.set(arc.fromCity, { volume: 0, count: 0, country: arc.fromCountry }); + if (!receivers.has(arc.toCity)) receivers.set(arc.toCity, { volume: 0, count: 0, country: arc.toCountry }); + emitters.get(arc.fromCity)!.volume += arc.amount; + emitters.get(arc.fromCity)!.count++; + receivers.get(arc.toCity)!.volume += arc.amount; + receivers.get(arc.toCity)!.count++; + } + + const allCities = new Set([...emitters.keys(), ...receivers.keys()]); + const netBalance = [...allCities].map(city => ({ + city, + net: (receivers.get(city)?.volume ?? 0) - (emitters.get(city)?.volume ?? 0), + countryCode: emitters.get(city)?.country ?? receivers.get(city)?.country ?? '', + })).sort((a, b) => Math.abs(b.net) - Math.abs(a.net)).slice(0, 5); + + return { + totalVolume: arcs.reduce((s, a) => s + a.amount, 0), + arcCount: arcs.length, + topEmitters: [...emitters.entries()].sort((a, b) => b[1].volume - a[1].volume).slice(0, 3) + .map(([city, d]) => ({ city, volume: d.volume, count: d.count, countryCode: d.country })), + topReceivers: [...receivers.entries()].sort((a, b) => b[1].volume - a[1].volume).slice(0, 3) + .map(([city, d]) => ({ city, volume: d.volume, count: d.count, countryCode: d.country })), + netBalance, + }; +} + +/** + * Génère des arcs mock : chaque transaction devient un arc dont le destinataire + * est une transaction aléatoire d'une ville différente. + */ +export function buildMockArcs(transactions: Transaction[]): TransactionArc[] { + if (transactions.length < 2) return []; + const arcs: TransactionArc[] = []; + for (let i = 0; i < transactions.length; i++) { + if (Math.random() > 0.55) continue; // ~55 % de couverture + const from = transactions[i]; + let toIdx = Math.floor(Math.random() * transactions.length); + for (let tries = 0; tries < 8 && transactions[toIdx].city === from.city; tries++) { + toIdx = Math.floor(Math.random() * transactions.length); + } + const to = transactions[toIdx]; + if (to.city === from.city) continue; + arcs.push({ + id: `${from.id}-arc`, + timestamp: from.timestamp, + amount: from.amount, + fromLat: from.lat, fromLng: from.lng, + fromCity: from.city, fromCountry: from.countryCode, + fromKey: from.fromKey, + toLat: to.lat, toLng: to.lng, + toCity: to.city, toCountry: to.countryCode, + toKey: to.toKey, + }); + } + return arcs; +} diff --git a/src/hooks/useAnimation.ts b/src/hooks/useAnimation.ts index ce7fda7..8a57710 100644 --- a/src/hooks/useAnimation.ts +++ b/src/hooks/useAnimation.ts @@ -1,5 +1,6 @@ import { useState, useMemo, useEffect } from 'react'; import type { Transaction } from '../data/mockData'; +import type { TransactionArc } from '../data/arcData'; export interface TimeFrame { label: string; @@ -56,7 +57,7 @@ function buildFrames(periodDays: number): TimeFrame[] { return frames; } -export function useAnimation(transactions: Transaction[], periodDays: number, allTimestamps: number[] = []) { +export function useAnimation(transactions: Transaction[], arcs: TransactionArc[], periodDays: number, allTimestamps: number[] = []) { const [active, setActive] = useState(false); const [currentIndex, setCurrentIndex] = useState(0); const [playing, setPlaying] = useState(false); @@ -95,6 +96,13 @@ export function useAnimation(transactions: Transaction[], periodDays: number, al return transactions.filter((t) => t.timestamp >= frame.from && t.timestamp < frame.to); }, [active, transactions, frames, currentIndex]); + const visibleArcs = useMemo(() => { + if (!active || frames.length === 0) return arcs; + const frame = frames[currentIndex]; + if (!frame) return arcs; + return arcs.filter((a) => a.timestamp >= frame.from && a.timestamp < frame.to); + }, [active, arcs, frames, currentIndex]); + // Nombre total de transfers (géo + non-géo) dans la frame courante const frameTotalCount = useMemo(() => { if (!active || frames.length === 0 || allTimestamps.length === 0) return null; @@ -117,6 +125,7 @@ export function useAnimation(transactions: Transaction[], periodDays: number, al frames, currentFrame: frames[currentIndex] ?? null, visibleTransactions, + visibleArcs, frameTotalCount, }; } diff --git a/src/services/DataService.ts b/src/services/DataService.ts index 1ccc547..3968270 100644 --- a/src/services/DataService.ts +++ b/src/services/DataService.ts @@ -13,12 +13,16 @@ */ import { fetchTransfers, buildIdentityKeyMap, fetchCurrentUD } from './adapters/SubsquidAdapter'; -import { resolveGeoByKeys } from './adapters/CesiumAdapter'; +import { resolveGeoByKeys, cleanCityName } from './adapters/CesiumAdapter'; import { getTransactionsForPeriod, computeStats, type Transaction, } from '../data/mockData'; +import { + buildMockArcs, + type TransactionArc, +} from '../data/arcData'; const USE_LIVE_API = import.meta.env.VITE_USE_LIVE_API === 'true'; @@ -43,15 +47,16 @@ async function getIdentityKeyMap(): Promise> { } async function fetchLiveTransactions(periodDays: number): Promise<{ - geolocated: Transaction[]; - totalCount: number; - totalVolume: number; + geolocated: Transaction[]; + arcs: TransactionArc[]; + totalCount: number; + totalVolume: number; allTimestamps: number[]; }> { // ~400 tx/jour sur le réseau Ğ1v2 → marge ×1.5 arrondie, minimum 2000 const limit = Math.max(2000, Math.ceil(periodDays * 600)); const { transfers: rawTransfers, totalCount } = await fetchTransfers(periodDays, limit); - if (rawTransfers.length === 0) return { geolocated: [], totalCount: 0, totalVolume: 0, allTimestamps: [] }; + if (rawTransfers.length === 0) return { geolocated: [], arcs: [], totalCount: 0, totalVolume: 0, allTimestamps: [] }; const totalVolume = rawTransfers.reduce((s, t) => s + t.amount, 0); @@ -63,15 +68,16 @@ async function fetchLiveTransactions(periodDays: number): Promise<{ console.warn('Identity key map indisponible :', err); } - // Clés Duniter uniques des émetteurs - const duniterKeys = [...new Set( - rawTransfers.map((t) => keyMap.get(t.fromId)).filter(Boolean) as string[] - )]; + // Clés Duniter uniques des émetteurs ET destinataires (un seul appel Cesium+) + const allDuniterKeys = [...new Set([ + ...rawTransfers.map((t) => keyMap.get(t.fromId)), + ...rawTransfers.map((t) => keyMap.get(t.toId)), + ].filter(Boolean) as string[])]; // Résolution géo par clé Duniter (_id Cesium+) let geoMap = new Map(); try { - const profiles = await resolveGeoByKeys(duniterKeys); + const profiles = await resolveGeoByKeys(allDuniterKeys); for (const [key, p] of profiles) { geoMap.set(key, { lat: p.lat, lng: p.lng, city: p.city, countryCode: p.countryCode }); } @@ -79,28 +85,53 @@ async function fetchLiveTransactions(periodDays: number): Promise<{ console.warn('Cesium+ indisponible :', err); } - // Seules les transactions avec un profil géo entrent dans le heatmap - const geolocated: Transaction[] = []; - for (const t of rawTransfers) { - const duniterKey = keyMap.get(t.fromId); - if (!duniterKey) continue; - const geo = geoMap.get(duniterKey); - if (!geo) continue; + const geolocated: Transaction[] = []; + const arcs: TransactionArc[] = []; + for (const t of rawTransfers) { + const fromDuniterKey = keyMap.get(t.fromId); + if (!fromDuniterKey) continue; + const fromGeo = geoMap.get(fromDuniterKey); + if (!fromGeo) continue; + + const fromCity = cleanCityName(fromGeo.city); + + // Heatmap : émetteur géolocalisé geolocated.push({ id: t.id, timestamp: t.timestamp, - lat: geo.lat, - lng: geo.lng, + lat: fromGeo.lat, + lng: fromGeo.lng, amount: t.amount, - city: geo.city, - countryCode: geo.countryCode, + city: fromCity, + countryCode: fromGeo.countryCode, fromKey: t.fromId, toKey: t.toId, }); + + // Arc : les deux extrémités géolocalisées + villes différentes + const toDuniterKey = keyMap.get(t.toId); + if (!toDuniterKey) continue; + const toGeo = geoMap.get(toDuniterKey); + if (!toGeo) continue; + + const toCity = cleanCityName(toGeo.city); + if (fromCity === toCity) continue; + + arcs.push({ + id: `${t.id}-arc`, + timestamp: t.timestamp, + amount: t.amount, + fromLat: fromGeo.lat, fromLng: fromGeo.lng, + fromCity, fromCountry: fromGeo.countryCode, + fromKey: t.fromId, + toLat: toGeo.lat, toLng: toGeo.lng, + toCity, toCountry: toGeo.countryCode, + toKey: t.toId, + }); } - return { geolocated, totalCount, totalVolume, allTimestamps: rawTransfers.map((t) => t.timestamp) }; + return { geolocated, arcs, totalCount, totalVolume, allTimestamps: rawTransfers.map((t) => t.timestamp) }; } // --------------------------------------------------------------------------- @@ -114,11 +145,12 @@ export interface PeriodStats { } export interface DataResult { - transactions: Transaction[]; // uniquement géolocalisées → heatmap + transactions: Transaction[]; // uniquement géolocalisées → heatmap + arcs: TransactionArc[]; // les deux extrémités géolocalisées → vue flux stats: PeriodStats; source: 'live' | 'mock'; - currentUD: number; // valeur du DU courant en Ğ1 - allTimestamps: number[]; // timestamps de TOUS les transfers (géo + non-géo) + currentUD: number; // valeur du DU courant en Ğ1 + allTimestamps: number[]; // timestamps de TOUS les transfers (géo + non-géo) } export async function fetchData(periodDays: number): Promise { @@ -126,8 +158,10 @@ export async function fetchData(periodDays: number): Promise { await new Promise((r) => setTimeout(r, 80)); const transactions = getTransactionsForPeriod(periodDays); const base = computeStats(transactions); + const arcs = buildMockArcs(transactions); return { transactions, + arcs, stats: { ...base, geoCount: transactions.length }, source: 'mock', currentUD: 11.78, @@ -135,7 +169,7 @@ export async function fetchData(periodDays: number): Promise { }; } - const [{ geolocated, totalCount, totalVolume, allTimestamps }, currentUD] = await Promise.all([ + const [{ geolocated, arcs, totalCount, totalVolume, allTimestamps }, currentUD] = await Promise.all([ fetchLiveTransactions(periodDays), getCurrentUD(), ]); @@ -143,6 +177,7 @@ export async function fetchData(periodDays: number): Promise { return { transactions: geolocated, + arcs, stats: { totalVolume, transactionCount: totalCount,