Files
g1flux/src/components/HeatMap.tsx
T
syoul a9bf445747
ci/woodpecker/push/woodpecker Pipeline was successful
fix: force reflow avant reset des transitions CSS du crossfade
Sans forcer un reflow, le browser ignore transition:none et applique
encore l'ancienne transition — causant un bug visuel sur la 1ère frame.
void canvas.offsetWidth flush les styles en attente.

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

139 lines
3.8 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;
};
}, []);
// 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;
}
// 2. Freeze current frame in the overlay
try {
overlay.src = canvas.toDataURL();
} catch {
// canvas tainted (shouldn't happen with heatmap-only canvas)
update();
return;
}
// 1. Reset transitions instantly — force reflow to flush any running transition
canvas.style.transition = 'none';
overlay.style.transition = 'none';
void canvas.offsetWidth; // force reflow
canvas.style.opacity = '0';
overlay.style.opacity = '1';
// 3. Update heatmap (invisible: canvas still at opacity 0)
update();
// 4. 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 (
<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>
);
}