Перейти к содержимому

Интерфейс

Дуговая временная шкала

Интерактивная временная шкала по дуге с активными шагами, описаниями и настраиваемой геометрией.


Дуговая временная шкала

ArcTimeline показывает этапы проекта на большой окружности. Клик по шагу или фокус с клавиатуры активирует его и раскрывает описание.

Интерактивное превью

01

Исследование идеи и первые прототипы

2023
02

Формирование визуального языка

03

Публикация первых open-source компонентов

2024
04

Рост библиотеки и документации

05

Новые инструменты для DevSecOps

2025
<script lang="ts">
  import type { Snippet } from 'svelte';
  import { cn } from '$lib/utils';
  import type { ArcTimelineProps, ArcTimelineRenderable } from './ArcTimeline.types';

  type TimelineStep = { line: ArcTimelineProps['data'][number]; step: ArcTimelineProps['data'][number]['steps'][number]; lineIndex: number; stepIndex: number; angle: number; isFirstStep: boolean; isLastStep: boolean };
  let { ref = $bindable(null), class: className, data, arcConfig = {}, defaultActiveStep = {}, ...restProps }: ArcTimelineProps = $props();
  let circleWidth = $derived(arcConfig.circleWidth ?? 5000);
  let angleBetweenMinorSteps = $derived(arcConfig.angleBetweenMinorSteps ?? 0.35);
  let lineCountFillBetweenSteps = $derived(arcConfig.lineCountFillBetweenSteps ?? 10);
  let boundaryPlaceholderLinesCount = $derived(arcConfig.boundaryPlaceholderLinesCount ?? 50);
  function getInitialRotation() { const time = defaultActiveStep.time ?? data[0]?.time; const index = defaultActiveStep.stepIndex ?? 0; let count = 0; for (const item of data) { if (item.time === time) { count += index; break; } count += item.steps.length; } return -count * angleBetweenMinorSteps * (lineCountFillBetweenSteps + 1) - angleBetweenMinorSteps * boundaryPlaceholderLinesCount; }
  let rotation = $state(getInitialRotation());
  let timelineSteps = $derived.by(() => { const steps: TimelineStep[] = []; let before = 0; data.forEach((line, lineIndex) => { line.steps.forEach((step, stepIndex) => { const angle = angleBetweenMinorSteps * (lineCountFillBetweenSteps + 1) * (before + stepIndex) + angleBetweenMinorSteps * boundaryPlaceholderLinesCount; steps.push({ line, step, lineIndex, stepIndex, angle, isFirstStep: lineIndex === 0 && stepIndex === 0, isLastStep: lineIndex === data.length - 1 && stepIndex === line.steps.length - 1 }); }); before += line.steps.length; }); return steps; });
  function snippet(value: ArcTimelineRenderable): Snippet | null { return typeof value === 'function' ? value : null; }
  function text(value: ArcTimelineRenderable): string | number | null { return typeof value === 'string' || typeof value === 'number' ? value : null; }
  function placeholders(first: boolean, last: boolean, angle: number) { const count = first || last ? boundaryPlaceholderLinesCount : lineCountFillBetweenSteps; return Array.from({ length: count }, (_, i) => first ? i * angleBetweenMinorSteps : angle + (i + 1) * angleBetweenMinorSteps); }
  function active(angle: number) { return Math.abs(angle + rotation) < 0.01; }
  function setActive(angle: number) { rotation = -angle; }
</script>

<div bind:this={ref} class={cn('relative h-[380px] w-full overflow-hidden', className as string)} {...restProps}>
  <div class="absolute top-28 left-1/2 aspect-square origin-center rounded-full transition-all duration-500 ease-in-out" style:transform={`translateX(-50%) rotate(${rotation}deg)`} style:width={`${circleWidth}px`}>
    {#each timelineSteps as item (`${item.lineIndex}-${item.stepIndex}`)}
      {@const isActive = active(item.angle)}
      {@const icon = snippet(item.step.icon)}
      {@const iconValue = text(item.step.icon)}
      {@const content = snippet(item.step.content)}
      {@const contentValue = text(item.step.content)}
      {@const time = snippet(item.line.time)}
      {@const timeValue = text(item.line.time)}
      {#if item.isFirstStep}{#each placeholders(true, false, item.angle) as fillAngle, i (`before-${i}`)}<div class="absolute top-0 left-1/2 h-[34px] w-px -translate-x-1/2" style:transform-origin={`50% ${circleWidth / 2}px`} style:transform={`rotate(${fillAngle}deg)`}><div class="h-full w-full bg-[var(--placeholder-line-color,#a1a1a1)] dark:bg-[var(--placeholder-line-color,#737373)]" style:transform={`rotate(${-fillAngle - rotation}deg)`}></div></div>{/each}{/if}
      <div class={cn('absolute top-0 left-1/2 -translate-x-1/2 cursor-pointer transition-all duration-200', isActive ? 'h-[120px] w-0.5' : 'h-16 w-[1.5px]')} role="button" tabindex="0" style:transform-origin={`50% ${circleWidth / 2}px`} style:transform={`rotate(${item.angle}deg)`} onclick={() => setActive(item.angle)} onkeydown={(event) => (event.key === 'Enter' || event.key === ' ') && (event.preventDefault(), setActive(item.angle))}>
        <div class={cn('h-full w-full transition-colors duration-200', isActive ? 'bg-[var(--step-line-active-color,#f43f5e)] dark:bg-[var(--step-line-active-color,#f43f5e)]' : 'bg-[var(--step-line-inactive-color,#b1b1b1)] dark:bg-[var(--step-line-inactive-color,#737373)]')} style:transform={`rotate(${-item.angle - rotation}deg)`}>
          <div class={cn('absolute bottom-0 left-1/2 aspect-square -translate-x-1/2', isActive ? 'translate-y-[calc(100%+14px)] scale-[1.2] text-[var(--icon-active-color,#555)] dark:text-[var(--icon-active-color,#d4d4d4)]' : 'translate-y-[calc(100%+4px)] text-[var(--icon-inactive-color,#a3a3a3)]')}>
            {#if icon}{@render icon()}{:else if iconValue !== null}{iconValue}{/if}
          </div>
          <p class={cn('absolute bottom-0 left-1/2 line-clamp-3 flex w-[240px] -translate-x-1/2 translate-y-[calc(100%+42px)] items-center justify-center text-center text-sm transition-opacity duration-300', 'text-[var(--description-color,#555)] dark:text-[var(--description-color,#d4d4d4)]', isActive ? 'opacity-100' : 'opacity-0')}>{#if content}{@render content()}{:else if contentValue !== null}{contentValue}{/if}</p>
        </div>
        {#if item.stepIndex === 0}<div class={cn('absolute top-0 left-1/2 z-10 -translate-x-1/2 -translate-y-[calc(100%+24px)] whitespace-nowrap', isActive ? 'text-[var(--time-active-color,#555)] dark:text-[var(--time-active-color,#d4d4d4)]' : 'text-[var(--time-inactive-color,#a3a3a3)]')}>{#if time}{@render time()}{:else if timeValue !== null}{timeValue}{/if}</div>{/if}
      </div>
      {#each placeholders(false, item.isLastStep, item.angle) as fillAngle, i (`after-${i}`)}<div class="absolute top-0 left-1/2 h-[34px] w-px -translate-x-1/2" style:transform-origin={`50% ${circleWidth / 2}px`} style:transform={`rotate(${fillAngle}deg)`}><div class="h-full w-full bg-[var(--placeholder-line-color,#a1a1a1)] dark:bg-[var(--placeholder-line-color,#737373)]" style:transform={`rotate(${-fillAngle - rotation}deg)`}></div></div>{/each}
    {/each}
  </div>
</div>
<script lang="ts">
  import type { Snippet } from 'svelte';
  import { cn } from '$lib/utils';
  import type { ArcTimelineProps, ArcTimelineRenderable } from './ArcTimeline.types';

  type TimelineStep = { line: ArcTimelineProps['data'][number]; step: ArcTimelineProps['data'][number]['steps'][number]; lineIndex: number; stepIndex: number; angle: number; isFirstStep: boolean; isLastStep: boolean };
  let { ref = $bindable(null), class: className, data, arcConfig = {}, defaultActiveStep = {}, ...restProps }: ArcTimelineProps = $props();
  let circleWidth = $derived(arcConfig.circleWidth ?? 5000);
  let angleBetweenMinorSteps = $derived(arcConfig.angleBetweenMinorSteps ?? 0.35);
  let lineCountFillBetweenSteps = $derived(arcConfig.lineCountFillBetweenSteps ?? 10);
  let boundaryPlaceholderLinesCount = $derived(arcConfig.boundaryPlaceholderLinesCount ?? 50);
  function getInitialRotation() { const time = defaultActiveStep.time ?? data[0]?.time; const index = defaultActiveStep.stepIndex ?? 0; let count = 0; for (const item of data) { if (item.time === time) { count += index; break; } count += item.steps.length; } return -count * angleBetweenMinorSteps * (lineCountFillBetweenSteps + 1) - angleBetweenMinorSteps * boundaryPlaceholderLinesCount; }
  let rotation = $state(getInitialRotation());
  let timelineSteps = $derived.by(() => { const steps: TimelineStep[] = []; let before = 0; data.forEach((line, lineIndex) => { line.steps.forEach((step, stepIndex) => { const angle = angleBetweenMinorSteps * (lineCountFillBetweenSteps + 1) * (before + stepIndex) + angleBetweenMinorSteps * boundaryPlaceholderLinesCount; steps.push({ line, step, lineIndex, stepIndex, angle, isFirstStep: lineIndex === 0 && stepIndex === 0, isLastStep: lineIndex === data.length - 1 && stepIndex === line.steps.length - 1 }); }); before += line.steps.length; }); return steps; });
  function snippet(value: ArcTimelineRenderable): Snippet | null { return typeof value === 'function' ? value : null; }
  function text(value: ArcTimelineRenderable): string | number | null { return typeof value === 'string' || typeof value === 'number' ? value : null; }
  function placeholders(first: boolean, last: boolean, angle: number) { const count = first || last ? boundaryPlaceholderLinesCount : lineCountFillBetweenSteps; return Array.from({ length: count }, (_, i) => first ? i * angleBetweenMinorSteps : angle + (i + 1) * angleBetweenMinorSteps); }
  function active(angle: number) { return Math.abs(angle + rotation) < 0.01; }
  function setActive(angle: number) { rotation = -angle; }
</script>

<div bind:this={ref} class={cn('relative h-[380px] w-full overflow-hidden', className as string)} {...restProps}>
  <div class="absolute top-28 left-1/2 aspect-square origin-center rounded-full transition-all duration-500 ease-in-out" style:transform={`translateX(-50%) rotate(${rotation}deg)`} style:width={`${circleWidth}px`}>
    {#each timelineSteps as item (`${item.lineIndex}-${item.stepIndex}`)}
      {@const isActive = active(item.angle)}
      {@const icon = snippet(item.step.icon)}
      {@const iconValue = text(item.step.icon)}
      {@const content = snippet(item.step.content)}
      {@const contentValue = text(item.step.content)}
      {@const time = snippet(item.line.time)}
      {@const timeValue = text(item.line.time)}
      {#if item.isFirstStep}{#each placeholders(true, false, item.angle) as fillAngle, i (`before-${i}`)}<div class="absolute top-0 left-1/2 h-[34px] w-px -translate-x-1/2" style:transform-origin={`50% ${circleWidth / 2}px`} style:transform={`rotate(${fillAngle}deg)`}><div class="h-full w-full bg-[var(--placeholder-line-color,#a1a1a1)] dark:bg-[var(--placeholder-line-color,#737373)]" style:transform={`rotate(${-fillAngle - rotation}deg)`}></div></div>{/each}{/if}
      <div class={cn('absolute top-0 left-1/2 -translate-x-1/2 cursor-pointer transition-all duration-200', isActive ? 'h-[120px] w-0.5' : 'h-16 w-[1.5px]')} role="button" tabindex="0" style:transform-origin={`50% ${circleWidth / 2}px`} style:transform={`rotate(${item.angle}deg)`} onclick={() => setActive(item.angle)} onkeydown={(event) => (event.key === 'Enter' || event.key === ' ') && (event.preventDefault(), setActive(item.angle))}>
        <div class={cn('h-full w-full transition-colors duration-200', isActive ? 'bg-[var(--step-line-active-color,#f43f5e)] dark:bg-[var(--step-line-active-color,#f43f5e)]' : 'bg-[var(--step-line-inactive-color,#b1b1b1)] dark:bg-[var(--step-line-inactive-color,#737373)]')} style:transform={`rotate(${-item.angle - rotation}deg)`}>
          <div class={cn('absolute bottom-0 left-1/2 aspect-square -translate-x-1/2', isActive ? 'translate-y-[calc(100%+14px)] scale-[1.2] text-[var(--icon-active-color,#555)] dark:text-[var(--icon-active-color,#d4d4d4)]' : 'translate-y-[calc(100%+4px)] text-[var(--icon-inactive-color,#a3a3a3)]')}>
            {#if icon}{@render icon()}{:else if iconValue !== null}{iconValue}{/if}
          </div>
          <p class={cn('absolute bottom-0 left-1/2 line-clamp-3 flex w-[240px] -translate-x-1/2 translate-y-[calc(100%+42px)] items-center justify-center text-center text-sm transition-opacity duration-300', 'text-[var(--description-color,#555)] dark:text-[var(--description-color,#d4d4d4)]', isActive ? 'opacity-100' : 'opacity-0')}>{#if content}{@render content()}{:else if contentValue !== null}{contentValue}{/if}</p>
        </div>
        {#if item.stepIndex === 0}<div class={cn('absolute top-0 left-1/2 z-10 -translate-x-1/2 -translate-y-[calc(100%+24px)] whitespace-nowrap', isActive ? 'text-[var(--time-active-color,#555)] dark:text-[var(--time-active-color,#d4d4d4)]' : 'text-[var(--time-inactive-color,#a3a3a3)]')}>{#if time}{@render time()}{:else if timeValue !== null}{timeValue}{/if}</div>{/if}
      </div>
      {#each placeholders(false, item.isLastStep, item.angle) as fillAngle, i (`after-${i}`)}<div class="absolute top-0 left-1/2 h-[34px] w-px -translate-x-1/2" style:transform-origin={`50% ${circleWidth / 2}px`} style:transform={`rotate(${fillAngle}deg)`}><div class="h-full w-full bg-[var(--placeholder-line-color,#a1a1a1)] dark:bg-[var(--placeholder-line-color,#737373)]" style:transform={`rotate(${-fillAngle - rotation}deg)`}></div></div>{/each}
    {/each}
  </div>
</div>

Использование

<script lang="ts">
  import ArcTimeline from '$lib/components/ui-registry/ArcTimeline.svelte';
  const data = [{ time: '2025', steps: [{ icon: '01', content: 'Запуск проекта' }] }];
</script>

<ArcTimeline {data} defaultActiveStep={{ stepIndex: 0 }} />
<script lang="ts">
  import ArcTimeline from '$lib/components/ui-registry/ArcTimeline.svelte';
  const data = [{ time: '2025', steps: [{ icon: '01', content: 'Запуск проекта' }] }];
</script>

<ArcTimeline {data} defaultActiveStep={{ stepIndex: 0 }} />

Пропсы

Пропс Тип Описание
data ArcTimelineItem[] Группы этапов с датой, иконкой и описанием
arcConfig ArcTimelineArcConfig Размер окружности, углы и количество линий заполнения
defaultActiveStep ArcTimelineDefaultActiveStep Активный шаг при первом отображении
class string Дополнительный класс корневого элемента

arcConfig

  • circleWidth — ширина окружности в пикселях, по умолчанию 5000.
  • angleBetweenMinorSteps — расстояние между шагами, по умолчанию 0.35.
  • lineCountFillBetweenSteps — число вспомогательных линий между шагами.
  • boundaryPlaceholderLinesCount — число линий до первого и после последнего шага.

Компонент поддерживает строки, числа и Svelte snippets для icon, content и time.