This commit is contained in:
Andras Schmelczer 2026-05-28 21:24:47 +01:00
parent 3ad2766f82
commit f74ee43cb4
196 changed files with 18949 additions and 32173 deletions

View file

@ -1,107 +1,233 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { range } from '../../../utils/range';
import { Range } from '../../../interfaces/range';
import {
Component,
ChangeDetectionStrategy,
input,
output,
signal,
computed,
effect,
untracked,
} from '@angular/core';
export interface DoubleSliderRange<T> {
from: T;
to: T;
}
/**
* Two-thumb range slider legacy "double-slider".
* Hands an indexed range over an arbitrary values array; emits the
* underlying values on each change. Labels magnetically lift as a thumb
* approaches them (rotated -45°), per the legacy.
*/
@Component({
selector: 'app-double-slider',
templateUrl: './double-slider.component.html',
styleUrls: ['./double-slider.component.scss']
selector: 'lt-double-slider',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="container">
<label for="ds-1">From</label>
<label for="ds-2">To</label>
<input
id="ds-1"
type="range"
min="0"
[max]="MAX - 1"
[value]="oneValue()"
(input)="oneValue.set(+$any($event.target).value)"
/>
<input
id="ds-2"
type="range"
min="0"
[max]="MAX - 1"
[value]="otherValue()"
(input)="otherValue.set(+$any($event.target).value)"
/>
<div class="value-container">
@for (i of drawnIndices(); track i) {
<span [style.transform]="getOffset(i)">{{ drawnLabels()[i] }}</span>
}
</div>
</div>
`,
styles: `
@import '../../../../library/main';
$line-height: 2px;
$height: 90px;
$slider-size: 40px;
.container {
width: 100%;
max-width: 800px;
height: $height;
position: relative;
margin: calc(#{$slider-size} / 2) auto 0 auto;
label { display: none; }
input[type='range'] {
width: 100%;
position: absolute;
left: 0;
-webkit-appearance: none;
appearance: none;
outline: none;
background: transparent;
&::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
height: $slider-size;
width: $slider-size;
border-radius: 1000px;
background-color: $light-color;
box-shadow: $shadow-border;
transform-origin: center center;
transform: translateY(calc(-1 * #{$slider-size} / 2 + #{$line-height} / 2));
transition: box-shadow $long-animation-time, transform $long-animation-time;
@media (min-width: $mobile-width) {
&:hover {
box-shadow: $shadow;
transform: translateY(calc(-1 * #{$slider-size} / 2 + #{$line-height} / 2))
scale(1.1);
}
}
cursor: pointer;
position: relative;
z-index: 2;
}
&::-moz-range-thumb {
-moz-appearance: none;
appearance: none;
height: $slider-size;
width: $slider-size;
border-radius: 1000px;
background-color: $light-color;
border: none;
box-shadow: $shadow-border;
cursor: pointer;
}
&::-webkit-slider-runnable-track {
-webkit-appearance: none;
width: 100%;
height: $line-height;
background-color: $text-color;
border-radius: 1000px;
}
&::-moz-range-track {
-moz-appearance: none;
width: 100%;
height: $line-height;
background-color: $text-color;
border-radius: 1000px;
}
&::-moz-focus-outer { border: 0; }
}
.value-container {
font-family: $normal-font;
color: $text-color;
font-size: var(--medium-font-size);
display: flex;
justify-content: space-evenly;
margin-top: calc(#{$slider-size} + 8px);
span {
display: block;
margin-top: 14px;
transform-origin: center bottom;
transition: transform $long-animation-time;
white-space: nowrap;
}
}
}
`,
})
export class DoubleSliderComponent {
@Input() labels: string[];
/** Ordered list of underlying values (e.g. dates). */
readonly values = input.required<unknown[]>();
/** Display labels for evenly-spaced ticks (≤ values.length). */
readonly labels = input.required<string[]>();
@Input() set values(values: any[]) {
if (values.length === 0) {
return;
}
this._values = values;
this.calculateLabels();
if (this._oneValue > this._otherValue) {
this._oneValue = this.MAX - 1;
} else {
this._otherValue = this.MAX - 1;
}
this.emitValue();
}
get values(): any[] {
return this._values;
}
get oneValue(): number {
return this._oneValue;
}
set oneValue(value: number) {
this._oneValue = value;
this.emitValue();
}
get otherValue(): number {
return this._otherValue;
}
set otherValue(value: number) {
this._otherValue = value;
this.emitValue();
}
private _values: any[];
@Output() range: EventEmitter<Range<any>> = new EventEmitter();
drawnLabels: string[];
readonly rangeChange = output<DoubleSliderRange<unknown>>();
readonly MAX = 100;
private _oneValue = 0;
readonly oneValue = signal(0);
readonly otherValue = signal(this.MAX - 1);
private _otherValue: number = this.MAX - 1;
private prevValuesLength = 0;
drawnLabelsIndices: Iterable<number>;
readonly drawnLabels = computed(() => {
const labels = this.labels();
const count = Math.min(labels.length, 6);
if (count === 0) return [] as string[];
const jump = Math.max(1, Math.round(labels.length / count));
return labels.filter((_, i) => i % jump === 0);
});
private calculateLabels() {
const labelCount = 6;
const jumpLength = Math.round(this.labels.length / labelCount);
this.drawnLabels = this.labels.filter((_, index) => index % jumpLength === 0);
this.drawnLabelsIndices = range({ max: this.drawnLabels.length });
readonly drawnIndices = computed(() =>
Array.from({ length: this.drawnLabels().length }, (_, i) => i),
);
constructor() {
// Re-emit the value range whenever the slider thumbs or values change.
effect(() => {
const a = this.oneValue();
const b = this.otherValue();
const vs = this.values();
if (vs.length === 0) return;
const lo = Math.min(a, b);
const hi = Math.max(a, b);
untracked(() => {
this.rangeChange.emit({
from: vs[this.indexFromValue(lo)],
to: vs[this.indexFromValue(hi)],
});
});
});
// Snap the higher thumb to MAX - 1 when a new entry is appended.
effect(() => {
const len = this.values().length;
untracked(() => {
if (len > this.prevValuesLength) {
const a = this.oneValue();
const b = this.otherValue();
if (a > b) this.oneValue.set(this.MAX - 1);
else this.otherValue.set(this.MAX - 1);
}
this.prevValuesLength = len;
});
});
}
private indexFromValue(value: number): number {
return Math.floor((value / this.MAX) * this.values.length);
return Math.min(
this.values().length - 1,
Math.floor((value / this.MAX) * this.values().length),
);
}
/**
* Magnetic label position: returns a CSS `transform` that lifts the label
* upward and rotates -45° as a thumb approaches.
*/
getOffset(index: number): string {
const labelIndex = index / this.drawnLabels.length;
const slider1Index = this.oneValue / this.MAX - 0.1;
const slider2Index = this.otherValue / this.MAX - 0.1;
const dist = (a, b) => Math.abs(a - b);
const labelSliderDistance = Math.min(dist(labelIndex, slider1Index), dist(labelIndex, slider2Index));
const labelIndex = index / Math.max(1, this.drawnLabels().length);
const a = this.oneValue() / this.MAX - 0.1;
const b = this.otherValue() / this.MAX - 0.1;
const dist = Math.min(Math.abs(labelIndex - a), Math.abs(labelIndex - b));
const ACTIVE_ZONE = 0.2;
const BASE_TRANSFORM = 'translateX(-50%) rotate(-45deg) translateY(100%)';
if (labelSliderDistance > ACTIVE_ZONE) {
return BASE_TRANSFORM;
}
return `translateY(${Math.pow((ACTIVE_ZONE - labelSliderDistance) / ACTIVE_ZONE, 1) * 30}px) ${BASE_TRANSFORM}`;
}
private emitValue() {
this.range.emit({
from:
this.oneValue < this.otherValue
? this.values[this.indexFromValue(this.oneValue)]
: this.values[this.indexFromValue(this.otherValue)],
to:
this.oneValue < this.otherValue
? this.values[this.indexFromValue(this.otherValue)]
: this.values[this.indexFromValue(this.oneValue)]
});
const base = 'translateX(-50%) rotate(-30deg) translateY(100%)';
if (dist > ACTIVE_ZONE) return base;
const lift = ((ACTIVE_ZONE - dist) / ACTIVE_ZONE) * 36;
return `translateY(${lift}px) ${base}`;
}
}