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
const ANIMATION_GAP = 5
const CATEGORY_ANIMATION_DURATION = 1000
const CATEGORY_THUMB_OFFSET = 600
const WHEEL_SCROLL_SENSITIVITY = 0.35
const GRID_LINES_COUNT = 7
const GRID_TOP_GAP_BASE = 110
const GRID_BOTTOM_GAP_BASE = 450
const GRID_BASE_WIDTH = 1920
const GRID_FADE_START = 0
const GRID_FADE_END = 0.35
const CARD_SCALE_END = 1.2
const CARD_SCALE_START = (CARD_SCALE_END * GRID_TOP_GAP_BASE) / GRID_BOTTOM_GAP_BASE
type TimelineMeta = { position: number; title: string }
interface TimelineSlide {
position: number
element?: HTMLElement
[key: string]: any
}
type ProgressData = { progress: number; position: number }
type SliderValue = {
cards: TimelineSlide[]
timeline: TimelineMeta[]
}
type SliderPayload = {
title: string
value: SliderValue
}[]
export interface MapNode {
real: number
visual: number
}
function clamp(val: number, min: number, max: number): number {
return val < min ? min : val > max ? max : val
}
function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t
}
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[sorted.length - 1]!.position
const segments = sorted.length - 1
return sorted.map(
(m, idx): MapNode => ({
real: m.position / lastPos,
visual: idx / segments,
}),
)
}
export function realToVisual(real: number, map: MapNode[]): number {
if (real <= 0) return 0
if (real >= 1) return 1
for (let i = 0; i < map.length - 1; i += 1) {
const next = map[i + 1]
if (real < next.real) {
const a = map[i]
const b = next
const tt = (real - a.real) / (b.real - a.real)
return lerp(a.visual, b.visual, tt)
}
}
return 1
}
export function visualToReal(visual: number, map: MapNode[]): number {
if (visual <= 0) return 0
if (visual >= 1) return 1
for (let i = 0; i < map.length - 1; i += 1) {
const next = map[i + 1]
if (visual < next.visual) {
const a = map[i]
const b = next
const tt = (visual - a.visual) / (b.visual - a.visual)
return lerp(a.real, b.real, tt)
}
}
return 1
}
const renderCategory = (data: SliderPayload): string =>
data
.map(
(item, index) => `
<div class="timeline3d__category-item ${
index === 0 ? 'timeline3d__category-item_active' : ''
}">${item.title}</div>`,
)
.join('')
export const renderTimeline = (data: TimelineMeta[]): string =>
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 | null = raw ? (JSON.parse(raw) as SliderPayload) : null
if (!data || !data.length) return
container.innerHTML = `
<div class="timeline3d">
<div class="timeline3d__category">
${renderCategory(data)}
</div>
<div class="timeline3d__slider">
<div class="timeline3d__grid"></div>
<div class="timeline3d__slides"></div>
</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"></div>
</div>
</div>
<div class="timeline3d__metrics">
<div class="timeline3d__metric" id="timeline3d-fps">FPS: 0</div>
<div class="timeline3d__metric" id="timeline3d-frame">Frame: 0.00ms</div>
<div class="timeline3d__metric" id="timeline3d-frame-max">Max frame: 0.00ms</div>
</div>
</div>
`
const root = container.querySelector<HTMLDivElement>('.timeline3d')!
const sliderEl = root.querySelector<HTMLDivElement>('.timeline3d__slider')!
const gridRoot = root.querySelector<HTMLDivElement>('.timeline3d__grid')!
const slidesRoot = root.querySelector<HTMLDivElement>('.timeline3d__slides')!
const track = root.querySelector<HTMLDivElement>('#timeline3d-scrollbar')!
const thumb = root.querySelector<HTMLDivElement>('#timeline3d-scrollbar-thumb')!
const inner = root.querySelector<HTMLDivElement>('#scrollbar-inner')!
const categoryElements = root.querySelectorAll<HTMLDivElement>('.timeline3d__category-item')!
const fpsEl = root.querySelector<HTMLDivElement>('#timeline3d-fps')!
const frameEl = root.querySelector<HTMLDivElement>('#timeline3d-frame')!
const frameMaxEl = root.querySelector<HTMLDivElement>('#timeline3d-frame-max')!
let activeSliderData = data[0]
let sliderTimeline: TimelineMeta[] = activeSliderData.value.timeline
let progressMap = buildProgressMap(sliderTimeline)
inner.innerHTML = renderTimeline(sliderTimeline)
const slidesPerView = Math.round(SPACE_SLIDER_DURATION / SPACE_BETWEEN_SLIDER)
let slides = new Map<number, TimelineSlide>()
let sliderWidth = 0
let gridLines: HTMLDivElement[] = []
let lineXTop: number[] = []
let lineXBottom: number[] = []
let thumbMaxOffset = 0
const buildGridLines = (): void => {
gridRoot.innerHTML = ''
gridLines = []
for (let i = 0; i < GRID_LINES_COUNT; i += 1) {
const line = document.createElement('div')
line.className = 'timeline3d__grid-line'
gridRoot.appendChild(line)
gridLines.push(line)
}
}
const buildSlides = (cards: TimelineSlide[]): void => {
slides.clear()
if (!cards.length) {
slidesRoot.innerHTML = ''
sliderWidth = 0
return
}
let minPos = cards[0].position
let maxCardPos = cards[0].position
for (let i = 0; i < cards.length; i += 1) {
const p = cards[i].position
if (p < minPos) minPos = p
if (p > maxCardPos) maxCardPos = p
}
const maxPosWithTail = maxCardPos + slidesPerView
const index = new Map<number, TimelineSlide>()
for (let i = 0; i < cards.length; i += 1) {
const c = cards[i]
index.set(c.position, { ...c, element: undefined })
}
const html: string[] = []
for (let pos = minPos; pos <= maxPosWithTail; pos += 1) {
const slide = index.get(pos) ?? { position: pos }
index.set(pos, slide)
const anySlide: any = slide
const hasCard = !!anySlide.card
const hasTitle = !!anySlide.title
html.push(
`<div class="slide" data-position="${pos}">
${hasCard ? '<div class="slide__card"><div class="slide__card-inner"></div></div>' : ''}
${
hasTitle
? `<div class="slide__card slide__card-title"><div class="slide__card-inner">${anySlide.title}</div></div>`
: ''
}
</div>`,
)
}
slidesRoot.innerHTML = html.join('')
// cache DOM refs for performance
let el = slidesRoot.firstElementChild as HTMLElement | null
while (el) {
const posAttr = (el as HTMLElement).dataset.position
if (posAttr != null) {
const pos = Number(posAttr)
const slide = index.get(pos)
if (slide) {
slide.element = el
;(slide as any).cardEl =
el.querySelector<HTMLElement>('.slide__card-title') ??
el.querySelector<HTMLElement>('.slide__card') ??
undefined
;(slide as any).innerEl = el.querySelector<HTMLElement>('.slide__card-inner') ?? undefined
}
}
el = el.nextElementSibling as HTMLElement | null
}
slides = index
const rawWidth = (maxPosWithTail - slidesPerView - SLIDE_GAP) * SPACE_BETWEEN_SLIDER - 1
sliderWidth = rawWidth > 0 ? rawWidth : 0
}
const getSlide = (position: number): TimelineSlide | undefined => slides.get(position)
const initCards = (): void => {
const baseCards = activeSliderData.value.cards.map((card) => ({
...card,
position: card.position + SLIDE_GAP,
}))
buildSlides(baseCards)
}
initCards()
buildGridLines()
let prevActiveElement = categoryElements[0]
let thumbX = 0
let startX = 0
let startOffset = 0
let activeId: number | null = null
let targetStatus = 0
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>()
let isCategoryAnimating = false
let isSliderHovered = false
// Метрики
let fps = 0
let frameDuration = 0
let maxFrameDuration = 0
let metricsLastTime = 0
let lastFrameTs = 0
let frameTimes: number[] = []
const updateMetrics = (): void => {
fpsEl.textContent = `FPS: ${fps}`
frameEl.textContent = `Frame: ${frameDuration.toFixed(2)}ms`
frameMaxEl.textContent = `Max frame: ${maxFrameDuration.toFixed(2)}ms`
}
const startAnimated = (): void => {
if (isAnimated) return
isAnimated = true
lastFrameTime = 0
lastFrameTs = 0
frameDuration = 0
maxFrameDuration = 0
fps = 0
metricsLastTime = 0
frameTimes = []
updateMetrics()
requestAnimationFrame(animate)
}
const getOffsetNorm = (slide: TimelineSlide | undefined): number => {
if (!slide) return 0.5
const anySlide: any = slide
const raw =
(anySlide.card && typeof anySlide.card.offset === 'number'
? anySlide.card.offset
: undefined) ??
(typeof anySlide.offset === 'number' ? anySlide.offset : undefined) ??
(typeof anySlide.titleOffset === 'number' ? anySlide.titleOffset : undefined)
if (typeof raw !== 'number' || Number.isNaN(raw)) return 0.5
// нормализация [-1;1] -> [0;1]
const t = (raw + 1) / 2
return clamp(t, 0, 1)
}
const renderCards = (cardsProgress: ProgressData[]): void => {
if (!cardsProgress.length) return
const first = getSlide(cardsProgress[0].position)
if (!firstPositionCard || firstPositionCard.position !== first?.position) {
firstPositionCard?.element?.classList.remove('slide__first')
firstPositionCard = first
firstPositionCard?.element?.classList.add('slide__first')
}
const maxIdx = GRID_LINES_COUNT - 1
const hasGrid = lineXTop.length === GRID_LINES_COUNT && lineXBottom.length === GRID_LINES_COUNT
for (let i = 0; i < cardsProgress.length; i += 1) {
const { progress, position } = cardsProgress[i]
const slide = getSlide(position)
const el = slide?.element
if (!slide || !el) continue
const ease = progress * progress
el.style.setProperty('--progress', String(progress))
if (hasGrid) {
const tOffset = getOffsetNorm(slide)
const idxFloat = tOffset * maxIdx
const i0 = idxFloat | 0
const i1 = i0 === maxIdx ? maxIdx : i0 + 1
const localT = i0 === i1 ? 0 : idxFloat - i0
const topX = lerp(lineXTop[i0], lineXTop[i1], localT)
const bottomX = lerp(lineXBottom[i0], lineXBottom[i1], localT)
const cardEl =
(slide as any).cardEl ??
el.querySelector<HTMLElement>('.slide__card-title') ??
el.querySelector<HTMLElement>('.slide__card')
if (cardEl) {
cardEl.style.setProperty('--timeline3d-card-x-top', String(topX))
cardEl.style.setProperty('--timeline3d-card-x-bottom', String(bottomX))
}
}
const inner = (slide as any).innerEl as HTMLElement | undefined
if (inner) {
const scale = lerp(CARD_SCALE_START, CARD_SCALE_END, ease)
inner.style.setProperty('--card-scale', String(scale))
}
}
}
const render = (): void => {
const maxOffset = thumbMaxOffset
const visualProgress = maxOffset ? thumbX / maxOffset : 0
const realProgress = visualToReal(visualProgress, progressMap)
sliderScrollStatus = realProgress * 100
targetStatus = sliderWidth * realProgress
const distance = targetStatus - status
const absDistance = distance < 0 ? -distance : distance
let desiredSpeed = 0
if (absDistance > 0) {
desiredSpeed = (distance > 0 ? 1 : -1) * 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, -9999999, sliderWidth || 0)
if (absDistance < 0.5 && Math.abs(speed) < 0.05) {
status = targetStatus
speed = 0
}
const realProgressFromStatus = sliderWidth ? status / sliderWidth : 0
root.style.setProperty('--progress', `${realProgressFromStatus}`)
if (!isCategoryAnimating) {
root.style.setProperty(
'--timeline-visual-progress',
`${realToVisual(realProgressFromStatus, progressMap)}`,
)
}
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>()
for (let i = 0; i < visibleSlides.length; i += 1) {
newVisible.add(visibleSlides[i].position)
}
newVisible.forEach((pos) => {
if (!currentVisible.has(pos)) {
getSlide(pos)?.element?.classList.add('slide_visible')
}
})
currentVisible.forEach((pos) => {
if (!newVisible.has(pos)) {
getSlide(pos)?.element?.classList.remove('slide_visible')
}
})
currentVisible = newVisible
}
const animate = (time: number): void => {
if (!lastFrameTs) lastFrameTs = time
const delta = time - lastFrameTs
lastFrameTs = time
if (delta > 0) {
frameDuration = delta
if (delta > maxFrameDuration) maxFrameDuration = delta
}
frameTimes.push(time)
while (frameTimes.length && frameTimes[0] <= time - 1000) {
frameTimes.shift()
}
if (!metricsLastTime) metricsLastTime = time
if (time - metricsLastTime >= 1000) {
fps = frameTimes.length
metricsLastTime = time
updateMetrics()
}
const shouldRender = !lastFrameTime || time - lastFrameTime >= FRAME_DURATION
if (shouldRender) {
lastFrameTime = time
render()
}
if (Math.abs(targetStatus - status) > 0.01 || Math.abs(speed) > 0.01) {
requestAnimationFrame(animate)
} else {
speed = 0
isAnimated = false
updateMetrics()
}
}
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}`)
thumbMaxOffset = scrollbarWidth - thumb.offsetWidth
if (thumbMaxOffset < 0) thumbMaxOffset = 0
if (height > 0 && gridLines.length === GRID_LINES_COUNT) {
const scale = Math.min(1, width / GRID_BASE_WIDTH)
const topGap = GRID_TOP_GAP_BASE * scale
const bottomGap = GRID_BOTTOM_GAP_BASE * scale
const center = width / 2
const segments = GRID_LINES_COUNT - 1
const topTotal = topGap * segments
const bottomTotal = bottomGap * segments
const topStart = center - topTotal / 2
const bottomStart = center - bottomTotal / 2
lineXTop = []
lineXBottom = []
for (let index = 0; index < GRID_LINES_COUNT; index += 1) {
const line = gridLines[index]
const topX = topStart + topGap * index
const bottomX = bottomStart + bottomGap * index
lineXTop.push(topX)
lineXBottom.push(bottomX)
const dx = bottomX - topX
const angle = Math.atan(dx / height)
const angleDeg = (angle * 180) / Math.PI
const cos = Math.cos(angle)
const len = cos !== 0 ? height / cos : height
const fadeStart = GRID_FADE_START * len
const fadeEnd = GRID_FADE_END * len
line.style.height = `${len}px`
line.style.left = `${bottomX}px`
line.style.transformOrigin = 'bottom center'
line.style.transform = `translateX(-50%) rotate(${-angleDeg}deg)`
line.style.background = `linear-gradient(
to bottom,
rgba(0,0,0,0) ${fadeStart}px,
rgba(0,0,0,0.5) ${fadeEnd}px,
rgba(0,0,0,0.5) 100%
)`
}
}
}
setTimeout(() => {
updateSliderSize()
render()
updateMetrics()
}, 0)
window.addEventListener('resize', () => {
updateSliderSize()
render()
})
const onPointerMove = (e: PointerEvent): void => {
const dx = e.clientX - startX
thumbX = clamp(startOffset + dx, 0, thumbMaxOffset)
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 => {
if (isCategoryAnimating) return
activeId = e.pointerId
thumb.setPointerCapture(activeId)
startX = e.clientX
startOffset = thumbX
document.addEventListener('pointermove', onPointerMove)
document.addEventListener('pointerup', onPointerUp)
})
inner.addEventListener('click', (e: MouseEvent): void => {
if (isCategoryAnimating) return
const rect = inner.getBoundingClientRect()
const vProgress = clamp((e.clientX - rect.left) / rect.width, 0, 1)
const realProgress = visualToReal(vProgress, progressMap)
targetStatus = sliderWidth * realProgress
thumbX = vProgress * thumbMaxOffset
sliderScrollStatus = realProgress * 100
startAnimated()
})
const changeCategory = (index: number): void => {
if (index < 0 || index >= data.length) return
if (data[index] === activeSliderData) return
if (isCategoryAnimating) return
isCategoryAnimating = true
root.classList.add('timeline3d_category-disabled')
prevActiveElement.classList.remove('timeline3d__category-item_active')
prevActiveElement = categoryElements[index]
prevActiveElement.classList.add('timeline3d__category-item_active')
const prevTimelineProgressRaw =
parseFloat(
(getComputedStyle(root).getPropertyValue('--timeline-visual-progress') || '0').trim(),
) || 0
const trackRect = track.getBoundingClientRect()
const thumbRect = thumb.getBoundingClientRect()
const oldThumbLeft = thumbRect.left - trackRect.left
const oldThumbClone = thumb.cloneNode(true) as HTMLDivElement
oldThumbClone.removeAttribute('id')
oldThumbClone.classList.add('scrollbar__thumb-clone')
oldThumbClone.style.left = `${oldThumbLeft}px`
oldThumbClone.style.opacity = '1'
oldThumbClone.style.transform = 'translateX(0)'
oldThumbClone.style.transition = `transform ${CATEGORY_ANIMATION_DURATION}ms ease, opacity ${CATEGORY_ANIMATION_DURATION}ms ease`
track.appendChild(oldThumbClone)
const newThumbClone = thumb.cloneNode(true) as HTMLDivElement
newThumbClone.removeAttribute('id')
newThumbClone.classList.add('scrollbar__thumb-clone')
newThumbClone.style.left = '0px'
newThumbClone.style.opacity = '0'
newThumbClone.style.transform = `translateX(-${CATEGORY_THUMB_OFFSET}px)`
newThumbClone.style.transition = `transform ${CATEGORY_ANIMATION_DURATION}ms ease, opacity ${CATEGORY_ANIMATION_DURATION}ms ease`
track.appendChild(newThumbClone)
thumb.style.opacity = '0'
const visibleNow = Array.from(currentVisible).sort((a, b) => a - b)
const hasVisible = visibleNow.length > 0
const firstVisiblePos = hasVisible ? visibleNow[0] : activePosition
const startFirst = (firstVisiblePos - slidesPerView) * SPACE_BETWEEN_SLIDER
const rawFirst =
(status - startFirst) / SPACE_SLIDER_DURATION >= 0 &&
(status - startFirst) / SPACE_SLIDER_DURATION <= 1
? (status - startFirst) / SPACE_SLIDER_DURATION
: 0
const negativeZone = (hasVisible ? visibleNow.length : slidesPerView) + ANIMATION_GAP
const negStart = -negativeZone
const usedNeg = new Set<number>()
const negativeSlides: TimelineSlide[] = []
if (hasVisible) {
for (let i = 0; i < visibleNow.length; i += 1) {
const pos = visibleNow[i]
const original = getSlide(pos)
const newPos = negStart + i
usedNeg.add(newPos)
negativeSlides.push(
original ? { ...original, position: newPos, element: undefined } : { position: newPos },
)
}
}
for (let p = negStart; p <= -1; p += 1) {
if (!usedNeg.has(p)) negativeSlides.push({ position: p })
}
activeSliderData = data[index]
sliderTimeline = activeSliderData.value.timeline
progressMap = buildProgressMap(sliderTimeline)
inner.innerHTML = renderTimeline(sliderTimeline)
const baseCards = activeSliderData.value.cards.map((card) => ({
...card,
position: card.position + SLIDE_GAP,
}))
const combined = [...negativeSlides, ...baseCards]
const statusForFirst =
rawFirst * SPACE_SLIDER_DURATION + (negStart - slidesPerView) * SPACE_BETWEEN_SLIDER
buildSlides(combined)
currentVisible = new Set<number>()
firstPositionCard = undefined
thumbX = 0
sliderScrollStatus = 0
targetStatus = 0
status = statusForFirst
root.style.setProperty('--timeline-visual-progress', `${prevTimelineProgressRaw}`)
render()
startAnimated()
updateSliderSize()
requestAnimationFrame(() => {
oldThumbClone.style.transform = `translateX(${CATEGORY_THUMB_OFFSET}px)`
oldThumbClone.style.opacity = '0'
newThumbClone.style.transform = 'translateX(0)'
newThumbClone.style.opacity = '1'
inner.style.transition = `transform ${CATEGORY_ANIMATION_DURATION}ms ease`
root.style.setProperty('--timeline-visual-progress', '0')
})
window.setTimeout(() => {
oldThumbClone.remove()
newThumbClone.remove()
inner.style.transition = ''
thumb.style.opacity = '1'
root.classList.remove('timeline3d_category-disabled')
isCategoryAnimating = false
}, CATEGORY_ANIMATION_DURATION)
}
categoryElements.forEach((el, index) => {
el.addEventListener('click', () => changeCategory(index))
})
sliderEl.addEventListener('mouseenter', () => {
isSliderHovered = true
})
sliderEl.addEventListener('mouseleave', () => {
isSliderHovered = false
})
sliderEl.addEventListener(
'wheel',
(e: WheelEvent): void => {
if (!isSliderHovered || isCategoryAnimating) return
const delta = e.deltaY !== 0 ? e.deltaY : e.deltaX
if (!delta) return
e.preventDefault()
if (!thumbMaxOffset) return
const diff = delta * WHEEL_SCROLL_SENSITIVITY
thumbX = clamp(thumbX + diff, 0, thumbMaxOffset)
const vProgress = thumbMaxOffset ? thumbX / thumbMaxOffset : 0
const realProgress = visualToReal(vProgress, progressMap)
targetStatus = sliderWidth * realProgress
sliderScrollStatus = realProgress * 100
startAnimated()
},
{ passive: false },
)
render()
updateMetrics()
startAnimated()
}
Самое сложное позади, основной функционал сделан, теперь сделаем косметику.