From 5ae6b82d2628d7d6e4f70e12ef948915d33a8b68 Mon Sep 17 00:00:00 2001 From: Bradlee Speice Date: Sun, 8 Dec 2024 19:53:06 -0500 Subject: [PATCH] Auto-sizing canvas, starting cleanup for display on mobile browsers --- .../1-introduction/Gasket.tsx | 17 +- .../1-introduction/GasketWeighted.tsx | 6 +- .../1-introduction/chaosGame.js | 43 ++--- .../1-introduction/chaosGameWeighted.ts | 36 ++-- .../1-introduction/index.mdx | 20 +-- .../1-introduction/plot.ts | 78 +++++---- .../2-transforms/index.mdx | 6 +- .../3-log-density/index.mdx | 6 +- .../src/Canvas.tsx | 155 ++++++------------ .../src/css/styles.module.css | 5 +- .../src/randomChoice.ts | 27 +-- .../src/randomInteger.ts | 8 +- src/css/custom.css | 13 ++ 13 files changed, 206 insertions(+), 214 deletions(-) diff --git a/blog/2024-11-15-playing-with-fire/1-introduction/Gasket.tsx b/blog/2024-11-15-playing-with-fire/1-introduction/Gasket.tsx index 1b96395..c1d848b 100644 --- a/blog/2024-11-15-playing-with-fire/1-introduction/Gasket.tsx +++ b/blog/2024-11-15-playing-with-fire/1-introduction/Gasket.tsx @@ -1,16 +1,21 @@ -import Canvas, {PainterContext} from "../src/Canvas"; -import {useContext} from "react"; +import {SquareCanvas, PainterContext} from "../src/Canvas"; +import {useContext, useEffect} from "react"; export function Render({f}) { - const {setPainter} = useContext(PainterContext); - setPainter(f); + const {width, height, setPainter} = useContext(PainterContext); + useEffect(() => { + if (width && height) { + const painter = f({width, height}); + setPainter(painter); + } + }, [width, height]); return <>; } export default function Gasket({f}) { return ( - + - + ) } \ No newline at end of file diff --git a/blog/2024-11-15-playing-with-fire/1-introduction/GasketWeighted.tsx b/blog/2024-11-15-playing-with-fire/1-introduction/GasketWeighted.tsx index 40d5a3b..326b7f1 100644 --- a/blog/2024-11-15-playing-with-fire/1-introduction/GasketWeighted.tsx +++ b/blog/2024-11-15-playing-with-fire/1-introduction/GasketWeighted.tsx @@ -1,4 +1,4 @@ -import {useEffect, useState, useContext} from "react"; +import {useEffect, useState, useContext, useRef} from "react"; import {PainterContext} from "../src/Canvas"; import {chaosGameWeighted} from "./chaosGameWeighted"; import TeX from '@matejmazur/react-katex'; @@ -30,7 +30,7 @@ export default function GasketWeighted() { const weightInput = (title, weight, setWeight) => ( <>
-

{title} weight: {weight}

+

{title}: {weight}

setWeight(Number(e.currentTarget.value))}/>
@@ -39,7 +39,7 @@ export default function GasketWeighted() { return ( <> -
+
{weightInput("F_0", f0Weight, setF0Weight)} {weightInput("F_1", f1Weight, setF1Weight)} {weightInput("F_2", f2Weight, setF2Weight)} diff --git a/blog/2024-11-15-playing-with-fire/1-introduction/chaosGame.js b/blog/2024-11-15-playing-with-fire/1-introduction/chaosGame.js index 2644edf..42ff413 100644 --- a/blog/2024-11-15-playing-with-fire/1-introduction/chaosGame.js +++ b/blog/2024-11-15-playing-with-fire/1-introduction/chaosGame.js @@ -1,30 +1,33 @@ -// Hint: try increasing the iteration count -const iterations = 10000; +// Hint: try changing the iteration count +const iterations = 100000; -// Hint: negating `x` and `y` creates some interesting images -const transforms = [ - (x, y) => [x / 2, y / 2], - (x, y) => [(x + 1) / 2, y / 2], - (x, y) => [x / 2, (y + 1) / 2] +// Hint: negating `x` and `y` creates some cool images +const xforms = [ + (x, y) => [x / 2, y / 2], + (x, y) => [(x + 1) / 2, y / 2], + (x, y) => [x / 2, (y + 1) / 2] ] -function* chaosGame() { - let image = new ImageData(500, 500); - let [x, y] = [randomBiUnit(), randomBiUnit()]; +function* chaosGame({width, height}) { + let img = new ImageData(width, height); + let [x, y] = [ + randomBiUnit(), + randomBiUnit() + ]; - for (var count = 0; count < iterations; count++) { - const i = randomInteger(0, transforms.length); - [x, y] = transforms[i](x, y); + for (let c = 0; c < iterations; c++) { + const i = randomInteger(0, xforms.length); + [x, y] = xforms[i](x, y); - if (count > 20) - plot(x, y, image); + if (c > 20) + plot(x, y, img); - if (count % 1000 === 0) - yield image; - } + if (c % 1000 === 0) + yield img; + } - yield image; + yield img; } // Wiring so the code above displays properly -render() +render() diff --git a/blog/2024-11-15-playing-with-fire/1-introduction/chaosGameWeighted.ts b/blog/2024-11-15-playing-with-fire/1-introduction/chaosGameWeighted.ts index 35b00f5..a8d4c7e 100644 --- a/blog/2024-11-15-playing-with-fire/1-introduction/chaosGameWeighted.ts +++ b/blog/2024-11-15-playing-with-fire/1-introduction/chaosGameWeighted.ts @@ -6,27 +6,33 @@ import {Transform} from "../src/transform"; const iterations = 50_000; const step = 1000; // hidden-end -export type ChaosGameWeightedProps = { +type Props = { width: number, height: number, transforms: [number, Transform][] } -export function* chaosGameWeighted({width, height, transforms}: ChaosGameWeightedProps) { - let image = new ImageData(width, height); - var [x, y] = [randomBiUnit(), randomBiUnit()]; +export function* chaosGameWeighted( + {width, height, transforms}: Props +) { + let img = new ImageData(width, height); + let [x, y] = [ + randomBiUnit(), + randomBiUnit() + ]; - for (let i = 0; i < iterations; i++) { - // highlight-start - const [_, transform] = randomChoice(transforms); - // highlight-end - [x, y] = transform(x, y); + const iterations = width * height * 0.5; + for (let c = 0; c < iterations; c++) { + // highlight-start + const [_, xform] = randomChoice(transforms); + // highlight-end + [x, y] = xform(x, y); - if (i > 20) - plot(x, y, image); + if (c > 20) + plot(x, y, img); - if (i % step === 0) - yield image; - } + if (c % step === 0) + yield img; + } - yield image; + yield img; } \ No newline at end of file diff --git a/blog/2024-11-15-playing-with-fire/1-introduction/index.mdx b/blog/2024-11-15-playing-with-fire/1-introduction/index.mdx index e4f1988..e9c8ec9 100644 --- a/blog/2024-11-15-playing-with-fire/1-introduction/index.mdx +++ b/blog/2024-11-15-playing-with-fire/1-introduction/index.mdx @@ -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: $$ -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 @@ -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: $$ -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) -\hspace{0.8cm} +F_0(x, y) = \left({x \over 2}, {y \over 2} \right) \\ +~\\ +F_1(x, y) = \left({{x + 1} \over 2}, {y \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*} -&(x, y) = \text{a random point in the bi-unit square} \\ +&(x, y) = \text{random point in the bi-unit square} \\ &\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} \text{plot}(x,y) \text{ except during the first 20 iterations} \\ +&\hspace{1cm} \text{plot}(x,y) \text{ if iterations} > 20 \\ \} \end{align*} $$ @@ -199,6 +199,6 @@ import chaosGameWeightedSource from "!!raw-loader!./chaosGameWeighted"; {chaosGameWeightedSource} import GasketWeighted from "./GasketWeighted"; -import Canvas from "../src/Canvas"; +import {SquareCanvas} from "../src/Canvas"; - \ No newline at end of file + \ No newline at end of file diff --git a/blog/2024-11-15-playing-with-fire/1-introduction/plot.ts b/blog/2024-11-15-playing-with-fire/1-introduction/plot.ts index 23f0b2f..a30eaf3 100644 --- a/blog/2024-11-15-playing-with-fire/1-introduction/plot.ts +++ b/blog/2024-11-15-playing-with-fire/1-introduction/plot.ts @@ -1,34 +1,52 @@ -export function plot(x: number, y: number, image: ImageData) { - // Translate (x,y) coordinates to pixel coordinates; - // also known as a "camera" function. - // - // The display range we care about is x=[0, 1], y=[0, 1], - // so our pixelX and pixelY coordinates are easy to calculate: - const pixelX = Math.floor(x * image.width); - const pixelY = Math.floor(y * image.height); +/** + * ImageData is an array that contains + * four elements per pixel (one for each + * red, green, blue, and alpha value). + * This maps from pixel coordinates + * to the array index + */ +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, - // skip it - if ( - pixelX < 0 || - pixelX > image.width || - pixelY < 0 || - pixelY > image.height - ) { - return; - } +export function plot( + x: number, + y: number, + img: ImageData +) { + // Translate (x,y) coordinates + // to pixel coordinates. + // Also known as a "camera" function. + // + // 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 - // (one for each of the red, green, blue, and alpha values). - // The (pixelX, pixelY) coordinates are used to find where - // in the image we need to write. - const index = pixelY * (image.width * 4) + pixelX * 4; + const index = imageIndex( + img.width, + pixelX, + pixelY + ); - // Set the pixel to black by writing a 0 to the first three - // bytes (red, green, blue), and 256 to the last byte (alpha), - // starting at our index: - image.data[index] = 0; - image.data[index + 1] = 0; - image.data[index + 2] = 0; - image.data[index + 3] = 0xff; + // Skip pixels outside the display range + if ( + index < 0 || + index > img.data.length + ) { + return; + } + + // 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; } \ No newline at end of file diff --git a/blog/2024-11-15-playing-with-fire/2-transforms/index.mdx b/blog/2024-11-15-playing-with-fire/2-transforms/index.mdx index 944243d..64f778f 100644 --- a/blog/2024-11-15-playing-with-fire/2-transforms/index.mdx +++ b/blog/2024-11-15-playing-with-fire/2-transforms/index.mdx @@ -145,7 +145,7 @@ each transform. import Canvas from "../src/Canvas"; import FlameBlend from "./FlameBlend"; - + ## Post transforms @@ -160,10 +160,10 @@ $$ import FlamePost from "./FlamePost"; - + ## Final transform import FlameFinal from "./FlameFinal"; - \ No newline at end of file + \ No newline at end of file diff --git a/blog/2024-11-15-playing-with-fire/3-log-density/index.mdx b/blog/2024-11-15-playing-with-fire/3-log-density/index.mdx index 515451f..69dbca5 100644 --- a/blog/2024-11-15-playing-with-fire/3-log-density/index.mdx +++ b/blog/2024-11-15-playing-with-fire/3-log-density/index.mdx @@ -32,7 +32,7 @@ import Canvas from "../src/Canvas"; import FlameHistogram from "./FlameHistogram"; import {paintLinear} from "./paintLinear"; - + ## Log display @@ -42,7 +42,7 @@ import paintLogarithmicSource from "!!raw-loader!./paintLogarithmic" import {paintLogarithmic} from './paintLogarithmic' - + ## Color @@ -52,4 +52,4 @@ import paintColorSource from "!!raw-loader!./paintColor" import FlameColor from "./FlameColor"; - \ No newline at end of file + \ No newline at end of file diff --git a/blog/2024-11-15-playing-with-fire/src/Canvas.tsx b/blog/2024-11-15-playing-with-fire/src/Canvas.tsx index c6afae3..0b262d7 100644 --- a/blog/2024-11-15-playing-with-fire/src/Canvas.tsx +++ b/blog/2024-11-15-playing-with-fire/src/Canvas.tsx @@ -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 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 = ({width, height, image}) => { - const [canvasCtx, setCanvasCtx] = useState(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 ( - - ) -} - type PainterProps = { width: number; height: number; setPainter: (painter: Iterator) => void; } -export const PainterContext = createContext(null); +export const PainterContext = createContext(null) -interface CanvasProps { - width?: number; - height?: number; - children?: React.ReactElement; +type CanvasProps = { + style?: any; + children?: React.ReactElement } +export const Canvas: React.FC = ({style, children}) => { + const canvasRef = useRef(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(null); const [isVisible, setIsVisible] = useState(false); useEffect(() => { - if (!viewportDetectionRef) { + if (!canvasRef.current) { return; } - const observer = new IntersectionObserver(([entry]) => { + const observer = new IntersectionObserver((entries) => { + const [entry] = entries; if (entry.isIntersecting) { setIsVisible(true); } - }, {root: null, threshold: .1}); - observer.observe(viewportDetectionRef.current); + }); + observer.observe(canvasRef.current); return () => { - if (viewportDetectionRef.current) { - observer.unobserve(viewportDetectionRef.current); + if (canvasRef.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]>(null); useEffect(() => { if (!isVisible || !painterHolder) { - console.log("Skipping, not visible"); return; } const painter = painterHolder[0]; const nextImage = painter.next().value; if (nextImage) { - setImage([nextImage]); + setImageHolder([nextImage]); setPainterHolder([painter]); } else { setPainterHolder(null); @@ -146,18 +76,25 @@ export default function Canvas({width, height, children}: CanvasProps) { } }, [painter]); - width = width ?? 500; - height = height ?? 500; + const {colorMode} = useColorMode(); return ( <> -
-
- -
-
+ {() => children} ) +} + +export const SquareCanvas: React.FC = ({style, children}) => { + return } \ No newline at end of file diff --git a/blog/2024-11-15-playing-with-fire/src/css/styles.module.css b/blog/2024-11-15-playing-with-fire/src/css/styles.module.css index f83b7cf..d769283 100644 --- a/blog/2024-11-15-playing-with-fire/src/css/styles.module.css +++ b/blog/2024-11-15-playing-with-fire/src/css/styles.module.css @@ -1,6 +1,7 @@ .inputGroup { - padding: .5em; - margin: .5em; + padding: .2em; + margin-top: .2em; + margin-bottom: .2em; border: 1px solid; border-radius: var(--ifm-global-radius); border-color: var(--ifm-color-emphasis-500); diff --git a/blog/2024-11-15-playing-with-fire/src/randomChoice.ts b/blog/2024-11-15-playing-with-fire/src/randomChoice.ts index 0494924..58cf17e 100644 --- a/blog/2024-11-15-playing-with-fire/src/randomChoice.ts +++ b/blog/2024-11-15-playing-with-fire/src/randomChoice.ts @@ -1,15 +1,20 @@ -export function randomChoice(choices: [number, T][]): [number, T] { - const weightSum = choices.reduce((sum, [weight, _]) => sum + weight, 0); - let choice = Math.random() * weightSum; +export function randomChoice( + choices: [number, T][] +): [number, T] { + const weightSum = choices.reduce( + (sum, [weight, _]) => sum + weight, + 0 + ); + let choice = Math.random() * weightSum; - for (const [index, element] of choices.entries()) { - const [weight, t] = element; - if (choice < weight) { - return [index, t]; - } - choice -= weight; + for (const [idx, elem] of choices.entries()) { + const [weight, t] = elem; + if (choice < weight) { + return [idx, t]; } + choice -= weight; + } - const index = choices.length - 1; - return [index, choices[index][1]]; + const index = choices.length - 1; + return [index, choices[index][1]]; } \ No newline at end of file diff --git a/blog/2024-11-15-playing-with-fire/src/randomInteger.ts b/blog/2024-11-15-playing-with-fire/src/randomInteger.ts index c3a6719..39e99b2 100644 --- a/blog/2024-11-15-playing-with-fire/src/randomInteger.ts +++ b/blog/2024-11-15-playing-with-fire/src/randomInteger.ts @@ -1,3 +1,7 @@ -export function randomInteger(min: number, max: number) { - return Math.floor(Math.random() * (max - min)) + min; +export function randomInteger( + min: number, + max: number +) { + let v = Math.random() * (max - min); + return Math.floor(v) + min; } \ No newline at end of file diff --git a/src/css/custom.css b/src/css/custom.css index 9a455f7..5030f55 100644 --- a/src/css/custom.css +++ b/src/css/custom.css @@ -3,6 +3,19 @@ --ifm-container-width-xl: 1440px; --ifm-footer-padding-vertical: .5rem; --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 {