release: v1.0.0
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
syoul
2026-03-23 23:47:47 +01:00
11 changed files with 556 additions and 61 deletions
+1
View File
@@ -30,3 +30,4 @@ dist-ssr
/docs-plan/ /docs-plan/
/docs-syoul/ /docs-syoul/
/docs-bugs/
+57 -6
View File
@@ -2,9 +2,12 @@ import { useState, useEffect } from 'react';
import { StatsPanel } from './components/StatsPanel'; import { StatsPanel } from './components/StatsPanel';
import { PeriodSelector } from './components/PeriodSelector'; import { PeriodSelector } from './components/PeriodSelector';
import { HeatMap } from './components/HeatMap'; import { HeatMap } from './components/HeatMap';
import { AnimationPlayer } from './components/AnimationPlayer';
import { fetchData } from './services/DataService'; import { fetchData } from './services/DataService';
import type { PeriodStats } from './services/DataService'; import type { PeriodStats } from './services/DataService';
import type { Transaction } from './data/mockData'; import type { Transaction } from './data/mockData';
import { computeStats } from './data/mockData';
import { useAnimation } from './hooks/useAnimation';
export default function App() { export default function App() {
const [periodDays, setPeriodDays] = useState(7); const [periodDays, setPeriodDays] = useState(7);
@@ -14,6 +17,15 @@ export default function App() {
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [lastUpdate, setLastUpdate] = useState<Date | null>(null); const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
const [source, setSource] = useState<'live' | 'mock'>('mock'); const [source, setSource] = useState<'live' | 'mock'>('mock');
const [currentUD, setCurrentUD] = useState<number>(11.78);
const [allTimestamps, setAllTimestamps] = useState<number[]>([]);
const animation = useAnimation(transactions, periodDays, allTimestamps);
const handlePeriodChange = (days: number) => {
animation.deactivate();
setPeriodDays(days);
};
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -22,11 +34,13 @@ export default function App() {
if (showLoading) setLoading(true); if (showLoading) setLoading(true);
else setRefreshing(true); else setRefreshing(true);
fetchData(periodDays) fetchData(periodDays)
.then(({ transactions, stats, source }) => { .then(({ transactions, stats, source, currentUD, allTimestamps }) => {
if (!cancelled) { if (!cancelled) {
setTransactions(transactions); setTransactions(transactions);
setStats(stats); setStats(stats);
setSource(source); setSource(source);
setCurrentUD(currentUD);
setAllTimestamps(allTimestamps);
setLastUpdate(new Date()); setLastUpdate(new Date());
} }
}) })
@@ -42,22 +56,44 @@ export default function App() {
return () => { cancelled = true; clearInterval(interval); }; return () => { cancelled = true; clearInterval(interval); };
}, [periodDays]); }, [periodDays]);
// Stats calculées 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;
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 */}
<StatsPanel stats={stats} loading={loading} periodDays={periodDays} source={source} /> <StatsPanel
stats={visibleStats}
loading={loading}
periodDays={periodDays}
source={source}
currentUD={currentUD}
animationLabel={animation.active ? (animation.currentFrame?.label ?? undefined) : undefined}
/>
{/* Map area */} {/* Map area */}
<div className="relative flex-1 min-w-0"> <div className="relative flex-1 min-w-0">
<HeatMap transactions={transactions} /> <HeatMap transactions={animation.visibleTransactions} />
{/* 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 left-1/2 -translate-x-1/2 z-[1000]">
<PeriodSelector value={periodDays} onChange={setPeriodDays} /> <PeriodSelector
value={periodDays}
onChange={handlePeriodChange}
animationActive={animation.active}
onAnimate={() => animation.active ? animation.deactivate() : animation.activate()}
/>
</div> </div>
{/* Transaction count + source badge */} {/* Transaction count + source badge (masqués en mode animation) */}
{!loading && ( {!loading && !animation.active && (
<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
@@ -74,6 +110,21 @@ export default function App() {
</div> </div>
)} )}
{/* Animation player */}
{animation.active && (
<AnimationPlayer
frames={animation.frames}
currentIndex={animation.currentIndex}
playing={animation.playing}
speed={animation.speed}
onSeek={animation.seek}
onPlay={animation.play}
onPause={animation.pause}
onSpeedChange={animation.setSpeed}
onClose={animation.deactivate}
/>
)}
{/* Loading overlay */} {/* Loading overlay */}
{loading && ( {loading && (
<div className="absolute inset-0 z-[999] flex items-center justify-center bg-[#0a0b0f]/60 backdrop-blur-sm"> <div className="absolute inset-0 z-[999] flex items-center justify-center bg-[#0a0b0f]/60 backdrop-blur-sm">
+109
View File
@@ -0,0 +1,109 @@
import type { TimeFrame } from '../hooks/useAnimation';
interface AnimationPlayerProps {
frames: TimeFrame[];
currentIndex: number;
playing: boolean;
speed: 1 | 2 | 4;
onSeek: (i: number) => void;
onPlay: () => void;
onPause: () => void;
onSpeedChange: (s: 1 | 2 | 4) => void;
onClose: () => void;
}
export function AnimationPlayer({
frames,
currentIndex,
playing,
speed,
onSeek,
onPlay,
onPause,
onSpeedChange,
onClose,
}: AnimationPlayerProps) {
const frame = frames[currentIndex];
return (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-[1001] w-[min(640px,90vw)]">
<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">
{/* Frame label + position */}
<div className="flex items-center justify-between">
<span className="text-[#d4a843] text-sm font-medium">
{frame?.label ?? '—'}
</span>
<span className="text-[#4b5563] text-xs tabular-nums">
{currentIndex + 1} / {frames.length}
</span>
</div>
{/* Slider */}
<input
type="range"
min={0}
max={frames.length - 1}
value={currentIndex}
onChange={(e) => onSeek(Number(e.target.value))}
className="w-full h-1 accent-[#d4a843] cursor-pointer"
/>
{/* Controls row */}
<div className="flex items-center justify-between">
{/* Playback buttons */}
<div className="flex items-center gap-1">
<button
onClick={() => onSeek(Math.max(0, currentIndex - 1))}
className="px-2.5 py-1.5 text-[#6b7280] hover:text-white transition-colors text-sm"
title="Frame précédente"
>
</button>
<button
onClick={playing ? onPause : onPlay}
className="px-4 py-1.5 bg-[#d4a843] text-[#0a0b0f] rounded-lg font-bold text-sm hover:bg-[#e8c060] transition-colors min-w-[52px] text-center shadow-[0_0_10px_rgba(212,168,67,0.3)]"
>
{playing ? '⏸' : '▶'}
</button>
<button
onClick={() => onSeek(Math.min(frames.length - 1, currentIndex + 1))}
className="px-2.5 py-1.5 text-[#6b7280] hover:text-white transition-colors text-sm"
title="Frame suivante"
>
</button>
</div>
{/* Speed selector */}
<div className="flex items-center gap-1">
<span className="text-[#4b5563] text-xs mr-1">Vitesse</span>
{([1, 2, 4] as const).map((s) => (
<button
key={s}
onClick={() => onSpeedChange(s)}
className={`px-2 py-1 rounded text-xs font-medium transition-colors ${
speed === s
? 'bg-[#d4a843] text-[#0a0b0f]'
: 'text-[#6b7280] hover:text-[#d4a843] hover:bg-[#1a1b23]'
}`}
>
×{s}
</button>
))}
</div>
{/* Close */}
<button
onClick={onClose}
className="text-[#4b5563] hover:text-white transition-colors px-2 py-1 text-sm ml-2"
title="Quitter l'animation"
>
</button>
</div>
</div>
</div>
);
}
+79 -10
View File
@@ -33,6 +33,12 @@ export function HeatMap({ transactions }: HeatMapProps) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<L.Map | null>(null); const mapRef = useRef<L.Map | null>(null);
const heatRef = useRef<L.HeatLayer | null>(null); const heatRef = useRef<L.HeatLayer | null>(null);
// Two img overlays that cross-fade between each other.
// The canvas opacity is NEVER touched — it stays at leaflet's default.
const prevRef = useRef<HTMLImageElement | null>(null);
const nextRef = useRef<HTMLImageElement | null>(null);
// Src of the currently visible frame (so prev can be initialised correctly)
const currentSrcRef = useRef<string>('');
// Initialize map once // Initialize map once
useEffect(() => { useEffect(() => {
@@ -64,32 +70,95 @@ export function HeatMap({ transactions }: HeatMapProps) {
}; };
}, []); }, []);
// Update heatmap data when transactions change // Crossfade: two img overlays swap roles each frame.
// Canvas is never hidden — we only read its pixel data via toDataURL().
useEffect(() => { useEffect(() => {
if (!heatRef.current || !mapRef.current) return; if (!heatRef.current || !mapRef.current) return;
// Normalize amounts for intensity (log scale feels better visually) const canvas = (heatRef.current as unknown as { _canvas?: HTMLCanvasElement })._canvas;
const maxAmount = Math.max(...transactions.map((t) => t.amount), 1); const prev = prevRef.current;
const next = nextRef.current;
const draw = () => {
const maxAmount = Math.max(...transactions.map((t) => t.amount), 1);
const points: L.HeatLatLngTuple[] = transactions.map((tx) => [ const points: L.HeatLatLngTuple[] = transactions.map((tx) => [
tx.lat, tx.lat,
tx.lng, tx.lng,
Math.min(Math.log1p(tx.amount) / Math.log1p(maxAmount), 1), Math.min(Math.log1p(tx.amount) / Math.log1p(maxAmount), 1),
]); ]);
// Guard: only update if the heat layer is still attached to the map
try { try {
heatRef.current.setLatLngs(points); heatRef.current?.setLatLngs(points);
} catch { } catch {
// map was torn down (React StrictMode double-invoke), ignore // map was torn down (React StrictMode double-invoke), ignore
} }
};
if (!canvas || !prev || !next) {
draw();
return;
}
// --- Phase 1 (synchronous): set start state ---
// prev shows the current frame (or nothing on first run)
prev.src = currentSrcRef.current;
prev.style.transition = 'none';
prev.style.opacity = currentSrcRef.current ? '1' : '0';
// next is hidden and will receive the incoming frame
next.style.transition = 'none';
next.style.opacity = '0';
void prev.offsetWidth; // flush CSS so transitions start cleanly
// Ask leaflet to draw new data (schedules an internal RAF)
draw();
// --- Phase 2 (after leaflet redraws): capture new frame, start crossfade ---
// leaflet.heat schedules its own RAF inside draw() above.
// Our raf1 is queued *after* leaflet's RAF, so when raf1 fires,
// leaflet has already redrawn the canvas.
let raf2 = 0;
const raf1 = requestAnimationFrame(() => {
raf2 = requestAnimationFrame(() => {
let src: string;
try {
src = canvas.toDataURL();
} catch {
return; // map torn down
}
currentSrcRef.current = src;
next.src = src;
void next.offsetWidth; // ensure img is decoded before transition
const DUR = '0.55s ease-in-out';
prev.style.transition = `opacity ${DUR}`;
prev.style.opacity = '0';
next.style.transition = `opacity ${DUR}`;
next.style.opacity = '1';
});
});
return () => { cancelAnimationFrame(raf1); cancelAnimationFrame(raf2); };
}, [transactions]); }, [transactions]);
return ( return (
<div <div className="w-full h-full relative" style={{ minHeight: 0 }}>
ref={containerRef} <div ref={containerRef} className="absolute inset-0" />
className="w-full h-full" {/* prev: outgoing frame */}
style={{ minHeight: 0 }} <img
ref={prevRef}
alt=""
className="absolute inset-0 w-full h-full pointer-events-none"
style={{ opacity: 0, zIndex: 500 }}
/> />
{/* next: incoming frame — sits on top of prev during crossfade */}
<img
ref={nextRef}
alt=""
className="absolute inset-0 w-full h-full pointer-events-none"
style={{ opacity: 0, zIndex: 501 }}
/>
</div>
); );
} }
+84 -4
View File
@@ -1,6 +1,10 @@
import { useState, useRef, useEffect } from 'react';
interface PeriodSelectorProps { interface PeriodSelectorProps {
value: number; value: number;
onChange: (days: number) => void; onChange: (days: number) => void;
animationActive: boolean;
onAnimate: () => void;
} }
const PERIODS = [ const PERIODS = [
@@ -9,16 +13,40 @@ const PERIODS = [
{ label: '30 jours', days: 30 }, { label: '30 jours', days: 30 },
]; ];
export function PeriodSelector({ value, onChange }: PeriodSelectorProps) { const PRESET_DAYS = new Set([1, 7, 30]);
export function PeriodSelector({ value, onChange, animationActive, onAnimate }: PeriodSelectorProps) {
const [customOpen, setCustomOpen] = useState(false);
const [inputVal, setInputVal] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
// Ouvre le champ custom avec la valeur courante pré-remplie
const openCustom = () => {
setInputVal(PRESET_DAYS.has(value) ? '' : String(value));
setCustomOpen(true);
};
useEffect(() => {
if (customOpen) inputRef.current?.focus();
}, [customOpen]);
const commit = () => {
const n = parseInt(inputVal, 10);
if (n >= 1 && n <= 365) onChange(n);
setCustomOpen(false);
};
const isCustomActive = !PRESET_DAYS.has(value);
return ( return (
<div className="flex gap-1 bg-[#0f1016] border border-[#2e2f3a] rounded-lg p-1"> <div className="flex gap-1 bg-[#0f1016] border border-[#2e2f3a] rounded-lg p-1 items-center">
{PERIODS.map(({ label, days }) => ( {PERIODS.map(({ label, days }) => (
<button <button
key={days} key={days}
onClick={() => onChange(days)} 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-1.5 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer
${value === days ${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]'
} }
@@ -27,6 +55,58 @@ export function PeriodSelector({ value, onChange }: PeriodSelectorProps) {
{label} {label}
</button> </button>
))} ))}
<div className="w-px mx-1 bg-[#2e2f3a] self-stretch" />
{/* Bouton Personnaliser + champ inline */}
{customOpen ? (
<div className="flex items-center gap-1">
<input
ref={inputRef}
type="number"
min={1}
max={365}
value={inputVal}
onChange={(e) => setInputVal(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') commit();
if (e.key === 'Escape') setCustomOpen(false);
}}
onBlur={commit}
placeholder="jours"
className="w-16 px-2 py-1 text-sm bg-[#1a1b23] border border-[#d4a843] rounded-md text-[#d4a843] text-center focus:outline-none tabular-nums [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none"
/>
<span className="text-[#6b7280] text-xs">j</span>
</div>
) : (
<button
onClick={openCustom}
className={`
px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer
${isCustomActive
? 'bg-[#d4a843] text-[#0a0b0f] shadow-[0_0_12px_rgba(212,168,67,0.4)]'
: 'text-[#6b7280] hover:text-[#d4a843] hover:bg-[#1a1b23]'
}
`}
>
{isCustomActive ? `${value} jours` : 'Personnaliser'}
</button>
)}
<div className="w-px mx-1 bg-[#2e2f3a] self-stretch" />
<button
onClick={onAnimate}
className={`
px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer
${animationActive
? 'bg-[#d4a843] text-[#0a0b0f] shadow-[0_0_12px_rgba(212,168,67,0.4)]'
: 'text-[#6b7280] hover:text-[#d4a843] hover:bg-[#1a1b23]'
}
`}
>
Animer
</button>
</div> </div>
); );
} }
+27 -11
View File
@@ -6,6 +6,8 @@ interface StatsPanelProps {
loading: boolean; loading: boolean;
periodDays: number; periodDays: number;
source: 'live' | 'mock'; source: 'live' | 'mock';
currentUD: number;
animationLabel?: string;
} }
const MEDALS = ['🥇', '🥈', '🥉']; const MEDALS = ['🥇', '🥈', '🥉'];
@@ -24,7 +26,14 @@ function StatCard({ label, value, sub, delta }: { label: string; value: string;
); );
} }
export function StatsPanel({ stats, loading, periodDays, source }: StatsPanelProps) { function formatDU(g1: number, ud: number): string {
const du = g1 / ud;
if (du < 10) return `${du.toFixed(2)} DU`;
if (du < 100) return `${du.toFixed(1)} DU`;
return `${Math.round(du).toLocaleString('fr-FR')} DU`;
}
export function StatsPanel({ stats, loading, periodDays, source, currentUD, animationLabel }: 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);
@@ -46,9 +55,6 @@ export function StatsPanel({ stats, loading, periodDays, source }: StatsPanelPro
// Mémorise les stats après le rendu // Mémorise les stats après le rendu
if (stats && !loading) prevStats.current = stats; if (stats && !loading) prevStats.current = stats;
const geoPct = stats && stats.transactionCount > 0
? Math.round((stats.geoCount / stats.transactionCount) * 100)
: null;
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-72 shrink-0 flex flex-col gap-4 bg-[#0a0b0f]/95 backdrop-blur-sm border-r border-[#1e1f2a] p-5 overflow-y-auto">
@@ -70,7 +76,10 @@ export function StatsPanel({ stats, loading, periodDays, source }: StatsPanelPro
{/* Period label */} {/* Period label */}
<p className="text-[#4b5563] text-xs border-t border-[#1e1f2a] pt-3"> <p className="text-[#4b5563] text-xs border-t border-[#1e1f2a] pt-3">
Période : <span className="text-[#6b7280]">{periodLabel}</span> {animationLabel
? <><span className="text-[#d4a843]"></span> <span className="text-[#d4a843]">{animationLabel}</span></>
: <>Période : <span className="text-[#6b7280]">{periodLabel}</span></>
}
</p> </p>
{/* Stats */} {/* Stats */}
@@ -85,16 +94,22 @@ export function StatsPanel({ stats, loading, periodDays, source }: StatsPanelPro
<StatCard <StatCard
label="Volume total" label="Volume total"
value={`${stats.totalVolume.toLocaleString('fr-FR', { maximumFractionDigits: 2 })} Ğ1`} 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} delta={prevVolume !== null ? (stats.totalVolume > prevVolume ? 'up' : stats.totalVolume < prevVolume ? 'down' : null) : null}
/> />
<StatCard <StatCard
label="Transactions" label="Transactions"
value={stats.transactionCount.toLocaleString('fr-FR')} value={stats.transactionCount.toLocaleString('fr-FR')}
sub={`${(stats.totalVolume / (stats.transactionCount || 1)).toFixed(2)} Ğ1 / tx`} 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} delta={prevTxCount !== null ? (stats.transactionCount > prevTxCount ? 'up' : stats.transactionCount < prevTxCount ? 'down' : null) : null}
/> />
{/* Couverture géo — uniquement en mode live */} {/* Couverture géo — transactionCount inclut le total réel de la frame */}
{source === 'live' && geoPct !== null && ( {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="bg-[#0f1016] border border-[#2e2f3a] rounded-xl p-3">
<div className="flex justify-between items-center mb-1.5"> <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-[#4b5563] text-xs uppercase tracking-widest">Géolocalisées</p>
@@ -103,12 +118,13 @@ export function StatsPanel({ stats, loading, periodDays, source }: StatsPanelPro
<div className="w-full bg-[#1e1f2a] rounded-full h-1.5"> <div className="w-full bg-[#1e1f2a] rounded-full h-1.5">
<div <div
className="bg-[#d4a843] h-1.5 rounded-full transition-all duration-500" className="bg-[#d4a843] h-1.5 rounded-full transition-all duration-500"
style={{ width: `${geoPct}%` }} style={{ width: `${pct}%` }}
/> />
</div> </div>
<p className="text-[#4b5563] text-xs mt-1 text-right">{geoPct}% via Cesium+</p> <p className="text-[#4b5563] text-xs mt-1 text-right">{pct}% via Cesium+</p>
</div> </div>
)} );
})()}
</div> </div>
) : null} ) : null}
+5 -1
View File
@@ -85,11 +85,15 @@ function generateTransactions(count: number, maxAgeMs: number): Transaction[] {
return transactions.sort((a, b) => b.timestamp - a.timestamp); return transactions.sort((a, b) => b.timestamp - a.timestamp);
} }
const POOL_GENERATED_AT = Date.now();
const TRANSACTION_POOL = generateTransactions(2400, 30 * 24 * 60 * 60 * 1000); const TRANSACTION_POOL = generateTransactions(2400, 30 * 24 * 60 * 60 * 1000);
export function getTransactionsForPeriod(periodDays: number): Transaction[] { export function getTransactionsForPeriod(periodDays: number): Transaction[] {
const drift = Date.now() - POOL_GENERATED_AT;
const cutoff = Date.now() - periodDays * 24 * 60 * 60 * 1000; const cutoff = Date.now() - periodDays * 24 * 60 * 60 * 1000;
return TRANSACTION_POOL.filter((tx) => tx.timestamp >= cutoff); return TRANSACTION_POOL
.map((tx) => ({ ...tx, timestamp: tx.timestamp + drift }))
.filter((tx) => tx.timestamp >= cutoff);
} }
export function computeStats(transactions: Transaction[]) { export function computeStats(transactions: Transaction[]) {
+122
View File
@@ -0,0 +1,122 @@
import { useState, useMemo, useEffect } from 'react';
import type { Transaction } from '../data/mockData';
export interface TimeFrame {
label: string;
from: number; // Unix ms
to: number; // Unix ms
}
function buildFrames(periodDays: number): TimeFrame[] {
const now = Date.now();
const start = now - periodDays * 24 * 60 * 60 * 1000;
const fmt = (ms: number, opts: Intl.DateTimeFormatOptions) =>
new Date(ms).toLocaleDateString('fr-FR', opts);
if (periodDays === 1) {
return Array.from({ length: 24 }, (_, i) => {
const from = start + i * 3_600_000;
const to = from + 3_600_000;
const h = new Date(from).getHours();
return {
label: `${fmt(from, { weekday: 'short', day: 'numeric', month: 'short' })} · ${h}h ${h + 1}h`,
from,
to,
};
});
}
if (periodDays === 7) {
return Array.from({ length: 7 }, (_, i) => {
const from = start + i * 86_400_000;
const to = from + 86_400_000;
return {
label: fmt(from, { weekday: 'long', day: 'numeric', month: 'short' }),
from,
to,
};
});
}
// 30 days → half-week frames (3.5 days ≈ 910 frames)
const HALF_WEEK = 3.5 * 86_400_000;
const frames: TimeFrame[] = [];
let cursor = start;
while (cursor < now) {
const from = cursor;
const to = Math.min(cursor + HALF_WEEK, now);
frames.push({
label: `${fmt(from, { weekday: 'short', day: 'numeric', month: 'short' })} ${fmt(to - 1, { weekday: 'short', day: 'numeric', month: 'short' })}`,
from,
to,
});
cursor = to;
}
return frames;
}
export function useAnimation(transactions: Transaction[], periodDays: number, allTimestamps: number[] = []) {
const [active, setActive] = useState(false);
const [currentIndex, setCurrentIndex] = useState(0);
const [playing, setPlaying] = useState(false);
const [speed, setSpeed] = useState<1 | 2 | 4>(2);
const frames = useMemo(() => buildFrames(periodDays), [periodDays]);
// Reset cursor when period or activation changes.
// Stop playback only on deactivation — not on activation, so activate() can
// start playing immediately without being overridden by this effect.
useEffect(() => {
setCurrentIndex(0);
if (!active) setPlaying(false);
}, [periodDays, active]);
// Auto-advance: one step every (2000 / speed) ms
useEffect(() => {
if (!playing || !active) return;
const delay = 1500 / speed; // ×1=1500ms, ×2=750ms, ×4=375ms
const t = setTimeout(() => {
setCurrentIndex((i) => {
if (i >= frames.length - 1) {
setPlaying(false);
return i;
}
return i + 1;
});
}, delay);
return () => clearTimeout(t);
}, [playing, active, currentIndex, speed, frames.length]);
const visibleTransactions = useMemo(() => {
if (!active || frames.length === 0) return transactions;
const frame = frames[currentIndex];
if (!frame) return transactions;
return transactions.filter((t) => t.timestamp >= frame.from && t.timestamp < frame.to);
}, [active, transactions, 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;
const frame = frames[currentIndex];
if (!frame) return null;
return allTimestamps.filter((ts) => ts >= frame.from && ts < frame.to).length;
}, [active, allTimestamps, frames, currentIndex]);
return {
active,
activate: () => { setActive(true); setSpeed(1); setPlaying(true); },
deactivate: () => { setActive(false); },
playing,
play: () => setPlaying(true),
pause: () => setPlaying(false),
currentIndex,
seek: (i: number) => { setCurrentIndex(i); setPlaying(false); },
speed,
setSpeed,
frames,
currentFrame: frames[currentIndex] ?? null,
visibleTransactions,
frameTotalCount,
};
}
+27 -5
View File
@@ -12,7 +12,7 @@
* Pour activer : définir VITE_USE_LIVE_API=true dans .env.local * Pour activer : définir VITE_USE_LIVE_API=true dans .env.local
*/ */
import { fetchTransfers, buildIdentityKeyMap } from './adapters/SubsquidAdapter'; import { fetchTransfers, buildIdentityKeyMap, fetchCurrentUD } from './adapters/SubsquidAdapter';
import { resolveGeoByKeys } from './adapters/CesiumAdapter'; import { resolveGeoByKeys } from './adapters/CesiumAdapter';
import { import {
getTransactionsForPeriod, getTransactionsForPeriod,
@@ -22,6 +22,16 @@ import {
const USE_LIVE_API = import.meta.env.VITE_USE_LIVE_API === 'true'; const USE_LIVE_API = import.meta.env.VITE_USE_LIVE_API === 'true';
// Cache du DU courant, valide 1 heure (le DU change tous les ~6 mois)
let udCache: { value: number; expiresAt: number } | null = null;
async function getCurrentUD(): Promise<number> {
if (udCache && Date.now() < udCache.expiresAt) return udCache.value;
const value = await fetchCurrentUD();
udCache = { value, expiresAt: Date.now() + 60 * 60 * 1000 };
return value;
}
// Cache de la carte identité SS58→DuniterKey, valide 10 minutes // Cache de la carte identité SS58→DuniterKey, valide 10 minutes
let keyMapCache: { map: Map<string, string>; expiresAt: number } | null = null; let keyMapCache: { map: Map<string, string>; expiresAt: number } | null = null;
@@ -36,9 +46,12 @@ async function fetchLiveTransactions(periodDays: number): Promise<{
geolocated: Transaction[]; geolocated: Transaction[];
totalCount: number; totalCount: number;
totalVolume: number; totalVolume: number;
allTimestamps: number[];
}> { }> {
const { transfers: rawTransfers, totalCount } = await fetchTransfers(periodDays); // ~400 tx/jour sur le réseau Ğ1v2 → marge ×1.5 arrondie, minimum 2000
if (rawTransfers.length === 0) return { geolocated: [], totalCount: 0, totalVolume: 0 }; 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: [] };
const totalVolume = rawTransfers.reduce((s, t) => s + t.amount, 0); const totalVolume = rawTransfers.reduce((s, t) => s + t.amount, 0);
@@ -87,7 +100,7 @@ async function fetchLiveTransactions(periodDays: number): Promise<{
}); });
} }
return { geolocated, totalCount, totalVolume }; return { geolocated, totalCount, totalVolume, allTimestamps: rawTransfers.map((t) => t.timestamp) };
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -104,6 +117,8 @@ export interface DataResult {
transactions: Transaction[]; // uniquement géolocalisées → heatmap transactions: Transaction[]; // uniquement géolocalisées → heatmap
stats: PeriodStats; stats: PeriodStats;
source: 'live' | 'mock'; source: 'live' | 'mock';
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<DataResult> { export async function fetchData(periodDays: number): Promise<DataResult> {
@@ -115,10 +130,15 @@ export async function fetchData(periodDays: number): Promise<DataResult> {
transactions, transactions,
stats: { ...base, geoCount: transactions.length }, stats: { ...base, geoCount: transactions.length },
source: 'mock', source: 'mock',
currentUD: 11.78,
allTimestamps: transactions.map((t) => t.timestamp),
}; };
} }
const { geolocated, totalCount, totalVolume } = await fetchLiveTransactions(periodDays); const [{ geolocated, totalCount, totalVolume, allTimestamps }, currentUD] = await Promise.all([
fetchLiveTransactions(periodDays),
getCurrentUD(),
]);
const base = computeStats(geolocated); const base = computeStats(geolocated);
return { return {
@@ -130,5 +150,7 @@ export async function fetchData(periodDays: number): Promise<DataResult> {
topCities: base.topCities, topCities: base.topCities,
}, },
source: 'live', source: 'live',
currentUD,
allTimestamps,
}; };
} }
+2 -2
View File
@@ -83,8 +83,8 @@ function countryCodeFromCity(city: string): string {
const HitSchema = z.object({ const HitSchema = z.object({
_id: z.string(), _id: z.string(),
_source: z.object({ _source: z.object({
title: z.string().optional(), title: z.string().nullable().optional(),
city: z.string().optional(), city: z.string().nullable().optional(),
geoPoint: z.unknown().optional(), geoPoint: z.unknown().optional(),
}), }),
}); });
+22 -1
View File
@@ -20,7 +20,7 @@ export const SUBSQUID_ENDPOINT = 'https://squidv2s.syoul.fr/v1/graphql';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const SubsquidTransferNodeSchema = z.object({ const SubsquidTransferNodeSchema = z.object({
id: z.string(), id: z.string(),
blockNumber: z.number().int().positive(), blockNumber: z.number().int(), // peut être négatif pour les blocs Ğ1v1 migrés
timestamp: z.string(), // ISO 8601 ex: "2026-03-22T14:53:36+00:00" timestamp: z.string(), // ISO 8601 ex: "2026-03-22T14:53:36+00:00"
amount: z.string(), // BigInt en string, en centimes Ğ1 amount: z.string(), // BigInt en string, en centimes Ğ1
fromId: z.string().nullable(), fromId: z.string().nullable(),
@@ -153,6 +153,27 @@ export async function buildIdentityKeyMap(): Promise<Map<string, string>> {
return result; return result;
} }
/** Retourne la valeur du DU courant en Ğ1 (ex : 11.78). Fallback hardcodé si indisponible. */
export async function fetchCurrentUD(): Promise<number> {
const UD_FALLBACK = 11.78; // valeur au bloc 225874 — mis à jour si la requête échoue
try {
const response = await fetch(SUBSQUID_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: `{ universalDividends(orderBy: BLOCK_NUMBER_DESC, first: 1) { nodes { amount } } }`,
}),
});
if (!response.ok) return UD_FALLBACK;
const raw = await response.json();
const amountStr: string | undefined = raw?.data?.universalDividends?.nodes?.[0]?.amount;
if (!amountStr) return UD_FALLBACK;
return parseInt(amountStr, 10) / 100;
} catch {
return UD_FALLBACK;
}
}
export interface FetchTransfersResult { export interface FetchTransfersResult {
transfers: RawTransfer[]; transfers: RawTransfer[];
totalCount: number; totalCount: number;