From 4e24df151164520f33043c0ba50abf6fab428499 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Tue, 26 May 2026 20:59:01 +0100 Subject: [PATCH] Minor improvements --- index.html | 12 ++-- src/audio/garden-audio-graph.ts | 28 ++++++---- src/config/eraser-size.ts | 84 ++++++++++++++++++++++++++++ src/game-loop/eraser-preview.ts | 14 +++-- src/game-loop/game-loop.ts | 7 ++- src/page/eraser-size-control.test.ts | 15 ++++- src/page/eraser-size-control.ts | 59 +++++++++---------- src/style/_panels.scss | 47 +--------------- src/style/common.scss | 9 +++ 9 files changed, 175 insertions(+), 100 deletions(-) create mode 100644 src/config/eraser-size.ts diff --git a/index.html b/index.html index 1414388..071ba68 100644 --- a/index.html +++ b/index.html @@ -4,14 +4,14 @@ @@ -22,7 +22,7 @@ @@ -35,7 +35,7 @@ @@ -46,7 +46,7 @@ "@type": "WebApplication", "name": "Fleeting Garden", "url": "https://schmelczer.dev/fleeting/", - "description": "Plant colour, fold gestures with mirrors, and watch small agents turn each brushstroke into a shifting WebGPU garden.", + "description": "Plant colours in your fleeting garden and watch them flow as time passes. Immerse yourself into shaping nature and nature shaping your work.", "image": "https://schmelczer.dev/fleeting/og-image.jpg", "applicationCategory": "DesignApplication", "operatingSystem": "Any", @@ -91,7 +91,7 @@

Fleeting Garden

- Tend it while you can. The garden returns to weather either way. + Plant colours in your fleeting garden and watch them flow as time passes. Immerse yourself into shaping nature and nature shaping your work.

diff --git a/src/audio/garden-audio-graph.ts b/src/audio/garden-audio-graph.ts index a288465..866bb94 100644 --- a/src/audio/garden-audio-graph.ts +++ b/src/audio/garden-audio-graph.ts @@ -28,10 +28,10 @@ const graphTuning = { latencyHint: 'interactive', outputFilterType: 'highpass', compressor: { - thresholdDb: -18, - kneeDb: 18, - ratio: 2.1, - attackSeconds: 0.018, + thresholdDb: -22, + kneeDb: 12, + ratio: 4.5, + attackSeconds: 0.006, releaseSeconds: 0.18, }, } as const; @@ -87,10 +87,12 @@ export class GardenAudioGraph { const context = new AudioContextConstructor({ latencyHint: graphTuning.latencyHint, }); + const outputBus = context.createGain(); const masterGain = context.createGain(); const highPass = context.createBiquadFilter(); const compressor = context.createDynamicsCompressor(); + outputBus.gain.value = 1; masterGain.gain.value = 0; highPass.type = graphTuning.outputFilterType; highPass.frequency.value = outputHighPassFrequencyHz; @@ -100,15 +102,17 @@ export class GardenAudioGraph { compressor.attack.value = graphTuning.compressor.attackSeconds; compressor.release.value = graphTuning.compressor.releaseSeconds; - masterGain.connect(highPass); + // Keep peak control independent from the user's volume slider. + outputBus.connect(highPass); highPass.connect(compressor); - compressor.connect(context.destination); + compressor.connect(masterGain); + masterGain.connect(context.destination); this.context = context; this.masterGain = masterGain; this.noiseBuffer = this.createNoiseBuffer(context); - this.createDelay(context, masterGain); - this.createBuses(context, masterGain); + this.createDelay(context, outputBus); + this.createBuses(context, outputBus); return context; } @@ -224,7 +228,7 @@ export class GardenAudioGraph { } } - private createDelay(context: AudioContext, masterGain: GainNode): void { + private createDelay(context: AudioContext, outputBus: GainNode): void { const delayInput = context.createGain(); const delayNode = context.createDelay(graphTuning.delayMaxSeconds); const delayFeedback = context.createGain(); @@ -250,7 +254,7 @@ export class GardenAudioGraph { delayFeedback.connect(delayNode); delayNode.connect(returnLowPass); returnLowPass.connect(delayOutput); - delayOutput.connect(masterGain); + delayOutput.connect(outputBus); this.delayInput = delayInput; this.delayNode = delayNode; @@ -258,10 +262,10 @@ export class GardenAudioGraph { this.delayOutput = delayOutput; } - private createBuses(context: AudioContext, masterGain: GainNode): void { + private createBuses(context: AudioContext, outputBus: GainNode): void { const eventBus = context.createGain(); eventBus.gain.value = graphTuning.eventBusGain; - eventBus.connect(masterGain); + eventBus.connect(outputBus); this.eventBus = eventBus; this.pianoBuses.clear(); diff --git a/src/config/eraser-size.ts b/src/config/eraser-size.ts new file mode 100644 index 0000000..008bee7 --- /dev/null +++ b/src/config/eraser-size.ts @@ -0,0 +1,84 @@ +export interface CssPixelSize { + height: number; + width: number; +} + +export const ERASER_SIZE_MIN = 24; +export const ERASER_SIZE_MAX = 480; + +const ERASER_MAX_SHORT_SIDE_RATIO = 0.55; + +const getNormalizedEraserSizeMax = (maxSize: number): number => { + const safeMaxSize = Number.isFinite(maxSize) ? Math.floor(maxSize) : ERASER_SIZE_MAX; + return Math.max(ERASER_SIZE_MIN, Math.min(ERASER_SIZE_MAX, safeMaxSize)); +}; + +export const getElementCssPixelSize = (element: Element): CssPixelSize => { + const rect = element.getBoundingClientRect(); + const { clientHeight = 0, clientWidth = 0 } = element as HTMLElement; + return { + height: rect.height || clientHeight, + width: rect.width || clientWidth, + }; +}; + +export const getEraserSizeMaxForCssSize = ({ height, width }: CssPixelSize): number => { + const shortestSide = Math.min(height, width); + if (!Number.isFinite(shortestSide) || shortestSide <= 0) { + return ERASER_SIZE_MAX; + } + + return getNormalizedEraserSizeMax( + Math.floor(shortestSide * ERASER_MAX_SHORT_SIDE_RATIO) + ); +}; + +export const clampEraserSize = ( + value: number, + maxSize = ERASER_SIZE_MAX, + fallbackSize = ERASER_SIZE_MIN +): number => { + const max = getNormalizedEraserSizeMax(maxSize); + const fallback = Number.isFinite(fallbackSize) ? fallbackSize : ERASER_SIZE_MIN; + const safeValue = Number.isFinite(value) ? value : fallback; + return Math.min(max, Math.max(ERASER_SIZE_MIN, Math.round(safeValue))); +}; + +export const getEffectiveEraserSize = ( + size: number, + cssSize: CssPixelSize, + fallbackSize = ERASER_SIZE_MIN +): number => clampEraserSize(size, getEraserSizeMaxForCssSize(cssSize), fallbackSize); + +export const getEraserSizeRatio = (size: number, maxSize = ERASER_SIZE_MAX): number => { + const max = getNormalizedEraserSizeMax(maxSize); + if (max === ERASER_SIZE_MIN) { + return 0; + } + + return (clampEraserSize(size, max) - ERASER_SIZE_MIN) / (max - ERASER_SIZE_MIN); +}; + +const ERASER_SLIDER_MIN = 0; +const ERASER_SLIDER_MAX = 1; + +const clampSliderRatio = (value: number): number => { + const safeValue = Number.isFinite(value) ? value : ERASER_SLIDER_MIN; + return Math.min(ERASER_SLIDER_MAX, Math.max(ERASER_SLIDER_MIN, safeValue)); +}; + +export const getEraserSizeFromSliderRatio = ( + sliderRatio: number, + maxSize = ERASER_SIZE_MAX +): number => { + const max = getNormalizedEraserSizeMax(maxSize); + return clampEraserSize( + ERASER_SIZE_MIN + (max - ERASER_SIZE_MIN) * clampSliderRatio(sliderRatio) ** 2, + max + ); +}; + +export const getEraserSliderRatioFromSize = ( + size: number, + maxSize = ERASER_SIZE_MAX +): number => Math.sqrt(getEraserSizeRatio(size, maxSize)); diff --git a/src/game-loop/eraser-preview.ts b/src/game-loop/eraser-preview.ts index fefd93c..493d497 100644 --- a/src/game-loop/eraser-preview.ts +++ b/src/game-loop/eraser-preview.ts @@ -1,3 +1,4 @@ +import { getEffectiveEraserSize } from '../config/eraser-size'; import { settings } from '../settings'; export class EraserPreview { @@ -49,9 +50,15 @@ export class EraserPreview { }; } - if (this.previousSize !== settings.eraserSize) { - this.element.style.setProperty('--eraser-preview-size', `${settings.eraserSize}px`); - this.previousSize = settings.eraserSize; + const rect = this.canvas.getBoundingClientRect(); + const size = getEffectiveEraserSize(settings.eraserSize, { + height: rect.height || this.canvas.clientHeight, + width: rect.width || this.canvas.clientWidth, + }); + + if (this.previousSize !== size) { + this.element.style.setProperty('--eraser-preview-size', `${size}px`); + this.previousSize = size; } if ( @@ -63,7 +70,6 @@ export class EraserPreview { return; } - const rect = this.canvas.getBoundingClientRect(); const left = `${this.previewClientPosition.x - rect.left}px`; const top = `${this.previewClientPosition.y - rect.top}px`; if (this.previousLeft !== left) { diff --git a/src/game-loop/game-loop.ts b/src/game-loop/game-loop.ts index 25d2323..d53a73c 100644 --- a/src/game-loop/game-loop.ts +++ b/src/game-loop/game-loop.ts @@ -2,6 +2,7 @@ import { vec2 } from 'gl-matrix'; import { GardenAudio } from '../audio/garden-audio'; import { createGardenAudioConfig } from '../audio/garden-audio-config'; +import { getEffectiveEraserSize, getElementCssPixelSize } from '../config/eraser-size'; import { activeVibe, settings } from '../settings'; import { DeltaTimeCalculator } from '../utils/delta-time-calculator'; import { rgbColorToCss, type RgbColor } from '../utils/rgb-color'; @@ -209,7 +210,11 @@ export default class GameLoop { const runtimeSettings = { ...settings }; const introProgress = this.introPrompt.progress; const canvasPixelRatio = this.canvasPixelRatio; - const eraserPixelSize = runtimeSettings.eraserSize * canvasPixelRatio; + const eraserCssSize = getEffectiveEraserSize( + runtimeSettings.eraserSize, + getElementCssPixelSize(this.canvas) + ); + const eraserPixelSize = eraserCssSize * canvasPixelRatio; const isErasing = this.pointerInput.isEraseMode; const accentColor = channelColors[runtimeSettings.selectedColorIndex] ?? channelColors[0]; diff --git a/src/page/eraser-size-control.test.ts b/src/page/eraser-size-control.test.ts index 1d93778..372c63e 100644 --- a/src/page/eraser-size-control.test.ts +++ b/src/page/eraser-size-control.test.ts @@ -3,9 +3,11 @@ import { describe, expect, it } from 'vitest'; import { ERASER_SIZE_MAX, ERASER_SIZE_MIN, + getEffectiveEraserSize, getEraserSizeFromSliderRatio, + getEraserSizeMaxForCssSize, getEraserSliderRatioFromSize, -} from './eraser-size-control'; +} from '../config/eraser-size'; describe('eraser size slider mapping', () => { it('maps slider position quadratically to eraser size', () => { @@ -23,4 +25,15 @@ describe('eraser size slider mapping', () => { expect(getEraserSliderRatioFromSize(quarterRangeSize)).toBe(0.5); expect(getEraserSliderRatioFromSize(ERASER_SIZE_MAX)).toBe(1); }); + + it('uses a responsive max size on small canvases', () => { + const mobileMax = getEraserSizeMaxForCssSize({ height: 640, width: 390 }); + + expect(mobileMax).toBeLessThan(ERASER_SIZE_MAX); + expect(getEraserSizeFromSliderRatio(1, mobileMax)).toBe(mobileMax); + expect(getEraserSliderRatioFromSize(ERASER_SIZE_MAX, mobileMax)).toBe(1); + expect(getEffectiveEraserSize(ERASER_SIZE_MAX, { height: 640, width: 390 })).toBe( + mobileMax + ); + }); }); diff --git a/src/page/eraser-size-control.ts b/src/page/eraser-size-control.ts index 8f93d51..beb2d0f 100644 --- a/src/page/eraser-size-control.ts +++ b/src/page/eraser-size-control.ts @@ -1,40 +1,25 @@ +import { + clampEraserSize, + ERASER_SIZE_MAX, + getElementCssPixelSize, + getEraserSizeFromSliderRatio, + getEraserSizeMaxForCssSize, + getEraserSizeRatio, + getEraserSliderRatioFromSize, +} from '../config/eraser-size'; import type GameLoop from '../game-loop/game-loop'; import { DEFAULT_ERASER_SIZE, settings } from '../settings'; import { queryRequiredElement } from '../utils/dom'; -export const ERASER_SIZE_MIN = 24; -export const ERASER_SIZE_MAX = 480; - const ERASER_CONTROL_SCALE_MIN = 0.74; const ERASER_CONTROL_SCALE_MAX = 1.34; -const clampEraserSize = (value: number): number => { - const safeValue = Number.isFinite(value) ? value : DEFAULT_ERASER_SIZE; - return Math.min(ERASER_SIZE_MAX, Math.max(ERASER_SIZE_MIN, Math.round(safeValue))); -}; - const ERASER_SLIDER_MIN = 0; const ERASER_SLIDER_MAX = 1; const ERASER_SLIDER_STEP = 0.001; -const clampSliderRatio = (value: number): number => { - const safeValue = Number.isFinite(value) ? value : ERASER_SLIDER_MIN; - return Math.min(ERASER_SLIDER_MAX, Math.max(ERASER_SLIDER_MIN, safeValue)); -}; - -const getEraserSizeRatio = (size: number): number => { - return (clampEraserSize(size) - ERASER_SIZE_MIN) / (ERASER_SIZE_MAX - ERASER_SIZE_MIN); -}; - -export const getEraserSizeFromSliderRatio = (sliderRatio: number): number => { - return clampEraserSize( - ERASER_SIZE_MIN + - (ERASER_SIZE_MAX - ERASER_SIZE_MIN) * clampSliderRatio(sliderRatio) ** 2 - ); -}; - -export const getEraserSliderRatioFromSize = (size: number): number => - Math.sqrt(getEraserSizeRatio(size)); +const clampStoredEraserSize = (value: number): number => + clampEraserSize(value, ERASER_SIZE_MAX, DEFAULT_ERASER_SIZE); interface EraserSizeControlOptions { getGame: () => GameLoop | null; @@ -48,6 +33,7 @@ export class EraserSizeControl { HTMLLabelElement ); private readonly slider = queryRequiredElement('.eraser-size-slider', HTMLInputElement); + private readonly canvas = queryRequiredElement('canvas', HTMLCanvasElement); private isActive = false; public constructor(private readonly options: EraserSizeControlOptions) { @@ -55,7 +41,10 @@ export class EraserSizeControl { this.control.addEventListener('click', this.activate); this.slider.addEventListener('focus', this.activate); this.slider.addEventListener('input', () => { - settings.eraserSize = getEraserSizeFromSliderRatio(Number(this.slider.value)); + settings.eraserSize = getEraserSizeFromSliderRatio( + Number(this.slider.value), + this.getResponsiveMaxSize() + ); this.activate(); this.render(); this.options.onChange(); @@ -63,19 +52,21 @@ export class EraserSizeControl { } public render(): void { - const size = clampEraserSize(settings.eraserSize); - if (settings.eraserSize !== size) { - settings.eraserSize = size; + const maxSize = this.getResponsiveMaxSize(); + const storedSize = clampStoredEraserSize(settings.eraserSize); + if (settings.eraserSize !== storedSize) { + settings.eraserSize = storedSize; } - const sliderRatio = getEraserSliderRatioFromSize(size); + const size = clampEraserSize(storedSize, maxSize, DEFAULT_ERASER_SIZE); + const sliderRatio = getEraserSliderRatioFromSize(size, maxSize); this.slider.min = ERASER_SLIDER_MIN.toString(); this.slider.max = ERASER_SLIDER_MAX.toString(); this.slider.step = ERASER_SLIDER_STEP.toString(); this.slider.value = sliderRatio.toString(); this.slider.setAttribute('aria-valuetext', `${size}px`); - const sizeRatio = getEraserSizeRatio(size); + const sizeRatio = getEraserSizeRatio(size, maxSize); const scale = ERASER_CONTROL_SCALE_MIN + (ERASER_CONTROL_SCALE_MAX - ERASER_CONTROL_SCALE_MIN) * sizeRatio; @@ -95,6 +86,10 @@ export class EraserSizeControl { this.options.onActivate(); }; + private getResponsiveMaxSize(): number { + return getEraserSizeMaxForCssSize(getElementCssPixelSize(this.canvas)); + } + private syncActiveState(): void { this.control.classList.toggle('active', this.isActive); this.slider.setAttribute( diff --git a/src/style/_panels.scss b/src/style/_panels.scss index 3e92b02..5d03b13 100644 --- a/src/style/_panels.scss +++ b/src/style/_panels.scss @@ -2,13 +2,10 @@ html > body > aside.control-dock > .info-page { width: min(100%, 520px); - max-height: min(62vh, 480px); - max-height: min(62dvh, 480px); + max-height: 200vh; + max-height: 200dvh; margin: 0 auto 10px; - overflow-x: hidden; - overflow-y: auto; - overscroll-behavior: contain; - touch-action: pan-y; + overflow: hidden; border: 1px solid rgb(255 255 255 / 46%); border-radius: 8px; background: @@ -20,26 +17,12 @@ html > body > aside.control-dock > .info-page { 0 16px 42px rgb(0 0 0 / 30%), 0 2px 10px rgb(0 0 0 / 18%); backdrop-filter: blur(16px) saturate(118%); - scrollbar-width: thin; - scrollbar-color: rgb(69 98 88 / 62%) transparent; - -webkit-overflow-scrolling: touch; transition: max-height var(--transition-time-long), opacity var(--transition-time-long), transform var(--transition-time-long), margin-bottom var(--transition-time-long); - &::-webkit-scrollbar-track, - &::-webkit-scrollbar { - background-color: transparent; - width: 6px; - } - - &::-webkit-scrollbar-thumb { - background-color: rgb(69 98 88 / 62%); - border-radius: 8px; - } - &:focus-visible { outline: 2px solid rgb(17 56 45); outline-offset: 3px; @@ -225,8 +208,6 @@ html > body > aside.control-dock > .info-page { @include on-small-screen { width: min(100%, 520px); - max-height: min(58vh, 500px); - max-height: min(58dvh, 500px); .info-page__content { gap: 0.75rem; @@ -244,26 +225,4 @@ html > body > aside.control-dock > .info-page { line-height: 1.45; } } - - @media (max-height: 420px) { - max-height: min( - 58vh, - max( - 10rem, - calc( - 100vh - 168px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px) - ) - ) - ); - max-height: min( - 58dvh, - max( - 10rem, - calc( - 100dvh - - 168px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px) - ) - ) - ); - } } diff --git a/src/style/common.scss b/src/style/common.scss index 23c82aa..9996bc5 100644 --- a/src/style/common.scss +++ b/src/style/common.scss @@ -16,15 +16,24 @@ html { height: 100%; + overscroll-behavior: none; touch-action: manipulation; + user-select: none; -webkit-font-smoothing: antialiased; + -webkit-touch-callout: none; + -webkit-user-select: none; -moz-osx-font-smoothing: grayscale; text-rendering: optimizeLegibility; } body { font-family: 'Open Sans', sans-serif; + overscroll-behavior: none; touch-action: manipulation; + user-select: none; + -webkit-tap-highlight-color: transparent; + -webkit-touch-callout: none; + -webkit-user-select: none; } .visually-hidden {