diff --git a/src/app/pages/algorithms/ant-colony/ant-colony.component.html b/src/app/pages/algorithms/ant-colony/ant-colony.component.html index 5e2231e..7ea8ebe 100644 --- a/src/app/pages/algorithms/ant-colony/ant-colony.component.html +++ b/src/app/pages/algorithms/ant-colony/ant-colony.component.html @@ -12,26 +12,12 @@ -
- {{ 'ANT_COLONY.CITIES' | translate }}: {{ cityCount() }} - - - -
+ @for (slider of sliders; track slider.key) { -
- {{ slider.label | translate }}: {{ slider.model() }} - - - -
+ } -
- {{ 'ANT_COLONY.SPEED' | translate }}: {{ speed() }} - - - -
+

{{ 'ANT_COLONY.CLICK_HINT' | translate }}

diff --git a/src/app/pages/algorithms/ant-colony/ant-colony.component.ts b/src/app/pages/algorithms/ant-colony/ant-colony.component.ts index 7ed3ee9..c04d5ff 100644 --- a/src/app/pages/algorithms/ant-colony/ant-colony.component.ts +++ b/src/app/pages/algorithms/ant-colony/ant-colony.component.ts @@ -1,10 +1,9 @@ import {AfterViewInit, Component, computed, ElementRef, HostListener, inject, NgZone, OnDestroy, PLATFORM_ID, signal, ViewChild, WritableSignal} from '@angular/core'; import {isPlatformBrowser} from '@angular/common'; -import {FormsModule} from '@angular/forms'; import {MatButton} from '@angular/material/button'; import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card'; import {MatIcon} from '@angular/material/icon'; -import {MatSliderModule} from '@angular/material/slider'; +import {SliderFieldComponent} from '../../../shared/components/slider-field/slider-field'; import {TranslatePipe} from '@ngx-translate/core'; import {Information} from '../information/information'; import {AlgorithmInformation} from '../information/information.models'; @@ -37,8 +36,7 @@ interface SettingSlider { Information, MatButton, MatIcon, - MatSliderModule, - FormsModule, + SliderFieldComponent, MetricsComponent ], templateUrl: './ant-colony.component.html' diff --git a/src/app/pages/algorithms/boids/boids.component.html b/src/app/pages/algorithms/boids/boids.component.html index e682405..02a9bc7 100644 --- a/src/app/pages/algorithms/boids/boids.component.html +++ b/src/app/pages/algorithms/boids/boids.component.html @@ -19,69 +19,19 @@ } -
- {{ 'BOIDS.AGENTS' | translate }}: {{ agentCount() }} - - - -
-
- {{ 'BOIDS.MAX_SPEED' | translate }}: {{ maxSpeed() }} - - - -
+ + @if (mode() === 'boids') { -
- {{ 'BOIDS.SEPARATION' | translate }}: {{ separation() }} - - - -
-
- {{ 'BOIDS.ALIGNMENT' | translate }}: {{ alignment() }} - - - -
-
- {{ 'BOIDS.COHESION' | translate }}: {{ cohesion() }} - - - -
-
- {{ 'BOIDS.VIEW_RADIUS' | translate }}: {{ viewRadius() }} - - - -
+ + + +

{{ 'BOIDS.PREDATOR_HINT' | translate }}

} @else { -
- {{ 'BOIDS.TYPES' | translate }}: {{ numTypes() }} - - - -
-
- {{ 'BOIDS.INTERACTION_RADIUS' | translate }}: {{ interactionRadius() }} - - - -
-
- {{ 'BOIDS.FRICTION' | translate }}: {{ friction() }} - - - -
-
- {{ 'BOIDS.FORCE_SCALE' | translate }}: {{ forceScale() }} - - - -
+ + + + } diff --git a/src/app/pages/algorithms/boids/boids.component.ts b/src/app/pages/algorithms/boids/boids.component.ts index 880e2d4..39a77fc 100644 --- a/src/app/pages/algorithms/boids/boids.component.ts +++ b/src/app/pages/algorithms/boids/boids.component.ts @@ -1,7 +1,6 @@ import {Component, computed, OnDestroy, signal} from '@angular/core'; -import {FormsModule} from '@angular/forms'; import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card'; -import {MatSliderModule} from '@angular/material/slider'; +import {SliderFieldComponent} from '../../../shared/components/slider-field/slider-field'; import {MatButtonToggleModule} from '@angular/material/button-toggle'; import {MatButton} from '@angular/material/button'; import {TranslatePipe} from '@ngx-translate/core'; @@ -45,8 +44,7 @@ import {BoidsCpuStrategy} from './strategies/boids-cpu.strategy'; BabylonCanvas, MatButton, MatButtonToggleModule, - MatSliderModule, - FormsModule, + SliderFieldComponent, Information, MetricsComponent ], diff --git a/src/app/pages/algorithms/cloth/cloth.component.html b/src/app/pages/algorithms/cloth/cloth.component.html index 5b9d42a..7f9a7a4 100644 --- a/src/app/pages/algorithms/cloth/cloth.component.html +++ b/src/app/pages/algorithms/cloth/cloth.component.html @@ -16,12 +16,7 @@ {{ 'CLOTH.RESTART_SIMULATION' | translate }} -
- {{ 'CLOTH.ELONGATION' | translate }}: {{ elongation() }} - - - -
+
- - {{ 'FOURIER.CIRCLES' | translate }}: {{ circleCount() }} - - - - - - {{ 'FOURIER.SPEED' | translate }}: {{ speed() }} - - - - + +
diff --git a/src/app/pages/algorithms/fourier/fourier.component.ts b/src/app/pages/algorithms/fourier/fourier.component.ts index b2c8bad..64ac9d9 100644 --- a/src/app/pages/algorithms/fourier/fourier.component.ts +++ b/src/app/pages/algorithms/fourier/fourier.component.ts @@ -1,10 +1,9 @@ import {AfterViewInit, Component, computed, ElementRef, HostListener, inject, NgZone, OnDestroy, signal, PLATFORM_ID, ViewChild} from '@angular/core'; import {isPlatformBrowser} from '@angular/common'; -import {FormsModule} from '@angular/forms'; import {MatButton} from '@angular/material/button'; import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card'; import {MatIcon} from '@angular/material/icon'; -import {MatSliderModule} from '@angular/material/slider'; +import {SliderFieldComponent} from '../../../shared/components/slider-field/slider-field'; import {TranslatePipe} from '@ngx-translate/core'; import {Information} from '../information/information'; import {AlgorithmInformation} from '../information/information.models'; @@ -29,8 +28,7 @@ import { Information, MatButton, MatIcon, - MatSliderModule, - FormsModule, + SliderFieldComponent, MetricsComponent ], templateUrl: './fourier.component.html' diff --git a/src/app/pages/algorithms/fractal/fractal.component.html b/src/app/pages/algorithms/fractal/fractal.component.html index 47a041b..2c25992 100644 --- a/src/app/pages/algorithms/fractal/fractal.component.html +++ b/src/app/pages/algorithms/fractal/fractal.component.html @@ -37,6 +37,14 @@
} zoom_in + + + diff --git a/src/app/pages/algorithms/fractal/fractal.component.scss b/src/app/pages/algorithms/fractal/fractal.component.scss index e69de29..4e2723e 100644 --- a/src/app/pages/algorithms/fractal/fractal.component.scss +++ b/src/app/pages/algorithms/fractal/fractal.component.scss @@ -0,0 +1,5 @@ +@use '@angular/material' as mat; + +.zoom-field { + @include mat.form-field-density(-4); +} diff --git a/src/app/pages/algorithms/fractal/fractal.component.ts b/src/app/pages/algorithms/fractal/fractal.component.ts index ec4466d..0afec82 100644 --- a/src/app/pages/algorithms/fractal/fractal.component.ts +++ b/src/app/pages/algorithms/fractal/fractal.component.ts @@ -2,7 +2,7 @@ import {afterNextRender, Component, computed, OnInit, signal} from '@angular/cor import {Information} from '../information/information'; import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card'; import {TranslatePipe} from '@ngx-translate/core'; -import {MatFormField, MatLabel} from '@angular/material/input'; +import {MatFormField, MatInput, MatLabel} from '@angular/material/input'; import {MatOption} from '@angular/material/core'; import {MatSelect} from '@angular/material/select'; import {AlgorithmInformation} from '../information/information.models'; @@ -18,6 +18,7 @@ import {NgxSliderModule, Options} from '@angular-slider/ngx-slider'; import {clampZoom, initialView, iterationsForZoom, zoomAroundPoint} from './fractal-view'; import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics'; import {FpsCounter} from '../../../shared/fps-counter'; +import {formatSliderValue, parseSliderInput} from '../../../shared/components/slider-field/slider-value'; @Component({ selector: 'app-fractal', @@ -29,6 +30,7 @@ import {FpsCounter} from '../../../shared/fps-counter'; MatCardTitle, TranslatePipe, MatFormField, + MatInput, MatLabel, MatOption, MatSelect, @@ -103,16 +105,18 @@ export class FractalComponent implements OnInit { selectedAlgorithm = 0; selectedColorScheme = 0; + readonly zoomStep = 0.01; sliderValue = signal(0); options: Options = { floor: this.minZoomValue, ceil: this.maxZoomValue, logScale: true, - step: 0.01, + step: this.zoomStep, showTicks: false, hideLimitLabels: true, hidePointerLabels: true }; + protected readonly zoomDisplay = computed(() => formatSliderValue(this.sliderValue(), this.zoomStep, this.minZoomValue)); zoom = 0; offsetX = 0; offsetY = 0; @@ -262,4 +266,13 @@ export class FractalComponent implements OnInit { this.zoom = clampZoom(newValue, this.minZoomValue, this.maxZoomValue); this.maxIterations.set(iterationsForZoom(this.zoom)); } + + protected applyZoom(field: HTMLInputElement): void { + const next = parseSliderInput(field.value, this.minZoomValue, this.maxZoomValue, this.zoomStep); + if (next !== null && next !== Number(this.zoomDisplay())) { + this.sliderValue.set(next); + this.onSliderChange(next); + } + field.value = this.zoomDisplay(); + } } diff --git a/src/app/pages/algorithms/gray-scott/gray-scott.component.html b/src/app/pages/algorithms/gray-scott/gray-scott.component.html index 65ea148..449fbb4 100644 --- a/src/app/pages/algorithms/gray-scott/gray-scott.component.html +++ b/src/app/pages/algorithms/gray-scott/gray-scott.component.html @@ -16,12 +16,8 @@ @for (slider of sliders; track slider.key) { -
- {{ slider.label | translate }}: {{ slider.model() }} - - - -
+ }

{{ 'GRAY_SCOTT.PAINT_HINT' | translate }}

diff --git a/src/app/pages/algorithms/gray-scott/gray-scott.component.ts b/src/app/pages/algorithms/gray-scott/gray-scott.component.ts index 29c1d89..f3536f4 100644 --- a/src/app/pages/algorithms/gray-scott/gray-scott.component.ts +++ b/src/app/pages/algorithms/gray-scott/gray-scott.component.ts @@ -1,7 +1,6 @@ import {Component, computed, OnDestroy, signal, WritableSignal} from '@angular/core'; -import {FormsModule} from '@angular/forms'; import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card'; -import {MatSliderModule} from '@angular/material/slider'; +import {SliderFieldComponent} from '../../../shared/components/slider-field/slider-field'; import {MatButtonToggleModule} from '@angular/material/button-toggle'; import {MatButton} from '@angular/material/button'; import {TranslatePipe} from '@ngx-translate/core'; @@ -43,8 +42,7 @@ interface SettingSlider { BabylonCanvas, MatButton, MatButtonToggleModule, - MatSliderModule, - FormsModule, + SliderFieldComponent, Information, MetricsComponent ], diff --git a/src/app/pages/algorithms/lenia/lenia.component.html b/src/app/pages/algorithms/lenia/lenia.component.html index d8537b2..5709933 100644 --- a/src/app/pages/algorithms/lenia/lenia.component.html +++ b/src/app/pages/algorithms/lenia/lenia.component.html @@ -19,12 +19,8 @@ @for (slider of sliders; track slider.key) { -
- {{ slider.label | translate }}: {{ slider.model() }} - - - -
+ }

{{ 'LENIA.PAINT_HINT' | translate }}

diff --git a/src/app/pages/algorithms/lenia/lenia.component.ts b/src/app/pages/algorithms/lenia/lenia.component.ts index d512143..ad7374c 100644 --- a/src/app/pages/algorithms/lenia/lenia.component.ts +++ b/src/app/pages/algorithms/lenia/lenia.component.ts @@ -1,7 +1,6 @@ import {Component, computed, OnDestroy, signal, WritableSignal} from '@angular/core'; -import {FormsModule} from '@angular/forms'; import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card'; -import {MatSliderModule} from '@angular/material/slider'; +import {SliderFieldComponent} from '../../../shared/components/slider-field/slider-field'; import {MatButtonToggleModule} from '@angular/material/button-toggle'; import {MatButton} from '@angular/material/button'; import {TranslatePipe} from '@ngx-translate/core'; @@ -48,8 +47,7 @@ const MASS_READ_INTERVAL_MS = 1000; BabylonCanvas, MatButton, MatButtonToggleModule, - MatSliderModule, - FormsModule, + SliderFieldComponent, Information, MetricsComponent ], diff --git a/src/app/pages/algorithms/pendulum/pendulum.component.html b/src/app/pages/algorithms/pendulum/pendulum.component.html index a858c0a..d739ce2 100644 --- a/src/app/pages/algorithms/pendulum/pendulum.component.html +++ b/src/app/pages/algorithms/pendulum/pendulum.component.html @@ -5,43 +5,18 @@
- @if (sliderReady()) { -
-
-

{{ 'PENDULUM.TRAIL_DECAY_TIME' | translate }}

- -
-
-

{{ 'PENDULUM.ATTRACTION' | translate }}

- -
+
+ + -
-

{{ 'PENDULUM.L1_LENGTH' | translate }}

- -
-
-

{{ 'PENDULUM.L2_LENGTH' | translate }}

- -
+ + -
-

{{ 'PENDULUM.M1_MASS' | translate }}

- -
-
-

{{ 'PENDULUM.M2_MASS' | translate }}

- -
+ + -
-

{{ 'PENDULUM.DAMPING' | translate }}

- -
-
- } @else { -
- } + +
-
- {{ 'PHYSARUM.AGENTS' | translate }}: {{ agentCount() }} - - - -
+ @for (slider of sliders; track slider.key) { -
- {{ slider.label | translate }}: {{ slider.model() }} - - - -
+ }

{{ 'PHYSARUM.FOOD_HINT' | translate }}

diff --git a/src/app/pages/algorithms/physarum/physarum.component.ts b/src/app/pages/algorithms/physarum/physarum.component.ts index 3647501..b650059 100644 --- a/src/app/pages/algorithms/physarum/physarum.component.ts +++ b/src/app/pages/algorithms/physarum/physarum.component.ts @@ -1,7 +1,6 @@ import {Component, computed, OnDestroy, signal, WritableSignal} from '@angular/core'; -import {FormsModule} from '@angular/forms'; import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card'; -import {MatSliderModule} from '@angular/material/slider'; +import {SliderFieldComponent} from '../../../shared/components/slider-field/slider-field'; import {MatButtonToggleModule} from '@angular/material/button-toggle'; import {MatButton} from '@angular/material/button'; import {TranslatePipe} from '@ngx-translate/core'; @@ -55,8 +54,7 @@ interface SettingSlider { BabylonCanvas, MatButton, MatButtonToggleModule, - MatSliderModule, - FormsModule, + SliderFieldComponent, Information, MetricsComponent ], diff --git a/src/app/shared/components/slider-field/slider-field.html b/src/app/shared/components/slider-field/slider-field.html new file mode 100644 index 0000000..e15a618 --- /dev/null +++ b/src/app/shared/components/slider-field/slider-field.html @@ -0,0 +1,18 @@ +
+ {{ label() | translate }} + + + + + + +
diff --git a/src/app/shared/components/slider-field/slider-field.scss b/src/app/shared/components/slider-field/slider-field.scss new file mode 100644 index 0000000..3d47c4b --- /dev/null +++ b/src/app/shared/components/slider-field/slider-field.scss @@ -0,0 +1,5 @@ +@use '@angular/material' as mat; + +:host { + @include mat.form-field-density(-4); +} diff --git a/src/app/shared/components/slider-field/slider-field.spec.ts b/src/app/shared/components/slider-field/slider-field.spec.ts new file mode 100644 index 0000000..d8b8df8 --- /dev/null +++ b/src/app/shared/components/slider-field/slider-field.spec.ts @@ -0,0 +1,110 @@ +import {Component, provideZonelessChangeDetection, signal} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {provideNoopAnimations} from '@angular/platform-browser/animations'; +import {provideTranslateService} from '@ngx-translate/core'; +import {SliderFieldComponent} from './slider-field'; + +@Component({ + imports: [SliderFieldComponent], + template: `` +}) +class HostComponent { + readonly model = signal(4); + commits = 0; +} + +describe('SliderFieldComponent', () => { + let fixture: ComponentFixture; + let host: HostComponent; + + const field = () => fixture.nativeElement.querySelector('input[type=number]') as HTMLInputElement; + + async function type(text: string, event: 'enter' | 'blur' = 'enter'): Promise { + field().value = text; + if (event === 'enter') { + field().dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter'})); + } else { + field().dispatchEvent(new Event('blur')); + } + await fixture.whenStable(); + } + + beforeEach(async () => { + TestBed.configureTestingModule({ + providers: [provideZonelessChangeDetection(), provideNoopAnimations(), provideTranslateService({lang: 'en', fallbackLang: 'en'})] + }); + fixture = TestBed.createComponent(HostComponent); + host = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('shows the value', () => { + expect(field().value).toBe('4'); + }); + + it('applies a typed value on Enter and emits once, also after blur', async () => { + await type('8'); + + expect(host.model()).toBe(8); + expect(host.commits).toBe(1); + + await type('8', 'blur'); + + expect(host.commits).toBe(1); + }); + + it('applies on blur', async () => { + await type('6', 'blur'); + + expect(host.model()).toBe(6); + expect(host.commits).toBe(1); + }); + + it('clamps out-of-range input in the signal and the field', async () => { + await type('99'); + + expect(host.model()).toBe(10); + expect(field().value).toBe('10'); + }); + + it('snaps off-step input', async () => { + await type('5.2'); + + expect(host.model()).toBe(6); + expect(field().value).toBe('6'); + }); + + it('reverts empty or invalid input without emitting', async () => { + await type(''); + + expect(field().value).toBe('4'); + + await type('abc'); + + expect(field().value).toBe('4'); + expect(host.model()).toBe(4); + expect(host.commits).toBe(0); + }); + + it('follows the host signal', async () => { + host.model.set(2); + await fixture.whenStable(); + + expect(field().value).toBe('2'); + }); + + it('names the number field by the label key', () => { + expect(field().getAttribute('aria-label')).toBe('TEST.LABEL'); + }); + + it('emits committed on slider release', async () => { + const thumb = fixture.nativeElement.querySelector('input[type=range]') as HTMLInputElement; + thumb.value = '8'; + thumb.dispatchEvent(new Event('input')); + thumb.dispatchEvent(new Event('change')); + await fixture.whenStable(); + + expect(host.model()).toBe(8); + expect(host.commits).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/src/app/shared/components/slider-field/slider-field.ts b/src/app/shared/components/slider-field/slider-field.ts new file mode 100644 index 0000000..61a1dbd --- /dev/null +++ b/src/app/shared/components/slider-field/slider-field.ts @@ -0,0 +1,42 @@ +import {booleanAttribute, ChangeDetectionStrategy, Component, computed, input, model, output} from '@angular/core'; +import {FormsModule} from '@angular/forms'; +import {MatInputModule} from '@angular/material/input'; +import {MatSliderModule} from '@angular/material/slider'; +import {TranslatePipe} from '@ngx-translate/core'; +import {formatSliderValue, parseSliderInput} from './slider-value'; + +@Component({ + selector: 'app-slider-field', + imports: [MatSliderModule, MatInputModule, FormsModule, TranslatePipe], + templateUrl: './slider-field.html', + styleUrl: './slider-field.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class SliderFieldComponent { + readonly value = model.required(); + /** Fires once per slider release and once per typed value that changes the value. */ + readonly committed = output(); + + /** i18n key */ + readonly label = input.required(); + readonly min = input.required(); + readonly max = input.required(); + readonly step = input(1); + readonly discrete = input(false, {transform: booleanAttribute}); + + protected readonly display = computed(() => formatSliderValue(this.value(), this.step(), this.min())); + + protected onSliderRelease(next: number): void { + this.value.set(next); + this.committed.emit(next); + } + + protected applyTyped(field: HTMLInputElement): void { + const next = parseSliderInput(field.value, this.min(), this.max(), this.step()); + if (next !== null && next !== this.value()) { + this.value.set(next); + this.committed.emit(next); + } + field.value = this.display(); + } +} diff --git a/src/app/shared/components/slider-field/slider-value.spec.ts b/src/app/shared/components/slider-field/slider-value.spec.ts new file mode 100644 index 0000000..19ce04d --- /dev/null +++ b/src/app/shared/components/slider-field/slider-value.spec.ts @@ -0,0 +1,43 @@ +import {formatSliderValue, parseSliderInput, snapToStep} from './slider-value'; + +describe('slider-value', () => { + it('clamps below min and above max', () => { + expect(snapToStep(-5, 0, 10, 1)).toBe(0); + expect(snapToStep(50, 0, 10, 1)).toBe(10); + }); + + it('snaps off-step values to the nearest step from min', () => { + expect(snapToStep(100, 16, 1000, 16)).toBe(96); + expect(snapToStep(105, 16, 1000, 16)).toBe(112); + }); + + it('rounds to an integer step', () => { + expect(snapToStep(3.7, 0, 10, 1)).toBe(4); + }); + + it('stays within max when max is off the step grid', () => { + expect(snapToStep(10, 0, 9.5, 2)).toBeLessThanOrEqual(9.5); + expect(snapToStep(10, 0, 9.5, 2)).toBe(8); + }); + + it('keeps an exact value on a fine step without float noise', () => { + expect(snapToStep(0.0545, 0.01, 0.1, 0.0001)).toBe(0.0545); + }); + + it('parses typed text, null for empty or non-numeric', () => { + expect(parseSliderInput(' 5.2 ', 0, 10, 2)).toBe(6); + expect(parseSliderInput('', 0, 10, 2)).toBeNull(); + expect(parseSliderInput('abc', 0, 10, 2)).toBeNull(); + }); + + it('formats without float noise', () => { + expect(formatSliderValue(0.1 + 0.2, 0.1, 0)).toBe('0.3'); + expect(formatSliderValue(0.5, 0.01, 0.5)).toBe('0.5'); + expect(formatSliderValue(16, 16, 16)).toBe('16'); + }); + + it('keepsMaxOnGridDespiteFloatNoise', () => { + expect(snapToStep(0.075, 0.04, 0.075, 0.0001)).toBe(0.075); + expect(snapToStep(3, 0.2, 3, 0.1)).toBe(3); + }); +}); diff --git a/src/app/shared/components/slider-field/slider-value.ts b/src/app/shared/components/slider-field/slider-value.ts new file mode 100644 index 0000000..5895dfa --- /dev/null +++ b/src/app/shared/components/slider-field/slider-value.ts @@ -0,0 +1,27 @@ +/** Number of fraction digits needed to show values on the `min + n * step` grid. */ +export function stepDecimals(step: number, min: number): number { + const digits = (n: number) => String(n).split('.')[1]?.length ?? 0; + return Math.max(digits(step), digits(min)); +} + +/** Clamps to [min, max] and snaps to the nearest step counted from min, without float noise. */ +export function snapToStep(value: number, min: number, max: number, step: number): number { + const clamped = Math.min(Math.max(value, min), max); + const decimals = stepDecimals(step, min); + const snapped = Number((min + Math.round((clamped - min) / step) * step).toFixed(decimals)); + return Number((snapped > max ? snapped - step : snapped).toFixed(decimals)); +} + +/** The typed text clamped and snapped, or null when it is not a number. */ +export function parseSliderInput(text: string, min: number, max: number, step: number): number | null { + const trimmed = text.trim(); + const typed = Number(trimmed); + if (trimmed === '' || !Number.isFinite(typed)) { + return null; + } + return snapToStep(typed, min, max, step); +} + +export function formatSliderValue(value: number, step: number, min: number): string { + return String(Number(value.toFixed(stepDecimals(step, min)))); +}