import { useEffect, useRef } from 'react'; import L from 'leaflet'; import 'leaflet.heat'; import type { Transaction } from '../data/mockData'; // Leaflet default marker fix (Vite asset pipeline) import iconUrl from 'leaflet/dist/images/marker-icon.png'; import iconShadowUrl from 'leaflet/dist/images/marker-shadow.png'; L.Icon.Default.mergeOptions({ iconUrl, shadowUrl: iconShadowUrl }); interface HeatMapProps { transactions: Transaction[]; } const HEAT_OPTIONS: L.HeatMapOptions = { radius: 30, blur: 22, maxZoom: 12, max: 1.0, minOpacity: 0.25, gradient: { 0.0: '#0d0221', 0.2: '#1a0a4a', 0.4: '#3a0e82', 0.55: '#7b1fa2', 0.7: '#e65100', 0.85: '#ff8f00', 1.0: '#ffd740', }, }; export function HeatMap({ transactions }: HeatMapProps) { const containerRef = useRef(null); const mapRef = useRef(null); const heatRef = useRef(null); const overlayRef = useRef(null); // Initialize map once useEffect(() => { if (!containerRef.current || mapRef.current) return; const map = L.map(containerRef.current, { center: [46.8, 2.35], zoom: 6, zoomControl: false, attributionControl: true, }); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap', maxZoom: 18, }).addTo(map); L.control.zoom({ position: 'bottomright' }).addTo(map); const heat = L.heatLayer([], HEAT_OPTIONS).addTo(map); mapRef.current = map; heatRef.current = heat; return () => { map.remove(); mapRef.current = null; heatRef.current = null; }; }, []); // True crossfade: old frame (overlay) fades out WHILE new frame (canvas) fades in simultaneously useEffect(() => { if (!heatRef.current || !mapRef.current) return; const canvas = (heatRef.current as unknown as { _canvas?: HTMLCanvasElement })._canvas; const overlay = overlayRef.current; const update = () => { 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 } }; if (!canvas || !overlay) { update(); return; } // 1. Capture old frame into overlay try { overlay.src = canvas.toDataURL(); } catch { update(); return; } // 2. Snap overlay to visible, cancel any running transition overlay.style.transition = 'none'; overlay.style.opacity = '1'; // 3. Hide canvas (new frame will fade in from here) canvas.style.transition = 'none'; canvas.style.opacity = '0'; // flush both void overlay.offsetWidth; // 4. Draw new data onto canvas (invisible at opacity 0) update(); // 5. Simultaneously: canvas fades in, overlay fades out → true crossfade // Both RAF ids must be cancelled on cleanup to avoid double-transition let raf2 = 0; const raf1 = requestAnimationFrame(() => { raf2 = requestAnimationFrame(() => { const DURATION = '0.55s ease-in-out'; canvas.style.transition = `opacity ${DURATION}`; canvas.style.opacity = '1'; overlay.style.transition = `opacity ${DURATION}`; overlay.style.opacity = '0'; }); }); return () => { cancelAnimationFrame(raf1); cancelAnimationFrame(raf2); }; }, [transactions]); return (
); }