diff --git a/src/App.tsx b/src/App.tsx index e7e4729..87db7d3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,9 +2,12 @@ import { useState, useEffect } from 'react'; import { StatsPanel } from './components/StatsPanel'; import { PeriodSelector } from './components/PeriodSelector'; import { HeatMap } from './components/HeatMap'; +import { AnimationPlayer } from './components/AnimationPlayer'; import { fetchData } from './services/DataService'; import type { PeriodStats } from './services/DataService'; import type { Transaction } from './data/mockData'; +import { computeStats } from './data/mockData'; +import { useAnimation } from './hooks/useAnimation'; export default function App() { const [periodDays, setPeriodDays] = useState(7); @@ -15,6 +18,13 @@ export default function App() { const [lastUpdate, setLastUpdate] = useState(null); const [source, setSource] = useState<'live' | 'mock'>('mock'); + const animation = useAnimation(transactions, periodDays); + + const handlePeriodChange = (days: number) => { + animation.deactivate(); + setPeriodDays(days); + }; + useEffect(() => { let cancelled = false; @@ -42,22 +52,41 @@ export default function App() { return () => { cancelled = true; clearInterval(interval); }; }, [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 (
{/* Side panel */} - + {/* Map area */}
- + {/* Period selector — floating over map */}
- + animation.active ? animation.deactivate() : animation.activate()} + />
- {/* Transaction count + source badge */} - {!loading && ( + {/* Transaction count + source badge (masqués en mode animation) */} + {!loading && !animation.active && (
{transactions.length} transactions affichées @@ -74,6 +103,21 @@ export default function App() {
)} + {/* Animation player */} + {animation.active && ( + + )} + {/* Loading overlay */} {loading && (
diff --git a/src/components/AnimationPlayer.tsx b/src/components/AnimationPlayer.tsx new file mode 100644 index 0000000..6e1817f --- /dev/null +++ b/src/components/AnimationPlayer.tsx @@ -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 ( +
+
+ + {/* Frame label + position */} +
+ + {frame?.label ?? '—'} + + + {currentIndex + 1} / {frames.length} + +
+ + {/* Slider */} + onSeek(Number(e.target.value))} + className="w-full h-1 accent-[#d4a843] cursor-pointer" + /> + + {/* Controls row */} +
+ + {/* Playback buttons */} +
+ + + +
+ + {/* Speed selector */} +
+ Vitesse + {([1, 2, 4] as const).map((s) => ( + + ))} +
+ + {/* Close */} + +
+
+
+ ); +} diff --git a/src/components/PeriodSelector.tsx b/src/components/PeriodSelector.tsx index 9ea0c88..f043cc0 100644 --- a/src/components/PeriodSelector.tsx +++ b/src/components/PeriodSelector.tsx @@ -1,6 +1,8 @@ interface PeriodSelectorProps { value: number; onChange: (days: number) => void; + animationActive: boolean; + onAnimate: () => void; } const PERIODS = [ @@ -9,7 +11,7 @@ const PERIODS = [ { label: '30 jours', days: 30 }, ]; -export function PeriodSelector({ value, onChange }: PeriodSelectorProps) { +export function PeriodSelector({ value, onChange, animationActive, onAnimate }: PeriodSelectorProps) { return (
{PERIODS.map(({ label, days }) => ( @@ -27,6 +29,19 @@ export function PeriodSelector({ value, onChange }: PeriodSelectorProps) { {label} ))} +
+
); } diff --git a/src/components/StatsPanel.tsx b/src/components/StatsPanel.tsx index a54a6ac..b5fb36c 100644 --- a/src/components/StatsPanel.tsx +++ b/src/components/StatsPanel.tsx @@ -6,6 +6,7 @@ interface StatsPanelProps { loading: boolean; periodDays: number; source: 'live' | 'mock'; + animationLabel?: string; } 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 prevStats = useRef(null); @@ -70,7 +71,10 @@ export function StatsPanel({ stats, loading, periodDays, source }: StatsPanelPro {/* Period label */}

- Période : {periodLabel} + {animationLabel + ? <> {animationLabel} + : <>Période : {periodLabel} + }

{/* Stats */} diff --git a/src/hooks/useAnimation.ts b/src/hooks/useAnimation.ts new file mode 100644 index 0000000..02ca2a6 --- /dev/null +++ b/src/hooks/useAnimation.ts @@ -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, + }; +}