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>` : ``}
        </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()
}
3D TIMELINE
Теперь рендерим сами карточки, и их позиционируем