feat(slider-field): add number input to all algorithm sliders

Add app-slider-field: a mat-slider with a number field. Typed values
apply on Enter or blur, clamped and snapped to the step.
Pendulum uses it. Fractal keeps ngx-slider (log zoom) plus a field.
Drop the ": value" suffix from slider labels.
This commit is contained in:
lobo committed 2026-10-23 13:29:36 +02:00
1 parent 008f1254f8
commit 3018c3f1b5
26 files changed
+333 -246

No files matched your search

@@ -12,26 +12,12 @@
<button mat-raised-button (click)="reset()">{{ 'ANT_COLONY.RESET' | translate }}</button> <button mat-raised-button (click)="reset()">{{ 'ANT_COLONY.RESET' | translate }}</button>
<button mat-raised-button (click)="reroll()">{{ 'ANT_COLONY.REROLL' | translate }}</button> <button mat-raised-button (click)="reroll()">{{ 'ANT_COLONY.REROLL' | translate }}</button>
</div> </div>
<div class="flex items-center gap-2.5"> <app-slider-field label="ANT_COLONY.CITIES" [min]="MIN_CITIES" [max]="MAX_CITIES" [(value)]="cityCount" (committed)="reroll()"/>
<span>{{ 'ANT_COLONY.CITIES' | translate }}: {{ cityCount() }}</span>
<mat-slider [min]="MIN_CITIES" [max]="MAX_CITIES" step="1">
<input matSliderThumb [(ngModel)]="cityCount" (valueChange)="reroll()">
</mat-slider>
</div>
@for (slider of sliders; track slider.key) { @for (slider of sliders; track slider.key) {
<div class="flex items-center gap-2.5"> <app-slider-field [label]="slider.label" [min]="limits[slider.key].min" [max]="limits[slider.key].max"
<span>{{ slider.label | translate }}: {{ slider.model() }}</span> [step]="limits[slider.key].step" [(value)]="slider.model"/>
<mat-slider [min]="limits[slider.key].min" [max]="limits[slider.key].max" [step]="limits[slider.key].step">
<input matSliderThumb [ngModel]="slider.model()" (ngModelChange)="slider.model.set($event)">
</mat-slider>
</div>
} }
<div class="flex items-center gap-2.5"> <app-slider-field label="ANT_COLONY.SPEED" [min]="MIN_SPEED" [max]="MAX_SPEED" [(value)]="speed"/>
<span>{{ 'ANT_COLONY.SPEED' | translate }}: {{ speed() }}</span>
<mat-slider [min]="MIN_SPEED" [max]="MAX_SPEED" step="1">
<input matSliderThumb [(ngModel)]="speed">
</mat-slider>
</div>
<p class="text-[0.9em] opacity-80">{{ 'ANT_COLONY.CLICK_HINT' | translate }}</p> <p class="text-[0.9em] opacity-80">{{ 'ANT_COLONY.CLICK_HINT' | translate }}</p>
</div> </div>
<app-metrics [metrics]="metrics()"/> <app-metrics [metrics]="metrics()"/>
@@ -1,10 +1,9 @@
import {AfterViewInit, Component, computed, ElementRef, HostListener, inject, NgZone, OnDestroy, PLATFORM_ID, signal, ViewChild, WritableSignal} from '@angular/core'; import {AfterViewInit, Component, computed, ElementRef, HostListener, inject, NgZone, OnDestroy, PLATFORM_ID, signal, ViewChild, WritableSignal} from '@angular/core';
import {isPlatformBrowser} from '@angular/common'; import {isPlatformBrowser} from '@angular/common';
import {FormsModule} from '@angular/forms';
import {MatButton} from '@angular/material/button'; import {MatButton} from '@angular/material/button';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card'; import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
import {MatIcon} from '@angular/material/icon'; 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 {TranslatePipe} from '@ngx-translate/core';
import {Information} from '../information/information'; import {Information} from '../information/information';
import {AlgorithmInformation} from '../information/information.models'; import {AlgorithmInformation} from '../information/information.models';
@@ -37,8 +36,7 @@ interface SettingSlider {
Information, Information,
MatButton, MatButton,
MatIcon, MatIcon,
MatSliderModule, SliderFieldComponent,
FormsModule,
MetricsComponent MetricsComponent
], ],
templateUrl: './ant-colony.component.html' templateUrl: './ant-colony.component.html'
@@ -19,69 +19,19 @@
</button> </button>
} }
</div> </div>
<div class="flex items-center gap-2.5"> <app-slider-field label="BOIDS.AGENTS" [min]="minAgents" [max]="maxAgents()" [step]="16" [(value)]="agentCount" (committed)="restartSimulation()"/>
<span>{{ 'BOIDS.AGENTS' | translate }}: {{ agentCount() }}</span> <app-slider-field label="BOIDS.MAX_SPEED" [min]="boidsLimits.maxSpeedMin" [max]="boidsLimits.maxSpeedMax" [(value)]="maxSpeed"/>
<mat-slider [min]="minAgents" [max]="maxAgents()" step="16">
<input matSliderThumb [(ngModel)]="agentCount" (valueChange)="restartSimulation()">
</mat-slider>
</div>
<div class="flex items-center gap-2.5">
<span>{{ 'BOIDS.MAX_SPEED' | translate }}: {{ maxSpeed() }}</span>
<mat-slider [min]="boidsLimits.maxSpeedMin" [max]="boidsLimits.maxSpeedMax" step="1">
<input matSliderThumb [(ngModel)]="maxSpeed">
</mat-slider>
</div>
@if (mode() === 'boids') { @if (mode() === 'boids') {
<div class="flex items-center gap-2.5"> <app-slider-field label="BOIDS.SEPARATION" [min]="0" [max]="boidsLimits.weightMax" [step]="0.1" [(value)]="separation"/>
<span>{{ 'BOIDS.SEPARATION' | translate }}: {{ separation() }}</span> <app-slider-field label="BOIDS.ALIGNMENT" [min]="0" [max]="boidsLimits.weightMax" [step]="0.1" [(value)]="alignment"/>
<mat-slider min="0" [max]="boidsLimits.weightMax" step="0.1"> <app-slider-field label="BOIDS.COHESION" [min]="0" [max]="boidsLimits.weightMax" [step]="0.1" [(value)]="cohesion"/>
<input matSliderThumb [(ngModel)]="separation"> <app-slider-field label="BOIDS.VIEW_RADIUS" [min]="boidsLimits.viewRadiusMin" [max]="boidsLimits.viewRadiusMax" [step]="0.5" [(value)]="viewRadius"/>
</mat-slider>
</div>
<div class="flex items-center gap-2.5">
<span>{{ 'BOIDS.ALIGNMENT' | translate }}: {{ alignment() }}</span>
<mat-slider min="0" [max]="boidsLimits.weightMax" step="0.1">
<input matSliderThumb [(ngModel)]="alignment">
</mat-slider>
</div>
<div class="flex items-center gap-2.5">
<span>{{ 'BOIDS.COHESION' | translate }}: {{ cohesion() }}</span>
<mat-slider min="0" [max]="boidsLimits.weightMax" step="0.1">
<input matSliderThumb [(ngModel)]="cohesion">
</mat-slider>
</div>
<div class="flex items-center gap-2.5">
<span>{{ 'BOIDS.VIEW_RADIUS' | translate }}: {{ viewRadius() }}</span>
<mat-slider [min]="boidsLimits.viewRadiusMin" [max]="boidsLimits.viewRadiusMax" step="0.5">
<input matSliderThumb [(ngModel)]="viewRadius">
</mat-slider>
</div>
<p class="text-[0.9em] opacity-80">{{ 'BOIDS.PREDATOR_HINT' | translate }}</p> <p class="text-[0.9em] opacity-80">{{ 'BOIDS.PREDATOR_HINT' | translate }}</p>
} @else { } @else {
<div class="flex items-center gap-2.5"> <app-slider-field label="BOIDS.TYPES" [min]="lifeLimits.numTypesMin" [max]="lifeLimits.numTypesMax" [(value)]="numTypes" (committed)="onNumTypesChange()"/>
<span>{{ 'BOIDS.TYPES' | translate }}: {{ numTypes() }}</span> <app-slider-field label="BOIDS.INTERACTION_RADIUS" [min]="2" [max]="20" [step]="0.5" [(value)]="interactionRadius"/>
<mat-slider [min]="lifeLimits.numTypesMin" [max]="lifeLimits.numTypesMax" step="1"> <app-slider-field label="BOIDS.FRICTION" [min]="0.5" [max]="0.99" [step]="0.01" [(value)]="friction"/>
<input matSliderThumb [(ngModel)]="numTypes" (valueChange)="onNumTypesChange()"> <app-slider-field label="BOIDS.FORCE_SCALE" [min]="10" [max]="200" [step]="5" [(value)]="forceScale"/>
</mat-slider>
</div>
<div class="flex items-center gap-2.5">
<span>{{ 'BOIDS.INTERACTION_RADIUS' | translate }}: {{ interactionRadius() }}</span>
<mat-slider min="2" max="20" step="0.5">
<input matSliderThumb [(ngModel)]="interactionRadius">
</mat-slider>
</div>
<div class="flex items-center gap-2.5">
<span>{{ 'BOIDS.FRICTION' | translate }}: {{ friction() }}</span>
<mat-slider min="0.5" max="0.99" step="0.01">
<input matSliderThumb [(ngModel)]="friction">
</mat-slider>
</div>
<div class="flex items-center gap-2.5">
<span>{{ 'BOIDS.FORCE_SCALE' | translate }}: {{ forceScale() }}</span>
<mat-slider min="10" max="200" step="5">
<input matSliderThumb [(ngModel)]="forceScale">
</mat-slider>
</div>
} }
</div> </div>
<app-metrics [metrics]="metrics()"/> <app-metrics [metrics]="metrics()"/>
@@ -1,7 +1,6 @@
import {Component, computed, OnDestroy, signal} from '@angular/core'; import {Component, computed, OnDestroy, signal} from '@angular/core';
import {FormsModule} from '@angular/forms';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card'; 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 {MatButtonToggleModule} from '@angular/material/button-toggle';
import {MatButton} from '@angular/material/button'; import {MatButton} from '@angular/material/button';
import {TranslatePipe} from '@ngx-translate/core'; import {TranslatePipe} from '@ngx-translate/core';
@@ -45,8 +44,7 @@ import {BoidsCpuStrategy} from './strategies/boids-cpu.strategy';
BabylonCanvas, BabylonCanvas,
MatButton, MatButton,
MatButtonToggleModule, MatButtonToggleModule,
MatSliderModule, SliderFieldComponent,
FormsModule,
Information, Information,
MetricsComponent MetricsComponent
], ],
@@ -16,12 +16,7 @@
{{ 'CLOTH.RESTART_SIMULATION' | translate }} {{ 'CLOTH.RESTART_SIMULATION' | translate }}
</button> </button>
</div> </div>
<div class="flex items-center gap-2.5"> <app-slider-field label="CLOTH.ELONGATION" [min]="0.5" [max]="2.0" [step]="0.1" [(value)]="elongation"/>
<span>{{ 'CLOTH.ELONGATION' | translate }}: {{ elongation() }}</span>
<mat-slider min="0.5" max="2.0" step="0.1">
<input matSliderThumb [(ngModel)]="elongation">
</mat-slider>
</div>
</div> </div>
<app-metrics [metrics]="metrics()"/> <app-metrics [metrics]="metrics()"/>
<app-babylon-canvas <app-babylon-canvas
@@ -1,7 +1,6 @@
import {Component, computed, signal} from '@angular/core'; import {Component, computed, signal} from '@angular/core';
import {FormsModule} from '@angular/forms';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card'; 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 {TranslatePipe} from '@ngx-translate/core'; import {TranslatePipe} from '@ngx-translate/core';
import {BabylonCanvas, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component'; import {BabylonCanvas, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component';
import {MatButton} from '@angular/material/button'; import {MatButton} from '@angular/material/button';
@@ -26,8 +25,7 @@ import {FpsCounter} from '../../../shared/fps-counter';
TranslatePipe, TranslatePipe,
BabylonCanvas, BabylonCanvas,
MatButton, MatButton,
MatSliderModule, SliderFieldComponent,
FormsModule,
Information, Information,
MetricsComponent MetricsComponent
], ],
@@ -26,18 +26,8 @@
</div> </div>
<div class="flex items-center gap-2.5 flex-wrap"> <div class="flex items-center gap-2.5 flex-wrap">
<span class="flex items-center gap-2.5"> <app-slider-field label="FOURIER.CIRCLES" [min]="1" [max]="maxCircles()" discrete [(value)]="circleCount"/>
{{ 'FOURIER.CIRCLES' | translate }}: {{ circleCount() }} <app-slider-field label="FOURIER.SPEED" [min]="MIN_SPEED" [max]="MAX_SPEED" [step]="0.02" [(value)]="speed"/>
<mat-slider [min]="1" [max]="maxCircles()" step="1" discrete>
<input matSliderThumb [(ngModel)]="circleCount">
</mat-slider>
</span>
<span class="flex items-center gap-2.5">
{{ 'FOURIER.SPEED' | translate }}: {{ speed() }}
<mat-slider [min]="MIN_SPEED" [max]="MAX_SPEED" step="0.02">
<input matSliderThumb [(ngModel)]="speed">
</mat-slider>
</span>
</div> </div>
<app-metrics [metrics]="metrics()"/> <app-metrics [metrics]="metrics()"/>
@@ -1,10 +1,9 @@
import {AfterViewInit, Component, computed, ElementRef, HostListener, inject, NgZone, OnDestroy, signal, PLATFORM_ID, ViewChild} from '@angular/core'; import {AfterViewInit, Component, computed, ElementRef, HostListener, inject, NgZone, OnDestroy, signal, PLATFORM_ID, ViewChild} from '@angular/core';
import {isPlatformBrowser} from '@angular/common'; import {isPlatformBrowser} from '@angular/common';
import {FormsModule} from '@angular/forms';
import {MatButton} from '@angular/material/button'; import {MatButton} from '@angular/material/button';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card'; import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
import {MatIcon} from '@angular/material/icon'; 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 {TranslatePipe} from '@ngx-translate/core';
import {Information} from '../information/information'; import {Information} from '../information/information';
import {AlgorithmInformation} from '../information/information.models'; import {AlgorithmInformation} from '../information/information.models';
@@ -29,8 +28,7 @@ import {
Information, Information,
MatButton, MatButton,
MatIcon, MatIcon,
MatSliderModule, SliderFieldComponent,
FormsModule,
MetricsComponent MetricsComponent
], ],
templateUrl: './fourier.component.html' templateUrl: './fourier.component.html'
@@ -37,6 +37,14 @@
<div class="grow h-[60px]"></div> <div class="grow h-[60px]"></div>
} }
<mat-icon>zoom_in</mat-icon> <mat-icon>zoom_in</mat-icon>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="zoom-field w-[130px] shrink-0">
<input #zoomField matInput type="number"
[attr.aria-label]="'METRICS.ZOOM' | translate"
[min]="minZoomValue" [max]="maxZoomValue" [step]="zoomStep"
[value]="zoomDisplay()"
(keydown.enter)="applyZoom(zoomField)"
(blur)="applyZoom(zoomField)">
</mat-form-field>
</div> </div>
</div> </div>
@@ -0,0 +1,5 @@
@use '@angular/material' as mat;
.zoom-field {
@include mat.form-field-density(-4);
}
@@ -2,7 +2,7 @@ import {afterNextRender, Component, computed, OnInit, signal} from '@angular/cor
import {Information} from '../information/information'; import {Information} from '../information/information';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card'; import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
import {TranslatePipe} from '@ngx-translate/core'; 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 {MatOption} from '@angular/material/core';
import {MatSelect} from '@angular/material/select'; import {MatSelect} from '@angular/material/select';
import {AlgorithmInformation} from '../information/information.models'; 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 {clampZoom, initialView, iterationsForZoom, zoomAroundPoint} from './fractal-view';
import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics'; import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics';
import {FpsCounter} from '../../../shared/fps-counter'; import {FpsCounter} from '../../../shared/fps-counter';
import {formatSliderValue, parseSliderInput} from '../../../shared/components/slider-field/slider-value';
@Component({ @Component({
selector: 'app-fractal', selector: 'app-fractal',
@@ -29,6 +30,7 @@ import {FpsCounter} from '../../../shared/fps-counter';
MatCardTitle, MatCardTitle,
TranslatePipe, TranslatePipe,
MatFormField, MatFormField,
MatInput,
MatLabel, MatLabel,
MatOption, MatOption,
MatSelect, MatSelect,
@@ -103,16 +105,18 @@ export class FractalComponent implements OnInit {
selectedAlgorithm = 0; selectedAlgorithm = 0;
selectedColorScheme = 0; selectedColorScheme = 0;
readonly zoomStep = 0.01;
sliderValue = signal(0); sliderValue = signal(0);
options: Options = { options: Options = {
floor: this.minZoomValue, floor: this.minZoomValue,
ceil: this.maxZoomValue, ceil: this.maxZoomValue,
logScale: true, logScale: true,
step: 0.01, step: this.zoomStep,
showTicks: false, showTicks: false,
hideLimitLabels: true, hideLimitLabels: true,
hidePointerLabels: true hidePointerLabels: true
}; };
protected readonly zoomDisplay = computed(() => formatSliderValue(this.sliderValue(), this.zoomStep, this.minZoomValue));
zoom = 0; zoom = 0;
offsetX = 0; offsetX = 0;
offsetY = 0; offsetY = 0;
@@ -262,4 +266,13 @@ export class FractalComponent implements OnInit {
this.zoom = clampZoom(newValue, this.minZoomValue, this.maxZoomValue); this.zoom = clampZoom(newValue, this.minZoomValue, this.maxZoomValue);
this.maxIterations.set(iterationsForZoom(this.zoom)); 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();
}
} }
@@ -16,12 +16,8 @@
</button> </button>
</div> </div>
@for (slider of sliders; track slider.key) { @for (slider of sliders; track slider.key) {
<div class="flex items-center gap-2.5"> <app-slider-field [label]="slider.label" [min]="limits[slider.key].min" [max]="limits[slider.key].max"
<span>{{ slider.label | translate }}: {{ slider.model() }}</span> [step]="limits[slider.key].step" [(value)]="slider.model"/>
<mat-slider [min]="limits[slider.key].min" [max]="limits[slider.key].max" [step]="limits[slider.key].step">
<input matSliderThumb [ngModel]="slider.model()" (ngModelChange)="slider.model.set($event)">
</mat-slider>
</div>
} }
<p class="text-[0.9em] opacity-80">{{ 'GRAY_SCOTT.PAINT_HINT' | translate }}</p> <p class="text-[0.9em] opacity-80">{{ 'GRAY_SCOTT.PAINT_HINT' | translate }}</p>
</div> </div>
@@ -1,7 +1,6 @@
import {Component, computed, OnDestroy, signal, WritableSignal} from '@angular/core'; import {Component, computed, OnDestroy, signal, WritableSignal} from '@angular/core';
import {FormsModule} from '@angular/forms';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card'; 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 {MatButtonToggleModule} from '@angular/material/button-toggle';
import {MatButton} from '@angular/material/button'; import {MatButton} from '@angular/material/button';
import {TranslatePipe} from '@ngx-translate/core'; import {TranslatePipe} from '@ngx-translate/core';
@@ -43,8 +42,7 @@ interface SettingSlider {
BabylonCanvas, BabylonCanvas,
MatButton, MatButton,
MatButtonToggleModule, MatButtonToggleModule,
MatSliderModule, SliderFieldComponent,
FormsModule,
Information, Information,
MetricsComponent MetricsComponent
], ],
@@ -19,12 +19,8 @@
</button> </button>
</div> </div>
@for (slider of sliders; track slider.key) { @for (slider of sliders; track slider.key) {
<div class="flex items-center gap-2.5"> <app-slider-field [label]="slider.label" [min]="limits[slider.key].min" [max]="limits[slider.key].max"
<span>{{ slider.label | translate }}: {{ slider.model() }}</span> [step]="limits[slider.key].step" [(value)]="slider.model"/>
<mat-slider [min]="limits[slider.key].min" [max]="limits[slider.key].max" [step]="limits[slider.key].step">
<input matSliderThumb [ngModel]="slider.model()" (ngModelChange)="slider.model.set($event)">
</mat-slider>
</div>
} }
<p class="text-[0.9em] opacity-80">{{ 'LENIA.PAINT_HINT' | translate }}</p> <p class="text-[0.9em] opacity-80">{{ 'LENIA.PAINT_HINT' | translate }}</p>
</div> </div>
@@ -1,7 +1,6 @@
import {Component, computed, OnDestroy, signal, WritableSignal} from '@angular/core'; import {Component, computed, OnDestroy, signal, WritableSignal} from '@angular/core';
import {FormsModule} from '@angular/forms';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card'; 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 {MatButtonToggleModule} from '@angular/material/button-toggle';
import {MatButton} from '@angular/material/button'; import {MatButton} from '@angular/material/button';
import {TranslatePipe} from '@ngx-translate/core'; import {TranslatePipe} from '@ngx-translate/core';
@@ -48,8 +47,7 @@ const MASS_READ_INTERVAL_MS = 1000;
BabylonCanvas, BabylonCanvas,
MatButton, MatButton,
MatButtonToggleModule, MatButtonToggleModule,
MatSliderModule, SliderFieldComponent,
FormsModule,
Information, Information,
MetricsComponent MetricsComponent
], ],
@@ -5,43 +5,18 @@
<mat-card-content> <mat-card-content>
<app-information [algorithmInformation]="algoInformation"/> <app-information [algorithmInformation]="algoInformation"/>
<div class="flex flex-col mb-4"> <div class="flex flex-col mb-4">
@if (sliderReady()) {
<div class="grid grid-cols-2 tablet:grid-cols-1 tablet:gap-4 mb-6"> <div class="grid grid-cols-2 tablet:grid-cols-1 tablet:gap-4 mb-6">
<div class="flex items-center gap-4 mr-4"> <app-slider-field class="mr-4" label="PENDULUM.TRAIL_DECAY_TIME" [min]="MIN_TRAIL_DECAY" [max]="MAX_TRAIL_DECAY" [step]="0.0001" [(value)]="trailDecay"/>
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.TRAIL_DECAY_TIME' | translate }}</p> <app-slider-field class="mr-4" label="PENDULUM.ATTRACTION" [min]="MIN_G" [max]="MAX_G" [step]="0.01" [(value)]="g"/>
<ngx-slider class="grow" [(value)]="trailDecay" [options]="trailDecayOptions" ></ngx-slider>
</div>
<div class="flex items-center gap-4 mr-4">
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.ATTRACTION' | translate }}</p>
<ngx-slider class="grow" [(value)]="g" [options]="gravityOptions" ></ngx-slider>
</div>
<div class="flex items-center gap-4 mr-4"> <app-slider-field class="mr-4" label="PENDULUM.L1_LENGTH" [min]="MIN_LENGTH" [max]="MAX_LENGTH" [step]="0.1" [(value)]="l1"/>
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.L1_LENGTH' | translate }}</p> <app-slider-field class="mr-4" label="PENDULUM.L2_LENGTH" [min]="MIN_LENGTH" [max]="MAX_LENGTH" [step]="0.1" [(value)]="l2"/>
<ngx-slider class="grow" [(value)]="l1" [options]="lengthOptions" ></ngx-slider>
</div>
<div class="flex items-center gap-4 mr-4">
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.L2_LENGTH' | translate }}</p>
<ngx-slider class="grow" [(value)]="l2" [options]="lengthOptions" ></ngx-slider>
</div>
<div class="flex items-center gap-4 mr-4"> <app-slider-field class="mr-4" label="PENDULUM.M1_MASS" [min]="MIN_MASS" [max]="MAX_MASS" [step]="0.1" [(value)]="m1"/>
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.M1_MASS' | translate }}</p> <app-slider-field class="mr-4" label="PENDULUM.M2_MASS" [min]="MIN_MASS" [max]="MAX_MASS" [step]="0.1" [(value)]="m2"/>
<ngx-slider class="grow" [(value)]="m1" [options]="massOptions" ></ngx-slider>
</div>
<div class="flex items-center gap-4 mr-4">
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.M2_MASS' | translate }}</p>
<ngx-slider class="grow" [(value)]="m2" [options]="massOptions" ></ngx-slider>
</div>
<div class="flex items-center gap-4 mr-4 col-span-full"> <app-slider-field class="mr-4 col-span-full" label="PENDULUM.DAMPING" [min]="MIN_DAMPING" [max]="MAX_DAMPING" [step]="0.001" [(value)]="damping"/>
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.DAMPING' | translate }}</p>
<ngx-slider class="grow" [(value)]="damping" [options]="dampingOptions" ></ngx-slider>
</div> </div>
</div>
} @else {
<div class="mb-6 h-[200px]"></div>
}
<div class="flex flex-wrap gap-3 mb-4"> <div class="flex flex-wrap gap-3 mb-4">
<button mat-raised-button color="primary" (click)="pushPendulum(true)"> <button mat-raised-button color="primary" (click)="pushPendulum(true)">
@@ -1,9 +1,8 @@
import {afterNextRender, Component, computed, signal} from '@angular/core'; import {Component, computed, signal} from '@angular/core';
import {BabylonCanvas, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component'; import {BabylonCanvas, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card'; import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
import {FormsModule} from '@angular/forms'; import {SliderFieldComponent} from '../../../shared/components/slider-field/slider-field';
import {NgxSliderModule, Options} from '@angular-slider/ngx-slider';
import {DEFAULT_DAMPING, DEFAULT_G, DEFAULT_L1_LENGTH, DEFAULT_M1_MASS, DEFAULT_L2_LENGTH, DEFAULT_M2_MASS, DEFAULT_TRAIL_DECAY, MAX_DAMPING, MAX_G, MAX_LENGTH, MAX_MASS, MAX_TRAIL_DECAY, MIN_DAMPING, MIN_G, MIN_LENGTH, MIN_MASS, MIN_TRAIL_DECAY, IMPULSE_M2, IMPULSE_M1} from './pendulum.model'; import {DEFAULT_DAMPING, DEFAULT_G, DEFAULT_L1_LENGTH, DEFAULT_M1_MASS, DEFAULT_L2_LENGTH, DEFAULT_M2_MASS, DEFAULT_TRAIL_DECAY, MAX_DAMPING, MAX_G, MAX_LENGTH, MAX_MASS, MAX_TRAIL_DECAY, MIN_DAMPING, MIN_G, MIN_LENGTH, MIN_MASS, MIN_TRAIL_DECAY, IMPULSE_M2, IMPULSE_M1} from './pendulum.model';
import {TranslatePipe} from '@ngx-translate/core'; import {TranslatePipe} from '@ngx-translate/core';
import {MatButton} from '@angular/material/button'; import {MatButton} from '@angular/material/button';
@@ -25,8 +24,7 @@ import {FpsCounter} from '../../../shared/fps-counter';
MatCardContent, MatCardContent,
MatCardHeader, MatCardHeader,
MatCardTitle, MatCardTitle,
FormsModule, SliderFieldComponent,
NgxSliderModule,
TranslatePipe, TranslatePipe,
MatButton, MatButton,
Information, Information,
@@ -36,13 +34,16 @@ import {FpsCounter} from '../../../shared/fps-counter';
styleUrl: './pendulum.component.scss', styleUrl: './pendulum.component.scss',
}) })
class PendulumComponent { class PendulumComponent {
protected readonly MIN_TRAIL_DECAY = MIN_TRAIL_DECAY;
// ngx-slider throws on the server (no layout APIs), so render it in the browser only protected readonly MAX_TRAIL_DECAY = MAX_TRAIL_DECAY;
readonly sliderReady = signal(false); protected readonly MIN_G = MIN_G;
protected readonly MAX_G = MAX_G;
constructor() { protected readonly MIN_LENGTH = MIN_LENGTH;
afterNextRender(() => this.sliderReady.set(true)); protected readonly MAX_LENGTH = MAX_LENGTH;
} protected readonly MIN_MASS = MIN_MASS;
protected readonly MAX_MASS = MAX_MASS;
protected readonly MIN_DAMPING = MIN_DAMPING;
protected readonly MAX_DAMPING = MAX_DAMPING;
algoInformation: AlgorithmInformation = { algoInformation: AlgorithmInformation = {
title: 'PENDULUM.EXPLANATION.TITLE', title: 'PENDULUM.EXPLANATION.TITLE',
@@ -65,56 +66,6 @@ class PendulumComponent {
initialViewSize: 2 initialViewSize: 2
}; };
trailDecayOptions: Options = {
floor: MIN_TRAIL_DECAY,
ceil: MAX_TRAIL_DECAY,
logScale: false,
step: 0.001,
showTicks: false,
hideLimitLabels: false,
hidePointerLabels: false
};
gravityOptions: Options = {
floor: MIN_G,
ceil: MAX_G,
logScale: false,
step: 0.01,
showTicks: false,
hideLimitLabels: false,
hidePointerLabels: false
};
dampingOptions: Options = {
floor: MAX_DAMPING,
ceil: MIN_DAMPING,
logScale: false,
step: 0.001,
showTicks: false,
hideLimitLabels: false,
hidePointerLabels: false
};
lengthOptions: Options = {
floor: MIN_LENGTH,
ceil: MAX_LENGTH,
logScale: false,
step: 0.1,
showTicks: false,
hideLimitLabels: false,
hidePointerLabels: false
};
massOptions: Options = {
floor: MIN_MASS,
ceil: MAX_MASS,
logScale: false,
step: 0.1,
showTicks: false,
hideLimitLabels: false,
hidePointerLabels: false
};
readonly trailDecay = signal(DEFAULT_TRAIL_DECAY); readonly trailDecay = signal(DEFAULT_TRAIL_DECAY);
readonly g = signal(DEFAULT_G); readonly g = signal(DEFAULT_G);
readonly l1 = signal(DEFAULT_L1_LENGTH); readonly l1 = signal(DEFAULT_L1_LENGTH);
@@ -3,8 +3,8 @@ export const MIN_G = 2;
export const MAX_G = 15; export const MAX_G = 15;
export const DEFAULT_DAMPING = 0.999; export const DEFAULT_DAMPING = 0.999;
export const MIN_DAMPING = 1; export const MIN_DAMPING = 0.7;
export const MAX_DAMPING = 0.7; export const MAX_DAMPING = 1;
export const DEFAULT_TRAIL_DECAY = 0.96; export const DEFAULT_TRAIL_DECAY = 0.96;
export const MIN_TRAIL_DECAY = 0.2; export const MIN_TRAIL_DECAY = 0.2;
@@ -15,19 +15,10 @@
{{ 'PHYSARUM.RESET' | translate }} {{ 'PHYSARUM.RESET' | translate }}
</button> </button>
</div> </div>
<div class="flex items-center gap-2.5"> <app-slider-field label="PHYSARUM.AGENTS" [min]="minAgents" [max]="maxAgents()" [step]="agentStep()" [(value)]="agentCount" (committed)="reset()"/>
<span>{{ 'PHYSARUM.AGENTS' | translate }}: {{ agentCount() }}</span>
<mat-slider [min]="minAgents" [max]="maxAgents()" [step]="agentStep()">
<input matSliderThumb [(ngModel)]="agentCount" (valueChange)="reset()">
</mat-slider>
</div>
@for (slider of sliders; track slider.key) { @for (slider of sliders; track slider.key) {
<div class="flex items-center gap-2.5"> <app-slider-field [label]="slider.label" [min]="limits[slider.key].min" [max]="limits[slider.key].max"
<span>{{ slider.label | translate }}: {{ slider.model() }}</span> [step]="limits[slider.key].step" [(value)]="slider.model"/>
<mat-slider [min]="limits[slider.key].min" [max]="limits[slider.key].max" [step]="limits[slider.key].step">
<input matSliderThumb [ngModel]="slider.model()" (ngModelChange)="slider.model.set($event)">
</mat-slider>
</div>
} }
<p class="text-[0.9em] opacity-80">{{ 'PHYSARUM.FOOD_HINT' | translate }}</p> <p class="text-[0.9em] opacity-80">{{ 'PHYSARUM.FOOD_HINT' | translate }}</p>
</div> </div>
@@ -1,7 +1,6 @@
import {Component, computed, OnDestroy, signal, WritableSignal} from '@angular/core'; import {Component, computed, OnDestroy, signal, WritableSignal} from '@angular/core';
import {FormsModule} from '@angular/forms';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card'; 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 {MatButtonToggleModule} from '@angular/material/button-toggle';
import {MatButton} from '@angular/material/button'; import {MatButton} from '@angular/material/button';
import {TranslatePipe} from '@ngx-translate/core'; import {TranslatePipe} from '@ngx-translate/core';
@@ -55,8 +54,7 @@ interface SettingSlider {
BabylonCanvas, BabylonCanvas,
MatButton, MatButton,
MatButtonToggleModule, MatButtonToggleModule,
MatSliderModule, SliderFieldComponent,
FormsModule,
Information, Information,
MetricsComponent MetricsComponent
], ],
@@ -0,0 +1,18 @@
<div class="flex items-center gap-2.5 flex-wrap">
<span>{{ label() | translate }}</span>
<mat-slider [min]="min()" [max]="max()" [step]="step()" [discrete]="discrete()">
<input matSliderThumb
[attr.aria-label]="label() | translate"
[ngModel]="value()"
(ngModelChange)="value.set($event)"
(valueChange)="onSliderRelease($event)">
</mat-slider>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="w-[110px]">
<input #field matInput type="number"
[attr.aria-label]="label() | translate"
[min]="min()" [max]="max()" [step]="step()"
[value]="display()"
(keydown.enter)="applyTyped(field)"
(blur)="applyTyped(field)">
</mat-form-field>
</div>
@@ -0,0 +1,5 @@
@use '@angular/material' as mat;
:host {
@include mat.form-field-density(-4);
}
@@ -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: `<app-slider-field label="TEST.LABEL" [min]="0" [max]="10" [step]="2" [(value)]="model" (committed)="commits = commits + 1"/>`
})
class HostComponent {
readonly model = signal(4);
commits = 0;
}
describe('SliderFieldComponent', () => {
let fixture: ComponentFixture<HostComponent>;
let host: HostComponent;
const field = () => fixture.nativeElement.querySelector('input[type=number]') as HTMLInputElement;
async function type(text: string, event: 'enter' | 'blur' = 'enter'): Promise<void> {
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);
});
});
@@ -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<number>();
/** Fires once per slider release and once per typed value that changes the value. */
readonly committed = output<number>();
/** i18n key */
readonly label = input.required<string>();
readonly min = input.required<number>();
readonly max = input.required<number>();
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();
}
}
@@ -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);
});
});
@@ -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))));
}