Compare commits
4 Commits
a36a6729e3
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 3148d74331 | |||
| dfe832728e | |||
| 782b063b25 | |||
| 88e2232cfb |
+13
-11
@@ -15,9 +15,10 @@ import { useAnimation } from './hooks/useAnimation';
|
||||
import { useMediaQuery } from './hooks/useMediaQuery';
|
||||
import { InfoPanel } from './components/InfoPanel';
|
||||
import { initialUrlState, useUrlSync } from './hooks/useUrlState';
|
||||
import { type Period, periodKey, isPastRange } from './types/period';
|
||||
|
||||
export default function App() {
|
||||
const [periodDays, setPeriodDays] = useState(initialUrlState.period);
|
||||
const [period, setPeriod] = useState<Period>(initialUrlState.period);
|
||||
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
||||
const [arcs, setArcs] = useState<TransactionArc[]>([]);
|
||||
const [stats, setStats] = useState<PeriodStats | null>(null);
|
||||
@@ -52,14 +53,14 @@ export default function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const animation = useAnimation(transactions, arcs, periodDays, allTimestamps);
|
||||
const animation = useAnimation(transactions, arcs, period, allTimestamps);
|
||||
|
||||
// Synchronise l'état dans l'URL (deep link / partage)
|
||||
useUrlSync(periodDays, viewMode, focusCity);
|
||||
useUrlSync(period, viewMode, focusCity);
|
||||
|
||||
const handlePeriodChange = (days: number) => {
|
||||
const handlePeriodChange = (newPeriod: Period) => {
|
||||
animation.deactivate();
|
||||
setPeriodDays(days);
|
||||
setPeriod(newPeriod);
|
||||
};
|
||||
|
||||
const handleViewModeChange = (mode: 'heatmap' | 'flow') => {
|
||||
@@ -98,7 +99,7 @@ export default function App() {
|
||||
const load = (showLoading: boolean) => {
|
||||
if (showLoading) setLoading(true);
|
||||
else setRefreshing(true);
|
||||
fetchData(periodDays)
|
||||
fetchData(period)
|
||||
.then(({ transactions, arcs, stats, source, currentUD, allTimestamps }) => {
|
||||
if (!cancelled) {
|
||||
setTransactions(transactions);
|
||||
@@ -117,10 +118,11 @@ export default function App() {
|
||||
};
|
||||
|
||||
load(true);
|
||||
const interval = setInterval(() => load(false), 120_000);
|
||||
const interval = isPastRange(period) ? null : setInterval(() => load(false), 120_000);
|
||||
|
||||
return () => { cancelled = true; clearInterval(interval); };
|
||||
}, [periodDays, endpointVersion]);
|
||||
return () => { cancelled = true; if (interval) clearInterval(interval); };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [periodKey(period), endpointVersion]);
|
||||
|
||||
// Stats heatmap sur la fenêtre courante en mode animation
|
||||
const visibleStats: PeriodStats | null = animation.active
|
||||
@@ -143,7 +145,7 @@ export default function App() {
|
||||
const statsPanelProps = {
|
||||
stats: visibleStats,
|
||||
loading,
|
||||
periodDays,
|
||||
period,
|
||||
source,
|
||||
currentUD,
|
||||
animationLabel: animation.active ? (animation.currentFrame?.label ?? undefined) : undefined,
|
||||
@@ -223,7 +225,7 @@ export default function App() {
|
||||
{/* Period selector — floating over map */}
|
||||
<div className={`absolute top-4 z-[1000] ${isMobile ? 'left-16 right-4' : 'left-1/2 -translate-x-1/2'}`}>
|
||||
<PeriodSelector
|
||||
value={periodDays}
|
||||
period={period}
|
||||
onChange={handlePeriodChange}
|
||||
animationActive={animation.active}
|
||||
onAnimate={() => animation.active ? animation.deactivate() : animation.activate()}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import {
|
||||
KNOWN_SUBSQUID_NODES,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
setSubsquidUrl,
|
||||
setCesiumUrl,
|
||||
} from '../services/EndpointConfig';
|
||||
import { discoverSquidNodes, clearPeerCache } from '../services/PeerDiscovery';
|
||||
import { testEndpoint } from '../hooks/useServiceStatus';
|
||||
|
||||
interface Props {
|
||||
@@ -30,8 +31,11 @@ export function EndpointPopover({ service, onClose, onSaved }: Props) {
|
||||
|
||||
const [inputUrl, setInputUrl] = useState(currentUrl);
|
||||
const [testResults, setTestResults] = useState<Map<string, TestResult>>(new Map());
|
||||
const [discoveredUrls, setDiscoveredUrls] = useState<string[]>([]);
|
||||
const [discovering, setDiscovering] = useState(false);
|
||||
const [discoverVersion, setDiscoverVersion] = useState(0);
|
||||
|
||||
const runTest = async (url: string) => {
|
||||
const testUrl = async (url: string) => {
|
||||
setTestResults((prev) => new Map(prev).set(url, { url, state: 'testing', latencyMs: null }));
|
||||
try {
|
||||
const ms = await testEndpoint(service, url);
|
||||
@@ -43,6 +47,23 @@ export function EndpointPopover({ service, onClose, onSaved }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
// Découverte des nœuds réseau (SubSquid uniquement)
|
||||
useEffect(() => {
|
||||
if (service !== 'subsquid') return;
|
||||
setDiscovering(true);
|
||||
setDiscoveredUrls([]);
|
||||
discoverSquidNodes().then((urls) => {
|
||||
setDiscoveredUrls(urls);
|
||||
setDiscovering(false);
|
||||
urls.forEach((url) => testUrl(url));
|
||||
});
|
||||
}, [discoverVersion]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const refreshDiscovery = () => {
|
||||
clearPeerCache();
|
||||
setDiscoverVersion((v) => v + 1);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const trimmed = inputUrl.trim();
|
||||
if (!trimmed) return;
|
||||
@@ -59,12 +80,47 @@ export function EndpointPopover({ service, onClose, onSaved }: Props) {
|
||||
return <span className="text-red-500">●</span>;
|
||||
};
|
||||
|
||||
const NodeRow = ({ url, label }: { url: string; label?: string }) => {
|
||||
const result = testResults.get(url);
|
||||
const isActive = inputUrl === url;
|
||||
const hostname = label ?? (() => { try { return new URL(url).hostname; } catch { return url; } })();
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center justify-between rounded-xl border px-3 py-2.5 cursor-pointer transition-colors ${
|
||||
isActive
|
||||
? 'border-[#d4a843]/60 bg-[#d4a843]/5'
|
||||
: 'border-[#1e1f2a] hover:border-[#2e2f3a]'
|
||||
}`}
|
||||
onClick={() => setInputUrl(url)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-white text-sm font-medium truncate">{hostname}</p>
|
||||
<p className="text-[#4b5563] text-xs font-mono truncate">{url}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-3 shrink-0">
|
||||
{result && (
|
||||
<span className="text-xs font-mono text-[#6b7280]">
|
||||
{dot(result.state)}
|
||||
{result.latencyMs !== null && ` ${result.latencyMs} ms`}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); testUrl(url); }}
|
||||
className="text-xs text-[#4b5563] hover:text-[#d4a843] transition-colors px-2 py-1 border border-[#2e2f3a] rounded-lg"
|
||||
>
|
||||
Tester
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div className="bg-[#0f1016] border border-[#2e2f3a] rounded-2xl shadow-2xl w-full max-w-md mx-4 p-6 space-y-5">
|
||||
<div className="bg-[#0f1016] border border-[#2e2f3a] rounded-2xl shadow-2xl w-full max-w-md mx-4 p-6 space-y-5 max-h-[85vh] overflow-y-auto">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -74,45 +130,40 @@ export function EndpointPopover({ service, onClose, onSaved }: Props) {
|
||||
<button onClick={onClose} className="text-[#4b5563] hover:text-white transition-colors text-xl leading-none">×</button>
|
||||
</div>
|
||||
|
||||
{/* Nœuds connus */}
|
||||
{/* Nœuds connus (statiques) */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-[#4b5563] text-xs uppercase tracking-widest">Nœuds connus</p>
|
||||
{knownNodes.map((node) => {
|
||||
const result = testResults.get(node.url);
|
||||
const isActive = inputUrl === node.url;
|
||||
return (
|
||||
<div
|
||||
key={node.url}
|
||||
className={`flex items-center justify-between rounded-xl border px-3 py-2.5 cursor-pointer transition-colors ${
|
||||
isActive
|
||||
? 'border-[#d4a843]/60 bg-[#d4a843]/5'
|
||||
: 'border-[#1e1f2a] hover:border-[#2e2f3a]'
|
||||
}`}
|
||||
onClick={() => setInputUrl(node.url)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-white text-sm font-medium truncate">{node.label}</p>
|
||||
<p className="text-[#4b5563] text-xs font-mono truncate">{node.url}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-3 shrink-0">
|
||||
{result && (
|
||||
<span className="text-xs font-mono text-[#6b7280]">
|
||||
{dot(result.state)}
|
||||
{result.latencyMs !== null && ` ${result.latencyMs} ms`}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); runTest(node.url); }}
|
||||
className="text-xs text-[#4b5563] hover:text-[#d4a843] transition-colors px-2 py-1 border border-[#2e2f3a] rounded-lg"
|
||||
>
|
||||
Tester
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{knownNodes.map((node) => (
|
||||
<NodeRow key={node.url} url={node.url} label={node.label} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Nœuds découverts via duniter_peerings (SubSquid uniquement) */}
|
||||
{service === 'subsquid' && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[#4b5563] text-xs uppercase tracking-widest">Réseau Ğ1</p>
|
||||
<button
|
||||
onClick={refreshDiscovery}
|
||||
disabled={discovering}
|
||||
className="text-xs text-[#4b5563] hover:text-[#d4a843] disabled:opacity-40 transition-colors"
|
||||
title="Actualiser la liste des nœuds"
|
||||
>
|
||||
{discovering ? <span className="animate-spin inline-block">↻</span> : '↻ Actualiser'}
|
||||
</button>
|
||||
</div>
|
||||
{discovering && discoveredUrls.length === 0 && (
|
||||
<p className="text-xs text-[#4b5563] pl-1">Découverte en cours…</p>
|
||||
)}
|
||||
{!discovering && discoveredUrls.length === 0 && (
|
||||
<p className="text-xs text-[#4b5563] pl-1">Aucun nœud trouvé</p>
|
||||
)}
|
||||
{discoveredUrls.map((url) => (
|
||||
<NodeRow key={url} url={url} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* URL personnalisée */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-[#4b5563] text-xs uppercase tracking-widest">URL personnalisée</p>
|
||||
@@ -125,7 +176,7 @@ export function EndpointPopover({ service, onClose, onSaved }: Props) {
|
||||
className="flex-1 bg-[#0a0b0f] border border-[#2e2f3a] rounded-xl px-3 py-2 text-white text-sm font-mono placeholder-[#2e2f3a] focus:outline-none focus:border-[#d4a843]/60 transition-colors"
|
||||
/>
|
||||
<button
|
||||
onClick={() => runTest(inputUrl.trim())}
|
||||
onClick={() => testUrl(inputUrl.trim())}
|
||||
disabled={!inputUrl.trim()}
|
||||
className="text-xs text-[#4b5563] hover:text-[#d4a843] disabled:opacity-30 transition-colors px-3 py-2 border border-[#2e2f3a] rounded-xl"
|
||||
>
|
||||
@@ -134,7 +185,10 @@ export function EndpointPopover({ service, onClose, onSaved }: Props) {
|
||||
</div>
|
||||
{(() => {
|
||||
const result = testResults.get(inputUrl.trim());
|
||||
if (!result || knownNodes.some((n) => n.url === inputUrl.trim())) return null;
|
||||
const isKnown = [...knownNodes, ...discoveredUrls.map((u) => ({ url: u }))].some(
|
||||
(n) => n.url === inputUrl.trim()
|
||||
);
|
||||
if (!result || isKnown) return null;
|
||||
return (
|
||||
<p className="text-xs font-mono text-[#6b7280] pl-1">
|
||||
{dot(result.state)}
|
||||
|
||||
@@ -330,8 +330,8 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
|
||||
style={{ cursor: 'default' }}
|
||||
>
|
||||
{/* Zone de hit invisible plus large */}
|
||||
<path d={a.path} fill="none" stroke="transparent" strokeWidth={Math.max(12, a.strokeW + 8)} />
|
||||
<path d={a.path} fill="none" stroke={a.stroke} strokeWidth={a.strokeW} strokeLinecap="round" />
|
||||
<path d={a.path} fill="none" stroke="transparent" strokeWidth={Math.max(2, a.strokeW)} pointerEvents="stroke" />
|
||||
<path d={a.path} fill="none" stroke={a.stroke} strokeWidth={a.strokeW} strokeLinecap="round" pointerEvents="stroke" />
|
||||
<polygon points={a.arrowPts} fill={a.arrowFill} />
|
||||
</g>
|
||||
))}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { type Period } from '../types/period';
|
||||
|
||||
interface PeriodSelectorProps {
|
||||
value: number;
|
||||
onChange: (days: number) => void;
|
||||
period: Period;
|
||||
onChange: (period: Period) => void;
|
||||
animationActive: boolean;
|
||||
onAnimate: () => void;
|
||||
viewMode: 'heatmap' | 'flow';
|
||||
@@ -10,50 +11,93 @@ interface PeriodSelectorProps {
|
||||
geoPercent?: number | null;
|
||||
}
|
||||
|
||||
const PERIODS = [
|
||||
const PRESETS = [
|
||||
{ label: '24h', days: 1 },
|
||||
{ label: '7 jours', days: 7 },
|
||||
{ label: '30 jours', days: 30 },
|
||||
];
|
||||
|
||||
const PRESET_DAYS = new Set([1, 7, 30]);
|
||||
|
||||
export function PeriodSelector({ value, onChange, animationActive, onAnimate, viewMode, onViewModeChange, geoPercent }: PeriodSelectorProps) {
|
||||
const [customOpen, setCustomOpen] = useState(false);
|
||||
const [inputVal, setInputVal] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
function toDateInputValue(d: Date): string {
|
||||
return d.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
export function PeriodSelector({ period, onChange, animationActive, onAnimate, viewMode, onViewModeChange, geoPercent }: PeriodSelectorProps) {
|
||||
const [customOpen, setCustomOpen] = useState(false);
|
||||
const [rangeOpen, setRangeOpen] = useState(false);
|
||||
const [inputVal, setInputVal] = useState('');
|
||||
const [fromInput, setFromInput] = useState('');
|
||||
const [toInput, setToInput] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const fromRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const isSliding = period.type === 'sliding';
|
||||
const isPreset = isSliding && PRESET_DAYS.has(period.days);
|
||||
const isCustomDay = isSliding && !PRESET_DAYS.has(period.days);
|
||||
const isRange = period.type === 'range';
|
||||
|
||||
const todayStr = toDateInputValue(new Date());
|
||||
|
||||
// Ouvre le champ custom avec la valeur courante pré-remplie
|
||||
const openCustom = () => {
|
||||
setInputVal(PRESET_DAYS.has(value) ? '' : String(value));
|
||||
setRangeOpen(false);
|
||||
setInputVal(isCustomDay ? String(period.days) : '');
|
||||
setCustomOpen(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (customOpen) inputRef.current?.focus();
|
||||
}, [customOpen]);
|
||||
const openRange = () => {
|
||||
setCustomOpen(false);
|
||||
if (isRange) {
|
||||
setFromInput(toDateInputValue(period.from));
|
||||
setToInput(toDateInputValue(period.to));
|
||||
} else {
|
||||
const to = new Date();
|
||||
const from = new Date(to.getTime() - 30 * 86_400_000);
|
||||
setFromInput(toDateInputValue(from));
|
||||
setToInput(toDateInputValue(to));
|
||||
}
|
||||
setRangeOpen(true);
|
||||
};
|
||||
|
||||
const commit = () => {
|
||||
useEffect(() => { if (customOpen) inputRef.current?.focus(); }, [customOpen]);
|
||||
useEffect(() => { if (rangeOpen) fromRef.current?.focus(); }, [rangeOpen]);
|
||||
|
||||
const commitDays = () => {
|
||||
const n = parseInt(inputVal, 10);
|
||||
if (n >= 1 && n <= 365) onChange(n);
|
||||
if (n >= 1) onChange({ type: 'sliding', days: n });
|
||||
setCustomOpen(false);
|
||||
};
|
||||
|
||||
const isCustomActive = !PRESET_DAYS.has(value);
|
||||
const commitRange = () => {
|
||||
if (!fromInput || !toInput) return;
|
||||
const from = new Date(fromInput);
|
||||
const to = new Date(toInput);
|
||||
to.setHours(23, 59, 59, 999);
|
||||
if (isNaN(from.getTime()) || isNaN(to.getTime()) || from >= to) return;
|
||||
onChange({ type: 'range', from, to });
|
||||
setRangeOpen(false);
|
||||
};
|
||||
|
||||
const btnClass = (active: boolean) => `
|
||||
px-3 py-2.5 sm:py-1.5 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer
|
||||
${active
|
||||
? 'bg-[#d4a843] text-[#0a0b0f] shadow-[0_0_12px_rgba(212,168,67,0.4)]'
|
||||
: 'text-[#6b7280] hover:text-[#d4a843] hover:bg-[#1a1b23]'
|
||||
}
|
||||
`;
|
||||
|
||||
const rangeLabel = isRange
|
||||
? `${period.from.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })} → ${period.to.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })}`
|
||||
: 'Plage';
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1 bg-[#0f1016] border border-[#2e2f3a] rounded-lg p-1 items-center max-w-[calc(100vw-2rem)]">
|
||||
{PERIODS.map(({ label, days }) => (
|
||||
|
||||
{/* Préréglages */}
|
||||
{PRESETS.map(({ label, days }) => (
|
||||
<button
|
||||
key={days}
|
||||
onClick={() => { onChange(days); setCustomOpen(false); }}
|
||||
className={`
|
||||
px-3 py-2.5 sm:py-1.5 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer
|
||||
${value === days && !customOpen
|
||||
? 'bg-[#d4a843] text-[#0a0b0f] shadow-[0_0_12px_rgba(212,168,67,0.4)]'
|
||||
: 'text-[#6b7280] hover:text-[#d4a843] hover:bg-[#1a1b23]'
|
||||
}
|
||||
`}
|
||||
onClick={() => { onChange({ type: 'sliding', days }); setCustomOpen(false); setRangeOpen(false); }}
|
||||
className={btnClass(isPreset && (period as { days: number }).days === days && !customOpen && !rangeOpen)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
@@ -61,53 +105,68 @@ export function PeriodSelector({ value, onChange, animationActive, onAnimate, vi
|
||||
|
||||
<div className="w-px mx-1 bg-[#2e2f3a] self-stretch" />
|
||||
|
||||
{/* Bouton Personnaliser + champ inline */}
|
||||
{/* Nombre de jours personnalisé */}
|
||||
{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}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') commitDays(); if (e.key === 'Escape') setCustomOpen(false); }}
|
||||
onBlur={commitDays}
|
||||
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-2.5 sm: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 onClick={openCustom} className={btnClass(isCustomDay && !rangeOpen)}>
|
||||
{isCustomDay ? `${period.days} jours` : 'N jours'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Plage de dates */}
|
||||
{rangeOpen ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
ref={fromRef}
|
||||
type="date"
|
||||
value={fromInput}
|
||||
max={toInput || todayStr}
|
||||
onChange={(e) => setFromInput(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Escape') setRangeOpen(false); }}
|
||||
className="w-32 px-2 py-1 text-xs bg-[#1a1b23] border border-[#d4a843] rounded-md text-[#d4a843] focus:outline-none"
|
||||
/>
|
||||
<span className="text-[#6b7280] text-xs">→</span>
|
||||
<input
|
||||
type="date"
|
||||
value={toInput}
|
||||
min={fromInput}
|
||||
max={todayStr}
|
||||
onChange={(e) => setToInput(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') commitRange(); if (e.key === 'Escape') setRangeOpen(false); }}
|
||||
className="w-32 px-2 py-1 text-xs bg-[#1a1b23] border border-[#d4a843] rounded-md text-[#d4a843] focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={commitRange}
|
||||
disabled={!fromInput || !toInput || fromInput >= toInput}
|
||||
className="px-2 py-1 text-xs text-[#0a0b0f] bg-[#d4a843] rounded-md disabled:opacity-30 hover:bg-[#e0b84d] transition-colors"
|
||||
>
|
||||
OK
|
||||
</button>
|
||||
<button onClick={() => setRangeOpen(false)} className="text-[#4b5563] hover:text-white text-xs">✕</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={openRange} className={btnClass(isRange && !customOpen)}>
|
||||
{rangeLabel}
|
||||
</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]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<button onClick={onAnimate} className={btnClass(animationActive)}>
|
||||
▶ Animer
|
||||
</button>
|
||||
|
||||
@@ -115,22 +174,16 @@ export function PeriodSelector({ value, onChange, animationActive, onAnimate, vi
|
||||
|
||||
<button
|
||||
onClick={() => onViewModeChange(viewMode === 'heatmap' ? 'flow' : 'heatmap')}
|
||||
className={`
|
||||
px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer
|
||||
${viewMode === 'flow'
|
||||
? 'bg-[#d4a843] text-[#0a0b0f] shadow-[0_0_12px_rgba(212,168,67,0.4)]'
|
||||
: 'text-[#6b7280] hover:text-[#d4a843] hover:bg-[#1a1b23]'
|
||||
}
|
||||
`}
|
||||
className={btnClass(viewMode === 'flow')}
|
||||
>
|
||||
{viewMode === 'flow' ? '⊙ Heatmap' : '◉ Flux'}
|
||||
</button>
|
||||
|
||||
{geoPercent != null && (
|
||||
<span className="text-[10px] font-mono text-white px-1 shrink-0">
|
||||
{geoPercent}% Tx géoloc.
|
||||
</span>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,30 +2,34 @@ import { useMemo } from 'react';
|
||||
|
||||
interface SparklineProps {
|
||||
timestamps: number[];
|
||||
periodDays: number;
|
||||
fromMs: number;
|
||||
toMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mini bar-chart SVG affichant l'activité journalière sur la période.
|
||||
* Mini bar-chart SVG affichant l'activité sur la période.
|
||||
* Utilise les timestamps déjà en mémoire — aucune requête supplémentaire.
|
||||
*/
|
||||
export function Sparkline({ timestamps, periodDays }: SparklineProps) {
|
||||
export function Sparkline({ timestamps, fromMs, toMs }: SparklineProps) {
|
||||
const buckets = useMemo(() => {
|
||||
if (timestamps.length === 0) return [];
|
||||
const n = periodDays === 1 ? 24 : Math.min(periodDays, 30);
|
||||
const now = Date.now();
|
||||
const start = now - periodDays * 864e5;
|
||||
const step = (periodDays * 864e5) / n;
|
||||
const duration = toMs - fromMs;
|
||||
const days = duration / 864e5;
|
||||
const n = days <= 1 ? 24 : Math.min(Math.ceil(days), 30);
|
||||
const step = duration / n;
|
||||
const counts = new Array(n).fill(0);
|
||||
for (const ts of timestamps) {
|
||||
const i = Math.floor((ts - start) / step);
|
||||
const i = Math.floor((ts - fromMs) / step);
|
||||
if (i >= 0 && i < n) counts[i]++;
|
||||
}
|
||||
return counts;
|
||||
}, [timestamps, periodDays]);
|
||||
}, [timestamps, fromMs, toMs]);
|
||||
|
||||
if (buckets.length === 0) return null;
|
||||
|
||||
const duration = toMs - fromMs;
|
||||
const days = duration / 864e5;
|
||||
|
||||
const n = buckets.length;
|
||||
const max = Math.max(...buckets, 1);
|
||||
const W = 100;
|
||||
@@ -33,6 +37,11 @@ export function Sparkline({ timestamps, periodDays }: SparklineProps) {
|
||||
const barW = W / n;
|
||||
const gap = barW * 0.18;
|
||||
|
||||
const fmtLabel = (ms: number) => {
|
||||
if (days <= 1) return new Date(ms).getHours() + 'h';
|
||||
return new Date(ms).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<svg
|
||||
@@ -58,8 +67,8 @@ export function Sparkline({ timestamps, periodDays }: SparklineProps) {
|
||||
})}
|
||||
</svg>
|
||||
<div className="flex justify-between text-[10px] text-[#4b5563]">
|
||||
<span>{periodDays === 1 ? '0h' : 'J-' + periodDays}</span>
|
||||
<span>{periodDays === 1 ? 'maintenant' : 'aujourd\'hui'}</span>
|
||||
<span>{fmtLabel(fromMs)}</span>
|
||||
<span>{fmtLabel(toMs)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,11 +6,12 @@ import { ServiceStatusDots } from './ServiceStatusDots';
|
||||
import { useServiceStatus } from '../hooks/useServiceStatus';
|
||||
import { CATEGORY_LABELS, CATEGORY_COLORS, type TxCategory } from '../data/commentParser';
|
||||
import type { Transaction } from '../data/mockData';
|
||||
import { type Period, periodToDates, periodToDays } from '../types/period';
|
||||
|
||||
interface StatsPanelProps {
|
||||
stats: PeriodStats | null;
|
||||
loading: boolean;
|
||||
periodDays: number;
|
||||
period: Period;
|
||||
source: 'live' | 'mock';
|
||||
className?: string;
|
||||
currentUD: number;
|
||||
@@ -68,10 +69,14 @@ function CityRow({ city, volume, count, countryCode, accent }: {
|
||||
);
|
||||
}
|
||||
|
||||
export function StatsPanel({ stats, loading, periodDays, source, currentUD, animationLabel, viewMode = 'heatmap', flowStats, focusCity, onClose, onEndpointChange, className, allTimestamps = [], transactions = [] }: StatsPanelProps) {
|
||||
export function StatsPanel({ stats, loading, period, source, currentUD, animationLabel, viewMode = 'heatmap', flowStats, focusCity, onClose, onEndpointChange, className, allTimestamps = [], transactions = [] }: StatsPanelProps) {
|
||||
const { subsquid, cesium, recheck } = useServiceStatus();
|
||||
const [openCategory, setOpenCategory] = useState<TxCategory | null>(null);
|
||||
const periodLabel = periodDays === 1 ? '24 dernières heures' : `${periodDays} derniers jours`;
|
||||
const { from, to } = periodToDates(period);
|
||||
const days = periodToDays(period);
|
||||
const periodLabel = period.type === 'range'
|
||||
? `${from.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', year: 'numeric' })} → ${to.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', year: 'numeric' })}`
|
||||
: days === 1 ? '24 dernières heures' : `${days} derniers jours`;
|
||||
const prevStats = useRef<PeriodStats | null>(null);
|
||||
|
||||
// Calcule le delta d'une valeur par rapport au refresh précédent
|
||||
@@ -136,7 +141,7 @@ export function StatsPanel({ stats, loading, periodDays, source, currentUD, anim
|
||||
}
|
||||
</p>
|
||||
{!animationLabel && allTimestamps.length > 0 && (
|
||||
<Sparkline timestamps={allTimestamps} periodDays={periodDays} />
|
||||
<Sparkline timestamps={allTimestamps} fromMs={from.getTime()} toMs={to.getTime()} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -94,12 +94,11 @@ function generateTransactions(count: number, maxAgeMs: number): Transaction[] {
|
||||
const POOL_GENERATED_AT = Date.now();
|
||||
const TRANSACTION_POOL = generateTransactions(2400, 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
export function getTransactionsForPeriod(periodDays: number): Transaction[] {
|
||||
export function getTransactionsForPeriod(from: Date, to: Date): Transaction[] {
|
||||
const drift = Date.now() - POOL_GENERATED_AT;
|
||||
const cutoff = Date.now() - periodDays * 24 * 60 * 60 * 1000;
|
||||
return TRANSACTION_POOL
|
||||
.map((tx) => ({ ...tx, timestamp: tx.timestamp + drift }))
|
||||
.filter((tx) => tx.timestamp >= cutoff);
|
||||
.filter((tx) => tx.timestamp >= from.getTime() && tx.timestamp <= to.getTime());
|
||||
}
|
||||
|
||||
export function computeStats(transactions: Transaction[]) {
|
||||
|
||||
+48
-37
@@ -1,6 +1,7 @@
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import type { Transaction } from '../data/mockData';
|
||||
import type { TransactionArc } from '../data/arcData';
|
||||
import { type Period, periodToDates, periodKey } from '../types/period';
|
||||
|
||||
export interface TimeFrame {
|
||||
label: string;
|
||||
@@ -8,47 +9,57 @@ export interface TimeFrame {
|
||||
to: number; // Unix ms
|
||||
}
|
||||
|
||||
function buildFrames(periodDays: number): TimeFrame[] {
|
||||
const now = Date.now();
|
||||
const start = now - periodDays * 24 * 60 * 60 * 1000;
|
||||
function buildFrames(fromMs: number, toMs: number): TimeFrame[] {
|
||||
const duration = toMs - fromMs;
|
||||
const days = duration / 86_400_000;
|
||||
|
||||
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;
|
||||
// ≤ 2 jours : frames horaires
|
||||
if (days <= 2) {
|
||||
const frames: TimeFrame[] = [];
|
||||
let cursor = fromMs;
|
||||
while (cursor < toMs) {
|
||||
const from = cursor;
|
||||
const to = Math.min(cursor + 3_600_000, toMs);
|
||||
const h = new Date(from).getHours();
|
||||
return {
|
||||
label: `${fmt(from, { weekday: 'short', day: 'numeric', month: 'short' })} · ${h}h – ${h + 1}h`,
|
||||
frames.push({
|
||||
label: `${fmt(from, { weekday: 'short', day: 'numeric', month: 'short' })} · ${h}h`,
|
||||
from,
|
||||
to,
|
||||
};
|
||||
});
|
||||
});
|
||||
cursor = to;
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
if (periodDays === 7) {
|
||||
return Array.from({ length: 7 }, (_, i) => {
|
||||
const from = start + i * 86_400_000;
|
||||
const to = from + 86_400_000;
|
||||
return {
|
||||
// ≤ 14 jours : frames journalières
|
||||
if (days <= 14) {
|
||||
const frames: TimeFrame[] = [];
|
||||
let cursor = fromMs;
|
||||
while (cursor < toMs) {
|
||||
const from = cursor;
|
||||
const to = Math.min(cursor + 86_400_000, toMs);
|
||||
frames.push({
|
||||
label: fmt(from, { weekday: 'long', day: 'numeric', month: 'short' }),
|
||||
from,
|
||||
to,
|
||||
};
|
||||
});
|
||||
});
|
||||
cursor = to;
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
// 30 days → half-week frames (3.5 days ≈ 9–10 frames)
|
||||
const HALF_WEEK = 3.5 * 86_400_000;
|
||||
// > 14 jours : frames hebdomadaires
|
||||
const WEEK = 7 * 86_400_000;
|
||||
const frames: TimeFrame[] = [];
|
||||
let cursor = start;
|
||||
while (cursor < now) {
|
||||
let cursor = fromMs;
|
||||
while (cursor < toMs) {
|
||||
const from = cursor;
|
||||
const to = Math.min(cursor + HALF_WEEK, now);
|
||||
const to = Math.min(cursor + WEEK, toMs);
|
||||
frames.push({
|
||||
label: `${fmt(from, { weekday: 'short', day: 'numeric', month: 'short' })} – ${fmt(to - 1, { weekday: 'short', day: 'numeric', month: 'short' })}`,
|
||||
label: `${fmt(from, { day: 'numeric', month: 'short' })} – ${fmt(to - 1, { day: 'numeric', month: 'short' })}`,
|
||||
from,
|
||||
to,
|
||||
});
|
||||
@@ -57,32 +68,33 @@ function buildFrames(periodDays: number): TimeFrame[] {
|
||||
return frames;
|
||||
}
|
||||
|
||||
export function useAnimation(transactions: Transaction[], arcs: TransactionArc[], periodDays: number, allTimestamps: number[] = []) {
|
||||
export function useAnimation(transactions: Transaction[], arcs: TransactionArc[], period: Period, 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]);
|
||||
const key = periodKey(period);
|
||||
|
||||
const frames = useMemo(() => {
|
||||
const { from, to } = periodToDates(period);
|
||||
return buildFrames(from.getTime(), to.getTime());
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key]);
|
||||
|
||||
// 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]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key, active]);
|
||||
|
||||
// Auto-advance: one step every (2000 / speed) ms
|
||||
// Auto-advance: one step every (1500 / speed) ms
|
||||
useEffect(() => {
|
||||
if (!playing || !active) return;
|
||||
const delay = 1500 / speed; // ×1=1500ms, ×2=750ms, ×4=375ms
|
||||
const delay = 1500 / speed;
|
||||
const t = setTimeout(() => {
|
||||
setCurrentIndex((i) => {
|
||||
if (i >= frames.length - 1) {
|
||||
setPlaying(false);
|
||||
return i;
|
||||
}
|
||||
if (i >= frames.length - 1) { setPlaying(false); return i; }
|
||||
return i + 1;
|
||||
});
|
||||
}, delay);
|
||||
@@ -103,7 +115,6 @@ export function useAnimation(transactions: Transaction[], arcs: TransactionArc[]
|
||||
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;
|
||||
const frame = frames[currentIndex];
|
||||
|
||||
@@ -5,17 +5,34 @@
|
||||
* Écriture : useUrlSync() à appeler dans App pour maintenir l'URL à jour.
|
||||
*
|
||||
* Paramètres supportés :
|
||||
* ?period=7&view=flow&city=Paris
|
||||
* ?period=7&view=flow&city=Paris (mode glissant)
|
||||
* ?from=2026-01-01&to=2026-01-31&view=flow (mode plage)
|
||||
*/
|
||||
import { useEffect } from 'react';
|
||||
import { type Period, periodKey } from '../types/period';
|
||||
|
||||
function parseInitialState(): { period: number; view: 'heatmap' | 'flow'; city: string | null } {
|
||||
function parseInitialState(): { period: Period; view: 'heatmap' | 'flow'; city: string | null } {
|
||||
const p = new URLSearchParams(window.location.search);
|
||||
const period = parseInt(p.get('period') ?? '', 10);
|
||||
const view = p.get('view') === 'flow' ? 'flow' : 'heatmap';
|
||||
const city = p.get('city') ?? null;
|
||||
|
||||
// Mode plage : ?from=YYYY-MM-DD&to=YYYY-MM-DD
|
||||
const fromStr = p.get('from');
|
||||
const toStr = p.get('to');
|
||||
if (fromStr && toStr) {
|
||||
const from = new Date(fromStr);
|
||||
const to = new Date(toStr);
|
||||
if (!isNaN(from.getTime()) && !isNaN(to.getTime()) && from < to) {
|
||||
return { period: { type: 'range', from, to }, view, city };
|
||||
}
|
||||
}
|
||||
|
||||
// Mode glissant : ?period=30
|
||||
const days = parseInt(p.get('period') ?? '', 10);
|
||||
return {
|
||||
period: Number.isFinite(period) && period >= 1 && period <= 365 ? period : 7,
|
||||
view: p.get('view') === 'flow' ? 'flow' : 'heatmap',
|
||||
city: p.get('city') ?? null,
|
||||
period: { type: 'sliding', days: Number.isFinite(days) && days >= 1 ? days : 7 },
|
||||
view,
|
||||
city,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,16 +41,23 @@ export const initialUrlState = parseInitialState();
|
||||
|
||||
/** Écrit l'état courant dans l'URL (history.replaceState, sans recharger). */
|
||||
export function useUrlSync(
|
||||
periodDays: number,
|
||||
period: Period,
|
||||
viewMode: 'heatmap' | 'flow',
|
||||
focusCity: string | null,
|
||||
) {
|
||||
const key = periodKey(period);
|
||||
useEffect(() => {
|
||||
const p = new URLSearchParams();
|
||||
if (periodDays !== 7) p.set('period', String(periodDays));
|
||||
if (period.type === 'range') {
|
||||
p.set('from', period.from.toISOString().split('T')[0]);
|
||||
p.set('to', period.to.toISOString().split('T')[0]);
|
||||
} else if (period.days !== 7) {
|
||||
p.set('period', String(period.days));
|
||||
}
|
||||
if (viewMode !== 'heatmap') p.set('view', viewMode);
|
||||
if (focusCity) p.set('city', focusCity);
|
||||
const qs = p.toString();
|
||||
history.replaceState(null, '', qs ? `?${qs}` : window.location.pathname);
|
||||
}, [periodDays, viewMode, focusCity]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key, viewMode, focusCity]);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
*/
|
||||
|
||||
import { fetchTransfers, buildIdentityKeyMap, fetchCurrentUD, ss58ToDuniterKey, fetchActiveMemberKeys } from './adapters/SubsquidAdapter';
|
||||
import { type Period, periodToDates, periodToDays } from '../types/period';
|
||||
import { resolveGeoByKeys, resolveGeoByKeysBatched, cleanCityName } from './adapters/CesiumAdapter';
|
||||
import { parseComment } from '../data/commentParser';
|
||||
import {
|
||||
@@ -47,16 +48,17 @@ async function getIdentityKeyMap(): Promise<Map<string, string>> {
|
||||
return map;
|
||||
}
|
||||
|
||||
async function fetchLiveTransactions(periodDays: number): Promise<{
|
||||
async function fetchLiveTransactions(period: Period): Promise<{
|
||||
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);
|
||||
const { from, to } = periodToDates(period);
|
||||
const days = periodToDays(period);
|
||||
const limit = Math.max(2000, Math.ceil(days * 600));
|
||||
const { transfers: rawTransfers, totalCount } = await fetchTransfers(from, to, limit);
|
||||
if (rawTransfers.length === 0) return { geolocated: [], arcs: [], totalCount: 0, totalVolume: 0, allTimestamps: [] };
|
||||
|
||||
const totalVolume = rawTransfers.reduce((s, t) => s + t.amount, 0);
|
||||
@@ -210,10 +212,11 @@ export interface DataResult {
|
||||
allTimestamps: number[]; // timestamps de TOUS les transfers (géo + non-géo)
|
||||
}
|
||||
|
||||
export async function fetchData(periodDays: number): Promise<DataResult> {
|
||||
export async function fetchData(period: Period): Promise<DataResult> {
|
||||
if (!USE_LIVE_API) {
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
const transactions = getTransactionsForPeriod(periodDays);
|
||||
const { from, to } = periodToDates(period);
|
||||
const transactions = getTransactionsForPeriod(from, to);
|
||||
const base = computeStats(transactions);
|
||||
const arcs = buildMockArcs(transactions);
|
||||
return {
|
||||
@@ -227,7 +230,7 @@ export async function fetchData(periodDays: number): Promise<DataResult> {
|
||||
}
|
||||
|
||||
const [{ geolocated, arcs, totalCount, totalVolume, allTimestamps }, currentUD] = await Promise.all([
|
||||
fetchLiveTransactions(periodDays),
|
||||
fetchLiveTransactions(period),
|
||||
getCurrentUD(),
|
||||
]);
|
||||
const base = computeStats(geolocated);
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
const DUNITER_RPC = 'https://rpc.duniter.org';
|
||||
const CACHE_KEY = 'geoflux-peers-v1';
|
||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
interface PeerCache {
|
||||
urls: string[];
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
function normalizeSquidUrl(raw: string): string {
|
||||
const url = raw.replace(/\/$/, '');
|
||||
return url.endsWith('/v1/graphql') ? url : `${url}/v1/graphql`;
|
||||
}
|
||||
|
||||
export async function discoverSquidNodes(): Promise<string[]> {
|
||||
try {
|
||||
const cached = localStorage.getItem(CACHE_KEY);
|
||||
if (cached) {
|
||||
const parsed: PeerCache = JSON.parse(cached);
|
||||
if (Date.now() - parsed.fetchedAt < CACHE_TTL_MS) return parsed.urls;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 8_000);
|
||||
try {
|
||||
const res = await fetch(DUNITER_RPC, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', method: 'duniter_peerings', params: [], id: 1 }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
const data = await res.json();
|
||||
const peers: { peer_id: string; endpoints: { protocol: string; address: string }[] }[] =
|
||||
data?.result?.peerings ?? [];
|
||||
|
||||
const seen = new Set<string>();
|
||||
const urls: string[] = [];
|
||||
for (const peer of peers) {
|
||||
for (const ep of peer.endpoints ?? []) {
|
||||
if (ep.protocol === 'squid' && ep.address) {
|
||||
const normalized = normalizeSquidUrl(ep.address);
|
||||
if (!seen.has(normalized)) {
|
||||
seen.add(normalized);
|
||||
urls.push(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify({ urls, fetchedAt: Date.now() }));
|
||||
return urls;
|
||||
} catch {
|
||||
return [];
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearPeerCache(): void {
|
||||
localStorage.removeItem(CACHE_KEY);
|
||||
}
|
||||
@@ -60,11 +60,11 @@ export interface RawTransfer {
|
||||
// Query
|
||||
// ---------------------------------------------------------------------------
|
||||
const TRANSFERS_QUERY = `
|
||||
query GetTransfers($since: Datetime!, $limit: Int!) {
|
||||
query GetTransfers($since: Datetime!, $until: Datetime!, $limit: Int!) {
|
||||
transfers(
|
||||
orderBy: TIMESTAMP_DESC
|
||||
first: $limit
|
||||
filter: { timestamp: { greaterThanOrEqualTo: $since } }
|
||||
filter: { timestamp: { greaterThanOrEqualTo: $since, lessThanOrEqualTo: $until } }
|
||||
) {
|
||||
totalCount
|
||||
nodes {
|
||||
@@ -221,19 +221,19 @@ export interface FetchTransfersResult {
|
||||
}
|
||||
|
||||
export async function fetchTransfers(
|
||||
periodDays: number,
|
||||
from: Date,
|
||||
to: Date,
|
||||
limit = 2000
|
||||
): Promise<FetchTransfersResult> {
|
||||
const since = new Date(
|
||||
Date.now() - periodDays * 24 * 60 * 60 * 1000
|
||||
).toISOString();
|
||||
const since = from.toISOString();
|
||||
const until = to.toISOString();
|
||||
|
||||
const response = await fetch(getSubsquidUrl(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
query: TRANSFERS_QUERY,
|
||||
variables: { since, limit },
|
||||
variables: { since, until, limit },
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
export type Period =
|
||||
| { type: 'sliding'; days: number }
|
||||
| { type: 'range'; from: Date; to: Date }
|
||||
|
||||
export function periodToDates(period: Period): { from: Date; to: Date } {
|
||||
if (period.type === 'range') return { from: period.from, to: period.to };
|
||||
const to = new Date();
|
||||
const from = new Date(to.getTime() - period.days * 86_400_000);
|
||||
return { from, to };
|
||||
}
|
||||
|
||||
export function periodToDays(period: Period): number {
|
||||
const { from, to } = periodToDates(period);
|
||||
return Math.max(1, Math.ceil((to.getTime() - from.getTime()) / 86_400_000));
|
||||
}
|
||||
|
||||
/** Clé stable pour deps React — ne change pas entre deux renders pour le mode glissant */
|
||||
export function periodKey(period: Period): string {
|
||||
return period.type === 'sliding'
|
||||
? `s-${period.days}`
|
||||
: `r-${period.from.toISOString().split('T')[0]}-${period.to.toISOString().split('T')[0]}`;
|
||||
}
|
||||
|
||||
/** Vrai si la plage est entièrement dans le passé (auto-refresh inutile) */
|
||||
export function isPastRange(period: Period): boolean {
|
||||
if (period.type === 'sliding') return false;
|
||||
return period.to.getTime() < Date.now() - 5 * 60 * 1000;
|
||||
}
|
||||
Reference in New Issue
Block a user