fix: crossfade via deux img overlays — canvas jamais modifié
ci/woodpecker/push/woodpecker Pipeline was successful

Problème racine : modifier l'opacité du canvas Leaflet (qui vit dans un
pane GPU-composité) via CSS causait des désynchronisations non-déterministes.

Nouvelle approche :
- Canvas : jamais touché (opacité Leaflet par défaut)
- Deux <img> overlays se croisent : prev (sortant) et next (entrant)
- Après draw(), on attend le RAF interne de Leaflet, puis on capture
  le canvas via toDataURL() dans le next img
- currentSrcRef garde l'src courante pour initialiser prev au prochain tour

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
syoul
2026-03-23 23:04:01 +01:00
parent bc61527b4e
commit bea7cbe60f
+53 -32
View File
@@ -33,7 +33,12 @@ export function HeatMap({ transactions }: HeatMapProps) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<L.Map | null>(null); const mapRef = useRef<L.Map | null>(null);
const heatRef = useRef<L.HeatLayer | null>(null); const heatRef = useRef<L.HeatLayer | null>(null);
const overlayRef = useRef<HTMLImageElement | null>(null); // Two img overlays that cross-fade between each other.
// The canvas opacity is NEVER touched — it stays at leaflet's default.
const prevRef = useRef<HTMLImageElement | null>(null);
const nextRef = useRef<HTMLImageElement | null>(null);
// Src of the currently visible frame (so prev can be initialised correctly)
const currentSrcRef = useRef<string>('');
// Initialize map once // Initialize map once
useEffect(() => { useEffect(() => {
@@ -65,14 +70,16 @@ export function HeatMap({ transactions }: HeatMapProps) {
}; };
}, []); }, []);
// True crossfade: old frame (overlay) fades out WHILE new frame (canvas) fades in simultaneously // Crossfade: two img overlays swap roles each frame.
// Canvas is never hidden — we only read its pixel data via toDataURL().
useEffect(() => { useEffect(() => {
if (!heatRef.current || !mapRef.current) return; if (!heatRef.current || !mapRef.current) return;
const canvas = (heatRef.current as unknown as { _canvas?: HTMLCanvasElement })._canvas; const canvas = (heatRef.current as unknown as { _canvas?: HTMLCanvasElement })._canvas;
const overlay = overlayRef.current; const prev = prevRef.current;
const next = nextRef.current;
const update = () => { const draw = () => {
const maxAmount = Math.max(...transactions.map((t) => t.amount), 1); const maxAmount = Math.max(...transactions.map((t) => t.amount), 1);
const points: L.HeatLatLngTuple[] = transactions.map((tx) => [ const points: L.HeatLatLngTuple[] = transactions.map((tx) => [
tx.lat, tx.lat,
@@ -86,43 +93,49 @@ export function HeatMap({ transactions }: HeatMapProps) {
} }
}; };
if (!canvas || !overlay) { if (!canvas || !prev || !next) {
update(); draw();
return; return;
} }
// 1. Capture old frame into overlay // --- Phase 1 (synchronous): set start state ---
try { // prev shows the current frame (or nothing on first run)
overlay.src = canvas.toDataURL(); prev.src = currentSrcRef.current;
} catch { prev.style.transition = 'none';
update(); prev.style.opacity = currentSrcRef.current ? '1' : '0';
return;
}
// 2. Snap overlay to visible, cancel any running transition // next is hidden and will receive the incoming frame
overlay.style.transition = 'none'; next.style.transition = 'none';
overlay.style.opacity = '1'; next.style.opacity = '0';
// 3. Hide canvas (new frame will fade in from here) void prev.offsetWidth; // flush CSS so transitions start cleanly
canvas.style.transition = 'none';
canvas.style.opacity = '0';
// flush both // Ask leaflet to draw new data (schedules an internal RAF)
void overlay.offsetWidth; draw();
// 4. Draw new data onto canvas (invisible at opacity 0) // --- Phase 2 (after leaflet redraws): capture new frame, start crossfade ---
update(); // leaflet.heat schedules its own RAF inside draw() above.
// Our raf1 is queued *after* leaflet's RAF, so when raf1 fires,
// 5. Simultaneously: canvas fades in, overlay fades out → true crossfade // leaflet has already redrawn the canvas.
// Both RAF ids must be cancelled on cleanup to avoid double-transition
let raf2 = 0; let raf2 = 0;
const raf1 = requestAnimationFrame(() => { const raf1 = requestAnimationFrame(() => {
raf2 = requestAnimationFrame(() => { raf2 = requestAnimationFrame(() => {
const DURATION = '0.55s ease-in-out'; let src: string;
canvas.style.transition = `opacity ${DURATION}`; try {
canvas.style.opacity = '1'; src = canvas.toDataURL();
overlay.style.transition = `opacity ${DURATION}`; } catch {
overlay.style.opacity = '0'; return; // map torn down
}
currentSrcRef.current = src;
next.src = src;
void next.offsetWidth; // ensure img is decoded before transition
const DUR = '0.55s ease-in-out';
prev.style.transition = `opacity ${DUR}`;
prev.style.opacity = '0';
next.style.transition = `opacity ${DUR}`;
next.style.opacity = '1';
}); });
}); });
@@ -132,12 +145,20 @@ export function HeatMap({ transactions }: HeatMapProps) {
return ( return (
<div className="w-full h-full relative" style={{ minHeight: 0 }}> <div className="w-full h-full relative" style={{ minHeight: 0 }}>
<div ref={containerRef} className="absolute inset-0" /> <div ref={containerRef} className="absolute inset-0" />
{/* prev: outgoing frame */}
<img <img
ref={overlayRef} ref={prevRef}
alt="" alt=""
className="absolute inset-0 w-full h-full pointer-events-none" className="absolute inset-0 w-full h-full pointer-events-none"
style={{ opacity: 0, zIndex: 500 }} style={{ opacity: 0, zIndex: 500 }}
/> />
{/* next: incoming frame — sits on top of prev during crossfade */}
<img
ref={nextRef}
alt=""
className="absolute inset-0 w-full h-full pointer-events-none"
style={{ opacity: 0, zIndex: 501 }}
/>
</div> </div>
); );
} }