diff --git a/.gitignore b/.gitignore index 0ecf508..1938f43 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ dist-ssr /docs-plan/ /docs-syoul/ +/docs-bugs/ diff --git a/src/App.tsx b/src/App.tsx index e7e4729..ac163dc 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); @@ -14,6 +17,15 @@ export default function App() { const [refreshing, setRefreshing] = useState(false); const [lastUpdate, setLastUpdate] = useState(null); const [source, setSource] = useState<'live' | 'mock'>('mock'); + const [currentUD, setCurrentUD] = useState(11.78); + const [allTimestamps, setAllTimestamps] = useState([]); + + const animation = useAnimation(transactions, periodDays, allTimestamps); + + const handlePeriodChange = (days: number) => { + animation.deactivate(); + setPeriodDays(days); + }; useEffect(() => { let cancelled = false; @@ -22,11 +34,13 @@ export default function App() { if (showLoading) setLoading(true); else setRefreshing(true); fetchData(periodDays) - .then(({ transactions, stats, source }) => { + .then(({ transactions, stats, source, currentUD, allTimestamps }) => { if (!cancelled) { setTransactions(transactions); setStats(stats); setSource(source); + setCurrentUD(currentUD); + setAllTimestamps(allTimestamps); setLastUpdate(new Date()); } }) @@ -42,22 +56,44 @@ 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, + // frameTotalCount = total réel (géo + non-géo) dans cette frame + transactionCount: animation.frameTotalCount ?? 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 +110,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/HeatMap.tsx b/src/components/HeatMap.tsx index 06070ae..9a24a03 100644 --- a/src/components/HeatMap.tsx +++ b/src/components/HeatMap.tsx @@ -33,6 +33,12 @@ export function HeatMap({ transactions }: HeatMapProps) { const containerRef = useRef(null); const mapRef = useRef(null); const heatRef = useRef(null); + // Two img overlays that cross-fade between each other. + // The canvas opacity is NEVER touched — it stays at leaflet's default. + const prevRef = useRef(null); + const nextRef = useRef(null); + // Src of the currently visible frame (so prev can be initialised correctly) + const currentSrcRef = useRef(''); // Initialize map once useEffect(() => { @@ -64,32 +70,95 @@ export function HeatMap({ transactions }: HeatMapProps) { }; }, []); - // Update heatmap data when transactions change + // Crossfade: two img overlays swap roles each frame. + // Canvas is never hidden — we only read its pixel data via toDataURL(). useEffect(() => { if (!heatRef.current || !mapRef.current) return; - // Normalize amounts for intensity (log scale feels better visually) - const maxAmount = Math.max(...transactions.map((t) => t.amount), 1); + const canvas = (heatRef.current as unknown as { _canvas?: HTMLCanvasElement })._canvas; + const prev = prevRef.current; + const next = nextRef.current; - const points: L.HeatLatLngTuple[] = transactions.map((tx) => [ - tx.lat, - tx.lng, - Math.min(Math.log1p(tx.amount) / Math.log1p(maxAmount), 1), - ]); + const draw = () => { + const maxAmount = Math.max(...transactions.map((t) => t.amount), 1); + const points: L.HeatLatLngTuple[] = transactions.map((tx) => [ + tx.lat, + tx.lng, + Math.min(Math.log1p(tx.amount) / Math.log1p(maxAmount), 1), + ]); + try { + heatRef.current?.setLatLngs(points); + } catch { + // map was torn down (React StrictMode double-invoke), ignore + } + }; - // Guard: only update if the heat layer is still attached to the map - try { - heatRef.current.setLatLngs(points); - } catch { - // map was torn down (React StrictMode double-invoke), ignore + if (!canvas || !prev || !next) { + draw(); + return; } + + // --- Phase 1 (synchronous): set start state --- + // prev shows the current frame (or nothing on first run) + prev.src = currentSrcRef.current; + prev.style.transition = 'none'; + prev.style.opacity = currentSrcRef.current ? '1' : '0'; + + // next is hidden and will receive the incoming frame + next.style.transition = 'none'; + next.style.opacity = '0'; + + void prev.offsetWidth; // flush CSS so transitions start cleanly + + // Ask leaflet to draw new data (schedules an internal RAF) + draw(); + + // --- Phase 2 (after leaflet redraws): capture new frame, start crossfade --- + // leaflet.heat schedules its own RAF inside draw() above. + // Our raf1 is queued *after* leaflet's RAF, so when raf1 fires, + // leaflet has already redrawn the canvas. + let raf2 = 0; + const raf1 = requestAnimationFrame(() => { + raf2 = requestAnimationFrame(() => { + let src: string; + try { + src = canvas.toDataURL(); + } catch { + return; // map torn down + } + + currentSrcRef.current = src; + next.src = src; + void next.offsetWidth; // ensure img is decoded before transition + + const DUR = '0.55s ease-in-out'; + prev.style.transition = `opacity ${DUR}`; + prev.style.opacity = '0'; + next.style.transition = `opacity ${DUR}`; + next.style.opacity = '1'; + }); + }); + + return () => { cancelAnimationFrame(raf1); cancelAnimationFrame(raf2); }; }, [transactions]); return ( -
+
+
+ {/* prev: outgoing frame */} + + {/* next: incoming frame — sits on top of prev during crossfade */} + +
); } diff --git a/src/components/PeriodSelector.tsx b/src/components/PeriodSelector.tsx index 9ea0c88..91169cb 100644 --- a/src/components/PeriodSelector.tsx +++ b/src/components/PeriodSelector.tsx @@ -1,6 +1,10 @@ +import { useState, useRef, useEffect } from 'react'; + interface PeriodSelectorProps { value: number; onChange: (days: number) => void; + animationActive: boolean; + onAnimate: () => void; } const PERIODS = [ @@ -9,16 +13,40 @@ const PERIODS = [ { label: '30 jours', days: 30 }, ]; -export function PeriodSelector({ value, onChange }: PeriodSelectorProps) { +const PRESET_DAYS = new Set([1, 7, 30]); + +export function PeriodSelector({ value, onChange, animationActive, onAnimate }: PeriodSelectorProps) { + const [customOpen, setCustomOpen] = useState(false); + const [inputVal, setInputVal] = useState(''); + const inputRef = useRef(null); + + // Ouvre le champ custom avec la valeur courante pré-remplie + const openCustom = () => { + setInputVal(PRESET_DAYS.has(value) ? '' : String(value)); + setCustomOpen(true); + }; + + useEffect(() => { + if (customOpen) inputRef.current?.focus(); + }, [customOpen]); + + const commit = () => { + const n = parseInt(inputVal, 10); + if (n >= 1 && n <= 365) onChange(n); + setCustomOpen(false); + }; + + const isCustomActive = !PRESET_DAYS.has(value); + return ( -
+
{PERIODS.map(({ label, days }) => ( ))} + +
+ + {/* Bouton Personnaliser + champ inline */} + {customOpen ? ( +
+ setInputVal(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') commit(); + if (e.key === 'Escape') setCustomOpen(false); + }} + onBlur={commit} + placeholder="jours" + className="w-16 px-2 py-1 text-sm bg-[#1a1b23] border border-[#d4a843] rounded-md text-[#d4a843] text-center focus:outline-none tabular-nums [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none" + /> + j +
+ ) : ( + + )} + +
+ +
); } diff --git a/src/components/StatsPanel.tsx b/src/components/StatsPanel.tsx index a54a6ac..1434961 100644 --- a/src/components/StatsPanel.tsx +++ b/src/components/StatsPanel.tsx @@ -6,6 +6,8 @@ interface StatsPanelProps { loading: boolean; periodDays: number; source: 'live' | 'mock'; + currentUD: number; + animationLabel?: string; } const MEDALS = ['🥇', '🥈', '🥉']; @@ -24,7 +26,14 @@ function StatCard({ label, value, sub, delta }: { label: string; value: string; ); } -export function StatsPanel({ stats, loading, periodDays, source }: StatsPanelProps) { +function formatDU(g1: number, ud: number): string { + const du = g1 / ud; + if (du < 10) return `≈ ${du.toFixed(2)} DU`; + if (du < 100) return `≈ ${du.toFixed(1)} DU`; + return `≈ ${Math.round(du).toLocaleString('fr-FR')} DU`; +} + +export function StatsPanel({ stats, loading, periodDays, source, currentUD, animationLabel }: StatsPanelProps) { const periodLabel = periodDays === 1 ? '24 dernières heures' : `${periodDays} derniers jours`; const prevStats = useRef(null); @@ -46,9 +55,6 @@ export function StatsPanel({ stats, loading, periodDays, source }: StatsPanelPro // Mémorise les stats après le rendu if (stats && !loading) prevStats.current = stats; - const geoPct = stats && stats.transactionCount > 0 - ? Math.round((stats.geoCount / stats.transactionCount) * 100) - : null; return (