type SlideMeta = { status: string; title: string };
type TimelineMeta = { position: number; title: string };
interface TimelineSlide {
position: number;
element?: HTMLElement;
[key: string]: any;
}
type ProgressData = { progress: number; position: number };
type SliderPayload = {
cards: TimelineSlide[];
timeline: TimelineMeta[];
};
const SPACE_BETWEEN_SLIDER = 100;
const SPACE_SLIDER_DURATION = 1000;
const MAX_SPEED = 20;
const SLOWDOWN_RANGE = 200;
const MAX_CHANGE_SPEED = 1.2;
const FRAME_DURATION = 1000 / 60;
const SLIDE_GAP = 1;
export interface MapNode {
real: number;
visual: number;
}
export function buildProgressMap(markers: TimelineMeta[]): MapNode[] {
if (markers.length < 2) throw new Error("Need at least two year markers");
const sorted = [...markers].sort((a, b) => a.position - b.position);
const lastPos = sorted.at(-1)!.position;
const segments = sorted.length - 1;
return sorted.map(
(m, idx): MapNode => ({
real: m.position / lastPos,
visual: idx / segments,
}),
);
}
function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
export function realToVisual(real: number, map: MapNode[]): number {
if (real <= 0) return 0;
if (real >= 1) return 1;
const i = map.findIndex((n, idx) => real < map[idx + 1].real);
if (i === -1 || i === map.length - 1) return 1;
const a = map[i];
const b = map[i + 1];
const t = (real - a.real) / (b.real - a.real);
return lerp(a.visual, b.visual, t);
}
export function visualToReal(visual: number, map: MapNode[]): number {
if (visual <= 0) return 0;
if (visual >= 1) return 1;
const i = map.findIndex((n, idx) => visual < map[idx + 1].visual);
if (i === -1 || i === map.length - 1) return 1;
const a = map[i];
const b = map[i + 1];
const t = (visual - a.visual) / (b.visual - a.visual);
return lerp(a.real, b.real, t);
}
const displayVariables = (data: SlideMeta[], slides: SlideMeta[]): void => {
const variablesElement = document.querySelector("#params");
const slidesElement = document.querySelector("#slides");
if (!variablesElement || !slidesElement) return;
variablesElement.innerHTML = data
.map(
({ status, title }) => `
<div class="timeline3d__variable">
<span>${title}</span>
<span>${status}</span>
</div>`,
)
.join("");
if (slides) {
slidesElement.innerHTML = slides
.map(
({ status, title }) => `
<div class="timeline3d__variable-small">
<span>${title}</span>
<span>${status}</span>
</div>`,
)
.join("");
}
};
const renderSliders = (data: TimelineSlide[]): string =>
data
.map(
(slide) => `
<div class="slide" data-position="${slide.position}" style="${slide.card ? `--offset-position: ${slide.card.offset}` : ``}" >
${slide.card ? `<div class="slide__card"><div class="slide__card-inner"></div></div>` : ``}
${slide.title ? `<div class="slide__card slide__card-title"><div class="slide__card-inner">${slide.title}</div></div>` : ``}
</div>
`,
)
.join("");
export const renderTimeline = (data: TimelineMeta[]): string => {
return data
.map((timeline, blockIndex) => {
const isLastBlock = blockIndex === data.length - 1;
const linesCount = isLastBlock ? 1 : 10;
const linesMarkup = Array.from({ length: linesCount }, (_, lineIndex) => {
if (lineIndex === 0) {
return `
<div class="scrollbar__line scrollbar__line_with-title">
<div class="scrollbar__title">${timeline.title}</div>
</div>
`;
}
return '<div class="scrollbar__line"></div>';
}).join("");
return `
<div class="scrollbar__block">
${linesMarkup}
</div>
`;
})
.join("");
};
export const runScript = (): void => {
const container = document.getElementById("3dTimeline") as HTMLElement | null;
if (!container) return;
const raw = container.dataset.slider3d;
const data: SliderPayload = raw
? ((JSON.parse(raw) as SliderPayload) ?? null)
: null;
if (!data) return;
const sliderData = data.cards;
const sliderTimeline = data.timeline;
sliderData.forEach((card) => {
card.position += SLIDE_GAP;
});
const slidesPerView = Math.round(
SPACE_SLIDER_DURATION / SPACE_BETWEEN_SLIDER,
);
const maxPosition =
Math.max(...sliderData.map((item) => item.position)) + slidesPerView;
const byPos = new Map<number, TimelineSlide>(
sliderData.map((card) => [card.position, card]),
);
const slides: TimelineSlide[] = Array.from(
{ length: maxPosition + 1 },
(_, pos) => byPos.get(pos) ?? { position: pos },
);
container.innerHTML = `
<div class="timeline3d">
<div class="timeline3d__category"></div>
<div class="timeline3d__slider">
${renderSliders(slides)}
</div>
<div class="timeline3d__scroll-wrapper">
<div class="scrollbar" id="timeline3d-scrollbar">
<div class="scrollbar__thumb" id="timeline3d-scrollbar-thumb"></div>
<div class="scrollbar__inner" id="scrollbar-inner">${renderTimeline(sliderTimeline)}</div>
</div>
</div>
<div class="timeline3d__variables" id="params"></div>
<div class="timeline3d__variables" id="slides"></div>
</div>
`;
slides.forEach((slide) => {
slide.element =
container.querySelector<HTMLElement>(
`.slide[data-position="${slide.position}"]`,
) ?? undefined;
});
const track = container.querySelector<HTMLDivElement>(
"#timeline3d-scrollbar",
)!;
const thumb = container.querySelector<HTMLDivElement>(
"#timeline3d-scrollbar-thumb",
)!;
const root = container.querySelector<HTMLDivElement>(".timeline3d")!;
const sliderEl = root.querySelector<HTMLDivElement>(".timeline3d__slider")!;
const updateSliderSize = (): void => {
const width = sliderEl.offsetWidth;
const height = sliderEl.clientHeight;
const scrollbarWidth = track.offsetWidth;
const scrollbarHeight = track.clientHeight;
root.style.setProperty("--slider-width", `${width}`);
root.style.setProperty("--slider-height", `${height}`);
root.style.setProperty("--scrollbar-width", `${scrollbarWidth}`);
root.style.setProperty("--scrollbar-height", `${scrollbarHeight}`);
};
setTimeout(() => {
updateSliderSize();
}, 1);
window.addEventListener("resize", updateSliderSize);
const clamp = (val: number, min: number, max: number): 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 =
(maxPosition - slidesPerView - SLIDE_GAP) * SPACE_BETWEEN_SLIDER - 1;
let sliderScrollStatus = 0;
let status = 0;
let speed = 0;
let isAnimated = false;
let lastFrameTime = 0;
let activePosition = 0;
let endPosition = 0;
let visibleSlides: ProgressData[] = [];
let firstPositionCard: TimelineSlide | undefined;
let currentVisible = new Set<number>();
const progressMap = buildProgressMap(sliderTimeline);
const startAnimated = (): void => {
if (!isAnimated) {
isAnimated = true;
lastFrameTime = 0;
requestAnimationFrame(animate);
}
};
const renderCards = (cardsProgress: ProgressData[]): void => {
if (!cardsProgress.length) return;
if (!firstPositionCard) {
firstPositionCard = slides[cardsProgress[0].position];
firstPositionCard.element?.classList.add("slide__first");
} else if (firstPositionCard.position !== cardsProgress[0].position) {
firstPositionCard.element?.classList.remove("slide__first");
firstPositionCard = slides[cardsProgress[0].position];
firstPositionCard.element?.classList.add("slide__first");
}
cardsProgress.forEach(({ progress, position }) => {
const card = slides[position];
card.element?.style.setProperty("--progress", progress.toString());
});
};
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`,
);
root.style.setProperty("--progress", `${sliderScrollStatus / 100}`);
root.style.setProperty(
"--timeline-visual-progress",
`${realToVisual(sliderScrollStatus / 100, progressMap)}`,
);
if (absDistance < 0.5 && Math.abs(speed) < 0.05) {
status = targetStatus;
speed = 0;
}
activePosition = Math.round(status / SPACE_BETWEEN_SLIDER);
endPosition =
activePosition + Math.round(SPACE_SLIDER_DURATION / SPACE_BETWEEN_SLIDER);
visibleSlides = [];
for (let i = activePosition; i <= endPosition; i += 1) {
const start = (i - slidesPerView) * SPACE_BETWEEN_SLIDER;
const rawProgress = (status - start) / SPACE_SLIDER_DURATION;
if (rawProgress > 1 || rawProgress < 0) continue;
const progress = Math.round(rawProgress * 1000) / 1000;
visibleSlides.push({ progress, position: i });
}
renderCards(visibleSlides);
const newVisible = new Set<number>(
visibleSlides.map(({ position }) => position),
);
for (const pos of newVisible) {
if (!currentVisible.has(pos)) {
slides[pos].element?.classList.add("slide_visible");
}
}
for (const pos of currentVisible) {
if (!newVisible.has(pos)) {
slides[pos].element?.classList.remove("slide_visible");
}
}
currentVisible = newVisible;
const result: SlideMeta[] = visibleSlides.map(({ progress, position }) => ({
status: `${Math.round(progress * 100)}%`,
title: `slide ${position}`,
}));
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)}%`,
},
{ title: "activePosition", status: `${activePosition}` },
{ title: "endPosition", status: `${endPosition}` },
{ title: "maxPosition", status: `${maxPosition}` },
],
result,
);
};
const animate = (time: number): void => {
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): void => {
const dx = e.clientX - startX;
const maxOffset = track.clientWidth - thumb.clientWidth;
thumbX = clamp(startOffset + dx, 0, maxOffset);
startAnimated();
};
const onPointerUp = (): void => {
document.removeEventListener("pointermove", onPointerMove);
document.removeEventListener("pointerup", onPointerUp);
if (activeId !== null) {
thumb.releasePointerCapture(activeId);
activeId = null;
}
};
thumb.addEventListener("pointerdown", (e: PointerEvent): void => {
activeId = e.pointerId;
thumb.setPointerCapture(activeId);
startX = e.clientX;
startOffset = thumbX;
document.addEventListener("pointermove", onPointerMove);
document.addEventListener("pointerup", onPointerUp);
});
startAnimated();
};
Теперь нужно реализовать синхронизацию между карточками годов в слайдере и годами на таймлайне