GrChartBar

Package: @feugene/granularity-chartscompanionGroup: misc

Machine-translated from the Russian original, not yet reviewed. Read the original

When to take it

  • how much every category has — orders by region, errors by service, revenue by product: columns are compared by eye more precisely than in any other type;
  • two or three metrics per category side by side — the series stand as a group, and groupPadding separates the groups;
  • the composition of every categorystacked folds the series into one column;
  • the shares inside a categorystacked: '100%' normalises the column to one, and the proportions are compared rather than the quantities;
  • long names of categoriesorientation: 'horizontal' lays the bars sideways, and a label is read as a line rather than as a slanted tail;
  • there are few categories — the vertical layout holds fifteen or twenty, beyond that the labels of the axis run into one another; the horizontal one counts height rather than width and calmly takes thirty or forty rows.

When to take something else

NeedTake
Show the course of a value over timeGrChartLine
Show the whole and the contribution of its parts over timeGrChartArea
Show the composition of one wholeGrChartPie
Compare the shape of a profile across several axesGrChartRadar
Compare the rows of a table with one anotherGrDataTable + GrSparkline

Hundreds of categories are not a chart but a list: the room runs out before the reader’s patience does (in the vertical layout it is the width, in the horizontal one the height of the page). Such data is shown as a table with sorting, and the top of it with columns.

Horizontal: bars sideways, the axes named by the data

<GrChartBar :series="series" orientation="horizontal" aria-label="Revenue by product" />

Only the layout changes: the stack, the group, '100%', the references, the legend, the tooltip and the hidden table work in exactly the same way.

The axes are named by the data rather than by the screen. yDomain, yTickFormat and yTickCount are always the value axis, wherever it lies; xTickFormat is always the labels of the categories. With showGrid: 'y' the value lines in the horizontal layout run vertically: the grid follows its own axis rather than the direction on the screen.

The alternative — introducing paired horizontalTickFormat and the rest — would give two props with one meaning and the question “and which of them is in charge right now” on every page. One imprecise name is cheaper than two precise ones.

The keyboard follows the eyes: / walk the categories from top to bottom, and / switch the series being read inside a category.

What the horizontal layout cannot do

dualAxis with orientation: 'horizontal' is switched off: the second value axis would become the top one, and the layout has no top margin. The prop does not fail — it simply does not apply, all of the series sit on one axis, and a warning is printed in dev. If two scales are needed, take the vertical layout.

Zero on the axis is not switchable

There is nothing to switch off in includeZero for columns: a bar is drawn from the baseline, and if the base is not zero, the length of the bar stops being the quantity. In a line chart there is a choice — there the course is read rather than the length.

Dimming instead of hiding

dimInactive dims the inactive series under the cursor instead of removing them. A removed series would shift its neighbours and change the width of the bars — the drawing would “move” from a single movement of the mouse.

A threshold is drawn as a reference, not as a series

A plan, a norm and the limit of the acceptable are the references prop rather than a series made of a constant: a constant series would enter the legend, would stretch the axis and would travel into the table as data. The details — ../model.md, the section “A reference is not a series”.

A stack and a group are different questions

A group answers “how much each has”, a stack “what each consists of”. Mixing them in one chart is not allowed: the reader does not know whether to compare the heights of the columns or the heights of the segments. If both answers are needed, that is two charts.

Two axes are a deliberate decision

Series of different orders (money and units) are readable on one chart only with two axes, and two axes let any pair be fitted to a visible correlation. That is why the axis: 'right' of a series does not work until dualAxis is switched on. The invariants — ../model.md, the section “The second value axis”.

Install

npm i @feugene/granularity-charts

Import

import { GrChartBar } from '@feugene/granularity-charts/components/GrChartBar'

API

The API for this component has not been generated yet: the showcase generator only covers the core so far. Until it does, the reference lives in the package documentation.

Examples 4

Basic

Basic
<script setup lang="ts">
import { ref } from 'vue'

// `GrChartBar` подставляется авто-импортом (`unplugin-vue-components`).

/**
 * Столбцы отвечают на вопрос «сколько», а не «куда движется»: величину
 * читают высотой полосы, и потому ось у них **всегда** от нуля. Обрежь её —
 * и разница в три процента нарисуется разницей в три раза.
 */
const categories = ['Q1', 'Q2', 'Q3', 'Q4']

const series = [
  { id: 'plan', label: 'План', x: categories, y: [4.2, 4.8, 5.1, 6.0] },
  { id: 'fact', label: 'Факт', x: categories, y: [3.9, 5.2, 4.7, 6.6] },
]

/**
 * Наведённая категория остаётся в полном цвете, соседние гаснут. Выключается,
 * когда график стоит рядом с таблицей и лишнее движение цвета мешает читать
 * соседей — тогда о выделенной категории говорит только тултип.
 */
const dimInactive = ref(true)
</script>

<template>
  <div class="grid gap-3">
    <div class="flex flex-wrap items-center justify-between gap-4">
      <span class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
        План и факт, млн ₽
      </span>

      <GrSwitch v-model="dimInactive" size="sm">Гасить остальные при наведении</GrSwitch>
    </div>

    <GrChartBar
      :series="series"
      :dim-inactive="dimInactive"
      :height="240"
      show-legend
      aria-label="План и факт по кварталам"
    />
  </div>
</template>

Horizontal

Horizontal
<script setup lang="ts">
import { computed, ref } from 'vue'

/**
 * Длинные названия категорий — единственный настоящий довод за горизонталь.
 *
 * Переключатель здесь не украшение: у вертикали те же подписи встают наклонным
 * хвостом и обрезаются, а у горизонтали читаются строкой. Данные, порог и
 * легенда при этом не меняются ни на байт.
 */
const departments = [
  'Клиентское обслуживание',
  'Разработка платформы',
  'Логистика и склад',
  'Финансы и отчётность',
  'Маркетинг и коммуникации',
  'Юридическая поддержка',
]

const series = [
  { id: 'closed', label: 'Закрыто', x: departments, y: [412, 388, 297, 214, 186, 92] },
  { id: 'open', label: 'В работе', x: departments, y: [64, 121, 48, 39, 57, 28] },
]

const orientation = ref<'horizontal' | 'vertical'>('horizontal')

const hint = computed(() => (
  orientation.value === 'horizontal'
    ? 'Подпись читается строкой, и категорий помещается втрое больше: горизонталь тратит высоту страницы, а её всегда можно прокрутить.'
    : 'Те же подписи по нижней оси: место кончается раньше названий, и читатель разбирает их по обрезкам.'
))
</script>

<template>
  <div class="grid gap-3">
    <div class="flex flex-wrap items-baseline justify-between gap-4">
      <span class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
        Заявки по отделам за квартал
      </span>

      <GrSegmented
        v-model="orientation"
        size="sm"
        :options="[
          { value: 'horizontal', label: 'Вбок' },
          { value: 'vertical', label: 'Вверх' },
        ]"
        aria-label="Раскладка столбцов"
      />
    </div>

    <GrChartBar
      :series="series"
      :orientation="orientation"
      stacked
      :height="320"
      :references="[{ axis: 'y', value: 350, label: 'План отдела', color: 'var(--gr-warning)' }]"
      show-legend
      aria-label="Заявки по отделам"
    />

    <p class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
      {{ hint }} Оси называются по данным, а не по экрану: порог задан как
      <code>axis: 'y'</code> в обеих раскладках — это всегда ось значений, и при
      горизонтали она рисует <strong>вертикальный</strong> пунктир.
    </p>
  </div>
</template>

Stacked

Stacked
<script setup lang="ts">
import { computed, ref } from 'vue'

/**
 * Три режима одного набора данных — и три разных вопроса.
 *
 * Рядом сравнивают **сегменты между собой**: у кого больше обращений. Стопка
 * показывает **целое и вклад**: сколько всего и из чего. Сто процентов
 * показывает **только структуру**: как менялись доли, когда абсолютные числа
 * растут у всех сразу и потому ничего не объясняют.
 */
const months = ['Май', 'Июн', 'Июл', 'Авг', 'Сен', 'Окт']

const series = [
  { id: 'bug', label: 'Баги', x: months, y: [120, 138, 129, 142, 118, 96] },
  { id: 'howto', label: 'Как сделать', x: months, y: [86, 92, 104, 121, 148, 173] },
  { id: 'billing', label: 'Оплата', x: months, y: [40, 44, 39, 52, 61, 74] },
]

const mode = ref<'group' | 'stack' | 'share'>('stack')

const stacked = computed(() => (
  mode.value === 'group' ? false : mode.value === 'share' ? '100%' as const : true
))

const hint = computed(() => ({
  group: 'Сегменты сравниваются между собой: видно, какой тип обращений крупнее в каждом месяце.',
  stack: 'Верх столбца — все обращения за месяц. Высота сегмента — вклад типа.',
  share: 'Абсолютные числа убраны: остаётся структура. Видно, как «как сделать» отъедает долю у багов.',
}[mode.value]))
</script>

<template>
  <div class="grid gap-3">
    <div class="flex flex-wrap items-baseline justify-between gap-4">
      <span class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
        Обращения в поддержку
      </span>

      <GrSegmented
        v-model="mode"
        size="sm"
        :options="[
          { value: 'group', label: 'Рядом' },
          { value: 'stack', label: 'Стопкой' },
          { value: 'share', label: '100%' },
        ]"
        aria-label="Режим столбцов"
      />
    </div>

    <GrChartBar
      :series="series"
      :stacked="stacked"
      :height="260"
      show-legend
      aria-label="Обращения в поддержку по типам"
    />

    <p class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
      {{ hint }} Тултип и скрытая таблица во всех трёх режимах показывают
      <strong>исходное число обращений</strong>, а не долю и не сумму под сегментом.
    </p>
  </div>
</template>

Zero

Zero
<script setup lang="ts">
/**
 * Полоса вниз от нуля — это минус, а не «столбец пониже».
 *
 * Скругляется только дальний от базовой линии конец, поэтому у отрицательной
 * полосы он снизу: столбец остаётся приклеенным к оси, а не висит над ней.
 */
const weeks = ['W40', 'W41', 'W42', 'W43', 'W44', 'W45', 'W46', 'W47']

const series = [{
  id: 'delta',
  label: 'Изменение к прошлой неделе',
  x: weeks,
  y: [-320, -140, 90, 210, -60, 340, 520, 410],
}]
</script>

<template>
  <div class="grid gap-3">
    <span class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
      Прирост активных пользователей, неделя к неделе
    </span>

    <GrChartBar
      :series="series"
      :height="220"
      aria-label="Прирост активных пользователей по неделям"
    />
  </div>
</template>

Accessibility

APG pattern
Серия читается той же парой стрелок; активная категория подсвечивается приглушением соседей

Full keyboard contract of the package

Component documentationAll components