diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..e0e6b19 --- /dev/null +++ b/src/config.ts @@ -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; diff --git a/src/config/brush-size.test.ts b/src/config/brush-size.test.ts new file mode 100644 index 0000000..9039e62 --- /dev/null +++ b/src/config/brush-size.test.ts @@ -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); + }); +}); diff --git a/src/config/brush-size.ts b/src/config/brush-size.ts new file mode 100644 index 0000000..fc77697 --- /dev/null +++ b/src/config/brush-size.ts @@ -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); diff --git a/src/config/color-interactions.ts b/src/config/color-interactions.ts new file mode 100644 index 0000000..c84e61d --- /dev/null +++ b/src/config/color-interactions.ts @@ -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, + }, +}); diff --git a/src/config/default-settings.ts b/src/config/default-settings.ts new file mode 100644 index 0000000..37ce510 --- /dev/null +++ b/src/config/default-settings.ts @@ -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, +}; diff --git a/src/config/normalize-runtime-settings.test.ts b/src/config/normalize-runtime-settings.test.ts new file mode 100644 index 0000000..259ff17 --- /dev/null +++ b/src/config/normalize-runtime-settings.test.ts @@ -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, + }); + }); +}); diff --git a/src/config/normalize-runtime-settings.ts b/src/config/normalize-runtime-settings.ts new file mode 100644 index 0000000..ec4dcf5 --- /dev/null +++ b/src/config/normalize-runtime-settings.ts @@ -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; +}; diff --git a/src/config/runtime-controls.ts b/src/config/runtime-controls.ts new file mode 100644 index 0000000..e07a259 --- /dev/null +++ b/src/config/runtime-controls.ts @@ -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, + }, +}; diff --git a/src/config/runtime-setting-bounds.ts b/src/config/runtime-setting-bounds.ts new file mode 100644 index 0000000..5400912 --- /dev/null +++ b/src/config/runtime-setting-bounds.ts @@ -0,0 +1,4 @@ +export const INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS = { + min: 0.5, + max: 16.6, +} as const; diff --git a/src/config/types.ts b/src/config/types.ts new file mode 100644 index 0000000..456dec8 --- /dev/null +++ b/src/config/types.ts @@ -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; + 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 +>; + +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; + 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; + }; +} diff --git a/src/config/vibe-presets.test.ts b/src/config/vibe-presets.test.ts new file mode 100644 index 0000000..a69d524 --- /dev/null +++ b/src/config/vibe-presets.test.ts @@ -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 = []; + + 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([]); + }); +}); diff --git a/src/config/vibe-presets.ts b/src/config/vibe-presets.ts new file mode 100644 index 0000000..7a30359 --- /dev/null +++ b/src/config/vibe-presets.ts @@ -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; + +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>; + +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>; + +export const defaultVibeId = VibeId.AuroraMycelium; + +export const vibePresets: Array = [ + { + 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, + }, + }, +]; diff --git a/src/constants.ts b/src/constants.ts deleted file mode 100644 index 24bf445..0000000 --- a/src/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const isProduction: boolean = import.meta.env.PROD; diff --git a/src/settings.ts b/src/settings.ts index 4715928..c91ac83 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,54 +1,77 @@ -import { GameLoopSettings } from './game-loop/game-loop-settings'; -import { AgentSettings } from './pipelines/agents/agent-settings'; -import { BrushSettings } from './pipelines/brush/brush-settings'; -import { DiffusionSettings } from './pipelines/diffusion/diffusion-settings'; -import { RenderSettings } from './pipelines/render/render-settings'; -import { persist } from './utils/persist'; +import { + appConfig, + normalizeRuntimeSettings, + type GardenRuntimeSettings, +} from './config'; +import { writeBrowserStorage } from './utils/browser-storage'; +import { getInitialVibe, type VibePreset } from './vibes'; -const initialValues: GameLoopSettings & - AgentSettings & - BrushSettings & - DiffusionSettings & - RenderSettings = { - agentCount: 1_001_500, +const preservedRuntimeSettingKeys = [ + 'eraserSize', + 'adaptiveCapInitial', + 'adaptiveCapMin', + 'internalRenderAreaMegapixels', + 'maxAgentCount', + 'mirrorSegmentCount', +] satisfies ReadonlyArray; - currentGenerationAggression: -5, - nextGenerationAggression: 0.2, +const cloneRgbColor = (color: T): T => + [...color] as T; - moveSpeed: 74, - turnSpeed: 45, - sensorOffsetAngle: 31, - sensorOffsetDistance: 43, - turnWhenLost: 0.01, +const cloneVibeAudio = (audio: VibePreset['audio']): VibePreset['audio'] => ({ + ...audio, + ...(audio.scale ? { scale: [...audio.scale] } : {}), + ...(audio.progression + ? { progression: audio.progression.map((chord) => ({ ...chord })) } + : {}), +}); - brushTrailWeight: 500, - individualTrailWeight: 0.05, +const cloneVibePreset = (vibe: VibePreset): VibePreset => ({ + ...vibe, + colors: vibe.colors.map(cloneRgbColor) as VibePreset['colors'], + backgroundColor: cloneRgbColor(vibe.backgroundColor), + settings: { ...vibe.settings }, + audio: cloneVibeAudio(vibe.audio), +}); - diffusionRateTrails: 0, - decayRateTrails: 944, - diffusionRateBrush: 0.35, - decayRateBrush: 18, +const buildSettings = (vibe: VibePreset): GardenRuntimeSettings => + normalizeRuntimeSettings( + { + ...appConfig.defaultSettings, + eraserSize: appConfig.toolbar.eraser.default, + mirrorSegmentCount: appConfig.toolbar.mirror.default, + ...vibe.settings, + }, + appConfig.runtimeSettings.controls + ); - clarity: 0.7, - brushSize: 12, +export let activeVibe = cloneVibePreset(getInitialVibe()); - brushSizeVariation: 0.5, // hidden on the UI - - startColorHue: 200, - - maxAgentCountUpperLimit: Number.POSITIVE_INFINITY, // requires restart - - // debug options - renderSpeed: 1, - simulatedDelayMs: 0, +export const settings: GardenRuntimeSettings = { + ...buildSettings(activeVibe), }; -export const settings: { [key: string]: number } & GameLoopSettings & - AgentSettings & - BrushSettings & - DiffusionSettings & - RenderSettings = persist({ ...initialValues }); - -export const resetSettings = () => { - Object.assign(settings, initialValues); +export const rememberActiveVibeSelection = (): void => { + writeBrowserStorage(appConfig.storage.vibeKey, activeVibe.id); +}; + +export const applyVibeSettings = (vibe: VibePreset) => { + activeVibe = cloneVibePreset(vibe); + const nextSettings = buildSettings(activeVibe); + 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; }; diff --git a/src/utils/browser-storage.ts b/src/utils/browser-storage.ts new file mode 100644 index 0000000..f91a3e0 --- /dev/null +++ b/src/utils/browser-storage.ts @@ -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 + ); + } +}; diff --git a/src/utils/clamp.ts b/src/utils/clamp.ts deleted file mode 100644 index 45da555..0000000 --- a/src/utils/clamp.ts +++ /dev/null @@ -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)); diff --git a/src/utils/exponential-decay.ts b/src/utils/exponential-decay.ts deleted file mode 100644 index 3f13b6a..0000000 --- a/src/utils/exponential-decay.ts +++ /dev/null @@ -1,9 +0,0 @@ -export const exponentialDecay = ({ - accumulator, - nextValue, - biasOfNextValue, -}: { - accumulator: number; - nextValue: number; - biasOfNextValue: number; -}) => accumulator * (1 - biasOfNextValue) + nextValue * biasOfNextValue; diff --git a/src/utils/format-number.test.ts b/src/utils/format-number.test.ts deleted file mode 100644 index c434967..0000000 --- a/src/utils/format-number.test.ts +++ /dev/null @@ -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'); - }); -}); diff --git a/src/utils/format-number.ts b/src/utils/format-number.ts deleted file mode 100644 index a57812e..0000000 --- a/src/utils/format-number.ts +++ /dev/null @@ -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}`; -}; diff --git a/src/utils/hsl.test.ts b/src/utils/hsl.test.ts deleted file mode 100644 index 458f758..0000000 --- a/src/utils/hsl.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/src/utils/hsl.ts b/src/utils/hsl.ts deleted file mode 100644 index 89c2d7c..0000000 --- a/src/utils/hsl.ts +++ /dev/null @@ -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); -}; diff --git a/src/utils/math.test.ts b/src/utils/math.test.ts deleted file mode 100644 index 9b5eeab..0000000 --- a/src/utils/math.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/src/utils/math.ts b/src/utils/math.ts new file mode 100644 index 0000000..5937e8a --- /dev/null +++ b/src/utils/math.ts @@ -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); diff --git a/src/utils/mix.ts b/src/utils/mix.ts deleted file mode 100644 index 16a76ed..0000000 --- a/src/utils/mix.ts +++ /dev/null @@ -1 +0,0 @@ -export const mix = (from: number, to: number, q: number) => from + (to - from) * q; diff --git a/src/utils/persist.ts b/src/utils/persist.ts deleted file mode 100644 index 4489458..0000000 --- a/src/utils/persist.ts +++ /dev/null @@ -1,39 +0,0 @@ -export const persist = >(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; - }, - }); -}; diff --git a/src/utils/random.test.ts b/src/utils/random.test.ts deleted file mode 100644 index 3d28634..0000000 --- a/src/utils/random.test.ts +++ /dev/null @@ -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); - } - }); -}); diff --git a/src/utils/random.ts b/src/utils/random.ts deleted file mode 100644 index 2d7e258..0000000 --- a/src/utils/random.ts +++ /dev/null @@ -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); - } -} diff --git a/src/utils/rgb-color.ts b/src/utils/rgb-color.ts new file mode 100644 index 0000000..aa9be3f --- /dev/null +++ b/src/utils/rgb-color.ts @@ -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)); diff --git a/src/utils/rgb.ts b/src/utils/rgb.ts deleted file mode 100644 index a6ef20a..0000000 --- a/src/utils/rgb.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { vec3 } from 'gl-matrix'; - -export const rgb = (r: number, g: number, b: number): vec3 => vec3.fromValues(r, g, b); diff --git a/src/utils/sleep.ts b/src/utils/sleep.ts deleted file mode 100644 index b358bfa..0000000 --- a/src/utils/sleep.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const sleep = (ms: number): Promise => { - return new Promise((resolve, _) => setTimeout(resolve, ms)); -}; diff --git a/src/vibe-registry.ts b/src/vibe-registry.ts new file mode 100644 index 0000000..0b0ae54 --- /dev/null +++ b/src/vibe-registry.ts @@ -0,0 +1,7 @@ +import { appConfig } from './config'; +import type { VibeId, VibePreset } from './config/types'; + +export const VIBE_PRESETS: Array = appConfig.vibes.presets; + +export const getVibeById = (vibeId: VibeId): VibePreset | undefined => + VIBE_PRESETS.find((vibe) => vibe.id === vibeId); diff --git a/src/vibe-uri.test.ts b/src/vibe-uri.test.ts new file mode 100644 index 0000000..fe55d5e --- /dev/null +++ b/src/vibe-uri.test.ts @@ -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'); + }); +}); diff --git a/src/vibe-uri.ts b/src/vibe-uri.ts new file mode 100644 index 0000000..3a4024c --- /dev/null +++ b/src/vibe-uri.ts @@ -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(); + +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 => { + 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); +}; diff --git a/src/vibes.ts b/src/vibes.ts new file mode 100644 index 0000000..95d1f7c --- /dev/null +++ b/src/vibes.ts @@ -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(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]; +};