GrChartArea

Package: @feugene/granularity-chartscompanionGroup: misc

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

When to take it

  • the whole and the contribution of its parts over time — revenue by channel, traffic by source, time by stage: stacked lays every series on the sum of the previous ones;
  • a single series whose volume matters — a fill down to zero reads as “how much in total” rather than “at what level”;
  • shares changing over timestacked: '100%' normalises every position to one, and the redistribution is visible rather than the growth;
  • series that have to show through — when overlaid without stacking, fill: 'auto' gives a gradient, and the lower series stays visible.

When to take something else

NeedTake
Show the course of a value, the volume does not matterGrChartLine
Compare quantities across categoriesGrChartBar
Show the composition of a whole at one momentGrChartPie
Compare the shape of a profile across several axesGrChartRadar
A trend in a table cellGrSparkline

With more than seven or eight categories a stack stops being readable: the bands become thinner than their labels, and they can no longer be compared by eye. That is a sign that GrChartBar with grouping is needed, or fewer series.

Above the threshold it is drawn by a canvas

The body of the chart can be drawn in two ways. Below the threshold it is SVG, above it a <canvas>; the component chooses itself, and there is no “which renderer” prop.

The threshold is counted in drawn vertices rather than in pointscanvasThreshold, 24,000 by default. The difference is substantial: decimation cuts every series down to the limit of the screen (about two vertices per pixel), so one series of a hundred thousand points is drawn as 2,400 vertices and costs milliseconds, while twenty series of 2,400 — the same 48,000 points — cost sixteen, that is, a whole frame.

The measured numbers at a width of 1200px, 2,400 vertices per series:

SeriesSVGCanvas
11.1 ms0.4 ms
86.7 ms0.6 ms
2016.3 ms1.7 ms

canvasThreshold: 0 switches the canvas off entirely — for the case where the drawing has to stay vector: printing, an SVG export, CSS of your own over the marks.

Accessibility does not change at all with the change of renderer. The cursor, the keyboard, the tooltip and the hidden table work with the overlay and with the full series rather than with the marks: the canvas does not exist for them, it is aria-hidden and does not catch the pointer. That was the condition on which a second renderer was allowed at all.

What the canvas draws differently. The grid moves into it as well: the canvas lies under the <svg> so that the axes and the active point stay on top — and the grid has to stay under the series. In an area chart the gradient fill becomes solid: the canvas does not understand url(#…), and across twenty areas a gradient reads as a mess anyway.

The fill is chosen by the mode rather than by taste

fill: 'auto' means a gradient for an overlay and a dense fill for a stack. The reason is different in each case: overlaid series need to show through one another, while the bands of a stack stand flush, and a gradient would blur the boundary between two neighbours — that is, the very place where one part ends and another begins.

The value of a point stays its own

In a stack the bounds of the band arrive on a point as separate fields, but its own y is not touched. The tooltip, the hidden table and the live region say “partners — one hundred and ninety” rather than “six hundred and fifty, because retail lies underneath”.

A hidden series drops out of the stack, and the ones above it come down. The value axis is computed from the tops of the bands and always includes zero: a stack from a non-zero base lies about the proportions.

A stack is built from the full series. A gap is the only place where it is not entirely honest: the sum at that position is understated. Carrying the last known value forward would be worse — that would be drawing data that did not exist.

A hundred per cent answers a different question

stacked: true shows the quantities and their sum, and stacked: '100%' only the distribution: the sum at every position equals one, and the band says how the shares changed rather than how much there was in total. One question has to be chosen: the reader does not know whether to compare the heights of the bands or their shares.

What is normalised is the drawing, not the data. The axis moves to percentages, while the tooltip, the hidden table and the live region still name the absolute quantities. A position with a zero sum has nothing to normalise against — there there is a zero rather than a NaN.

A long series is shortened in the drawing rather than in the data

decimate: 'auto' (the default) thins out the vertices of the path when there are more points than the screen can show. In a stack the set of abscissas is shared across all of the series — otherwise the bottom of the upper band would be interpolated over one set of X values and the top of the lower one over another, and the fill would come apart at the seams. The cursor, the keyboard, the tooltip and the hidden table know the full series; the details — ../model.md, the section “Decimation is a projection, not the data”.

Zooming along the abscissa

zoom switches the window on: 'brush' is a drag across the canvas, 'wheel' is the wheel, and 'both' is both. It is off by default.

<GrChartArea v-model:x-window="window" :series="series" zoom="both" />

<GrButton :disabled="window === null" @click="window = null">
The whole series
</GrButton>

The window selects the data rather than cropping the drawing: the positions, the cursor, the keyboard, the hidden table and the span of the value axis are computed from it. The practical consequence is that zooming reveals the fine structure that on the full series lies as a solid hatching: the decimation budget is computed from the width of the area, and there are fewer points in the window, so more vertices fall to each of them.

The keyboard works whenever zoom is on: +/- zoom towards the active point, Shift+arrows shift the window, and 0 returns the whole series. The union of the prop enumerates only pointer gestures — zooming has no switchable keyboard by design (../a11y.md, the section on zooming).

v-model:x-window is not mandatory — without a binding the chart zooms by itself. It is bound for something else: a synchronised pair of charts, a reset button next to the canvas, keeping the zoom in the address bar.

The bounds are accepted in the same form as the abscissas of the points (Date, an ISO string, a number) and go out as numbers. With a window set, activeIndex addresses it rather than the whole series.

The hidden table and its ceiling

The full data of the chart as rows — what a screen reader reads instead of the picture. By default dataTable: 'hidden': in the accessibility tree, invisible to the eye.

A row per point is readable while there are not many rows. The table therefore has a ceiling — dataTableMaxRows, 'auto' by default, that is, the budget of the drawing:

<GrChartArea :series="series" />                              <!-- auto: as drawn -->

<GrChartArea :series="series" :data-table-max-rows="200" />   <!-- a ceiling of your own -->

<GrChartArea :series="series" :data-table-max-rows="Infinity" /> <!-- always full -->

<GrChartArea :series="series" data-table="off" />             <!-- there is no table at all -->

Above the ceiling the table prints the same points that are drawn and says so with a note in the footer. The point-by-point completeness is not lost in the process: the arrows walk the whole series and pronounce every point. The table is responsible for the overview and the keyboard for the exact value; the details — ../model.md.

'auto' means “as many rows as can be read”. The budget of the drawing is taken when there is one; with decimate: 'never' there is none, and a fixed ceiling with a uniform sample remains.

Which of those is needed is decided by the application: keeping ten thousand rows in the accessibility tree is its right, but so is the cost of rebuilding such a table.

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”.

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 { GrChartArea } from '@feugene/granularity-charts/components/GrChartArea'

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 { computed, ref } from 'vue'

import { GR_TONES, GrButton, type GrTone } from '@feugene/granularity'

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

/**
 * Площадь вместо линии берут тогда, когда важен не только уровень, но и объём:
 * «сколько всего набежало». Заливка гаснет к базовой линии — сплошная плашка
 * утяжелила бы низ графика, где смотреть не на что.
 */
const traffic = Array.from({ length: 14 }, (_, day) => ({
  x: new Date(2026, 6, day + 1),
  y: Math.round(1800 + Math.sin(day / 2.2) * 420 + day * 55),
}))

/**
 * Линия и заливка красятся **ролями темы**, а не готовыми цветами: при
 * переключении light/dark ничего не пересоздаётся — значение роли меняет себя
 * само. Отсюда `var(--gr-…)`, а не hex.
 */
const toneColor: Record<GrTone, string> = {
  primary: 'var(--gr-primary)',
  neutral: 'var(--gr-secondary)',
  success: 'var(--gr-success)',
  warning: 'var(--gr-warning)',
  danger: 'var(--gr-danger)',
  info: 'var(--gr-info)',
  slate: 'var(--gr-slate)',
  azure: 'var(--gr-azure)',
}

const lineTone = ref<GrTone>('primary')
const fillTone = ref<GrTone>('primary')

const series = computed(() => [{
  id: 'sessions',
  label: 'Сессии',
  data: traffic,
  color: toneColor[lineTone.value],
  // Заливка — своя роль: линия обязана читаться на фоне, а площадь под ней —
  // не спорить с сеткой. Совпадение цветов частый случай, но не закон.
  fillColor: toneColor[fillTone.value],
}])
</script>

<template>
  <div class="grid gap-3">
    <span class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
      Сессии, две недели
    </span>

    <GrChartArea
      :series="series"
      :height="220"
      curve="smooth"
      include-zero
      aria-label="Сессии за две недели"
    />

    <div class="flex flex-wrap items-center gap-2">
      <span class="w-16 shrink-0 text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
        Линия
      </span>
      <GrButton
        v-for="tone in GR_TONES"
        :key="tone"
        size="sm"
        :variant="lineTone === tone ? 'primary' : 'outline'"
        :tone="tone"
        @click="lineTone = tone"
      >
        {{ tone }}
      </GrButton>
    </div>

    <div class="flex flex-wrap items-center gap-2">
      <span class="w-16 shrink-0 text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
        Заливка
      </span>
      <GrButton
        v-for="tone in GR_TONES"
        :key="tone"
        size="sm"
        :variant="fillTone === tone ? 'primary' : 'outline'"
        :tone="tone"
        @click="fillTone = tone"
      >
        {{ tone }}
      </GrButton>
    </div>
  </div>
</template>

Share

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

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

const series = [
  { id: 'free', label: 'Free', x: months, y: [820, 910, 1040, 1180, 1240, 1310] },
  { id: 'pro', label: 'Pro', x: months, y: [210, 246, 268, 331, 402, 486] },
  { id: 'team', label: 'Team', x: months, y: [42, 51, 58, 74, 96, 128] },
]

const normalized = ref(true)
</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>

      <GrSwitch v-model="normalized" size="sm">
          Сто процентов
        </GrSwitch>
    </div>

    <GrChartArea
      :series="series"
      :stacked="normalized ? '100%' : true"
      :height="280"
      show-legend
      data-table="visible"
      aria-label="Активные подписки по планам"
    />

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

Stacked

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

/**
 * Стек и наложение отвечают на разные вопросы, и переключатель ниже — самый
 * быстрый способ это увидеть.
 *
 * Стек показывает **целое и вклад каждого канала** в него: верхний край полос
 * это выручка компании. Наложение показывает **каналы сами по себе**: сравнить
 * два ряда между собой на стеке нельзя — второй ряд едет по горбам первого.
 */
const weeks = ['W27', 'W28', 'W29', 'W30', 'W31', 'W32', 'W33', 'W34']

const series = [
  { id: 'retail', label: 'Розница', x: weeks, y: [420, 460, 445, 510, 495, 540, 560, 585] },
  { id: 'partners', label: 'Партнёры', x: weeks, y: [180, 190, 230, 210, 245, 260, 250, 290] },
  { id: 'api', label: 'API', x: weeks, y: [60, 75, 90, 120, 140, 165, 190, 230] },
]

const mode = ref<'stacked' | 'overlay'>('stacked')

const hint = computed(() => (mode.value === 'stacked'
  ? 'Верхний край полос — выручка целиком. Высота полосы — вклад канала.'
  : 'Ряды сравниваются между собой: заливка просвечивает там, где они пересекаются.'))
</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: 'stacked', label: 'Стек' }, { value: 'overlay', label: 'Наложение' }]"
        aria-label="Режим площадей"
      />
    </div>

    <GrChartArea
      :series="series"
      :stacked="mode === 'stacked'"
      :height="240"
      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 months = ['Янв', 'Фев', 'Мар', 'Апр', 'Май', 'Июн', 'Июл', 'Авг', 'Сен']

const series = [{
  id: 'profit',
  label: 'Прибыль',
  x: months,
  y: [-140, -95, -30, 25, 60, 40, 110, 165, 210],
}]
</script>

<template>
  <div class="grid gap-3">
    <span class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
      Операционная прибыль, тыс. ₽
    </span>

    <GrChartArea
      :series="series"
      :height="220"
      curve="smooth"
      aria-label="Операционная прибыль по месяцам"
    />
  </div>
</template>

Accessibility

APG pattern
При нескольких сериях ↑/↓ переключают читаемую серию

Full keyboard contract of the package

Component documentationAll components