mirror of
https://github.com/bspeice/speice.io
synced 2024-12-21 16:18:10 -05:00
commit
e49ba23e3f
2
.gitignore
vendored
2
.gitignore
vendored
@ -10,11 +10,13 @@
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.idea
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.styles
|
||||
.vscode
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
|
4
blog/2024-11-15-playing-with-fire/.prettierrc
Normal file
4
blog/2024-11-15-playing-with-fire/.prettierrc
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"tabWidth": 2,
|
||||
"semi": true
|
||||
}
|
21
blog/2024-11-15-playing-with-fire/1-introduction/Gasket.tsx
Normal file
21
blog/2024-11-15-playing-with-fire/1-introduction/Gasket.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import { PainterContext, SquareCanvas } from "../src/Canvas";
|
||||
import { useContext, useEffect } from "react";
|
||||
|
||||
export function Render({ 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 (
|
||||
<SquareCanvas name={"gasket"}>
|
||||
<Render f={f} />
|
||||
</SquareCanvas>
|
||||
);
|
||||
}
|
@ -0,0 +1,49 @@
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { PainterContext } from "../src/Canvas";
|
||||
import { chaosGameWeighted } from "./chaosGameWeighted";
|
||||
import TeX from "@matejmazur/react-katex";
|
||||
|
||||
import styles from "../src/css/styles.module.css";
|
||||
|
||||
type Transform = (x: number, y: number) => [number, number];
|
||||
|
||||
export default function GasketWeighted() {
|
||||
const [f0Weight, setF0Weight] = useState<number>(1);
|
||||
const [f1Weight, setF1Weight] = useState<number>(1);
|
||||
const [f2Weight, setF2Weight] = useState<number>(1);
|
||||
|
||||
const f0: Transform = (x, y) => [x / 2, y / 2];
|
||||
const f1: Transform = (x, y) => [(x + 1) / 2, y / 2];
|
||||
const f2: Transform = (x, y) => [x / 2, (y + 1) / 2];
|
||||
|
||||
const { width, height, setPainter } = useContext(PainterContext);
|
||||
|
||||
useEffect(() => {
|
||||
const transforms: [number, Transform][] = [
|
||||
[f0Weight, f0],
|
||||
[f1Weight, f1],
|
||||
[f2Weight, f2]
|
||||
];
|
||||
setPainter(chaosGameWeighted({ width, height, transforms }));
|
||||
}, [f0Weight, f1Weight, f2Weight]);
|
||||
|
||||
const weightInput = (title, weight, setWeight) => (
|
||||
<>
|
||||
<div className={styles.inputElement}>
|
||||
<p><TeX>{title}</TeX>: {weight}</p>
|
||||
<input type={"range"} min={0} max={1} step={.01} value={weight}
|
||||
onInput={e => setWeight(Number(e.currentTarget.value))} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.inputGroup} style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr" }}>
|
||||
{weightInput("F_0", f0Weight, setF0Weight)}
|
||||
{weightInput("F_1", f1Weight, setF1Weight)}
|
||||
{weightInput("F_2", f2Weight, setF2Weight)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
export function camera(
|
||||
x: number,
|
||||
y: number,
|
||||
size: number
|
||||
): [number, number] {
|
||||
return [
|
||||
Math.floor(x * size),
|
||||
Math.floor(y * size)
|
||||
];
|
||||
}
|
@ -0,0 +1,34 @@
|
||||
// Hint: try changing the iteration count
|
||||
const iterations = 100000;
|
||||
|
||||
// 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({ width, height }) {
|
||||
let img =
|
||||
new ImageData(width, height);
|
||||
let [x, y] = [
|
||||
randomBiUnit(),
|
||||
randomBiUnit()
|
||||
];
|
||||
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
const index =
|
||||
randomInteger(0, xforms.length);
|
||||
[x, y] = xforms[index](x, y);
|
||||
|
||||
if (i > 20)
|
||||
plot(x, y, img);
|
||||
|
||||
if (i % 1000 === 0)
|
||||
yield img;
|
||||
}
|
||||
|
||||
yield img;
|
||||
}
|
||||
|
||||
render(<Gasket f={chaosGame} />);
|
@ -0,0 +1,43 @@
|
||||
// hidden-start
|
||||
import { randomBiUnit } from "../src/randomBiUnit";
|
||||
import { randomChoice } from "../src/randomChoice";
|
||||
import { plot } from "./plot";
|
||||
import { Transform } from "../src/transform";
|
||||
|
||||
const quality = 0.5;
|
||||
const step = 1000;
|
||||
// hidden-end
|
||||
export type Props = {
|
||||
width: number,
|
||||
height: number,
|
||||
transforms: [number, Transform][]
|
||||
}
|
||||
|
||||
export function* chaosGameWeighted(
|
||||
{ width, height, transforms }: Props
|
||||
) {
|
||||
let img =
|
||||
new ImageData(width, height);
|
||||
let [x, y] = [
|
||||
randomBiUnit(),
|
||||
randomBiUnit()
|
||||
];
|
||||
|
||||
const pixels = width * height;
|
||||
const iterations = quality * pixels;
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
// highlight-start
|
||||
const [_, xform] =
|
||||
randomChoice(transforms);
|
||||
// highlight-end
|
||||
[x, y] = xform(x, y);
|
||||
|
||||
if (i > 20)
|
||||
plot(x, y, img);
|
||||
|
||||
if (i % step === 0)
|
||||
yield img;
|
||||
}
|
||||
|
||||
yield img;
|
||||
}
|
303
blog/2024-11-15-playing-with-fire/1-introduction/index.mdx
Normal file
303
blog/2024-11-15-playing-with-fire/1-introduction/index.mdx
Normal file
@ -0,0 +1,303 @@
|
||||
---
|
||||
slug: 2024/11/playing-with-fire
|
||||
title: "Playing with fire: The fractal flame algorithm"
|
||||
date: 2024-12-16 21:30:00
|
||||
authors: [bspeice]
|
||||
tags: []
|
||||
---
|
||||
|
||||
|
||||
Wikipedia describes [fractal flames](https://en.wikipedia.org/wiki/Fractal_flame) as:
|
||||
|
||||
> a member of the iterated function system class of fractals
|
||||
|
||||
It's tedious, but technically correct. I choose to think of them a different way: beauty in mathematics.
|
||||
|
||||
import isDarkMode from '@site/src/isDarkMode'
|
||||
import banner from '../banner.png'
|
||||
|
||||
<center>
|
||||
<img src={banner} style={{filter: isDarkMode() ? '' : 'invert(1)'}}/>
|
||||
</center>
|
||||
|
||||
<!-- truncate -->
|
||||
|
||||
I don't remember when exactly I first learned about fractal flames, but I do remember being entranced by the images they created.
|
||||
I also remember their unique appeal to my young engineering mind; this was an art form I could participate in.
|
||||
|
||||
The [Fractal Flame Algorithm paper](https://flam3.com/flame_draves.pdf) describing their structure was too much
|
||||
for me to handle at the time (I was ~12 years old), so I was content to play around and enjoy the pictures.
|
||||
But the desire to understand it stuck around. Now, with a graduate degree under my belt, I wanted to revisit it.
|
||||
|
||||
This guide is my attempt to explain how fractal flames work so that younger me — and others interested in the art —
|
||||
can understand without too much prior knowledge.
|
||||
|
||||
---
|
||||
|
||||
## Iterated function systems
|
||||
|
||||
:::note
|
||||
This post covers section 2 of the Fractal Flame Algorithm paper
|
||||
:::
|
||||
|
||||
As mentioned, fractal flames are a type of "[iterated function system](https://en.wikipedia.org/wiki/Iterated_function_system),"
|
||||
or IFS. The formula for an IFS is short, but takes some time to work through:
|
||||
|
||||
$$
|
||||
S = \bigcup_{i=0}^{n-1} F_i(S)
|
||||
$$
|
||||
|
||||
### Solution set
|
||||
|
||||
First, $S$. $S$ is the set of points in two dimensions (in math terms, $S \in \mathbb{R}^2$)
|
||||
that represent a "solution" of some kind to our equation.
|
||||
Our goal is to find all the points in $S$, plot them, and display that image.
|
||||
|
||||
For example, if we say $S = \{(0,0), (1, 1), (2, 2)\}$, there are three points to plot:
|
||||
|
||||
import {VictoryChart, VictoryTheme, VictoryScatter, VictoryLegend} from "victory";
|
||||
export const simpleData = [
|
||||
{x: 0, y: 0},
|
||||
{x: 1, y: 1},
|
||||
{x: 2, y: 2}
|
||||
]
|
||||
|
||||
<VictoryChart theme={VictoryTheme.clean}>
|
||||
<VictoryScatter data={simpleData} size={5} style={{data: {fill: "blue"}}}/>
|
||||
</VictoryChart>
|
||||
|
||||
With fractal flames, rather than listing individual points, we use functions to describe the solution.
|
||||
This means there are an infinite number of points, but if we find _enough_ points to plot, we get a nice picture.
|
||||
And if the functions change, the solution also changes, and we get something new.
|
||||
|
||||
### Transform functions
|
||||
|
||||
Second, the $F_i(S)$ functions, also known as "transforms."
|
||||
Each transform takes in a 2-dimensional point and gives a new point back
|
||||
(in math terms, $F_i \in \mathbb{R}^2 \rightarrow \mathbb{R}^2$).
|
||||
While you could theoretically use any function, we'll focus on a specific kind of function
|
||||
called an "[affine transformation](https://en.wikipedia.org/wiki/Affine_transformation)." Every transform uses the same formula:
|
||||
|
||||
$$
|
||||
F_i(a_i x + b_i y + c_i, d_i x + e_i y + f_i)
|
||||
$$
|
||||
|
||||
import transformSource from "!!raw-loader!../src/transform"
|
||||
import CodeBlock from '@theme/CodeBlock'
|
||||
|
||||
<CodeBlock language="typescript">{transformSource}</CodeBlock>
|
||||
|
||||
The parameters ($a_i$, $b_i$, etc.) are values we choose.
|
||||
For example, we can define a "shift" function like this:
|
||||
|
||||
$$
|
||||
\begin{align*}
|
||||
a &= 1 \\
|
||||
b &= 0 \\
|
||||
c &= 0.5 \\
|
||||
d &= 0 \\
|
||||
e &= 1 \\
|
||||
f &= 1.5 \\
|
||||
F_{shift}(x, y) &= (1 \cdot x + 0.5, 1 \cdot y + 1.5)
|
||||
\end{align*}
|
||||
$$
|
||||
|
||||
Applying this transform to the original points gives us a new set of points:
|
||||
|
||||
import {applyCoefs} from "../src/transform"
|
||||
|
||||
export const coefs = {a: 1, b: 0, c: 0.5, d: 0, e: 1, f: 1.5}
|
||||
export const toData = ([x, y]) => ({x, y})
|
||||
|
||||
export const shiftData = simpleData.map(({x, y}) => toData(applyCoefs(x, y, coefs)))
|
||||
|
||||
<VictoryChart theme={VictoryTheme.clean}>
|
||||
<VictoryScatter data={simpleData} size={5} style={{data: {fill: "blue"}}}/>
|
||||
<VictoryScatter data={shiftData} size={5} style={{data: {fill: "orange"}}}/>
|
||||
<VictoryLegend
|
||||
data={[
|
||||
{name: "(x,y)", symbol: {fill: "blue"}},
|
||||
{name: "F(x,y)", symbol: {fill: "orange"}}
|
||||
]}
|
||||
orientation={"vertical"}
|
||||
x={75}
|
||||
y={10}
|
||||
/>
|
||||
</VictoryChart>
|
||||
|
||||
Fractal flames use more complex functions, but they all start with this structure.
|
||||
|
||||
### Fixed set
|
||||
|
||||
With those definitions in place, let's revisit the initial problem:
|
||||
|
||||
$$
|
||||
S = \bigcup_{i=0}^{n-1} F_i(S)
|
||||
$$
|
||||
|
||||
Or, in English, we might say:
|
||||
|
||||
> Our solution, $S$, is the union of all sets produced by applying each function, $F_i$,
|
||||
> to points in the solution.
|
||||
|
||||
There's just one small problem: to find the solution, we must already know which points are in the solution.
|
||||
What?
|
||||
|
||||
John E. Hutchinson provides an explanation in the [original paper](https://maths-people.anu.edu.au/~john/Assets/Research%20Papers/fractals_self-similarity.pdf)
|
||||
defining the mathematics of iterated function systems:
|
||||
|
||||
> Furthermore, $S$ is compact and is the closure of the set of fixed points $s_{i_1...i_p}$
|
||||
> of finite compositions $F_{i_1...i_p}$ of members of $F$.
|
||||
|
||||
Before your eyes glaze over, let's unpack this:
|
||||
|
||||
- **Furthermore, $S$ is [compact](https://en.wikipedia.org/wiki/Compact_space)...**: All points in our solution will be in a finite range
|
||||
- **...and is the [closure](https://en.wikipedia.org/wiki/Closure_(mathematics)) of the set of [fixed points](https://en.wikipedia.org/wiki/Fixed_point_(mathematics))**:
|
||||
Applying our functions to points in the solution will give us other points that are in the solution
|
||||
- **...of finite compositions $F_{i_1...i_p}$ of members of $F$**: By composing our functions (that is,
|
||||
using the output of one function as input to the next), we will arrive at the points in the solution
|
||||
|
||||
Thus, by applying the functions to fixed points of our system, we will find the other points we care about.
|
||||
|
||||
<details>
|
||||
<summary>If you want a bit more math...</summary>
|
||||
|
||||
...then there are some extra details I've glossed over so far.
|
||||
|
||||
First, the Hutchinson paper requires that the functions $F_i$ be _contractive_ for the solution set to exist.
|
||||
That is, applying the function to a point must bring it closer to other points. However, as the fractal flame
|
||||
algorithm demonstrates, we only need functions to be contractive _on average_. At worst, the system will
|
||||
degenerate and produce a bad image.
|
||||
|
||||
Second, we're focused on $\mathbb{R}^2$ because we're generating images, but the math
|
||||
allows for arbitrary dimensions; you could also have 3-dimensional fractal flames.
|
||||
|
||||
Finally, there's a close relationship between fractal flames and [attractors](https://en.wikipedia.org/wiki/Attractor).
|
||||
Specifically, the fixed points of $S$ act as attractors for the chaos game (explained below).
|
||||
</details>
|
||||
|
||||
This is still a bit vague, so let's work through an example.
|
||||
|
||||
## [Sierpinski's gasket](https://www.britannica.com/biography/Waclaw-Sierpinski)
|
||||
|
||||
The Fractal Flame paper gives three functions to use for a first IFS:
|
||||
|
||||
$$
|
||||
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)
|
||||
$$
|
||||
|
||||
### The chaos game
|
||||
|
||||
Now, how do we find the "fixed points" mentioned earlier? The paper lays out an algorithm called the "[chaos game](https://en.wikipedia.org/wiki/Chaos_game)"
|
||||
that gives us points in the solution:
|
||||
|
||||
$$
|
||||
\begin{align*}
|
||||
&(x, y) = \text{random point in the bi-unit square} \\
|
||||
&\text{iterate } \{ \\
|
||||
&\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{ if iterations} > 20 \\
|
||||
\}
|
||||
\end{align*}
|
||||
$$
|
||||
|
||||
:::note
|
||||
The chaos game algorithm is effectively the "finite compositions of $F_{i_1..i_p}$" mentioned earlier.
|
||||
:::
|
||||
|
||||
Let's turn this into code, one piece at a time.
|
||||
|
||||
To start, we need to generate some random numbers. The "bi-unit square" is the range $[-1, 1]$,
|
||||
and we can do this using an existing API:
|
||||
|
||||
import biunitSource from '!!raw-loader!../src/randomBiUnit'
|
||||
|
||||
<CodeBlock language="typescript">{biunitSource}</CodeBlock>
|
||||
|
||||
Next, we need to choose a random integer from $0$ to $n - 1$:
|
||||
|
||||
import randintSource from '!!raw-loader!../src/randomInteger'
|
||||
|
||||
<CodeBlock language="typescript">{randintSource}</CodeBlock>
|
||||
|
||||
### Plotting
|
||||
|
||||
Finally, implementing the `plot` function. This blog series is interactive,
|
||||
so everything displays directly in the browser. As an alternative,
|
||||
software like `flam3` and Apophysis can "plot" by saving an image to disk.
|
||||
|
||||
To see the results, we'll use the [Canvas API](https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API).
|
||||
This allows us to manipulate individual pixels in an image and show it on screen.
|
||||
|
||||
First, we need to convert from fractal flame coordinates to pixel coordinates.
|
||||
To simplify things, we'll assume that we're plotting a square image
|
||||
with range $[0, 1]$ for both $x$ and $y$:
|
||||
|
||||
import cameraSource from "!!raw-loader!./cameraGasket"
|
||||
|
||||
<CodeBlock language="typescript">{cameraSource}</CodeBlock>
|
||||
|
||||
Next, we'll store the pixel data in an [`ImageData` object](https://developer.mozilla.org/en-US/docs/Web/API/ImageData).
|
||||
Each pixel on screen has a corresponding index in the `data` array.
|
||||
To plot a point, we set that pixel to be black:
|
||||
|
||||
import plotSource from '!!raw-loader!./plot'
|
||||
|
||||
<CodeBlock language="typescript">{plotSource}</CodeBlock>
|
||||
|
||||
Putting it all together, we have our first image:
|
||||
|
||||
import Playground from '@theme/Playground'
|
||||
import Scope from './scope'
|
||||
|
||||
import chaosGameSource from '!!raw-loader!./chaosGame'
|
||||
|
||||
<Playground scope={Scope} noInline={true}>{chaosGameSource}</Playground>
|
||||
|
||||
<hr/>
|
||||
|
||||
<small>
|
||||
The image here is slightly different than in the paper.
|
||||
I think the paper has an error, so I'm plotting the image
|
||||
like the [reference implementation](https://github.com/scottdraves/flam3/blob/7fb50c82e90e051f00efcc3123d0e06de26594b2/rect.c#L440-L441).
|
||||
</small>
|
||||
|
||||
### Weights
|
||||
|
||||
There's one last step before we finish the introduction. So far, each transform has
|
||||
the same chance of being picked in the chaos game.
|
||||
We can change that by giving them a "weight" ($w_i$) instead:
|
||||
|
||||
import randomChoiceSource from '!!raw-loader!../src/randomChoice'
|
||||
|
||||
<CodeBlock language={'typescript'}>{randomChoiceSource}</CodeBlock>
|
||||
|
||||
If we let the chaos game run forever, these weights wouldn't matter.
|
||||
But because the iteration count is limited, changing the weights
|
||||
means we don't plot some parts of the image:
|
||||
|
||||
import chaosGameWeightedSource from "!!raw-loader!./chaosGameWeighted";
|
||||
|
||||
<CodeBlock language={'typescript'}>{chaosGameWeightedSource}</CodeBlock>
|
||||
|
||||
:::tip
|
||||
Double-click the image if you want to save a copy!
|
||||
:::
|
||||
|
||||
import GasketWeighted from "./GasketWeighted";
|
||||
import {SquareCanvas} from "../src/Canvas";
|
||||
|
||||
<SquareCanvas name={"gasket_weighted"}><GasketWeighted/></SquareCanvas>
|
||||
|
||||
## Summary
|
||||
|
||||
Studying the foundations of fractal flames is challenging,
|
||||
but we now have an understanding of the mathematics
|
||||
and the implementation of iterated function systems.
|
||||
|
||||
In the next post, we'll look at the first innovation of fractal flame algorithm: variations.
|
44
blog/2024-11-15-playing-with-fire/1-introduction/plot.ts
Normal file
44
blog/2024-11-15-playing-with-fire/1-introduction/plot.ts
Normal file
@ -0,0 +1,44 @@
|
||||
// hidden-start
|
||||
import { camera } from "./cameraGasket";
|
||||
|
||||
// hidden-end
|
||||
function imageIndex(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number
|
||||
) {
|
||||
return y * (width * 4) + x * 4;
|
||||
}
|
||||
|
||||
export function plot(
|
||||
x: number,
|
||||
y: number,
|
||||
img: ImageData
|
||||
) {
|
||||
let [pixelX, pixelY] =
|
||||
camera(x, y, img.width);
|
||||
|
||||
// Skip coordinates outside the display
|
||||
if (
|
||||
pixelX < 0 ||
|
||||
pixelX >= img.width ||
|
||||
pixelY < 0 ||
|
||||
pixelY >= img.height
|
||||
)
|
||||
return;
|
||||
|
||||
const i = imageIndex(
|
||||
pixelX,
|
||||
pixelY,
|
||||
img.width
|
||||
);
|
||||
|
||||
// Set the pixel to black by setting
|
||||
// the first three elements to 0
|
||||
// (red, green, and blue, respectively),
|
||||
// and 255 to the last element (alpha)
|
||||
img.data[i] = 0;
|
||||
img.data[i + 1] = 0;
|
||||
img.data[i + 2] = 0;
|
||||
img.data[i + 3] = 0xff;
|
||||
}
|
12
blog/2024-11-15-playing-with-fire/1-introduction/scope.tsx
Normal file
12
blog/2024-11-15-playing-with-fire/1-introduction/scope.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import Gasket from "./Gasket";
|
||||
import { plot } from "./plot";
|
||||
import { randomBiUnit } from "../src/randomBiUnit";
|
||||
import { randomInteger } from "../src/randomInteger";
|
||||
|
||||
const Scope = {
|
||||
Gasket,
|
||||
plot,
|
||||
randomBiUnit,
|
||||
randomInteger
|
||||
};
|
||||
export default Scope;
|
@ -0,0 +1,52 @@
|
||||
import TeX from "@matejmazur/react-katex";
|
||||
import { Coefs } from "../src/transform";
|
||||
|
||||
import styles from "../src/css/styles.module.css";
|
||||
|
||||
export interface Props {
|
||||
title: String;
|
||||
isPost: boolean;
|
||||
coefs: Coefs;
|
||||
setCoefs: (coefs: Coefs) => void;
|
||||
resetCoefs: () => void;
|
||||
}
|
||||
|
||||
export const CoefEditor = ({ title, isPost, coefs, setCoefs, resetCoefs }: Props) => {
|
||||
const resetButton = <button className={styles.inputReset} onClick={resetCoefs}>Reset</button>;
|
||||
|
||||
return (
|
||||
<div className={styles.inputGroup} style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr" }}>
|
||||
<p className={styles.inputTitle} style={{ gridColumn: "1/-1" }}>{title} {resetButton}</p>
|
||||
<div className={styles.inputElement}>
|
||||
<p>{isPost ? <TeX>\alpha</TeX> : "a"}: {coefs.a}</p>
|
||||
<input type={"range"} min={-2} max={2} step={0.01} value={coefs.a}
|
||||
onInput={e => setCoefs({ ...coefs, a: Number(e.currentTarget.value) })} />
|
||||
</div>
|
||||
<div className={styles.inputElement}>
|
||||
<p>{isPost ? <TeX>\beta</TeX> : "b"}: {coefs.b}</p>
|
||||
<input type={"range"} min={-2} max={2} step={0.01} value={coefs.b}
|
||||
onInput={e => setCoefs({ ...coefs, b: Number(e.currentTarget.value) })} />
|
||||
</div>
|
||||
<div className={styles.inputElement}>
|
||||
<p>{isPost ? <TeX>\gamma</TeX> : "c"}: {coefs.c}</p>
|
||||
<input type={"range"} min={-2} max={2} step={0.01} value={coefs.c}
|
||||
onInput={e => setCoefs({ ...coefs, c: Number(e.currentTarget.value) })} />
|
||||
</div>
|
||||
<div className={styles.inputElement}>
|
||||
<p>{isPost ? <TeX>\delta</TeX> : "d"}: {coefs.d}</p>
|
||||
<input type={"range"} min={-2} max={2} step={0.01} value={coefs.d}
|
||||
onInput={e => setCoefs({ ...coefs, d: Number(e.currentTarget.value) })} />
|
||||
</div>
|
||||
<div className={styles.inputElement}>
|
||||
<p>{isPost ? <TeX>\epsilon</TeX> : "e"}: {coefs.e}</p>
|
||||
<input type={"range"} min={-2} max={2} step={0.01} value={coefs.e}
|
||||
onInput={e => setCoefs({ ...coefs, e: Number(e.currentTarget.value) })} />
|
||||
</div>
|
||||
<div className={styles.inputElement}>
|
||||
<p>{isPost ? <TeX>\zeta</TeX> : "f"}: {coefs.f}</p>
|
||||
<input type={"range"} min={-2} max={2} step={0.01} value={coefs.f}
|
||||
onInput={e => setCoefs({ ...coefs, f: Number(e.currentTarget.value) })} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,68 @@
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { Transform } from "../src/transform";
|
||||
import * as params from "../src/params";
|
||||
import { PainterContext } from "../src/Canvas";
|
||||
import { chaosGameFinal } from "./chaosGameFinal";
|
||||
import { VariationEditor, VariationProps } from "./VariationEditor";
|
||||
import { applyTransform } from "../src/applyTransform";
|
||||
import { buildBlend } from "./buildBlend";
|
||||
|
||||
export default function FlameBlend() {
|
||||
const { width, height, setPainter } = useContext(PainterContext);
|
||||
|
||||
const xform1VariationsDefault: VariationProps = {
|
||||
linear: 0,
|
||||
julia: 1,
|
||||
popcorn: 0,
|
||||
pdj: 0
|
||||
};
|
||||
const [xform1Variations, setXform1Variations] = useState(xform1VariationsDefault);
|
||||
const resetXform1Variations = () => setXform1Variations(xform1VariationsDefault);
|
||||
|
||||
const xform2VariationsDefault: VariationProps = {
|
||||
linear: 1,
|
||||
julia: 0,
|
||||
popcorn: 1,
|
||||
pdj: 0
|
||||
};
|
||||
const [xform2Variations, setXform2Variations] = useState(xform2VariationsDefault);
|
||||
const resetXform2Variations = () => setXform2Variations(xform2VariationsDefault);
|
||||
|
||||
const xform3VariationsDefault: VariationProps = {
|
||||
linear: 0,
|
||||
julia: 0,
|
||||
popcorn: 0,
|
||||
pdj: 1
|
||||
};
|
||||
const [xform3Variations, setXform3Variations] = useState(xform3VariationsDefault);
|
||||
const resetXform3Variations = () => setXform3Variations(xform3VariationsDefault);
|
||||
|
||||
const identityXform: Transform = (x, y) => [x, y];
|
||||
|
||||
useEffect(() => {
|
||||
const transforms: [number, Transform][] = [
|
||||
[params.xform1Weight, applyTransform(params.xform1Coefs, buildBlend(params.xform1Coefs, xform1Variations))],
|
||||
[params.xform2Weight, applyTransform(params.xform2Coefs, buildBlend(params.xform2Coefs, xform2Variations))],
|
||||
[params.xform3Weight, applyTransform(params.xform3Coefs, buildBlend(params.xform3Coefs, xform3Variations))]
|
||||
];
|
||||
|
||||
const gameParams = {
|
||||
width,
|
||||
height,
|
||||
transforms,
|
||||
final: identityXform
|
||||
};
|
||||
setPainter(chaosGameFinal(gameParams));
|
||||
}, [xform1Variations, xform2Variations, xform3Variations]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<VariationEditor title={"Transform 1"} variations={xform1Variations} setVariations={setXform1Variations}
|
||||
resetVariations={resetXform1Variations} />
|
||||
<VariationEditor title={"Transform 2"} variations={xform2Variations} setVariations={setXform2Variations}
|
||||
resetVariations={resetXform2Variations} />
|
||||
<VariationEditor title={"Transform 3"} variations={xform3Variations} setVariations={setXform3Variations}
|
||||
resetVariations={resetXform3Variations} />
|
||||
</>
|
||||
);
|
||||
}
|
@ -0,0 +1,46 @@
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { Coefs } from "../src/transform";
|
||||
import * as params from "../src/params";
|
||||
import { PainterContext } from "../src/Canvas";
|
||||
import { buildBlend } from "./buildBlend";
|
||||
import { chaosGameFinal } from "./chaosGameFinal";
|
||||
import { VariationEditor, VariationProps } from "./VariationEditor";
|
||||
import { CoefEditor } from "./CoefEditor";
|
||||
import { applyPost, applyTransform } from "../src/applyTransform";
|
||||
|
||||
export default function FlameFinal() {
|
||||
const { width, height, setPainter } = useContext(PainterContext);
|
||||
|
||||
const [xformFinalCoefs, setXformFinalCoefs] = useState<Coefs>(params.xformFinalCoefs);
|
||||
const resetXformFinalCoefs = () => setXformFinalCoefs(params.xformFinalCoefs);
|
||||
|
||||
const xformFinalVariationsDefault: VariationProps = {
|
||||
linear: 0,
|
||||
julia: 1,
|
||||
popcorn: 0,
|
||||
pdj: 0
|
||||
};
|
||||
const [xformFinalVariations, setXformFinalVariations] = useState<VariationProps>(xformFinalVariationsDefault);
|
||||
const resetXformFinalVariations = () => setXformFinalVariations(xformFinalVariationsDefault);
|
||||
|
||||
const [xformFinalCoefsPost, setXformFinalCoefsPost] = useState<Coefs>(params.xformFinalCoefsPost);
|
||||
const resetXformFinalCoefsPost = () => setXformFinalCoefsPost(params.xformFinalCoefsPost);
|
||||
|
||||
useEffect(() => {
|
||||
const finalBlend = buildBlend(xformFinalCoefs, xformFinalVariations);
|
||||
const finalXform = applyPost(xformFinalCoefsPost, applyTransform(xformFinalCoefs, finalBlend));
|
||||
|
||||
setPainter(chaosGameFinal({ width, height, transforms: params.xforms, final: finalXform }));
|
||||
}, [xformFinalCoefs, xformFinalVariations, xformFinalCoefsPost]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CoefEditor title={"Final Transform"} isPost={false} coefs={xformFinalCoefs} setCoefs={setXformFinalCoefs}
|
||||
resetCoefs={resetXformFinalCoefs} />
|
||||
<VariationEditor title={"Final Transform Variations"} variations={xformFinalVariations}
|
||||
setVariations={setXformFinalVariations} resetVariations={resetXformFinalVariations} />
|
||||
<CoefEditor title={"Final Transform Post"} isPost={true} coefs={xformFinalCoefsPost}
|
||||
setCoefs={setXformFinalCoefsPost} resetCoefs={resetXformFinalCoefsPost} />
|
||||
</>
|
||||
);
|
||||
}
|
46
blog/2024-11-15-playing-with-fire/2-transforms/FlamePost.tsx
Normal file
46
blog/2024-11-15-playing-with-fire/2-transforms/FlamePost.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { applyTransform } from "../src/applyTransform";
|
||||
import { Coefs, Transform } from "../src/transform";
|
||||
import * as params from "../src/params";
|
||||
import { PainterContext } from "../src/Canvas";
|
||||
import { chaosGameFinal, Props as ChaosGameFinalProps } from "./chaosGameFinal";
|
||||
import { CoefEditor } from "./CoefEditor";
|
||||
import { transformPost } from "./post";
|
||||
|
||||
export default function FlamePost() {
|
||||
const { width, height, setPainter } = useContext(PainterContext);
|
||||
|
||||
const [xform1CoefsPost, setXform1CoefsPost] = useState<Coefs>(params.xform1CoefsPost);
|
||||
const resetXform1CoefsPost = () => setXform1CoefsPost(params.xform1CoefsPost);
|
||||
|
||||
const [xform2CoefsPost, setXform2CoefsPost] = useState<Coefs>(params.xform2CoefsPost);
|
||||
const resetXform2CoefsPost = () => setXform2CoefsPost(params.xform2CoefsPost);
|
||||
|
||||
const [xform3CoefsPost, setXform3CoefsPost] = useState<Coefs>(params.xform3CoefsPost);
|
||||
const resetXform3CoefsPost = () => setXform3CoefsPost(params.xform3CoefsPost);
|
||||
|
||||
const identityXform: Transform = (x, y) => [x, y];
|
||||
|
||||
const gameParams: ChaosGameFinalProps = {
|
||||
width,
|
||||
height,
|
||||
transforms: [
|
||||
[params.xform1Weight, transformPost(applyTransform(params.xform1Coefs, params.xform1Variations), xform1CoefsPost)],
|
||||
[params.xform2Weight, transformPost(applyTransform(params.xform2Coefs, params.xform2Variations), xform2CoefsPost)],
|
||||
[params.xform3Weight, transformPost(applyTransform(params.xform3Coefs, params.xform3Variations), xform3CoefsPost)]
|
||||
],
|
||||
final: identityXform
|
||||
};
|
||||
useEffect(() => setPainter(chaosGameFinal(gameParams)), [xform1CoefsPost, xform2CoefsPost, xform3CoefsPost]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CoefEditor title={"Transform 1 Post"} isPost={true} coefs={xform1CoefsPost} setCoefs={setXform1CoefsPost}
|
||||
resetCoefs={resetXform1CoefsPost} />
|
||||
<CoefEditor title={"Transform 2 Post"} isPost={true} coefs={xform2CoefsPost} setCoefs={setXform2CoefsPost}
|
||||
resetCoefs={resetXform2CoefsPost} />
|
||||
<CoefEditor title={"Transform 3 Post"} isPost={true} coefs={xform3CoefsPost} setCoefs={setXform3CoefsPost}
|
||||
resetCoefs={resetXform3CoefsPost} />
|
||||
</>
|
||||
);
|
||||
}
|
@ -0,0 +1,45 @@
|
||||
import styles from "../src/css/styles.module.css";
|
||||
|
||||
export interface VariationProps {
|
||||
linear: number;
|
||||
julia: number;
|
||||
popcorn: number;
|
||||
pdj: number;
|
||||
}
|
||||
|
||||
export interface Props {
|
||||
title: String;
|
||||
variations: VariationProps;
|
||||
setVariations: (variations: VariationProps) => void;
|
||||
resetVariations: () => void;
|
||||
}
|
||||
|
||||
export const VariationEditor = ({ title, variations, setVariations, resetVariations }: Props) => {
|
||||
const resetButton = <button className={styles.inputReset} onClick={resetVariations}>Reset</button>;
|
||||
|
||||
return (
|
||||
<div className={styles.inputGroup} style={{ display: "grid", gridTemplateColumns: "1fr 1fr" }}>
|
||||
<p className={styles.inputTitle} style={{ gridColumn: "1/-1" }}>{title} {resetButton}</p>
|
||||
<div className={styles.inputElement}>
|
||||
<span>Linear: {variations.linear}</span>
|
||||
<input type={"range"} min={0} max={1} step={0.01} style={{ width: "100%" }} value={variations.linear}
|
||||
onInput={e => setVariations({ ...variations, linear: Number(e.currentTarget.value) })} />
|
||||
</div>
|
||||
<div className={styles.inputElement}>
|
||||
<span>Julia: {variations.julia}</span>
|
||||
<input type={"range"} min={0} max={1} step={0.01} style={{ width: "100%" }} value={variations.julia}
|
||||
onInput={e => setVariations({ ...variations, julia: Number(e.currentTarget.value) })} />
|
||||
</div>
|
||||
<div className={styles.inputElement}>
|
||||
<span>Popcorn: {variations.popcorn}</span>
|
||||
<input type={"range"} min={0} max={1} step={0.01} style={{ width: "100%" }} value={variations.popcorn}
|
||||
onInput={e => setVariations({ ...variations, popcorn: Number(e.currentTarget.value) })} />
|
||||
</div>
|
||||
<div className={styles.inputElement}>
|
||||
<span>PDJ: {variations.pdj}</span>
|
||||
<input type={"range"} min={0} max={1} step={0.01} style={{ width: "100%" }} value={variations.pdj}
|
||||
onInput={e => setVariations({ ...variations, pdj: Number(e.currentTarget.value) })} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
17
blog/2024-11-15-playing-with-fire/2-transforms/buildBlend.ts
Normal file
17
blog/2024-11-15-playing-with-fire/2-transforms/buildBlend.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { Coefs } from "../src/transform";
|
||||
import { VariationProps } from "./VariationEditor";
|
||||
import { linear } from "../src/linear";
|
||||
import { julia } from "../src/julia";
|
||||
import { popcorn } from "../src/popcorn";
|
||||
import { pdj } from "../src/pdj";
|
||||
import { pdjParams } from "../src/params";
|
||||
import { Blend } from "../src/blend";
|
||||
|
||||
export function buildBlend(coefs: Coefs, variations: VariationProps): Blend {
|
||||
return [
|
||||
[variations.linear, linear],
|
||||
[variations.julia, julia],
|
||||
[variations.popcorn, popcorn(coefs)],
|
||||
[variations.pdj, pdj(pdjParams)]
|
||||
];
|
||||
}
|
@ -0,0 +1,51 @@
|
||||
// hidden-start
|
||||
import { randomBiUnit } from "../src/randomBiUnit";
|
||||
import { randomChoice } from "../src/randomChoice";
|
||||
import { plotBinary as plot } from "../src/plotBinary";
|
||||
import { Transform } from "../src/transform";
|
||||
import { Props as WeightedProps } from "../1-introduction/chaosGameWeighted";
|
||||
|
||||
const quality = 0.5;
|
||||
const step = 1000;
|
||||
// hidden-end
|
||||
export type Props = WeightedProps & {
|
||||
final: Transform,
|
||||
}
|
||||
|
||||
export function* chaosGameFinal(
|
||||
{
|
||||
width,
|
||||
height,
|
||||
transforms,
|
||||
final
|
||||
}: Props
|
||||
) {
|
||||
let img =
|
||||
new ImageData(width, height);
|
||||
let [x, y] = [
|
||||
randomBiUnit(),
|
||||
randomBiUnit()
|
||||
];
|
||||
|
||||
const pixels = width * height;
|
||||
const iterations = quality * pixels;
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
const [_, transform] =
|
||||
randomChoice(transforms);
|
||||
[x, y] = transform(x, y);
|
||||
|
||||
// highlight-start
|
||||
const [finalX, finalY] = final(x, y);
|
||||
// highlight-end
|
||||
|
||||
if (i > 20)
|
||||
// highlight-start
|
||||
plot(finalX, finalY, img);
|
||||
// highlight-end
|
||||
|
||||
if (i % step === 0)
|
||||
yield img;
|
||||
}
|
||||
|
||||
yield img;
|
||||
}
|
216
blog/2024-11-15-playing-with-fire/2-transforms/index.mdx
Normal file
216
blog/2024-11-15-playing-with-fire/2-transforms/index.mdx
Normal file
@ -0,0 +1,216 @@
|
||||
---
|
||||
slug: 2024/11/playing-with-fire-transforms
|
||||
title: "Playing with fire: Transforms and variations"
|
||||
date: 2024-12-16 21:31:00
|
||||
authors: [bspeice]
|
||||
tags: []
|
||||
---
|
||||
|
||||
Now that we've learned about the chaos game, it's time to spice things up. Variations create the
|
||||
shapes and patterns that fractal flames are known for.
|
||||
|
||||
<!-- truncate -->
|
||||
|
||||
:::info
|
||||
This post uses [reference parameters](../params.flame) to demonstrate the fractal flame algorithm.
|
||||
If you're interested in tweaking the parameters, or creating your own, [Apophysis](https://sourceforge.net/projects/apophysis/)
|
||||
can load that file.
|
||||
:::
|
||||
|
||||
## Variations
|
||||
|
||||
:::note
|
||||
This post covers section 3 of the Fractal Flame Algorithm paper
|
||||
:::
|
||||
|
||||
import CodeBlock from '@theme/CodeBlock'
|
||||
|
||||
We previously introduced transforms as the "functions" of an "iterated function system," and showed how
|
||||
playing the chaos game gives us an image of Sierpinski's Gasket. Even though we used simple functions,
|
||||
the image it generates is intriguing. But what would happen if we used something more complex?
|
||||
|
||||
This leads us to the first big innovation of the fractal flame algorithm: adding non-linear functions
|
||||
after the affine transform. These functions are called "variations":
|
||||
|
||||
$$
|
||||
F_i(x, y) = V_j(a_i x + b_i y + c_i, d_i x + e_i y + f_i)
|
||||
$$
|
||||
|
||||
import variationSource from '!!raw-loader!../src/variation'
|
||||
|
||||
<CodeBlock language="typescript">{variationSource}</CodeBlock>
|
||||
|
||||
Just like transforms, variations ($V_j$) are functions that take in $(x, y)$ coordinates
|
||||
and give back new $(x, y)$ coordinates.
|
||||
However, the sky is the limit for what happens between input and output.
|
||||
The Fractal Flame paper lists 49 variation functions,
|
||||
and the official `flam3` implementation supports [98 different variations](https://github.com/scottdraves/flam3/blob/7fb50c82e90e051f00efcc3123d0e06de26594b2/variations.c).
|
||||
|
||||
To draw our reference image, we'll focus on just four:
|
||||
|
||||
### Linear (variation 0)
|
||||
|
||||
This variation is dead simple: return the $x$ and $y$ coordinates as-is.
|
||||
|
||||
$$
|
||||
V_0(x,y) = (x,y)
|
||||
$$
|
||||
|
||||
import linearSrc from '!!raw-loader!../src/linear'
|
||||
|
||||
<CodeBlock language={'typescript'}>{linearSrc}</CodeBlock>
|
||||
|
||||
:::tip
|
||||
In a way, we've already been using this variation! The transforms that define Sierpinski's Gasket
|
||||
apply the affine coefficients to the input point and use that as the output.
|
||||
:::
|
||||
|
||||
### Julia (variation 13)
|
||||
|
||||
This variation is a good example of a non-linear function. It uses both trigonometry
|
||||
and probability to produce interesting shapes:
|
||||
|
||||
$$
|
||||
\begin{align*}
|
||||
r &= \sqrt{x^2 + y^2} \\
|
||||
\theta &= \text{arctan}(x / y) \\
|
||||
\Omega &= \left\{
|
||||
\begin{array}{lr}
|
||||
0 \hspace{0.4cm} \text{w.p. } 0.5 \\
|
||||
\pi \hspace{0.4cm} \text{w.p. } 0.5 \\
|
||||
\end{array}
|
||||
\right\} \\
|
||||
|
||||
V_{13}(x, y) &= \sqrt{r} \cdot (\text{cos} ( \theta / 2 + \Omega ), \text{sin} ( \theta / 2 + \Omega ))
|
||||
\end{align*}
|
||||
$$
|
||||
|
||||
import juliaSrc from '!!raw-loader!../src/julia'
|
||||
|
||||
<CodeBlock language={'typescript'}>{juliaSrc}</CodeBlock>
|
||||
|
||||
### Popcorn (variation 17)
|
||||
|
||||
Some variations rely on knowing the transform's affine coefficients; they're called "dependent variations."
|
||||
For this variation, we use $c$ and $f$:
|
||||
|
||||
$$
|
||||
V_{17}(x,y) = (x + c\ \text{sin}(\text{tan }3y), y + f\ \text{sin}(\text{tan }3x))
|
||||
$$
|
||||
|
||||
import popcornSrc from '!!raw-loader!../src/popcorn'
|
||||
|
||||
<CodeBlock language={'typescript'}>{popcornSrc}</CodeBlock>
|
||||
|
||||
### PDJ (variation 24)
|
||||
|
||||
Some variations have extra parameters we can choose; they're called "parametric variations."
|
||||
For the PDJ variation, there are four extra parameters:
|
||||
|
||||
$$
|
||||
p_1 = \text{pdj.a} \hspace{0.1cm} p_2 = \text{pdj.b} \hspace{0.1cm} p_3 = \text{pdj.c} \hspace{0.1cm} p_4 = \text{pdj.d} \\
|
||||
V_{24} = (\text{sin}(p_1 y) - \text{cos}(p_2 x), \text{sin}(p_3 x) - \text{cos}(p_4 y))
|
||||
$$
|
||||
|
||||
import pdjSrc from '!!raw-loader!../src/pdj'
|
||||
|
||||
<CodeBlock language={'typescript'}>{pdjSrc}</CodeBlock>
|
||||
|
||||
## Blending
|
||||
|
||||
Now, one variation is fun, but we can also combine variations in a process called "blending."
|
||||
Each variation receives the same $x$ and $y$ inputs, and we add together each variation's $x$ and $y$ outputs.
|
||||
We'll also give each variation a weight ($v_{ij}$) that changes how much it contributes to the result:
|
||||
|
||||
$$
|
||||
F_i(x,y) = \sum_{j} v_{ij} V_j(x, y)
|
||||
$$
|
||||
|
||||
The formula looks intimidating, but it's not hard to implement:
|
||||
|
||||
import blendSource from "!!raw-loader!../src/blend";
|
||||
|
||||
<CodeBlock language={'typescript'}>{blendSource}</CodeBlock>
|
||||
|
||||
With that in place, we have enough to render a fractal flame. We'll use the same
|
||||
chaos game as before, but the new transforms and variations produce a dramatically different image:
|
||||
|
||||
:::tip
|
||||
Try using the variation weights to figure out which parts of the image each transform controls.
|
||||
:::
|
||||
|
||||
import {SquareCanvas} from "../src/Canvas";
|
||||
import FlameBlend from "./FlameBlend";
|
||||
|
||||
<SquareCanvas name={"flame_blend"}><FlameBlend/></SquareCanvas>
|
||||
|
||||
## Post transforms
|
||||
|
||||
Next, we'll introduce a second affine transform applied _after_ variation blending. This is called a "post transform."
|
||||
|
||||
We'll use some new variables, but the post transform should look familiar:
|
||||
|
||||
$$
|
||||
\begin{align*}
|
||||
P_i(x, y) &= (\alpha_i x + \beta_i y + \gamma_i, \delta_i x + \epsilon_i y + \zeta_i) \\
|
||||
F_i(x, y) &= P_i\left(\sum_{j} v_{ij} V_j(x, y)\right)
|
||||
\end{align*}
|
||||
$$
|
||||
|
||||
import postSource from '!!raw-loader!./post'
|
||||
|
||||
<CodeBlock language="typescript">{postSource}</CodeBlock>
|
||||
|
||||
The image below uses the same transforms/variations as the previous fractal flame,
|
||||
but allows changing the post-transform coefficients:
|
||||
|
||||
<details>
|
||||
<summary>If you want to test your understanding...</summary>
|
||||
|
||||
- What post-transform coefficients will give us the previous image?
|
||||
- What post-transform coefficients will give us a _mirrored_ image?
|
||||
</details>
|
||||
|
||||
import FlamePost from "./FlamePost";
|
||||
|
||||
<SquareCanvas name={"flame_post"}><FlamePost/></SquareCanvas>
|
||||
|
||||
## Final transforms
|
||||
|
||||
The last step is to introduce a "final transform" ($F_{final}$) that is applied
|
||||
regardless of which regular transform ($F_i$) the chaos game selects.
|
||||
It's just like a normal transform (composition of affine transform, variation blend, and post transform),
|
||||
but it doesn't affect the chaos game state.
|
||||
|
||||
After adding the final transform, our chaos game algorithm looks like this:
|
||||
|
||||
$$
|
||||
\begin{align*}
|
||||
&(x, y) = \text{random point in the bi-unit square} \\
|
||||
&\text{iterate } \{ \\
|
||||
&\hspace{1cm} i = \text{random integer from 0 to } n - 1 \\
|
||||
&\hspace{1cm} (x,y) = F_i(x,y) \\
|
||||
&\hspace{1cm} (x_f,y_f) = F_{final}(x,y) \\
|
||||
&\hspace{1cm} \text{plot}(x_f,y_f) \text{ if iterations} > 20 \\
|
||||
\}
|
||||
\end{align*}
|
||||
$$
|
||||
|
||||
import chaosGameFinalSource from "!!raw-loader!./chaosGameFinal"
|
||||
|
||||
<CodeBlock language="typescript">{chaosGameFinalSource}</CodeBlock>
|
||||
|
||||
This image uses the same normal/post transforms as above, but allows modifying
|
||||
the coefficients and variations of the final transform:
|
||||
|
||||
import FlameFinal from "./FlameFinal";
|
||||
|
||||
<SquareCanvas name={"flame_final"}><FlameFinal/></SquareCanvas>
|
||||
|
||||
## Summary
|
||||
|
||||
Variations are the fractal flame algorithm's first major innovation.
|
||||
By blending variation functions and post/final transforms, we generate unique images.
|
||||
|
||||
However, these images are grainy and unappealing. In the next post, we'll clean up
|
||||
the image quality and add some color.
|
11
blog/2024-11-15-playing-with-fire/2-transforms/post.ts
Normal file
11
blog/2024-11-15-playing-with-fire/2-transforms/post.ts
Normal file
@ -0,0 +1,11 @@
|
||||
// hidden-start
|
||||
import { applyCoefs, Coefs, Transform } from "../src/transform";
|
||||
// hidden-end
|
||||
export const transformPost = (
|
||||
transform: Transform,
|
||||
coefs: Coefs
|
||||
): Transform =>
|
||||
(x, y) => {
|
||||
[x, y] = transform(x, y);
|
||||
return applyCoefs(x, y, coefs);
|
||||
}
|
181
blog/2024-11-15-playing-with-fire/3-log-density/FlameColor.tsx
Normal file
181
blog/2024-11-15-playing-with-fire/3-log-density/FlameColor.tsx
Normal file
@ -0,0 +1,181 @@
|
||||
import React, { useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import * as params from "../src/params";
|
||||
import { PainterContext } from "../src/Canvas";
|
||||
import { colorFromPalette } from "./colorFromPalette";
|
||||
import { chaosGameColor, Props as ChaosGameColorProps, TransformColor } from "./chaosGameColor";
|
||||
|
||||
import styles from "../src/css/styles.module.css";
|
||||
import { histIndex } from "../src/camera";
|
||||
import { useColorMode } from "@docusaurus/theme-common";
|
||||
|
||||
type PaletteBarProps = {
|
||||
height: number;
|
||||
palette: number[];
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
export const PaletteBar: React.FC<PaletteBarProps> = ({ height, palette, children }) => {
|
||||
const sizingRef = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState(0);
|
||||
useEffect(() => {
|
||||
if (sizingRef) {
|
||||
setWidth(sizingRef.current.offsetWidth);
|
||||
}
|
||||
}, [sizingRef]);
|
||||
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const paletteImage = useMemo(() => {
|
||||
if (width === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const image = new ImageData(width, height);
|
||||
for (let x = 0; x < width; x++) {
|
||||
const colorIndex = x / width;
|
||||
const [r, g, b] = colorFromPalette(palette, colorIndex);
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
const pixelIndex = histIndex(x, y, width, 4);
|
||||
image.data[pixelIndex] = r * 0xff;
|
||||
image.data[pixelIndex + 1] = g * 0xff;
|
||||
image.data[pixelIndex + 2] = b * 0xff;
|
||||
image.data[pixelIndex + 3] = 0xff;
|
||||
}
|
||||
}
|
||||
|
||||
return image;
|
||||
}, [width, height, palette]);
|
||||
|
||||
useEffect(() => {
|
||||
if (canvasRef && paletteImage) {
|
||||
canvasRef.current.getContext("2d").putImageData(paletteImage, 0, 0);
|
||||
}
|
||||
}, [canvasRef, paletteImage]);
|
||||
|
||||
const canvasStyle = { filter: useColorMode().colorMode === "dark" ? "invert(1)" : "" };
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={sizingRef} style={{ width: "100%", height }}>
|
||||
{width > 0 ? <canvas ref={canvasRef} width={width} height={height} style={canvasStyle} /> : null}
|
||||
</div>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type ColorEditorProps = {
|
||||
title: string;
|
||||
palette: number[];
|
||||
transformColor: TransformColor;
|
||||
setTransformColor: (transformColor: TransformColor) => void;
|
||||
resetTransformColor: () => void;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
const ColorEditor: React.FC<ColorEditorProps> = (
|
||||
{
|
||||
title,
|
||||
palette,
|
||||
transformColor,
|
||||
setTransformColor,
|
||||
resetTransformColor,
|
||||
children
|
||||
}) => {
|
||||
const resetButton = <button className={styles.inputReset} onClick={resetTransformColor}>Reset</button>;
|
||||
|
||||
const [r, g, b] = colorFromPalette(palette, transformColor.color);
|
||||
const colorCss = `rgb(${Math.floor(r * 0xff)},${Math.floor(g * 0xff)},${Math.floor(b * 0xff)})`;
|
||||
|
||||
const { colorMode } = useColorMode();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.inputGroup} style={{ display: "grid", gridTemplateColumns: "2fr 2fr 1fr" }}>
|
||||
<p className={styles.inputTitle} style={{ gridColumn: "1/-1" }}>{title} {resetButton}</p>
|
||||
<div className={styles.inputElement}>
|
||||
<p>Color: {transformColor.color}</p>
|
||||
<input type={"range"} min={0} max={1} step={.001} value={transformColor.color}
|
||||
onInput={e => setTransformColor({ ...transformColor, color: Number(e.currentTarget.value) })} />
|
||||
</div>
|
||||
<div className={styles.inputElement}>
|
||||
<p>Speed: {transformColor.colorSpeed}</p>
|
||||
<input type={"range"} min={0} max={1} step={.001} value={transformColor.colorSpeed}
|
||||
onInput={e => setTransformColor({ ...transformColor, colorSpeed: Number(e.currentTarget.value) })} />
|
||||
</div>
|
||||
<div className={styles.inputElement} style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
backgroundColor: colorCss,
|
||||
filter: colorMode === "dark" ? "invert(1)" : ""
|
||||
}} />
|
||||
</div>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactElement;
|
||||
}
|
||||
export default function FlameColor({ children }: Props) {
|
||||
const { width, height, setPainter } = useContext(PainterContext);
|
||||
|
||||
const xform1ColorDefault: TransformColor = { color: params.xform1Color, colorSpeed: 0.5 };
|
||||
const [xform1Color, setXform1Color] = useState(xform1ColorDefault);
|
||||
const resetXform1Color = () => setXform1Color(xform1ColorDefault);
|
||||
|
||||
const xform2ColorDefault: TransformColor = { color: params.xform2Color, colorSpeed: 0.5 };
|
||||
const [xform2Color, setXform2Color] = useState(xform2ColorDefault);
|
||||
const resetXform2Color = () => setXform2Color(xform2ColorDefault);
|
||||
|
||||
const xform3ColorDefault: TransformColor = { color: params.xform3Color, colorSpeed: 0.5 };
|
||||
const [xform3Color, setXform3Color] = useState(xform3ColorDefault);
|
||||
const resetXform3Color = () => setXform3Color(xform3ColorDefault);
|
||||
|
||||
const xformFinalColorDefault: TransformColor = { color: params.xformFinalColor, colorSpeed: 0 };
|
||||
const [xformFinalColor, setXformFinalColor] = useState(xformFinalColorDefault);
|
||||
const resetXformFinalColor = () => setXformFinalColor(xformFinalColorDefault);
|
||||
|
||||
useEffect(() => {
|
||||
const gameParams: ChaosGameColorProps = {
|
||||
width,
|
||||
height,
|
||||
transforms: params.xforms,
|
||||
final: params.xformFinal,
|
||||
palette: params.palette,
|
||||
colors: [xform1Color, xform2Color, xform3Color],
|
||||
finalColor: xformFinalColor
|
||||
};
|
||||
setPainter(chaosGameColor(gameParams));
|
||||
}, [xform1Color, xform2Color, xform3Color, xformFinalColor]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PaletteBar height={40} palette={params.palette} />
|
||||
<ColorEditor
|
||||
title={"Transform 1"}
|
||||
palette={params.palette}
|
||||
transformColor={xform1Color}
|
||||
setTransformColor={setXform1Color}
|
||||
resetTransformColor={resetXform1Color} />
|
||||
<ColorEditor
|
||||
title={"Transform 2"}
|
||||
palette={params.palette}
|
||||
transformColor={xform2Color}
|
||||
setTransformColor={setXform2Color}
|
||||
resetTransformColor={resetXform2Color} />
|
||||
<ColorEditor
|
||||
title={"Transform 3"}
|
||||
palette={params.palette}
|
||||
transformColor={xform3Color}
|
||||
setTransformColor={setXform3Color}
|
||||
resetTransformColor={resetXform3Color} />
|
||||
<ColorEditor
|
||||
title={"Transform Final"}
|
||||
palette={params.palette}
|
||||
transformColor={xformFinalColor}
|
||||
setTransformColor={setXformFinalColor}
|
||||
resetTransformColor={resetXformFinalColor} />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
@ -0,0 +1,25 @@
|
||||
import React, { useContext, useEffect } from "react";
|
||||
import { xformFinal as final, xforms as transforms } from "../src/params";
|
||||
import { PainterContext } from "../src/Canvas";
|
||||
import { chaosGameHistogram } from "./chaosGameHistogram";
|
||||
|
||||
type Props = {
|
||||
paint: (width: number, height: number, histogram: number[]) => ImageData;
|
||||
children?: React.ReactElement;
|
||||
}
|
||||
export default function FlameHistogram({ paint, children }: Props) {
|
||||
const { width, height, setPainter } = useContext(PainterContext);
|
||||
|
||||
useEffect(() => {
|
||||
const gameParams = {
|
||||
width,
|
||||
height,
|
||||
transforms,
|
||||
final,
|
||||
paint
|
||||
};
|
||||
setPainter(chaosGameHistogram(gameParams));
|
||||
}, [width, height]);
|
||||
|
||||
return children;
|
||||
}
|
@ -0,0 +1,135 @@
|
||||
// hidden-start
|
||||
import { Props as ChaosGameFinalProps } from "../2-transforms/chaosGameFinal";
|
||||
import { randomBiUnit } from "../src/randomBiUnit";
|
||||
import { randomChoice } from "../src/randomChoice";
|
||||
import { camera, histIndex } from "../src/camera";
|
||||
import { colorFromPalette } from "./colorFromPalette";
|
||||
import { mixColor } from "./mixColor";
|
||||
import { paintColor } from "./paintColor";
|
||||
|
||||
const quality = 15;
|
||||
const step = 100_000;
|
||||
// hidden-end
|
||||
export type TransformColor = {
|
||||
color: number;
|
||||
colorSpeed: number;
|
||||
}
|
||||
|
||||
export type Props = ChaosGameFinalProps & {
|
||||
palette: number[];
|
||||
colors: TransformColor[];
|
||||
finalColor: TransformColor;
|
||||
}
|
||||
|
||||
export function* chaosGameColor(
|
||||
{
|
||||
width,
|
||||
height,
|
||||
transforms,
|
||||
final,
|
||||
palette,
|
||||
colors,
|
||||
finalColor
|
||||
}: Props
|
||||
) {
|
||||
const pixels = width * height;
|
||||
|
||||
// highlight-start
|
||||
const imgRed = Array<number>(pixels)
|
||||
.fill(0);
|
||||
const imgGreen = Array<number>(pixels)
|
||||
.fill(0);
|
||||
const imgBlue = Array<number>(pixels)
|
||||
.fill(0);
|
||||
const imgAlpha = Array<number>(pixels)
|
||||
.fill(0);
|
||||
|
||||
const plotColor = (
|
||||
x: number,
|
||||
y: number,
|
||||
c: number
|
||||
) => {
|
||||
const [pixelX, pixelY] =
|
||||
camera(x, y, width);
|
||||
|
||||
if (
|
||||
pixelX < 0 ||
|
||||
pixelX >= width ||
|
||||
pixelY < 0 ||
|
||||
pixelY >= width
|
||||
)
|
||||
return;
|
||||
|
||||
const hIndex =
|
||||
histIndex(pixelX, pixelY, width, 1);
|
||||
|
||||
const [r, g, b] =
|
||||
colorFromPalette(palette, c);
|
||||
|
||||
imgRed[hIndex] += r;
|
||||
imgGreen[hIndex] += g;
|
||||
imgBlue[hIndex] += b;
|
||||
imgAlpha[hIndex] += 1;
|
||||
}
|
||||
// highlight-end
|
||||
|
||||
let [x, y] = [
|
||||
randomBiUnit(),
|
||||
randomBiUnit()
|
||||
];
|
||||
let c = Math.random();
|
||||
|
||||
const iterations = quality * pixels;
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
const [transformIndex, transform] =
|
||||
randomChoice(transforms);
|
||||
[x, y] = transform(x, y);
|
||||
|
||||
// highlight-start
|
||||
const transformColor =
|
||||
colors[transformIndex];
|
||||
|
||||
c = mixColor(
|
||||
c,
|
||||
transformColor.color,
|
||||
transformColor.colorSpeed
|
||||
);
|
||||
// highlight-end
|
||||
|
||||
const [finalX, finalY] = final(x, y);
|
||||
|
||||
// highlight-start
|
||||
const finalC = mixColor(
|
||||
c,
|
||||
finalColor.color,
|
||||
finalColor.colorSpeed
|
||||
);
|
||||
// highlight-end
|
||||
|
||||
if (i > 20)
|
||||
plotColor(
|
||||
finalX,
|
||||
finalY,
|
||||
finalC
|
||||
)
|
||||
|
||||
if (i % step === 0)
|
||||
yield paintColor(
|
||||
width,
|
||||
height,
|
||||
imgRed,
|
||||
imgGreen,
|
||||
imgBlue,
|
||||
imgAlpha
|
||||
);
|
||||
}
|
||||
|
||||
yield paintColor(
|
||||
width,
|
||||
height,
|
||||
imgRed,
|
||||
imgGreen,
|
||||
imgBlue,
|
||||
imgAlpha
|
||||
);
|
||||
}
|
@ -0,0 +1,78 @@
|
||||
// hidden-start
|
||||
import { randomBiUnit } from "../src/randomBiUnit";
|
||||
import { randomChoice } from "../src/randomChoice";
|
||||
import { Props as ChaosGameFinalProps } from "../2-transforms/chaosGameFinal";
|
||||
import { camera, histIndex } from "../src/camera";
|
||||
|
||||
const quality = 10;
|
||||
const step = 100_000;
|
||||
// hidden-end
|
||||
type Props = ChaosGameFinalProps & {
|
||||
paint: (
|
||||
width: number,
|
||||
height: number,
|
||||
histogram: number[]
|
||||
) => ImageData;
|
||||
}
|
||||
|
||||
export function* chaosGameHistogram(
|
||||
{
|
||||
width,
|
||||
height,
|
||||
transforms,
|
||||
final,
|
||||
paint
|
||||
}: Props
|
||||
) {
|
||||
const pixels = width * height;
|
||||
const iterations = quality * pixels;
|
||||
|
||||
// highlight-start
|
||||
const hist = Array<number>(pixels)
|
||||
.fill(0);
|
||||
|
||||
const plotHist = (
|
||||
x: number,
|
||||
y: number
|
||||
) => {
|
||||
const [pixelX, pixelY] =
|
||||
camera(x, y, width);
|
||||
|
||||
if (
|
||||
pixelX < 0 ||
|
||||
pixelX >= width ||
|
||||
pixelY < 0 ||
|
||||
pixelY >= height
|
||||
)
|
||||
return;
|
||||
|
||||
const hIndex =
|
||||
histIndex(pixelX, pixelY, width, 1);
|
||||
|
||||
hist[hIndex] += 1;
|
||||
};
|
||||
// highlight-end
|
||||
|
||||
let [x, y] = [
|
||||
randomBiUnit(),
|
||||
randomBiUnit()
|
||||
];
|
||||
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
const [_, transform] =
|
||||
randomChoice(transforms);
|
||||
[x, y] = transform(x, y);
|
||||
const [finalX, finalY] = final(x, y);
|
||||
|
||||
if (i > 20) {
|
||||
// highlight-start
|
||||
plotHist(finalX, finalY);
|
||||
// highlight-end
|
||||
}
|
||||
|
||||
if (i % step === 0)
|
||||
yield paint(width, height, hist);
|
||||
}
|
||||
|
||||
yield paint(width, height, hist);
|
||||
}
|
@ -0,0 +1,14 @@
|
||||
export function colorFromPalette(
|
||||
palette: number[],
|
||||
colorIndex: number
|
||||
): [number, number, number] {
|
||||
const numColors = palette.length / 3;
|
||||
const paletteIndex = Math.floor(
|
||||
colorIndex * (numColors)
|
||||
) * 3;
|
||||
return [
|
||||
palette[paletteIndex], // red
|
||||
palette[paletteIndex + 1], // green
|
||||
palette[paletteIndex + 2] // blue
|
||||
];
|
||||
}
|
222
blog/2024-11-15-playing-with-fire/3-log-density/index.mdx
Normal file
222
blog/2024-11-15-playing-with-fire/3-log-density/index.mdx
Normal file
@ -0,0 +1,222 @@
|
||||
---
|
||||
slug: 2024/11/playing-with-fire-log-density
|
||||
title: "Playing with fire: Tone mapping and color"
|
||||
date: 2024-12-16 21:32:00
|
||||
authors: [bspeice]
|
||||
tags: []
|
||||
---
|
||||
|
||||
So far, our `plot()` function has been fairly simple: map a fractal flame coordinate to a specific pixel,
|
||||
and color in that pixel. This works well for simple function systems (like Sierpinski's Gasket),
|
||||
but more complex systems (like the reference parameters) produce grainy images.
|
||||
|
||||
In this post, we'll refine the image quality and add color to really make things shine.
|
||||
|
||||
<!-- truncate -->
|
||||
|
||||
## Image histograms
|
||||
|
||||
:::note
|
||||
This post covers sections 4 and 5 of the Fractal Flame Algorithm paper
|
||||
:::
|
||||
|
||||
One problem with the current chaos game algorithm is that we waste work
|
||||
because pixels are either "on" (opaque) or "off" (transparent).
|
||||
If the chaos game encounters the same pixel twice, nothing changes.
|
||||
|
||||
To demonstrate how much work is wasted, we'll count each time the chaos game
|
||||
visits a pixel while iterating. This gives us a kind of image "histogram":
|
||||
|
||||
import chaosGameHistogramSource from "!!raw-loader!./chaosGameHistogram"
|
||||
|
||||
<CodeBlock language="typescript">{chaosGameHistogramSource}</CodeBlock>
|
||||
|
||||
When the chaos game finishes, we find the pixel encountered most often.
|
||||
Finally, we "paint" the image by setting each pixel's alpha (transparency) value
|
||||
to the ratio of times visited divided by the maximum:
|
||||
|
||||
import CodeBlock from "@theme/CodeBlock";
|
||||
|
||||
import paintLinearSource from "!!raw-loader!./paintLinear"
|
||||
|
||||
<CodeBlock language="typescript">{paintLinearSource}</CodeBlock>
|
||||
|
||||
import {SquareCanvas} from "../src/Canvas";
|
||||
import FlameHistogram from "./FlameHistogram";
|
||||
import {paintLinear} from "./paintLinear";
|
||||
|
||||
<SquareCanvas><FlameHistogram paint={paintLinear}/></SquareCanvas>
|
||||
|
||||
## Tone mapping
|
||||
|
||||
While using a histogram reduces the "graining," it also leads to some parts vanishing entirely.
|
||||
In the reference parameters, the outer circle is still there, but the interior is gone!
|
||||
|
||||
To fix this, we'll introduce the second major innovation of the fractal flame algorithm: [tone mapping](https://en.wikipedia.org/wiki/Tone_mapping).
|
||||
This is a technique used in computer graphics to compensate for differences in how
|
||||
computers represent brightness, and how people actually see brightness.
|
||||
|
||||
As a concrete example, high-dynamic-range (HDR) photography uses this technique to capture
|
||||
scenes with a wide range of brightnesses. To take a picture of something dark,
|
||||
you need a long exposure time. However, long exposures lead to "hot spots" (sections that are pure white).
|
||||
By taking multiple pictures with different exposure times, we can combine them to create
|
||||
a final image where everything is visible.
|
||||
|
||||
In fractal flames, this "tone map" is accomplished by scaling brightness according to the _logarithm_
|
||||
of how many times we encounter a pixel. This way, "cold spots" (pixels the chaos game visits infrequently)
|
||||
are still visible, and "hot spots" (pixels the chaos game visits frequently) won't wash out.
|
||||
|
||||
<details>
|
||||
<summary>Log-scale vibrancy also explains fractal flames appear to be 3D...</summary>
|
||||
|
||||
As mentioned in the paper:
|
||||
|
||||
> Where one branch of the fractal crosses another, one may appear to occlude the other
|
||||
> if their densities are different enough because the lesser density is inconsequential in sum.
|
||||
> For example, branches of densities 1000 and 100 might have brightnesses of 30 and 20.
|
||||
> Where they cross the density is 1100, whose brightness is 30.4, which is
|
||||
> hardly distinguishable from 30.
|
||||
</details>
|
||||
|
||||
import paintLogarithmicSource from "!!raw-loader!./paintLogarithmic"
|
||||
|
||||
<CodeBlock language="typescript">{paintLogarithmicSource}</CodeBlock>
|
||||
|
||||
import {paintLogarithmic} from './paintLogarithmic'
|
||||
|
||||
<SquareCanvas><FlameHistogram paint={paintLogarithmic}/></SquareCanvas>
|
||||
|
||||
## Color
|
||||
|
||||
Now we'll introduce the last innovation of the fractal flame algorithm: color.
|
||||
By including a third coordinate ($c$) in the chaos game, we can illustrate the transforms
|
||||
responsible for the image.
|
||||
|
||||
### Color coordinate
|
||||
|
||||
Color in a fractal flame is continuous on the range $[0, 1]$. This is important for two reasons:
|
||||
|
||||
- It helps blend colors together in the final image. Slight changes in the color value lead to
|
||||
slight changes in the actual color
|
||||
- It allows us to swap in new color palettes easily. We're free to choose what actual colors
|
||||
each value represents
|
||||
|
||||
We'll give each transform a color value ($c_i$) in the $[0, 1]$ range.
|
||||
The final transform gets a value too ($c_f$).
|
||||
Then, at each step in the chaos game, we'll set the current color
|
||||
by blending it with the previous color:
|
||||
|
||||
$$
|
||||
\begin{align*}
|
||||
&(x, y) = \text{random point in the bi-unit square} \\
|
||||
&c = \text{random point from [0,1]} \\
|
||||
&\text{iterate } \{ \\
|
||||
&\hspace{1cm} i = \text{random integer from 0 to } n - 1 \\
|
||||
&\hspace{1cm} (x,y) = F_i(x,y) \\
|
||||
&\hspace{1cm} (x_f,y_f) = F_{final}(x,y) \\
|
||||
&\hspace{1cm} c = (c + c_i) / 2 \\
|
||||
&\hspace{1cm} \text{plot}(x_f,y_f,c_f) \text{ if iterations} > 20 \\
|
||||
\}
|
||||
\end{align*}
|
||||
$$
|
||||
|
||||
### Color speed
|
||||
|
||||
:::warning
|
||||
Color speed isn't introduced in the Fractal Flame Algorithm paper.
|
||||
|
||||
It is included here because [`flam3` implements it](https://github.com/scottdraves/flam3/blob/7fb50c82e90e051f00efcc3123d0e06de26594b2/variations.c#L2140),
|
||||
and because it's fun to play with.
|
||||
:::
|
||||
|
||||
Next, we'll add a parameter to each transform that controls how much it changes the current color.
|
||||
This is known as the "color speed" ($s_i$):
|
||||
|
||||
$$
|
||||
c = c \cdot (1 - s_i) + c_i \cdot s_i
|
||||
$$
|
||||
|
||||
import mixColorSource from "!!raw-loader!./mixColor"
|
||||
|
||||
<CodeBlock language="typescript">{mixColorSource}</CodeBlock>
|
||||
|
||||
Color speed values work just like transform weights. A value of 1
|
||||
means we take the transform color and ignore the previous color state.
|
||||
A value of 0 means we keep the current color state and ignore the
|
||||
transform color.
|
||||
|
||||
### Palette
|
||||
|
||||
Now, we need to map the color coordinate to a pixel color. Fractal flames typically use
|
||||
256 colors (each color has 3 values - red, green, blue) to define a palette.
|
||||
The color coordinate then becomes an index into the palette.
|
||||
|
||||
There's one small complication: the color coordinate is continuous, but the palette
|
||||
uses discrete colors. How do we handle situations where the color coordinate is
|
||||
"in between" the colors of our palette?
|
||||
|
||||
One way to handle this is a step function. In the code below, we multiply the color coordinate
|
||||
by the number of colors in the palette, then truncate that value. This gives us a discrete index:
|
||||
|
||||
import colorFromPaletteSource from "!!raw-loader!./colorFromPalette";
|
||||
|
||||
<CodeBlock language="typescript">{colorFromPaletteSource}</CodeBlock>
|
||||
|
||||
<details>
|
||||
<summary>As an alternative...</summary>
|
||||
|
||||
...you could interpolate between colors in the palette.
|
||||
For example, `flam3` uses [linear interpolation](https://github.com/scottdraves/flam3/blob/7fb50c82e90e051f00efcc3123d0e06de26594b2/rect.c#L483-L486)
|
||||
</details>
|
||||
|
||||
In the diagram below, each color in the palette is plotted on a small vertical strip.
|
||||
Putting the strips side by side shows the full palette used by the reference parameters:
|
||||
|
||||
import * as params from "../src/params"
|
||||
import {PaletteBar} from "./FlameColor"
|
||||
|
||||
<PaletteBar height="40" palette={params.palette}/>
|
||||
|
||||
### Plotting
|
||||
|
||||
We're now ready to plot our $(x_f,y_f,c_f)$ coordinates. This time, we'll use a histogram
|
||||
for each color channel (red, green, blue, alpha). After translating from color coordinate ($c_f$)
|
||||
to RGB value, add that to the histogram:
|
||||
|
||||
import chaosGameColorSource from "!!raw-loader!./chaosGameColor"
|
||||
|
||||
<CodeBlock language="typescript">{chaosGameColorSource}</CodeBlock>
|
||||
|
||||
Finally, painting the image. With tone mapping, logarithms scale the image brightness to match
|
||||
how it is perceived. With color, we use a similar method, but scale each color channel
|
||||
by the alpha channel:
|
||||
|
||||
import paintColorSource from "!!raw-loader!./paintColor"
|
||||
|
||||
<CodeBlock language="typescript">{paintColorSource}</CodeBlock>
|
||||
|
||||
And now, at long last, a full-color fractal flame:
|
||||
|
||||
import FlameColor from "./FlameColor";
|
||||
|
||||
<SquareCanvas><FlameColor/></SquareCanvas>
|
||||
|
||||
## Summary
|
||||
|
||||
Tone mapping is the second major innovation of the fractal flame algorithm.
|
||||
By tracking how often the chaos game encounters each pixel, we can adjust
|
||||
brightness/transparency to reduce the visual "graining" of previous images.
|
||||
|
||||
Next, introducing a third coordinate to the chaos game makes color images possible,
|
||||
the third major innovation of the fractal flame algorithm. Using a continuous
|
||||
color scale and color palette adds a splash of excitement to the image.
|
||||
|
||||
The Fractal Flame Algorithm paper goes on to describe more techniques
|
||||
not covered here. For example, image quality can be improved with density estimation
|
||||
and filtering. New parameters can be generated by "mutating" existing
|
||||
fractal flames. And fractal flames can even be animated to produce videos!
|
||||
|
||||
That said, I think this is a good place to wrap up. We went from
|
||||
an introduction to the mathematics of fractal systems all the way to
|
||||
generating full-color images. Fractal flames are a challenging topic,
|
||||
but it's extremely rewarding to learn about how they work.
|
@ -0,0 +1,8 @@
|
||||
export function mixColor(
|
||||
color1: number,
|
||||
color2: number,
|
||||
colorSpeed: number
|
||||
) {
|
||||
return color1 * (1 - colorSpeed) +
|
||||
color2 * colorSpeed;
|
||||
}
|
@ -0,0 +1,34 @@
|
||||
export function paintColor(
|
||||
width: number,
|
||||
height: number,
|
||||
red: number[],
|
||||
green: number[],
|
||||
blue: number[],
|
||||
alpha: number[]
|
||||
): ImageData {
|
||||
const pixels = width * height;
|
||||
const img =
|
||||
new ImageData(width, height);
|
||||
|
||||
for (let i = 0; i < pixels; i++) {
|
||||
const scale =
|
||||
Math.log10(alpha[i]) /
|
||||
(alpha[i] * 1.5);
|
||||
|
||||
const pixelIndex = i * 4;
|
||||
|
||||
const rVal = red[i] * scale * 0xff;
|
||||
img.data[pixelIndex] = rVal;
|
||||
|
||||
const gVal = green[i] * scale * 0xff;
|
||||
img.data[pixelIndex + 1] = gVal;
|
||||
|
||||
const bVal = blue[i] * scale * 0xff;
|
||||
img.data[pixelIndex + 2] = bVal;
|
||||
|
||||
const aVal = alpha[i] * scale * 0xff;
|
||||
img.data[pixelIndex + 3] = aVal;
|
||||
}
|
||||
|
||||
return img;
|
||||
}
|
@ -0,0 +1,26 @@
|
||||
export function paintLinear(
|
||||
width: number,
|
||||
height: number,
|
||||
hist: number[]
|
||||
) {
|
||||
const img =
|
||||
new ImageData(width, height);
|
||||
|
||||
let hMax = 0;
|
||||
for (let value of hist) {
|
||||
hMax = Math.max(hMax, value);
|
||||
}
|
||||
|
||||
for (let i = 0; i < hist.length; i++) {
|
||||
const pixelIndex = i * 4;
|
||||
|
||||
img.data[pixelIndex] = 0;
|
||||
img.data[pixelIndex + 1] = 0;
|
||||
img.data[pixelIndex + 2] = 0;
|
||||
|
||||
const alpha = hist[i] / hMax * 0xff;
|
||||
img.data[pixelIndex + 3] = alpha;
|
||||
}
|
||||
|
||||
return img;
|
||||
}
|
@ -0,0 +1,29 @@
|
||||
export function paintLogarithmic(
|
||||
width: number,
|
||||
height: number,
|
||||
hist: number[]
|
||||
) {
|
||||
const img =
|
||||
new ImageData(width, height);
|
||||
|
||||
const histLog = hist.map(Math.log);
|
||||
|
||||
let hLogMax = -Infinity;
|
||||
for (let value of histLog) {
|
||||
hLogMax = Math.max(hLogMax, value);
|
||||
}
|
||||
|
||||
for (let i = 0; i < hist.length; i++) {
|
||||
const pixelIndex = i * 4;
|
||||
|
||||
img.data[pixelIndex] = 0; // red
|
||||
img.data[pixelIndex + 1] = 0; // green
|
||||
img.data[pixelIndex + 2] = 0; // blue
|
||||
|
||||
const alpha =
|
||||
histLog[i] / hLogMax * 0xff;
|
||||
img.data[pixelIndex + 3] = alpha;
|
||||
}
|
||||
|
||||
return img;
|
||||
}
|
BIN
blog/2024-11-15-playing-with-fire/banner.png
Normal file
BIN
blog/2024-11-15-playing-with-fire/banner.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 418 KiB |
120
blog/2024-11-15-playing-with-fire/params.flame
Normal file
120
blog/2024-11-15-playing-with-fire/params.flame
Normal file
@ -0,0 +1,120 @@
|
||||
<Flames name="params">
|
||||
<flame name="post xform" version="Apophysis 2.08 beta" size="600 600" center="0 0" scale="150" oversample="1" filter="0.2" quality="1" background="0 0 0" brightness="4" gamma="4" >
|
||||
<xform weight="0.564534951145298" color="0" julia="1" coefs="-1.381068 1.381068 -1.381068 -1.381068 0 0" post="1 0 0 1 2 0"/>
|
||||
<xform weight="0.0131350067581356" color="0" linear="1" popcorn="1" coefs="0.031393 -0.031367 0.031367 0.031393 0 0" post="1 0 0 1 0.24 0.27" />
|
||||
<xform weight="0.422330042096567" color="0" pdj="1" coefs="1.51523 0.740356 -3.048677 -1.455964 0.724135 -0.362059" pdj_a="1.09358" pdj_b="2.13048" pdj_c="2.54127" pdj_d="2.37267" />
|
||||
<palette count="256" format="RGB">
|
||||
3130323635383B3A3D403F424644484B494D504E52565358
|
||||
5B585D605D626562686B676D706C737571787B767D807B83
|
||||
8580888A858D908A93958F989A949DA099A3A59EA8AAA3AD
|
||||
AFA8B3B5ADB8BAB2BEBFB7C3C5BCC8CAC1CECFC6D3D4CBD8
|
||||
DAD0DEDFD5E3DFD2E0DFCEDDE0CBDAE0C8D7E0C4D3E0C1D0
|
||||
E1BECDE1BBCAE1B7C7E1B4C4E1B1C1E2ADBEE2AABAE2A7B7
|
||||
E2A3B4E2A0B1E39DAEE399ABE396A8E393A5E490A1E48C9E
|
||||
E4899BE48698E48295E57F92E57C8FE5788CE57589E57285
|
||||
E66E82E66B7FE6687CE66479E76176E75E73E75B70E7576C
|
||||
E75469E85166E84D63E84A60E4495EE0485CDC475BD84659
|
||||
D44557D04455CB4353C74252C34150BF404EBB3F4CB73E4B
|
||||
B33D49AF3C47AB3B45A73A43A339429F38409B373E97363C
|
||||
92353A8E34398A33378632358231337E30327A2F30762E2E
|
||||
722D2C6E2C2A6A2B29662A276229255E2823592721552620
|
||||
51251E4D241C49231A4522194121173D20153C1F153A1F14
|
||||
391E14381E14361D14351C13341C13321B13311B132F1A12
|
||||
2E19122D19122B18122A1811291711271611261611251510
|
||||
23151022141021140F1F130F1E120F1C120F1B110E1A110E
|
||||
18100E170F0E160F0D140E0D130E0D120D0D100C0C0F0C0C
|
||||
0E0B0C0C0B0C0B0A0B09090B08090B07080B05080A04070A
|
||||
0606090804090A03088C46728A457087446D85436B824369
|
||||
8042667D41647B4061793F5F763E5D743D5A713D586F3C56
|
||||
6C3B536A3A5168394F65384C63374A6037485E36455B3543
|
||||
59344057333E54323C5231394F31374D30354A2F32482E30
|
||||
462D2E432C2B412B293E2B273C2A2439292237281F35271D
|
||||
32261B3025182D25162B241428231126220F25210F24210E
|
||||
23200E221F0E221E0D211E0D201D0D1F1C0D1E1B0C1D1B0C
|
||||
1C1A0C1B190B1B180B1A180B19170A18160A17150A161509
|
||||
1514091413091413081312081211081110081010070F0F07
|
||||
0E0E070D0D060C0D060C0C060B0B050A0A05090A05080904
|
||||
070804060704050704050603040503030403020402010302
|
||||
0608070C0D0D1112121617171B1C1D2121222626272B2B2D
|
||||
</palette>
|
||||
</flame>
|
||||
<flame name="baseline" version="Apophysis 2.08 beta" size="600 600" center="0 0" scale="150" oversample="1" filter="0.2" quality="1" background="0 0 0" brightness="4" gamma="4" >
|
||||
<xform weight="0.564534951145298" color="0.13" julia="1" coefs="-1.381068 1.381068 -1.381068 -1.381068 0 0" />
|
||||
<xform weight="0.0131350067581356" color="0.844" linear="1" popcorn="1" coefs="0.031393 -0.031367 0.031367 0.031393 0 0" />
|
||||
<xform weight="0.422330042096567" color="0" pdj="1" coefs="1.51523 0.740356 -3.048677 -1.455964 0.724135 -0.362059" pdj_a="1.09358" pdj_b="2.13048" pdj_c="2.54127" pdj_d="2.37267" />
|
||||
<palette count="256" format="RGB">
|
||||
3130323635383B3A3D403F424644484B494D504E52565358
|
||||
5B585D605D626562686B676D706C737571787B767D807B83
|
||||
8580888A858D908A93958F989A949DA099A3A59EA8AAA3AD
|
||||
AFA8B3B5ADB8BAB2BEBFB7C3C5BCC8CAC1CECFC6D3D4CBD8
|
||||
DAD0DEDFD5E3DFD2E0DFCEDDE0CBDAE0C8D7E0C4D3E0C1D0
|
||||
E1BECDE1BBCAE1B7C7E1B4C4E1B1C1E2ADBEE2AABAE2A7B7
|
||||
E2A3B4E2A0B1E39DAEE399ABE396A8E393A5E490A1E48C9E
|
||||
E4899BE48698E48295E57F92E57C8FE5788CE57589E57285
|
||||
E66E82E66B7FE6687CE66479E76176E75E73E75B70E7576C
|
||||
E75469E85166E84D63E84A60E4495EE0485CDC475BD84659
|
||||
D44557D04455CB4353C74252C34150BF404EBB3F4CB73E4B
|
||||
B33D49AF3C47AB3B45A73A43A339429F38409B373E97363C
|
||||
92353A8E34398A33378632358231337E30327A2F30762E2E
|
||||
722D2C6E2C2A6A2B29662A276229255E2823592721552620
|
||||
51251E4D241C49231A4522194121173D20153C1F153A1F14
|
||||
391E14381E14361D14351C13341C13321B13311B132F1A12
|
||||
2E19122D19122B18122A1811291711271611261611251510
|
||||
23151022141021140F1F130F1E120F1C120F1B110E1A110E
|
||||
18100E170F0E160F0D140E0D130E0D120D0D100C0C0F0C0C
|
||||
0E0B0C0C0B0C0B0A0B09090B08090B07080B05080A04070A
|
||||
0606090804090A03088C46728A457087446D85436B824369
|
||||
8042667D41647B4061793F5F763E5D743D5A713D586F3C56
|
||||
6C3B536A3A5168394F65384C63374A6037485E36455B3543
|
||||
59344057333E54323C5231394F31374D30354A2F32482E30
|
||||
462D2E432C2B412B293E2B273C2A2439292237281F35271D
|
||||
32261B3025182D25162B241428231126220F25210F24210E
|
||||
23200E221F0E221E0D211E0D201D0D1F1C0D1E1B0C1D1B0C
|
||||
1C1A0C1B190B1B180B1A180B19170A18160A17150A161509
|
||||
1514091413091413081312081211081110081010070F0F07
|
||||
0E0E070D0D060C0D060C0C060B0B050A0A05090A05080904
|
||||
070804060704050704050603040503030403020402010302
|
||||
0608070C0D0D1112121617171B1C1D2121222626272B2B2D
|
||||
</palette>
|
||||
</flame>
|
||||
<flame name="final xform" version="Apophysis 2.08 beta" size="600 600" center="0 0" scale="150" oversample="1" filter="0.2" quality="1" background="1 1 1" brightness="4" gamma="4" >
|
||||
<xform weight="0.564534951145298" color="0" julia="1" coefs="-1.381068 1.381068 -1.381068 -1.381068 0 0" />
|
||||
<xform weight="0.0131350067581356" color="0.766" linear="1" popcorn="1" coefs="0.031393 -0.031367 0.031367 0.031393 0 0" post="1 0 0 1 0.24 0.27" />
|
||||
<xform weight="0.422330042096567" color="0.349" pdj="1" coefs="1.51523 0.740356 -3.048677 -1.455964 0.724135 -0.362059" pdj_a="1.09358" pdj_b="2.13048" pdj_c="2.54127" pdj_d="2.37267" />
|
||||
<finalxform color="0" symmetry="1" julia="1" coefs="2 0 0 2 0 0" />
|
||||
<palette count="256" format="RGB">
|
||||
3130323635383B3A3D403F424644484B494D504E52565358
|
||||
5B585D605D626562686B676D706C737571787B767D807B83
|
||||
8580888A858D908A93958F989A949DA099A3A59EA8AAA3AD
|
||||
AFA8B3B5ADB8BAB2BEBFB7C3C5BCC8CAC1CECFC6D3D4CBD8
|
||||
DAD0DEDFD5E3DFD2E0DFCEDDE0CBDAE0C8D7E0C4D3E0C1D0
|
||||
E1BECDE1BBCAE1B7C7E1B4C4E1B1C1E2ADBEE2AABAE2A7B7
|
||||
E2A3B4E2A0B1E39DAEE399ABE396A8E393A5E490A1E48C9E
|
||||
E4899BE48698E48295E57F92E57C8FE5788CE57589E57285
|
||||
E66E82E66B7FE6687CE66479E76176E75E73E75B70E7576C
|
||||
E75469E85166E84D63E84A60E4495EE0485CDC475BD84659
|
||||
D44557D04455CB4353C74252C34150BF404EBB3F4CB73E4B
|
||||
B33D49AF3C47AB3B45A73A43A339429F38409B373E97363C
|
||||
92353A8E34398A33378632358231337E30327A2F30762E2E
|
||||
722D2C6E2C2A6A2B29662A276229255E2823592721552620
|
||||
51251E4D241C49231A4522194121173D20153C1F153A1F14
|
||||
391E14381E14361D14351C13341C13321B13311B132F1A12
|
||||
2E19122D19122B18122A1811291711271611261611251510
|
||||
23151022141021140F1F130F1E120F1C120F1B110E1A110E
|
||||
18100E170F0E160F0D140E0D130E0D120D0D100C0C0F0C0C
|
||||
0E0B0C0C0B0C0B0A0B09090B08090B07080B05080A04070A
|
||||
0606090804090A03088C46728A457087446D85436B824369
|
||||
8042667D41647B4061793F5F763E5D743D5A713D586F3C56
|
||||
6C3B536A3A5168394F65384C63374A6037485E36455B3543
|
||||
59344057333E54323C5231394F31374D30354A2F32482E30
|
||||
462D2E432C2B412B293E2B273C2A2439292237281F35271D
|
||||
32261B3025182D25162B241428231126220F25210F24210E
|
||||
23200E221F0E221E0D211E0D201D0D1F1C0D1E1B0C1D1B0C
|
||||
1C1A0C1B190B1B180B1A180B19170A18160A17150A161509
|
||||
1514091413091413081312081211081110081010070F0F07
|
||||
0E0E070D0D060C0D060C0C060B0B050A0A05090A05080904
|
||||
070804060704050704050603040503030403020402010302
|
||||
0608070C0D0D1112121617171B1C1D2121222626272B2B2D
|
||||
</palette>
|
||||
</flame>
|
||||
</Flames>
|
110
blog/2024-11-15-playing-with-fire/src/Canvas.tsx
Normal file
110
blog/2024-11-15-playing-with-fire/src/Canvas.tsx
Normal file
@ -0,0 +1,110 @@
|
||||
import React, {useEffect, useState, createContext, useRef, MouseEvent} from "react";
|
||||
import {useColorMode} from "@docusaurus/theme-common";
|
||||
|
||||
type PainterProps = {
|
||||
width: number;
|
||||
height: number;
|
||||
setPainter: (painter: Iterator<ImageData>) => void;
|
||||
}
|
||||
export const PainterContext = createContext<PainterProps>(null)
|
||||
|
||||
const downloadImage = (name: string) =>
|
||||
(e: MouseEvent) => {
|
||||
const link = document.createElement("a");
|
||||
link.download = `${name}.png`;
|
||||
link.href = (e.target as HTMLCanvasElement).toDataURL("image/png");
|
||||
link.click();
|
||||
}
|
||||
|
||||
type CanvasProps = {
|
||||
name: string;
|
||||
style?: any;
|
||||
children?: React.ReactElement
|
||||
}
|
||||
export const Canvas: React.FC<CanvasProps> = ({name, style, children}) => {
|
||||
const sizingRef = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState(0);
|
||||
const [height, setHeight] = useState(0);
|
||||
useEffect(() => {
|
||||
if (sizingRef.current) {
|
||||
setWidth(sizingRef.current.offsetWidth);
|
||||
setHeight(sizingRef.current.offsetHeight);
|
||||
}
|
||||
}, [sizingRef]);
|
||||
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
const [entry] = entries;
|
||||
if (entry.isIntersecting) {
|
||||
setIsVisible(true);
|
||||
}
|
||||
});
|
||||
observer.observe(canvasRef.current);
|
||||
|
||||
return () => {
|
||||
if (canvasRef.current) {
|
||||
observer.unobserve(canvasRef.current);
|
||||
}
|
||||
}
|
||||
}, [canvasRef.current]);
|
||||
|
||||
const [imageHolder, setImageHolder] = useState<[ImageData]>(null);
|
||||
useEffect(() => {
|
||||
if (canvasRef.current && imageHolder) {
|
||||
canvasRef.current.getContext("2d").putImageData(imageHolder[0], 0, 0);
|
||||
}
|
||||
}, [canvasRef, imageHolder]);
|
||||
|
||||
const [painterHolder, setPainterHolder] = useState<[Iterator<ImageData>]>(null);
|
||||
useEffect(() => {
|
||||
if (!isVisible || !painterHolder) {
|
||||
return;
|
||||
}
|
||||
|
||||
const painter = painterHolder[0];
|
||||
const nextImage = painter.next().value;
|
||||
if (nextImage) {
|
||||
setImageHolder([nextImage]);
|
||||
setPainterHolder([painter]);
|
||||
} else {
|
||||
setPainterHolder(null);
|
||||
}
|
||||
}, [isVisible, painterHolder]);
|
||||
|
||||
const [painter, setPainter] = useState<Iterator<ImageData>>(null);
|
||||
useEffect(() => {
|
||||
if (painter) {
|
||||
setPainterHolder([painter]);
|
||||
}
|
||||
}, [painter]);
|
||||
|
||||
const canvasProps = {
|
||||
ref: canvasRef,
|
||||
width,
|
||||
height,
|
||||
style: {filter: useColorMode().colorMode === 'dark' ? 'invert(1)' : ''}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<center>
|
||||
<div ref={sizingRef} style={style}>
|
||||
{width > 0 ? <canvas {...canvasProps} onDoubleClick={downloadImage(name)}/> : null}
|
||||
</div>
|
||||
</center>
|
||||
<PainterContext.Provider value={{width, height, setPainter}}>
|
||||
{width > 0 ? children : null}
|
||||
</PainterContext.Provider>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const SquareCanvas: React.FC<CanvasProps> = ({name, style, children}) => {
|
||||
return <center><Canvas name={name} style={{width: '75%', aspectRatio: '1/1', ...style}} children={children}/></center>
|
||||
}
|
8
blog/2024-11-15-playing-with-fire/src/applyTransform.ts
Normal file
8
blog/2024-11-15-playing-with-fire/src/applyTransform.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { applyCoefs, Coefs, Transform } from "./transform";
|
||||
import { blend, Blend } from "./blend";
|
||||
|
||||
export const applyTransform = (coefs: Coefs, variations: Blend): Transform =>
|
||||
(x, y) => blend(...applyCoefs(x, y, coefs), variations)
|
||||
|
||||
export const applyPost = (coefsPost: Coefs, transform: Transform): Transform =>
|
||||
(x, y) => applyCoefs(...transform(x, y), coefsPost);
|
20
blog/2024-11-15-playing-with-fire/src/blend.ts
Normal file
20
blog/2024-11-15-playing-with-fire/src/blend.ts
Normal file
@ -0,0 +1,20 @@
|
||||
// hidden-start
|
||||
import { Variation } from "./variation";
|
||||
// hidden-end
|
||||
export type Blend = [number, Variation][];
|
||||
|
||||
export function blend(
|
||||
x: number,
|
||||
y: number,
|
||||
varFns: Blend
|
||||
): [number, number] {
|
||||
let [outX, outY] = [0, 0];
|
||||
|
||||
for (const [weight, varFn] of varFns) {
|
||||
const [varX, varY] = varFn(x, y);
|
||||
outX += weight * varX;
|
||||
outY += weight * varY;
|
||||
}
|
||||
|
||||
return [outX, outY];
|
||||
}
|
42
blog/2024-11-15-playing-with-fire/src/camera.ts
Normal file
42
blog/2024-11-15-playing-with-fire/src/camera.ts
Normal file
@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Translate values in the flame coordinate system to pixel coordinates
|
||||
*
|
||||
* 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 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
|
||||
* "pixels per unit", and with the default zoom, is chosen to give a
|
||||
* range of [-2, 2]).
|
||||
*
|
||||
* @param x point in the range [-2, 2]
|
||||
* @param y point in the range [-2, 2]
|
||||
* @param size image width/height in pixels
|
||||
* @returns pair of pixel coordinates
|
||||
*/
|
||||
export function camera(x: number, y: number, size: number): [number, number] {
|
||||
return [Math.floor(((x + 2) * size) / 4), Math.floor(((y + 2) * size) / 4)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate values in pixel coordinates to a 1-dimensional array index
|
||||
*
|
||||
* Unlike the camera function, this mapping doesn't assume a square image,
|
||||
* and only requires knowing the image width.
|
||||
*
|
||||
* The stride parameter is used to calculate indices that take into account
|
||||
* how many "values" each pixel has. For example, in an ImageData, each pixel
|
||||
* has a red, green, blue, and alpha component per pixel, so a stride of 4
|
||||
* is appropriate. For situations where there are separate red/green/blue/alpha
|
||||
* arrays per pixel, a stride of 1 is appropriate
|
||||
*
|
||||
* @param x point in the range [0, size)
|
||||
* @param y point in the range [0, size)
|
||||
* @param width width of image in pixel units
|
||||
* @param stride values per pixel coordinate
|
||||
*/
|
||||
export function histIndex(x: number, y: number, width: number, stride: number): number {
|
||||
return y * (width * stride) + x * stride;
|
||||
}
|
37
blog/2024-11-15-playing-with-fire/src/css/styles.module.css
Normal file
37
blog/2024-11-15-playing-with-fire/src/css/styles.module.css
Normal file
@ -0,0 +1,37 @@
|
||||
.inputGroup {
|
||||
padding: .2em;
|
||||
margin-top: .5em;
|
||||
margin-bottom: .5em;
|
||||
border: 1px solid;
|
||||
border-radius: var(--ifm-global-radius);
|
||||
border-color: var(--ifm-color-emphasis-500);
|
||||
}
|
||||
|
||||
.inputTitle {
|
||||
border: 0 solid;
|
||||
border-bottom: 1px solid;
|
||||
border-color: var(--ifm-color-emphasis-500);
|
||||
margin-bottom: .5em;
|
||||
}
|
||||
|
||||
.inputElement {
|
||||
padding-left: .5em;
|
||||
padding-right: 1em;
|
||||
}
|
||||
|
||||
.inputElement > p {
|
||||
margin: 0
|
||||
}
|
||||
|
||||
.inputElement > input[type=range] {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
appearance: none;
|
||||
background: var(--ifm-color-emphasis-400);
|
||||
border-radius: var(--ifm-global-radius);
|
||||
}
|
||||
|
||||
.inputReset {
|
||||
display: flex;
|
||||
float: right;
|
||||
}
|
21
blog/2024-11-15-playing-with-fire/src/julia.ts
Normal file
21
blog/2024-11-15-playing-with-fire/src/julia.ts
Normal file
@ -0,0 +1,21 @@
|
||||
// hidden-start
|
||||
import { Variation } from "./variation";
|
||||
// hidden-end
|
||||
const omega =
|
||||
() => Math.random() > 0.5 ? 0 : Math.PI;
|
||||
|
||||
export const julia: Variation =
|
||||
(x, y) => {
|
||||
const x2 = Math.pow(x, 2);
|
||||
const y2 = Math.pow(y, 2);
|
||||
const r = Math.sqrt(x2 + y2);
|
||||
|
||||
const theta = Math.atan2(x, y);
|
||||
|
||||
const sqrtR = Math.sqrt(r);
|
||||
const thetaVal = theta / 2 + omega();
|
||||
return [
|
||||
sqrtR * Math.cos(thetaVal),
|
||||
sqrtR * Math.sin(thetaVal)
|
||||
];
|
||||
};
|
5
blog/2024-11-15-playing-with-fire/src/linear.ts
Normal file
5
blog/2024-11-15-playing-with-fire/src/linear.ts
Normal file
@ -0,0 +1,5 @@
|
||||
// hidden-start
|
||||
import {Variation} from "./variation"
|
||||
//hidden-end
|
||||
export const linear: Variation =
|
||||
(x, y) => [x, y];
|
120
blog/2024-11-15-playing-with-fire/src/params.ts
Normal file
120
blog/2024-11-15-playing-with-fire/src/params.ts
Normal file
@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Parameters taken from the reference .flame file,
|
||||
* translated into something that's easier to work with.
|
||||
*/
|
||||
|
||||
import { Blend } from "./blend";
|
||||
import { linear } from "./linear";
|
||||
import { julia } from "./julia";
|
||||
import { popcorn } from "./popcorn";
|
||||
import { pdj, PdjParams } from "./pdj";
|
||||
import { Coefs, Transform } from "./transform";
|
||||
import { applyPost, applyTransform } from "./applyTransform";
|
||||
|
||||
export const identityCoefs: Coefs = {
|
||||
a: 1, b: 0, c: 0,
|
||||
d: 0, e: 1, f: 0
|
||||
};
|
||||
|
||||
export const pdjParams: PdjParams = {
|
||||
a: 1.09358, b: 2.13048, c: 2.54127, d: 2.37267
|
||||
};
|
||||
|
||||
export const xform1Weight = 0.56453495;
|
||||
export const xform1Coefs = {
|
||||
a: -1.381068, b: -1.381068, c: 0,
|
||||
d: 1.381068, e: -1.381068, f: 0
|
||||
};
|
||||
export const xform1CoefsPost = identityCoefs;
|
||||
export const xform1Variations: Blend = [
|
||||
[1, julia]
|
||||
];
|
||||
export const xform1Color = 0;
|
||||
|
||||
export const xform2Weight = 0.013135;
|
||||
export const xform2Coefs = {
|
||||
a: 0.031393, b: 0.031367, c: 0,
|
||||
d: -0.031367, e: 0.031393, f: 0
|
||||
};
|
||||
export const xform2CoefsPost = {
|
||||
a: 1, b: 0, c: 0.24,
|
||||
d: 0, e: 1, f: 0.27
|
||||
};
|
||||
export const xform2Variations: Blend = [
|
||||
[1, linear],
|
||||
[1, popcorn(xform2Coefs)]
|
||||
];
|
||||
export const xform2Color = 0.844;
|
||||
|
||||
export const xform3Weight = 0.42233;
|
||||
export const xform3Coefs = {
|
||||
a: 1.51523, b: -3.048677, c: 0.724135,
|
||||
d: 0.740356, e: -1.455964, f: -0.362059
|
||||
};
|
||||
export const xform3CoefsPost = identityCoefs;
|
||||
export const xform3Variations: Blend = [
|
||||
[1, pdj(pdjParams)]
|
||||
];
|
||||
export const xform3Color = 0.349;
|
||||
|
||||
export const xformFinalCoefs = {
|
||||
a: 2, b: 0, c: 0,
|
||||
d: 0, e: 2, f: 0
|
||||
};
|
||||
export const xformFinalCoefsPost = identityCoefs;
|
||||
export const xformFinalVariations: Blend = [
|
||||
[1, julia]
|
||||
];
|
||||
export const xformFinalColor = 0;
|
||||
|
||||
export const xforms: [number, Transform][] = [
|
||||
[xform1Weight, applyPost(xform1CoefsPost, applyTransform(xform1Coefs, xform1Variations))],
|
||||
[xform2Weight, applyPost(xform2CoefsPost, applyTransform(xform2Coefs, xform2Variations))],
|
||||
[xform3Weight, applyPost(xform3CoefsPost, applyTransform(xform3Coefs, xform3Variations))]
|
||||
];
|
||||
|
||||
export const xformFinal: Transform = applyPost(xformFinalCoefsPost, applyTransform(xformFinalCoefs, xformFinalVariations));
|
||||
|
||||
export const paletteString =
|
||||
"3130323635383B3A3D403F424644484B494D504E52565358" +
|
||||
"5B585D605D626562686B676D706C737571787B767D807B83" +
|
||||
"8580888A858D908A93958F989A949DA099A3A59EA8AAA3AD" +
|
||||
"AFA8B3B5ADB8BAB2BEBFB7C3C5BCC8CAC1CECFC6D3D4CBD8" +
|
||||
"DAD0DEDFD5E3DFD2E0DFCEDDE0CBDAE0C8D7E0C4D3E0C1D0" +
|
||||
"E1BECDE1BBCAE1B7C7E1B4C4E1B1C1E2ADBEE2AABAE2A7B7" +
|
||||
"E2A3B4E2A0B1E39DAEE399ABE396A8E393A5E490A1E48C9E" +
|
||||
"E4899BE48698E48295E57F92E57C8FE5788CE57589E57285" +
|
||||
"E66E82E66B7FE6687CE66479E76176E75E73E75B70E7576C" +
|
||||
"E75469E85166E84D63E84A60E4495EE0485CDC475BD84659" +
|
||||
"D44557D04455CB4353C74252C34150BF404EBB3F4CB73E4B" +
|
||||
"B33D49AF3C47AB3B45A73A43A339429F38409B373E97363C" +
|
||||
"92353A8E34398A33378632358231337E30327A2F30762E2E" +
|
||||
"722D2C6E2C2A6A2B29662A276229255E2823592721552620" +
|
||||
"51251E4D241C49231A4522194121173D20153C1F153A1F14" +
|
||||
"391E14381E14361D14351C13341C13321B13311B132F1A12" +
|
||||
"2E19122D19122B18122A1811291711271611261611251510" +
|
||||
"23151022141021140F1F130F1E120F1C120F1B110E1A110E" +
|
||||
"18100E170F0E160F0D140E0D130E0D120D0D100C0C0F0C0C" +
|
||||
"0E0B0C0C0B0C0B0A0B09090B08090B07080B05080A04070A" +
|
||||
"0606090804090A03088C46728A457087446D85436B824369" +
|
||||
"8042667D41647B4061793F5F763E5D743D5A713D586F3C56" +
|
||||
"6C3B536A3A5168394F65384C63374A6037485E36455B3543" +
|
||||
"59344057333E54323C5231394F31374D30354A2F32482E30" +
|
||||
"462D2E432C2B412B293E2B273C2A2439292237281F35271D" +
|
||||
"32261B3025182D25162B241428231126220F25210F24210E" +
|
||||
"23200E221F0E221E0D211E0D201D0D1F1C0D1E1B0C1D1B0C" +
|
||||
"1C1A0C1B190B1B180B1A180B19170A18160A17150A161509" +
|
||||
"1514091413091413081312081211081110081010070F0F07" +
|
||||
"0E0E070D0D060C0D060C0C060B0B050A0A05090A05080904" +
|
||||
"070804060704050704050603040503030403020402010302" +
|
||||
"0608070C0D0D1112121617171B1C1D2121222626272B2B2D";
|
||||
|
||||
function hexToBytes(hex: string) {
|
||||
let bytes: number[] = [];
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
bytes.push(parseInt(hex.substring(i, i + 2), 16));
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export const palette = hexToBytes(paletteString).map(value => value / 0xff);
|
15
blog/2024-11-15-playing-with-fire/src/pdj.ts
Normal file
15
blog/2024-11-15-playing-with-fire/src/pdj.ts
Normal file
@ -0,0 +1,15 @@
|
||||
// hidden-start
|
||||
import { Variation } from './variation'
|
||||
//hidden-end
|
||||
export type PdjParams = {
|
||||
a: number,
|
||||
b: number,
|
||||
c: number,
|
||||
d: number
|
||||
};
|
||||
export const pdj =
|
||||
({a, b, c, d}: PdjParams): Variation =>
|
||||
(x, y) => [
|
||||
Math.sin(a * y) - Math.cos(b * x),
|
||||
Math.sin(c * x) - Math.cos(d * y)
|
||||
]
|
14
blog/2024-11-15-playing-with-fire/src/plotBinary.ts
Normal file
14
blog/2024-11-15-playing-with-fire/src/plotBinary.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { camera, histIndex } from "./camera";
|
||||
|
||||
export function plotBinary(x: number, y: number, image: ImageData) {
|
||||
const [pixelX, pixelY] = camera(x, y, image.width);
|
||||
if (pixelX < 0 || pixelX >= image.width || pixelY < 0 || pixelY >= image.height)
|
||||
return;
|
||||
|
||||
const pixelIndex = histIndex(pixelX, pixelY, image.width, 4);
|
||||
|
||||
image.data[pixelIndex] = 0;
|
||||
image.data[pixelIndex + 1] = 0;
|
||||
image.data[pixelIndex + 2] = 0;
|
||||
image.data[pixelIndex + 3] = 0xff;
|
||||
}
|
10
blog/2024-11-15-playing-with-fire/src/popcorn.ts
Normal file
10
blog/2024-11-15-playing-with-fire/src/popcorn.ts
Normal file
@ -0,0 +1,10 @@
|
||||
// hidden-start
|
||||
import { Coefs } from "./transform";
|
||||
import { Variation } from "./variation";
|
||||
// hidden-end
|
||||
export const popcorn =
|
||||
({ c, f }: Coefs): Variation =>
|
||||
(x, y) => [
|
||||
x + c * Math.sin(Math.tan(3 * y)),
|
||||
y + f * Math.sin(Math.tan(3 * x))
|
||||
];
|
3
blog/2024-11-15-playing-with-fire/src/randomBiUnit.ts
Normal file
3
blog/2024-11-15-playing-with-fire/src/randomBiUnit.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export function randomBiUnit() {
|
||||
return Math.random() * 2 - 1;
|
||||
}
|
21
blog/2024-11-15-playing-with-fire/src/randomChoice.ts
Normal file
21
blog/2024-11-15-playing-with-fire/src/randomChoice.ts
Normal file
@ -0,0 +1,21 @@
|
||||
export function randomChoice<T>(
|
||||
choices: [number, T][]
|
||||
): [number, T] {
|
||||
const weightSum = choices.reduce(
|
||||
(sum, [weight, _]) => sum + weight,
|
||||
0
|
||||
);
|
||||
let choice = Math.random() * weightSum;
|
||||
|
||||
for (const entry of choices.entries()) {
|
||||
const [idx, elem] = entry;
|
||||
const [weight, t] = elem;
|
||||
if (choice < weight) {
|
||||
return [idx, t];
|
||||
}
|
||||
choice -= weight;
|
||||
}
|
||||
|
||||
const index = choices.length - 1;
|
||||
return [index, choices[index][1]];
|
||||
}
|
7
blog/2024-11-15-playing-with-fire/src/randomInteger.ts
Normal file
7
blog/2024-11-15-playing-with-fire/src/randomInteger.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export function randomInteger(
|
||||
min: number,
|
||||
max: number
|
||||
) {
|
||||
let v = Math.random() * (max - min);
|
||||
return Math.floor(v) + min;
|
||||
}
|
23
blog/2024-11-15-playing-with-fire/src/transform.ts
Normal file
23
blog/2024-11-15-playing-with-fire/src/transform.ts
Normal file
@ -0,0 +1,23 @@
|
||||
export type Transform =
|
||||
(x: number, y: number) =>
|
||||
[number, number];
|
||||
|
||||
export interface Coefs {
|
||||
a: number,
|
||||
b: number,
|
||||
c: number,
|
||||
d: number,
|
||||
e: number,
|
||||
f: number
|
||||
}
|
||||
|
||||
export function applyCoefs(
|
||||
x: number,
|
||||
y: number,
|
||||
coefs: Coefs
|
||||
): [number, number] {
|
||||
return [
|
||||
(x * coefs.a + y * coefs.b + coefs.c),
|
||||
(x * coefs.d + y * coefs.e + coefs.f)
|
||||
];
|
||||
}
|
4
blog/2024-11-15-playing-with-fire/src/variation.ts
Normal file
4
blog/2024-11-15-playing-with-fire/src/variation.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export type Variation = (
|
||||
x: number,
|
||||
y: number
|
||||
) => [number, number];
|
@ -28,13 +28,14 @@ const config: Config = {
|
||||
locales: ['en'],
|
||||
},
|
||||
|
||||
themes: ['@docusaurus/theme-live-codeblock'],
|
||||
presets: [
|
||||
[
|
||||
'classic',
|
||||
{
|
||||
docs: false,
|
||||
blog: {
|
||||
routeBasePath: "/",
|
||||
routeBasePath: '/',
|
||||
blogSidebarTitle: 'All posts',
|
||||
blogSidebarCount: 'ALL',
|
||||
showReadingTime: true,
|
||||
@ -80,17 +81,26 @@ const config: Config = {
|
||||
prism: {
|
||||
theme: prismThemes.oneLight,
|
||||
darkTheme: prismThemes.oneDark,
|
||||
additionalLanguages: ['bash', 'java', 'julia', 'nasm']
|
||||
additionalLanguages: ['bash', 'java', 'julia', 'nasm'],
|
||||
magicComments: [
|
||||
// Remember to extend the default highlight class name as well!
|
||||
{
|
||||
className: 'theme-code-block-highlighted-line',
|
||||
line: 'highlight-next-line',
|
||||
block: {start: 'highlight-start', end: 'highlight-end'},
|
||||
},
|
||||
{
|
||||
className: 'code-block-hidden',
|
||||
block: {start: 'hidden-start', end: 'hidden-end'}
|
||||
}
|
||||
]
|
||||
},
|
||||
} satisfies Preset.ThemeConfig,
|
||||
plugins: [require.resolve('docusaurus-lunr-search')],
|
||||
stylesheets: [
|
||||
{
|
||||
href: 'https://cdn.jsdelivr.net/npm/katex@0.13.24/dist/katex.min.css',
|
||||
href: '/katex/katex.min.css',
|
||||
type: 'text/css',
|
||||
integrity:
|
||||
'sha384-odtC+0UGzzFL/6PNoE8rX/SPcQDXBJ+uRepguP4QkPCm2LBxH3FA3y+fKSiJ+AmM',
|
||||
crossorigin: 'anonymous',
|
||||
},
|
||||
],
|
||||
future: {
|
||||
|
4253
package-lock.json
generated
4253
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
14
package.json
14
package.json
@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"docusaurus": "docusaurus",
|
||||
"start": "docusaurus start",
|
||||
"start": "docusaurus start --host 0.0.0.0",
|
||||
"build": "docusaurus build",
|
||||
"swizzle": "docusaurus swizzle",
|
||||
"deploy": "docusaurus deploy",
|
||||
@ -15,17 +15,21 @@
|
||||
"typecheck": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "^3.6.0",
|
||||
"@docusaurus/faster": "^3.5.2",
|
||||
"@docusaurus/preset-classic": "^3.6.0",
|
||||
"@docusaurus/core": "^3.6.1",
|
||||
"@docusaurus/faster": "^3.6.1",
|
||||
"@docusaurus/preset-classic": "^3.6.1",
|
||||
"@docusaurus/theme-live-codeblock": "^3.6.1",
|
||||
"@matejmazur/react-katex": "^3.1.3",
|
||||
"@mdx-js/react": "^3.0.0",
|
||||
"clsx": "^2.0.0",
|
||||
"docusaurus-lunr-search": "^3.5.0",
|
||||
"prism-react-renderer": "^2.3.0",
|
||||
"raw-loader": "^4.0.2",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"remark-math": "^6.0.0"
|
||||
"remark-math": "^6.0.0",
|
||||
"victory": "^37.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "^3.6.0",
|
||||
|
@ -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-200);
|
||||
|
||||
/*--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 {
|
||||
@ -21,4 +34,19 @@
|
||||
[data-theme='dark'] .header-github-link::before {
|
||||
background: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill='white' d='M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12'/%3E%3C/svg%3E")
|
||||
no-repeat;
|
||||
}
|
||||
|
||||
/*
|
||||
Inspired by https://github.com/plotly/plotly.js/issues/2006#issuecomment-2081535168,
|
||||
adapted for Victory charts
|
||||
*/
|
||||
[data-theme='dark'] .VictoryContainer {
|
||||
filter: invert(75%) hue-rotate(180deg);
|
||||
}
|
||||
|
||||
/*
|
||||
Custom magic comment for Prism - hide parts of the code in display
|
||||
*/
|
||||
.code-block-hidden {
|
||||
display: none;
|
||||
}
|
6
src/isDarkMode.ts
Normal file
6
src/isDarkMode.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import {useColorMode} from "@docusaurus/theme-common";
|
||||
|
||||
export default function isDarkMode() {
|
||||
const {colorMode} = useColorMode();
|
||||
return colorMode === "dark";
|
||||
}
|
135
src/theme/Playground/index.tsx
Normal file
135
src/theme/Playground/index.tsx
Normal file
@ -0,0 +1,135 @@
|
||||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
import useIsBrowser from '@docusaurus/useIsBrowser';
|
||||
import {LiveProvider, LiveEditor, LiveError, LivePreview} from 'react-live';
|
||||
import Translate from '@docusaurus/Translate';
|
||||
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
|
||||
import BrowserOnly from '@docusaurus/BrowserOnly';
|
||||
import {
|
||||
ErrorBoundaryErrorMessageFallback,
|
||||
usePrismTheme,
|
||||
} from '@docusaurus/theme-common';
|
||||
import ErrorBoundary from '@docusaurus/ErrorBoundary';
|
||||
|
||||
import type {Props} from '@theme/Playground';
|
||||
import type {ThemeConfig} from '@docusaurus/theme-live-codeblock';
|
||||
|
||||
import styles from './styles.module.css';
|
||||
|
||||
function Header({children}: {children: React.ReactNode}) {
|
||||
return <div className={clsx(styles.playgroundHeader)}>{children}</div>;
|
||||
}
|
||||
|
||||
function LivePreviewLoader() {
|
||||
// Is it worth improving/translating?
|
||||
// eslint-disable-next-line @docusaurus/no-untranslated-text
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
function Preview() {
|
||||
// No SSR for the live preview
|
||||
// See https://github.com/facebook/docusaurus/issues/5747
|
||||
return (
|
||||
<BrowserOnly fallback={<LivePreviewLoader />}>
|
||||
{() => (
|
||||
<>
|
||||
<ErrorBoundary
|
||||
fallback={(params) => (
|
||||
<ErrorBoundaryErrorMessageFallback {...params} />
|
||||
)}>
|
||||
<LivePreview />
|
||||
</ErrorBoundary>
|
||||
<LiveError />
|
||||
</>
|
||||
)}
|
||||
</BrowserOnly>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultWithHeader() {
|
||||
return (
|
||||
<>
|
||||
<Header>
|
||||
<Translate
|
||||
id="theme.Playground.result"
|
||||
description="The result label of the live codeblocks">
|
||||
Result
|
||||
</Translate>
|
||||
</Header>
|
||||
{/* https://github.com/facebook/docusaurus/issues/5747 */}
|
||||
<div className={styles.playgroundPreview}>
|
||||
<Preview />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemedLiveEditor() {
|
||||
const isBrowser = useIsBrowser();
|
||||
return (
|
||||
<LiveEditor
|
||||
// We force remount the editor on hydration,
|
||||
// otherwise dark prism theme is not applied
|
||||
key={String(isBrowser)}
|
||||
className={styles.playgroundEditor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function EditorWithHeader() {
|
||||
return (
|
||||
<>
|
||||
<Header>
|
||||
<Translate
|
||||
id="theme.Playground.liveEditor"
|
||||
description="The live editor label of the live codeblocks">
|
||||
Live Editor
|
||||
</Translate>
|
||||
</Header>
|
||||
<ThemedLiveEditor />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// this should rather be a stable function
|
||||
// see https://github.com/facebook/docusaurus/issues/9630#issuecomment-1855682643
|
||||
const DEFAULT_TRANSFORM_CODE = (code: string) => `${code};`;
|
||||
|
||||
export default function Playground({
|
||||
children,
|
||||
transformCode,
|
||||
...props
|
||||
}: Props): JSX.Element {
|
||||
const {
|
||||
siteConfig: {themeConfig},
|
||||
} = useDocusaurusContext();
|
||||
const {
|
||||
liveCodeBlock: {playgroundPosition},
|
||||
} = themeConfig as ThemeConfig;
|
||||
const prismTheme = usePrismTheme();
|
||||
|
||||
const noInline = props.metastring?.includes('noInline') ?? false;
|
||||
|
||||
return (
|
||||
<div className={styles.playgroundContainer}>
|
||||
<LiveProvider
|
||||
code={children?.replace(/\n$/, '')}
|
||||
noInline={noInline}
|
||||
transformCode={transformCode ?? DEFAULT_TRANSFORM_CODE}
|
||||
theme={prismTheme}
|
||||
{...props}>
|
||||
{playgroundPosition === 'top' ? (
|
||||
<>
|
||||
<ResultWithHeader />
|
||||
<EditorWithHeader />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<EditorWithHeader />
|
||||
<ResultWithHeader />
|
||||
</>
|
||||
)}
|
||||
</LiveProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
43
src/theme/Playground/styles.module.css
Normal file
43
src/theme/Playground/styles.module.css
Normal file
@ -0,0 +1,43 @@
|
||||
.playgroundContainer {
|
||||
margin-bottom: var(--ifm-leading);
|
||||
border-radius: var(--ifm-global-radius);
|
||||
box-shadow: var(--ifm-global-shadow-lw);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.playgroundHeader {
|
||||
letter-spacing: 0.08rem;
|
||||
padding: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
font-weight: bold;
|
||||
background: var(--ifm-color-emphasis-200);
|
||||
color: var(--ifm-color-content);
|
||||
font-size: var(--ifm-code-font-size);
|
||||
}
|
||||
|
||||
.playgroundHeader:first-of-type {
|
||||
background: var(--ifm-color-emphasis-600);
|
||||
color: var(--ifm-color-content-inverse);
|
||||
}
|
||||
|
||||
.playgroundEditor {
|
||||
font: var(--ifm-code-font-size) / var(--ifm-pre-line-height)
|
||||
var(--ifm-font-family-monospace) !important;
|
||||
/* rtl:ignore */
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
.playgroundPreview {
|
||||
padding: 1rem;
|
||||
background-color: var(--ifm-pre-background);
|
||||
}
|
||||
|
||||
/*
|
||||
Docusaurus global CSS applies a `border-radius` to `pre` that leads to a minor graphical issue
|
||||
where the "LIVE EDITOR" title bar and code block meet - https://github.com/facebook/docusaurus/issues/6032#issuecomment-2481803877
|
||||
|
||||
This change disables the border radius so the edges properly join together
|
||||
*/
|
||||
.playgroundEditor > pre {
|
||||
border-radius: 0;
|
||||
}
|
BIN
static/katex/fonts/KaTeX_AMS-Regular.woff2
Normal file
BIN
static/katex/fonts/KaTeX_AMS-Regular.woff2
Normal file
Binary file not shown.
BIN
static/katex/fonts/KaTeX_Caligraphic-Bold.woff2
Normal file
BIN
static/katex/fonts/KaTeX_Caligraphic-Bold.woff2
Normal file
Binary file not shown.
BIN
static/katex/fonts/KaTeX_Caligraphic-Regular.woff2
Normal file
BIN
static/katex/fonts/KaTeX_Caligraphic-Regular.woff2
Normal file
Binary file not shown.
BIN
static/katex/fonts/KaTeX_Fraktur-Bold.woff2
Normal file
BIN
static/katex/fonts/KaTeX_Fraktur-Bold.woff2
Normal file
Binary file not shown.
BIN
static/katex/fonts/KaTeX_Fraktur-Regular.woff2
Normal file
BIN
static/katex/fonts/KaTeX_Fraktur-Regular.woff2
Normal file
Binary file not shown.
BIN
static/katex/fonts/KaTeX_Main-Bold.woff2
Normal file
BIN
static/katex/fonts/KaTeX_Main-Bold.woff2
Normal file
Binary file not shown.
BIN
static/katex/fonts/KaTeX_Main-BoldItalic.woff2
Normal file
BIN
static/katex/fonts/KaTeX_Main-BoldItalic.woff2
Normal file
Binary file not shown.
BIN
static/katex/fonts/KaTeX_Main-Italic.woff2
Normal file
BIN
static/katex/fonts/KaTeX_Main-Italic.woff2
Normal file
Binary file not shown.
BIN
static/katex/fonts/KaTeX_Main-Regular.woff2
Normal file
BIN
static/katex/fonts/KaTeX_Main-Regular.woff2
Normal file
Binary file not shown.
BIN
static/katex/fonts/KaTeX_Math-BoldItalic.woff2
Normal file
BIN
static/katex/fonts/KaTeX_Math-BoldItalic.woff2
Normal file
Binary file not shown.
BIN
static/katex/fonts/KaTeX_Math-Italic.woff2
Normal file
BIN
static/katex/fonts/KaTeX_Math-Italic.woff2
Normal file
Binary file not shown.
BIN
static/katex/fonts/KaTeX_SansSerif-Bold.woff2
Normal file
BIN
static/katex/fonts/KaTeX_SansSerif-Bold.woff2
Normal file
Binary file not shown.
BIN
static/katex/fonts/KaTeX_SansSerif-Italic.woff2
Normal file
BIN
static/katex/fonts/KaTeX_SansSerif-Italic.woff2
Normal file
Binary file not shown.
BIN
static/katex/fonts/KaTeX_SansSerif-Regular.woff2
Normal file
BIN
static/katex/fonts/KaTeX_SansSerif-Regular.woff2
Normal file
Binary file not shown.
BIN
static/katex/fonts/KaTeX_Script-Regular.woff2
Normal file
BIN
static/katex/fonts/KaTeX_Script-Regular.woff2
Normal file
Binary file not shown.
BIN
static/katex/fonts/KaTeX_Size1-Regular.woff2
Normal file
BIN
static/katex/fonts/KaTeX_Size1-Regular.woff2
Normal file
Binary file not shown.
BIN
static/katex/fonts/KaTeX_Size2-Regular.woff2
Normal file
BIN
static/katex/fonts/KaTeX_Size2-Regular.woff2
Normal file
Binary file not shown.
BIN
static/katex/fonts/KaTeX_Size3-Regular.woff2
Normal file
BIN
static/katex/fonts/KaTeX_Size3-Regular.woff2
Normal file
Binary file not shown.
BIN
static/katex/fonts/KaTeX_Size4-Regular.woff2
Normal file
BIN
static/katex/fonts/KaTeX_Size4-Regular.woff2
Normal file
Binary file not shown.
BIN
static/katex/fonts/KaTeX_Typewriter-Regular.woff2
Normal file
BIN
static/katex/fonts/KaTeX_Typewriter-Regular.woff2
Normal file
Binary file not shown.
1
static/katex/katex.min.css
vendored
Normal file
1
static/katex/katex.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user