From bea7cbe60f5bc7f582031f80dd0abbe6ad4515a1 Mon Sep 17 00:00:00 2001 From: syoul Date: Mon, 23 Mar 2026 23:04:01 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20crossfade=20via=20deux=20img=20overlays?= =?UTF-8?q?=20=E2=80=94=20canvas=20jamais=20modifi=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- src/components/HeatMap.tsx | 85 ++++++++++++++++++++++++-------------- 1 file changed, 53 insertions(+), 32 deletions(-) diff --git a/src/components/HeatMap.tsx b/src/components/HeatMap.tsx index 4527dd6..9a24a03 100644 --- a/src/components/HeatMap.tsx +++ b/src/components/HeatMap.tsx @@ -33,7 +33,12 @@ export function HeatMap({ transactions }: HeatMapProps) { const containerRef = useRef(null); const mapRef = useRef(null); const heatRef = useRef(null); - const overlayRef = useRef(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(null); + const nextRef = useRef(null); + // Src of the currently visible frame (so prev can be initialised correctly) + const currentSrcRef = useRef(''); // Initialize map once 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(() => { if (!heatRef.current || !mapRef.current) return; 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 points: L.HeatLatLngTuple[] = transactions.map((tx) => [ tx.lat, @@ -86,43 +93,49 @@ export function HeatMap({ transactions }: HeatMapProps) { } }; - if (!canvas || !overlay) { - update(); + if (!canvas || !prev || !next) { + draw(); return; } - // 1. Capture old frame into overlay - try { - overlay.src = canvas.toDataURL(); - } catch { - update(); - return; - } + // --- Phase 1 (synchronous): set start state --- + // prev shows the current frame (or nothing on first run) + prev.src = currentSrcRef.current; + prev.style.transition = 'none'; + prev.style.opacity = currentSrcRef.current ? '1' : '0'; - // 2. Snap overlay to visible, cancel any running transition - overlay.style.transition = 'none'; - overlay.style.opacity = '1'; + // next is hidden and will receive the incoming frame + next.style.transition = 'none'; + next.style.opacity = '0'; - // 3. Hide canvas (new frame will fade in from here) - canvas.style.transition = 'none'; - canvas.style.opacity = '0'; + void prev.offsetWidth; // flush CSS so transitions start cleanly - // flush both - void overlay.offsetWidth; + // Ask leaflet to draw new data (schedules an internal RAF) + draw(); - // 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 + // --- Phase 2 (after leaflet redraws): capture new frame, start crossfade --- + // leaflet.heat schedules its own RAF inside draw() above. + // Our raf1 is queued *after* leaflet's RAF, so when raf1 fires, + // leaflet has already redrawn the canvas. 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'; + let src: string; + try { + src = canvas.toDataURL(); + } catch { + 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 (
+ {/* prev: outgoing frame */} + {/* next: incoming frame — sits on top of prev during crossfade */} +
); }