JS CSS HTML
const SPACE_BETWEEN_SLIDER = 100;
const MAX_SPEED = 20;
const SLOWDOWN_RANGE = 200;
const MAX_CHANGE_SPEED = 1.2;
const FRAME_DURATION = 1000 / 60;
const displayVariables = (data: { status: string; title: string }[]): void => {
const variablesElement = document.querySelector(".timeline3d__variables");
if (!variablesElement) return;
variablesElement.innerHTML = data
.map(
({ status, title }) => `
<div class="timeline3d__variable">
<span>${title}</span>
<span>${status}</span>
</div>`,
)
.join("");
};
export const runScript = (): void => {
const container = document.getElementById("3dTimeline") as HTMLElement | null;
if (!container) return;
const raw = container.dataset.slider3d;
const sliderData = raw ? JSON.parse(raw) : null;
if (!sliderData) return;
container.innerHTML = `
<div class="timeline3d">
<div class="timeline3d__category"></div>
<div class="timeline3d__slider"></div>
<div class="scrollbar" id="timeline3d-scrollbar">
<div class="scrollbar__thumb" id="timeline3d-scrollbar-thumb"></div>
</div>
<div class="timeline3d__variables">
</div>
</div>
`;
const track = container.querySelector<HTMLDivElement>(
"#timeline3d-scrollbar",
)!;
const thumb = container.querySelector<HTMLDivElement>(
"#timeline3d-scrollbar-thumb",
)!;
const root = container.querySelector<HTMLDivElement>(".timeline3d")!;
const clamp = (val: number, min: number, max: number) =>
Math.min(Math.max(val, min), max);
let thumbX = 0;
let startX = 0;
let startOffset = 0;
let activeId: number | null = null;
let targetStatus = 0;
const sliderWidth = sliderData.length * SPACE_BETWEEN_SLIDER;
let sliderScrollStatus = 0;
let status = 0;
let speed = 0;
let isAnimated = false;
let lastFrameTime = 0;
const startAnimated = () => {
if (!isAnimated) {
isAnimated = true;
lastFrameTime = 0;
requestAnimationFrame(animate);
}
};
const render = (): void => {
const maxOffset = track.clientWidth - thumb.clientWidth;
sliderScrollStatus = maxOffset ? (thumbX / maxOffset) * 100 : 0;
targetStatus = (sliderWidth / 100) * sliderScrollStatus;
const distance = targetStatus - status;
const absDistance = Math.abs(distance);
let desiredSpeed = 0;
if (absDistance > 0) {
desiredSpeed = Math.sign(distance) * MAX_SPEED;
if (absDistance < SLOWDOWN_RANGE) {
desiredSpeed *= absDistance / SLOWDOWN_RANGE;
}
}
const deltaSpeed = clamp(
desiredSpeed - speed,
-MAX_CHANGE_SPEED,
MAX_CHANGE_SPEED,
);
speed += deltaSpeed;
status = clamp(status + speed, 0, sliderWidth);
sliderScrollStatus = (status / sliderWidth) * 100;
root.style.setProperty(
"--thumb-x",
`${(sliderScrollStatus / 100) * maxOffset}px`,
);
if (absDistance < 0.5 && Math.abs(speed) < 0.05) {
status = targetStatus;
speed = 0;
}
displayVariables([
{ title: "targetStatus", status: `${targetStatus.toFixed(2)}px` },
{ title: "realStatus", status: `${status.toFixed(2)}px` },
{ title: "speed", status: `${speed.toFixed(2)}px` },
{
title: "sliderScrollStatus",
status: `${sliderScrollStatus.toFixed(2)}%`,
},
]);
};
const animate = (time: number) => {
if (time - lastFrameTime >= FRAME_DURATION) {
lastFrameTime = time;
render();
}
if (Math.abs(targetStatus - status) > 0.01 || Math.abs(speed) > 0.01) {
requestAnimationFrame(animate);
} else {
speed = 0;
isAnimated = false;
}
};
const onPointerMove = (e: PointerEvent) => {
const dx = e.clientX - startX;
const maxOffset = track.clientWidth - thumb.clientWidth;
thumbX = clamp(startOffset + dx, 0, maxOffset);
startAnimated();
};
const onPointerUp = () => {
document.removeEventListener("pointermove", onPointerMove);
document.removeEventListener("pointerup", onPointerUp);
if (activeId !== null) {
thumb.releasePointerCapture(activeId);
activeId = null;
}
};
thumb.addEventListener("pointerdown", (e: PointerEvent) => {
activeId = e.pointerId;
thumb.setPointerCapture(activeId);
startX = e.clientX;
startOffset = thumbX;
document.addEventListener("pointermove", onPointerMove);
document.addEventListener("pointerup", onPointerUp);
});
startAnimated();
};
Теперь нужно добавить анимацию, поменял статус на пиксели, а слайдер скролл на проценты, так же добавил таргетный статус, теперь мы меняем только таргетный статус и запускаем анимацию, а реальный статус на основе которого идет прорисовка, пытаеться догнать таргетный, чем ближе таргетный и реальный статус тем медленнее скорость, скорость так же уменьшаеться и увеличиваеться постепенно, так же ограничили количество фпс 60 кадрами в секунду.