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; }; }, []); // Crossfade: capture current heatmap → update underneath → fade out overlay 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. Cancel any running overlay transition instantly (flush only the overlay) overlay.style.transition = 'none'; overlay.style.opacity = '0'; void overlay.offsetWidth; // flush overlay only — avoids double-paint with canvas // 2. Capture current frame (canvas still visible at opacity 1) try { overlay.src = canvas.toDataURL(); } catch { update(); return; } // 3. In one paint batch: hide canvas + show overlay (Frame A moves from canvas to overlay) canvas.style.transition = 'none'; canvas.style.opacity = '0'; overlay.style.opacity = '1'; // 4. Update heatmap (invisible: canvas at opacity 0) update(); // 5. Simultaneous crossfade: overlay fades out, canvas fades in const raf = requestAnimationFrame(() => { requestAnimationFrame(() => { const DURATION = '0.5s ease-in-out'; overlay.style.transition = `opacity ${DURATION}`; overlay.style.opacity = '0'; canvas.style.transition = `opacity ${DURATION}`; canvas.style.opacity = '1'; }); }); return () => cancelAnimationFrame(raf); }, [transactions]); return (
); }