10 Commits

Author SHA1 Message Date
Syoul f81ff92e0e Merge pull request 'dev' (#3) from dev into main
ci/woodpecker/push/woodpecker Pipeline was successful
Reviewed-on: #3
2026-04-22 02:10:38 +02:00
Syoul a36a6729e3 Merge pull request 'dev' (#2) from dev into main
ci/woodpecker/push/woodpecker Pipeline was successful
Reviewed-on: #2
2026-04-22 00:29:50 +02:00
Syoul 96ee4a2382 Merge pull request 'dev' (#1) from dev into main
ci/woodpecker/push/woodpecker Pipeline was successful
Reviewed-on: #1
2026-04-21 20:52:40 +02:00
syoul ac168c3689 Merge branch 'dev'
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-28 11:52:27 +01:00
syoul 9f3752b621 chore: merge dev → main v1.4.0
ci/woodpecker/push/woodpecker Pipeline was successful
- feat: bouton ℹ flottant isolé sous ☰ (mobile) / top-left (desktop)
- fix: supprimer label 'Vitesse' + bouton ✕ AnimationPlayer
- fix: couleur émetteurs rouge #e53935 (meilleur contraste vs or)
- fix: InfoPanel — dégradés or→rouge / or→vert documentés
- docs: features-roadmap.md (14 features planifiées)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 13:31:49 +01:00
syoul 70de7e4c06 chore: merge dev → main v1.3.2
ci/woodpecker/push/woodpecker Pipeline was successful
- fix: bouton Clusters bottom-44 mobile / bottom-24 desktop
- fix: bouton Clusters bottom-36/32 (iterations précédentes)
- fix: plan historique-genesis.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 13:08:09 +01:00
syoul ab1bad2209 chore: merge dev → main v1.3.1
ci/woodpecker/push/woodpecker Pipeline was successful
- fix: retirer mention Mock de l'InfoPanel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 12:39:32 +01:00
syoul 00f0602c61 chore: merge dev → main v1.3.0
ci/woodpecker/push/woodpecker Pipeline was successful
- feat: clusters vue Flux (toggle Clusters/Villes, popup balance)
- feat: % Tx géoloc. dans la barre de contrôles
- feat: bouton ℹ + modale InfoPanel (toutes les fonctionnalités)
- fix: layout mobile (bottom drawer, badge focus, AnimationPlayer)
- fix: bouton Clusters visible en mode animation (z-[1002], bottom-32)
- fix: pipeline CI — .dockerignore, syft v1.42.3 pinné, trivy 0.69.3

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 12:35:56 +01:00
syoul e4eb02560a Merge dev → main : v1.2.0
ci/woodpecker/push/woodpecker Pipeline was successful
- feat: coloration nœuds par balance nette (orange émetteur / vert récepteur)
- feat: clustering géographique des villes dans la vue Flux
- feat: adaptation mobile — drawer bottom + layout responsive
2026-03-24 11:11:27 +01:00
syoul 5978ddfed3 release: v1.1.0
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-24 00:33:12 +01:00
10 changed files with 149 additions and 283 deletions
+11 -13
View File
@@ -15,10 +15,9 @@ import { useAnimation } from './hooks/useAnimation';
import { useMediaQuery } from './hooks/useMediaQuery'; import { useMediaQuery } from './hooks/useMediaQuery';
import { InfoPanel } from './components/InfoPanel'; import { InfoPanel } from './components/InfoPanel';
import { initialUrlState, useUrlSync } from './hooks/useUrlState'; import { initialUrlState, useUrlSync } from './hooks/useUrlState';
import { type Period, periodKey, isPastRange } from './types/period';
export default function App() { export default function App() {
const [period, setPeriod] = useState<Period>(initialUrlState.period); const [periodDays, setPeriodDays] = useState(initialUrlState.period);
const [transactions, setTransactions] = useState<Transaction[]>([]); const [transactions, setTransactions] = useState<Transaction[]>([]);
const [arcs, setArcs] = useState<TransactionArc[]>([]); const [arcs, setArcs] = useState<TransactionArc[]>([]);
const [stats, setStats] = useState<PeriodStats | null>(null); const [stats, setStats] = useState<PeriodStats | null>(null);
@@ -53,14 +52,14 @@ export default function App() {
} }
}; };
const animation = useAnimation(transactions, arcs, period, allTimestamps); const animation = useAnimation(transactions, arcs, periodDays, allTimestamps);
// Synchronise l'état dans l'URL (deep link / partage) // Synchronise l'état dans l'URL (deep link / partage)
useUrlSync(period, viewMode, focusCity); useUrlSync(periodDays, viewMode, focusCity);
const handlePeriodChange = (newPeriod: Period) => { const handlePeriodChange = (days: number) => {
animation.deactivate(); animation.deactivate();
setPeriod(newPeriod); setPeriodDays(days);
}; };
const handleViewModeChange = (mode: 'heatmap' | 'flow') => { const handleViewModeChange = (mode: 'heatmap' | 'flow') => {
@@ -99,7 +98,7 @@ export default function App() {
const load = (showLoading: boolean) => { const load = (showLoading: boolean) => {
if (showLoading) setLoading(true); if (showLoading) setLoading(true);
else setRefreshing(true); else setRefreshing(true);
fetchData(period) fetchData(periodDays)
.then(({ transactions, arcs, stats, source, currentUD, allTimestamps }) => { .then(({ transactions, arcs, stats, source, currentUD, allTimestamps }) => {
if (!cancelled) { if (!cancelled) {
setTransactions(transactions); setTransactions(transactions);
@@ -118,11 +117,10 @@ export default function App() {
}; };
load(true); load(true);
const interval = isPastRange(period) ? null : setInterval(() => load(false), 120_000); const interval = setInterval(() => load(false), 120_000);
return () => { cancelled = true; if (interval) clearInterval(interval); }; return () => { cancelled = true; clearInterval(interval); };
// eslint-disable-next-line react-hooks/exhaustive-deps }, [periodDays, endpointVersion]);
}, [periodKey(period), endpointVersion]);
// Stats heatmap sur la fenêtre courante en mode animation // Stats heatmap sur la fenêtre courante en mode animation
const visibleStats: PeriodStats | null = animation.active const visibleStats: PeriodStats | null = animation.active
@@ -145,7 +143,7 @@ export default function App() {
const statsPanelProps = { const statsPanelProps = {
stats: visibleStats, stats: visibleStats,
loading, loading,
period, periodDays,
source, source,
currentUD, currentUD,
animationLabel: animation.active ? (animation.currentFrame?.label ?? undefined) : undefined, animationLabel: animation.active ? (animation.currentFrame?.label ?? undefined) : undefined,
@@ -225,7 +223,7 @@ export default function App() {
{/* Period selector — floating over map */} {/* Period selector — floating over map */}
<div className={`absolute top-4 z-[1000] ${isMobile ? 'left-16 right-4' : 'left-1/2 -translate-x-1/2'}`}> <div className={`absolute top-4 z-[1000] ${isMobile ? 'left-16 right-4' : 'left-1/2 -translate-x-1/2'}`}>
<PeriodSelector <PeriodSelector
period={period} value={periodDays}
onChange={handlePeriodChange} onChange={handlePeriodChange}
animationActive={animation.active} animationActive={animation.active}
onAnimate={() => animation.active ? animation.deactivate() : animation.activate()} onAnimate={() => animation.active ? animation.deactivate() : animation.activate()}
+56 -109
View File
@@ -1,9 +1,8 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect } from 'react';
import { type Period } from '../types/period';
interface PeriodSelectorProps { interface PeriodSelectorProps {
period: Period; value: number;
onChange: (period: Period) => void; onChange: (days: number) => void;
animationActive: boolean; animationActive: boolean;
onAnimate: () => void; onAnimate: () => void;
viewMode: 'heatmap' | 'flow'; viewMode: 'heatmap' | 'flow';
@@ -11,93 +10,50 @@ interface PeriodSelectorProps {
geoPercent?: number | null; geoPercent?: number | null;
} }
const PRESETS = [ const PERIODS = [
{ label: '24h', days: 1 }, { label: '24h', days: 1 },
{ label: '7 jours', days: 7 }, { label: '7 jours', days: 7 },
{ label: '30 jours', days: 30 }, { label: '30 jours', days: 30 },
]; ];
const PRESET_DAYS = new Set([1, 7, 30]); const PRESET_DAYS = new Set([1, 7, 30]);
function toDateInputValue(d: Date): string { export function PeriodSelector({ value, onChange, animationActive, onAnimate, viewMode, onViewModeChange, geoPercent }: PeriodSelectorProps) {
return d.toISOString().split('T')[0];
}
export function PeriodSelector({ period, onChange, animationActive, onAnimate, viewMode, onViewModeChange, geoPercent }: PeriodSelectorProps) {
const [customOpen, setCustomOpen] = useState(false); const [customOpen, setCustomOpen] = useState(false);
const [rangeOpen, setRangeOpen] = useState(false);
const [inputVal, setInputVal] = useState(''); const [inputVal, setInputVal] = useState('');
const [fromInput, setFromInput] = useState('');
const [toInput, setToInput] = useState('');
const inputRef = useRef<HTMLInputElement>(null); 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 = () => { const openCustom = () => {
setRangeOpen(false); setInputVal(PRESET_DAYS.has(value) ? '' : String(value));
setInputVal(isCustomDay ? String(period.days) : '');
setCustomOpen(true); setCustomOpen(true);
}; };
const openRange = () => { useEffect(() => {
setCustomOpen(false); if (customOpen) inputRef.current?.focus();
if (isRange) { }, [customOpen]);
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);
};
useEffect(() => { if (customOpen) inputRef.current?.focus(); }, [customOpen]); const commit = () => {
useEffect(() => { if (rangeOpen) fromRef.current?.focus(); }, [rangeOpen]);
const commitDays = () => {
const n = parseInt(inputVal, 10); const n = parseInt(inputVal, 10);
if (n >= 1) onChange({ type: 'sliding', days: n }); if (n >= 1 && n <= 365) onChange(n);
setCustomOpen(false); setCustomOpen(false);
}; };
const commitRange = () => { const isCustomActive = !PRESET_DAYS.has(value);
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 ( return (
<div className="flex flex-wrap gap-1 bg-[#0f1016] border border-[#2e2f3a] rounded-lg p-1 items-center max-w-[calc(100vw-2rem)]"> <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 <button
key={days} key={days}
onClick={() => { onChange({ type: 'sliding', days }); setCustomOpen(false); setRangeOpen(false); }} onClick={() => { onChange(days); setCustomOpen(false); }}
className={btnClass(isPreset && (period as { days: number }).days === days && !customOpen && !rangeOpen)} 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]'
}
`}
> >
{label} {label}
</button> </button>
@@ -105,68 +61,53 @@ export function PeriodSelector({ period, onChange, animationActive, onAnimate, v
<div className="w-px mx-1 bg-[#2e2f3a] self-stretch" /> <div className="w-px mx-1 bg-[#2e2f3a] self-stretch" />
{/* Nombre de jours personnalisé */} {/* Bouton Personnaliser + champ inline */}
{customOpen ? ( {customOpen ? (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<input <input
ref={inputRef} ref={inputRef}
type="number" type="number"
min={1} min={1}
max={365}
value={inputVal} value={inputVal}
onChange={(e) => setInputVal(e.target.value)} onChange={(e) => setInputVal(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') commitDays(); if (e.key === 'Escape') setCustomOpen(false); }} onKeyDown={(e) => {
onBlur={commitDays} if (e.key === 'Enter') commit();
if (e.key === 'Escape') setCustomOpen(false);
}}
onBlur={commit}
placeholder="jours" 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" 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> <span className="text-[#6b7280] text-xs">j</span>
</div> </div>
) : ( ) : (
<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 <button
onClick={commitRange} onClick={openCustom}
disabled={!fromInput || !toInput || fromInput >= toInput} className={`
className="px-2 py-1 text-xs text-[#0a0b0f] bg-[#d4a843] rounded-md disabled:opacity-30 hover:bg-[#e0b84d] transition-colors" 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]'
}
`}
> >
OK {isCustomActive ? `${value} jours` : 'Personnaliser'}
</button>
<button onClick={() => setRangeOpen(false)} className="text-[#4b5563] hover:text-white text-xs"></button>
</div>
) : (
<button onClick={openRange} className={btnClass(isRange && !customOpen)}>
{rangeLabel}
</button> </button>
)} )}
<div className="w-px mx-1 bg-[#2e2f3a] self-stretch" /> <div className="w-px mx-1 bg-[#2e2f3a] self-stretch" />
<button onClick={onAnimate} className={btnClass(animationActive)}> <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 Animer
</button> </button>
@@ -174,16 +115,22 @@ export function PeriodSelector({ period, onChange, animationActive, onAnimate, v
<button <button
onClick={() => onViewModeChange(viewMode === 'heatmap' ? 'flow' : 'heatmap')} onClick={() => onViewModeChange(viewMode === 'heatmap' ? 'flow' : 'heatmap')}
className={btnClass(viewMode === 'flow')} 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]'
}
`}
> >
{viewMode === 'flow' ? '⊙ Heatmap' : '◉ Flux'} {viewMode === 'flow' ? '⊙ Heatmap' : '◉ Flux'}
</button> </button>
{geoPercent != null && ( {geoPercent != null && (
<span className="text-[10px] font-mono text-white px-1 shrink-0"> <span className="text-[10px] font-mono text-white px-1 shrink-0">
{geoPercent}% Tx géoloc. {geoPercent}% Tx géoloc.
</span> </span>
)} )}
</div> </div>
); );
} }
+11 -20
View File
@@ -2,34 +2,30 @@ import { useMemo } from 'react';
interface SparklineProps { interface SparklineProps {
timestamps: number[]; timestamps: number[];
fromMs: number; periodDays: number;
toMs: number;
} }
/** /**
* Mini bar-chart SVG affichant l'activité sur la période. * Mini bar-chart SVG affichant l'activité journalière sur la période.
* Utilise les timestamps déjà en mémoire — aucune requête supplémentaire. * Utilise les timestamps déjà en mémoire — aucune requête supplémentaire.
*/ */
export function Sparkline({ timestamps, fromMs, toMs }: SparklineProps) { export function Sparkline({ timestamps, periodDays }: SparklineProps) {
const buckets = useMemo(() => { const buckets = useMemo(() => {
if (timestamps.length === 0) return []; if (timestamps.length === 0) return [];
const duration = toMs - fromMs; const n = periodDays === 1 ? 24 : Math.min(periodDays, 30);
const days = duration / 864e5; const now = Date.now();
const n = days <= 1 ? 24 : Math.min(Math.ceil(days), 30); const start = now - periodDays * 864e5;
const step = duration / n; const step = (periodDays * 864e5) / n;
const counts = new Array(n).fill(0); const counts = new Array(n).fill(0);
for (const ts of timestamps) { for (const ts of timestamps) {
const i = Math.floor((ts - fromMs) / step); const i = Math.floor((ts - start) / step);
if (i >= 0 && i < n) counts[i]++; if (i >= 0 && i < n) counts[i]++;
} }
return counts; return counts;
}, [timestamps, fromMs, toMs]); }, [timestamps, periodDays]);
if (buckets.length === 0) return null; if (buckets.length === 0) return null;
const duration = toMs - fromMs;
const days = duration / 864e5;
const n = buckets.length; const n = buckets.length;
const max = Math.max(...buckets, 1); const max = Math.max(...buckets, 1);
const W = 100; const W = 100;
@@ -37,11 +33,6 @@ export function Sparkline({ timestamps, fromMs, toMs }: SparklineProps) {
const barW = W / n; const barW = W / n;
const gap = barW * 0.18; 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 ( return (
<div className="space-y-1"> <div className="space-y-1">
<svg <svg
@@ -67,8 +58,8 @@ export function Sparkline({ timestamps, fromMs, toMs }: SparklineProps) {
})} })}
</svg> </svg>
<div className="flex justify-between text-[10px] text-[#4b5563]"> <div className="flex justify-between text-[10px] text-[#4b5563]">
<span>{fmtLabel(fromMs)}</span> <span>{periodDays === 1 ? '0h' : 'J-' + periodDays}</span>
<span>{fmtLabel(toMs)}</span> <span>{periodDays === 1 ? 'maintenant' : 'aujourd\'hui'}</span>
</div> </div>
</div> </div>
); );
+4 -9
View File
@@ -6,12 +6,11 @@ import { ServiceStatusDots } from './ServiceStatusDots';
import { useServiceStatus } from '../hooks/useServiceStatus'; import { useServiceStatus } from '../hooks/useServiceStatus';
import { CATEGORY_LABELS, CATEGORY_COLORS, type TxCategory } from '../data/commentParser'; import { CATEGORY_LABELS, CATEGORY_COLORS, type TxCategory } from '../data/commentParser';
import type { Transaction } from '../data/mockData'; import type { Transaction } from '../data/mockData';
import { type Period, periodToDates, periodToDays } from '../types/period';
interface StatsPanelProps { interface StatsPanelProps {
stats: PeriodStats | null; stats: PeriodStats | null;
loading: boolean; loading: boolean;
period: Period; periodDays: number;
source: 'live' | 'mock'; source: 'live' | 'mock';
className?: string; className?: string;
currentUD: number; currentUD: number;
@@ -69,14 +68,10 @@ function CityRow({ city, volume, count, countryCode, accent }: {
); );
} }
export function StatsPanel({ stats, loading, period, source, currentUD, animationLabel, viewMode = 'heatmap', flowStats, focusCity, onClose, onEndpointChange, className, allTimestamps = [], transactions = [] }: StatsPanelProps) { export function StatsPanel({ stats, loading, periodDays, source, currentUD, animationLabel, viewMode = 'heatmap', flowStats, focusCity, onClose, onEndpointChange, className, allTimestamps = [], transactions = [] }: StatsPanelProps) {
const { subsquid, cesium, recheck } = useServiceStatus(); const { subsquid, cesium, recheck } = useServiceStatus();
const [openCategory, setOpenCategory] = useState<TxCategory | null>(null); const [openCategory, setOpenCategory] = useState<TxCategory | null>(null);
const { from, to } = periodToDates(period); const periodLabel = periodDays === 1 ? '24 dernières heures' : `${periodDays} derniers jours`;
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); const prevStats = useRef<PeriodStats | null>(null);
// Calcule le delta d'une valeur par rapport au refresh précédent // Calcule le delta d'une valeur par rapport au refresh précédent
@@ -141,7 +136,7 @@ export function StatsPanel({ stats, loading, period, source, currentUD, animatio
} }
</p> </p>
{!animationLabel && allTimestamps.length > 0 && ( {!animationLabel && allTimestamps.length > 0 && (
<Sparkline timestamps={allTimestamps} fromMs={from.getTime()} toMs={to.getTime()} /> <Sparkline timestamps={allTimestamps} periodDays={periodDays} />
)} )}
</div> </div>
+3 -2
View File
@@ -94,11 +94,12 @@ function generateTransactions(count: number, maxAgeMs: number): Transaction[] {
const POOL_GENERATED_AT = Date.now(); 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(from: Date, to: Date): Transaction[] { export function getTransactionsForPeriod(periodDays: number): Transaction[] {
const drift = Date.now() - POOL_GENERATED_AT; const drift = Date.now() - POOL_GENERATED_AT;
const cutoff = Date.now() - periodDays * 24 * 60 * 60 * 1000;
return TRANSACTION_POOL return TRANSACTION_POOL
.map((tx) => ({ ...tx, timestamp: tx.timestamp + drift })) .map((tx) => ({ ...tx, timestamp: tx.timestamp + drift }))
.filter((tx) => tx.timestamp >= from.getTime() && tx.timestamp <= to.getTime()); .filter((tx) => tx.timestamp >= cutoff);
} }
export function computeStats(transactions: Transaction[]) { export function computeStats(transactions: Transaction[]) {
+35 -46
View File
@@ -1,7 +1,6 @@
import { useState, useMemo, useEffect } from 'react'; import { useState, useMemo, useEffect } from 'react';
import type { Transaction } from '../data/mockData'; import type { Transaction } from '../data/mockData';
import type { TransactionArc } from '../data/arcData'; import type { TransactionArc } from '../data/arcData';
import { type Period, periodToDates, periodKey } from '../types/period';
export interface TimeFrame { export interface TimeFrame {
label: string; label: string;
@@ -9,57 +8,47 @@ export interface TimeFrame {
to: number; // Unix ms to: number; // Unix ms
} }
function buildFrames(fromMs: number, toMs: number): TimeFrame[] { function buildFrames(periodDays: number): TimeFrame[] {
const duration = toMs - fromMs; const now = Date.now();
const days = duration / 86_400_000; const start = now - periodDays * 24 * 60 * 60 * 1000;
const fmt = (ms: number, opts: Intl.DateTimeFormatOptions) => const fmt = (ms: number, opts: Intl.DateTimeFormatOptions) =>
new Date(ms).toLocaleDateString('fr-FR', opts); new Date(ms).toLocaleDateString('fr-FR', opts);
// ≤ 2 jours : frames horaires if (periodDays === 1) {
if (days <= 2) { return Array.from({ length: 24 }, (_, i) => {
const frames: TimeFrame[] = []; const from = start + i * 3_600_000;
let cursor = fromMs; const to = from + 3_600_000;
while (cursor < toMs) {
const from = cursor;
const to = Math.min(cursor + 3_600_000, toMs);
const h = new Date(from).getHours(); const h = new Date(from).getHours();
frames.push({ return {
label: `${fmt(from, { weekday: 'short', day: 'numeric', month: 'short' })} · ${h}h`, label: `${fmt(from, { weekday: 'short', day: 'numeric', month: 'short' })} · ${h}h ${h + 1}h`,
from, from,
to, to,
};
}); });
cursor = to;
}
return frames;
} }
// ≤ 14 jours : frames journalières if (periodDays === 7) {
if (days <= 14) { return Array.from({ length: 7 }, (_, i) => {
const frames: TimeFrame[] = []; const from = start + i * 86_400_000;
let cursor = fromMs; const to = from + 86_400_000;
while (cursor < toMs) { return {
const from = cursor;
const to = Math.min(cursor + 86_400_000, toMs);
frames.push({
label: fmt(from, { weekday: 'long', day: 'numeric', month: 'short' }), label: fmt(from, { weekday: 'long', day: 'numeric', month: 'short' }),
from, from,
to, to,
};
}); });
cursor = to;
}
return frames;
} }
// > 14 jours : frames hebdomadaires // 30 days → half-week frames (3.5 days ≈ 910 frames)
const WEEK = 7 * 86_400_000; const HALF_WEEK = 3.5 * 86_400_000;
const frames: TimeFrame[] = []; const frames: TimeFrame[] = [];
let cursor = fromMs; let cursor = start;
while (cursor < toMs) { while (cursor < now) {
const from = cursor; const from = cursor;
const to = Math.min(cursor + WEEK, toMs); const to = Math.min(cursor + HALF_WEEK, now);
frames.push({ frames.push({
label: `${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,
}); });
@@ -68,33 +57,32 @@ function buildFrames(fromMs: number, toMs: number): TimeFrame[] {
return frames; return frames;
} }
export function useAnimation(transactions: Transaction[], arcs: TransactionArc[], period: Period, allTimestamps: number[] = []) { export function useAnimation(transactions: Transaction[], arcs: TransactionArc[], 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>(2); const [speed, setSpeed] = useState<1 | 2 | 4>(2);
const key = periodKey(period); const frames = useMemo(() => buildFrames(periodDays), [periodDays]);
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(() => { useEffect(() => {
setCurrentIndex(0); setCurrentIndex(0);
if (!active) setPlaying(false); if (!active) setPlaying(false);
// eslint-disable-next-line react-hooks/exhaustive-deps }, [periodDays, active]);
}, [key, active]);
// Auto-advance: one step every (1500 / speed) ms // Auto-advance: one step every (2000 / speed) ms
useEffect(() => { useEffect(() => {
if (!playing || !active) return; if (!playing || !active) return;
const delay = 1500 / 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) { setPlaying(false); return i; } if (i >= frames.length - 1) {
setPlaying(false);
return i;
}
return i + 1; return i + 1;
}); });
}, delay); }, delay);
@@ -115,6 +103,7 @@ export function useAnimation(transactions: Transaction[], arcs: TransactionArc[]
return arcs.filter((a) => a.timestamp >= frame.from && a.timestamp < frame.to); return arcs.filter((a) => a.timestamp >= frame.from && a.timestamp < frame.to);
}, [active, arcs, frames, currentIndex]); }, [active, arcs, frames, currentIndex]);
// Nombre total de transfers (géo + non-géo) dans la frame courante
const frameTotalCount = useMemo(() => { const frameTotalCount = useMemo(() => {
if (!active || frames.length === 0 || allTimestamps.length === 0) return null; if (!active || frames.length === 0 || allTimestamps.length === 0) return null;
const frame = frames[currentIndex]; const frame = frames[currentIndex];
+9 -33
View File
@@ -5,34 +5,17 @@
* Écriture : useUrlSync() à appeler dans App pour maintenir l'URL à jour. * Écriture : useUrlSync() à appeler dans App pour maintenir l'URL à jour.
* *
* Paramètres supportés : * Paramètres supportés :
* ?period=7&view=flow&city=Paris (mode glissant) * ?period=7&view=flow&city=Paris
* ?from=2026-01-01&to=2026-01-31&view=flow (mode plage)
*/ */
import { useEffect } from 'react'; import { useEffect } from 'react';
import { type Period, periodKey } from '../types/period';
function parseInitialState(): { period: Period; view: 'heatmap' | 'flow'; city: string | null } { function parseInitialState(): { period: number; view: 'heatmap' | 'flow'; city: string | null } {
const p = new URLSearchParams(window.location.search); const p = new URLSearchParams(window.location.search);
const view = p.get('view') === 'flow' ? 'flow' : 'heatmap'; const period = parseInt(p.get('period') ?? '', 10);
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 { return {
period: { type: 'sliding', days: Number.isFinite(days) && days >= 1 ? days : 7 }, period: Number.isFinite(period) && period >= 1 && period <= 365 ? period : 7,
view, view: p.get('view') === 'flow' ? 'flow' : 'heatmap',
city, city: p.get('city') ?? null,
}; };
} }
@@ -41,23 +24,16 @@ export const initialUrlState = parseInitialState();
/** Écrit l'état courant dans l'URL (history.replaceState, sans recharger). */ /** Écrit l'état courant dans l'URL (history.replaceState, sans recharger). */
export function useUrlSync( export function useUrlSync(
period: Period, periodDays: number,
viewMode: 'heatmap' | 'flow', viewMode: 'heatmap' | 'flow',
focusCity: string | null, focusCity: string | null,
) { ) {
const key = periodKey(period);
useEffect(() => { useEffect(() => {
const p = new URLSearchParams(); const p = new URLSearchParams();
if (period.type === 'range') { if (periodDays !== 7) p.set('period', String(periodDays));
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 (viewMode !== 'heatmap') p.set('view', viewMode);
if (focusCity) p.set('city', focusCity); if (focusCity) p.set('city', focusCity);
const qs = p.toString(); const qs = p.toString();
history.replaceState(null, '', qs ? `?${qs}` : window.location.pathname); history.replaceState(null, '', qs ? `?${qs}` : window.location.pathname);
// eslint-disable-next-line react-hooks/exhaustive-deps }, [periodDays, viewMode, focusCity]);
}, [key, viewMode, focusCity]);
} }
+7 -10
View File
@@ -13,7 +13,6 @@
*/ */
import { fetchTransfers, buildIdentityKeyMap, fetchCurrentUD, ss58ToDuniterKey, fetchActiveMemberKeys } from './adapters/SubsquidAdapter'; 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 { resolveGeoByKeys, resolveGeoByKeysBatched, cleanCityName } from './adapters/CesiumAdapter';
import { parseComment } from '../data/commentParser'; import { parseComment } from '../data/commentParser';
import { import {
@@ -48,17 +47,16 @@ async function getIdentityKeyMap(): Promise<Map<string, string>> {
return map; return map;
} }
async function fetchLiveTransactions(period: Period): Promise<{ async function fetchLiveTransactions(periodDays: number): Promise<{
geolocated: Transaction[]; geolocated: Transaction[];
arcs: TransactionArc[]; arcs: TransactionArc[];
totalCount: number; totalCount: number;
totalVolume: number; totalVolume: number;
allTimestamps: number[]; allTimestamps: number[];
}> { }> {
const { from, to } = periodToDates(period); // ~400 tx/jour sur le réseau Ğ1v2 → marge ×1.5 arrondie, minimum 2000
const days = periodToDays(period); const limit = Math.max(2000, Math.ceil(periodDays * 600));
const limit = Math.max(2000, Math.ceil(days * 600)); const { transfers: rawTransfers, totalCount } = await fetchTransfers(periodDays, limit);
const { transfers: rawTransfers, totalCount } = await fetchTransfers(from, to, limit);
if (rawTransfers.length === 0) return { geolocated: [], arcs: [], totalCount: 0, totalVolume: 0, allTimestamps: [] }; if (rawTransfers.length === 0) return { geolocated: [], arcs: [], totalCount: 0, totalVolume: 0, allTimestamps: [] };
const totalVolume = rawTransfers.reduce((s, t) => s + t.amount, 0); const totalVolume = rawTransfers.reduce((s, t) => s + t.amount, 0);
@@ -212,11 +210,10 @@ export interface DataResult {
allTimestamps: number[]; // timestamps de TOUS les transfers (géo + non-géo) allTimestamps: number[]; // timestamps de TOUS les transfers (géo + non-géo)
} }
export async function fetchData(period: Period): Promise<DataResult> { export async function fetchData(periodDays: number): Promise<DataResult> {
if (!USE_LIVE_API) { if (!USE_LIVE_API) {
await new Promise((r) => setTimeout(r, 80)); await new Promise((r) => setTimeout(r, 80));
const { from, to } = periodToDates(period); const transactions = getTransactionsForPeriod(periodDays);
const transactions = getTransactionsForPeriod(from, to);
const base = computeStats(transactions); const base = computeStats(transactions);
const arcs = buildMockArcs(transactions); const arcs = buildMockArcs(transactions);
return { return {
@@ -230,7 +227,7 @@ export async function fetchData(period: Period): Promise<DataResult> {
} }
const [{ geolocated, arcs, totalCount, totalVolume, allTimestamps }, currentUD] = await Promise.all([ const [{ geolocated, arcs, totalCount, totalVolume, allTimestamps }, currentUD] = await Promise.all([
fetchLiveTransactions(period), fetchLiveTransactions(periodDays),
getCurrentUD(), getCurrentUD(),
]); ]);
const base = computeStats(geolocated); const base = computeStats(geolocated);
+7 -7
View File
@@ -60,11 +60,11 @@ export interface RawTransfer {
// Query // Query
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const TRANSFERS_QUERY = ` const TRANSFERS_QUERY = `
query GetTransfers($since: Datetime!, $until: Datetime!, $limit: Int!) { query GetTransfers($since: Datetime!, $limit: Int!) {
transfers( transfers(
orderBy: TIMESTAMP_DESC orderBy: TIMESTAMP_DESC
first: $limit first: $limit
filter: { timestamp: { greaterThanOrEqualTo: $since, lessThanOrEqualTo: $until } } filter: { timestamp: { greaterThanOrEqualTo: $since } }
) { ) {
totalCount totalCount
nodes { nodes {
@@ -221,19 +221,19 @@ export interface FetchTransfersResult {
} }
export async function fetchTransfers( export async function fetchTransfers(
from: Date, periodDays: number,
to: Date,
limit = 2000 limit = 2000
): Promise<FetchTransfersResult> { ): Promise<FetchTransfersResult> {
const since = from.toISOString(); const since = new Date(
const until = to.toISOString(); Date.now() - periodDays * 24 * 60 * 60 * 1000
).toISOString();
const response = await fetch(getSubsquidUrl(), { const response = await fetch(getSubsquidUrl(), {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
query: TRANSFERS_QUERY, query: TRANSFERS_QUERY,
variables: { since, until, limit }, variables: { since, limit },
}), }),
}); });
-28
View File
@@ -1,28 +0,0 @@
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;
}