Add configurable vibe presets

This commit is contained in:
Andras Schmelczer 2026-05-24 10:57:47 +01:00
parent e54bddc7db
commit f8294934fd
34 changed files with 1701 additions and 341 deletions

View 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
);
}
};

View file

@ -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));

View file

@ -1,9 +0,0 @@
export const exponentialDecay = ({
accumulator,
nextValue,
biasOfNextValue,
}: {
accumulator: number;
nextValue: number;
biasOfNextValue: number;
}) => accumulator * (1 - biasOfNextValue) + nextValue * biasOfNextValue;

View file

@ -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');
});
});

View file

@ -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}`;
};

View file

@ -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);
});
});

View file

@ -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);
};

View file

@ -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
View 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);

View file

@ -1 +0,0 @@
export const mix = (from: number, to: number, q: number) => from + (to - from) * q;

View file

@ -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;
},
});
};

View file

@ -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);
}
});
});

View file

@ -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
View 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));

View file

@ -1,3 +0,0 @@
import { vec3 } from 'gl-matrix';
export const rgb = (r: number, g: number, b: number): vec3 => vec3.fromValues(r, g, b);

View file

@ -1,3 +0,0 @@
export const sleep = (ms: number): Promise<void> => {
return new Promise<void>((resolve, _) => setTimeout(resolve, ms));
};