Rewrite canvas to use React state management properly

This commit is contained in:
2024-11-29 23:08:47 -05:00
parent ce5a28b7bd
commit 112470ce5a
9 changed files with 190 additions and 118 deletions

View File

@ -1,16 +1,53 @@
import React, {useCallback, useEffect, useState} from "react";
import React, {useCallback, useEffect, useState, createContext} from "react";
import {useColorMode} from "@docusaurus/theme-common";
interface Props {
export interface PainterProps {
readonly width: number;
readonly height: number;
readonly setPainter: (painter: Iterator<ImageData>) => void;
}
/**
* Context provider for child elements to submit image iterator functions
* (painters) for rendering
*/
export const PainterContext = createContext<PainterProps>(null);
interface CanvasProps {
width: number;
height: number;
painter: Iterator<ImageData>;
children?: React.ReactNode;
}
export default function Canvas({width, height, painter, children}: Props) {
const {colorMode} = useColorMode();
const [image, setImage] = useState<[ImageData]>(null);
/**
* Draw fractal flames to a canvas.
*
* This component is a bit involved because it attempts to solve
* a couple problems at the same time:
* - Incrementally drawing an image to the canvas
* - Interrupting drawing with new parameters on demand
* - Dark mode
*
* Image iterators provide a means to draw incremental images;
* iterators can easily checkpoint state, and this component will
* request the next image on the next animation frame. As a result,
* the browser should be responsive even though we run CPU-heavy
* code on the main thread.
*
* Swapping a new iterator allows interrupting a render in progress,
* as the canvas completely repaints on each provided image.
*
* Finally, check whether dark mode is active, and invert the most
* recent image prior to painting if so.
*
* PainterContext is used to allow child elements to swap in
* new iterators.
*
* @param width Canvas draw width
* @param height Canvas draw height
* @param children Child elements
*/
export default function Canvas({width, height, children}: CanvasProps) {
const [canvasCtx, setCanvasCtx] = useState<CanvasRenderingContext2D>(null);
const canvasRef = useCallback(node => {
if (node !== null) {
@ -18,52 +55,93 @@ export default function Canvas({width, height, painter, children}: Props) {
}
}, []);
const paintImage = new ImageData(width, height);
const paint = () => {
if (!canvasCtx || !image) {
const {colorMode} = useColorMode();
// Holder objects are used to force re-painting even if the iterator
// returns a modified image with the same reference
type ImageHolder = { image?: ImageData };
const [imageHolder, setImageHolder] = useState<ImageHolder>({ image: null });
useEffect(() => {
const image = imageHolder.image;
if (!image) {
// No image is available, leave the canvas as-is
return;
}
for (const [index, value] of image[0].data.entries()) {
if (index % 4 === 3) {
// Alpha values are copied as-is
paintImage.data[index] = value;
} else {
// If dark mode is active, invert the color
paintImage.data[index] = colorMode === 'light' ? value : 255 - value;
}
if (!canvasCtx) {
// Canvas is not ready for the image we have,
// re-submit the image and wait for the ref to populate
setImageHolder({ image });
return;
}
canvasCtx.putImageData(paintImage, 0, 0);
}
useEffect(paint, [colorMode, image]);
// If light mode is active, paint the image as-is
if (colorMode === 'light') {
canvasCtx.putImageData(image, 0, 0);
return;
}
const animate = () => {
// If dark mode is active, copy the image into a new buffer
// and invert colors prior to painting.
// Copy alpha values as-is.
const paintImage = new ImageData(image.width, image.height);
image.data.forEach((value, index) => {
const isAlpha = index % 4 === 3;
paintImage.data[index] = isAlpha ? value : 255 - value;
})
canvasCtx.putImageData(paintImage, 0, 0);
}, [colorMode, imageHolder]);
// Image iterators (painters) are also in a holder; this allows
// re-submitting the existing iterator to draw the next frame,
// and also allows child components to over-write the iterator
// if a new set of parameters becomes available
// TODO(bspeice): Potential race condition?
// Not sure if it's possible for painters submitted by children
// to be over-ridden as a result re-submitting the
// existing iterator
type PainterHolder = { painter?: Iterator<ImageData> };
const [animHolder, setAnimHolder] = useState<PainterHolder>({ painter: null });
useEffect(() => {
const painter = animHolder.painter;
if (!painter) {
return;
}
console.log("Animating");
const nextImage = painter.next().value;
if (nextImage) {
setImage([nextImage])
requestAnimationFrame(animate);
if (!canvasCtx) {
setAnimHolder({ painter });
return;
}
}
useEffect(animate, [painter, canvasCtx]);
const image = painter.next().value;
if (image) {
setImageHolder({ image });
setAnimHolder({ painter });
} else {
setAnimHolder({ painter: null });
}
}, [animHolder, canvasCtx]);
// Finally, child elements submit painters through a context provider
const [painter, setPainter] = useState<Iterator<ImageData>>(null);
useEffect(() => setAnimHolder({ painter }), [painter]);
return (
<>
<canvas
ref={canvasRef}
width={width}
height={height}
style={{
aspectRatio: width / height,
width: '100%'
}}
/>
{children}
<center>
<canvas
ref={canvasRef}
width={width}
height={height}
style={{
aspectRatio: width / height,
width: '80%'
}}
/>
</center>
<PainterContext.Provider value={{width, height, setPainter}}>
{children}
</PainterContext.Provider>
</>
)
}

View File

@ -4,9 +4,7 @@
* The way `flam3` actually calculates the "camera" for mapping a point
* to its pixel coordinate is fairly involved - it also needs to calculate
* zoom and rotation (see the bucket accumulator code in rect.c).
* We'll make some simplifying assumptions:
* - The final image is square
* - We want to plot the range [-2, 2]
* We simplify things here by assuming a square image
*
* The reference parameters were designed in Apophysis, which uses the
* range [-2, 2] by default (the `scale` parameter in XML defines the
@ -15,7 +13,7 @@
*
* @param x point in the range [-2, 2]
* @param y point in the range [-2, 2]
* @param size image size
* @param size image width/height in pixels
* @returns pair of pixel coordinates
*/
export function camera(x: number, y: number, size: number): [number, number] {