Compare commits
21 Commits
3aa3933b4c
...
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| b9bcfa8518 | |||
| 42286a8c0d | |||
| ee5e401185 | |||
| f29625c6bc | |||
| a2fdad46d4 | |||
| 45080d83ac | |||
| bea7cbe60f | |||
| bc61527b4e | |||
| ac2f5bc431 | |||
| 2debc3587a | |||
| 8a31b60716 | |||
| a9bf445747 | |||
| d7fef466f3 | |||
| 14d218e4ff | |||
| d50b30666b | |||
| 30057a07fb | |||
| 40c09e2e4b | |||
| 0aea929b48 | |||
| d4cc4fbd3a | |||
| bf2dbd6d35 | |||
| 2fce063703 |
+9
-2
@@ -17,8 +17,10 @@ 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);
|
const animation = useAnimation(transactions, periodDays, allTimestamps);
|
||||||
|
|
||||||
const handlePeriodChange = (days: number) => {
|
const handlePeriodChange = (days: number) => {
|
||||||
animation.deactivate();
|
animation.deactivate();
|
||||||
@@ -32,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());
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -57,6 +61,8 @@ export default function App() {
|
|||||||
? {
|
? {
|
||||||
...computeStats(animation.visibleTransactions),
|
...computeStats(animation.visibleTransactions),
|
||||||
geoCount: animation.visibleTransactions.length,
|
geoCount: animation.visibleTransactions.length,
|
||||||
|
// frameTotalCount = total réel (géo + non-géo) dans cette frame
|
||||||
|
transactionCount: animation.frameTotalCount ?? animation.visibleTransactions.length,
|
||||||
}
|
}
|
||||||
: stats;
|
: stats;
|
||||||
|
|
||||||
@@ -68,6 +74,7 @@ export default function App() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
periodDays={periodDays}
|
periodDays={periodDays}
|
||||||
source={source}
|
source={source}
|
||||||
|
currentUD={currentUD}
|
||||||
animationLabel={animation.active ? (animation.currentFrame?.label ?? undefined) : undefined}
|
animationLabel={animation.active ? (animation.currentFrame?.label ?? undefined) : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
+79
-10
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
|
||||||
interface PeriodSelectorProps {
|
interface PeriodSelectorProps {
|
||||||
value: number;
|
value: number;
|
||||||
onChange: (days: number) => void;
|
onChange: (days: number) => void;
|
||||||
@@ -11,16 +13,40 @@ const PERIODS = [
|
|||||||
{ label: '30 jours', days: 30 },
|
{ label: '30 jours', days: 30 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const PRESET_DAYS = new Set([1, 7, 30]);
|
||||||
|
|
||||||
export function PeriodSelector({ value, onChange, animationActive, onAnimate }: PeriodSelectorProps) {
|
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]'
|
||||||
}
|
}
|
||||||
@@ -29,7 +55,46 @@ export function PeriodSelector({ value, onChange, animationActive, onAnimate }:
|
|||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="w-px mx-1 bg-[#2e2f3a] self-stretch" />
|
<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
|
<button
|
||||||
onClick={onAnimate}
|
onClick={onAnimate}
|
||||||
className={`
|
className={`
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ interface StatsPanelProps {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
periodDays: number;
|
periodDays: number;
|
||||||
source: 'live' | 'mock';
|
source: 'live' | 'mock';
|
||||||
|
currentUD: number;
|
||||||
animationLabel?: string;
|
animationLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,7 +26,14 @@ function StatCard({ label, value, sub, delta }: { label: string; value: string;
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatsPanel({ stats, loading, periodDays, source, animationLabel }: 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);
|
||||||
|
|
||||||
@@ -47,9 +55,6 @@ export function StatsPanel({ stats, loading, periodDays, source, animationLabel
|
|||||||
|
|
||||||
// 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">
|
||||||
@@ -89,16 +94,22 @@ export function StatsPanel({ stats, loading, periodDays, source, animationLabel
|
|||||||
<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>
|
||||||
@@ -107,12 +118,13 @@ export function StatsPanel({ stats, loading, periodDays, source, animationLabel
|
|||||||
<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}
|
||||||
|
|
||||||
|
|||||||
+21
-11
@@ -39,42 +39,43 @@ function buildFrames(periodDays: number): TimeFrame[] {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 30 days → weekly frames
|
// 30 days → half-week frames (3.5 days ≈ 9–10 frames)
|
||||||
|
const HALF_WEEK = 3.5 * 86_400_000;
|
||||||
const frames: TimeFrame[] = [];
|
const frames: TimeFrame[] = [];
|
||||||
let cursor = start;
|
let cursor = start;
|
||||||
let week = 1;
|
|
||||||
while (cursor < now) {
|
while (cursor < now) {
|
||||||
const from = cursor;
|
const from = cursor;
|
||||||
const to = Math.min(cursor + 7 * 86_400_000, now);
|
const to = Math.min(cursor + HALF_WEEK, now);
|
||||||
frames.push({
|
frames.push({
|
||||||
label: `Semaine ${week} · ${fmt(from, { day: 'numeric', month: 'short' })} – ${fmt(to - 1, { day: 'numeric', month: 'short' })}`,
|
label: `${fmt(from, { weekday: 'short', day: 'numeric', month: 'short' })} – ${fmt(to - 1, { weekday: 'short', day: 'numeric', month: 'short' })}`,
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
});
|
});
|
||||||
cursor = to;
|
cursor = to;
|
||||||
week++;
|
|
||||||
}
|
}
|
||||||
return frames;
|
return frames;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAnimation(transactions: Transaction[], periodDays: number) {
|
export function useAnimation(transactions: Transaction[], periodDays: number, allTimestamps: number[] = []) {
|
||||||
const [active, setActive] = useState(false);
|
const [active, setActive] = useState(false);
|
||||||
const [currentIndex, setCurrentIndex] = useState(0);
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
const [playing, setPlaying] = useState(false);
|
const [playing, setPlaying] = useState(false);
|
||||||
const [speed, setSpeed] = useState<1 | 2 | 4>(1);
|
const [speed, setSpeed] = useState<1 | 2 | 4>(2);
|
||||||
|
|
||||||
const frames = useMemo(() => buildFrames(periodDays), [periodDays]);
|
const frames = useMemo(() => buildFrames(periodDays), [periodDays]);
|
||||||
|
|
||||||
// Reset cursor and playback when period or activation changes
|
// 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(() => {
|
useEffect(() => {
|
||||||
setCurrentIndex(0);
|
setCurrentIndex(0);
|
||||||
setPlaying(false);
|
if (!active) setPlaying(false);
|
||||||
}, [periodDays, active]);
|
}, [periodDays, active]);
|
||||||
|
|
||||||
// Auto-advance: one step every (2000 / speed) ms
|
// Auto-advance: one step every (2000 / speed) ms
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!playing || !active) return;
|
if (!playing || !active) return;
|
||||||
const delay = 2000 / speed;
|
const delay = 1500 / speed; // ×1=1500ms, ×2=750ms, ×4=375ms
|
||||||
const t = setTimeout(() => {
|
const t = setTimeout(() => {
|
||||||
setCurrentIndex((i) => {
|
setCurrentIndex((i) => {
|
||||||
if (i >= frames.length - 1) {
|
if (i >= frames.length - 1) {
|
||||||
@@ -94,9 +95,17 @@ export function useAnimation(transactions: Transaction[], periodDays: number) {
|
|||||||
return transactions.filter((t) => t.timestamp >= frame.from && t.timestamp < frame.to);
|
return transactions.filter((t) => t.timestamp >= frame.from && t.timestamp < frame.to);
|
||||||
}, [active, transactions, frames, currentIndex]);
|
}, [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 {
|
return {
|
||||||
active,
|
active,
|
||||||
activate: () => setActive(true),
|
activate: () => { setActive(true); setSpeed(1); setPlaying(true); },
|
||||||
deactivate: () => { setActive(false); },
|
deactivate: () => { setActive(false); },
|
||||||
playing,
|
playing,
|
||||||
play: () => setPlaying(true),
|
play: () => setPlaying(true),
|
||||||
@@ -108,5 +117,6 @@ export function useAnimation(transactions: Transaction[], periodDays: number) {
|
|||||||
frames,
|
frames,
|
||||||
currentFrame: frames[currentIndex] ?? null,
|
currentFrame: frames[currentIndex] ?? null,
|
||||||
visibleTransactions,
|
visibleTransactions,
|
||||||
|
frameTotalCount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user