Wire up garden controls
This commit is contained in:
parent
018f8c9d4d
commit
839747304e
13 changed files with 1442 additions and 378 deletions
|
|
@ -1,44 +1,90 @@
|
|||
export class CollapsiblePanelAnimator {
|
||||
private _isOpen = false;
|
||||
|
||||
public onOpen: () => unknown = () => {};
|
||||
public onClose: () => unknown = () => {};
|
||||
private focusBeforeOpen: HTMLElement | null = null;
|
||||
private readonly abortController = new AbortController();
|
||||
public onOpen?: () => void;
|
||||
|
||||
public constructor(
|
||||
private readonly toggleButton: HTMLButtonElement,
|
||||
private readonly collapsibleContent: HTMLElement,
|
||||
ignoreForCloseOnClick: HTMLElement
|
||||
) {
|
||||
toggleButton.addEventListener('click', this.toggle.bind(this));
|
||||
const { signal } = this.abortController;
|
||||
toggleButton.addEventListener('click', this.toggle, { signal });
|
||||
window.addEventListener(
|
||||
'click',
|
||||
(event) => !ignoreForCloseOnClick.contains(event.target as Node) && this.close()
|
||||
(event) => !ignoreForCloseOnClick.contains(event.target as Node) && this.close(),
|
||||
{ signal }
|
||||
);
|
||||
window.addEventListener(
|
||||
'keydown',
|
||||
(event) => {
|
||||
if (this._isOpen && event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
this.close();
|
||||
}
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
this.syncAccessibility();
|
||||
}
|
||||
|
||||
public open() {
|
||||
if (this._isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.focusBeforeOpen =
|
||||
document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
this._isOpen = true;
|
||||
this.collapsibleContent.classList.remove('hidden');
|
||||
this.toggleButton.classList.add('active');
|
||||
this.onOpen();
|
||||
this.onOpen?.();
|
||||
this.syncAccessibility();
|
||||
this.focusPanel();
|
||||
}
|
||||
|
||||
public close() {
|
||||
if (!this._isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const focusWasInside = this.collapsibleContent.contains(document.activeElement);
|
||||
this._isOpen = false;
|
||||
this.collapsibleContent.classList.add('hidden');
|
||||
this.toggleButton.classList.remove('active');
|
||||
this.onClose();
|
||||
this.syncAccessibility();
|
||||
|
||||
if (focusWasInside) {
|
||||
(this.focusBeforeOpen ?? this.toggleButton).focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
public toggle() {
|
||||
public readonly toggle = () => {
|
||||
if (this._isOpen) {
|
||||
this.close();
|
||||
} else {
|
||||
this.open();
|
||||
}
|
||||
};
|
||||
|
||||
public destroy(): void {
|
||||
this.abortController.abort();
|
||||
}
|
||||
|
||||
public get isOpen() {
|
||||
return this._isOpen;
|
||||
}
|
||||
|
||||
private syncAccessibility() {
|
||||
this.collapsibleContent.classList.toggle('hidden', !this._isOpen);
|
||||
this.toggleButton.classList.toggle('active', this._isOpen);
|
||||
this.toggleButton.setAttribute('aria-expanded', String(this._isOpen));
|
||||
this.collapsibleContent.setAttribute('aria-hidden', String(!this._isOpen));
|
||||
this.collapsibleContent.inert = !this._isOpen;
|
||||
}
|
||||
|
||||
private focusPanel() {
|
||||
requestAnimationFrame(() => {
|
||||
if (this._isOpen) {
|
||||
this.collapsibleContent.focus({ preventScroll: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
187
src/page/color-reaction-matrix-control.ts
Normal file
187
src/page/color-reaction-matrix-control.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
import type { FolderApi } from '@tweakpane/core';
|
||||
|
||||
import { appConfig, normalizeNumberControlValue } from '../config';
|
||||
import { activeVibe, settings } from '../settings';
|
||||
import { rgbColorToCss } from '../utils/rgb-color';
|
||||
|
||||
type PaneContainer = Pick<FolderApi, 'addFolder'>;
|
||||
type ColorReactionKey = (typeof colorReactionRows)[number]['keys'][number];
|
||||
|
||||
const COLOR_REACTION_LABELS = ['Color 1', 'Color 2', 'Color 3'] as const;
|
||||
const COLOR_REACTION_STATES = [
|
||||
{ id: 'follow', label: 'Move Toward', value: 1 },
|
||||
{ id: 'ignore', label: 'Ignore', value: 0 },
|
||||
{ id: 'avoid', label: 'Move Away', value: -1 },
|
||||
] as const;
|
||||
|
||||
const colorReactionRows = [
|
||||
{
|
||||
colorIndex: 0,
|
||||
label: COLOR_REACTION_LABELS[0],
|
||||
keys: ['color1ToColor1', 'color1ToColor2', 'color1ToColor3'],
|
||||
},
|
||||
{
|
||||
colorIndex: 1,
|
||||
label: COLOR_REACTION_LABELS[1],
|
||||
keys: ['color2ToColor1', 'color2ToColor2', 'color2ToColor3'],
|
||||
},
|
||||
{
|
||||
colorIndex: 2,
|
||||
label: COLOR_REACTION_LABELS[2],
|
||||
keys: ['color3ToColor1', 'color3ToColor2', 'color3ToColor3'],
|
||||
},
|
||||
] as const;
|
||||
|
||||
const getColorReactionStateIndex = (value: number): number =>
|
||||
COLOR_REACTION_STATES.findIndex((state) => state.value === value);
|
||||
|
||||
const getColorReactionState = (value: number): (typeof COLOR_REACTION_STATES)[number] =>
|
||||
COLOR_REACTION_STATES[getColorReactionStateIndex(value)] ?? COLOR_REACTION_STATES[1];
|
||||
|
||||
const getNextColorReactionState = (
|
||||
value: number
|
||||
): (typeof COLOR_REACTION_STATES)[number] => {
|
||||
const index = getColorReactionStateIndex(value);
|
||||
return COLOR_REACTION_STATES[
|
||||
((index < 0 ? 1 : index) + 1) % COLOR_REACTION_STATES.length
|
||||
];
|
||||
};
|
||||
|
||||
export class ColorReactionMatrixControl {
|
||||
private readonly buttons = new Map<
|
||||
ColorReactionKey,
|
||||
{
|
||||
element: HTMLButtonElement;
|
||||
sourceColorIndex: number;
|
||||
targetColorIndex: number;
|
||||
}
|
||||
>();
|
||||
private readonly swatches: Array<{
|
||||
colorIndex: number;
|
||||
element: HTMLElement;
|
||||
}> = [];
|
||||
|
||||
public constructor(private readonly onRuntimeChange: () => void) {}
|
||||
|
||||
public addTo(container: PaneContainer): void {
|
||||
const folder = container.addFolder({
|
||||
title: 'Color Behavior',
|
||||
expanded: true,
|
||||
});
|
||||
|
||||
const matrix = document.createElement('div');
|
||||
matrix.className = 'color-reaction-matrix';
|
||||
|
||||
matrix.appendChild(this.createCorner());
|
||||
colorReactionRows.forEach((row) => {
|
||||
matrix.appendChild(this.createHeader(row.colorIndex, row.label));
|
||||
});
|
||||
|
||||
colorReactionRows.forEach((row) => {
|
||||
matrix.appendChild(this.createHeader(row.colorIndex, row.label));
|
||||
row.keys.forEach((key, columnIndex) => {
|
||||
matrix.appendChild(this.createCell(key, row.colorIndex, columnIndex));
|
||||
});
|
||||
});
|
||||
|
||||
const matrixBlade = folder.addBlade({ view: 'separator' });
|
||||
matrixBlade.element.classList.add('color-reaction-matrix-blade');
|
||||
matrixBlade.element.replaceChildren(matrix);
|
||||
this.sync();
|
||||
}
|
||||
|
||||
public sync(): void {
|
||||
this.buttons.forEach(({ element, sourceColorIndex, targetColorIndex }, key) => {
|
||||
this.syncButton(element, key, sourceColorIndex, targetColorIndex);
|
||||
});
|
||||
|
||||
this.swatches.forEach(({ colorIndex, element }) => {
|
||||
element.style.backgroundColor = rgbColorToCss(activeVibe.colors[colorIndex]);
|
||||
});
|
||||
}
|
||||
|
||||
private createCorner(): HTMLDivElement {
|
||||
const corner = document.createElement('div');
|
||||
corner.className = 'color-reaction-matrix__corner';
|
||||
return corner;
|
||||
}
|
||||
|
||||
private createHeader(colorIndex: number, label: string): HTMLDivElement {
|
||||
const header = document.createElement('div');
|
||||
header.className = 'color-reaction-matrix__header';
|
||||
header.setAttribute('aria-label', label);
|
||||
header.title = label;
|
||||
|
||||
const swatch = document.createElement('span');
|
||||
swatch.className = 'color-reaction-matrix__swatch';
|
||||
this.swatches.push({ colorIndex, element: swatch });
|
||||
header.appendChild(swatch);
|
||||
|
||||
return header;
|
||||
}
|
||||
|
||||
private createCell(
|
||||
key: ColorReactionKey,
|
||||
sourceColorIndex: number,
|
||||
targetColorIndex: number
|
||||
): HTMLDivElement {
|
||||
const cell = document.createElement('div');
|
||||
cell.className = 'color-reaction-matrix__cell';
|
||||
|
||||
const config = appConfig.runtimeSettings.controls[key];
|
||||
if (!config) {
|
||||
return cell;
|
||||
}
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.className = 'color-reaction-matrix__button';
|
||||
button.type = 'button';
|
||||
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'color-reaction-matrix__icon';
|
||||
button.appendChild(icon);
|
||||
|
||||
button.addEventListener('click', () => {
|
||||
const currentValue = normalizeNumberControlValue(settings[key], config);
|
||||
const nextState = getNextColorReactionState(currentValue);
|
||||
settings[key] = nextState.value;
|
||||
this.syncButton(button, key, sourceColorIndex, targetColorIndex);
|
||||
this.onRuntimeChange();
|
||||
});
|
||||
|
||||
this.buttons.set(key, {
|
||||
element: button,
|
||||
sourceColorIndex,
|
||||
targetColorIndex,
|
||||
});
|
||||
cell.appendChild(button);
|
||||
|
||||
return cell;
|
||||
}
|
||||
|
||||
private syncButton(
|
||||
button: HTMLButtonElement,
|
||||
key: ColorReactionKey,
|
||||
sourceColorIndex: number,
|
||||
targetColorIndex: number
|
||||
): void {
|
||||
const config = appConfig.runtimeSettings.controls[key];
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
settings[key] = normalizeNumberControlValue(settings[key], config);
|
||||
|
||||
const state = getColorReactionState(settings[key]);
|
||||
const nextState = getNextColorReactionState(settings[key]);
|
||||
const sourceLabel = COLOR_REACTION_LABELS[sourceColorIndex];
|
||||
const targetLabel = COLOR_REACTION_LABELS[targetColorIndex];
|
||||
|
||||
button.dataset.reaction = state.id;
|
||||
button.setAttribute(
|
||||
'aria-label',
|
||||
`${sourceLabel} ${state.label.toLowerCase()} ${targetLabel.toLowerCase()} trails; click to switch to ${nextState.label.toLowerCase()}`
|
||||
);
|
||||
button.title = `${sourceLabel}: ${state.label} ${targetLabel} trails`;
|
||||
}
|
||||
}
|
||||
365
src/page/config-pane.ts
Normal file
365
src/page/config-pane.ts
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
import type { BindingParams, FolderApi } from '@tweakpane/core';
|
||||
import { Pane } from 'tweakpane';
|
||||
|
||||
import type { GardenAudioVibeSettings } from '../audio/garden-audio-config';
|
||||
import {
|
||||
appConfig,
|
||||
normalizeNumberControlValue,
|
||||
type GardenRuntimeSettings,
|
||||
type NumberControlConfig,
|
||||
} from '../config';
|
||||
import { activeVibe, settings } from '../settings';
|
||||
import { hexColorToRgbColor, rgbColorToHex, type RgbColor } from '../utils/rgb-color';
|
||||
import { ColorReactionMatrixControl } from './color-reaction-matrix-control';
|
||||
|
||||
type PaneContainer = Pick<FolderApi, 'addBinding' | 'addButton' | 'addFolder'>;
|
||||
type RuntimeControlKey = keyof GardenRuntimeSettings & string;
|
||||
type VibeColorKey = 'color1' | 'color2' | 'color3' | 'backgroundColor';
|
||||
type NumberPropertyKey<T> = {
|
||||
[Key in keyof T]-?: T[Key] extends number ? Key : never;
|
||||
}[keyof T] &
|
||||
string;
|
||||
type VibeNumberKey = NumberPropertyKey<GardenAudioVibeSettings>;
|
||||
|
||||
interface PaneState extends GardenAudioVibeSettings {
|
||||
backgroundColor: string;
|
||||
color1: string;
|
||||
color2: string;
|
||||
color3: string;
|
||||
}
|
||||
|
||||
const runtimeFolderOrder = ['Brush', 'Movement', 'Look', 'Performance'] as const;
|
||||
|
||||
const MUSIC_CONTROLS: ReadonlyArray<{
|
||||
key: VibeNumberKey;
|
||||
label: string;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
}> = [
|
||||
{ key: 'idleIntensity', label: 'Ambient Notes', min: 0, max: 1, step: 0.01 },
|
||||
{ key: 'bpm', label: 'Tempo', min: 48, max: 150, step: 1 },
|
||||
{ key: 'rampUpIntensity', label: 'Touch Energy', min: 0, max: 2, step: 0.01 },
|
||||
{ key: 'rampUpTime', label: 'Response Time', min: 0.01, max: 0.4, step: 0.01 },
|
||||
{ key: 'noteLength', label: 'Note Length', min: 0.1, max: 1.8, step: 0.01 },
|
||||
{ key: 'notePitchOffset', label: 'Pitch Shift', min: -12, max: 12, step: 1 },
|
||||
{ key: 'brightness', label: 'Tone Brightness', min: 0.5, max: 1.5, step: 0.01 },
|
||||
];
|
||||
|
||||
interface ConfigPaneOptions {
|
||||
maxSupportedAgentCount: number;
|
||||
onConfigChange: () => void;
|
||||
onOpen?: () => void;
|
||||
onRuntimeChange: () => void;
|
||||
settingsButton: HTMLButtonElement;
|
||||
}
|
||||
|
||||
const getRuntimeControlKeys = (folder: string): Array<RuntimeControlKey> =>
|
||||
(
|
||||
Object.entries(appConfig.runtimeSettings.controls) as Array<
|
||||
[RuntimeControlKey, NumberControlConfig | undefined]
|
||||
>
|
||||
)
|
||||
.filter(([, config]) => config?.folder === folder)
|
||||
.map(([key]) => key);
|
||||
|
||||
const getNumberBindingParams = (config: NumberControlConfig): BindingParams => {
|
||||
const params: BindingParams = {
|
||||
label: config.label,
|
||||
options: config.options,
|
||||
step: config.step,
|
||||
};
|
||||
if (config.format !== undefined) {
|
||||
params.format = config.format;
|
||||
}
|
||||
if (config.min !== undefined) {
|
||||
params.min = config.min;
|
||||
}
|
||||
if (config.max !== undefined) {
|
||||
params.max = config.max;
|
||||
}
|
||||
return params;
|
||||
};
|
||||
|
||||
export class ConfigPane {
|
||||
private readonly container: HTMLDivElement;
|
||||
private readonly closeButton: HTMLButtonElement;
|
||||
private readonly colorReactionMatrix: ColorReactionMatrixControl;
|
||||
private readonly pane: Pane;
|
||||
private readonly state: PaneState = {
|
||||
backgroundColor: rgbColorToHex(activeVibe.backgroundColor),
|
||||
color1: rgbColorToHex(activeVibe.colors[0]),
|
||||
color2: rgbColorToHex(activeVibe.colors[1]),
|
||||
color3: rgbColorToHex(activeVibe.colors[2]),
|
||||
...activeVibe.audio,
|
||||
};
|
||||
|
||||
public constructor(private readonly options: ConfigPaneOptions) {
|
||||
this.colorReactionMatrix = new ColorReactionMatrixControl(
|
||||
this.options.onRuntimeChange
|
||||
);
|
||||
this.container = document.createElement('div');
|
||||
this.container.className = 'config-pane-container';
|
||||
|
||||
this.closeButton = document.createElement('button');
|
||||
this.closeButton.className = 'config-pane-close';
|
||||
this.closeButton.type = 'button';
|
||||
this.closeButton.setAttribute('aria-label', 'Hide config overlay');
|
||||
this.closeButton.title = 'Hide config overlay';
|
||||
this.closeButton.addEventListener('click', () => this.setHidden(true));
|
||||
this.container.appendChild(this.closeButton);
|
||||
|
||||
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.id = 'config-pane';
|
||||
|
||||
this.options.settingsButton.setAttribute('aria-controls', this.pane.element.id);
|
||||
this.options.settingsButton.addEventListener('click', this.toggle);
|
||||
document.addEventListener('pointerdown', this.dismissOnOutsidePointerDown, {
|
||||
passive: true,
|
||||
});
|
||||
document.addEventListener('keydown', this.dismissOnEscape);
|
||||
|
||||
this.setUpTuningPane(this.pane);
|
||||
this.syncOpenState();
|
||||
}
|
||||
|
||||
public get isOpen(): boolean {
|
||||
return !this.pane.hidden;
|
||||
}
|
||||
|
||||
public refresh(): void {
|
||||
this.syncVibeState();
|
||||
this.pane.refresh();
|
||||
this.colorReactionMatrix.sync();
|
||||
this.syncOpenState();
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
this.setHidden(true);
|
||||
}
|
||||
|
||||
private readonly toggle = () => {
|
||||
this.setHidden(!this.pane.hidden);
|
||||
};
|
||||
|
||||
private readonly dismissOnOutsidePointerDown = (event: PointerEvent) => {
|
||||
if (!this.isOpen || !(event.target instanceof Node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
this.container.contains(event.target) ||
|
||||
this.options.settingsButton.contains(event.target)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setHidden(true);
|
||||
};
|
||||
|
||||
private readonly dismissOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && this.isOpen) {
|
||||
this.setHidden(true);
|
||||
}
|
||||
};
|
||||
|
||||
private setHidden(isHidden: boolean): void {
|
||||
const wasOpen = this.isOpen;
|
||||
this.pane.hidden = isHidden;
|
||||
this.syncOpenState();
|
||||
if (!wasOpen && this.isOpen) {
|
||||
this.options.onOpen?.();
|
||||
}
|
||||
}
|
||||
|
||||
private setUpTuningPane(container: PaneContainer): void {
|
||||
this.setUpVibeSection(container);
|
||||
this.addRuntimeSection(container, runtimeFolderOrder[0], true);
|
||||
this.addRuntimeSection(container, runtimeFolderOrder[1], true);
|
||||
this.colorReactionMatrix.addTo(container);
|
||||
this.addRuntimeSection(container, runtimeFolderOrder[2], true);
|
||||
const performanceFolder = this.addRuntimeSection(
|
||||
container,
|
||||
runtimeFolderOrder[3],
|
||||
true
|
||||
);
|
||||
this.addFpsOverlayBinding(performanceFolder);
|
||||
this.setUpMusicSection(container);
|
||||
this.colorReactionMatrix.sync();
|
||||
}
|
||||
|
||||
private setUpVibeSection(container: PaneContainer): void {
|
||||
const folder = container.addFolder({
|
||||
title: 'Colors',
|
||||
expanded: true,
|
||||
});
|
||||
|
||||
this.addColorBinding(folder, 'color1', '', (color) => {
|
||||
activeVibe.colors[0] = color;
|
||||
});
|
||||
this.addColorBinding(folder, 'color2', '', (color) => {
|
||||
activeVibe.colors[1] = color;
|
||||
});
|
||||
this.addColorBinding(folder, 'color3', '', (color) => {
|
||||
activeVibe.colors[2] = color;
|
||||
});
|
||||
this.addColorBinding(folder, 'backgroundColor', 'Background Color', (color) => {
|
||||
activeVibe.backgroundColor = color;
|
||||
});
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
folder
|
||||
.addButton({ title: 'Copy Vibe Preset' })
|
||||
.on('click', () => void this.copyVibePresetToClipboard());
|
||||
}
|
||||
}
|
||||
|
||||
private addColorBinding(
|
||||
container: PaneContainer,
|
||||
key: VibeColorKey,
|
||||
label: string,
|
||||
updateColor: (color: RgbColor) => void
|
||||
): void {
|
||||
container
|
||||
.addBinding(this.state, key, {
|
||||
label,
|
||||
view: 'color',
|
||||
} as BindingParams)
|
||||
.on('change', ({ value }) => {
|
||||
const color = hexColorToRgbColor(String(value));
|
||||
if (!color) {
|
||||
this.syncVibeState();
|
||||
this.pane.refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
updateColor(color);
|
||||
this.colorReactionMatrix.sync();
|
||||
this.options.onConfigChange();
|
||||
});
|
||||
}
|
||||
|
||||
private addRuntimeSection(
|
||||
container: PaneContainer,
|
||||
title: string,
|
||||
expanded: boolean
|
||||
): PaneContainer {
|
||||
const folder = container.addFolder({ title, expanded });
|
||||
getRuntimeControlKeys(title).forEach((key) => this.addRuntimeBinding(folder, key));
|
||||
return folder;
|
||||
}
|
||||
|
||||
private addRuntimeBinding(container: PaneContainer, key: RuntimeControlKey): void {
|
||||
const config = this.getRuntimeControlConfig(key);
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
settings[key] = normalizeNumberControlValue(settings[key], config);
|
||||
|
||||
container
|
||||
.addBinding(settings, key, getNumberBindingParams(config))
|
||||
.on('change', () => {
|
||||
const nextValue = normalizeNumberControlValue(settings[key], config);
|
||||
if (nextValue !== settings[key]) {
|
||||
settings[key] = nextValue;
|
||||
this.pane.refresh();
|
||||
}
|
||||
this.options.onRuntimeChange();
|
||||
});
|
||||
}
|
||||
|
||||
private getRuntimeControlConfig(
|
||||
key: RuntimeControlKey
|
||||
): NumberControlConfig | undefined {
|
||||
const config = appConfig.runtimeSettings.controls[key];
|
||||
if (!config || key !== 'maxAgentCount') {
|
||||
return config;
|
||||
}
|
||||
|
||||
return {
|
||||
...config,
|
||||
max: Math.max(config.min ?? 0, Math.floor(this.options.maxSupportedAgentCount)),
|
||||
};
|
||||
}
|
||||
|
||||
private addFpsOverlayBinding(container: PaneContainer): void {
|
||||
container
|
||||
.addBinding(appConfig.tuningPane, 'showFpsOverlay', {
|
||||
label: 'Show FPS',
|
||||
})
|
||||
.on('change', () => this.options.onConfigChange());
|
||||
}
|
||||
|
||||
private setUpMusicSection(container: PaneContainer): void {
|
||||
const folder = container.addFolder({ title: 'Music', expanded: true });
|
||||
MUSIC_CONTROLS.forEach(({ key, label, min, max, step }) => {
|
||||
this.addVibeNumberBinding(folder, key, { folder: 'Music', label, min, max, step });
|
||||
});
|
||||
}
|
||||
|
||||
private addVibeNumberBinding(
|
||||
container: PaneContainer,
|
||||
key: VibeNumberKey,
|
||||
config: NumberControlConfig
|
||||
): void {
|
||||
this.state[key] = normalizeNumberControlValue(this.state[key], config);
|
||||
|
||||
container
|
||||
.addBinding(this.state, key, getNumberBindingParams(config))
|
||||
.on('change', () => {
|
||||
const nextValue = normalizeNumberControlValue(this.state[key], config);
|
||||
if (nextValue !== this.state[key]) {
|
||||
this.state[key] = nextValue;
|
||||
this.pane.refresh();
|
||||
}
|
||||
activeVibe.audio[key] = nextValue;
|
||||
this.options.onConfigChange();
|
||||
});
|
||||
}
|
||||
|
||||
private async copyVibePresetToClipboard(): Promise<void> {
|
||||
const settingKeys = Object.keys(activeVibe.settings) as Array<
|
||||
keyof typeof activeVibe.settings
|
||||
>;
|
||||
const preset = {
|
||||
name: `${activeVibe.name} Copy`,
|
||||
colors: activeVibe.colors,
|
||||
backgroundColor: activeVibe.backgroundColor,
|
||||
settings: Object.fromEntries(settingKeys.map((key) => [key, settings[key]])),
|
||||
audio: activeVibe.audio,
|
||||
};
|
||||
try {
|
||||
await navigator.clipboard.writeText(JSON.stringify(preset, null, 2));
|
||||
} catch (error) {
|
||||
console.warn('Could not copy vibe preset to clipboard.', error);
|
||||
}
|
||||
}
|
||||
|
||||
private syncVibeState(): void {
|
||||
this.state.color1 = rgbColorToHex(activeVibe.colors[0]);
|
||||
this.state.color2 = rgbColorToHex(activeVibe.colors[1]);
|
||||
this.state.color3 = rgbColorToHex(activeVibe.colors[2]);
|
||||
this.state.backgroundColor = rgbColorToHex(activeVibe.backgroundColor);
|
||||
Object.assign(this.state, activeVibe.audio);
|
||||
}
|
||||
|
||||
private syncOpenState(): void {
|
||||
const { settingsButton } = this.options;
|
||||
const label = this.isOpen ? 'Hide config overlay' : 'Show config overlay';
|
||||
settingsButton.classList.toggle('active', this.isOpen);
|
||||
settingsButton.setAttribute('aria-expanded', String(this.isOpen));
|
||||
settingsButton.setAttribute('aria-label', label);
|
||||
settingsButton.title = label;
|
||||
this.container.classList.toggle('config-pane-container--open', this.isOpen);
|
||||
this.closeButton.hidden = !this.isOpen;
|
||||
}
|
||||
}
|
||||
84
src/page/eraser-size-control.ts
Normal file
84
src/page/eraser-size-control.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { appConfig } from '../config';
|
||||
import type GameLoop from '../game-loop/game-loop';
|
||||
import { settings } from '../settings';
|
||||
import { queryRequiredElement } from '../utils/dom';
|
||||
|
||||
const clampEraserSize = (value: number): number => {
|
||||
const { default: defaultSize, max, min } = appConfig.toolbar.eraser;
|
||||
const safeValue = Number.isFinite(value) ? value : defaultSize;
|
||||
return Math.min(max, Math.max(min, Math.round(safeValue)));
|
||||
};
|
||||
|
||||
const getEraserSizeRatio = (size: number): number => {
|
||||
const { max, min } = appConfig.toolbar.eraser;
|
||||
return (size - min) / (max - min);
|
||||
};
|
||||
|
||||
interface EraserSizeControlOptions {
|
||||
getGame: () => GameLoop | null;
|
||||
onActivate: () => void;
|
||||
onChange: () => void;
|
||||
}
|
||||
|
||||
export class EraserSizeControl {
|
||||
private readonly control = queryRequiredElement(
|
||||
'.eraser-size-control',
|
||||
HTMLLabelElement
|
||||
);
|
||||
private readonly slider = queryRequiredElement('.eraser-size-slider', HTMLInputElement);
|
||||
private isActive = false;
|
||||
|
||||
public constructor(private readonly options: EraserSizeControlOptions) {
|
||||
this.control.addEventListener('pointerdown', this.activate);
|
||||
this.control.addEventListener('click', this.activate);
|
||||
this.slider.addEventListener('focus', this.activate);
|
||||
this.slider.addEventListener('input', () => {
|
||||
settings.eraserSize = clampEraserSize(Number(this.slider.value));
|
||||
this.activate();
|
||||
this.render();
|
||||
this.options.onChange();
|
||||
});
|
||||
}
|
||||
|
||||
public render(): void {
|
||||
const size = clampEraserSize(settings.eraserSize);
|
||||
if (settings.eraserSize !== size) {
|
||||
settings.eraserSize = size;
|
||||
}
|
||||
|
||||
this.slider.min = appConfig.toolbar.eraser.min.toString();
|
||||
this.slider.max = appConfig.toolbar.eraser.max.toString();
|
||||
this.slider.step = appConfig.toolbar.eraser.step.toString();
|
||||
this.slider.value = size.toString();
|
||||
this.slider.setAttribute('aria-valuetext', `${size}px`);
|
||||
|
||||
const ratio = getEraserSizeRatio(size);
|
||||
const scale =
|
||||
appConfig.toolbar.eraser.controlScaleMin +
|
||||
(appConfig.toolbar.eraser.controlScaleMax -
|
||||
appConfig.toolbar.eraser.controlScaleMin) *
|
||||
ratio;
|
||||
this.control.style.setProperty('--eraser-progress', `${ratio * 100}%`);
|
||||
this.control.style.setProperty('--eraser-control-scale', scale.toFixed(3));
|
||||
this.syncActiveState();
|
||||
this.options.getGame()?.updateEraserPreview();
|
||||
}
|
||||
|
||||
public setActive(isActive: boolean): void {
|
||||
this.isActive = isActive;
|
||||
this.syncActiveState();
|
||||
}
|
||||
|
||||
private readonly activate = () => {
|
||||
this.setActive(true);
|
||||
this.options.onActivate();
|
||||
};
|
||||
|
||||
private syncActiveState(): void {
|
||||
this.control.classList.toggle('active', this.isActive);
|
||||
this.slider.setAttribute(
|
||||
'aria-label',
|
||||
this.isActive ? 'Eraser size, active' : 'Eraser size'
|
||||
);
|
||||
}
|
||||
}
|
||||
62
src/page/error-presenter.ts
Normal file
62
src/page/error-presenter.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import {
|
||||
ErrorHandler,
|
||||
getErrorMessage,
|
||||
RuntimeError,
|
||||
Severity,
|
||||
} from '../utils/error-handler';
|
||||
|
||||
type RuntimeUiError = Parameters<
|
||||
Parameters<typeof ErrorHandler.addOnErrorListener>[0]
|
||||
>[0];
|
||||
|
||||
const ERROR_CONTAINER_SELECTOR = '.errors-container';
|
||||
const ERROR_CONTAINER_CLASS = 'errors-container';
|
||||
|
||||
const renderRuntimeMessage = (container: HTMLElement, error: RuntimeUiError): void => {
|
||||
const message = document.createElement('pre');
|
||||
message.className = error.severity;
|
||||
message.textContent = error.code ? `${error.message}\n${error.code}` : error.message;
|
||||
message.setAttribute('role', error.severity === Severity.ERROR ? 'alert' : 'status');
|
||||
message.setAttribute(
|
||||
'aria-live',
|
||||
error.severity === Severity.ERROR ? 'assertive' : 'polite'
|
||||
);
|
||||
container.append(message);
|
||||
|
||||
if (error.severity === Severity.ERROR) {
|
||||
message.tabIndex = -1;
|
||||
message.focus({ preventScroll: true });
|
||||
}
|
||||
};
|
||||
|
||||
const getRuntimeUiError = (exception: unknown): RuntimeUiError => ({
|
||||
severity: Severity.ERROR,
|
||||
message: getErrorMessage(exception),
|
||||
...(exception instanceof RuntimeError ? { code: exception.code } : {}),
|
||||
});
|
||||
|
||||
export class ErrorPresenter {
|
||||
public constructor(private readonly container: HTMLElement) {
|
||||
container.setAttribute('aria-live', 'assertive');
|
||||
}
|
||||
|
||||
public render(error: RuntimeUiError): void {
|
||||
renderRuntimeMessage(this.container, error);
|
||||
}
|
||||
|
||||
public static renderStartup(exception: unknown): void {
|
||||
const existingContainer = document.querySelector(ERROR_CONTAINER_SELECTOR);
|
||||
const container =
|
||||
existingContainer instanceof HTMLElement
|
||||
? existingContainer
|
||||
: document.createElement('div');
|
||||
|
||||
if (!(existingContainer instanceof HTMLElement)) {
|
||||
container.className = ERROR_CONTAINER_CLASS;
|
||||
document.body.append(container);
|
||||
}
|
||||
|
||||
container.setAttribute('aria-live', 'assertive');
|
||||
renderRuntimeMessage(container, getRuntimeUiError(exception));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,43 +1,46 @@
|
|||
export class FullScreenHandler {
|
||||
private readonly abortController = new AbortController();
|
||||
|
||||
public constructor(
|
||||
private readonly minimizeButton: HTMLElement,
|
||||
private readonly maximizeButton: HTMLElement,
|
||||
private readonly toggleButton: HTMLElement,
|
||||
target: HTMLElement
|
||||
) {
|
||||
if (!document.fullscreenEnabled) {
|
||||
minimizeButton.style.display = 'none';
|
||||
maximizeButton.style.display = 'none';
|
||||
if (!document.fullscreenEnabled || typeof target.requestFullscreen !== 'function') {
|
||||
toggleButton.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateButtons();
|
||||
|
||||
addEventListener('keydown', (e) => {
|
||||
// on full screen request, only apply it to the target
|
||||
if (e.key === 'F11') {
|
||||
e.preventDefault();
|
||||
const { signal } = this.abortController;
|
||||
addEventListener('fullscreenchange', this.updateButtons, { signal });
|
||||
toggleButton.addEventListener(
|
||||
'click',
|
||||
() => {
|
||||
if (FullScreenHandler.isInFullScreenMode()) {
|
||||
document.exitFullscreen();
|
||||
} else {
|
||||
target.requestFullscreen();
|
||||
void document.exitFullscreen();
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
addEventListener('fullscreenchange', this.updateButtons.bind(this));
|
||||
maximizeButton.addEventListener('click', () => target.requestFullscreen());
|
||||
minimizeButton.addEventListener('click', () => document.exitFullscreen());
|
||||
|
||||
void target.requestFullscreen().catch(() => undefined);
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
}
|
||||
|
||||
public static isInFullScreenMode(): boolean {
|
||||
return document.fullscreenElement !== null;
|
||||
}
|
||||
|
||||
private updateButtons() {
|
||||
this.minimizeButton.style.display = FullScreenHandler.isInFullScreenMode()
|
||||
? 'block'
|
||||
: 'none';
|
||||
this.maximizeButton.style.display = FullScreenHandler.isInFullScreenMode()
|
||||
? 'none'
|
||||
: 'block';
|
||||
public destroy(): void {
|
||||
this.abortController.abort();
|
||||
}
|
||||
|
||||
private readonly updateButtons = (): void => {
|
||||
const isInFullScreenMode = FullScreenHandler.isInFullScreenMode();
|
||||
const label = isInFullScreenMode ? 'Exit fullscreen' : 'Enter fullscreen';
|
||||
this.toggleButton.classList.toggle('active', isInFullScreenMode);
|
||||
this.toggleButton.setAttribute('aria-label', label);
|
||||
this.toggleButton.title = label;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,139 @@
|
|||
import { appConfig } from '../config';
|
||||
|
||||
export class MenuHider {
|
||||
private static readonly DEFAULT_TIME_TO_LIVE = 3500;
|
||||
private static readonly INTERVAL = 50;
|
||||
private timeToLive = MenuHider.DEFAULT_TIME_TO_LIVE;
|
||||
private readonly desktopMediaQuery = window.matchMedia(
|
||||
appConfig.menuHider.desktopMediaQuery
|
||||
);
|
||||
private hideTimeout: number | undefined;
|
||||
private isHidden = false;
|
||||
private pointerInside = false;
|
||||
|
||||
public constructor(element: HTMLElement, shouldBeHidden: () => boolean) {
|
||||
setInterval(() => {
|
||||
this.timeToLive = Math.max(0, this.timeToLive - MenuHider.INTERVAL);
|
||||
element.style.opacity = this.timeToLive == 0 && shouldBeHidden() ? '0' : '1';
|
||||
}, MenuHider.INTERVAL);
|
||||
public constructor(
|
||||
private readonly element: HTMLElement,
|
||||
private readonly shouldBeHidden: () => boolean
|
||||
) {
|
||||
element.addEventListener('pointerenter', this.onPointerEnter);
|
||||
element.addEventListener('pointerleave', this.onPointerLeave);
|
||||
element.addEventListener('focusin', this.onFocusIn);
|
||||
element.addEventListener('focusout', this.onFocusOut);
|
||||
window.addEventListener('pointermove', this.onPointerMove, { passive: true });
|
||||
document.addEventListener('fullscreenchange', this.onVisibilityContextChange);
|
||||
this.desktopMediaQuery.addEventListener('change', this.onVisibilityContextChange);
|
||||
|
||||
element.addEventListener(
|
||||
'mouseover',
|
||||
() => (this.timeToLive = MenuHider.DEFAULT_TIME_TO_LIVE)
|
||||
this.reveal();
|
||||
}
|
||||
|
||||
private get canAutoHide(): boolean {
|
||||
return (
|
||||
this.desktopMediaQuery.matches &&
|
||||
this.shouldBeHidden() &&
|
||||
!this.pointerInside &&
|
||||
!this.element.contains(document.activeElement)
|
||||
);
|
||||
}
|
||||
|
||||
private readonly onPointerEnter = () => {
|
||||
this.pointerInside = true;
|
||||
this.reveal();
|
||||
};
|
||||
|
||||
private readonly onPointerLeave = () => {
|
||||
this.pointerInside = false;
|
||||
this.scheduleHide();
|
||||
};
|
||||
|
||||
private readonly onFocusIn = () => {
|
||||
this.reveal();
|
||||
};
|
||||
|
||||
private readonly onFocusOut = () => {
|
||||
window.setTimeout(() => this.scheduleHide(), 0);
|
||||
};
|
||||
|
||||
private readonly onPointerMove = (event: PointerEvent) => {
|
||||
if (!this.desktopMediaQuery.matches || !this.shouldBeHidden()) {
|
||||
this.reveal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isPointerOverDock(event.clientX, event.clientY)) {
|
||||
this.pointerInside = true;
|
||||
this.reveal();
|
||||
return;
|
||||
}
|
||||
|
||||
this.pointerInside = false;
|
||||
|
||||
if (this.isHidden) {
|
||||
if (this.isNearViewportBottom(event.clientY)) {
|
||||
this.reveal();
|
||||
this.scheduleHide();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.scheduleHide();
|
||||
};
|
||||
|
||||
private readonly onVisibilityContextChange = () => {
|
||||
this.scheduleHide();
|
||||
};
|
||||
|
||||
private scheduleHide(): void {
|
||||
if (!this.canAutoHide) {
|
||||
this.clearHideTimeout();
|
||||
this.reveal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.hideTimeout !== undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.hideTimeout = window.setTimeout(() => {
|
||||
this.hideTimeout = undefined;
|
||||
if (this.canAutoHide) {
|
||||
this.hide();
|
||||
}
|
||||
}, appConfig.menuHider.hideDelayMs);
|
||||
}
|
||||
|
||||
private reveal(): void {
|
||||
if (!this.isHidden && this.hideTimeout === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearHideTimeout();
|
||||
this.isHidden = false;
|
||||
this.element.classList.remove('menu-hidden');
|
||||
}
|
||||
|
||||
private hide(): void {
|
||||
this.isHidden = true;
|
||||
this.element.classList.add('menu-hidden');
|
||||
}
|
||||
|
||||
private clearHideTimeout(): void {
|
||||
if (this.hideTimeout === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.clearTimeout(this.hideTimeout);
|
||||
this.hideTimeout = undefined;
|
||||
}
|
||||
|
||||
private isPointerOverDock(clientX: number, clientY: number): boolean {
|
||||
const rect = this.element.getBoundingClientRect();
|
||||
return (
|
||||
clientX >= rect.left &&
|
||||
clientX <= rect.right &&
|
||||
clientY >= rect.top &&
|
||||
clientY <= rect.bottom
|
||||
);
|
||||
}
|
||||
|
||||
private isNearViewportBottom(clientY: number): boolean {
|
||||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
|
||||
return clientY >= viewportHeight - appConfig.menuHider.bottomRevealDistancePx;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
66
src/page/mirror-segment-control.ts
Normal file
66
src/page/mirror-segment-control.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { appConfig } from '../config';
|
||||
import { settings } from '../settings';
|
||||
import { queryRequiredElement } from '../utils/dom';
|
||||
|
||||
const clampMirrorSegmentCount = (value: number): number => {
|
||||
const { default: defaultCount, max, min } = appConfig.toolbar.mirror;
|
||||
const safeValue = Number.isFinite(value) ? value : defaultCount;
|
||||
return Math.min(max, Math.max(min, Math.round(safeValue)));
|
||||
};
|
||||
|
||||
const getMirrorSegmentRatio = (count: number): number => {
|
||||
const { max, min } = appConfig.toolbar.mirror;
|
||||
return (count - min) / (max - min);
|
||||
};
|
||||
|
||||
const formatMirrorSegmentCount = (count: number): string =>
|
||||
count <= 1
|
||||
? appConfig.toolbar.mirror.offLabel
|
||||
: `${count} ${
|
||||
appConfig.toolbar.mirror.names[
|
||||
count as keyof typeof appConfig.toolbar.mirror.names
|
||||
] ?? appConfig.toolbar.mirror.fallbackSegmentName
|
||||
}`;
|
||||
|
||||
interface MirrorSegmentControlOptions {
|
||||
onChange: () => void;
|
||||
}
|
||||
|
||||
export class MirrorSegmentControl {
|
||||
private readonly control = queryRequiredElement(
|
||||
'.mirror-segment-control',
|
||||
HTMLLabelElement
|
||||
);
|
||||
private readonly slider = queryRequiredElement(
|
||||
'.mirror-segment-slider',
|
||||
HTMLInputElement
|
||||
);
|
||||
|
||||
public constructor(private readonly options: MirrorSegmentControlOptions) {
|
||||
this.slider.addEventListener('input', () => {
|
||||
settings.mirrorSegmentCount = clampMirrorSegmentCount(Number(this.slider.value));
|
||||
this.render();
|
||||
this.options.onChange();
|
||||
});
|
||||
}
|
||||
|
||||
public render(): void {
|
||||
const count = clampMirrorSegmentCount(settings.mirrorSegmentCount);
|
||||
if (settings.mirrorSegmentCount !== count) {
|
||||
settings.mirrorSegmentCount = count;
|
||||
}
|
||||
|
||||
this.slider.min = appConfig.toolbar.mirror.min.toString();
|
||||
this.slider.max = appConfig.toolbar.mirror.max.toString();
|
||||
this.slider.step = appConfig.toolbar.mirror.step.toString();
|
||||
this.slider.value = count.toString();
|
||||
|
||||
const label = formatMirrorSegmentCount(count);
|
||||
const ratio = getMirrorSegmentRatio(count);
|
||||
this.slider.setAttribute('aria-valuetext', label);
|
||||
this.control.title = label;
|
||||
this.control.classList.toggle('active', count > 1);
|
||||
this.control.style.setProperty('--mirror-progress', `${ratio * 100}%`);
|
||||
this.control.style.setProperty('--mirror-angle', `${(360 / count).toFixed(3)}deg`);
|
||||
}
|
||||
}
|
||||
76
src/page/palette-control.ts
Normal file
76
src/page/palette-control.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import type GameLoop from '../game-loop/game-loop';
|
||||
import { activeVibe, settings } from '../settings';
|
||||
import { ErrorCode, RuntimeError } from '../utils/error-handler';
|
||||
import { rgbColorToCss } from '../utils/rgb-color';
|
||||
|
||||
interface PaletteControlOptions {
|
||||
getGame: () => GameLoop | null;
|
||||
onChange: () => void;
|
||||
onModeChange?: (isEraserActive: boolean) => void;
|
||||
}
|
||||
|
||||
export class PaletteControl {
|
||||
private readonly swatches = queryRequiredColorSwatches();
|
||||
private isEraserActiveState = false;
|
||||
|
||||
public constructor(private readonly options: PaletteControlOptions) {
|
||||
this.swatches.forEach((swatch, index) => {
|
||||
swatch.addEventListener('click', () => {
|
||||
settings.selectedColorIndex = index;
|
||||
this.isEraserActiveState = false;
|
||||
this.render();
|
||||
this.options.onModeChange?.(false);
|
||||
this.options.onChange();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public get isEraserActive(): boolean {
|
||||
return this.isEraserActiveState;
|
||||
}
|
||||
|
||||
public setEraserActive(active: boolean): void {
|
||||
this.isEraserActiveState = active;
|
||||
this.render();
|
||||
this.options.onModeChange?.(active);
|
||||
}
|
||||
|
||||
public render(): void {
|
||||
this.swatches.forEach((swatch, index) => {
|
||||
swatch.style.backgroundColor = rgbColorToCss(activeVibe.colors[index]);
|
||||
const isActive = settings.selectedColorIndex === index && !this.isEraserActiveState;
|
||||
swatch.classList.toggle('active', isActive);
|
||||
swatch.setAttribute('aria-pressed', String(isActive));
|
||||
});
|
||||
this.options.getGame()?.setEraseMode(this.isEraserActiveState);
|
||||
document.documentElement.style.setProperty(
|
||||
'--garden-background',
|
||||
rgbColorToCss(activeVibe.backgroundColor)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const queryRequiredColorSwatches = (): Array<HTMLButtonElement> => {
|
||||
const selector = '.color-swatch';
|
||||
const swatches = Array.from(document.querySelectorAll(selector));
|
||||
const expectedCount = activeVibe.colors.length;
|
||||
const hasExpectedSwatches =
|
||||
swatches.length === expectedCount &&
|
||||
swatches.every((swatch) => swatch instanceof HTMLButtonElement);
|
||||
|
||||
if (!hasExpectedSwatches) {
|
||||
throw new RuntimeError(
|
||||
ErrorCode.DOM_ELEMENT_MISSING,
|
||||
`Expected ${expectedCount} color swatches.`,
|
||||
{
|
||||
details: {
|
||||
actualCount: swatches.length,
|
||||
expectedCount,
|
||||
selector,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return swatches as Array<HTMLButtonElement>;
|
||||
};
|
||||
|
|
@ -1,115 +0,0 @@
|
|||
import { isProduction } from '../constants';
|
||||
import { settings } from '../settings';
|
||||
import { SettingsSlider, ValueScaling } from './settings-slider';
|
||||
|
||||
export const setUpSettingsPage = (
|
||||
settingsPage: HTMLDivElement,
|
||||
maxAgentCount: number
|
||||
): Array<SettingsSlider<any>> => {
|
||||
const sliders: Array<SettingsSlider<any>> = [
|
||||
...(isProduction
|
||||
? []
|
||||
: [
|
||||
new SettingsSlider(settings, 'renderSpeed', {
|
||||
min: 1,
|
||||
max: 10,
|
||||
rounding: Math.round,
|
||||
}),
|
||||
]),
|
||||
|
||||
new SettingsSlider(settings, 'agentCount', {
|
||||
min: 1,
|
||||
max: maxAgentCount,
|
||||
scaling: ValueScaling.Quadratic,
|
||||
rounding: Math.round,
|
||||
}),
|
||||
|
||||
new SettingsSlider(settings, 'currentGenerationAggression', {
|
||||
min: -5,
|
||||
max: 5,
|
||||
}),
|
||||
|
||||
new SettingsSlider(settings, 'nextGenerationAggression', {
|
||||
min: -5,
|
||||
max: 5,
|
||||
}),
|
||||
|
||||
new SettingsSlider(settings, 'moveSpeed', {
|
||||
min: 10,
|
||||
max: 500,
|
||||
scaling: ValueScaling.Quadratic,
|
||||
rounding: Math.round,
|
||||
}),
|
||||
|
||||
new SettingsSlider(settings, 'turnSpeed', {
|
||||
min: 1,
|
||||
max: 200,
|
||||
scaling: ValueScaling.Quadratic,
|
||||
rounding: Math.round,
|
||||
}),
|
||||
|
||||
new SettingsSlider(settings, 'sensorOffsetAngle', {
|
||||
min: 0,
|
||||
max: 90,
|
||||
step: 1,
|
||||
}),
|
||||
|
||||
new SettingsSlider(settings, 'sensorOffsetDistance', {
|
||||
min: 0,
|
||||
max: 200,
|
||||
scaling: ValueScaling.Quadratic,
|
||||
rounding: Math.round,
|
||||
}),
|
||||
|
||||
new SettingsSlider(settings, 'turnWhenLost', {
|
||||
min: 0,
|
||||
max: 1,
|
||||
}),
|
||||
|
||||
new SettingsSlider(settings, 'individualTrailWeight', {
|
||||
min: 0,
|
||||
max: 1,
|
||||
}),
|
||||
|
||||
new SettingsSlider(settings, 'diffusionRateTrails', {
|
||||
min: 0,
|
||||
max: 2,
|
||||
}),
|
||||
|
||||
new SettingsSlider(settings, 'decayRateTrails', {
|
||||
min: 0.1,
|
||||
max: 5000,
|
||||
scaling: ValueScaling.Quadratic,
|
||||
}),
|
||||
|
||||
new SettingsSlider(settings, 'diffusionRateBrush', {
|
||||
min: 0.001,
|
||||
max: 1,
|
||||
}),
|
||||
|
||||
new SettingsSlider(settings, 'decayRateBrush', {
|
||||
min: 0.1,
|
||||
max: 100,
|
||||
}),
|
||||
|
||||
new SettingsSlider(settings, 'brushSize', {
|
||||
min: 1,
|
||||
max: 30,
|
||||
}),
|
||||
|
||||
new SettingsSlider(settings, 'clarity', {
|
||||
min: 0.00001,
|
||||
max: 1,
|
||||
}),
|
||||
];
|
||||
|
||||
const sliderContainerElement = document.createElement('div');
|
||||
|
||||
sliders.forEach((slider) => {
|
||||
sliderContainerElement.appendChild(slider.element);
|
||||
});
|
||||
|
||||
settingsPage.appendChild(sliderContainerElement);
|
||||
|
||||
return sliders;
|
||||
};
|
||||
|
|
@ -1,146 +0,0 @@
|
|||
import { formatNumber } from '../utils/format-number';
|
||||
|
||||
export enum ValueScaling {
|
||||
Linear,
|
||||
Quadratic,
|
||||
Logarithmic,
|
||||
}
|
||||
|
||||
export interface SliderConfiguration {
|
||||
min: number;
|
||||
max: number;
|
||||
unit?: string;
|
||||
step?: number;
|
||||
onChangeCallback?: (value: number) => unknown;
|
||||
scaling: ValueScaling;
|
||||
rounding: (value: number) => number;
|
||||
}
|
||||
|
||||
export class SettingsSlider<T extends Record<string, number>> {
|
||||
private static readonly DEFAULT_STEP_COUNT = 20000;
|
||||
|
||||
private readonly slider: HTMLInputElement;
|
||||
private readonly valueDisplay: HTMLSpanElement;
|
||||
private readonly sliderWrapper: HTMLDivElement;
|
||||
private readonly config: SliderConfiguration = {
|
||||
min: 0,
|
||||
max: 1,
|
||||
scaling: ValueScaling.Linear,
|
||||
rounding: (value) => value,
|
||||
};
|
||||
|
||||
public constructor(
|
||||
private readonly settings: T,
|
||||
private readonly settingName: keyof T & string,
|
||||
config: Partial<SliderConfiguration> = {}
|
||||
) {
|
||||
this.slider = SettingsSlider.createSlider();
|
||||
this.valueDisplay = SettingsSlider.createValueDisplay();
|
||||
this.sliderWrapper = SettingsSlider.createSliderWrapper(
|
||||
this.settingName,
|
||||
this.slider,
|
||||
this.valueDisplay
|
||||
);
|
||||
|
||||
this.slider.addEventListener('input', this.onChange.bind(this));
|
||||
|
||||
this.updateConfig(config);
|
||||
}
|
||||
|
||||
private static createSlider() {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'range';
|
||||
return input;
|
||||
}
|
||||
|
||||
private static createValueDisplay() {
|
||||
return document.createElement('span');
|
||||
}
|
||||
|
||||
private static createSliderWrapper(
|
||||
name: string,
|
||||
slider: HTMLInputElement,
|
||||
valueDisplay: HTMLSpanElement
|
||||
) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.classList.add('slider');
|
||||
const label = document.createElement('label');
|
||||
|
||||
const title = document.createElement('p');
|
||||
title.innerText = SettingsSlider.formatLabel(name);
|
||||
title.appendChild(valueDisplay);
|
||||
|
||||
label.appendChild(title);
|
||||
label.appendChild(slider);
|
||||
wrapper.appendChild(label);
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
private static formatLabel(value: string): string {
|
||||
const formatted = value.replace(/([A-Z])/g, ' $1');
|
||||
|
||||
return (
|
||||
formatted.charAt(0).toLocaleUpperCase() + formatted.slice(1).toLocaleLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
private onChange() {
|
||||
this.settings[this.settingName] = this.config.rounding(
|
||||
this.inverseScaling(Number(this.slider.value))
|
||||
) as any;
|
||||
|
||||
this.config.onChangeCallback?.(this.settings[this.settingName]);
|
||||
this.valueDisplay.innerText = formatNumber(
|
||||
this.settings[this.settingName],
|
||||
this.config.unit
|
||||
);
|
||||
}
|
||||
|
||||
public updateSliderValueBasedOnSource() {
|
||||
this.slider.value = this.scaling(this.settings[this.settingName]).toString();
|
||||
this.onChange();
|
||||
}
|
||||
|
||||
public updateConfig(config: Partial<SliderConfiguration>) {
|
||||
Object.assign(this.config, config);
|
||||
|
||||
if (this.config.step === undefined) {
|
||||
this.config.step =
|
||||
(this.scaling(this.config.max) - this.scaling(this.config.min)) /
|
||||
SettingsSlider.DEFAULT_STEP_COUNT;
|
||||
}
|
||||
|
||||
this.slider.min = this.scaling(this.config.min).toString();
|
||||
this.slider.max = this.scaling(this.config.max).toString();
|
||||
this.slider.step = this.config.step.toString();
|
||||
this.slider.value = this.scaling(this.settings[this.settingName]).toString();
|
||||
this.onChange();
|
||||
}
|
||||
|
||||
public get element(): HTMLElement {
|
||||
return this.sliderWrapper;
|
||||
}
|
||||
|
||||
private get scaling(): (value: number) => number {
|
||||
switch (this.config.scaling) {
|
||||
case ValueScaling.Linear:
|
||||
return (value) => value;
|
||||
case ValueScaling.Quadratic:
|
||||
return (value) => Math.sqrt(value);
|
||||
case ValueScaling.Logarithmic:
|
||||
return (value) => Math.log10(value);
|
||||
}
|
||||
}
|
||||
|
||||
private get inverseScaling(): (value: number) => number {
|
||||
switch (this.config.scaling) {
|
||||
case ValueScaling.Linear:
|
||||
return (value) => value;
|
||||
case ValueScaling.Quadratic:
|
||||
return (value) => Math.pow(value, 2);
|
||||
case ValueScaling.Logarithmic:
|
||||
return (value) => Math.pow(10, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
61
src/page/splash-screen.ts
Normal file
61
src/page/splash-screen.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { queryRequiredElement } from '../utils/dom';
|
||||
import { clamp01 } from '../utils/math';
|
||||
|
||||
export class SplashScreen {
|
||||
public readonly startButton = queryRequiredElement('.start-button', HTMLButtonElement);
|
||||
private readonly splash = queryRequiredElement('.splash', HTMLDivElement);
|
||||
private readonly loadingBar = queryRequiredElement('.loading-bar', HTMLDivElement);
|
||||
private readonly loadingStatus = queryRequiredElement(
|
||||
'.loading-status',
|
||||
HTMLDivElement
|
||||
);
|
||||
private readonly loadingProgress = queryRequiredElement(
|
||||
'.loading-progress',
|
||||
HTMLDivElement
|
||||
);
|
||||
|
||||
private setVisible(element: HTMLElement, isVisible: boolean): void {
|
||||
element.dataset.visible = String(isVisible);
|
||||
element.setAttribute('aria-hidden', String(!isVisible));
|
||||
element.inert = !isVisible;
|
||||
}
|
||||
|
||||
public setLoadingStage(label: string, ratio: number): void {
|
||||
const percent = Math.round(clamp01(ratio) * 100);
|
||||
this.loadingStatus.textContent = label;
|
||||
this.loadingProgress.style.setProperty('--loading-progress', `${percent}%`);
|
||||
this.loadingProgress.setAttribute('aria-valuenow', String(percent));
|
||||
}
|
||||
|
||||
public awaitStart(onStart: () => void): Promise<void> {
|
||||
this.startButton.disabled = false;
|
||||
return new Promise<void>((resolve) => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Enter' || event.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
this.startButton.click();
|
||||
};
|
||||
const onClick = () => {
|
||||
this.startButton.removeEventListener('click', onClick);
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
onStart();
|
||||
this.setVisible(this.splash, false);
|
||||
resolve();
|
||||
};
|
||||
|
||||
this.startButton.addEventListener('click', onClick);
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
});
|
||||
}
|
||||
|
||||
public showLoadingBar(): void {
|
||||
this.setVisible(this.loadingBar, true);
|
||||
}
|
||||
|
||||
public hideLoadingBar(): void {
|
||||
this.setVisible(this.loadingBar, false);
|
||||
}
|
||||
}
|
||||
85
src/page/vibe-navigator.ts
Normal file
85
src/page/vibe-navigator.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { activeVibe, applyVibeSettings, rememberActiveVibeSelection } from '../settings';
|
||||
import { queryRequiredElement } from '../utils/dom';
|
||||
import { getCurrentUriVibeId, writeCurrentVibeUri } from '../vibe-uri';
|
||||
import { getVibeById, VIBE_PRESETS, type VibeId } from '../vibes';
|
||||
|
||||
interface VibeSelection {
|
||||
source: string;
|
||||
userGesture: boolean;
|
||||
vibeId: VibeId;
|
||||
vibeName: string;
|
||||
}
|
||||
|
||||
interface VibeNavigatorOptions {
|
||||
onChange: (selection: VibeSelection) => void;
|
||||
}
|
||||
|
||||
export class VibeNavigator {
|
||||
private readonly abortController = new AbortController();
|
||||
private readonly previousButton = queryRequiredElement(
|
||||
'.previous-vibe',
|
||||
HTMLButtonElement
|
||||
);
|
||||
private readonly nextButton = queryRequiredElement('.next-vibe', HTMLButtonElement);
|
||||
|
||||
public constructor(private readonly options: VibeNavigatorOptions) {
|
||||
rememberActiveVibeSelection();
|
||||
writeCurrentVibeUri(activeVibe.id, 'replace');
|
||||
|
||||
const { signal } = this.abortController;
|
||||
this.previousButton.addEventListener(
|
||||
'click',
|
||||
() => this.select(-1, 'previous-button'),
|
||||
{ signal }
|
||||
);
|
||||
this.nextButton.addEventListener('click', () => this.select(1, 'next-button'), {
|
||||
signal,
|
||||
});
|
||||
window.addEventListener('popstate', this.selectFromCurrentUri, { signal });
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.abortController.abort();
|
||||
}
|
||||
|
||||
private select(offset: number, source: string): void {
|
||||
const current = VIBE_PRESETS.findIndex((vibe) => vibe.id === activeVibe.id);
|
||||
const currentIndex = current >= 0 ? current : 0;
|
||||
const vibe =
|
||||
VIBE_PRESETS[(currentIndex + VIBE_PRESETS.length + offset) % VIBE_PRESETS.length];
|
||||
const activePreset = applyVibeSettings(vibe);
|
||||
writeCurrentVibeUri(activePreset.id, 'push');
|
||||
this.notifyChange(activePreset, source, true);
|
||||
}
|
||||
|
||||
private readonly selectFromCurrentUri = (): void => {
|
||||
const vibeId = getCurrentUriVibeId();
|
||||
if (!vibeId || vibeId === activeVibe.id) {
|
||||
writeCurrentVibeUri(activeVibe.id, 'replace');
|
||||
return;
|
||||
}
|
||||
|
||||
const vibe = getVibeById(vibeId);
|
||||
if (!vibe) {
|
||||
writeCurrentVibeUri(activeVibe.id, 'replace');
|
||||
return;
|
||||
}
|
||||
|
||||
const activePreset = applyVibeSettings(vibe);
|
||||
writeCurrentVibeUri(activePreset.id, 'replace');
|
||||
this.notifyChange(activePreset, 'uri-popstate', false);
|
||||
};
|
||||
|
||||
private notifyChange(
|
||||
activePreset: typeof activeVibe,
|
||||
source: string,
|
||||
userGesture: boolean
|
||||
): void {
|
||||
this.options.onChange({
|
||||
userGesture,
|
||||
vibeId: activePreset.id,
|
||||
vibeName: activePreset.name,
|
||||
source,
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue