Auto-sizing canvas, starting cleanup for display on mobile browsers

This commit is contained in:
Bradlee Speice 2024-12-08 19:53:06 -05:00
parent 78d71cbc7b
commit 5ae6b82d26
13 changed files with 206 additions and 214 deletions

View File

@ -1,16 +1,21 @@
import Canvas, {PainterContext} from "../src/Canvas"; import {SquareCanvas, PainterContext} from "../src/Canvas";
import {useContext} from "react"; import {useContext, useEffect} from "react";
export function Render({f}) { export function Render({f}) {
const {setPainter} = useContext(PainterContext); const {width, height, setPainter} = useContext(PainterContext);
setPainter(f); useEffect(() => {
if (width && height) {
const painter = f({width, height});
setPainter(painter);
}
}, [width, height]);
return <></>; return <></>;
} }
export default function Gasket({f}) { export default function Gasket({f}) {
return ( return (
<Canvas width={500} height={500}> <SquareCanvas>
<Render f={f}/> <Render f={f}/>
</Canvas> </SquareCanvas>
) )
} }

View File

@ -1,4 +1,4 @@
import {useEffect, useState, useContext} from "react"; import {useEffect, useState, useContext, useRef} from "react";
import {PainterContext} from "../src/Canvas"; import {PainterContext} from "../src/Canvas";
import {chaosGameWeighted} from "./chaosGameWeighted"; import {chaosGameWeighted} from "./chaosGameWeighted";
import TeX from '@matejmazur/react-katex'; import TeX from '@matejmazur/react-katex';
@ -30,7 +30,7 @@ export default function GasketWeighted() {
const weightInput = (title, weight, setWeight) => ( const weightInput = (title, weight, setWeight) => (
<> <>
<div className={styles.inputElement}> <div className={styles.inputElement}>
<p><TeX>{title}</TeX> weight: {weight}</p> <p><TeX>{title}</TeX>: {weight}</p>
<input type={'range'} min={0} max={1} step={.01} style={{width: '100%'}} value={weight} <input type={'range'} min={0} max={1} step={.01} style={{width: '100%'}} value={weight}
onInput={e => setWeight(Number(e.currentTarget.value))}/> onInput={e => setWeight(Number(e.currentTarget.value))}/>
</div> </div>
@ -39,7 +39,7 @@ export default function GasketWeighted() {
return ( return (
<> <>
<div className={styles.inputGroup} style={{display: 'grid', gridTemplateColumns: 'auto auto auto'}}> <div className={styles.inputGroup} style={{display: 'grid', gridTemplateColumns: '1fr 1fr 1fr'}}>
{weightInput("F_0", f0Weight, setF0Weight)} {weightInput("F_0", f0Weight, setF0Weight)}
{weightInput("F_1", f1Weight, setF1Weight)} {weightInput("F_1", f1Weight, setF1Weight)}
{weightInput("F_2", f2Weight, setF2Weight)} {weightInput("F_2", f2Weight, setF2Weight)}

View File

@ -1,30 +1,33 @@
// Hint: try increasing the iteration count // Hint: try changing the iteration count
const iterations = 10000; const iterations = 100000;
// Hint: negating `x` and `y` creates some interesting images // Hint: negating `x` and `y` creates some cool images
const transforms = [ const xforms = [
(x, y) => [x / 2, y / 2], (x, y) => [x / 2, y / 2],
(x, y) => [(x + 1) / 2, y / 2], (x, y) => [(x + 1) / 2, y / 2],
(x, y) => [x / 2, (y + 1) / 2] (x, y) => [x / 2, (y + 1) / 2]
] ]
function* chaosGame() { function* chaosGame({width, height}) {
let image = new ImageData(500, 500); let img = new ImageData(width, height);
let [x, y] = [randomBiUnit(), randomBiUnit()]; let [x, y] = [
randomBiUnit(),
randomBiUnit()
];
for (var count = 0; count < iterations; count++) { for (let c = 0; c < iterations; c++) {
const i = randomInteger(0, transforms.length); const i = randomInteger(0, xforms.length);
[x, y] = transforms[i](x, y); [x, y] = xforms[i](x, y);
if (count > 20) if (c > 20)
plot(x, y, image); plot(x, y, img);
if (count % 1000 === 0) if (c % 1000 === 0)
yield image; yield img;
} }
yield image; yield img;
} }
// Wiring so the code above displays properly // Wiring so the code above displays properly
render(<Gasket f={chaosGame()}/>) render(<Gasket f={chaosGame}/>)

View File

@ -6,27 +6,33 @@ import {Transform} from "../src/transform";
const iterations = 50_000; const iterations = 50_000;
const step = 1000; const step = 1000;
// hidden-end // hidden-end
export type ChaosGameWeightedProps = { type Props = {
width: number, width: number,
height: number, height: number,
transforms: [number, Transform][] transforms: [number, Transform][]
} }
export function* chaosGameWeighted({width, height, transforms}: ChaosGameWeightedProps) { export function* chaosGameWeighted(
let image = new ImageData(width, height); {width, height, transforms}: Props
var [x, y] = [randomBiUnit(), randomBiUnit()]; ) {
let img = new ImageData(width, height);
let [x, y] = [
randomBiUnit(),
randomBiUnit()
];
for (let i = 0; i < iterations; i++) { const iterations = width * height * 0.5;
// highlight-start for (let c = 0; c < iterations; c++) {
const [_, transform] = randomChoice(transforms); // highlight-start
// highlight-end const [_, xform] = randomChoice(transforms);
[x, y] = transform(x, y); // highlight-end
[x, y] = xform(x, y);
if (i > 20) if (c > 20)
plot(x, y, image); plot(x, y, img);
if (i % step === 0) if (c % step === 0)
yield image; yield img;
} }
yield image; yield img;
} }

View File

@ -101,7 +101,7 @@ export const shiftData = simpleData.map(({x, y}) => { return {x: x + 1, y} })
This is a simple example designed to illustrate the principle. In general, $F_i$ functions have the form: This is a simple example designed to illustrate the principle. In general, $F_i$ functions have the form:
$$ $$
F_i(x,y) = (a_i \cdot x + b_i \cdot y + c_i, \hspace{0.2cm} d_i \cdot x + e_i \cdot y + f_i) F_i(x,y) = (a_i \cdot x + b_i \cdot y + c_i, d_i \cdot x + e_i \cdot y + f_i)
$$ $$
The parameters ($a_i$, $b_i$, etc.) are values we get to choose. In the example above, we can represent our shift The parameters ($a_i$, $b_i$, etc.) are values we get to choose. In the example above, we can represent our shift
@ -126,10 +126,10 @@ Fractal flames use more complex functions to produce a wide variety of images, b
Using these definitions, we can build the first image. The paper defines a function system for us: Using these definitions, we can build the first image. The paper defines a function system for us:
$$ $$
F_0(x, y) = \left({x \over 2}, {y \over 2} \right) F_0(x, y) = \left({x \over 2}, {y \over 2} \right) \\
\hspace{0.8cm} ~\\
F_1(x, y) = \left({{x + 1} \over 2}, {y \over 2} \right) F_1(x, y) = \left({{x + 1} \over 2}, {y \over 2} \right) \\
\hspace{0.8cm} ~\\
F_2(x, y) = \left({x \over 2}, {{y + 1} \over 2} \right) F_2(x, y) = \left({x \over 2}, {{y + 1} \over 2} \right)
$$ $$
@ -141,11 +141,11 @@ Next, how do we find out all the points in $S$? The paper lays out an algorithm
$$ $$
\begin{align*} \begin{align*}
&(x, y) = \text{a random point in the bi-unit square} \\ &(x, y) = \text{random point in the bi-unit square} \\
&\text{iterate } \{ \\ &\text{iterate } \{ \\
&\hspace{1cm} i = \text{a random integer from 0 to } n - 1 \text{ inclusive} \\ &\hspace{1cm} i = \text{random integer from 0 to } n - 1 \\
&\hspace{1cm} (x,y) = F_i(x,y) \\ &\hspace{1cm} (x,y) = F_i(x,y) \\
&\hspace{1cm} \text{plot}(x,y) \text{ except during the first 20 iterations} \\ &\hspace{1cm} \text{plot}(x,y) \text{ if iterations} > 20 \\
\} \}
\end{align*} \end{align*}
$$ $$
@ -199,6 +199,6 @@ import chaosGameWeightedSource from "!!raw-loader!./chaosGameWeighted";
<CodeBlock language={'typescript'}>{chaosGameWeightedSource}</CodeBlock> <CodeBlock language={'typescript'}>{chaosGameWeightedSource}</CodeBlock>
import GasketWeighted from "./GasketWeighted"; import GasketWeighted from "./GasketWeighted";
import Canvas from "../src/Canvas"; import {SquareCanvas} from "../src/Canvas";
<Canvas><GasketWeighted/></Canvas> <SquareCanvas><GasketWeighted/></SquareCanvas>

View File

@ -1,34 +1,52 @@
export function plot(x: number, y: number, image: ImageData) { /**
// Translate (x,y) coordinates to pixel coordinates; * ImageData is an array that contains
// also known as a "camera" function. * four elements per pixel (one for each
// * red, green, blue, and alpha value).
// The display range we care about is x=[0, 1], y=[0, 1], * This maps from pixel coordinates
// so our pixelX and pixelY coordinates are easy to calculate: * to the array index
const pixelX = Math.floor(x * image.width); */
const pixelY = Math.floor(y * image.height); function imageIndex(
width: number,
x: number,
y: number
) {
return y * (width * 4) + x * 4;
}
// If we have an (x,y) coordinate outside the display range, export function plot(
// skip it x: number,
if ( y: number,
pixelX < 0 || img: ImageData
pixelX > image.width || ) {
pixelY < 0 || // Translate (x,y) coordinates
pixelY > image.height // to pixel coordinates.
) { // Also known as a "camera" function.
return; //
} // The display range is:
// x=[0, 1]
// y=[0, 1]
let pixelX = Math.floor(x * img.width);
let pixelY = Math.floor(y * img.height);
// ImageData is an array that contains four bytes per pixel const index = imageIndex(
// (one for each of the red, green, blue, and alpha values). img.width,
// The (pixelX, pixelY) coordinates are used to find where pixelX,
// in the image we need to write. pixelY
const index = pixelY * (image.width * 4) + pixelX * 4; );
// Set the pixel to black by writing a 0 to the first three // Skip pixels outside the display range
// bytes (red, green, blue), and 256 to the last byte (alpha), if (
// starting at our index: index < 0 ||
image.data[index] = 0; index > img.data.length
image.data[index + 1] = 0; ) {
image.data[index + 2] = 0; return;
image.data[index + 3] = 0xff; }
// Set the pixel to black by writing 0
// to the first three elements,
// and 255 to the last element
img.data[index] = 0;
img.data[index + 1] = 0;
img.data[index + 2] = 0;
img.data[index + 3] = 0xff;
} }

View File

@ -145,7 +145,7 @@ each transform.
import Canvas from "../src/Canvas"; import Canvas from "../src/Canvas";
import FlameBlend from "./FlameBlend"; import FlameBlend from "./FlameBlend";
<Canvas><FlameBlend/></Canvas> <!-- <Canvas><FlameBlend/></Canvas> -->
## Post transforms ## Post transforms
@ -160,10 +160,10 @@ $$
import FlamePost from "./FlamePost"; import FlamePost from "./FlamePost";
<Canvas><FlamePost/></Canvas> <!-- <Canvas><FlamePost/></Canvas> -->
## Final transform ## Final transform
import FlameFinal from "./FlameFinal"; import FlameFinal from "./FlameFinal";
<Canvas><FlameFinal/></Canvas> <!-- <Canvas><FlameFinal/></Canvas> -->

View File

@ -32,7 +32,7 @@ import Canvas from "../src/Canvas";
import FlameHistogram from "./FlameHistogram"; import FlameHistogram from "./FlameHistogram";
import {paintLinear} from "./paintLinear"; import {paintLinear} from "./paintLinear";
<Canvas><FlameHistogram quality={5} paint={paintLinear}/></Canvas> <!-- <Canvas><FlameHistogram quality={5} paint={paintLinear}/></Canvas> -->
## Log display ## Log display
@ -42,7 +42,7 @@ import paintLogarithmicSource from "!!raw-loader!./paintLogarithmic"
import {paintLogarithmic} from './paintLogarithmic' import {paintLogarithmic} from './paintLogarithmic'
<Canvas><FlameHistogram quality={5} paint={paintLogarithmic}/></Canvas> <!-- <Canvas><FlameHistogram quality={5} paint={paintLogarithmic}/></Canvas> -->
## Color ## Color
@ -52,4 +52,4 @@ import paintColorSource from "!!raw-loader!./paintColor"
import FlameColor from "./FlameColor"; import FlameColor from "./FlameColor";
<Canvas><FlameColor quality={15}/></Canvas> <!-- <Canvas><FlameColor quality={15}/></Canvas> -->

View File

@ -1,138 +1,68 @@
import React, {useCallback, useEffect, useState, createContext, useRef} from "react"; import React, {useEffect, useState, createContext, useRef} from "react";
import {useColorMode} from "@docusaurus/theme-common"; import {useColorMode} from "@docusaurus/theme-common";
import BrowserOnly from "@docusaurus/BrowserOnly"; import BrowserOnly from "@docusaurus/BrowserOnly";
function invertImage(sourceImage: ImageData): ImageData {
const image = new ImageData(sourceImage.width, sourceImage.height);
sourceImage.data.forEach((value, index) =>
image.data[index] = index % 4 === 3 ? value : 0xff - value)
return image;
}
type InvertibleCanvasProps = {
width: number,
height: number,
// NOTE: Images are provided as a single-element array
//so we can allow re-painting with the same (modified) ImageData reference.
image?: [ImageData],
}
/**
* Draw images to a canvas, automatically inverting colors as needed.
*
* @param width Canvas width
* @param height Canvas height
* @param hidden Hide the canvas element
* @param image Image data to draw on the canvas
*/
export const InvertibleCanvas: React.FC<InvertibleCanvasProps> = ({width, height, image}) => {
const [canvasCtx, setCanvasCtx] = useState<CanvasRenderingContext2D>(null);
const canvasRef = useCallback(node => {
if (node !== null) {
setCanvasCtx(node.getContext("2d"));
}
}, []);
const [paintImage, setPaintImage] = useState<[ImageData]>(null);
useEffect(() => {
if (canvasCtx && paintImage) {
canvasCtx.putImageData(paintImage[0], 0, 0);
}
}, [canvasCtx, paintImage]);
const {colorMode} = useColorMode();
useEffect(() => {
if (image) {
setPaintImage(colorMode === 'light' ? image : [invertImage(image[0])]);
}
}, [image, colorMode]);
return (
<canvas
ref={canvasRef}
width={width}
height={height}
style={{aspectRatio: width / height}}
/>
)
}
type PainterProps = { type PainterProps = {
width: number; width: number;
height: number; height: number;
setPainter: (painter: Iterator<ImageData>) => void; setPainter: (painter: Iterator<ImageData>) => void;
} }
export const PainterContext = createContext<PainterProps>(null); export const PainterContext = createContext<PainterProps>(null)
interface CanvasProps { type CanvasProps = {
width?: number; style?: any;
height?: number; children?: React.ReactElement
children?: React.ReactElement;
} }
export const Canvas: React.FC<CanvasProps> = ({style, children}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
/**
* Draw fractal flames to a canvas.
*
* This component is a bit involved because it attempts to solve
* a couple problems at once:
* - Incrementally drawing an image to the canvas
* - Interrupting drawing with new parameters
*
* Running a full render is labor-intensive, so we model it
* as an iterator that yields an image of the current system.
* Internally, that iterator is re-queued on each new image;
* so long as retrieving each image happens quickly,
* we keep the main loop running even with CPU-heavy code.
* As a side benefit, this also animates the chaos game nicely.
* TODO(bspeice): This also causes React to complain about maximum update depth
* Would this be better off spawning a `useEffect` animator
* that has access to a `setState` queue?
*
* To interrupt drawing, children set the active iterator
* through the context provider. This component doesn't care
* about which iterator is in progress, it exists only
* to fetch the next image and paint it to our canvas.
*
* TODO(bspeice): Can we make this "re-queueing iterator" pattern generic?
* It would be nice to have iterators returning arbitrary objects,
* but we rely on contexts to manage the iterator, and I can't find
* a good way to make those generic.
*/
export default function Canvas({width, height, children}: CanvasProps) {
const viewportDetectionRef = useRef<HTMLDivElement>(null);
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
useEffect(() => { useEffect(() => {
if (!viewportDetectionRef) { if (!canvasRef.current) {
return; return;
} }
const observer = new IntersectionObserver(([entry]) => { const observer = new IntersectionObserver((entries) => {
const [entry] = entries;
if (entry.isIntersecting) { if (entry.isIntersecting) {
setIsVisible(true); setIsVisible(true);
} }
}, {root: null, threshold: .1}); });
observer.observe(viewportDetectionRef.current); observer.observe(canvasRef.current);
return () => { return () => {
if (viewportDetectionRef.current) { if (canvasRef.current) {
observer.unobserve(viewportDetectionRef.current); observer.unobserve(canvasRef.current);
} }
} }
}, [viewportDetectionRef]); }, [canvasRef]);
const [width, setWidth] = useState(0);
const [height, setHeight] = useState(0);
useEffect(() => {
if (canvasRef.current) {
setWidth(canvasRef.current.offsetWidth);
setHeight(canvasRef.current.offsetHeight);
}
}, [canvasRef]);
const [imageHolder, setImageHolder] = useState<[ImageData]>(null);
useEffect(() => {
if (canvasRef.current && imageHolder) {
canvasRef.current.getContext("2d").putImageData(imageHolder[0], 0, 0);
}
}, [canvasRef, imageHolder]);
const [image, setImage] = useState<[ImageData]>(null);
const [painterHolder, setPainterHolder] = useState<[Iterator<ImageData>]>(null); const [painterHolder, setPainterHolder] = useState<[Iterator<ImageData>]>(null);
useEffect(() => { useEffect(() => {
if (!isVisible || !painterHolder) { if (!isVisible || !painterHolder) {
console.log("Skipping, not visible");
return; return;
} }
const painter = painterHolder[0]; const painter = painterHolder[0];
const nextImage = painter.next().value; const nextImage = painter.next().value;
if (nextImage) { if (nextImage) {
setImage([nextImage]); setImageHolder([nextImage]);
setPainterHolder([painter]); setPainterHolder([painter]);
} else { } else {
setPainterHolder(null); setPainterHolder(null);
@ -146,18 +76,25 @@ export default function Canvas({width, height, children}: CanvasProps) {
} }
}, [painter]); }, [painter]);
width = width ?? 500; const {colorMode} = useColorMode();
height = height ?? 500;
return ( return (
<> <>
<center> <canvas
<div ref={viewportDetectionRef}> ref={canvasRef}
<InvertibleCanvas width={width} height={height} image={image}/> width={width}
</div> height={height}
</center> style={{
filter: colorMode === 'dark' ? 'invert(1)' : '',
...style
}}
/>
<PainterContext.Provider value={{width, height, setPainter}}> <PainterContext.Provider value={{width, height, setPainter}}>
<BrowserOnly>{() => children}</BrowserOnly> <BrowserOnly>{() => children}</BrowserOnly>
</PainterContext.Provider> </PainterContext.Provider>
</> </>
) )
}
export const SquareCanvas: React.FC<CanvasProps> = ({style, children}) => {
return <Canvas style={{width: '100%', aspectRatio: '1/1', ...style}} children={children}/>
} }

View File

@ -1,6 +1,7 @@
.inputGroup { .inputGroup {
padding: .5em; padding: .2em;
margin: .5em; margin-top: .2em;
margin-bottom: .2em;
border: 1px solid; border: 1px solid;
border-radius: var(--ifm-global-radius); border-radius: var(--ifm-global-radius);
border-color: var(--ifm-color-emphasis-500); border-color: var(--ifm-color-emphasis-500);

View File

@ -1,15 +1,20 @@
export function randomChoice<T>(choices: [number, T][]): [number, T] { export function randomChoice<T>(
const weightSum = choices.reduce((sum, [weight, _]) => sum + weight, 0); choices: [number, T][]
let choice = Math.random() * weightSum; ): [number, T] {
const weightSum = choices.reduce(
(sum, [weight, _]) => sum + weight,
0
);
let choice = Math.random() * weightSum;
for (const [index, element] of choices.entries()) { for (const [idx, elem] of choices.entries()) {
const [weight, t] = element; const [weight, t] = elem;
if (choice < weight) { if (choice < weight) {
return [index, t]; return [idx, t];
}
choice -= weight;
} }
choice -= weight;
}
const index = choices.length - 1; const index = choices.length - 1;
return [index, choices[index][1]]; return [index, choices[index][1]];
} }

View File

@ -1,3 +1,7 @@
export function randomInteger(min: number, max: number) { export function randomInteger(
return Math.floor(Math.random() * (max - min)) + min; min: number,
max: number
) {
let v = Math.random() * (max - min);
return Math.floor(v) + min;
} }

View File

@ -3,6 +3,19 @@
--ifm-container-width-xl: 1440px; --ifm-container-width-xl: 1440px;
--ifm-footer-padding-vertical: .5rem; --ifm-footer-padding-vertical: .5rem;
--ifm-spacing-horizontal: .8rem; --ifm-spacing-horizontal: .8rem;
/* Reduce padding on code blocks */
--ifm-pre-padding: .6rem;
/* More readable code highlight background */
--docusaurus-highlighted-code-line-bg: var(--ifm-color-emphasis-300);
/*--ifm-code-font-size: 85%;*/
}
.katex {
/* Default is 1.21, this helps with fitting on mobile screens */
font-size: 1.16em;
} }
.header-github-link:hover { .header-github-link:hover {