This commit is contained in:
Andras Schmelczer 2026-05-13 21:07:10 +01:00
commit 39b0160064
136 changed files with 7129 additions and 1950 deletions

268
src/page/config-pane.ts Normal file
View file

@ -0,0 +1,268 @@
import { Pane, type BindingParams, type FolderApi } from 'tweakpane';
import {
appConfig,
type GardenRuntimeSettings,
type NumberControlConfig,
} from '../config';
import { activeVibe, settings } from '../settings';
import { VIBE_PRESETS } from '../vibes';
type PaneContainer = Pick<FolderApi, 'addBinding' | 'addButton' | 'addFolder'>;
interface ConfigPaneOptions {
onConfigChange: () => void;
onRestart: () => void;
onRuntimeChange: () => void;
onRuntimeReset: () => void;
onVibeChange: (vibeId: string) => void;
settingsButton: HTMLButtonElement;
}
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null;
const isBindablePrimitive = (value: unknown): value is boolean | number | string =>
['boolean', 'number', 'string'].includes(typeof value);
const isColorString = (value: unknown): value is string =>
typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value);
const toLabel = (value: string): string =>
value
.replace(/\[(\d+)\]/g, ' $1')
.replace(/([A-Z])/g, ' $1')
.replace(/[-_]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
const normalizeNumber = (value: number, config: NumberControlConfig): number => {
const finiteValue = Number.isFinite(value) ? value : config.min;
const clampedValue = Math.min(config.max, Math.max(config.min, finiteValue));
return config.integer ? Math.round(clampedValue) : clampedValue;
};
const getNumberBindingParams = (
key: keyof GardenRuntimeSettings & string,
config: NumberControlConfig
): BindingParams => ({
label: config.label ?? toLabel(key),
min: config.min,
max: config.max,
step: config.step,
});
export class ConfigPane {
private readonly container: HTMLDivElement;
private readonly pane: Pane;
private readonly state = {
activeVibeId: activeVibe.id,
};
public constructor(private readonly options: ConfigPaneOptions) {
this.container = document.createElement('div');
this.container.className = 'config-pane-container';
Object.assign(this.container.style, {
boxSizing: 'border-box',
maxHeight: 'calc(100vh - 24px)',
pointerEvents: 'none',
position: 'fixed',
right: 'max(12px, env(safe-area-inset-right, 0px))',
top: 'max(12px, env(safe-area-inset-top, 0px))',
width:
'min(420px, calc(100vw - 24px - env(safe-area-inset-left, 0px) - env(safe-area-inset-right, 0px)))',
zIndex: '20',
});
document.body.appendChild(this.container);
this.pane = new Pane({
container: this.container,
title: appConfig.tuningPane.title,
expanded: true,
});
this.pane.hidden = appConfig.tuningPane.startHidden;
this.pane.element.classList.add('config-pane');
this.pane.element.style.boxSizing = 'border-box';
this.pane.element.style.maxHeight = 'calc(100vh - 24px)';
this.pane.element.style.overflowY = 'auto';
this.pane.element.style.pointerEvents = 'auto';
this.pane.element.style.width = '100%';
this.options.settingsButton.addEventListener('click', this.toggle);
const tabs = this.pane.addTab({
pages: [{ title: 'Runtime' }, { title: 'Config' }],
});
this.setUpRuntimeTab(tabs.pages[0]);
this.setUpConfigTab(tabs.pages[1]);
this.syncButton();
}
public get isOpen(): boolean {
return !this.pane.hidden;
}
public refresh(): void {
this.state.activeVibeId = activeVibe.id;
this.pane.refresh();
this.syncButton();
}
private readonly toggle = () => {
this.pane.hidden = !this.pane.hidden;
this.syncButton();
};
private setHidden(isHidden: boolean): void {
this.pane.hidden = isHidden;
this.syncButton();
}
private setUpRuntimeTab(container: PaneContainer): void {
container
.addBinding(this.state, 'activeVibeId', {
label: 'active vibe',
options: Object.fromEntries(
VIBE_PRESETS.map((vibe) => [vibe.name, vibe.id])
) as Record<string, string>,
})
.on('change', ({ value }) => {
this.options.onVibeChange(value);
this.refresh();
});
container
.addButton({
title: 'Reset runtime settings',
})
.on('click', () => {
this.options.onRuntimeReset();
this.refresh();
});
container
.addButton({
title: 'Restart simulation',
})
.on('click', () => this.options.onRestart());
const folders = new Map<string, PaneContainer>();
Object.entries(appConfig.runtimeSettings.controls).forEach(([key, config]) => {
const folder =
folders.get(config.folder) ??
container.addFolder({
title: config.folder,
expanded: config.folder !== 'Runtime',
});
folders.set(config.folder, folder);
const settingKey = key as keyof GardenRuntimeSettings & string;
settings[settingKey] = normalizeNumber(settings[settingKey], config);
folder
.addBinding(settings, settingKey, getNumberBindingParams(settingKey, config))
.on('change', () => {
const nextValue = normalizeNumber(settings[settingKey], config);
if (nextValue !== settings[settingKey]) {
settings[settingKey] = nextValue;
this.pane.refresh();
}
this.options.onRuntimeChange();
});
});
}
private setUpConfigTab(container: PaneContainer): void {
this.addObjectBindings(
container,
appConfig as unknown as Record<string, unknown>,
[]
);
}
private addObjectBindings(
container: PaneContainer,
source: Record<string, unknown>,
path: Array<string>
): void {
Object.entries(source).forEach(([key, value]) => {
if (isBindablePrimitive(value)) {
this.addPrimitiveBinding(container, source, key, value);
return;
}
if (Array.isArray(value)) {
const folder = container.addFolder({
title: toLabel(`${key}[]`),
expanded: path.length < appConfig.tuningPane.expandedDepth,
});
value.forEach((item, index) => {
if (isBindablePrimitive(item)) {
this.addPrimitiveBinding(
folder,
value as unknown as Record<string, unknown>,
`${index}`,
item
);
return;
}
if (isPlainObject(item)) {
this.addObjectBindings(
folder.addFolder({
title: `[${index}]`,
expanded: false,
}),
item,
[...path, key, String(index)]
);
}
});
return;
}
if (isPlainObject(value)) {
this.addObjectBindings(
container.addFolder({
title: toLabel(key),
expanded: path.length < appConfig.tuningPane.expandedDepth,
}),
value,
[...path, key]
);
}
});
}
private addPrimitiveBinding(
container: PaneContainer,
source: Record<string, unknown>,
key: string,
value: boolean | number | string
): void {
const params: BindingParams = {
label: toLabel(key),
...(isColorString(value) ? { color: { type: 'int' } } : {}),
...(key === 'quality' ? { options: { major: 'major', minor: 'minor' } } : {}),
};
container
.addBinding(source, key, params)
.on('change', () => this.options.onConfigChange());
}
private syncButton(): void {
this.options.settingsButton.setAttribute('aria-expanded', String(this.isOpen));
this.options.settingsButton.setAttribute(
'aria-label',
this.isOpen ? 'Hide config overlay' : 'Show config overlay'
);
this.options.settingsButton.title = this.isOpen
? 'Hide config overlay'
: 'Show config overlay';
}
public close(): void {
this.setHidden(true);
}
}