Log density visualization

This commit is contained in:
Bradlee Speice 2024-12-01 21:57:10 -05:00
parent 79b66337e8
commit 2e8a6d1ce7
20 changed files with 206 additions and 194 deletions

View File

@ -16,14 +16,15 @@ export default function GasketWeighted() {
const f1: Transform = (x, y) => [(x + 1) / 2, y / 2];
const f2: Transform = (x, y) => [x / 2, (y + 1) / 2];
const {setPainter} = useContext(PainterContext);
const {width, height, setPainter} = useContext(PainterContext);
useEffect(() => {
setPainter(chaosGameWeighted([
const transforms: [number, Transform][] = [
[f0Weight, f0],
[f1Weight, f1],
[f2Weight, f2]
]));
];
setPainter(chaosGameWeighted({width, height, transforms}));
}, [f0Weight, f1Weight, f2Weight]);
const weightInput = (title, weight, setWeight) => (

View File

@ -6,8 +6,13 @@ import {Transform} from "../src/transform";
const iterations = 50_000;
const step = 1000;
// hidden-end
export function* chaosGameWeighted(transforms: [number, Transform][]) {
let image = new ImageData(500, 500);
export type ChaosGameWeightedProps = {
width: number,
height: number,
transforms: [number, Transform][]
}
export function* chaosGameWeighted({width, height, transforms}: ChaosGameWeightedProps) {
let image = new ImageData(width, height);
var [x, y] = [randomBiUnit(), randomBiUnit()];
for (let i = 0; i < iterations; i++) {

View File

@ -1,17 +1,12 @@
import {useContext, useEffect, useState} from "react";
import {Transform} from "../src/transform";
import {
xform1Coefs,
xform1Weight,
xform2Coefs,
xform2Weight,
xform3Coefs,
xform3Weight
} from "../src/params";
import * as params from "../src/params"
import {PainterContext} from "../src/Canvas"
import {buildBlend, buildTransform} from "./buildTransform"
import {chaosGameFinal} from "./chaosGameFinal"
import {VariationEditor, VariationProps} from "./VariationEditor"
import {xform1Weight} from "../src/params";
import {applyTransform} from "@site/blog/2024-11-15-playing-with-fire/src/applyTransform";
import {buildBlend} from "@site/blog/2024-11-15-playing-with-fire/2-transforms/buildBlend";
export default function FlameBlend() {
const {width, height, setPainter} = useContext(PainterContext);
@ -47,11 +42,21 @@ export default function FlameBlend() {
// and swap in identity components for each
const identityXform: Transform = (x, y) => [x, y];
useEffect(() => setPainter(chaosGameFinal(width, height, [
[xform1Weight, buildTransform(xform1Coefs, buildBlend(xform1Coefs, xform1Variations))],
[xform2Weight, buildTransform(xform2Coefs, buildBlend(xform2Coefs, xform2Variations))],
[xform3Weight, buildTransform(xform3Coefs, buildBlend(xform3Coefs, xform3Variations))]
], identityXform)), [xform1Variations, xform2Variations, xform3Variations]);
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 (
<>

View File

@ -1,40 +1,18 @@
import {useContext, useEffect, useState} from "react";
import {Coefs} from "../src/coefs"
import {
xform1Coefs,
xform1Weight,
xform1Variations,
xform1CoefsPost,
xform2Coefs,
xform2Weight,
xform2Variations,
xform2CoefsPost,
xform3Coefs,
xform3Weight,
xform3Variations,
xform3CoefsPost,
xformFinalCoefs as xformFinalCoefsDefault,
xformFinalCoefsPost as xformFinalCoefsPostDefault,
} from "../src/params";
import * as params from "../src/params";
import {PainterContext} from "../src/Canvas"
import {buildBlend, buildTransform} from "./buildTransform";
import {transformPost} from "./post";
import {buildBlend} from "./buildBlend";
import {chaosGameFinal} from "./chaosGameFinal"
import {VariationEditor, VariationProps} from "./VariationEditor";
import {CoefEditor} from "./CoefEditor";
import {Transform} from "../src/transform";
export const transforms: [number, Transform][] = [
[xform1Weight, transformPost(buildTransform(xform1Coefs, xform1Variations), xform1CoefsPost)],
[xform2Weight, transformPost(buildTransform(xform2Coefs, xform2Variations), xform2CoefsPost)],
[xform3Weight, transformPost(buildTransform(xform3Coefs, xform3Variations), xform3CoefsPost)]
];
import {applyPost, applyTransform} from "../src/applyTransform";
export default function FlameFinal() {
const {width, height, setPainter} = useContext(PainterContext);
const [xformFinalCoefs, setXformFinalCoefs] = useState<Coefs>(xformFinalCoefsDefault);
const resetXformFinalCoefs = () => setXformFinalCoefs(xformFinalCoefsDefault);
const [xformFinalCoefs, setXformFinalCoefs] = useState<Coefs>(params.xformFinalCoefs);
const resetXformFinalCoefs = () => setXformFinalCoefs(params.xformFinalCoefs);
const xformFinalVariationsDefault: VariationProps = {
linear: 0,
@ -45,15 +23,14 @@ export default function FlameFinal() {
const [xformFinalVariations, setXformFinalVariations] = useState<VariationProps>(xformFinalVariationsDefault);
const resetXformFinalVariations = () => setXformFinalVariations(xformFinalVariationsDefault);
const [xformFinalCoefsPost, setXformFinalCoefsPost] = useState<Coefs>(xformFinalCoefsPostDefault);
const resetXformFinalCoefsPost = () => setXformFinalCoefsPost(xformFinalCoefsPostDefault);
const [xformFinalCoefsPost, setXformFinalCoefsPost] = useState<Coefs>(params.xformFinalCoefsPost);
const resetXformFinalCoefsPost = () => setXformFinalCoefsPost(params.xformFinalCoefsPost);
useEffect(() => {
const finalBlend = buildBlend(xformFinalCoefs, xformFinalVariations);
const finalTransform = buildTransform(xformFinalCoefs, finalBlend);
const finalPost = transformPost(finalTransform, xformFinalCoefsPost);
const finalXform = applyPost(xformFinalCoefsPost, applyTransform(xformFinalCoefs, finalBlend));
setPainter(chaosGameFinal(width, height, transforms, finalPost));
setPainter(chaosGameFinal({width, height, transforms: params.xforms, final: finalXform}));
}, [xformFinalCoefs, xformFinalVariations, xformFinalCoefsPost]);
return (

View File

@ -1,45 +1,37 @@
import {useContext, useEffect, useState} from "react";
import {Coefs} from "../src/coefs"
import {Transform} from "../src/transform";
import {
xform1Coefs,
xform1Weight,
xform1Variations,
xform1CoefsPost as xform1CoefsPostDefault,
xform2Coefs,
xform2Weight,
xform2Variations,
xform2CoefsPost as xform2CoefsPostDefault,
xform3Coefs,
xform3Weight,
xform3Variations,
xform3CoefsPost as xform3CoefsPostDefault
} from "../src/params";
import * as params from "../src/params";
import {PainterContext} from "../src/Canvas"
import {chaosGameFinal} from "./chaosGameFinal"
import {chaosGameFinal, ChaosGameFinalProps} from "./chaosGameFinal"
import {CoefEditor} from "./CoefEditor"
import {transformPost} from "./post";
import {buildTransform} from "./buildTransform";
import {applyPost, applyTransform} from "@site/blog/2024-11-15-playing-with-fire/src/applyTransform";
export default function FlamePost() {
const {width, height, setPainter} = useContext(PainterContext);
const [xform1CoefsPost, setXform1CoefsPost] = useState<Coefs>(xform1CoefsPostDefault);
const resetXform1CoefsPost = () => setXform1CoefsPost(xform1CoefsPostDefault);
const [xform1CoefsPost, setXform1CoefsPost] = useState<Coefs>(params.xform1CoefsPost);
const resetXform1CoefsPost = () => setXform1CoefsPost(params.xform1CoefsPost);
const [xform2CoefsPost, setXform2CoefsPost] = useState<Coefs>(xform2CoefsPostDefault);
const resetXform2CoefsPost = () => setXform2CoefsPost(xform2CoefsPostDefault);
const [xform2CoefsPost, setXform2CoefsPost] = useState<Coefs>(params.xform2CoefsPost);
const resetXform2CoefsPost = () => setXform2CoefsPost(params.xform2CoefsPost);
const [xform3CoefsPost, setXform3CoefsPost] = useState<Coefs>(xform3CoefsPostDefault);
const resetXform3CoefsPost = () => setXform1CoefsPost(xform3CoefsPostDefault);
const [xform3CoefsPost, setXform3CoefsPost] = useState<Coefs>(params.xform3CoefsPost);
const resetXform3CoefsPost = () => setXform1CoefsPost(params.xform3CoefsPost);
const identityXform: Transform = (x, y) => [x, y];
useEffect(() => setPainter(chaosGameFinal(width, height, [
[xform1Weight, transformPost(buildTransform(xform1Coefs, xform1Variations), xform1CoefsPost)],
[xform2Weight, transformPost(buildTransform(xform2Coefs, xform2Variations), xform2CoefsPost)],
[xform3Weight, transformPost(buildTransform(xform3Coefs, xform3Variations), xform3CoefsPost)]
], identityXform)), [xform1CoefsPost, xform2CoefsPost, xform3CoefsPost]);
const gameParams: ChaosGameFinalProps = {
width,
height,
transforms: [
[params.xform1Weight, applyPost(xform1CoefsPost, applyTransform(params.xform1Coefs, params.xform1Variations))],
[params.xform2Weight, applyPost(xform2CoefsPost, applyTransform(params.xform2Coefs, params.xform2Variations))],
[params.xform3Weight, applyPost(xform3CoefsPost, applyTransform(params.xform3Coefs, params.xform3Variations))],
],
final: identityXform
}
useEffect(() => setPainter(chaosGameFinal(gameParams)), [xform1CoefsPost, xform2CoefsPost, xform3CoefsPost]);
return (
<>

View File

@ -1,17 +0,0 @@
// hidden-start
import {VariationBlend} from "../src/variationBlend";
// hidden-end
export function blend(
x: number,
y: number,
variations: VariationBlend): [number, number] {
let [finalX, finalY] = [0, 0];
for (const [weight, variation] of variations) {
const [varX, varY] = variation(x, y);
finalX += weight * varX;
finalY += weight * varY;
}
return [finalX, finalY];
}

View File

@ -1,13 +1,11 @@
import {applyCoefs, Coefs} from "../src/coefs";
import {Coefs} from "../src/coefs";
import {VariationProps} from "./VariationEditor";
import {Transform} from "../src/transform";
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 "./blend";
import {VariationBlend} from "../src/variationBlend";
import {VariationBlend} from "../src/blend";
export function buildBlend(coefs: Coefs, variations: VariationProps): VariationBlend {
return [
@ -17,10 +15,3 @@ export function buildBlend(coefs: Coefs, variations: VariationProps): VariationB
[variations.pdj, pdj(pdjParams)]
]
}
export function buildTransform(coefs: Coefs, variations: VariationBlend): Transform {
return (x: number, y: number) => {
[x, y] = applyCoefs(x, y, coefs);
return blend(x, y, variations);
}
}

View File

@ -3,13 +3,20 @@ import { randomBiUnit } from "../src/randomBiUnit";
import { randomChoice } from "../src/randomChoice";
import { plotBinary as plot } from "../src/plotBinary"
import {Transform} from "../src/transform";
const iterations = 500_000;
const step = 1000;
import {ChaosGameWeightedProps} from "../1-introduction/chaosGameWeighted";
// hidden-end
export function* chaosGameFinal(width: number, height: number, transforms: [number, Transform][], final: Transform) {
export type ChaosGameFinalProps = ChaosGameWeightedProps & {
final: Transform,
quality?: number,
step?: number,
}
export function* chaosGameFinal({width, height, transforms, final, quality, step}: ChaosGameFinalProps) {
let image = new ImageData(width, height);
let [x, y] = [randomBiUnit(), randomBiUnit()];
const iterations = (quality ?? 0.5) * width * height;
step = step ?? 1000;
for (let i = 0; i < iterations; i++) {
const [_, transform] = randomChoice(transforms);
[x, y] = transform(x, y);

View File

@ -133,7 +133,7 @@ $$
The formula looks intimidating, but it's not hard to implement:
import blendSource from "!!raw-loader!./blend";
import blendSource from "!!raw-loader!../src/blend";
<CodeBlock language={'typescript'}>{blendSource}</CodeBlock>

View File

@ -1,30 +1,27 @@
import {VictoryChart, VictoryLine, VictoryScatter, VictoryTheme} from "victory";
import {useContext, useEffect, useState} from "react";
import React, {useContext, useEffect} from "react";
import * as params from "../src/params";
import {PainterContext} from "../src/Canvas";
import {chaosGameHistogram} from "./chaosGameHistogram";
import {PlotData, plotHistogram} from "./plotHistogram";
import {chaosGameHistogram} from "@site/blog/2024-11-15-playing-with-fire/3-log-density/chaosGameHistogram";
function* plotChaosGame(width: number, height: number, setPdf: (data: PlotData) => void, setCdf: (data: PlotData) => void) {
const emptyImage = new ImageData(width, height);
for (let histogram of chaosGameHistogram(width, height)) {
const plotData = plotHistogram(histogram);
setPdf(plotData);
yield emptyImage;
type Props = {
quality?: number;
paintFn: (width: number, histogram: Uint32Array) => ImageData;
children?: React.ReactElement;
}
}
export default function FlameHistogram() {
export default function FlameHistogram({quality, paintFn, children}: Props) {
const {width, height, setPainter} = useContext(PainterContext);
const [pdfData, setPdfData] = useState<{ x: number, y: number }[]>(null);
useEffect(() => setPainter(plotChaosGame(width, height, setPdfData, null)), []);
return (
<VictoryChart theme={VictoryTheme.clean}>
<VictoryLine
data={pdfData}
interpolation='natural'
/>
</VictoryChart>
)
useEffect(() => {
const gameParams = {
width,
height,
transforms: params.xforms,
final: params.xformFinal,
quality,
painter: paintFn
}
setPainter(chaosGameHistogram(gameParams));
}, []);
return children;
}

View File

@ -1,34 +1,34 @@
import {plot} from "./plotHistogram";
// hidden-start
import {randomBiUnit} from "../src/randomBiUnit";
import {randomChoice} from "../src/randomChoice";
import {buildTransform} from "../2-transforms/buildTransform";
import {transformPost} from "../2-transforms/post";
import {transforms} from "../2-transforms/FlameFinal";
import * as params from "../src/params";
import {ChaosGameFinalProps} from "../2-transforms/chaosGameFinal";
import {camera, histIndex} from "../src/camera";
// hidden-end
export type ChaosGameHistogramProps = ChaosGameFinalProps & {
painter: (width: number, histogram: Uint32Array) => ImageData;
}
export function* chaosGameHistogram({width, height, transforms, final, quality, step, painter}: ChaosGameHistogramProps) {
let iterations = (quality ?? 10) * width * height;
step = step ?? 100_000;
const finalTransform = buildTransform(params.xformFinalCoefs, params.xformFinalVariations);
const finalTransformPost = transformPost(finalTransform, params.xformFinalCoefsPost);
const step = 1000;
const quality = 1;
export function* chaosGameHistogram(width: number, height: number) {
let iterations = quality * width * height;
let histogram = new Uint32Array(width * height);
const histogram = new Uint32Array(width * height);
let [x, y] = [randomBiUnit(), randomBiUnit()];
for (let i = 0; i < iterations; i++) {
const [_, transform] = randomChoice(transforms);
[x, y] = transform(x, y);
[x, y] = finalTransformPost(x, y);
[x, y] = final(x, y);
if (i > 20)
plot(x, y, width, histogram);
if (i > 20) {
const [pixelX, pixelY] = camera(x, y, width);
const pixelIndex = histIndex(pixelX, pixelY, width, 1);
histogram[pixelIndex] += 1;
}
if (i % step === 0)
yield histogram;
yield painter(width, histogram);
}
yield histogram;
yield painter(width, histogram);
}

View File

@ -19,17 +19,23 @@ Can we do something more intelligent with that information?
## Image histograms
To start with, it's worth demonstrating how much work is actually "wasted."
We'll render the reference image again, but this time, counting the times
we tried to turn on a pixel.
We'll render the reference image again, but this time, set each pixel's transparency
based on how many times we encounter it in the chaos game:
import CodeBlock from "@theme/CodeBlock";
import plotHistogramSource from "!!raw-loader!./plotHistogram";
<CodeBlock language="typescript">{plotHistogramSource}</CodeBlock>
import paintLinearSource from "!!raw-loader!./paintLinear"
<CodeBlock language="typescript">{paintLinearSource}</CodeBlock>
import Canvas from "../src/Canvas";
import FlameHistogram from "./FlameHistogram";
import {paintLinear} from "./paintLinear";
<Canvas width={400} height={400} hidden={true}>
<FlameHistogram/>
</Canvas>
<Canvas><FlameHistogram quality={5} paintFn={paintLinear}/></Canvas>
## Log display
import {paintLogarithmic} from './paintLogarithmic'
<Canvas><FlameHistogram quality={10} paintFn={paintLogarithmic}/></Canvas>

View File

@ -0,0 +1,18 @@
export function paintLinear(width: number, histogram: Uint32Array): ImageData {
const image = new ImageData(width, histogram.length / width);
let countMax = 0;
for (let value of histogram) {
countMax = Math.max(countMax, value);
}
for (let i = 0; i < histogram.length; i++) {
const pixelIndex = i * 4;
image.data[pixelIndex] = 0; // red
image.data[pixelIndex + 1] = 0; // green
image.data[pixelIndex + 2] = 0; // blue
image.data[pixelIndex + 3] = Number(histogram[i]) / countMax * 0xff;
}
return image;
}

View File

@ -0,0 +1,21 @@
export function paintLogarithmic(width: number, histogram: Uint32Array): ImageData {
const image = new ImageData(width, histogram.length / width);
const histogramLog = new Array<number>();
histogram.forEach(value => histogramLog.push(Math.log(value)));
let histogramLogMax = -Infinity;
for (let value of histogramLog) {
histogramLogMax = Math.max(histogramLogMax, value);
}
for (let i = 0; i < histogram.length; i++) {
const pixelIndex = i * 4;
image.data[pixelIndex] = 0; // red
image.data[pixelIndex + 1] = 0; // green
image.data[pixelIndex + 2] = 0; // blue
image.data[pixelIndex + 3] = histogramLog[i] / histogramLogMax * 0xff;
}
return image;
}

View File

@ -1,23 +0,0 @@
// hidden-start
import {camera, histIndex} from "../src/camera";
// hidden-end
export function plot(x: number, y: number, width: number, hitCount: Uint32Array) {
const [pixelX, pixelY] = camera(x, y, width);
const pixelIndex = histIndex(pixelX, pixelY, width, 1);
hitCount[pixelIndex] += 1;
}
export type PlotData = {x: number, y: number}[];
export function plotHistogram(hitCount: Uint32Array) {
const data = new Map<number, number>();
hitCount.forEach(value => {
const bucket = 32 - Math.clz32(value);
const currentCount = data.get(bucket) ?? 0;
data.set(bucket, currentCount + 1);
})
const output: PlotData = [];
data.forEach((value, bucket) =>
output.push({x: Math.pow(2, bucket), y: value}));
return output;
}

View File

@ -4,7 +4,7 @@ import BrowserOnly from "@docusaurus/BrowserOnly";
function invertImage(sourceImage: ImageData): ImageData {
const image = new ImageData(sourceImage.width, sourceImage.height);
image.data.forEach((value, index) =>
sourceImage.data.forEach((value, index) =>
image.data[index] = index % 4 === 3 ? value : 0xff - value)
return image;
@ -13,7 +13,6 @@ function invertImage(sourceImage: ImageData): ImageData {
type InvertibleCanvasProps = {
width: number,
height: number,
hidden?: boolean,
// NOTE: Images are provided as a single-element array
//so we can allow re-painting with the same (modified) ImageData reference.
image?: [ImageData],
@ -27,7 +26,7 @@ type InvertibleCanvasProps = {
* @param hidden Hide the canvas element
* @param image Image data to draw on the canvas
*/
const InvertibleCanvas: React.FC<InvertibleCanvasProps> = ({width, height, hidden, image}) => {
const InvertibleCanvas: React.FC<InvertibleCanvasProps> = ({width, height, image}) => {
const [canvasCtx, setCanvasCtx] = useState<CanvasRenderingContext2D>(null);
const canvasRef = useCallback(node => {
if (node !== null) {
@ -54,7 +53,6 @@ const InvertibleCanvas: React.FC<InvertibleCanvasProps> = ({width, height, hidde
ref={canvasRef}
width={width}
height={height}
hidden={hidden ?? false}
style={{
aspectRatio: width / height,
width: '75%'
@ -73,7 +71,6 @@ export const PainterContext = createContext<PainterProps>(null);
interface CanvasProps {
width?: number;
height?: number;
hidden?: boolean;
children?: React.ReactElement;
}
@ -105,7 +102,7 @@ interface CanvasProps {
* but we rely on contexts to manage the iterator, and I can't find
* a good way to make those generic.
*/
export default function Canvas({width, height, hidden, children}: CanvasProps) {
export default function Canvas({width, height, children}: CanvasProps) {
const [image, setImage] = useState<[ImageData]>(null);
const [painterHolder, setPainterHolder] = useState<[Iterator<ImageData>]>(null);
useEffect(() => {
@ -135,7 +132,7 @@ export default function Canvas({width, height, hidden, children}: CanvasProps) {
return (
<>
<center>
<InvertibleCanvas width={width} height={height} hidden={hidden} image={image}/>
<InvertibleCanvas width={width} height={height} image={image}/>
</center>
<PainterContext.Provider value={{width, height, setPainter}}>
<BrowserOnly>{() => children}</BrowserOnly>

View File

@ -0,0 +1,9 @@
import {Transform} from "./transform";
import {applyCoefs, Coefs} from "./coefs";
import {blend, VariationBlend} from "./blend";
export const applyTransform = (coefs: Coefs, variations: VariationBlend): Transform =>
(x, y) => blend(...applyCoefs(x, y, coefs), variations);
export const applyPost = (coefsPost: Coefs, transform: Transform): Transform =>
(x, y) => applyCoefs(...transform(x, y), coefsPost);

View File

@ -0,0 +1,18 @@
// hidden-start
import {Variation} from "./variation";
// hidden-end
export type VariationBlend = [number, Variation][];
export function blend(
x: number,
y: number,
variations: VariationBlend): [number, number] {
let [finalX, finalY] = [0, 0];
for (const [weight, variation] of variations) {
const [varX, varY] = variation(x, y);
finalX += weight * varX;
finalY += weight * varY;
}
return [finalX, finalY];
}

View File

@ -4,11 +4,13 @@
*/
import { Coefs } from './coefs';
import {VariationBlend} from "./variationBlend";
import {VariationBlend} from "./blend";
import { linear } from './linear'
import { julia } from './julia'
import { popcorn } from './popcorn'
import {pdj, PdjParams} from './pdj'
import {Transform} from "./transform";
import {applyPost, applyTransform} from "./applyTransform";
export const identityCoefs: Coefs = {
a: 1, b: 0, c: 0,
@ -66,6 +68,14 @@ export const xformFinalVariations: VariationBlend = [
]
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 palette =
"7E3037762C45722B496E2A4E6A2950672853652754632656" +
"5C265C5724595322574D2155482153462050451F4E441E4D" +

View File

@ -1,2 +0,0 @@
import {Variation} from "./variation";
export type VariationBlend = [number, Variation][];