Add configurable vibe presets
This commit is contained in:
parent
e54bddc7db
commit
f8294934fd
34 changed files with 1701 additions and 341 deletions
18
src/utils/browser-storage.ts
Normal file
18
src/utils/browser-storage.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
export const readBrowserStorage = (key: string): string | null => {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const writeBrowserStorage = (key: string, value: string): void => {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'Storage can be unavailable in private browsing or embedded contexts.',
|
||||
error
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
export const clamp = (value: number, min: number, max: number): number =>
|
||||
Math.min(max, Math.max(min, value));
|
||||
|
||||
export const clamp01 = (value: number): number => Math.min(1, Math.max(0, value));
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
export const exponentialDecay = ({
|
||||
accumulator,
|
||||
nextValue,
|
||||
biasOfNextValue,
|
||||
}: {
|
||||
accumulator: number;
|
||||
nextValue: number;
|
||||
biasOfNextValue: number;
|
||||
}) => accumulator * (1 - biasOfNextValue) + nextValue * biasOfNextValue;
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { formatNumber } from './format-number';
|
||||
|
||||
describe('formatNumber', () => {
|
||||
it('renders integers without decimals', () => {
|
||||
expect(formatNumber(42)).toBe('42 ');
|
||||
});
|
||||
it('renders fractional values with two decimals', () => {
|
||||
expect(formatNumber(3.14159)).toBe('3.14 ');
|
||||
});
|
||||
it('renders thousands compactly', () => {
|
||||
expect(formatNumber(2500)).toBe('2.5 thousand ');
|
||||
});
|
||||
it('renders millions compactly', () => {
|
||||
expect(formatNumber(1_500_000)).toBe('1.5 million ');
|
||||
});
|
||||
it('appends the unit when provided', () => {
|
||||
expect(formatNumber(5, 'agents')).toBe('5 agents');
|
||||
expect(formatNumber(2_000_000, 'agents')).toBe('2.0 million agents');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
export const formatNumber = (value: number, unit = ''): string => {
|
||||
if (value >= 1e6) {
|
||||
return `${(value / 1e6).toFixed(1)} million ${unit}`;
|
||||
}
|
||||
|
||||
if (value >= 1e3) {
|
||||
return `${(value / 1e3).toFixed(1)} thousand ${unit}`;
|
||||
}
|
||||
|
||||
return `${value === Math.floor(value) ? value : value.toFixed(2)} ${unit}`;
|
||||
};
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { hsl } from './hsl';
|
||||
|
||||
describe('hsl', () => {
|
||||
it('produces pure red at hue 0', () => {
|
||||
const [r, g, b] = hsl(0, 100, 50);
|
||||
expect(r).toBeCloseTo(1);
|
||||
expect(g).toBeCloseTo(0);
|
||||
expect(b).toBeCloseTo(0);
|
||||
});
|
||||
it('produces pure green at hue 120', () => {
|
||||
const [r, g, b] = hsl(120, 100, 50);
|
||||
expect(r).toBeCloseTo(0);
|
||||
expect(g).toBeCloseTo(1);
|
||||
expect(b).toBeCloseTo(0);
|
||||
});
|
||||
it('produces pure blue at hue 240', () => {
|
||||
const [r, g, b] = hsl(240, 100, 50);
|
||||
expect(r).toBeCloseTo(0);
|
||||
expect(g).toBeCloseTo(0);
|
||||
expect(b).toBeCloseTo(1);
|
||||
});
|
||||
it('produces gray at saturation 0', () => {
|
||||
const [r, g, b] = hsl(180, 0, 50);
|
||||
expect(r).toBeCloseTo(0.5);
|
||||
expect(g).toBeCloseTo(0.5);
|
||||
expect(b).toBeCloseTo(0.5);
|
||||
});
|
||||
it('produces black at lightness 0', () => {
|
||||
const [r, g, b] = hsl(0, 100, 0);
|
||||
expect(r).toBe(0);
|
||||
expect(g).toBe(0);
|
||||
expect(b).toBe(0);
|
||||
});
|
||||
it('produces white at lightness 100', () => {
|
||||
const [r, g, b] = hsl(0, 100, 100);
|
||||
expect(r).toBeCloseTo(1);
|
||||
expect(g).toBeCloseTo(1);
|
||||
expect(b).toBeCloseTo(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
import { vec3 } from 'gl-matrix';
|
||||
|
||||
import { rgb } from './rgb';
|
||||
|
||||
export const hsl = (hue: number, saturation: number, lightness: number): vec3 => {
|
||||
hue /= 360;
|
||||
saturation /= 100;
|
||||
lightness /= 100;
|
||||
let r: number, g: number, b: number;
|
||||
|
||||
if (saturation == 0) {
|
||||
r = g = b = lightness;
|
||||
} else {
|
||||
const hue2rgb = (p: number, q: number, t: number) => {
|
||||
if (t < 0) t += 1;
|
||||
if (t > 1) t -= 1;
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||
if (t < 1 / 2) return q;
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||
return p;
|
||||
};
|
||||
|
||||
const q =
|
||||
lightness < 0.5
|
||||
? lightness * (1 + saturation)
|
||||
: lightness + saturation - lightness * saturation;
|
||||
const p = 2 * lightness - q;
|
||||
|
||||
r = hue2rgb(p, q, hue + 1 / 3);
|
||||
g = hue2rgb(p, q, hue);
|
||||
b = hue2rgb(p, q, hue - 1 / 3);
|
||||
}
|
||||
|
||||
return rgb(r, g, b);
|
||||
};
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { clamp, clamp01 } from './clamp';
|
||||
import { exponentialDecay } from './exponential-decay';
|
||||
import { mix } from './mix';
|
||||
|
||||
describe('clamp', () => {
|
||||
it('returns value when within bounds', () => {
|
||||
expect(clamp(5, 0, 10)).toBe(5);
|
||||
});
|
||||
it('clamps below to lower bound', () => {
|
||||
expect(clamp(-3, 0, 10)).toBe(0);
|
||||
});
|
||||
it('clamps above to upper bound', () => {
|
||||
expect(clamp(42, 0, 10)).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clamp01', () => {
|
||||
it('passes through values in [0, 1]', () => {
|
||||
expect(clamp01(0.25)).toBe(0.25);
|
||||
});
|
||||
it('clamps negatives to 0', () => {
|
||||
expect(clamp01(-1)).toBe(0);
|
||||
});
|
||||
it('clamps above 1 to 1', () => {
|
||||
expect(clamp01(2)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mix', () => {
|
||||
it('returns from at q=0', () => {
|
||||
expect(mix(10, 20, 0)).toBe(10);
|
||||
});
|
||||
it('returns to at q=1', () => {
|
||||
expect(mix(10, 20, 1)).toBe(20);
|
||||
});
|
||||
it('interpolates at q=0.5', () => {
|
||||
expect(mix(10, 20, 0.5)).toBe(15);
|
||||
});
|
||||
it('extrapolates outside [0, 1]', () => {
|
||||
expect(mix(0, 10, 2)).toBe(20);
|
||||
expect(mix(0, 10, -1)).toBe(-10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('exponentialDecay', () => {
|
||||
it('returns nextValue when bias is 1', () => {
|
||||
expect(exponentialDecay({ accumulator: 0, nextValue: 10, biasOfNextValue: 1 })).toBe(
|
||||
10
|
||||
);
|
||||
});
|
||||
it('returns accumulator when bias is 0', () => {
|
||||
expect(exponentialDecay({ accumulator: 5, nextValue: 10, biasOfNextValue: 0 })).toBe(
|
||||
5
|
||||
);
|
||||
});
|
||||
it('blends with given bias', () => {
|
||||
expect(
|
||||
exponentialDecay({ accumulator: 0, nextValue: 10, biasOfNextValue: 0.25 })
|
||||
).toBe(2.5);
|
||||
});
|
||||
});
|
||||
29
src/utils/math.ts
Normal file
29
src/utils/math.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
export const clamp = (value: number, min: number, max: number): number =>
|
||||
Math.min(max, Math.max(min, value));
|
||||
|
||||
export const clamp01 = (value: number): number => clamp(value, 0, 1);
|
||||
|
||||
export const mix = (from: number, to: number, amount: number): number =>
|
||||
from + (to - from) * amount;
|
||||
|
||||
export const mixAngle = (from: number, to: number, amount: number): number => {
|
||||
const delta = Math.atan2(Math.sin(to - from), Math.cos(to - from));
|
||||
return from + delta * amount;
|
||||
};
|
||||
|
||||
export const approach = (
|
||||
current: number,
|
||||
target: number,
|
||||
elapsedSeconds: number,
|
||||
timeConstantSeconds: number
|
||||
): number => {
|
||||
const amount = 1 - Math.exp(-elapsedSeconds / Math.max(0.001, timeConstantSeconds));
|
||||
return mix(current, target, amount);
|
||||
};
|
||||
|
||||
export const smoothstep = (edge0: number, edge1: number, value: number): number => {
|
||||
const amount = clamp01((value - edge0) / (edge1 - edge0));
|
||||
return amount * amount * (3 - 2 * amount);
|
||||
};
|
||||
|
||||
export const easeOutQuad = (value: number): number => value * (2 - value);
|
||||
|
|
@ -1 +0,0 @@
|
|||
export const mix = (from: number, to: number, q: number) => from + (to - from) * q;
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
export const persist = <T extends Record<string, number>>(wrapee: T): T => {
|
||||
const keys = Object.keys(wrapee);
|
||||
keys.sort();
|
||||
|
||||
const keysToShortKeys = Object.fromEntries(keys.map((key) => [key, key]));
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const newParams = new URLSearchParams();
|
||||
keys.forEach((key) => {
|
||||
if (params.has(keysToShortKeys[key])) {
|
||||
(wrapee as any)[key] = Number(params.get(keysToShortKeys[key]));
|
||||
newParams.set(keysToShortKeys[key], params.get(keysToShortKeys[key])!);
|
||||
}
|
||||
});
|
||||
|
||||
window.history.replaceState(
|
||||
{},
|
||||
'',
|
||||
`${window.location.pathname}?${newParams.toString()}`
|
||||
);
|
||||
|
||||
return new Proxy(wrapee, {
|
||||
set: (target, key: string, value: number) => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
params.set(keysToShortKeys[key], value.toString());
|
||||
|
||||
(target as any)[key] = value;
|
||||
|
||||
window.history.replaceState(
|
||||
{},
|
||||
'',
|
||||
`${window.location.pathname}?${params.toString()}`
|
||||
);
|
||||
|
||||
return true;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { Random } from './random';
|
||||
|
||||
describe('Random', () => {
|
||||
beforeEach(() => {
|
||||
Random.seed = 42;
|
||||
});
|
||||
|
||||
it('produces values in [0, 1)', () => {
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const v = Random.getRandom();
|
||||
expect(v).toBeGreaterThanOrEqual(0);
|
||||
expect(v).toBeLessThan(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('is deterministic for the same seed', () => {
|
||||
Random.seed = 42;
|
||||
const a = Array.from({ length: 8 }, () => Random.getRandom());
|
||||
Random.seed = 42;
|
||||
const b = Array.from({ length: 8 }, () => Random.getRandom());
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
|
||||
it('produces different sequences for different seeds', () => {
|
||||
Random.seed = 1;
|
||||
const a = Array.from({ length: 4 }, () => Random.getRandom());
|
||||
Random.seed = 2;
|
||||
const b = Array.from({ length: 4 }, () => Random.getRandom());
|
||||
expect(a).not.toEqual(b);
|
||||
});
|
||||
|
||||
it('randomBetween stays within [from, to)', () => {
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const v = Random.randomBetween(-10, 10);
|
||||
expect(v).toBeGreaterThanOrEqual(-10);
|
||||
expect(v).toBeLessThan(10);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
// https://stackoverflow.com/questions/521295/seeding-the-random-number-generator-in-javascript, Mulberry32
|
||||
export abstract class Random {
|
||||
private static _seed = 42;
|
||||
|
||||
public static set seed(value: number) {
|
||||
Random._seed = value;
|
||||
}
|
||||
|
||||
public static getRandomInt(): number {
|
||||
let t = (Random._seed += 0x6d2b79f5);
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return (t ^ (t >>> 14)) >>> 0;
|
||||
}
|
||||
|
||||
public static getRandom(): number {
|
||||
return Random.getRandomInt() / 4294967296;
|
||||
}
|
||||
|
||||
public static randomBetween(from: number, to: number): number {
|
||||
return from + Random.getRandom() * (to - from);
|
||||
}
|
||||
}
|
||||
42
src/utils/rgb-color.ts
Normal file
42
src/utils/rgb-color.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
export type RgbColor = [red: number, green: number, blue: number];
|
||||
|
||||
const RGB_CHANNEL_MAX = 255;
|
||||
|
||||
const toFiniteRgbChannel = (value: number): number =>
|
||||
Number.isFinite(value) ? value : 0;
|
||||
|
||||
const clampRgbChannel = (value: number): number =>
|
||||
Math.min(RGB_CHANNEL_MAX, Math.max(0, Math.round(toFiniteRgbChannel(value))));
|
||||
|
||||
export const rgbColorToCss = ([red, green, blue]: RgbColor): string =>
|
||||
`rgb(${clampRgbChannel(red)}, ${clampRgbChannel(green)}, ${clampRgbChannel(blue)})`;
|
||||
|
||||
export const rgbColorToHex = ([red, green, blue]: RgbColor): string =>
|
||||
`#${[red, green, blue]
|
||||
.map((channel) => clampRgbChannel(channel).toString(16).padStart(2, '0'))
|
||||
.join('')}`;
|
||||
|
||||
export const hexColorToRgbColor = (value: string): RgbColor | null => {
|
||||
const match = value.trim().match(/^#?([0-9a-f]{3}|[0-9a-f]{6})$/i);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const shorthandOrHex = match[1];
|
||||
const hex =
|
||||
shorthandOrHex.length === 3
|
||||
? shorthandOrHex
|
||||
.split('')
|
||||
.map((channel) => `${channel}${channel}`)
|
||||
.join('')
|
||||
: shorthandOrHex;
|
||||
|
||||
return [
|
||||
Number.parseInt(hex.slice(0, 2), 16),
|
||||
Number.parseInt(hex.slice(2, 4), 16),
|
||||
Number.parseInt(hex.slice(4, 6), 16),
|
||||
];
|
||||
};
|
||||
|
||||
export const rgbChannelToUnit = (value: number): number =>
|
||||
Math.min(1, Math.max(0, toFiniteRgbChannel(value) / RGB_CHANNEL_MAX));
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
import { vec3 } from 'gl-matrix';
|
||||
|
||||
export const rgb = (r: number, g: number, b: number): vec3 => vec3.fromValues(r, g, b);
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export const sleep = (ms: number): Promise<void> => {
|
||||
return new Promise<void>((resolve, _) => setTimeout(resolve, ms));
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue