Charts

Critical Path

План, где критический путь выделен начертанием: задачи без запаса получают заливку и жирную рамку, у остальных виден пунктирный хвост допустимого сдвига.

  • gantt
  • critical-path
  • slack
  • schedule

Превью

1440pxHost theme

Использовать с AI

  1. 1. Скопируйте ссылку.
  2. 2. Напишите агенту своими словами и вставьте её в предложение.
  3. 3. Агент откроет ссылку и поставит компонент из registry.

размести это в шапке: https://vibeui.ru/c/gantt-007

Критический путь

24 дней, без запаса 6 из 8

  1. БрифКП2 дн
  2. ИсследованиеКП5 дн
  3. Тексты4 дн
  4. МакетыКП6 дн
  5. ВёрсткаКП7 дн
  6. Фотосъёмка3 дн
  7. ПроверкаКП3 дн
  8. ЗапускКП1 дн
Сроки и запас таблицей
ЗадачаНачалоКонецЗапас, дн
Бриф (критическая)2026-05-042026-05-050
Исследование (критическая)2026-05-062026-05-100
Тексты2026-05-062026-05-097
Макеты (критическая)2026-05-112026-05-160
Вёрстка (критическая)2026-05-172026-05-230
Фотосъёмка2026-05-062026-05-0815
Проверка (критическая)2026-05-242026-05-260
Запуск (критическая)2026-05-272026-05-270
Показать полную инструкцию

То, что лежит по ссылке в развёрнутом виде. Нужна, если агент не может открыть ссылку — тогда вставьте этот текст целиком.

# Установка и размещение «gantt-007» (Critical Path) из VibeUI

## 1. Сначала установка — не пропускай, не пересоздавай
Выполни эту команду до того, как писать код:
npx shadcn@latest add https://vibeui.ru/r/gantt-007.json

Registry item: https://vibeui.ru/r/gantt-007.json
Ставится в: components/vibeui/gantt-007.tsx (точный путь следует алиасам components.json этого проекта).
npm-зависимости: нет.
Registry-зависимости: нет.

Ставь из реестра. Не пересоздавай по описанию, не подменяй похожим
компонентом из другой библиотеки и не переписывай под стиль проекта.

## 2. Что это
План, где критический путь выделен начертанием: задачи без запаса получают заливку и жирную рамку, у остальных виден пунктирный хвост допустимого сдвига.

Критический путь считается из связей при рендере: ранние и поздние сроки, запас, выделение начертанием. Ноль зависимостей, один файл, клиентского JS нет.

## 3. Как использовать
import { Gantt007 } from "@/components/vibeui/gantt-007"

<Gantt007
  startDate="2026-05-04"
  tasks={[
    { id: "design", title: "Макеты", days: 6, after: ["research"] },
    { id: "build", title: "Вёрстка", days: 7, after: ["design"] },
  ]}
/>

Полный список пропсов — в установленном файле.

## 4. Куда поставить
Это мелкий inline-компонент. Поставь его ровно туда, куда просил
пользователь, внутрь существующей разметки. Не создавай ради него
новую страницу, секцию или обёртку. Если на этом месте уже стоит похожий
контрол — замени его, а не добавляй второй.

Куда поставить: ___
(Эту строку заполняет пользователь. Если она осталась пустой — спроси,
а не угадывай.)

## 5. Сохранить как установлено
- локальную палитру --vibeui-gantt-007-* и не подменять её токенами вашей темы
- выделение начертанием, а не только цветом: заливка, рамка и жирный текст переживают печать и дальтонизм
- метку «КП» текстом — по одному оттенку критичность не назвать
- пунктирный хвост запаса: он отвечает на вопрос, насколько задачу можно сдвинуть
- контейнер прокрутки с overflow-x и tabindex=0: длинный план шире экрана
- таблицу сроков и запаса в <details>: диаграмме нужна текстовая альтернатива

## 6. Можно менять
- массив tasks вместе со связями after
- начало плана через startDate
- заголовок через heading
- акцент через accent и цвет некритических полос

## 7. Правила
- Массив обязан быть топологически упорядочен: задача идёт после всех своих предшественников.
- Циклы в связях не проверяются — данные с петлёй дадут бессмысленные сроки.
- Считаются календарные дни подряд: выходные и загрузка людей не учитываются.
- Не выносите CSS-переменные в globals.css: компонент обязан оставаться одним файлом.

## 8. Проверка
- рендерится без ошибок в консоли;
- выглядит так же, как превью на странице VibeUI, откуда взята ссылка;
- если нет — ты изменил что-то из раздела 5, верни как было.

Для разработчика

npx shadcn@latest add https://vibeui.ru/r/gantt-007.json
https://vibeui.ru/r/gantt-007.json

Компонент самодостаточен: один файл, без зависимостей, собственная палитра --vibeui-gantt-007-*. Серверный: хуков нет. Функция schedule() делает прямой проход по связям after (ранние сроки) и обратный (поздние сроки), запас считается их разностью, нулевой запас означает критическую задачу. Раскладка идёт не по колонкам грида, а в процентах от длины проекта на flex-дорожках, поэтому масштаб не зависит от числа дней. Прокрутка вбок в контейнере с tabindex=0, сроки и запас продублированы таблицей в <details>.

Исходник компонента

Тот же файл, который поставит агент. Нужен, если вы предпочитаете скопировать код руками.

import type { ComponentPropsWithoutRef, CSSProperties } from "react"

export type Gantt007Task = {
  id: string
  title: string
  days: number
  /** id задач, после которых эта может начаться. */
  after?: string[]
}

export type Gantt007Props = Omit<
  ComponentPropsWithoutRef<"section">,
  "children" | "title"
> & {
  heading?: string
  startDate?: string
  tasks?: Gantt007Task[]
  accent?: string
}

// Идея компонента: критический путь — это не цвет, а начертание. Задачи без
// запаса получают сплошную заливку, жирную рамку и подпись «КП», остальные —
// тонкий контур и полупрозрачный «хвост» запаса: сразу видно, где план
// сдвинется от любой задержки, а где есть люфт. Сроки и запас считаются из
// связей прямо при рендере — прямого и обратного прохода хватает, состояния
// нет, компонент остаётся серверным.
const STYLES = `
:where([data-vibeui-block="gantt-007"]){
--vibeui-gantt-007-bg:oklch(1 0 0);
--vibeui-gantt-007-fg:oklch(0.22 0.014 265);
--vibeui-gantt-007-muted:oklch(0.6 0.014 265);
--vibeui-gantt-007-border:oklch(0.91 0.006 265);
--vibeui-gantt-007-line:oklch(0.955 0.004 265);
--vibeui-gantt-007-accent:oklch(0.52 0.19 25);
--vibeui-gantt-007-calm:oklch(0.55 0.03 265);
--vibeui-gantt-007-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="gantt-007"]{
width:100%;box-sizing:border-box;padding:1rem;
background:var(--vibeui-gantt-007-bg);
border:1px solid var(--vibeui-gantt-007-border);border-radius:1rem;
color:var(--vibeui-gantt-007-fg);font-family:var(--vibeui-gantt-007-font);
}
[data-vibeui-block="gantt-007"] *{box-sizing:border-box}
[data-vibeui-block="gantt-007"] ol,
[data-vibeui-block="gantt-007"] ul{margin:0;padding:0;list-style:none}
[data-vibeui-block="gantt-007"] [data-part="head"]{
display:flex;flex-wrap:wrap;align-items:baseline;justify-content:space-between;
gap:0.5rem;margin:0 0 0.75rem;
}
[data-vibeui-block="gantt-007"] [data-part="heading"]{
margin:0;font-size:0.9375rem;font-weight:700;letter-spacing:-0.01em;
}
[data-vibeui-block="gantt-007"] [data-part="hint"]{
margin:0;font-size:0.75rem;color:var(--vibeui-gantt-007-muted);
}
[data-vibeui-block="gantt-007"] [data-part="scroll"]{
overflow-x:auto;border:1px solid var(--vibeui-gantt-007-line);border-radius:0.625rem;
padding:0.5rem 0.625rem 0.625rem;
}
[data-vibeui-block="gantt-007"] [data-part="scroll"]:focus-visible{
outline:2px solid var(--vibeui-gantt-007-accent);outline-offset:2px;
}
[data-vibeui-block="gantt-007"] [data-part="shell"]{min-width:30rem}
[data-vibeui-block="gantt-007"] [data-part="row"]{
display:flex;align-items:center;gap:0.625rem;padding:0.1875rem 0;
}
[data-vibeui-block="gantt-007"] [data-part="label"]{
flex:none;width:9rem;font-size:0.75rem;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
}
/* Дорожка занимает всю оставшуюся ширину: полоса ставится в процентах
   от длины проекта, поэтому сетка колонок вообще не нужна. */
[data-vibeui-block="gantt-007"] [data-part="track"]{
position:relative;flex:1;height:1.25rem;
background:repeating-linear-gradient(
to right,
var(--vibeui-gantt-007-line) 0 1px,
transparent 1px calc(100% / var(--vibeui-gantt-007-ticks,10)));
}
[data-vibeui-block="gantt-007"] [data-part="bar"]{
position:absolute;top:0.125rem;bottom:0.125rem;
display:flex;align-items:center;padding:0 0.375rem;border-radius:0.25rem;
font-size:0.5625rem;line-height:1;white-space:nowrap;overflow:hidden;
font-variant-numeric:tabular-nums;
border:1px solid var(--vibeui-gantt-007-calm);
color:var(--vibeui-gantt-007-fg);
background:color-mix(in oklab,var(--vibeui-gantt-007-calm) 10%,var(--vibeui-gantt-007-bg));
}
/* Критическая задача отличается начертанием, а не оттенком: заливка,
   двойная рамка и жирный текст переживают и печать, и дальтонизм. */
[data-vibeui-block="gantt-007"] [data-part="bar"][data-critical="true"]{
border:2px solid var(--vibeui-gantt-007-accent);
background:color-mix(in oklab,var(--vibeui-gantt-007-accent) 22%,var(--vibeui-gantt-007-bg));
font-weight:750;
box-shadow:inset 0 0 0 1px var(--vibeui-gantt-007-bg);
}
[data-vibeui-block="gantt-007"] [data-part="row"][data-critical="true"] [data-part="label"]{
font-weight:700;
}
[data-vibeui-block="gantt-007"] [data-part="row"][data-critical="false"] [data-part="label"]{
color:var(--vibeui-gantt-007-muted);
}
/* Хвост запаса: докуда задачу можно сдвинуть, ничего не сломав. */
[data-vibeui-block="gantt-007"] [data-part="slack"]{
position:absolute;top:0.4375rem;bottom:0.4375rem;
border-top:1px dashed var(--vibeui-gantt-007-calm);
border-bottom:1px dashed var(--vibeui-gantt-007-calm);
}
[data-vibeui-block="gantt-007"] [data-part="scale"]{
display:flex;margin-left:9.625rem;
}
[data-vibeui-block="gantt-007"] [data-part="scale"] span{
flex:1;font-size:0.5625rem;color:var(--vibeui-gantt-007-muted);
font-variant-numeric:tabular-nums;
border-left:1px solid var(--vibeui-gantt-007-line);padding-left:0.1875rem;
}
[data-vibeui-block="gantt-007"] [data-part="tag"]{
margin-left:0.375rem;padding:0 0.1875rem;border-radius:0.1875rem;
background:var(--vibeui-gantt-007-accent);color:var(--vibeui-gantt-007-bg);
font-size:0.5rem;font-weight:700;letter-spacing:0.04em;
}
[data-vibeui-block="gantt-007"] [data-part="table"]{margin:0.75rem 0 0}
[data-vibeui-block="gantt-007"] summary{
cursor:pointer;font-size:0.75rem;font-weight:600;color:var(--vibeui-gantt-007-accent);
}
[data-vibeui-block="gantt-007"] summary:focus-visible{
outline:2px solid var(--vibeui-gantt-007-accent);outline-offset:2px;border-radius:0.25rem;
}
[data-vibeui-block="gantt-007"] table{
width:100%;margin-top:0.5rem;border-collapse:collapse;font-size:0.6875rem;
}
[data-vibeui-block="gantt-007"] th,
[data-vibeui-block="gantt-007"] td{
padding:0.25rem 0.375rem;text-align:left;
border-bottom:1px solid var(--vibeui-gantt-007-line);
}
[data-vibeui-block="gantt-007"] thead th{color:var(--vibeui-gantt-007-muted);font-weight:600}
[data-vibeui-block="gantt-007"] td{font-variant-numeric:tabular-nums}
[data-vibeui-block="gantt-007"] tr[data-critical="true"] th{font-weight:750}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="gantt-007"] *{animation:none!important;transition:none!important}}
`

const DAY = 86400000

const DEFAULT_TASKS: Gantt007Task[] = [
  { id: "brief", title: "Бриф", days: 2 },
  { id: "research", title: "Исследование", days: 5, after: ["brief"] },
  { id: "content", title: "Тексты", days: 4, after: ["brief"] },
  { id: "design", title: "Макеты", days: 6, after: ["research"] },
  { id: "build", title: "Вёрстка", days: 7, after: ["design", "content"] },
  { id: "photo", title: "Фотосъёмка", days: 3, after: ["brief"] },
  { id: "qa", title: "Проверка", days: 3, after: ["build", "photo"] },
  { id: "launch", title: "Запуск", days: 1, after: ["qa"] },
]

/**
 * Прямой и обратный проход по связям: ранние сроки, поздние сроки и запас.
 * Задача без запаса лежит на критическом пути.
 */
function schedule(tasks: Gantt007Task[]) {
  const earliest = new Map<string, number>()

  for (const task of tasks) {
    const start = (task.after ?? []).reduce(
      (max, id) => Math.max(max, earliest.get(id) ?? 0),
      0,
    )
    earliest.set(task.id, start + task.days)
  }

  const total = Math.max(...earliest.values())
  const latest = new Map<string, number>()

  for (const task of [...tasks].reverse()) {
    const finish = tasks
      .filter((other) => other.after?.includes(task.id))
      .reduce(
        (min, other) =>
          Math.min(min, (latest.get(other.id) ?? total) - other.days),
        total,
      )
    latest.set(task.id, finish)
  }

  return {
    total,
    rows: tasks.map((task) => {
      const finish = earliest.get(task.id) ?? task.days
      const start = finish - task.days
      const slack = (latest.get(task.id) ?? total) - finish

      return { task, start, finish, slack, critical: slack === 0 }
    }),
  }
}

/**
 * План с выделенным критическим путём: срок, запас и критичность считаются
 * из связей при рендере. Один файл, ноль зависимостей, клиентского JS нет.
 */
export function Gantt007({
  heading = "Критический путь",
  startDate = "2026-05-04",
  tasks = DEFAULT_TASKS,
  accent,
  className,
  style,
  ...props
}: Gantt007Props) {
  const origin = new Date(`${startDate}T00:00:00Z`).getTime()
  const { total, rows } = schedule(tasks)
  const ticks = Math.min(10, total)
  const step = Math.ceil(total / ticks)
  const critical = rows.filter((row) => row.critical).length

  const dateText = (offset: number) =>
    new Date(origin + offset * DAY).toISOString().slice(0, 10)

  const palette = {
    "--vibeui-gantt-007-ticks": ticks,
    ...(accent ? { "--vibeui-gantt-007-accent": accent } : null),
    ...style,
  } as CSSProperties

  return (
    <>
      <style href="vibeui-gantt-007" precedence="medium">
        {STYLES}
      </style>
      <section
        {...props}
        data-vibeui-block="gantt-007"
        aria-label={heading}
        className={className}
        style={palette}
      >
        <header data-part="head">
          <h3 data-part="heading">{heading}</h3>
          <p data-part="hint">
            {total} дней, без запаса {critical} из {rows.length}
          </p>
        </header>

        <div
          data-part="scroll"
          tabIndex={0}
          role="group"
          aria-label={`${heading}: диаграмма, прокручивается вбок`}
        >
          <div data-part="shell">
            <ol>
              {rows.map((row) => (
                <li
                  key={row.task.id}
                  data-part="row"
                  data-critical={String(row.critical)}
                >
                  <span data-part="label">
                    {row.task.title}
                    {row.critical ? <b data-part="tag">КП</b> : null}
                  </span>

                  <span data-part="track">
                    {row.slack > 0 ? (
                      <i
                        data-part="slack"
                        aria-hidden="true"
                        style={{
                          left: `${(row.finish / total) * 100}%`,
                          width: `${(row.slack / total) * 100}%`,
                        }}
                      />
                    ) : null}

                    <b
                      data-part="bar"
                      data-critical={String(row.critical)}
                      style={{
                        left: `${(row.start / total) * 100}%`,
                        width: `${(row.task.days / total) * 100}%`,
                      }}
                    >
                      {row.task.days} дн
                    </b>
                  </span>
                </li>
              ))}
            </ol>

            <div data-part="scale" aria-hidden="true">
              {Array.from({ length: ticks }, (_, index) => (
                <span key={index}>{index * step}</span>
              ))}
            </div>
          </div>
        </div>

        <details data-part="table">
          <summary>Сроки и запас таблицей</summary>
          <table>
            <thead>
              <tr>
                <th scope="col">Задача</th>
                <th scope="col">Начало</th>
                <th scope="col">Конец</th>
                <th scope="col">Запас, дн</th>
              </tr>
            </thead>
            <tbody>
              {rows.map((row) => (
                <tr key={row.task.id} data-critical={String(row.critical)}>
                  <th scope="row">
                    {row.task.title}
                    {row.critical ? " (критическая)" : ""}
                  </th>
                  <td>{dateText(row.start)}</td>
                  <td>{dateText(row.finish - 1)}</td>
                  <td>{row.slack}</td>
                </tr>
              ))}
            </tbody>
          </table>
        </details>
      </section>
    </>
  )
}