Files
g1flux/src/components/HeatMap.tsx
T
syoul bc61527b4e
ci/woodpecker/push/woodpecker Pipeline was successful
fix: annuler les deux RAFs au cleanup pour éviter la double transition
Le cleanup n'annulait que raf1. Si raf1 avait déjà tiré avant le cleanup React,
raf2 restait en queue et déclenchait une deuxième transition (l'aller-retour visible
à la fin de chaque frame). Fix : stocker raf2 dans la closure et l'annuler aussi.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 22:43:12 +01:00

144 lines
4.0 KiB
TypeScript

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<HTMLDivElement>(null);
const mapRef = useRef<L.Map | null>(null);
const heatRef = useRef<L.HeatLayer | null>(null);
const overlayRef = useRef<HTMLImageElement | null>(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: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
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 (
<div className="w-full h-full relative" style={{ minHeight: 0 }}>
<div ref={containerRef} className="absolute inset-0" />
<img
ref={overlayRef}
alt=""
className="absolute inset-0 w-full h-full pointer-events-none"
style={{ opacity: 0, zIndex: 500 }}
/>
</div>
);
}