///
import {
type Cell,
cell,
Default,
derive,
handler,
lift,
recipe,
str,
toSchema,
} from "commontools";
interface RangeSliderArgs {
min: Default;
max: Default;
value: Default;
step: Default;
}
interface SliderSnapshot {
interaction: number;
value: number;
percentage: number;
}
const toFiniteNumber = (input: unknown, fallback: number): number => {
if (typeof input !== "number" || !Number.isFinite(input)) {
return fallback;
}
return input;
};
const clampNumber = (value: number, minValue: number, maxValue: number) => {
if (value < minValue) return minValue;
if (value > maxValue) return maxValue;
return value;
};
const normalizePercentage = (value: number): number => {
if (!Number.isFinite(value)) return 0;
if (value > 1) return clampNumber(value / 100, 0, 1);
return clampNumber(value, 0, 1);
};
const computePercentage = (
value: number,
minValue: number,
maxValue: number,
) => {
const span = maxValue - minValue;
if (!Number.isFinite(span) || span <= 0) return 0;
const ratio = clampNumber((value - minValue) / span, 0, 1);
const percent = ratio * 100;
return Math.round(percent * 10) / 10;
};
const sanitizeSnapshots = (entries: SliderSnapshot[] | undefined) => {
if (!Array.isArray(entries)) return [] as SliderSnapshot[];
return entries.map((entry) => ({
interaction: toFiniteNumber(entry?.interaction, 0),
value: toFiniteNumber(entry?.value, 0),
percentage: toFiniteNumber(entry?.percentage, 0),
}));
};
const applySliderUpdate = (
context: {
value: Cell;
min: Cell;
max: Cell;
interactions: Cell;
history: Cell;
},
desired: number,
) => {
const rawMin = context.min.get();
const rawMax = context.max.get();
const minValue = toFiniteNumber(rawMin, 0);
const maxCandidate = toFiniteNumber(rawMax, minValue + 100);
const maxValue = maxCandidate > minValue ? maxCandidate : minValue + 100;
const next = clampNumber(
toFiniteNumber(desired, minValue),
minValue,
maxValue,
);
context.value.set(next);
const counter = toFiniteNumber(context.interactions.get(), 0) + 1;
context.interactions.set(counter);
const percentage = computePercentage(next, minValue, maxValue);
const snapshot: SliderSnapshot = {
interaction: counter,
value: next,
percentage,
};
const existing = context.history.get();
const list = Array.isArray(existing) ? existing.slice() : [];
list.push(snapshot);
context.history.set(list);
};
const setSliderValue = handler(
(
event: { value?: number; percentage?: number } | undefined,
context: {
value: Cell;
min: Cell;
max: Cell;
interactions: Cell;
history: Cell;
},
) => {
const rawMin = context.min.get();
const rawMax = context.max.get();
const minValue = toFiniteNumber(rawMin, 0);
const maxCandidate = toFiniteNumber(rawMax, minValue + 100);
const maxValue = maxCandidate > minValue ? maxCandidate : minValue + 100;
if (typeof event?.value === "number") {
applySliderUpdate(context, event.value);
return;
}
if (typeof event?.percentage === "number") {
const ratio = normalizePercentage(event.percentage);
const desired = minValue + (maxValue - minValue) * ratio;
applySliderUpdate(context, desired);
return;
}
const current = toFiniteNumber(context.value.get(), minValue);
applySliderUpdate(context, current);
},
);
const nudgeSlider = handler(
(
event: { direction?: "increase" | "decrease"; ticks?: number } | undefined,
context: {
value: Cell;
min: Cell;
max: Cell;
step: Cell;
interactions: Cell;
history: Cell;
},
) => {
const direction = event?.direction === "decrease" ? -1 : 1;
const ticks = typeof event?.ticks === "number"
? Math.max(1, Math.abs(Math.trunc(event.ticks)))
: 1;
const rawStep = context.step.get();
const baseStep = toFiniteNumber(rawStep, 1);
const stepSize = baseStep > 0 ? baseStep : 1;
const current = toFiniteNumber(context.value.get(), 0);
const desired = current + stepSize * ticks * direction;
applySliderUpdate(context, desired);
},
);
export const counterRangeSliderSimulation = recipe(
"Counter Range Slider Simulation",
({ min, max, value, step }) => {
const interactions = cell(0);
const history = cell([]);
const sliderState = lift(
toSchema<
{ min: Cell; max: Cell; value: Cell }
>(),
toSchema<
{ min: number; max: number; span: number; value: number }
>(),
({ min, max, value }) => {
const rawMin = min.get();
const rawMax = max.get();
const minValue = toFiniteNumber(rawMin, 0);
const maxCandidate = toFiniteNumber(rawMax, minValue + 100);
const maxValue = maxCandidate > minValue
? maxCandidate
: minValue + 100;
const current = clampNumber(
toFiniteNumber(value.get(), minValue),
minValue,
maxValue,
);
const span = maxValue - minValue;
return {
min: minValue,
max: maxValue,
span: span > 0 ? span : 1,
value: current,
};
},
)({ min, max, value });
const currentValue = derive(sliderState, (state) => state.value);
const minView = derive(sliderState, (state) => state.min);
const maxView = derive(sliderState, (state) => state.max);
const percentage = derive(
sliderState,
(state) => computePercentage(state.value, state.min, state.max),
);
const stepSize = lift((raw: number | undefined) => {
const normalized = toFiniteNumber(raw, 1);
return normalized > 0 ? normalized : 1;
})(step);
const interactionCount = lift((count: number | undefined) =>
toFiniteNumber(count, 0)
)(interactions);
const historyView = lift(sanitizeSnapshots)(history);
const rangeSummary = str`Range ${minView} to ${maxView}`;
const label = str`Slider at ${currentValue} (${percentage}%)`;
return {
min,
max,
step: stepSize,
currentValue,
percentage,
label,
rangeSummary,
interactions: interactionCount,
history: historyView,
controls: {
setPosition: setSliderValue({
value,
min,
max,
interactions,
history,
}),
nudge: nudgeSlider({
value,
min,
max,
step,
interactions,
history,
}),
},
};
},
);