fix: crossfade via deux img overlays — canvas jamais modifié
ci/woodpecker/push/woodpecker Pipeline was successful
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:
+53
-32
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user