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:
1 parent
008f1254f8
commit
3018c3f1b5
26 files changed
+335
-248
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">
|
<app-slider-field class="mr-4" label="PENDULUM.TRAIL_DECAY_TIME" [min]="MIN_TRAIL_DECAY" [max]="MAX_TRAIL_DECAY" [step]="0.0001" [(value)]="trailDecay"/>
|
||||||
<div class="flex items-center gap-4 mr-4">
|
<app-slider-field class="mr-4" label="PENDULUM.ATTRACTION" [min]="MIN_G" [max]="MAX_G" [step]="0.01" [(value)]="g"/>
|
||||||
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.TRAIL_DECAY_TIME' | translate }}</p>
|
|
||||||
<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>
|
</div>
|
||||||
<ngx-slider class="grow" [(value)]="damping" [options]="dampingOptions" ></ngx-slider>
|
|
||||||
</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))));
|
||||||
|
}
|
||||||
Reference in new issue
Block a user