mirror of
https://github.com/bspeice/speice.io
synced 2026-06-06 13:21:47 -04:00
@@ -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*
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"tabWidth": 2,
|
||||
"semi": true
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 418 KiB |
@@ -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>
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
// hidden-start
|
||||
import {Variation} from "./variation"
|
||||
//hidden-end
|
||||
export const linear: Variation =
|
||||
(x, y) => [x, y];
|
||||
@@ -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);
|
||||
@@ -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)
|
||||
]
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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))
|
||||
];
|
||||
@@ -0,0 +1,3 @@
|
||||
export function randomBiUnit() {
|
||||
return Math.random() * 2 - 1;
|
||||
}
|
||||
@@ -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]];
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export function randomInteger(
|
||||
min: number,
|
||||
max: number
|
||||
) {
|
||||
let v = Math.random() * (max - min);
|
||||
return Math.floor(v) + min;
|
||||
}
|
||||
@@ -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)
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export type Variation = (
|
||||
x: number,
|
||||
y: number
|
||||
) => [number, number];
|
||||
+16
-6
@@ -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: {
|
||||
|
||||
Generated
+3842
-411
File diff suppressed because it is too large
Load Diff
+9
-5
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import {useColorMode} from "@docusaurus/theme-common";
|
||||
|
||||
export default function isDarkMode() {
|
||||
const {colorMode} = useColorMode();
|
||||
return colorMode === "dark";
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Vendored
+1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user