bc61527b4e
ci/woodpecker/push/woodpecker Pipeline was successful
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>
144 lines
4.0 KiB
TypeScript
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>
|
|
);
|
|
}
|