Charts

Line Chart

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

  • chart
  • line
  • svg
  • analytics

Превью

1440pxHost theme

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

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

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

Посетители за неделю
2 310
ПнВтСрЧтПтСбВс
Показать полную инструкцию

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

# Установка и размещение «chart-001» (Line Chart) из VibeUI

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

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

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

## 2. Что это
Линейный график на чистом SVG: путь считается из массива точек на сервере. Ни библиотеки графиков, ни клиентского JS — картинка видна до гидратации и печатается.

Линейный график: сетка из трёх линий, кривая с заливкой под ней, отметка максимума и подписи по оси. Путь SVG считается из массива точек — библиотека графиков не нужна, клиентского JS нет, график виден до гидратации и корректно печатается. Ноль зависимостей, один файл, своя палитра.

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

<Chart001
  title="Посетители за неделю"
  points={[{ label: "Пн", value: 1240 }, { label: "Вт", value: 1580 }]}
/>

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

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

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

## 5. Сохранить как установлено
- локальную палитру --vibeui-chart-001-* и не подменять её токенами вашей темы (bg-card, --chart-1 и подобными)
- нормировку значений с опущенной нижней границей: линия, лежащая на оси, читается как ноль
- vector-effect:non-scaling-stroke — без него растянутый по ширине график даёт неровную толщину линии
- preserveAspectRatio="none" вместе с viewBox: график обязан занимать всю ширину контейнера
- заливку под линией с низкой непрозрачностью: она удерживает взгляд на области значений
- aria-label у svg с диапазоном значений — иначе для скринридера это пустая картинка
- разметку figure / figcaption: подпись принадлежит графику, а не соседнему тексту
- блок <style> внутри компонента — в нём вся палитра и геометрия

## 6. Можно менять
- массив points: подпись и значение каждой точки
- title — заголовок графика
- unit — единица у крупного значения справа
- markPeak — отмечать ли максимум
- акцент через проп accent — цвет линии, заливки и значения
- высоту графика и внешние отступы через className

## 7. Правила
- Наведения и подсказок нет: это статичный график. Интерактивность требует клиентского кода и другого компонента.
- Точек больше двадцати линия не выдержит — агрегируйте данные до отрисовки.
- Не показывайте одним графиком два ряда: для сравнения нужны две линии и легенда, а это другой компонент.
- Не выносите CSS-переменные в globals.css: компонент обязан оставаться одним файлом.

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

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

npx shadcn@latest add https://vibeui.ru/r/chart-001.json
https://vibeui.ru/r/chart-001.json

Компонент самодостаточен: один файл, без зависимостей, собственная палитра в локальных переменных --vibeui-chart-001-*. Путь линии и заливки собирается из точек прямо в разметке: значения нормируются к области 320×120, нижняя граница опускается на четверть размаха ниже минимума, чтобы линия не лежала на оси. Толщина линии не искажается при растяжении благодаря vector-effect:non-scaling-stroke. Весь график — figure с figcaption и aria-label у svg.

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

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

import type { CSSProperties } from "react"

export type Chart001Point = {
  label: string
  value: number
}

export type Chart001Props = {
  points?: Chart001Point[]
  title?: string
  /** Единица у подписей значений: «₽», «%», « ч». */
  unit?: string
  /** Отмечать точку максимума подписью. */
  markPeak?: boolean
  accent?: string
  className?: string
  style?: CSSProperties
}

// Идея компонента: график рисуется разметкой, а не библиотекой. SVG считается
// на сервере из массива точек, поэтому клиентского JS нет вовсе, а сам график
// виден до гидратации и печатается. Заливка под линией — не украшение: она
// удерживает взгляд на области значений, а не на самой кривой.
const STYLES = `
:where([data-vibeui-block="chart-001"]){
--vibeui-chart-001-fg:oklch(0.24 0.016 265);
--vibeui-chart-001-muted:oklch(0.55 0.014 265);
--vibeui-chart-001-bg:oklch(1 0 0);
--vibeui-chart-001-border:oklch(0.91 0.006 265);
--vibeui-chart-001-grid:oklch(0.93 0.005 265);
--vibeui-chart-001-accent:oklch(0.55 0.2 262);
--vibeui-chart-001-radius:0.875rem;
--vibeui-chart-001-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="chart-001"]{
display:flex;flex-direction:column;gap:0.875rem;
width:100%;box-sizing:border-box;padding:1.125rem 1.25rem 1rem;
border:1px solid var(--vibeui-chart-001-border);
border-radius:var(--vibeui-chart-001-radius);
background:var(--vibeui-chart-001-bg);color:var(--vibeui-chart-001-fg);
font-family:var(--vibeui-chart-001-font);
}
[data-vibeui-block="chart-001"] [data-part="head"]{display:flex;align-items:baseline;justify-content:space-between;gap:1rem}
[data-vibeui-block="chart-001"] [data-part="title"]{margin:0;font-size:0.9375rem;font-weight:600}
[data-vibeui-block="chart-001"] [data-part="last"]{
font-size:1.125rem;font-weight:650;font-variant-numeric:tabular-nums;
color:var(--vibeui-chart-001-accent);
}
[data-vibeui-block="chart-001"] svg{display:block;width:100%;height:8rem;overflow:visible}
[data-vibeui-block="chart-001"] [data-part="grid"]{stroke:var(--vibeui-chart-001-grid);stroke-width:1}
[data-vibeui-block="chart-001"] [data-part="line"]{
fill:none;stroke:var(--vibeui-chart-001-accent);stroke-width:2;
stroke-linecap:round;stroke-linejoin:round;
vector-effect:non-scaling-stroke;
}
[data-vibeui-block="chart-001"] [data-part="area"]{fill:var(--vibeui-chart-001-accent);opacity:.1}
[data-vibeui-block="chart-001"] [data-part="peak"]{fill:var(--vibeui-chart-001-accent)}
[data-vibeui-block="chart-001"] [data-part="peak-ring"]{fill:var(--vibeui-chart-001-bg);stroke:var(--vibeui-chart-001-accent);stroke-width:2}
[data-vibeui-block="chart-001"] [data-part="axis"]{
display:flex;justify-content:space-between;gap:0.5rem;
font-size:0.6875rem;color:var(--vibeui-chart-001-muted);
}
[data-vibeui-block="chart-001"] [data-part="sr"]{
position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);
}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="chart-001"] *{animation:none!important;transition:none!important}}
`

const DEFAULT_POINTS: Chart001Point[] = [
  { label: "Пн", value: 1240 },
  { label: "Вт", value: 1580 },
  { label: "Ср", value: 1390 },
  { label: "Чт", value: 2180 },
  { label: "Пт", value: 2640 },
  { label: "Сб", value: 1980 },
  { label: "Вс", value: 2310 },
]

const WIDTH = 320
const HEIGHT = 120

/**
 * Линейный график на SVG: путь считается из точек, зависимостей нет.
 * Один файл, ноль зависимостей, собственная палитра.
 */
export function Chart001({
  points = DEFAULT_POINTS,
  title = "Посетители за неделю",
  unit = "",
  markPeak = true,
  accent,
  className,
  style,
}: Chart001Props) {
  const palette = {
    ...(accent ? { "--vibeui-chart-001-accent": accent } : null),
    ...style,
  } as CSSProperties

  const values = points.map((point) => point.value)
  const max = Math.max(...values)
  const min = Math.min(...values)
  // Нижняя граница опускается ниже минимума: линия не должна лежать на оси.
  const floor = min - (max - min) * 0.25
  const span = max - floor || 1

  const coords = points.map((point, index) => ({
    x: (index / Math.max(1, points.length - 1)) * WIDTH,
    y: HEIGHT - ((point.value - floor) / span) * HEIGHT,
    point,
  }))

  const line = coords
    .map((coord, index) => `${index === 0 ? "M" : "L"}${coord.x} ${coord.y}`)
    .join(" ")
  const area = `${line} L${WIDTH} ${HEIGHT} L0 ${HEIGHT} Z`
  const peak = coords.reduce((best, coord) =>
    coord.point.value > best.point.value ? coord : best,
  )
  const last = points[points.length - 1]

  return (
    <>
      <style href="vibeui-chart-001" precedence="medium">
        {STYLES}
      </style>
      <figure
        data-vibeui-block="chart-001"
        className={className}
        style={palette}
      >
        <div data-part="head">
          <figcaption data-part="title">{title}</figcaption>
          <span data-part="last">
            {last.value.toLocaleString("ru-RU")}
            {unit}
          </span>
        </div>
        <svg
          viewBox={`0 0 ${WIDTH} ${HEIGHT}`}
          preserveAspectRatio="none"
          role="img"
          aria-label={`${title}: от ${min}${unit} до ${max}${unit}`}
        >
          {[0, 0.5, 1].map((ratio) => (
            <line
              key={ratio}
              data-part="grid"
              x1={0}
              x2={WIDTH}
              y1={HEIGHT * ratio}
              y2={HEIGHT * ratio}
            />
          ))}
          <path data-part="area" d={area} />
          <path data-part="line" d={line} />
          {markPeak ? (
            <>
              <circle data-part="peak-ring" cx={peak.x} cy={peak.y} r={4.5} />
              <circle data-part="peak" cx={peak.x} cy={peak.y} r={2} />
            </>
          ) : null}
        </svg>
        <div data-part="axis">
          {points.map((point) => (
            <span key={point.label}>{point.label}</span>
          ))}
        </div>
      </figure>
    </>
  )
}