Add configurable vibe presets
This commit is contained in:
parent
e54bddc7db
commit
f8294934fd
34 changed files with 1698 additions and 338 deletions
199
src/config.ts
Normal file
199
src/config.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
import {
|
||||||
|
createGardenAudioConfig,
|
||||||
|
DEFAULT_AUDIO_VOLUME,
|
||||||
|
} from './audio/garden-audio-config';
|
||||||
|
import { defaultSettings } from './config/default-settings';
|
||||||
|
import { runtimeControls } from './config/runtime-controls';
|
||||||
|
import type { GardenAppConfig } from './config/types';
|
||||||
|
import { defaultVibeId, vibePresets } from './config/vibe-presets';
|
||||||
|
|
||||||
|
export {
|
||||||
|
normalizeNumberControlValue,
|
||||||
|
normalizeRuntimeSettings,
|
||||||
|
} from './config/normalize-runtime-settings';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
GardenAppConfig,
|
||||||
|
GardenRuntimeSettings,
|
||||||
|
NumberControlConfig,
|
||||||
|
} from './config/types';
|
||||||
|
|
||||||
|
export const appConfig = {
|
||||||
|
audio: createGardenAudioConfig(),
|
||||||
|
analytics: {
|
||||||
|
autoCapturePageviews: true,
|
||||||
|
domain: 'fleeting.garden',
|
||||||
|
endpoint: 'https://stats.schmelczer.dev/status',
|
||||||
|
logging: import.meta.env.DEV,
|
||||||
|
},
|
||||||
|
deltaTime: {
|
||||||
|
maxDeltaTimeSeconds: 1 / 30,
|
||||||
|
minDeltaTimeSeconds: 1 / 240,
|
||||||
|
},
|
||||||
|
exportSnapshot: {
|
||||||
|
bytesPerPixel: 4,
|
||||||
|
filenameExtension: 'png',
|
||||||
|
filenamePrefix: 'fleeting-garden',
|
||||||
|
filenameSuffix: '-snapshot',
|
||||||
|
mimeType: 'image/png',
|
||||||
|
rowAlignmentBytes: 256,
|
||||||
|
},
|
||||||
|
menuHider: {
|
||||||
|
bottomRevealDistancePx: 96,
|
||||||
|
desktopMediaQuery: '(min-width: 600px) and (hover: hover) and (pointer: fine)',
|
||||||
|
hideDelayMs: 3000,
|
||||||
|
},
|
||||||
|
pipelines: {
|
||||||
|
common: {
|
||||||
|
noiseChannelSeeds: [0, 1, 2, 3],
|
||||||
|
noiseClearValue: { r: 1, g: 1, b: 1, a: 1 },
|
||||||
|
noiseDrawInstanceCount: 1,
|
||||||
|
noiseDrawVertexCount: 3,
|
||||||
|
noiseHashMultiplier: 43758.5453123,
|
||||||
|
noiseHashX: 12.9898,
|
||||||
|
noiseHashY: 78.233,
|
||||||
|
noiseTextureFormat: 'r8unorm',
|
||||||
|
noiseTextureSize: 2048,
|
||||||
|
},
|
||||||
|
brush: {
|
||||||
|
maxLineCount: 240,
|
||||||
|
},
|
||||||
|
diffusion: {
|
||||||
|
minDiffusionRate: 0.000001,
|
||||||
|
},
|
||||||
|
eraser: {
|
||||||
|
maxTextureLineCount: 384,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultSettings,
|
||||||
|
runtimeSettings: {
|
||||||
|
controls: runtimeControls,
|
||||||
|
},
|
||||||
|
simulation: {
|
||||||
|
brushEffectFramesPerSecond: 60,
|
||||||
|
clearColor: { r: 0, g: 0, b: 0, a: 0 },
|
||||||
|
initialAgentCount: 180_000,
|
||||||
|
// How long the source map continues to be diffused after a brush stroke ends.
|
||||||
|
// 600 frames at ~60 FPS ≈ 10 seconds.
|
||||||
|
sourceActiveFramesAfterWrite: 600,
|
||||||
|
intro: {
|
||||||
|
angleJitterRadians: Math.PI * 0.08,
|
||||||
|
angleEaseEnd: 1,
|
||||||
|
angleEaseStart: 0.6,
|
||||||
|
circleMaxSideRatio: 0.46,
|
||||||
|
circleMinSideRatio: 0.32,
|
||||||
|
drawHintDelayMs: 3000,
|
||||||
|
durationSeconds: 4,
|
||||||
|
entryJitterSideRatio: 0.035,
|
||||||
|
fontScaleDown: 0.94,
|
||||||
|
fontFamily: '"Open Sans", sans-serif',
|
||||||
|
initialFontHeightRatio: 0.28,
|
||||||
|
initialFontWidthRatio: 0.19,
|
||||||
|
letterSpacingEm: 0.07,
|
||||||
|
maskAlphaThreshold: 32,
|
||||||
|
maskGradientThreshold: 8,
|
||||||
|
maskMaxPixels: 1_000_000,
|
||||||
|
maskSampleDensity: 540,
|
||||||
|
maxHeightRatio: 0.25,
|
||||||
|
maxWidthRatio: 0.76,
|
||||||
|
minEntryJitterPx: 6,
|
||||||
|
minFontSizePx: 18,
|
||||||
|
minTargetJitterPx: 1,
|
||||||
|
pathEasing: 'easeOutQuad',
|
||||||
|
pathProgressEpsilon: 0.001,
|
||||||
|
radialJitterRatio: 0.35,
|
||||||
|
radialStartEpsilon: 0.001,
|
||||||
|
resizeMinimumRemainingSeconds: 1.4,
|
||||||
|
resizeSettleMs: 120,
|
||||||
|
targetDelayDistanceMultiplier: 0.12,
|
||||||
|
targetDelayMax: 0.22,
|
||||||
|
targetDelayRandomMultiplier: 0.06,
|
||||||
|
targetJitterSideRatio: 0.0035,
|
||||||
|
title: 'Fleeting',
|
||||||
|
titleColorCutLetters: [2, 5],
|
||||||
|
titleRadiusMultiplier: 1.55,
|
||||||
|
titleStrokeWidthMinPx: 6,
|
||||||
|
titleStrokeWidthRatio: 0.11,
|
||||||
|
verticalAnchor: 0.47,
|
||||||
|
},
|
||||||
|
introMoveSpeed: 280,
|
||||||
|
stroke: {
|
||||||
|
densityMultiplier: 110,
|
||||||
|
maxAgentCount: 2_400,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
storage: {
|
||||||
|
audioMutedKey: 'fleeting-garden:audio-muted',
|
||||||
|
audioVolumeKey: 'fleeting-garden:audio-volume',
|
||||||
|
vibeKey: 'fleeting-garden:vibe',
|
||||||
|
},
|
||||||
|
toolbar: {
|
||||||
|
eraser: {
|
||||||
|
controlScaleMax: 1.34,
|
||||||
|
controlScaleMin: 0.74,
|
||||||
|
default: 96,
|
||||||
|
max: 480,
|
||||||
|
min: 24,
|
||||||
|
step: 1,
|
||||||
|
},
|
||||||
|
mirror: {
|
||||||
|
default: 8,
|
||||||
|
fallbackSegmentName: 'slices',
|
||||||
|
max: 12,
|
||||||
|
min: 1,
|
||||||
|
names: {
|
||||||
|
2: 'halves',
|
||||||
|
3: 'thirds',
|
||||||
|
4: 'quarters',
|
||||||
|
5: 'fifths',
|
||||||
|
6: 'sixths',
|
||||||
|
7: 'sevenths',
|
||||||
|
8: 'eighths',
|
||||||
|
9: 'ninths',
|
||||||
|
10: 'tenths',
|
||||||
|
11: 'elevenths',
|
||||||
|
12: 'twelfths',
|
||||||
|
},
|
||||||
|
offLabel: 'Mirror off',
|
||||||
|
step: 1,
|
||||||
|
},
|
||||||
|
contrast: {
|
||||||
|
backgroundOpacityMax: 0.82,
|
||||||
|
brightLuminanceThreshold: 0.32,
|
||||||
|
brightWeight: 0.65,
|
||||||
|
bytesPerSample: 4,
|
||||||
|
contrastOffset: 0.05,
|
||||||
|
linearChannelBreakpoint: 0.03928,
|
||||||
|
linearChannelDivisor: 12.92,
|
||||||
|
linearChannelGamma: 2.4,
|
||||||
|
linearChannelOffset: 0.055,
|
||||||
|
linearChannelScale: 1.055,
|
||||||
|
lowContrastThreshold: 3,
|
||||||
|
lowContrastWeight: 1.8,
|
||||||
|
luminanceBase: 0.11,
|
||||||
|
luminanceBlueWeight: 0.0722,
|
||||||
|
luminanceGreenWeight: 0.7152,
|
||||||
|
luminanceRange: 0.28,
|
||||||
|
luminanceRedWeight: 0.2126,
|
||||||
|
sampleColumns: 13,
|
||||||
|
sampleIntervalMs: 300,
|
||||||
|
sampleRows: 7,
|
||||||
|
whiteContrastNumerator: 1.05,
|
||||||
|
},
|
||||||
|
volume: {
|
||||||
|
default: DEFAULT_AUDIO_VOLUME,
|
||||||
|
max: 1,
|
||||||
|
min: 0,
|
||||||
|
step: 0.01,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tuningPane: {
|
||||||
|
showFpsOverlay: import.meta.env.DEV,
|
||||||
|
startHidden: true,
|
||||||
|
title: 'Garden Settings',
|
||||||
|
},
|
||||||
|
vibes: {
|
||||||
|
defaultVibeId,
|
||||||
|
presets: vibePresets,
|
||||||
|
},
|
||||||
|
} satisfies GardenAppConfig;
|
||||||
27
src/config/brush-size.test.ts
Normal file
27
src/config/brush-size.test.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS,
|
||||||
|
getBrushRenderQualityScale,
|
||||||
|
getRenderQualityBrushSize,
|
||||||
|
} from './brush-size';
|
||||||
|
|
||||||
|
describe('render-quality brush sizing', () => {
|
||||||
|
it('keeps brush sizes unchanged at the 7.3 MP baseline', () => {
|
||||||
|
expect(
|
||||||
|
getRenderQualityBrushSize(21, BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS)
|
||||||
|
).toBe(21);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scales linear brush size with the square root of render area', () => {
|
||||||
|
const doubledLinearQuality = BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS * 4;
|
||||||
|
|
||||||
|
expect(getBrushRenderQualityScale(doubledLinearQuality)).toBe(2);
|
||||||
|
expect(getRenderQualityBrushSize(9.75, doubledLinearQuality)).toBe(19.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to baseline scaling for invalid render areas', () => {
|
||||||
|
expect(getBrushRenderQualityScale(0)).toBe(1);
|
||||||
|
expect(getRenderQualityBrushSize(6.5, Number.NaN)).toBe(6.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
19
src/config/brush-size.ts
Normal file
19
src/config/brush-size.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
export const BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS = 7.3;
|
||||||
|
|
||||||
|
const getSafeRenderAreaMegapixels = (renderAreaMegapixels: number): number =>
|
||||||
|
Number.isFinite(renderAreaMegapixels) && renderAreaMegapixels > 0
|
||||||
|
? renderAreaMegapixels
|
||||||
|
: BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS;
|
||||||
|
|
||||||
|
export const getBrushRenderQualityScale = (renderAreaMegapixels: number): number =>
|
||||||
|
Math.sqrt(
|
||||||
|
getSafeRenderAreaMegapixels(renderAreaMegapixels) /
|
||||||
|
BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getRenderQualityBrushSize = (
|
||||||
|
brushSize: number,
|
||||||
|
renderAreaMegapixels: number
|
||||||
|
): number =>
|
||||||
|
Math.max(0, Number.isFinite(brushSize) ? brushSize : 0) *
|
||||||
|
getBrushRenderQualityScale(renderAreaMegapixels);
|
||||||
14
src/config/color-interactions.ts
Normal file
14
src/config/color-interactions.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import type { NumberControlConfig } from './types';
|
||||||
|
|
||||||
|
export const colorInteractionControl = (label: string): NumberControlConfig => ({
|
||||||
|
folder: 'Color Reactions',
|
||||||
|
label,
|
||||||
|
min: -1,
|
||||||
|
max: 1,
|
||||||
|
step: 1,
|
||||||
|
options: {
|
||||||
|
'Move Toward': 1,
|
||||||
|
Ignore: 0,
|
||||||
|
'Move Away': -1,
|
||||||
|
},
|
||||||
|
});
|
||||||
74
src/config/default-settings.ts
Normal file
74
src/config/default-settings.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS } from './runtime-setting-bounds';
|
||||||
|
import type { GardenAppConfig } from './types';
|
||||||
|
|
||||||
|
// Mirrors the historical render-scale cap so the default render area stays
|
||||||
|
// roughly equivalent to native rendering on high-DPR phones without the
|
||||||
|
// pipeline applying its own clamp. The slider can override freely.
|
||||||
|
const DEFAULT_DEVICE_PIXEL_RATIO_CAP = 2;
|
||||||
|
|
||||||
|
const computeDefaultInternalRenderAreaMegapixels = (): number => {
|
||||||
|
const rawDpr =
|
||||||
|
typeof window !== 'undefined' && Number.isFinite(window.devicePixelRatio)
|
||||||
|
? window.devicePixelRatio
|
||||||
|
: 1;
|
||||||
|
const dpr = Math.min(Math.max(rawDpr, 1), DEFAULT_DEVICE_PIXEL_RATIO_CAP);
|
||||||
|
const cssWidth = typeof window !== 'undefined' ? window.innerWidth : 1920;
|
||||||
|
const cssHeight = typeof window !== 'undefined' ? window.innerHeight : 1080;
|
||||||
|
const cssMegapixels = (Math.max(cssWidth, 1) * Math.max(cssHeight, 1)) / 1_000_000;
|
||||||
|
return Math.min(
|
||||||
|
INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS.max,
|
||||||
|
Math.max(INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS.min, dpr * dpr * cssMegapixels)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const defaultSettings: GardenAppConfig['defaultSettings'] = {
|
||||||
|
selectedColorIndex: 0,
|
||||||
|
|
||||||
|
introNearDistanceMin: 28,
|
||||||
|
introNearDistanceInner: 4,
|
||||||
|
introNearSensorOffsetMultiplier: 0.75,
|
||||||
|
introTargetAngleBlend: 0.2,
|
||||||
|
introProgressCutoff: 0.999,
|
||||||
|
introTurnRateMultiplier: 3.4,
|
||||||
|
introRandomTurnMultiplier: 0.18,
|
||||||
|
introStepStopDistance: 0.5,
|
||||||
|
randomTimeScale: 0.34816,
|
||||||
|
|
||||||
|
diffusionRateTrails: 0.22,
|
||||||
|
decayRateBrush: 18,
|
||||||
|
diffusionDecayRateDivisor: 1000,
|
||||||
|
diffusionNeighborDivisor: 8,
|
||||||
|
brushDecayAlphaOffset: 1.001,
|
||||||
|
brushEffectDuration: 8,
|
||||||
|
|
||||||
|
brushCurveResolution: 12,
|
||||||
|
brushCurveMinBrushRadius: 1,
|
||||||
|
brushCurveMinSegmentSpacing: 4,
|
||||||
|
brushCurveMirrorResolutionExponent: 0.5,
|
||||||
|
brushCurveSegmentBrushRadiusRatio: 0.65,
|
||||||
|
brushSmoothingMinSampleDistance: 0.5,
|
||||||
|
|
||||||
|
brushAlpha: 1,
|
||||||
|
brushDiscardThreshold: 0.02,
|
||||||
|
brushGrainNoiseScale: 22,
|
||||||
|
brushGrainNoiseOffsetX: 0.31,
|
||||||
|
brushGrainNoiseOffsetY: 0.67,
|
||||||
|
brushGrainMinStrength: 0.45,
|
||||||
|
brushGrainMaxStrength: 1,
|
||||||
|
|
||||||
|
eraserClearAlpha: 0,
|
||||||
|
eraserClearBlue: 0,
|
||||||
|
eraserClearGreen: 0,
|
||||||
|
eraserClearRed: 0,
|
||||||
|
eraserLineDistanceEpsilon: 0.0001,
|
||||||
|
eraserMaskAlphaThreshold: 0.5,
|
||||||
|
|
||||||
|
adaptiveCapInitial: 1_000_000,
|
||||||
|
adaptiveCapMin: 50_000,
|
||||||
|
internalRenderAreaMegapixels: computeDefaultInternalRenderAreaMegapixels(),
|
||||||
|
maxAgentCount: 1_500_000,
|
||||||
|
|
||||||
|
renderTraceNormalizationFloor: 1,
|
||||||
|
renderBrushColorBase: 1.2,
|
||||||
|
renderBrushColorStrengthMultiplier: 1.6,
|
||||||
|
};
|
||||||
65
src/config/normalize-runtime-settings.test.ts
Normal file
65
src/config/normalize-runtime-settings.test.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
normalizeNumberControlValue,
|
||||||
|
normalizeRuntimeSettings,
|
||||||
|
} from './normalize-runtime-settings';
|
||||||
|
import type { GardenRuntimeSettings } from './types';
|
||||||
|
|
||||||
|
describe('normalizeNumberControlValue', () => {
|
||||||
|
it('clamps and rounds numeric controls', () => {
|
||||||
|
expect(
|
||||||
|
normalizeNumberControlValue(12.6, {
|
||||||
|
folder: 'Test',
|
||||||
|
integer: true,
|
||||||
|
max: 10,
|
||||||
|
min: 0,
|
||||||
|
})
|
||||||
|
).toBe(10);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeNumberControlValue(Number.NaN, {
|
||||||
|
folder: 'Test',
|
||||||
|
min: 3,
|
||||||
|
})
|
||||||
|
).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps only declared option values', () => {
|
||||||
|
expect(
|
||||||
|
normalizeNumberControlValue(2, {
|
||||||
|
folder: 'Test',
|
||||||
|
options: { off: 0, on: 2 },
|
||||||
|
})
|
||||||
|
).toBe(2);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeNumberControlValue(3, {
|
||||||
|
folder: 'Test',
|
||||||
|
options: { off: 0, on: 2 },
|
||||||
|
})
|
||||||
|
).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('normalizeRuntimeSettings', () => {
|
||||||
|
it('normalizes configured runtime keys and leaves hidden keys alone', () => {
|
||||||
|
const settings = {
|
||||||
|
brushSize: 99,
|
||||||
|
selectedColorIndex: 7,
|
||||||
|
} as GardenRuntimeSettings;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeRuntimeSettings(settings, {
|
||||||
|
brushSize: {
|
||||||
|
folder: 'Brush',
|
||||||
|
max: 12,
|
||||||
|
min: 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).toMatchObject({
|
||||||
|
brushSize: 12,
|
||||||
|
selectedColorIndex: 7,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
46
src/config/normalize-runtime-settings.ts
Normal file
46
src/config/normalize-runtime-settings.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import type {
|
||||||
|
GardenAppConfig,
|
||||||
|
GardenRuntimeSettings,
|
||||||
|
NumberControlConfig,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
type RuntimeSettingControls = GardenAppConfig['runtimeSettings']['controls'];
|
||||||
|
|
||||||
|
export const normalizeNumberControlValue = (
|
||||||
|
value: number,
|
||||||
|
config: NumberControlConfig
|
||||||
|
): number => {
|
||||||
|
if (config.options) {
|
||||||
|
const optionValues = Object.values(config.options);
|
||||||
|
if (optionValues.includes(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return optionValues.includes(0) ? 0 : (optionValues[0] ?? config.min ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const min = config.min ?? Number.NEGATIVE_INFINITY;
|
||||||
|
const max = config.max ?? Number.POSITIVE_INFINITY;
|
||||||
|
const fallbackValue = config.min ?? 0;
|
||||||
|
const finiteValue = Number.isFinite(value) ? value : fallbackValue;
|
||||||
|
const clampedValue = Math.min(max, Math.max(min, finiteValue));
|
||||||
|
return config.integer ? Math.round(clampedValue) : clampedValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeRuntimeSettings = (
|
||||||
|
settings: GardenRuntimeSettings,
|
||||||
|
controls: RuntimeSettingControls
|
||||||
|
): GardenRuntimeSettings => {
|
||||||
|
const normalized = { ...settings };
|
||||||
|
|
||||||
|
(
|
||||||
|
Object.entries(controls) as Array<
|
||||||
|
[keyof GardenRuntimeSettings, NumberControlConfig | undefined]
|
||||||
|
>
|
||||||
|
).forEach(([key, config]) => {
|
||||||
|
if (config) {
|
||||||
|
normalized[key] = normalizeNumberControlValue(normalized[key], config);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
};
|
||||||
147
src/config/runtime-controls.ts
Normal file
147
src/config/runtime-controls.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
import { colorInteractionControl } from './color-interactions';
|
||||||
|
import { INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS } from './runtime-setting-bounds';
|
||||||
|
import type { GardenAppConfig } from './types';
|
||||||
|
|
||||||
|
const formatPercent = (value: number): string => `${Math.round(value * 100)}%`;
|
||||||
|
const formatRadiansAsDegrees = (value: number): string =>
|
||||||
|
`${Math.round((value * 180) / Math.PI)} deg`;
|
||||||
|
const formatCompactNumber = (value: number): string => {
|
||||||
|
if (value >= 1_000_000) {
|
||||||
|
const millions = value / 1_000_000;
|
||||||
|
return `${Number.isInteger(millions) ? millions : millions.toFixed(1)}M`;
|
||||||
|
}
|
||||||
|
if (value >= 1_000) {
|
||||||
|
return `${Math.round(value / 1_000)}k`;
|
||||||
|
}
|
||||||
|
return `${value}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
|
||||||
|
color1ToColor1: colorInteractionControl('Color 1 Follows Color 1'),
|
||||||
|
color1ToColor2: colorInteractionControl('Color 1 Follows Color 2'),
|
||||||
|
color1ToColor3: colorInteractionControl('Color 1 Follows Color 3'),
|
||||||
|
color2ToColor1: colorInteractionControl('Color 2 Follows Color 1'),
|
||||||
|
color2ToColor2: colorInteractionControl('Color 2 Follows Color 2'),
|
||||||
|
color2ToColor3: colorInteractionControl('Color 2 Follows Color 3'),
|
||||||
|
color3ToColor1: colorInteractionControl('Color 3 Follows Color 1'),
|
||||||
|
color3ToColor2: colorInteractionControl('Color 3 Follows Color 2'),
|
||||||
|
color3ToColor3: colorInteractionControl('Color 3 Follows Color 3'),
|
||||||
|
|
||||||
|
brushSize: {
|
||||||
|
folder: 'Brush',
|
||||||
|
label: 'Brush Size',
|
||||||
|
min: 1,
|
||||||
|
max: 36,
|
||||||
|
step: 0.25,
|
||||||
|
},
|
||||||
|
spawnPerPixel: {
|
||||||
|
folder: 'Brush',
|
||||||
|
label: 'Density',
|
||||||
|
min: 0.01,
|
||||||
|
max: 0.38,
|
||||||
|
step: 0.001,
|
||||||
|
},
|
||||||
|
strokeAngleJitterRadians: {
|
||||||
|
folder: 'Brush',
|
||||||
|
format: formatRadiansAsDegrees,
|
||||||
|
label: 'Spawn Spread',
|
||||||
|
min: 0,
|
||||||
|
max: Math.PI,
|
||||||
|
step: 0.01,
|
||||||
|
},
|
||||||
|
sensorOffsetDistance: {
|
||||||
|
folder: 'Movement',
|
||||||
|
label: 'Sensor Reach',
|
||||||
|
min: 0,
|
||||||
|
max: 200,
|
||||||
|
step: 1,
|
||||||
|
},
|
||||||
|
sensorOffsetAngle: {
|
||||||
|
folder: 'Movement',
|
||||||
|
label: 'Sensor Angle',
|
||||||
|
min: 0,
|
||||||
|
max: 180,
|
||||||
|
step: 1,
|
||||||
|
},
|
||||||
|
moveSpeed: {
|
||||||
|
folder: 'Movement',
|
||||||
|
label: 'Travel Speed',
|
||||||
|
min: 10,
|
||||||
|
max: 500,
|
||||||
|
step: 1,
|
||||||
|
},
|
||||||
|
turnSpeed: {
|
||||||
|
folder: 'Movement',
|
||||||
|
label: 'Turning Speed',
|
||||||
|
min: 1,
|
||||||
|
max: 200,
|
||||||
|
step: 1,
|
||||||
|
},
|
||||||
|
forwardRotationScale: {
|
||||||
|
folder: 'Movement',
|
||||||
|
format: formatPercent,
|
||||||
|
label: 'Forward Focus',
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
step: 0.01,
|
||||||
|
},
|
||||||
|
turnWhenLost: {
|
||||||
|
folder: 'Movement',
|
||||||
|
label: 'Wander Turn',
|
||||||
|
min: 0,
|
||||||
|
max: Math.PI * 2,
|
||||||
|
step: 0.01,
|
||||||
|
},
|
||||||
|
individualTrailWeight: {
|
||||||
|
folder: 'Movement',
|
||||||
|
label: 'Trail Strength',
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
step: 0.001,
|
||||||
|
},
|
||||||
|
diffusionRateTrails: {
|
||||||
|
folder: 'Movement',
|
||||||
|
label: 'Diffusion Rate',
|
||||||
|
min: 0.01,
|
||||||
|
max: 1,
|
||||||
|
step: 0.01,
|
||||||
|
},
|
||||||
|
decayRateTrails: {
|
||||||
|
folder: 'Movement',
|
||||||
|
label: 'Trail Fade',
|
||||||
|
min: 800,
|
||||||
|
max: 1000,
|
||||||
|
step: 1,
|
||||||
|
},
|
||||||
|
|
||||||
|
clarity: {
|
||||||
|
folder: 'Look',
|
||||||
|
label: 'Sharpness',
|
||||||
|
min: 0.00001,
|
||||||
|
max: 1,
|
||||||
|
step: 0.001,
|
||||||
|
},
|
||||||
|
backgroundGrainStrength: {
|
||||||
|
folder: 'Look',
|
||||||
|
label: 'Background Grain',
|
||||||
|
min: 0,
|
||||||
|
max: 0.12,
|
||||||
|
step: 0.001,
|
||||||
|
},
|
||||||
|
|
||||||
|
maxAgentCount: {
|
||||||
|
folder: 'Performance',
|
||||||
|
format: formatCompactNumber,
|
||||||
|
integer: true,
|
||||||
|
label: 'Population Limit',
|
||||||
|
min: 0,
|
||||||
|
step: 10_000,
|
||||||
|
},
|
||||||
|
internalRenderAreaMegapixels: {
|
||||||
|
folder: 'Performance',
|
||||||
|
label: 'Render Quality (MP)',
|
||||||
|
min: INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS.min,
|
||||||
|
max: INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS.max,
|
||||||
|
step: 0.1,
|
||||||
|
},
|
||||||
|
};
|
||||||
4
src/config/runtime-setting-bounds.ts
Normal file
4
src/config/runtime-setting-bounds.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
export const INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS = {
|
||||||
|
min: 0.5,
|
||||||
|
max: 16.6,
|
||||||
|
} as const;
|
||||||
268
src/config/types.ts
Normal file
268
src/config/types.ts
Normal file
|
|
@ -0,0 +1,268 @@
|
||||||
|
import type {
|
||||||
|
GardenAudioConfig,
|
||||||
|
GardenAudioVibeSettings,
|
||||||
|
} from '../audio/garden-audio-config';
|
||||||
|
import type { AgentSettings } from '../pipelines/agents/agent-pipeline';
|
||||||
|
import type { BrushSettings } from '../pipelines/brush/brush-pipeline';
|
||||||
|
import type { DiffusionSettings } from '../pipelines/diffusion/diffusion-pipeline';
|
||||||
|
import type { RenderSettings } from '../pipelines/render/render-pipeline';
|
||||||
|
import type { RgbColor } from '../utils/rgb-color';
|
||||||
|
|
||||||
|
export interface NumberControlConfig {
|
||||||
|
format?: (value: number) => string;
|
||||||
|
folder: string;
|
||||||
|
integer?: boolean;
|
||||||
|
label?: string;
|
||||||
|
max?: number;
|
||||||
|
min?: number;
|
||||||
|
options?: Record<string, number>;
|
||||||
|
step?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GardenRuntimeSettings = {
|
||||||
|
adaptiveCapInitial: number;
|
||||||
|
adaptiveCapMin: number;
|
||||||
|
backgroundGrainStrength: number;
|
||||||
|
brushCurveResolution: number;
|
||||||
|
brushCurveMinBrushRadius: number;
|
||||||
|
brushCurveMinSegmentSpacing: number;
|
||||||
|
brushCurveMirrorResolutionExponent: number;
|
||||||
|
brushCurveSegmentBrushRadiusRatio: number;
|
||||||
|
brushEffectDuration: number;
|
||||||
|
brushSmoothingMinSampleDistance: number;
|
||||||
|
eraserClearAlpha: number;
|
||||||
|
eraserClearBlue: number;
|
||||||
|
eraserClearGreen: number;
|
||||||
|
eraserClearRed: number;
|
||||||
|
eraserLineDistanceEpsilon: number;
|
||||||
|
eraserMaskAlphaThreshold: number;
|
||||||
|
eraserSize: number;
|
||||||
|
internalRenderAreaMegapixels: number;
|
||||||
|
mirrorSegmentCount: number;
|
||||||
|
maxAgentCount: number;
|
||||||
|
selectedColorIndex: number;
|
||||||
|
spawnPerPixel: number;
|
||||||
|
strokeAngleJitterRadians: number;
|
||||||
|
} & AgentSettings &
|
||||||
|
BrushSettings &
|
||||||
|
DiffusionSettings &
|
||||||
|
RenderSettings;
|
||||||
|
|
||||||
|
type RuntimeSettingControlConfig = Partial<
|
||||||
|
Record<keyof GardenRuntimeSettings, NumberControlConfig>
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type GardenVibeSettings = Pick<
|
||||||
|
GardenRuntimeSettings,
|
||||||
|
| 'backgroundGrainStrength'
|
||||||
|
| 'brushSize'
|
||||||
|
| 'clarity'
|
||||||
|
| 'color1ToColor1'
|
||||||
|
| 'color1ToColor2'
|
||||||
|
| 'color1ToColor3'
|
||||||
|
| 'color2ToColor1'
|
||||||
|
| 'color2ToColor2'
|
||||||
|
| 'color2ToColor3'
|
||||||
|
| 'color3ToColor1'
|
||||||
|
| 'color3ToColor2'
|
||||||
|
| 'color3ToColor3'
|
||||||
|
| 'decayRateTrails'
|
||||||
|
| 'forwardRotationScale'
|
||||||
|
| 'individualTrailWeight'
|
||||||
|
| 'moveSpeed'
|
||||||
|
| 'sensorOffsetAngle'
|
||||||
|
| 'sensorOffsetDistance'
|
||||||
|
| 'spawnPerPixel'
|
||||||
|
| 'strokeAngleJitterRadians'
|
||||||
|
| 'turnSpeed'
|
||||||
|
| 'turnWhenLost'
|
||||||
|
>;
|
||||||
|
|
||||||
|
type GardenDefaultSettings = Omit<
|
||||||
|
GardenRuntimeSettings,
|
||||||
|
keyof GardenVibeSettings | 'eraserSize' | 'mirrorSegmentCount'
|
||||||
|
>;
|
||||||
|
|
||||||
|
export enum VibeId {
|
||||||
|
AuroraMycelium = 'aurora-mycelium',
|
||||||
|
VelvetObservatory = 'velvet-observatory',
|
||||||
|
LichenSignal = 'lichen-signal',
|
||||||
|
TidepoolLantern = 'tidepool-lantern',
|
||||||
|
PaperLanternFog = 'paper-lantern-fog',
|
||||||
|
ChromePollen = 'chrome-pollen',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VibePreset {
|
||||||
|
id: VibeId;
|
||||||
|
name: string;
|
||||||
|
colors: [RgbColor, RgbColor, RgbColor];
|
||||||
|
backgroundColor: RgbColor;
|
||||||
|
settings: GardenVibeSettings;
|
||||||
|
audio: GardenAudioVibeSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GardenAppConfig {
|
||||||
|
audio: GardenAudioConfig;
|
||||||
|
analytics: {
|
||||||
|
autoCapturePageviews: boolean;
|
||||||
|
domain: string;
|
||||||
|
endpoint: string;
|
||||||
|
logging: boolean;
|
||||||
|
};
|
||||||
|
deltaTime: {
|
||||||
|
maxDeltaTimeSeconds: number;
|
||||||
|
minDeltaTimeSeconds: number;
|
||||||
|
};
|
||||||
|
exportSnapshot: {
|
||||||
|
bytesPerPixel: number;
|
||||||
|
filenameExtension: string;
|
||||||
|
filenamePrefix: string;
|
||||||
|
filenameSuffix: string;
|
||||||
|
mimeType: string;
|
||||||
|
rowAlignmentBytes: number;
|
||||||
|
};
|
||||||
|
menuHider: {
|
||||||
|
bottomRevealDistancePx: number;
|
||||||
|
desktopMediaQuery: string;
|
||||||
|
hideDelayMs: number;
|
||||||
|
};
|
||||||
|
pipelines: {
|
||||||
|
common: {
|
||||||
|
noiseChannelSeeds: [number, number, number, number];
|
||||||
|
noiseClearValue: GPUColor;
|
||||||
|
noiseDrawInstanceCount: number;
|
||||||
|
noiseDrawVertexCount: number;
|
||||||
|
noiseHashMultiplier: number;
|
||||||
|
noiseHashX: number;
|
||||||
|
noiseHashY: number;
|
||||||
|
noiseTextureFormat: GPUTextureFormat;
|
||||||
|
noiseTextureSize: number;
|
||||||
|
};
|
||||||
|
brush: {
|
||||||
|
maxLineCount: number;
|
||||||
|
};
|
||||||
|
diffusion: {
|
||||||
|
minDiffusionRate: number;
|
||||||
|
};
|
||||||
|
eraser: {
|
||||||
|
maxTextureLineCount: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
defaultSettings: GardenDefaultSettings;
|
||||||
|
runtimeSettings: {
|
||||||
|
controls: RuntimeSettingControlConfig;
|
||||||
|
};
|
||||||
|
simulation: {
|
||||||
|
brushEffectFramesPerSecond: number;
|
||||||
|
clearColor: GPUColor;
|
||||||
|
initialAgentCount: number;
|
||||||
|
sourceActiveFramesAfterWrite: number;
|
||||||
|
intro: {
|
||||||
|
angleJitterRadians: number;
|
||||||
|
angleEaseEnd: number;
|
||||||
|
angleEaseStart: number;
|
||||||
|
circleMaxSideRatio: number;
|
||||||
|
circleMinSideRatio: number;
|
||||||
|
drawHintDelayMs: number;
|
||||||
|
durationSeconds: number;
|
||||||
|
entryJitterSideRatio: number;
|
||||||
|
fontScaleDown: number;
|
||||||
|
fontFamily: string;
|
||||||
|
initialFontHeightRatio: number;
|
||||||
|
initialFontWidthRatio: number;
|
||||||
|
letterSpacingEm: number;
|
||||||
|
maskAlphaThreshold: number;
|
||||||
|
maskGradientThreshold: number;
|
||||||
|
maskMaxPixels: number;
|
||||||
|
maskSampleDensity: number;
|
||||||
|
maxHeightRatio: number;
|
||||||
|
maxWidthRatio: number;
|
||||||
|
minEntryJitterPx: number;
|
||||||
|
minFontSizePx: number;
|
||||||
|
minTargetJitterPx: number;
|
||||||
|
pathEasing: 'easeOutQuad' | 'linear';
|
||||||
|
pathProgressEpsilon: number;
|
||||||
|
radialJitterRatio: number;
|
||||||
|
radialStartEpsilon: number;
|
||||||
|
resizeMinimumRemainingSeconds: number;
|
||||||
|
resizeSettleMs: number;
|
||||||
|
targetDelayDistanceMultiplier: number;
|
||||||
|
targetDelayMax: number;
|
||||||
|
targetDelayRandomMultiplier: number;
|
||||||
|
targetJitterSideRatio: number;
|
||||||
|
title: string;
|
||||||
|
titleColorCutLetters: [number, number];
|
||||||
|
titleRadiusMultiplier: number;
|
||||||
|
titleStrokeWidthMinPx: number;
|
||||||
|
titleStrokeWidthRatio: number;
|
||||||
|
verticalAnchor: number;
|
||||||
|
};
|
||||||
|
introMoveSpeed: number;
|
||||||
|
stroke: {
|
||||||
|
densityMultiplier: number;
|
||||||
|
maxAgentCount: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
storage: {
|
||||||
|
audioMutedKey: string;
|
||||||
|
audioVolumeKey: string;
|
||||||
|
vibeKey: string;
|
||||||
|
};
|
||||||
|
toolbar: {
|
||||||
|
eraser: {
|
||||||
|
controlScaleMax: number;
|
||||||
|
controlScaleMin: number;
|
||||||
|
default: number;
|
||||||
|
max: number;
|
||||||
|
min: number;
|
||||||
|
step: number;
|
||||||
|
};
|
||||||
|
mirror: {
|
||||||
|
default: number;
|
||||||
|
fallbackSegmentName: string;
|
||||||
|
max: number;
|
||||||
|
min: number;
|
||||||
|
names: Record<number, string>;
|
||||||
|
offLabel: string;
|
||||||
|
step: number;
|
||||||
|
};
|
||||||
|
contrast: {
|
||||||
|
backgroundOpacityMax: number;
|
||||||
|
brightLuminanceThreshold: number;
|
||||||
|
brightWeight: number;
|
||||||
|
bytesPerSample: number;
|
||||||
|
contrastOffset: number;
|
||||||
|
linearChannelBreakpoint: number;
|
||||||
|
linearChannelDivisor: number;
|
||||||
|
linearChannelGamma: number;
|
||||||
|
linearChannelOffset: number;
|
||||||
|
linearChannelScale: number;
|
||||||
|
lowContrastThreshold: number;
|
||||||
|
lowContrastWeight: number;
|
||||||
|
luminanceBase: number;
|
||||||
|
luminanceBlueWeight: number;
|
||||||
|
luminanceGreenWeight: number;
|
||||||
|
luminanceRange: number;
|
||||||
|
luminanceRedWeight: number;
|
||||||
|
sampleColumns: number;
|
||||||
|
sampleIntervalMs: number;
|
||||||
|
sampleRows: number;
|
||||||
|
whiteContrastNumerator: number;
|
||||||
|
};
|
||||||
|
volume: {
|
||||||
|
default: number;
|
||||||
|
max: number;
|
||||||
|
min: number;
|
||||||
|
step: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
tuningPane: {
|
||||||
|
showFpsOverlay: boolean;
|
||||||
|
startHidden: boolean;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
vibes: {
|
||||||
|
defaultVibeId: VibeId;
|
||||||
|
presets: Array<VibePreset>;
|
||||||
|
};
|
||||||
|
}
|
||||||
81
src/config/vibe-presets.test.ts
Normal file
81
src/config/vibe-presets.test.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { runtimeControls } from './runtime-controls';
|
||||||
|
import { vibePresets } from './vibe-presets';
|
||||||
|
|
||||||
|
const FINAL_VIBE_NAMES = [
|
||||||
|
'Aurora Mycelium Copy',
|
||||||
|
'Velvet Observatory Copy',
|
||||||
|
'Lichen Signal',
|
||||||
|
'Tidepool Lantern',
|
||||||
|
'Paper Lantern Fog',
|
||||||
|
'Chrome Pollen',
|
||||||
|
];
|
||||||
|
|
||||||
|
const BLENDED_BRUSH_SIZE_MIN = 17;
|
||||||
|
const BLENDED_CLARITY_MAX = 0.56;
|
||||||
|
const SOFT_PARTICLE_BRUSH_SIZE_MAX = 5;
|
||||||
|
const SOFT_PARTICLE_CLARITY_MAX = 0.2;
|
||||||
|
|
||||||
|
// Performance guardrails — bumping any of these is an explicit perf trade-off.
|
||||||
|
const MAX_SPAWN_PER_PIXEL = runtimeControls.spawnPerPixel?.max ?? 0.38;
|
||||||
|
const MAX_BRUSH_SIZE = runtimeControls.brushSize?.max ?? 36;
|
||||||
|
const HIGH_DENSITY_SPAWN_THRESHOLD = 0.28;
|
||||||
|
const HIGH_DENSITY_DECAY_LIMIT = 940;
|
||||||
|
const HIGH_DENSITY_BRUSH_SIZE_LIMIT = 14;
|
||||||
|
const HIGH_DENSITY_TRAIL_WEIGHT_LIMIT = 0.055;
|
||||||
|
|
||||||
|
describe('vibePresets', () => {
|
||||||
|
it('keeps the classic preset set distinct', () => {
|
||||||
|
expect(vibePresets.map((preset) => preset.name)).toEqual(FINAL_VIBE_NAMES);
|
||||||
|
|
||||||
|
const ids = vibePresets.map((preset) => preset.id);
|
||||||
|
expect(new Set(ids).size).toBe(vibePresets.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes both blended and visibly particulate styles', () => {
|
||||||
|
const blendedNames = vibePresets
|
||||||
|
.filter(
|
||||||
|
(preset) =>
|
||||||
|
preset.settings.brushSize >= BLENDED_BRUSH_SIZE_MIN &&
|
||||||
|
preset.settings.clarity <= BLENDED_CLARITY_MAX
|
||||||
|
)
|
||||||
|
.map((preset) => preset.name);
|
||||||
|
const softParticleNames = vibePresets
|
||||||
|
.filter(
|
||||||
|
(preset) =>
|
||||||
|
preset.settings.brushSize <= SOFT_PARTICLE_BRUSH_SIZE_MAX &&
|
||||||
|
preset.settings.clarity <= SOFT_PARTICLE_CLARITY_MAX
|
||||||
|
)
|
||||||
|
.map((preset) => preset.name);
|
||||||
|
|
||||||
|
expect(blendedNames).toEqual(['Tidepool Lantern']);
|
||||||
|
expect(softParticleNames).toEqual(['Chrome Pollen']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays inside interactive performance guardrails', () => {
|
||||||
|
const violations = vibePresets.flatMap((preset) => {
|
||||||
|
const { name, settings } = preset;
|
||||||
|
const presetViolations: Array<string> = [];
|
||||||
|
|
||||||
|
if (settings.spawnPerPixel > MAX_SPAWN_PER_PIXEL) {
|
||||||
|
presetViolations.push(`${name} density exceeds ${MAX_SPAWN_PER_PIXEL}`);
|
||||||
|
}
|
||||||
|
if (settings.brushSize > MAX_BRUSH_SIZE) {
|
||||||
|
presetViolations.push(`${name} brush size exceeds ${MAX_BRUSH_SIZE}`);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
settings.spawnPerPixel >= HIGH_DENSITY_SPAWN_THRESHOLD &&
|
||||||
|
(settings.decayRateTrails > HIGH_DENSITY_DECAY_LIMIT ||
|
||||||
|
settings.brushSize > HIGH_DENSITY_BRUSH_SIZE_LIMIT ||
|
||||||
|
settings.individualTrailWeight > HIGH_DENSITY_TRAIL_WEIGHT_LIMIT)
|
||||||
|
) {
|
||||||
|
presetViolations.push(`${name} combines high density with too much persistence`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return presetViolations;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(violations).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
366
src/config/vibe-presets.ts
Normal file
366
src/config/vibe-presets.ts
Normal file
|
|
@ -0,0 +1,366 @@
|
||||||
|
import {
|
||||||
|
defaultGardenAudioVibeSettings,
|
||||||
|
type GardenAudioChord,
|
||||||
|
} from '../audio/garden-audio-config';
|
||||||
|
import { VibeId, type GardenVibeSettings, type VibePreset } from './types';
|
||||||
|
|
||||||
|
type ColorReactionSettings = Pick<
|
||||||
|
GardenVibeSettings,
|
||||||
|
| 'color1ToColor1'
|
||||||
|
| 'color1ToColor2'
|
||||||
|
| 'color1ToColor3'
|
||||||
|
| 'color2ToColor1'
|
||||||
|
| 'color2ToColor2'
|
||||||
|
| 'color2ToColor3'
|
||||||
|
| 'color3ToColor1'
|
||||||
|
| 'color3ToColor2'
|
||||||
|
| 'color3ToColor3'
|
||||||
|
>;
|
||||||
|
|
||||||
|
const colorReactions = {
|
||||||
|
auroraMycelium: {
|
||||||
|
color1ToColor1: 1,
|
||||||
|
color1ToColor2: 0,
|
||||||
|
color1ToColor3: 0,
|
||||||
|
color2ToColor1: -1,
|
||||||
|
color2ToColor2: 1,
|
||||||
|
color2ToColor3: 0,
|
||||||
|
color3ToColor1: -1,
|
||||||
|
color3ToColor2: -1,
|
||||||
|
color3ToColor3: 1,
|
||||||
|
},
|
||||||
|
velvetObservatory: {
|
||||||
|
color1ToColor1: 1,
|
||||||
|
color1ToColor2: -1,
|
||||||
|
color1ToColor3: -1,
|
||||||
|
color2ToColor1: -1,
|
||||||
|
color2ToColor2: 1,
|
||||||
|
color2ToColor3: -1,
|
||||||
|
color3ToColor1: -1,
|
||||||
|
color3ToColor2: -1,
|
||||||
|
color3ToColor3: 1,
|
||||||
|
},
|
||||||
|
lichenSignal: {
|
||||||
|
color1ToColor1: 0,
|
||||||
|
color1ToColor2: -1,
|
||||||
|
color1ToColor3: 1,
|
||||||
|
color2ToColor1: -1,
|
||||||
|
color2ToColor2: 0,
|
||||||
|
color2ToColor3: -1,
|
||||||
|
color3ToColor1: 1,
|
||||||
|
color3ToColor2: -1,
|
||||||
|
color3ToColor3: 1,
|
||||||
|
},
|
||||||
|
tidepoolLantern: {
|
||||||
|
color1ToColor1: 0,
|
||||||
|
color1ToColor2: 1,
|
||||||
|
color1ToColor3: 0,
|
||||||
|
color2ToColor1: 0,
|
||||||
|
color2ToColor2: 0,
|
||||||
|
color2ToColor3: 1,
|
||||||
|
color3ToColor1: 1,
|
||||||
|
color3ToColor2: 0,
|
||||||
|
color3ToColor3: 0,
|
||||||
|
},
|
||||||
|
paperLanternFog: {
|
||||||
|
color1ToColor1: 1,
|
||||||
|
color1ToColor2: 1,
|
||||||
|
color1ToColor3: 1,
|
||||||
|
color2ToColor1: 1,
|
||||||
|
color2ToColor2: 1,
|
||||||
|
color2ToColor3: 1,
|
||||||
|
color3ToColor1: 1,
|
||||||
|
color3ToColor2: 1,
|
||||||
|
color3ToColor3: 1,
|
||||||
|
},
|
||||||
|
chromePollen: {
|
||||||
|
color1ToColor1: 1,
|
||||||
|
color1ToColor2: 0,
|
||||||
|
color1ToColor3: 1,
|
||||||
|
color2ToColor1: -1,
|
||||||
|
color2ToColor2: 1,
|
||||||
|
color2ToColor3: 0,
|
||||||
|
color3ToColor1: 1,
|
||||||
|
color3ToColor2: 0,
|
||||||
|
color3ToColor3: 1,
|
||||||
|
},
|
||||||
|
} satisfies Record<string, ColorReactionSettings>;
|
||||||
|
|
||||||
|
const musicScales = {
|
||||||
|
dorian: [0, 2, 3, 5, 7, 9, 10],
|
||||||
|
lydian: [0, 2, 4, 6, 7, 9, 11],
|
||||||
|
mixolydian: [0, 2, 4, 5, 7, 9, 10],
|
||||||
|
naturalMinor: [0, 2, 3, 5, 7, 8, 10],
|
||||||
|
} satisfies Record<string, Array<number>>;
|
||||||
|
|
||||||
|
const musicProgressions = {
|
||||||
|
aurora: [
|
||||||
|
{ rootOffset: 0, quality: 'sus2' },
|
||||||
|
{ rootOffset: 7, quality: 'major' },
|
||||||
|
{ rootOffset: 9, quality: 'minor' },
|
||||||
|
{ rootOffset: 5, quality: 'sus4' },
|
||||||
|
],
|
||||||
|
chrome: [
|
||||||
|
{ rootOffset: 0, quality: 'major' },
|
||||||
|
{ rootOffset: 2, quality: 'major' },
|
||||||
|
{ rootOffset: 7, quality: 'sus2' },
|
||||||
|
{ rootOffset: 9, quality: 'minor' },
|
||||||
|
],
|
||||||
|
lichen: [
|
||||||
|
{ rootOffset: 0, quality: 'minor' },
|
||||||
|
{ rootOffset: 5, quality: 'major' },
|
||||||
|
{ rootOffset: 10, quality: 'major' },
|
||||||
|
{ rootOffset: 3, quality: 'major' },
|
||||||
|
],
|
||||||
|
paperLantern: [
|
||||||
|
{ rootOffset: 0, quality: 'minor' },
|
||||||
|
{ rootOffset: 8, quality: 'major' },
|
||||||
|
{ rootOffset: 5, quality: 'minor' },
|
||||||
|
{ rootOffset: 10, quality: 'sus4' },
|
||||||
|
],
|
||||||
|
tidepool: [
|
||||||
|
{ rootOffset: 0, quality: 'major' },
|
||||||
|
{ rootOffset: 10, quality: 'major' },
|
||||||
|
{ rootOffset: 5, quality: 'sus2' },
|
||||||
|
{ rootOffset: 9, quality: 'minor' },
|
||||||
|
],
|
||||||
|
velvet: [
|
||||||
|
{ rootOffset: 0, quality: 'minor' },
|
||||||
|
{ rootOffset: 8, quality: 'major' },
|
||||||
|
{ rootOffset: 3, quality: 'major' },
|
||||||
|
{ rootOffset: 5, quality: 'sus4' },
|
||||||
|
],
|
||||||
|
} satisfies Record<string, Array<GardenAudioChord>>;
|
||||||
|
|
||||||
|
export const defaultVibeId = VibeId.AuroraMycelium;
|
||||||
|
|
||||||
|
export const vibePresets: Array<VibePreset> = [
|
||||||
|
{
|
||||||
|
id: VibeId.AuroraMycelium,
|
||||||
|
name: 'Aurora Mycelium Copy',
|
||||||
|
colors: [
|
||||||
|
[251, 210, 94],
|
||||||
|
[154, 99, 255],
|
||||||
|
[255, 31, 199],
|
||||||
|
],
|
||||||
|
backgroundColor: [6, 13, 22],
|
||||||
|
settings: {
|
||||||
|
...colorReactions.auroraMycelium,
|
||||||
|
backgroundGrainStrength: 0.003,
|
||||||
|
brushSize: 8.75,
|
||||||
|
clarity: 1,
|
||||||
|
decayRateTrails: 973,
|
||||||
|
forwardRotationScale: 0.37,
|
||||||
|
individualTrailWeight: 0.053000000000000005,
|
||||||
|
moveSpeed: 144,
|
||||||
|
sensorOffsetAngle: 35,
|
||||||
|
sensorOffsetDistance: 52,
|
||||||
|
spawnPerPixel: 0.13999999999999999,
|
||||||
|
strokeAngleJitterRadians: 0.45,
|
||||||
|
turnSpeed: 13,
|
||||||
|
turnWhenLost: 0,
|
||||||
|
},
|
||||||
|
audio: {
|
||||||
|
...defaultGardenAudioVibeSettings,
|
||||||
|
idleIntensity: 0.12000000000000002,
|
||||||
|
bpm: 60,
|
||||||
|
rampUpIntensity: 0.7,
|
||||||
|
rampUpTime: 0.14,
|
||||||
|
noteLength: 0.8599999999999999,
|
||||||
|
notePitchOffset: -2,
|
||||||
|
brightness: 0.84,
|
||||||
|
scale: musicScales.lydian,
|
||||||
|
progression: musicProgressions.aurora,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: VibeId.VelvetObservatory,
|
||||||
|
name: 'Velvet Observatory Copy',
|
||||||
|
colors: [
|
||||||
|
[178, 76, 62],
|
||||||
|
[2, 174, 255],
|
||||||
|
[213, 193, 9],
|
||||||
|
],
|
||||||
|
backgroundColor: [7, 4, 22],
|
||||||
|
settings: {
|
||||||
|
...colorReactions.velvetObservatory,
|
||||||
|
backgroundGrainStrength: 0.005,
|
||||||
|
brushSize: 9.75,
|
||||||
|
clarity: 1,
|
||||||
|
decayRateTrails: 974,
|
||||||
|
forwardRotationScale: 0,
|
||||||
|
individualTrailWeight: 0.232,
|
||||||
|
moveSpeed: 121,
|
||||||
|
sensorOffsetAngle: 24,
|
||||||
|
sensorOffsetDistance: 17,
|
||||||
|
spawnPerPixel: 0.11499999999999999,
|
||||||
|
strokeAngleJitterRadians: 0.17,
|
||||||
|
turnSpeed: 33,
|
||||||
|
turnWhenLost: 0.42,
|
||||||
|
},
|
||||||
|
audio: {
|
||||||
|
...defaultGardenAudioVibeSettings,
|
||||||
|
idleIntensity: 0.24000000000000002,
|
||||||
|
bpm: 72,
|
||||||
|
rampUpIntensity: 1.42,
|
||||||
|
rampUpTime: 0.07,
|
||||||
|
noteLength: 0.7,
|
||||||
|
notePitchOffset: 0,
|
||||||
|
brightness: 0.94,
|
||||||
|
scale: musicScales.naturalMinor,
|
||||||
|
progression: musicProgressions.velvet,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: VibeId.LichenSignal,
|
||||||
|
name: 'Lichen Signal',
|
||||||
|
colors: [
|
||||||
|
[183, 216, 92],
|
||||||
|
[65, 166, 128],
|
||||||
|
[238, 120, 76],
|
||||||
|
],
|
||||||
|
backgroundColor: [0, 0, 0],
|
||||||
|
settings: {
|
||||||
|
...colorReactions.lichenSignal,
|
||||||
|
backgroundGrainStrength: 0.02,
|
||||||
|
brushSize: 6.5,
|
||||||
|
clarity: 0.74,
|
||||||
|
decayRateTrails: 962,
|
||||||
|
forwardRotationScale: 0.3,
|
||||||
|
individualTrailWeight: 0.052,
|
||||||
|
moveSpeed: 72,
|
||||||
|
sensorOffsetAngle: 42,
|
||||||
|
sensorOffsetDistance: 54,
|
||||||
|
spawnPerPixel: 0.16,
|
||||||
|
strokeAngleJitterRadians: 3.14,
|
||||||
|
turnSpeed: 44,
|
||||||
|
turnWhenLost: 0.92,
|
||||||
|
},
|
||||||
|
audio: {
|
||||||
|
...defaultGardenAudioVibeSettings,
|
||||||
|
idleIntensity: 0.13,
|
||||||
|
bpm: 68,
|
||||||
|
rampUpIntensity: 1.46,
|
||||||
|
rampUpTime: 0.1,
|
||||||
|
noteLength: 0.6,
|
||||||
|
notePitchOffset: -3,
|
||||||
|
brightness: 1.21,
|
||||||
|
scale: musicScales.dorian,
|
||||||
|
progression: musicProgressions.lichen,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: VibeId.TidepoolLantern,
|
||||||
|
name: 'Tidepool Lantern',
|
||||||
|
colors: [
|
||||||
|
[30, 219, 194],
|
||||||
|
[61, 118, 255],
|
||||||
|
[255, 191, 91],
|
||||||
|
],
|
||||||
|
backgroundColor: [4, 18, 29],
|
||||||
|
settings: {
|
||||||
|
...colorReactions.tidepoolLantern,
|
||||||
|
backgroundGrainStrength: 0.018,
|
||||||
|
brushSize: 17,
|
||||||
|
clarity: 0.56,
|
||||||
|
decayRateTrails: 968,
|
||||||
|
forwardRotationScale: 0.38,
|
||||||
|
individualTrailWeight: 0.06,
|
||||||
|
moveSpeed: 88,
|
||||||
|
sensorOffsetAngle: 64,
|
||||||
|
sensorOffsetDistance: 46,
|
||||||
|
spawnPerPixel: 0.22,
|
||||||
|
strokeAngleJitterRadians: 1.8,
|
||||||
|
turnSpeed: 66,
|
||||||
|
turnWhenLost: 1.05,
|
||||||
|
},
|
||||||
|
audio: {
|
||||||
|
...defaultGardenAudioVibeSettings,
|
||||||
|
idleIntensity: 0.08,
|
||||||
|
bpm: 84,
|
||||||
|
rampUpIntensity: 0.95,
|
||||||
|
rampUpTime: 0.08,
|
||||||
|
noteLength: 0.46,
|
||||||
|
notePitchOffset: 0,
|
||||||
|
brightness: 0.98,
|
||||||
|
scale: musicScales.mixolydian,
|
||||||
|
progression: musicProgressions.tidepool,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: VibeId.PaperLanternFog,
|
||||||
|
name: 'Paper Lantern Fog',
|
||||||
|
colors: [
|
||||||
|
[255, 176, 108],
|
||||||
|
[239, 90, 108],
|
||||||
|
[128, 213, 184],
|
||||||
|
],
|
||||||
|
backgroundColor: [30, 23, 20],
|
||||||
|
settings: {
|
||||||
|
...colorReactions.paperLanternFog,
|
||||||
|
backgroundGrainStrength: 0.038,
|
||||||
|
brushSize: 3.5,
|
||||||
|
clarity: 1,
|
||||||
|
decayRateTrails: 999,
|
||||||
|
forwardRotationScale: 0.24,
|
||||||
|
individualTrailWeight: 0.937,
|
||||||
|
moveSpeed: 28,
|
||||||
|
sensorOffsetAngle: 34,
|
||||||
|
sensorOffsetDistance: 66,
|
||||||
|
spawnPerPixel: 0.055,
|
||||||
|
strokeAngleJitterRadians: 0,
|
||||||
|
turnSpeed: 30,
|
||||||
|
turnWhenLost: 1.52,
|
||||||
|
},
|
||||||
|
audio: {
|
||||||
|
...defaultGardenAudioVibeSettings,
|
||||||
|
idleIntensity: 0.33,
|
||||||
|
bpm: 127,
|
||||||
|
rampUpIntensity: 0.66,
|
||||||
|
rampUpTime: 0.03,
|
||||||
|
noteLength: 0.92,
|
||||||
|
notePitchOffset: 10,
|
||||||
|
brightness: 1.42,
|
||||||
|
scale: musicScales.naturalMinor,
|
||||||
|
progression: musicProgressions.paperLantern,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: VibeId.ChromePollen,
|
||||||
|
name: 'Chrome Pollen',
|
||||||
|
colors: [
|
||||||
|
[178, 34, 34],
|
||||||
|
[255, 214, 48],
|
||||||
|
[77, 240, 157],
|
||||||
|
],
|
||||||
|
backgroundColor: [7, 12, 11],
|
||||||
|
settings: {
|
||||||
|
...colorReactions.chromePollen,
|
||||||
|
backgroundGrainStrength: 0.012,
|
||||||
|
brushSize: 4.5,
|
||||||
|
clarity: 0.1,
|
||||||
|
decayRateTrails: 922,
|
||||||
|
forwardRotationScale: 0.5,
|
||||||
|
individualTrailWeight: 0.026,
|
||||||
|
moveSpeed: 86,
|
||||||
|
sensorOffsetAngle: 46,
|
||||||
|
sensorOffsetDistance: 14,
|
||||||
|
spawnPerPixel: 0.36,
|
||||||
|
strokeAngleJitterRadians: 3,
|
||||||
|
turnSpeed: 34,
|
||||||
|
turnWhenLost: 1.35,
|
||||||
|
},
|
||||||
|
audio: {
|
||||||
|
...defaultGardenAudioVibeSettings,
|
||||||
|
idleIntensity: 0.11,
|
||||||
|
bpm: 150,
|
||||||
|
rampUpIntensity: 2,
|
||||||
|
rampUpTime: 0.06,
|
||||||
|
noteLength: 1.8,
|
||||||
|
notePitchOffset: -12,
|
||||||
|
brightness: 0.5,
|
||||||
|
scale: musicScales.lydian,
|
||||||
|
progression: musicProgressions.chrome,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export const isProduction: boolean = import.meta.env.PROD;
|
|
||||||
111
src/settings.ts
111
src/settings.ts
|
|
@ -1,54 +1,77 @@
|
||||||
import { GameLoopSettings } from './game-loop/game-loop-settings';
|
import {
|
||||||
import { AgentSettings } from './pipelines/agents/agent-settings';
|
appConfig,
|
||||||
import { BrushSettings } from './pipelines/brush/brush-settings';
|
normalizeRuntimeSettings,
|
||||||
import { DiffusionSettings } from './pipelines/diffusion/diffusion-settings';
|
type GardenRuntimeSettings,
|
||||||
import { RenderSettings } from './pipelines/render/render-settings';
|
} from './config';
|
||||||
import { persist } from './utils/persist';
|
import { writeBrowserStorage } from './utils/browser-storage';
|
||||||
|
import { getInitialVibe, type VibePreset } from './vibes';
|
||||||
|
|
||||||
const initialValues: GameLoopSettings &
|
const preservedRuntimeSettingKeys = [
|
||||||
AgentSettings &
|
'eraserSize',
|
||||||
BrushSettings &
|
'adaptiveCapInitial',
|
||||||
DiffusionSettings &
|
'adaptiveCapMin',
|
||||||
RenderSettings = {
|
'internalRenderAreaMegapixels',
|
||||||
agentCount: 1_001_500,
|
'maxAgentCount',
|
||||||
|
'mirrorSegmentCount',
|
||||||
|
] satisfies ReadonlyArray<keyof GardenRuntimeSettings>;
|
||||||
|
|
||||||
currentGenerationAggression: -5,
|
const cloneRgbColor = <T extends [number, number, number]>(color: T): T =>
|
||||||
nextGenerationAggression: 0.2,
|
[...color] as T;
|
||||||
|
|
||||||
moveSpeed: 74,
|
const cloneVibeAudio = (audio: VibePreset['audio']): VibePreset['audio'] => ({
|
||||||
turnSpeed: 45,
|
...audio,
|
||||||
sensorOffsetAngle: 31,
|
...(audio.scale ? { scale: [...audio.scale] } : {}),
|
||||||
sensorOffsetDistance: 43,
|
...(audio.progression
|
||||||
turnWhenLost: 0.01,
|
? { progression: audio.progression.map((chord) => ({ ...chord })) }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
|
||||||
brushTrailWeight: 500,
|
const cloneVibePreset = (vibe: VibePreset): VibePreset => ({
|
||||||
individualTrailWeight: 0.05,
|
...vibe,
|
||||||
|
colors: vibe.colors.map(cloneRgbColor) as VibePreset['colors'],
|
||||||
|
backgroundColor: cloneRgbColor(vibe.backgroundColor),
|
||||||
|
settings: { ...vibe.settings },
|
||||||
|
audio: cloneVibeAudio(vibe.audio),
|
||||||
|
});
|
||||||
|
|
||||||
diffusionRateTrails: 0,
|
const buildSettings = (vibe: VibePreset): GardenRuntimeSettings =>
|
||||||
decayRateTrails: 944,
|
normalizeRuntimeSettings(
|
||||||
diffusionRateBrush: 0.35,
|
{
|
||||||
decayRateBrush: 18,
|
...appConfig.defaultSettings,
|
||||||
|
eraserSize: appConfig.toolbar.eraser.default,
|
||||||
|
mirrorSegmentCount: appConfig.toolbar.mirror.default,
|
||||||
|
...vibe.settings,
|
||||||
|
},
|
||||||
|
appConfig.runtimeSettings.controls
|
||||||
|
);
|
||||||
|
|
||||||
clarity: 0.7,
|
export let activeVibe = cloneVibePreset(getInitialVibe());
|
||||||
brushSize: 12,
|
|
||||||
|
|
||||||
brushSizeVariation: 0.5, // hidden on the UI
|
export const settings: GardenRuntimeSettings = {
|
||||||
|
...buildSettings(activeVibe),
|
||||||
startColorHue: 200,
|
|
||||||
|
|
||||||
maxAgentCountUpperLimit: Number.POSITIVE_INFINITY, // requires restart
|
|
||||||
|
|
||||||
// debug options
|
|
||||||
renderSpeed: 1,
|
|
||||||
simulatedDelayMs: 0,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const settings: { [key: string]: number } & GameLoopSettings &
|
export const rememberActiveVibeSelection = (): void => {
|
||||||
AgentSettings &
|
writeBrowserStorage(appConfig.storage.vibeKey, activeVibe.id);
|
||||||
BrushSettings &
|
};
|
||||||
DiffusionSettings &
|
|
||||||
RenderSettings = persist({ ...initialValues });
|
export const applyVibeSettings = (vibe: VibePreset) => {
|
||||||
|
activeVibe = cloneVibePreset(vibe);
|
||||||
export const resetSettings = () => {
|
const nextSettings = buildSettings(activeVibe);
|
||||||
Object.assign(settings, initialValues);
|
preservedRuntimeSettingKeys.forEach((key) => {
|
||||||
|
nextSettings[key] = settings[key];
|
||||||
|
});
|
||||||
|
nextSettings.selectedColorIndex = Math.min(
|
||||||
|
settings.selectedColorIndex,
|
||||||
|
activeVibe.colors.length - 1
|
||||||
|
);
|
||||||
|
|
||||||
|
Object.assign(
|
||||||
|
settings,
|
||||||
|
normalizeRuntimeSettings(nextSettings, appConfig.runtimeSettings.controls)
|
||||||
|
);
|
||||||
|
|
||||||
|
rememberActiveVibeSelection();
|
||||||
|
|
||||||
|
return activeVibe;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
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));
|
|
||||||
};
|
|
||||||
7
src/vibe-registry.ts
Normal file
7
src/vibe-registry.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { appConfig } from './config';
|
||||||
|
import type { VibeId, VibePreset } from './config/types';
|
||||||
|
|
||||||
|
export const VIBE_PRESETS: Array<VibePreset> = appConfig.vibes.presets;
|
||||||
|
|
||||||
|
export const getVibeById = (vibeId: VibeId): VibePreset | undefined =>
|
||||||
|
VIBE_PRESETS.find((vibe) => vibe.id === vibeId);
|
||||||
54
src/vibe-uri.test.ts
Normal file
54
src/vibe-uri.test.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { VibeId } from './config/types';
|
||||||
|
import { createVibeUri, getVibeIdFromUri } from './vibe-uri';
|
||||||
|
|
||||||
|
describe('vibe URI handling', () => {
|
||||||
|
it('loads vibes from slug IDs and display names', () => {
|
||||||
|
expect(getVibeIdFromUri('https://example.test/?vibe=aurora-mycelium')).toBe(
|
||||||
|
VibeId.AuroraMycelium
|
||||||
|
);
|
||||||
|
expect(getVibeIdFromUri('https://example.test/?vibe=Aurora%20Mycelium')).toBe(
|
||||||
|
VibeId.AuroraMycelium
|
||||||
|
);
|
||||||
|
expect(getVibeIdFromUri('https://example.test/?vibe=Velvet%20Observatory%20Copy')).toBe(
|
||||||
|
VibeId.VelvetObservatory
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses query values before path or hash fallbacks', () => {
|
||||||
|
expect(
|
||||||
|
getVibeIdFromUri(
|
||||||
|
'https://example.test/chrome-pollen?vibe=lichen-signal#vibe=aurora-mycelium'
|
||||||
|
)
|
||||||
|
).toBe(VibeId.LichenSignal);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts explicit path segments and hash fallbacks', () => {
|
||||||
|
expect(getVibeIdFromUri('https://example.test/vibes/tidepool-lantern')).toBe(
|
||||||
|
VibeId.TidepoolLantern
|
||||||
|
);
|
||||||
|
expect(getVibeIdFromUri('https://example.test/#paper-lantern-fog')).toBe(
|
||||||
|
VibeId.PaperLanternFog
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores unknown or malformed vibe values', () => {
|
||||||
|
expect(getVibeIdFromUri('https://example.test/?vibe=missing')).toBeNull();
|
||||||
|
expect(getVibeIdFromUri('https://example.test/?vibe=%E0%A4%A')).toBeNull();
|
||||||
|
expect(getVibeIdFromUri('not a url')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a canonical query URI without dropping other URL parts', () => {
|
||||||
|
expect(
|
||||||
|
createVibeUri('https://example.test/garden?debug=1#panel', VibeId.ChromePollen)
|
||||||
|
).toBe('/garden?debug=1&vibe=chrome-pollen#panel');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
createVibeUri(
|
||||||
|
'https://example.test/garden?vibe=aurora-mycelium&debug=1',
|
||||||
|
VibeId.LichenSignal
|
||||||
|
)
|
||||||
|
).toBe('/garden?vibe=lichen-signal&debug=1');
|
||||||
|
});
|
||||||
|
});
|
||||||
148
src/vibe-uri.ts
Normal file
148
src/vibe-uri.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
import type { VibeId } from './config/types';
|
||||||
|
import { getVibeById, VIBE_PRESETS } from './vibe-registry';
|
||||||
|
|
||||||
|
const VIBE_URI_QUERY_PARAM = 'vibe';
|
||||||
|
const FALLBACK_URL_ORIGIN = 'https://fleeting.garden';
|
||||||
|
|
||||||
|
const slugifyVibeName = (value: string): string =>
|
||||||
|
value
|
||||||
|
.normalize('NFKD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/&/g, ' and ')
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
|
||||||
|
const safeDecodeURIComponent = (value: string): string => {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(value);
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeVibeIdentifier = (value: string): string =>
|
||||||
|
slugifyVibeName(safeDecodeURIComponent(value).replace(/^[#/\\?\s]+|[/\\?\s]+$/g, ''));
|
||||||
|
|
||||||
|
const vibeIdByIdentifier = new Map<string, VibeId>();
|
||||||
|
|
||||||
|
for (const vibe of VIBE_PRESETS) {
|
||||||
|
vibeIdByIdentifier.set(normalizeVibeIdentifier(vibe.id), vibe.id);
|
||||||
|
vibeIdByIdentifier.set(normalizeVibeIdentifier(vibe.name), vibe.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const toUrl = (url: string | URL): URL | null => {
|
||||||
|
try {
|
||||||
|
return new URL(url, FALLBACK_URL_ORIGIN);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveVibeId = (value: string | null | undefined): VibeId | null => {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return vibeIdByIdentifier.get(normalizeVibeIdentifier(value)) ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getHashSearchParam = (hash: string): string | null => {
|
||||||
|
const hashValue = hash.replace(/^#/, '');
|
||||||
|
if (!hashValue.includes('=')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchText = hashValue.startsWith('?') ? hashValue.slice(1) : hashValue;
|
||||||
|
try {
|
||||||
|
return new URLSearchParams(searchText).get(VIBE_URI_QUERY_PARAM);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPathVibeCandidates = (pathname: string): Array<string> => {
|
||||||
|
const segments = pathname.split('/').map(safeDecodeURIComponent).filter(Boolean);
|
||||||
|
const explicitVibeIndex = segments.findIndex((segment) =>
|
||||||
|
['vibe', 'vibes'].includes(segment.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
explicitVibeIndex >= 0 ? segments[explicitVibeIndex + 1] : undefined,
|
||||||
|
segments.at(-1),
|
||||||
|
].filter((candidate): candidate is string => typeof candidate === 'string');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getVibeIdFromUri = (url: string | URL): VibeId | null => {
|
||||||
|
const parsedUrl = toUrl(url);
|
||||||
|
if (!parsedUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = [
|
||||||
|
parsedUrl.searchParams.get(VIBE_URI_QUERY_PARAM),
|
||||||
|
getHashSearchParam(parsedUrl.hash),
|
||||||
|
...getPathVibeCandidates(parsedUrl.pathname),
|
||||||
|
parsedUrl.hash.replace(/^#/, ''),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const vibeId = resolveVibeId(candidate);
|
||||||
|
if (vibeId) {
|
||||||
|
return vibeId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCurrentUriVibeId = (): VibeId | null => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getVibeIdFromUri(window.location.href);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getVibeSlug = (vibeId: VibeId): string => {
|
||||||
|
const vibe = getVibeById(vibeId);
|
||||||
|
return vibe ? vibe.id : vibeId;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createVibeUri = (url: string | URL, vibeId: VibeId): string => {
|
||||||
|
const parsedUrl = toUrl(url);
|
||||||
|
if (!parsedUrl) {
|
||||||
|
return `?${VIBE_URI_QUERY_PARAM}=${encodeURIComponent(getVibeSlug(vibeId))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedUrl.searchParams.set(VIBE_URI_QUERY_PARAM, getVibeSlug(vibeId));
|
||||||
|
return `${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const writeCurrentVibeUri = (
|
||||||
|
vibeId: VibeId,
|
||||||
|
mode: 'push' | 'replace' = 'replace'
|
||||||
|
): void => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextUri = createVibeUri(window.location.href, vibeId);
|
||||||
|
const currentUri = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||||
|
if (nextUri === currentUri) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextState =
|
||||||
|
typeof window.history.state === 'object' && window.history.state !== null
|
||||||
|
? { ...window.history.state, vibeId }
|
||||||
|
: { vibeId };
|
||||||
|
|
||||||
|
if (mode === 'push') {
|
||||||
|
window.history.pushState(nextState, '', nextUri);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.history.replaceState(nextState, '', nextUri);
|
||||||
|
};
|
||||||
26
src/vibes.ts
Normal file
26
src/vibes.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { appConfig } from './config';
|
||||||
|
import { VibeId, type VibePreset } from './config/types';
|
||||||
|
import { readBrowserStorage } from './utils/browser-storage';
|
||||||
|
import { getVibeById, VIBE_PRESETS } from './vibe-registry';
|
||||||
|
import { getCurrentUriVibeId, getVibeIdFromUri } from './vibe-uri';
|
||||||
|
|
||||||
|
export { VibeId };
|
||||||
|
export { getVibeById, VIBE_PRESETS };
|
||||||
|
export type { VibePreset };
|
||||||
|
|
||||||
|
const VIBE_IDS = new Set<VibeId>(VIBE_PRESETS.map((vibe) => vibe.id));
|
||||||
|
|
||||||
|
const isVibeId = (value: unknown): value is VibeId =>
|
||||||
|
typeof value === 'string' && VIBE_IDS.has(value as VibeId);
|
||||||
|
|
||||||
|
export const getInitialVibe = (): VibePreset => {
|
||||||
|
const uriVibeId = getCurrentUriVibeId();
|
||||||
|
const storedVibeId = readBrowserStorage(appConfig.storage.vibeKey);
|
||||||
|
const storedOrLegacyVibeId = isVibeId(storedVibeId)
|
||||||
|
? storedVibeId
|
||||||
|
: getVibeIdFromUri(`?vibe=${encodeURIComponent(storedVibeId ?? '')}`);
|
||||||
|
const initialVibeId =
|
||||||
|
uriVibeId ?? storedOrLegacyVibeId ?? appConfig.vibes.defaultVibeId;
|
||||||
|
|
||||||
|
return getVibeById(initialVibeId) ?? VIBE_PRESETS[0];
|
||||||
|
};
|
||||||
Loading…
Add table
Add a link
Reference in a new issue