type SlideMeta = { status: string; title: string }
interface TimelineSlide {
position: number
element?: HTMLElement
[key: string]: any
}
type ProgressData = { progress: number; position: number }
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 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>` : ``}
</div>
`,
)
.join('')
export const runScript = (): void => {
const container = document.getElementById('3dTimeline') as HTMLElement | null
if (!container) return
const raw = container.dataset.slider3d
const sliderData: TimelineSlide[] = raw ? (JSON.parse(raw) as TimelineSlide[]) : []
if (!sliderData.length) return
const slidesPerView = Math.round(SPACE_SLIDER_DURATION / SPACE_BETWEEN_SLIDER)
const maxPosition = Math.max(...sliderData.map((item) => item.position)) + slidesPerView + 1
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="scrollbar" id="timeline3d-scrollbar">
<div class="scrollbar__thumb" id="timeline3d-scrollbar-thumb"></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
root.style.setProperty('--slider-width', `${width}`)
root.style.setProperty('--slider-height', `${height}`)
}
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 = sliderData.length * SPACE_BETWEEN_SLIDER
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
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`)
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)
root.setAttribute('data-count', `${visibleSlides.length}`)
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()
}
теперь нужно добавить scale а так же чтобы вертикальный offset зависел от прогресса