Nouveau mode animation accessible via "▶ Animer" dans le sélecteur de période. - useAnimation : hook gérant frames, lecture, vitesse, filtrage client - AnimationPlayer : barre de contrôle (play/pause, slider, ×1/×2/×4) - Granularité auto : 24 frames/h (24h), 7 frames/jour (7j), ~4 frames/semaine (30j) - Stats et heatmap mis à jour sur la fenêtre courante, zéro requête réseau supplémentaire Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+49
-5
@@ -2,9 +2,12 @@ import { useState, useEffect } from 'react';
|
|||||||
import { StatsPanel } from './components/StatsPanel';
|
import { StatsPanel } from './components/StatsPanel';
|
||||||
import { PeriodSelector } from './components/PeriodSelector';
|
import { PeriodSelector } from './components/PeriodSelector';
|
||||||
import { HeatMap } from './components/HeatMap';
|
import { HeatMap } from './components/HeatMap';
|
||||||
|
import { AnimationPlayer } from './components/AnimationPlayer';
|
||||||
import { fetchData } from './services/DataService';
|
import { fetchData } from './services/DataService';
|
||||||
import type { PeriodStats } from './services/DataService';
|
import type { PeriodStats } from './services/DataService';
|
||||||
import type { Transaction } from './data/mockData';
|
import type { Transaction } from './data/mockData';
|
||||||
|
import { computeStats } from './data/mockData';
|
||||||
|
import { useAnimation } from './hooks/useAnimation';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [periodDays, setPeriodDays] = useState(7);
|
const [periodDays, setPeriodDays] = useState(7);
|
||||||
@@ -15,6 +18,13 @@ export default function App() {
|
|||||||
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 animation = useAnimation(transactions, periodDays);
|
||||||
|
|
||||||
|
const handlePeriodChange = (days: number) => {
|
||||||
|
animation.deactivate();
|
||||||
|
setPeriodDays(days);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
@@ -42,22 +52,41 @@ export default function App() {
|
|||||||
return () => { cancelled = true; clearInterval(interval); };
|
return () => { cancelled = true; clearInterval(interval); };
|
||||||
}, [periodDays]);
|
}, [periodDays]);
|
||||||
|
|
||||||
|
// Stats calculées sur la fenêtre courante en mode animation
|
||||||
|
const visibleStats: PeriodStats | null = animation.active
|
||||||
|
? {
|
||||||
|
...computeStats(animation.visibleTransactions),
|
||||||
|
geoCount: animation.visibleTransactions.length,
|
||||||
|
}
|
||||||
|
: stats;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-svh w-full overflow-hidden bg-[#0a0b0f] text-white">
|
<div className="flex h-svh w-full overflow-hidden bg-[#0a0b0f] text-white">
|
||||||
{/* Side panel */}
|
{/* Side panel */}
|
||||||
<StatsPanel stats={stats} loading={loading} periodDays={periodDays} source={source} />
|
<StatsPanel
|
||||||
|
stats={visibleStats}
|
||||||
|
loading={loading}
|
||||||
|
periodDays={periodDays}
|
||||||
|
source={source}
|
||||||
|
animationLabel={animation.active ? (animation.currentFrame?.label ?? undefined) : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Map area */}
|
{/* Map area */}
|
||||||
<div className="relative flex-1 min-w-0">
|
<div className="relative flex-1 min-w-0">
|
||||||
<HeatMap transactions={transactions} />
|
<HeatMap transactions={animation.visibleTransactions} />
|
||||||
|
|
||||||
{/* Period selector — floating over map */}
|
{/* Period selector — floating over map */}
|
||||||
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-[1000]">
|
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-[1000]">
|
||||||
<PeriodSelector value={periodDays} onChange={setPeriodDays} />
|
<PeriodSelector
|
||||||
|
value={periodDays}
|
||||||
|
onChange={handlePeriodChange}
|
||||||
|
animationActive={animation.active}
|
||||||
|
onAnimate={() => animation.active ? animation.deactivate() : animation.activate()}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Transaction count + source badge */}
|
{/* Transaction count + source badge (masqués en mode animation) */}
|
||||||
{!loading && (
|
{!loading && !animation.active && (
|
||||||
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 z-[1000] flex items-center gap-2">
|
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 z-[1000] flex items-center gap-2">
|
||||||
<div className="bg-[#0a0b0f]/80 backdrop-blur-sm border border-[#2e2f3a] rounded-full px-4 py-1.5 text-xs text-[#6b7280]">
|
<div className="bg-[#0a0b0f]/80 backdrop-blur-sm border border-[#2e2f3a] rounded-full px-4 py-1.5 text-xs text-[#6b7280]">
|
||||||
<span className="text-[#d4a843] font-medium">{transactions.length}</span> transactions affichées
|
<span className="text-[#d4a843] font-medium">{transactions.length}</span> transactions affichées
|
||||||
@@ -74,6 +103,21 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Animation player */}
|
||||||
|
{animation.active && (
|
||||||
|
<AnimationPlayer
|
||||||
|
frames={animation.frames}
|
||||||
|
currentIndex={animation.currentIndex}
|
||||||
|
playing={animation.playing}
|
||||||
|
speed={animation.speed}
|
||||||
|
onSeek={animation.seek}
|
||||||
|
onPlay={animation.play}
|
||||||
|
onPause={animation.pause}
|
||||||
|
onSpeedChange={animation.setSpeed}
|
||||||
|
onClose={animation.deactivate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Loading overlay */}
|
{/* Loading overlay */}
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="absolute inset-0 z-[999] flex items-center justify-center bg-[#0a0b0f]/60 backdrop-blur-sm">
|
<div className="absolute inset-0 z-[999] flex items-center justify-center bg-[#0a0b0f]/60 backdrop-blur-sm">
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import type { TimeFrame } from '../hooks/useAnimation';
|
||||||
|
|
||||||
|
interface AnimationPlayerProps {
|
||||||
|
frames: TimeFrame[];
|
||||||
|
currentIndex: number;
|
||||||
|
playing: boolean;
|
||||||
|
speed: 1 | 2 | 4;
|
||||||
|
onSeek: (i: number) => void;
|
||||||
|
onPlay: () => void;
|
||||||
|
onPause: () => void;
|
||||||
|
onSpeedChange: (s: 1 | 2 | 4) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnimationPlayer({
|
||||||
|
frames,
|
||||||
|
currentIndex,
|
||||||
|
playing,
|
||||||
|
speed,
|
||||||
|
onSeek,
|
||||||
|
onPlay,
|
||||||
|
onPause,
|
||||||
|
onSpeedChange,
|
||||||
|
onClose,
|
||||||
|
}: AnimationPlayerProps) {
|
||||||
|
const frame = frames[currentIndex];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-[1001] w-[min(640px,90vw)]">
|
||||||
|
<div className="bg-[#0a0b0f]/90 backdrop-blur-sm border border-[#2e2f3a] rounded-2xl px-5 py-3 flex flex-col gap-2.5 shadow-xl">
|
||||||
|
|
||||||
|
{/* Frame label + position */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[#d4a843] text-sm font-medium">
|
||||||
|
{frame?.label ?? '—'}
|
||||||
|
</span>
|
||||||
|
<span className="text-[#4b5563] text-xs tabular-nums">
|
||||||
|
{currentIndex + 1} / {frames.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Slider */}
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={frames.length - 1}
|
||||||
|
value={currentIndex}
|
||||||
|
onChange={(e) => onSeek(Number(e.target.value))}
|
||||||
|
className="w-full h-1 accent-[#d4a843] cursor-pointer"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Controls row */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
|
||||||
|
{/* Playback buttons */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => onSeek(Math.max(0, currentIndex - 1))}
|
||||||
|
className="px-2.5 py-1.5 text-[#6b7280] hover:text-white transition-colors text-sm"
|
||||||
|
title="Frame précédente"
|
||||||
|
>
|
||||||
|
◀◀
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={playing ? onPause : onPlay}
|
||||||
|
className="px-4 py-1.5 bg-[#d4a843] text-[#0a0b0f] rounded-lg font-bold text-sm hover:bg-[#e8c060] transition-colors min-w-[52px] text-center shadow-[0_0_10px_rgba(212,168,67,0.3)]"
|
||||||
|
>
|
||||||
|
{playing ? '⏸' : '▶'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onSeek(Math.min(frames.length - 1, currentIndex + 1))}
|
||||||
|
className="px-2.5 py-1.5 text-[#6b7280] hover:text-white transition-colors text-sm"
|
||||||
|
title="Frame suivante"
|
||||||
|
>
|
||||||
|
▶▶
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Speed selector */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-[#4b5563] text-xs mr-1">Vitesse</span>
|
||||||
|
{([1, 2, 4] as const).map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
onClick={() => onSpeedChange(s)}
|
||||||
|
className={`px-2 py-1 rounded text-xs font-medium transition-colors ${
|
||||||
|
speed === s
|
||||||
|
? 'bg-[#d4a843] text-[#0a0b0f]'
|
||||||
|
: 'text-[#6b7280] hover:text-[#d4a843] hover:bg-[#1a1b23]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
×{s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Close */}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-[#4b5563] hover:text-white transition-colors px-2 py-1 text-sm ml-2"
|
||||||
|
title="Quitter l'animation"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
interface PeriodSelectorProps {
|
interface PeriodSelectorProps {
|
||||||
value: number;
|
value: number;
|
||||||
onChange: (days: number) => void;
|
onChange: (days: number) => void;
|
||||||
|
animationActive: boolean;
|
||||||
|
onAnimate: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PERIODS = [
|
const PERIODS = [
|
||||||
@@ -9,7 +11,7 @@ const PERIODS = [
|
|||||||
{ label: '30 jours', days: 30 },
|
{ label: '30 jours', days: 30 },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function PeriodSelector({ value, onChange }: PeriodSelectorProps) {
|
export function PeriodSelector({ value, onChange, animationActive, onAnimate }: PeriodSelectorProps) {
|
||||||
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">
|
||||||
{PERIODS.map(({ label, days }) => (
|
{PERIODS.map(({ label, days }) => (
|
||||||
@@ -27,6 +29,19 @@ export function PeriodSelector({ value, onChange }: PeriodSelectorProps) {
|
|||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
<div className="w-px mx-1 bg-[#2e2f3a] self-stretch" />
|
||||||
|
<button
|
||||||
|
onClick={onAnimate}
|
||||||
|
className={`
|
||||||
|
px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer
|
||||||
|
${animationActive
|
||||||
|
? 'bg-[#d4a843] text-[#0a0b0f] shadow-[0_0_12px_rgba(212,168,67,0.4)]'
|
||||||
|
: 'text-[#6b7280] hover:text-[#d4a843] hover:bg-[#1a1b23]'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
▶ Animer
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ interface StatsPanelProps {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
periodDays: number;
|
periodDays: number;
|
||||||
source: 'live' | 'mock';
|
source: 'live' | 'mock';
|
||||||
|
animationLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MEDALS = ['🥇', '🥈', '🥉'];
|
const MEDALS = ['🥇', '🥈', '🥉'];
|
||||||
@@ -24,7 +25,7 @@ function StatCard({ label, value, sub, delta }: { label: string; value: string;
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatsPanel({ stats, loading, periodDays, source }: StatsPanelProps) {
|
export function StatsPanel({ stats, loading, periodDays, source, 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);
|
||||||
|
|
||||||
@@ -70,7 +71,10 @@ export function StatsPanel({ stats, loading, periodDays, source }: StatsPanelPro
|
|||||||
|
|
||||||
{/* Period label */}
|
{/* Period label */}
|
||||||
<p className="text-[#4b5563] text-xs border-t border-[#1e1f2a] pt-3">
|
<p className="text-[#4b5563] text-xs border-t border-[#1e1f2a] pt-3">
|
||||||
Période : <span className="text-[#6b7280]">{periodLabel}</span>
|
{animationLabel
|
||||||
|
? <><span className="text-[#d4a843]">▶</span> <span className="text-[#d4a843]">{animationLabel}</span></>
|
||||||
|
: <>Période : <span className="text-[#6b7280]">{periodLabel}</span></>
|
||||||
|
}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { useState, useMemo, useEffect } from 'react';
|
||||||
|
import type { Transaction } from '../data/mockData';
|
||||||
|
|
||||||
|
export interface TimeFrame {
|
||||||
|
label: string;
|
||||||
|
from: number; // Unix ms
|
||||||
|
to: number; // Unix ms
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFrames(periodDays: number): TimeFrame[] {
|
||||||
|
const now = Date.now();
|
||||||
|
const start = now - periodDays * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const fmt = (ms: number, opts: Intl.DateTimeFormatOptions) =>
|
||||||
|
new Date(ms).toLocaleDateString('fr-FR', opts);
|
||||||
|
|
||||||
|
if (periodDays === 1) {
|
||||||
|
return Array.from({ length: 24 }, (_, i) => {
|
||||||
|
const from = start + i * 3_600_000;
|
||||||
|
const to = from + 3_600_000;
|
||||||
|
const h = new Date(from).getHours();
|
||||||
|
return {
|
||||||
|
label: `${fmt(from, { weekday: 'short', day: 'numeric', month: 'short' })} · ${h}h – ${h + 1}h`,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (periodDays === 7) {
|
||||||
|
return Array.from({ length: 7 }, (_, i) => {
|
||||||
|
const from = start + i * 86_400_000;
|
||||||
|
const to = from + 86_400_000;
|
||||||
|
return {
|
||||||
|
label: fmt(from, { weekday: 'long', day: 'numeric', month: 'short' }),
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 30 days → weekly frames
|
||||||
|
const frames: TimeFrame[] = [];
|
||||||
|
let cursor = start;
|
||||||
|
let week = 1;
|
||||||
|
while (cursor < now) {
|
||||||
|
const from = cursor;
|
||||||
|
const to = Math.min(cursor + 7 * 86_400_000, now);
|
||||||
|
frames.push({
|
||||||
|
label: `Semaine ${week} · ${fmt(from, { day: 'numeric', month: 'short' })} – ${fmt(to - 1, { day: 'numeric', month: 'short' })}`,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
});
|
||||||
|
cursor = to;
|
||||||
|
week++;
|
||||||
|
}
|
||||||
|
return frames;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAnimation(transactions: Transaction[], periodDays: number) {
|
||||||
|
const [active, setActive] = useState(false);
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const [speed, setSpeed] = useState<1 | 2 | 4>(1);
|
||||||
|
|
||||||
|
const frames = useMemo(() => buildFrames(periodDays), [periodDays]);
|
||||||
|
|
||||||
|
// Reset cursor and playback when period or activation changes
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentIndex(0);
|
||||||
|
setPlaying(false);
|
||||||
|
}, [periodDays, active]);
|
||||||
|
|
||||||
|
// Auto-advance: one step every (2000 / speed) ms
|
||||||
|
useEffect(() => {
|
||||||
|
if (!playing || !active) return;
|
||||||
|
const delay = 2000 / speed;
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
setCurrentIndex((i) => {
|
||||||
|
if (i >= frames.length - 1) {
|
||||||
|
setPlaying(false);
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
return i + 1;
|
||||||
|
});
|
||||||
|
}, delay);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [playing, active, currentIndex, speed, frames.length]);
|
||||||
|
|
||||||
|
const visibleTransactions = useMemo(() => {
|
||||||
|
if (!active || frames.length === 0) return transactions;
|
||||||
|
const frame = frames[currentIndex];
|
||||||
|
if (!frame) return transactions;
|
||||||
|
return transactions.filter((t) => t.timestamp >= frame.from && t.timestamp < frame.to);
|
||||||
|
}, [active, transactions, frames, currentIndex]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
active,
|
||||||
|
activate: () => setActive(true),
|
||||||
|
deactivate: () => { setActive(false); },
|
||||||
|
playing,
|
||||||
|
play: () => setPlaying(true),
|
||||||
|
pause: () => setPlaying(false),
|
||||||
|
currentIndex,
|
||||||
|
seek: (i: number) => { setCurrentIndex(i); setPlaying(false); },
|
||||||
|
speed,
|
||||||
|
setSpeed,
|
||||||
|
frames,
|
||||||
|
currentFrame: frames[currentIndex] ?? null,
|
||||||
|
visibleTransactions,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user