@konce-pt/react 0.18.0 → 0.18.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,9 +3,13 @@ import { type KptChartConfig } from '@konce-pt/chart';
3
3
  import { type KptI18nOverrides } from '../i18n/i18n-context.tsx';
4
4
  export interface KptChartProps extends Omit<ComponentPropsWithRef<'div'>, 'children'>, KptI18nOverrides {
5
5
  config: KptChartConfig;
6
- /** Szerokość viewBoxa w jednostkach SVG; wykres skaluje się do kontenera. */
6
+ /**
7
+ * Szerokość projektowa. Wykres rysuje się w pikselach swojego kontenera (mierzy go
8
+ * `ResizeObserver`), więc `width`/`height` wyznaczają proporcję (wysokość = szerokość kontenera
9
+ * x height / width) i rozmiar pierwszej klatki, zanim pomiar dotrze (SSR).
10
+ */
7
11
  width?: number;
8
- /** Wysokość viewBoxa w jednostkach SVG. */
12
+ /** Wysokość projektowa - patrz `width`. */
9
13
  height?: number;
10
14
  }
11
15
  /**
@@ -16,4 +20,4 @@ export interface KptChartProps extends Omit<ComponentPropsWithRef<'div'>, 'child
16
20
  * @example
17
21
  * <KptChart config={{ type: 'line', labels: ['Sty', 'Lut'], series: [{ name: 'Sprzedaż', data: [12, 19] }] }} />
18
22
  */
19
- export declare function KptChart({ config, width, height, locale, dictionary, className, style, ...rest }: Readonly<KptChartProps>): import("react").JSX.Element;
23
+ export declare function KptChart({ config, width: designWidth, height: designHeight, locale, dictionary, className, style, ...rest }: Readonly<KptChartProps>): import("react").JSX.Element;
@@ -1,14 +1,33 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { useEffect, useId, useRef, useState } from 'react';
4
- import { KptChartMotion, areaPath, axisBase, bandPath, bandScale, barLayout, chartScale, chartAccessibleName, chartDataTable, dataScale, deltaKind, domainScale, formatNumber, linePath, linearScale, pieSlices, plotArea, polarSlices, polarToCartesian, polygonPath, radarGrid, radarPoints, radialArea, sameChartShape, scaleRows, seriesColor, significantDelta, stackSeries, } from '@konce-pt/chart';
4
+ import { KptChartMotion, areaPath, axisBase, axisLabelIndices, bandPath, bandScale, barLayout, chartScale, chartAccessibleName, chartDataTable, chartMetrics, clampLabel, dataScale, deltaKind, domainScale, estimateLabelWidth, formatNumber, linePath, linearScale, pieSlices, plotArea, polarSlices, polarToCartesian, polygonPath, radarGrid, radarPoints, radialArea, sameChartShape, scaleRows, seriesColor, significantDelta, stackSeries, } from '@konce-pt/chart';
5
5
  import { cn } from "../internal/cn.js";
6
6
  import { useKptMotion } from "../motion/motion-context.js";
7
7
  import { useKptMessages } from "../i18n/i18n-context.js";
8
8
  import { KptChartLegend } from "./chart-legend.js";
9
9
  import { KptChartTooltip } from "./chart-tooltip.js";
10
- const VERTICAL_INSETS = { top: 12, right: 16, bottom: 28, left: 46 };
11
- const HORIZONTAL_INSETS = { top: 12, right: 16, bottom: 28, left: 76 };
10
+ /**
11
+ * Etykiety osi poziomej: wszystkie, jeśli się mieszczą, inaczej równy podział 9/5/3/2
12
+ * (`axisLabelIndices`). Pełna nazwa każdej kategorii zostaje w pigułce prowadnicy i w dymku.
13
+ */
14
+ function bottomLabels(labels, centers, y, width, m) {
15
+ const sizes = labels.map((l) => estimateLabelWidth(l, m.fontSize));
16
+ const shown = new Set(axisLabelIndices(centers, sizes));
17
+ return labels.map((text, i) => ({
18
+ at: centers[i] ?? 0,
19
+ x: clampLabel(centers[i] ?? 0, sizes[i] ?? 0, 0, width),
20
+ y,
21
+ text,
22
+ anchor: 'middle',
23
+ visible: shown.has(i),
24
+ }));
25
+ }
26
+ /** Etykiety osi pionowej (słupki poziome): ten sam podział, rozmiarem jest wysokość linii. */
27
+ function sideLabels(labels, centers, x, m) {
28
+ const shown = new Set(axisLabelIndices(centers, centers.map(() => m.categoryLine), 2));
29
+ return labels.map((text, i) => ({ at: centers[i] ?? 0, x, y: (centers[i] ?? 0) + m.baselineShift, text, anchor: 'end', visible: shown.has(i) }));
30
+ }
12
31
  /** Szerokość pasma smugi światła (jednostki viewBoxa) - połowa po każdej stronie czoła fali. */
13
32
  const SHEEN_HALF = 90;
14
33
  /** Zjechanie z wykresu: dymek czeka chwilę, zanim zgaśnie (powrót kursora go zatrzymuje). */
@@ -49,13 +68,46 @@ function styleFor(align) {
49
68
  * @example
50
69
  * <KptChart config={{ type: 'line', labels: ['Sty', 'Lut'], series: [{ name: 'Sprzedaż', data: [12, 19] }] }} />
51
70
  */
52
- export function KptChart({ config, width = 600, height = 360, locale, dictionary, className, style, ...rest }) {
71
+ export function KptChart({ config, width: designWidth = 600, height: designHeight = 360, locale, dictionary, className, style, ...rest }) {
53
72
  const { t, locale: activeLocale } = useKptMessages({ locale, dictionary });
54
73
  const descId = `${useId()}-desc`;
55
74
  // Alternatywa tekstowa (WCAG 1.1.1): nazwa SVG (`ariaLabel` -> tytuł -> i18n) i dane w ukrytej
56
75
  // wizualnie tabeli - dymek na najechanie jest dostępny tylko myszą.
57
76
  const accessibleName = chartAccessibleName(config, t('chart.label'));
58
77
  const dataTable = chartDataTable(config, activeLocale);
78
+ // viewBox równy pikselom ekranu: długości rosną z kontenerem, a czcionka (11px) i grubość kresek
79
+ // zostają prawdziwymi pikselami. Stały viewBox skalował się w całości - na szerokim ekranie
80
+ // etykiety puchły i rozmywały się, w wąskiej kolumnie malały do nieczytelności.
81
+ const canvasRef = useRef(null);
82
+ const [measured, setMeasured] = useState(null);
83
+ // Rozmiar tekstu osi z CSS płótna (`--kpt-chart-font-size`, domyślnie `--kpt-font-size-sm`).
84
+ // Tekst w SVG go dziedziczy, a marginesy, odstęp etykiet od osi, pigułka prowadnicy
85
+ // i przerzedzanie etykiet liczą się z niego. 14 to wartość domyślnego tokenu.
86
+ const [fontPx, setFontPx] = useState(14);
87
+ useEffect(() => {
88
+ const el = canvasRef.current;
89
+ if (!el || typeof ResizeObserver === 'undefined')
90
+ return;
91
+ const readFont = () => {
92
+ const px = parseFloat(getComputedStyle(el).fontSize);
93
+ if (px > 0)
94
+ setFontPx(px);
95
+ };
96
+ readFont();
97
+ // Wysokość wynika z szerokości, a szerokości kontenera nie zmienia wysokość wykresu - bez pętli.
98
+ // Rozmiar tekstu czytany przy każdej zmianie szerokości - zmiana tokenu zwykle idzie w parze ze zmianą układu.
99
+ const observer = new ResizeObserver(([entry]) => {
100
+ const w = Math.round(entry?.contentRect.width ?? 0);
101
+ if (w > 0)
102
+ setMeasured(w);
103
+ readFont();
104
+ });
105
+ observer.observe(el);
106
+ return () => observer.disconnect();
107
+ }, []);
108
+ const width = measured ?? designWidth;
109
+ const height = Math.round((width * designHeight) / designWidth);
110
+ const m = chartMetrics(fontPx);
59
111
  const [hidden, setHidden] = useState(new Set());
60
112
  const [highlighted, setHighlighted] = useState(null);
61
113
  /** Indeks kategorii pod kursorem - prowadnica crosshair w wykresie liniowym. */
@@ -125,7 +177,7 @@ export function KptChart({ config, width = 600, height = 360, locale, dictionary
125
177
  const sheen = (() => {
126
178
  if (!frame.sheen)
127
179
  return null;
128
- const plot = plotArea(width, height, horizontal ? HORIZONTAL_INSETS : VERTICAL_INSETS);
180
+ const plot = plotArea(width, height, horizontal ? m.horizontalInsets : m.verticalInsets);
129
181
  const start = horizontal ? plot.y : plot.x;
130
182
  const length = horizontal ? plot.height : plot.width;
131
183
  const mid = start - SHEEN_HALF + (length + 2 * SHEEN_HALF) * frame.sheen.progress;
@@ -213,7 +265,7 @@ export function KptChart({ config, width = 600, height = 360, locale, dictionary
213
265
  : chartScale(rows, { valueAxis: view.valueAxis, stacked: view.stacked });
214
266
  // Stos: punkty serii na narastającej sumie, pasmo od linii serii pod nią.
215
267
  const stack = view.stacked ? stackSeries(rows) : null;
216
- const plot = plotArea(width, height, VERTICAL_INSETS);
268
+ const plot = plotArea(width, height, m.verticalInsets);
217
269
  const yScale = linearScale(scale.min, scale.max, plot.y + plot.height, plot.y);
218
270
  const count = view.labels.length;
219
271
  const xAt = (i) => count > 1 ? plot.x + (i / (count - 1)) * plot.width : plot.x + plot.width / 2;
@@ -229,17 +281,13 @@ export function KptChart({ config, width = 600, height = 360, locale, dictionary
229
281
  y2: yScale(t),
230
282
  }));
231
283
  const valueLabels = scale.ticks.map((t) => ({
232
- x: plot.x - 8,
233
- y: yScale(t) + 3,
284
+ x: plot.x - m.sideGap,
285
+ y: yScale(t) + m.baselineShift,
234
286
  text: formatNumber(t),
235
287
  anchor: 'end',
236
288
  }));
237
- const catLabels = view.labels.map((label, i) => ({
238
- x: xAt(i),
239
- y: plot.y + plot.height + 18,
240
- text: label,
241
- anchor: 'middle',
242
- }));
289
+ const catLabels = bottomLabels(view.labels, view.labels.map((_, i) => xAt(i)), plot.y + plot.height + m.labelOffset, width, m);
290
+ const catTicks = view.labels.map((_, i) => ({ x: xAt(i), y: plot.y + plot.height }));
243
291
  const series = active.map(({ s, i }, k) => ({
244
292
  key: `ser:${s.name}`,
245
293
  name: s.name,
@@ -257,14 +305,16 @@ export function KptChart({ config, width = 600, height = 360, locale, dictionary
257
305
  : [];
258
306
  const crosshairCat = config.crosshair && activeIndex !== null ? catLabels[activeIndex] : undefined;
259
307
  // Prowadnica i pigułka jadą razem z dymkiem.
260
- const crossX = crosshairCat ? (frame.tip?.x ?? crosshairCat.x) : 0;
261
- const pillWidth = crosshairCat ? Math.max(28, crosshairCat.text.length * 7 + 16) : 0;
308
+ const crossX = crosshairCat ? (frame.tip?.x ?? crosshairCat.at) : 0;
309
+ const pillWidth = crosshairCat ? Math.max(m.pill.minWidth, estimateLabelWidth(crosshairCat.text, m.fontSize) + 2 * m.pill.padding) : 0;
310
+ // Pigułka przy skrajnej kategorii przesuwa się do środka zamiast wyjść poza viewBox.
311
+ const pillX = crosshairCat ? clampLabel(crossX, pillWidth, 0, width) : 0;
262
312
  /** Najbliższa kategoria w całym obszarze wykresu: wiersze wszystkich serii, dymek nad najwyższym punktem. */
263
313
  const onMove = (event) => {
264
314
  const p = svgPoint(event);
265
315
  if (!p || !series.length)
266
316
  return;
267
- const idx = nearestIndex(catLabels.map((l) => l.x), p.x);
317
+ const idx = nearestIndex(catLabels.map((l) => l.at), p.x);
268
318
  if (keepTip(idx))
269
319
  return;
270
320
  const hits = series.map((s) => ({ s, pt: s.points[idx] })).filter((e) => !!e.pt);
@@ -278,7 +328,7 @@ export function KptChart({ config, width = 600, height = 360, locale, dictionary
278
328
  if (moved && tipMotion())
279
329
  setRipple({ id: ++rippleId.current, x: top.pt.x, y: top.pt.y, color: top.s.color });
280
330
  };
281
- return (_jsxs(_Fragment, { children: [_jsx("g", { className: "kpt-chart__grid", children: grid.map((g, i) => (_jsx("line", { className: "kpt-chart__gridline", x1: g.x1, y1: g.y1, x2: g.x2, y2: g.y2 }, i))) }), crosshairCat ? (_jsx("line", { className: "kpt-chart__crosshair", x1: crossX, y1: plot.y, x2: crossX, y2: plot.y + plot.height })) : null, _jsx("g", { className: "kpt-chart__axis", children: [...valueLabels, ...catLabels].map((l, i) => (_jsx("text", { x: l.x, y: l.y, textAnchor: l.anchor, children: l.text }, i))) }), crosshairCat ? (_jsxs("g", { className: "kpt-chart__cat-active", children: [_jsx("rect", { x: crossX - pillWidth / 2, y: crosshairCat.y - 13, width: pillWidth, height: 18, rx: 9 }), _jsx("text", { x: crossX, y: crosshairCat.y, textAnchor: "middle", children: crosshairCat.text })] })) : null, ghostState
331
+ return (_jsxs(_Fragment, { children: [_jsx("g", { className: "kpt-chart__grid", children: grid.map((g, i) => (_jsx("line", { className: "kpt-chart__gridline", x1: g.x1, y1: g.y1, x2: g.x2, y2: g.y2 }, i))) }), crosshairCat ? (_jsx("line", { className: "kpt-chart__crosshair", x1: crossX, y1: plot.y, x2: crossX, y2: plot.y + plot.height })) : null, _jsx("g", { className: "kpt-chart__axis", children: [...valueLabels, ...catLabels.filter((l) => l.visible)].map((l, i) => (_jsx("text", { x: l.x, y: l.y, textAnchor: l.anchor, children: l.text }, i))) }), _jsx("g", { className: "kpt-chart__cat-ticks", "aria-hidden": "true", children: catTicks.map((tk, i) => (_jsx("circle", { className: cn('kpt-chart__cat-tick', activeIndex === i && 'kpt-chart__cat-tick--active'), r: 2, cx: tk.x, cy: tk.y }, i))) }), crosshairCat ? (_jsxs("g", { className: "kpt-chart__cat-active", children: [_jsx("rect", { x: pillX - pillWidth / 2, y: crosshairCat.y - m.pill.rise, width: pillWidth, height: m.pill.height, rx: m.pill.height / 2 }), _jsx("text", { x: pillX, y: crosshairCat.y, textAnchor: "middle", children: crosshairCat.text })] })) : null, ghostState
282
332
  ? series.map((s) => {
283
333
  const prev = ghostState.prev[s.i] ?? [];
284
334
  const next = ghostState.next[s.i] ?? [];
@@ -313,7 +363,7 @@ export function KptChart({ config, width = 600, height = 360, locale, dictionary
313
363
  const orientation = view.orientation ?? 'vertical';
314
364
  const vertical = orientation === 'vertical';
315
365
  const active = view.series.map((s, i) => ({ s, i })).filter(({ s }) => !hidden.has(`ser:${s.name}`));
316
- const plot = plotArea(width, height, vertical ? VERTICAL_INSETS : HORIZONTAL_INSETS);
366
+ const plot = plotArea(width, height, vertical ? m.verticalInsets : m.horizontalInsets);
317
367
  const radius = view.borderRadius ?? 4;
318
368
  const layout = barLayout(active.map(({ s }) => s.data), view.labels.length, plot, orientation, radius, frame.domain ? domainScale(frame.domain.target, frame.domain.min, frame.domain.max) : undefined, { stacked: view.stacked, valueAxis: view.valueAxis });
319
369
  const colors = active.map(({ s, i }) => seriesColor(s.color, i));
@@ -325,16 +375,17 @@ export function KptChart({ config, width = 600, height = 360, locale, dictionary
325
375
  ? layout.ticks.map((t) => ({ x1: plot.x, y1: t.pos, x2: plot.x + plot.width, y2: t.pos }))
326
376
  : layout.ticks.map((t) => ({ x1: t.pos, y1: plot.y, x2: t.pos, y2: plot.y + plot.height }));
327
377
  const valueLabels = vertical
328
- ? layout.ticks.map((t) => ({ x: plot.x - 8, y: t.pos + 3, text: formatNumber(t.value), anchor: 'end' }))
378
+ ? layout.ticks.map((t) => ({ x: plot.x - m.sideGap, y: t.pos + m.baselineShift, text: formatNumber(t.value), anchor: 'end' }))
329
379
  : layout.ticks.map((t) => ({
330
380
  x: t.pos,
331
- y: plot.y + plot.height + 18,
381
+ y: plot.y + plot.height + m.labelOffset,
332
382
  text: formatNumber(t.value),
333
383
  anchor: 'middle',
334
384
  }));
385
+ const centers = view.labels.map((_, i) => cat.center(i));
335
386
  const catLabels = vertical
336
- ? view.labels.map((l, i) => ({ x: cat.center(i), y: plot.y + plot.height + 18, text: l, anchor: 'middle' }))
337
- : view.labels.map((l, i) => ({ x: plot.x - 8, y: cat.center(i) + 3, text: l, anchor: 'end' }));
387
+ ? bottomLabels(view.labels, centers, plot.y + plot.height + m.labelOffset, width, m)
388
+ : sideLabels(view.labels, centers, plot.x - m.sideGap, m);
338
389
  const bars = layout.bars.map((b, order) => ({
339
390
  b,
340
391
  order,
@@ -371,7 +422,7 @@ export function KptChart({ config, width = 600, height = 360, locale, dictionary
371
422
  const p = svgPoint(event);
372
423
  if (!p)
373
424
  return;
374
- const idx = nearestIndex(catLabels.map((l) => (vertical ? l.x : l.y - 3)), vertical ? p.x : p.y);
425
+ const idx = nearestIndex(catLabels.map((l) => l.at), vertical ? p.x : p.y);
375
426
  if (keepTip(idx))
376
427
  return;
377
428
  const group = bars.filter((e) => e.b.categoryIndex === idx);
@@ -379,11 +430,11 @@ export function KptChart({ config, width = 600, height = 360, locale, dictionary
379
430
  if (!group.length || !center)
380
431
  return;
381
432
  const label = config.labels[idx] ?? '';
382
- const x = vertical ? center.x : Math.max(...group.map((e) => e.tipX));
383
- const y = vertical ? Math.min(...group.map((e) => e.tipY)) : center.y - 3;
433
+ const x = vertical ? center.at : Math.max(...group.map((e) => e.tipX));
434
+ const y = vertical ? Math.min(...group.map((e) => e.tipY)) : center.at;
384
435
  openTip(x, y, label, group.map((e) => row(e.name, label, e.b.value, e.color)), idx);
385
436
  };
386
- return (_jsxs(_Fragment, { children: [_jsx("g", { className: "kpt-chart__grid", children: grid.map((g, i) => (_jsx("line", { className: "kpt-chart__gridline", x1: g.x1, y1: g.y1, x2: g.x2, y2: g.y2 }, i))) }), _jsx("g", { className: "kpt-chart__axis", children: [...valueLabels, ...catLabels].map((l, i) => (_jsx("text", { x: l.x, y: l.y, textAnchor: l.anchor, children: l.text }, i))) }), ghostBars.length ? (_jsx("g", { className: "kpt-chart__ghost", "aria-hidden": "true", children: ghostBars.map((g) => (_jsx("path", { className: "kpt-chart__ghost-bar", d: g.path }, g.key))) })) : null, _jsx("g", { className: "kpt-chart__bars", "data-orientation": orientation, children: bars.map((e) => (_jsx("path", { className: cn('kpt-chart__bar', dim(e.key) && 'kpt-chart__series--dim'), d: e.b.path, style: { fill: e.color, ['--kpt-chart-i']: e.order } }, `${e.key}#${e.order}`))) }), ghostBars.length ? (_jsx("g", { className: "kpt-chart__ghost", "aria-hidden": "true", children: ghostBars.map((g) => (_jsx("rect", { className: "kpt-chart__delta-bar", "data-kind": g.kind, rx: 2, ...g.delta }, g.key))) })) : null, sheen
437
+ return (_jsxs(_Fragment, { children: [_jsx("g", { className: "kpt-chart__grid", children: grid.map((g, i) => (_jsx("line", { className: "kpt-chart__gridline", x1: g.x1, y1: g.y1, x2: g.x2, y2: g.y2 }, i))) }), _jsx("g", { className: "kpt-chart__axis", children: [...valueLabels, ...catLabels.filter((l) => l.visible)].map((l, i) => (_jsx("text", { x: l.x, y: l.y, textAnchor: l.anchor, children: l.text }, i))) }), ghostBars.length ? (_jsx("g", { className: "kpt-chart__ghost", "aria-hidden": "true", children: ghostBars.map((g) => (_jsx("path", { className: "kpt-chart__ghost-bar", d: g.path }, g.key))) })) : null, _jsx("g", { className: "kpt-chart__bars", "data-orientation": orientation, children: bars.map((e) => (_jsx("path", { className: cn('kpt-chart__bar', dim(e.key) && 'kpt-chart__series--dim'), d: e.b.path, style: { fill: e.color, ['--kpt-chart-i']: e.order } }, `${e.key}#${e.order}`))) }), ghostBars.length ? (_jsx("g", { className: "kpt-chart__ghost", "aria-hidden": "true", children: ghostBars.map((g) => (_jsx("rect", { className: "kpt-chart__delta-bar", "data-kind": g.kind, rx: 2, ...g.delta }, g.key))) })) : null, sheen
387
438
  ? bars.map((e) => (_jsx("path", { className: "kpt-chart__sheen-bar", d: e.b.path, fill: `url(#${sheenId})`, style: { opacity: sheen.opacity * 0.7 }, "aria-hidden": "true" }, `${e.key}#${e.order}`)))
388
439
  : null, _jsx("rect", { className: "kpt-chart__hover", x: plot.x, y: plot.y, width: plot.width, height: plot.height, onPointerMove: onMove, onPointerLeave: hideTip })] }));
389
440
  })();
@@ -394,7 +445,7 @@ export function KptChart({ config, width = 600, height = 360, locale, dictionary
394
445
  const first = view.series[0];
395
446
  if (!first)
396
447
  return null;
397
- const { cx, cy, radius } = radialArea(width, height, 8);
448
+ const { cx, cy, radius } = radialArea(width, height, m.radialPadding.plain);
398
449
  const entries = config.labels
399
450
  .map((label, i) => ({ label, i, value: first.data[i] ?? 0, color: seriesColor(first.color, i) }))
400
451
  .filter((entry) => !hidden.has(`cat:${entry.i}`));
@@ -416,7 +467,7 @@ export function KptChart({ config, width = 600, height = 360, locale, dictionary
416
467
  const radarVm = (() => {
417
468
  if (config.type !== 'radar')
418
469
  return null;
419
- const { cx, cy, radius } = radialArea(width, height, 30);
470
+ const { cx, cy, radius } = radialArea(width, height, m.radialPadding.labelled);
420
471
  const grid = radarGrid(config.labels.length, cx, cy, radius, 4);
421
472
  const active = view.series.map((s, i) => ({ s, i })).filter(({ s }) => !hidden.has(`ser:${s.name}`));
422
473
  let max = 1;
@@ -424,9 +475,9 @@ export function KptChart({ config, width = 600, height = 360, locale, dictionary
424
475
  for (const v of s.data)
425
476
  max = Math.max(max, v);
426
477
  return (_jsxs(_Fragment, { children: [_jsxs("g", { className: "kpt-chart__grid", children: [grid.rings.map((ring, i) => (_jsx("path", { className: "kpt-chart__ring", d: ring }, i))), grid.axes.map((axis, i) => (_jsx("line", { className: "kpt-chart__gridline", x1: cx, y1: cy, x2: axis.end.x, y2: axis.end.y }, i)))] }), _jsx("g", { className: "kpt-chart__axis", children: grid.axes.map((axis, i) => {
427
- const p = polarToCartesian(cx, cy, radius + 14, axis.angle);
478
+ const p = polarToCartesian(cx, cy, radius + m.radarLabelGap, axis.angle);
428
479
  const anchor = Math.abs(p.x - cx) < 1 ? 'middle' : p.x > cx ? 'start' : 'end';
429
- return (_jsx("text", { x: p.x, y: p.y + 3, textAnchor: anchor, children: config.labels[axis.labelIndex] ?? '' }, i));
480
+ return (_jsx("text", { x: p.x, y: p.y + m.baselineShift, textAnchor: anchor, children: config.labels[axis.labelIndex] ?? '' }, i));
430
481
  }) }), active.map(({ s, i }) => {
431
482
  const key = `ser:${s.name}`;
432
483
  const color = seriesColor(s.color, i);
@@ -455,5 +506,5 @@ export function KptChart({ config, width = 600, height = 360, locale, dictionary
455
506
  fontWeight: config.subtitle.font?.weight,
456
507
  paddingTop: config.subtitle.padding?.top,
457
508
  paddingBottom: config.subtitle.padding?.bottom,
458
- }, children: config.subtitle.text })) : null, _jsxs("div", { className: "kpt-chart__body", children: [_jsxs("div", { className: "kpt-chart__canvas", children: [_jsxs("svg", { className: "kpt-chart__svg", viewBox: `0 0 ${width} ${height}`, role: "img", "aria-label": accessibleName, "aria-describedby": config.description ? descId : undefined, preserveAspectRatio: "xMidYMid meet", children: [sheen ? (_jsx("defs", { children: _jsxs("linearGradient", { id: sheenId, gradientUnits: "userSpaceOnUse", x1: sheen.x1, x2: sheen.x2, y1: sheen.y1, y2: sheen.y2, children: [_jsx("stop", { offset: "0", className: "kpt-chart__sheen-stop", stopOpacity: 0 }), _jsx("stop", { offset: "0.5", className: "kpt-chart__sheen-stop", stopOpacity: 0.95 }), _jsx("stop", { offset: "1", className: "kpt-chart__sheen-stop", stopOpacity: 0 })] }) })) : null, lineVm, barVm, radarVm, pieVm] }), config.description ? (_jsx("p", { className: "kpt-chart__sr", id: descId, children: config.description })) : null, _jsxs("table", { className: "kpt-chart__sr", children: [_jsxs("caption", { children: [t('chart.dataTable'), ": ", accessibleName] }), _jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { scope: "col", children: t('chart.category') }), dataTable.columns.map((col, i) => (_jsx("th", { scope: "col", children: col }, i)))] }) }), _jsx("tbody", { children: dataTable.rows.map((row, i) => (_jsxs("tr", { children: [_jsx("th", { scope: "row", children: row.label }), row.values.map((value, j) => (_jsx("td", { children: value }, j)))] }, i))) })] }), _jsx(KptChartTooltip, { state: tooltip })] }), legendOpts.display ? (_jsx(KptChartLegend, { items: legendItems, position: legendOpts.position, align: legendOpts.align, pointStyle: legendOpts.pointStyle, title: legendOpts.title, onToggle: toggle, onHighlight: setHighlighted })) : null] })] }) }));
509
+ }, children: config.subtitle.text })) : null, _jsxs("div", { className: "kpt-chart__body", children: [_jsxs("div", { className: "kpt-chart__canvas", ref: canvasRef, children: [_jsxs("svg", { className: "kpt-chart__svg", viewBox: `0 0 ${width} ${height}`, role: "img", "aria-label": accessibleName, "aria-describedby": config.description ? descId : undefined, preserveAspectRatio: "xMidYMid meet", children: [sheen ? (_jsx("defs", { children: _jsxs("linearGradient", { id: sheenId, gradientUnits: "userSpaceOnUse", x1: sheen.x1, x2: sheen.x2, y1: sheen.y1, y2: sheen.y2, children: [_jsx("stop", { offset: "0", className: "kpt-chart__sheen-stop", stopOpacity: 0 }), _jsx("stop", { offset: "0.5", className: "kpt-chart__sheen-stop", stopOpacity: 0.95 }), _jsx("stop", { offset: "1", className: "kpt-chart__sheen-stop", stopOpacity: 0 })] }) })) : null, lineVm, barVm, radarVm, pieVm] }), config.description ? (_jsx("p", { className: "kpt-chart__sr", id: descId, children: config.description })) : null, _jsxs("table", { className: "kpt-chart__sr", children: [_jsxs("caption", { children: [t('chart.dataTable'), ": ", accessibleName] }), _jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { scope: "col", children: t('chart.category') }), dataTable.columns.map((col, i) => (_jsx("th", { scope: "col", children: col }, i)))] }) }), _jsx("tbody", { children: dataTable.rows.map((row, i) => (_jsxs("tr", { children: [_jsx("th", { scope: "row", children: row.label }), row.values.map((value, j) => (_jsx("td", { children: value }, j)))] }, i))) })] }), _jsx(KptChartTooltip, { state: tooltip })] }), legendOpts.display ? (_jsx(KptChartLegend, { items: legendItems, position: legendOpts.position, align: legendOpts.align, pointStyle: legendOpts.pointStyle, title: legendOpts.title, onToggle: toggle, onHighlight: setHighlighted })) : null] })] }) }));
459
510
  }
@@ -1,85 +1,102 @@
1
- # KptChart (@konce-pt/react/charts)
2
-
3
- An SVG chart driven by a single `config` object: line, bar, pie, doughnut, polarArea, radar.
4
- Import: `import { KptChart, type KptChartConfig } from '@konce-pt/react/charts';`
5
-
6
- A separate subpath, because the geometry and the chart styles are heavy next to the library core
7
- and not every app needs them.
8
-
9
- ## Props
10
- - `config`: `KptChartConfig` (required)
11
- - `width`, `height` - the viewBox in SVG units (600 x 360); the chart scales to its container
12
-
13
- The whole drawing lives in that coordinate system, TYPOGRAPHY INCLUDED: the axis labels are set in
14
- `font-size: 11px`, but inside an SVG that means 11 user units, not 11 device pixels. Render a
15
- 600-unit chart in a 300px column and the labels come out at roughly half size. `width`/`height` are
16
- therefore not just an aspect ratio - they are the text scale. In a narrow container shrink them
17
- proportionally (`width={300} height={180}`) instead of letting a 600-unit chart squeeze itself.
18
- - `className`, `ref` and every other `<div>` prop go to the host
19
-
20
- ## The config is the API
21
- `{ type, labels, series, points?, area?, interpolation?, orientation?, borderRadius?, innerRadius?,
22
- valueAxis?, stacked?, crosshair?, tooltip?, legend?, title?, subtitle?, animation?, ariaLabel?, description? }` - the same shape the Angular port
23
- takes, because both read it from `@konce-pt/chart`.
24
-
25
- ## Value axis and stacking (line, bar)
26
- - `valueAxis?: { zero?, min?, max? }` - the value axis starts at zero by default, so a bar and the area under a line
27
- show the size directly. `zero: false` fits the axis to the data (a weight of 79-90 kg is no longer a flat line under
28
- the ceiling); `min` / `max` pin an end of the axis exactly and win over `zero` - a value beyond a pinned end is
29
- cut at the edge. Bars and areas grow from zero, or from the end of the axis nearer to it
30
- - `stacked?: boolean` - stacked series: bars on top of each other in one band per category (positive values up,
31
- negative down; only the outer segment is rounded), lines as running totals with a band under each one (with
32
- `area`). The axis covers the totals; the tooltip shows the values of the series, not the totals
33
- - The rules live in `@konce-pt/chart`: `axisScale`, `chartScale`, `stackSeries`, `bandPath` (tested)
34
-
35
- ## The geometry lives in a package
36
- Scales, paths, bar layout and arcs come from `@konce-pt/chart` - pure functions with `node --test`
37
- coverage, shared with the Angular port. The component only turns them into SVG elements; there is
38
- no DOM manipulation and no canvas.
39
-
40
- ## Hover covers the whole plot
41
- Line and bar charts listen on an invisible surface over the plot: the pointer anywhere picks the nearest
42
- category, the tooltip lists every visible series there and travels from category to category without
43
- fading out in between. Slices and radar points react to the element itself. The bubble never leaves the
44
- chart - it is clamped by its real width.
45
-
46
- ## Motion: data updates and the tooltip
47
- A new `config` with the same type, categories and series names is a data update: the values travel as a
48
- wave from left to right on springs, with a light sheen at the wave front. Line and bar charts keep the
49
- previous state for 1.7 s - a dashed ghost, up/down segments (`--kpt-chart-delta-up` / `--kpt-chart-delta-down`)
50
- and the difference above points that changed by 2% or more - and the value axis waits for the ghost to
51
- fade before zooming to the new data. The tooltip, the crosshair and the category pill travel between
52
- categories, the digits roll on reels and the category name slides in from the direction of travel.
53
- `animation.update: false`, `animation.delta: false` and `tooltip.motion: false` switch the parts off;
54
- `animation.enabled: false`, `<KptMotionProvider enabled={false}>` and the system "reduce motion" setting
55
- switch off all code-driven motion. The frame loop and its numbers live in `@konce-pt/chart`
56
- (`KptChartMotion`, `springStep`, `reelCells`), shared with the Angular port.
57
-
58
- ## Interaction is local state
59
- Hidden series, the highlighted one, the tooltip, the active category and the motion frame are
60
- `useState` - the config stays a plain object the caller owns, and nothing is written back into it.
61
-
62
- ## Examples
63
- <KptChart config={{ type: 'line', labels: ['Jan', 'Feb'], series: [{ name: 'Sales', data: [12, 19] }] }} />
64
-
65
- <KptChart
66
- config={{
67
- type: 'doughnut',
68
- innerRadius: 0.62,
69
- labels: ['Done', 'In progress'],
70
- series: [{ name: 'Tasks', data: [42, 15] }],
71
- legend: { position: 'right' },
72
- }}
73
- />
74
-
75
- ## Tokens
76
- `--kpt-chart-{duration,stagger,delay}` for the animation; `--kpt-chart-sheen`,
77
- `--kpt-chart-delta-up` and `--kpt-chart-delta-down` for the data update; series colours come from
78
- `KPT_CHART_PALETTE` or `series.color`.
79
-
80
- ## Accessibility
81
- The `<svg>` is `role="img"` with a name: `config.ariaLabel`, else the title text, else `chart.label`
82
- from i18n. `config.description` (one sentence with the takeaway) is tied to it through
83
- `aria-describedby`. The data itself sits in a visually hidden `<table>` (categories in rows, series in
84
- columns, numbers in the active language), so a screen reader can walk the values - the tooltip is
85
- pointer-only. The legend is a list of real buttons, so series can be toggled from the keyboard.
1
+ # KptChart (@konce-pt/react/charts)
2
+
3
+ An SVG chart driven by a single `config` object: line, bar, pie, doughnut, polarArea, radar.
4
+ Import: `import { KptChart, type KptChartConfig } from '@konce-pt/react/charts';`
5
+
6
+ A separate subpath, because the geometry and the chart styles are heavy next to the library core
7
+ and not every app needs them.
8
+
9
+ ## Props
10
+ - `config`: `KptChartConfig` (required)
11
+ - `width`, `height` - the aspect ratio (default 600 x 360); the chart fills the width of its container
12
+
13
+ The chart draws in the pixels of its container: a `ResizeObserver` measures the canvas width and the
14
+ viewBox follows it, the height comes from the `width`/`height` ratio. Lengths grow and shrink with the
15
+ container, while the text (axis labels, 14px by default) and the line weights stay real pixels - the
16
+ same in a 300px column and on a 3000px screen. `width`/`height` are therefore only the aspect ratio and the
17
+ size of the first frame before the measurement arrives (SSR); there is no need to scale them by hand.
18
+ - `className`, `ref` and every other `<div>` prop go to the host
19
+
20
+ ## The config is the API
21
+ `{ type, labels, series, points?, area?, interpolation?, orientation?, borderRadius?, innerRadius?,
22
+ valueAxis?, stacked?, crosshair?, tooltip?, legend?, title?, subtitle?, animation?, ariaLabel?, description? }` - the same shape the Angular port
23
+ takes, because both read it from `@konce-pt/chart`.
24
+
25
+ ## Value axis and stacking (line, bar)
26
+ - `valueAxis?: { zero?, min?, max? }` - the value axis starts at zero by default, so a bar and the area under a line
27
+ show the size directly. `zero: false` fits the axis to the data (a weight of 79-90 kg is no longer a flat line under
28
+ the ceiling); `min` / `max` pin an end of the axis exactly and win over `zero` - a value beyond a pinned end is
29
+ cut at the edge. Bars and areas grow from zero, or from the end of the axis nearer to it
30
+ - `stacked?: boolean` - stacked series: bars on top of each other in one band per category (positive values up,
31
+ negative down; only the outer segment is rounded), lines as running totals with a band under each one (with
32
+ `area`). The axis covers the totals; the tooltip shows the values of the series, not the totals
33
+ - The rules live in `@konce-pt/chart`: `axisScale`, `chartScale`, `stackSeries`, `bandPath` (tested)
34
+
35
+ ## Category axis (line, bar)
36
+ - When the category labels do not fit, the axis shows an even subset instead of overlapping text: all of
37
+ them if they fit, otherwise the densest split that fits - 9 labels (eighths of the axis), 5 (first, 1/4,
38
+ middle, 3/4, last), 3, and finally the two ends only. Horizontal bars apply the same rule to the rows
39
+ - The choice is made in on-screen pixels (the chart measures its container), so a narrow column shows fewer
40
+ labels and a wide one more; the outer labels and the
41
+ crosshair pill are shifted inward so they never leave the chart
42
+ - A line chart marks every category with a small dot on the axis; the active one grows on hover. The full
43
+ name of any category stays in the crosshair pill and the tooltip
44
+ - The rules live in `@konce-pt/chart`: `axisLabelIndices`, `estimateLabelWidth`, `clampLabel` (tested)
45
+
46
+ ## The geometry lives in a package
47
+ Scales, paths, bar layout and arcs come from `@konce-pt/chart` - pure functions with `node --test`
48
+ coverage, shared with the Angular port. The component only turns them into SVG elements; there is
49
+ no DOM manipulation and no canvas.
50
+
51
+ ## Hover covers the whole plot
52
+ Line and bar charts listen on an invisible surface over the plot: the pointer anywhere picks the nearest
53
+ category, the tooltip lists every visible series there and travels from category to category without
54
+ fading out in between. Slices and radar points react to the element itself. The bubble never leaves the
55
+ chart - it is clamped by its real width.
56
+
57
+ ## Motion: data updates and the tooltip
58
+ A new `config` with the same type, categories and series names is a data update: the values travel as a
59
+ wave from left to right on springs, with a light sheen at the wave front. Line and bar charts keep the
60
+ previous state for 1.7 s - a dashed ghost, up/down segments (`--kpt-chart-delta-up` / `--kpt-chart-delta-down`)
61
+ and the difference above points that changed by 2% or more - and the value axis waits for the ghost to
62
+ fade before zooming to the new data. The tooltip, the crosshair and the category pill travel between
63
+ categories, the digits roll on reels and the category name slides in from the direction of travel.
64
+ `animation.update: false`, `animation.delta: false` and `tooltip.motion: false` switch the parts off;
65
+ `animation.enabled: false`, `<KptMotionProvider enabled={false}>` and the system "reduce motion" setting
66
+ switch off all code-driven motion. The frame loop and its numbers live in `@konce-pt/chart`
67
+ (`KptChartMotion`, `springStep`, `reelCells`), shared with the Angular port.
68
+
69
+ ## Interaction is local state
70
+ Hidden series, the highlighted one, the tooltip, the active category and the motion frame are
71
+ `useState` - the config stays a plain object the caller owns, and nothing is written back into it.
72
+
73
+ ## Examples
74
+ <KptChart config={{ type: 'line', labels: ['Jan', 'Feb'], series: [{ name: 'Sales', data: [12, 19] }] }} />
75
+
76
+ <KptChart
77
+ config={{
78
+ type: 'doughnut',
79
+ innerRadius: 0.62,
80
+ labels: ['Done', 'In progress'],
81
+ series: [{ name: 'Tasks', data: [42, 15] }],
82
+ legend: { position: 'right' },
83
+ }}
84
+ />
85
+
86
+ ## Tokens
87
+ `--kpt-chart-{duration,stagger,delay}` for the animation; `--kpt-chart-sheen`,
88
+ `--kpt-chart-delta-up` and `--kpt-chart-delta-down` for the data update; series colours come from
89
+ `KPT_CHART_PALETTE` or `series.color`.
90
+
91
+ Axis text size: `--kpt-chart-font-size` on the chart (default `--kpt-font-size-sm`, 14px - the size of the
92
+ tooltip and the legend). The SVG text inherits it, and the component reads the computed value to derive
93
+ everything that sits next to the text - the margins for the labels, the gap between a label and its axis,
94
+ the crosshair pill, the label thinning (`chartMetrics` in `@konce-pt/chart`). Override it in CSS, the
95
+ layout follows: `.revenue .kpt-chart-host { --kpt-chart-font-size: 12px; }`.
96
+
97
+ ## Accessibility
98
+ The `<svg>` is `role="img"` with a name: `config.ariaLabel`, else the title text, else `chart.label`
99
+ from i18n. `config.description` (one sentence with the takeaway) is tied to it through
100
+ `aria-describedby`. The data itself sits in a visually hidden `<table>` (categories in rows, series in
101
+ columns, numbers in the active language), so a screen reader can walk the values - the tooltip is
102
+ pointer-only. The legend is a list of real buttons, so series can be toggled from the keyboard.
@@ -1,4 +1,4 @@
1
1
  export { KptChart } from '../charts/chart.tsx';
2
2
  export type { KptChartProps } from '../charts/chart.tsx';
3
- export type { KptChartConfig, KptChartSeries, KptChartType, KptLineInterpolation, KptBarOrientation, KptLegendOptions, KptLegendPosition, KptLegendTitle, KptAlign, KptPointStyle, KptTitleOptions, KptTooltipOptions, KptTooltipContext, KptAnimationOptions, KptFont, KptPadding, } from '@konce-pt/chart';
3
+ export type { KptChartConfig, KptChartSeries, KptChartType, KptLineInterpolation, KptBarOrientation, KptLegendOptions, KptLegendPosition, KptLegendTitle, KptAlign, KptPointStyle, KptTitleOptions, KptTooltipOptions, KptTooltipContext, KptAnimationOptions, KptValueAxisOptions, KptFont, KptPadding, } from '@konce-pt/chart';
4
4
  export { KPT_CHART_PALETTE, seriesColor, linePath, areaPath, pieSlices, polarSlices, barLayout, radarGrid, radarPoints, niceScale, } from '@konce-pt/chart';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@konce-pt/react",
3
- "version": "0.18.0",
3
+ "version": "0.18.2",
4
4
  "description": "Open-source React 19 component library on the Koncept UI design system - the same kpt-* CSS layer and --kpt-* tokens as the Angular port. Zero runtime dependencies.",
5
5
  "license": "MIT",
6
6
  "author": "konce.pt",
@@ -65,19 +65,19 @@
65
65
  "leaflet": "^1.9.0"
66
66
  },
67
67
  "dependencies": {
68
- "@konce-pt/backdrop": "0.18.0",
69
- "@konce-pt/mention": "0.18.0",
70
- "@konce-pt/chat": "0.18.0",
71
- "@konce-pt/chart": "0.18.0",
72
- "@konce-pt/speech": "0.18.0",
73
- "@konce-pt/i18n": "0.18.0",
74
- "@konce-pt/icons": "0.18.0",
75
- "@konce-pt/motion": "0.18.0",
76
- "@konce-pt/roadmap": "0.18.0",
77
- "@konce-pt/datetime": "0.18.0",
78
- "@konce-pt/table": "0.18.0",
79
- "@konce-pt/tokens": "0.18.0",
80
- "@konce-pt/map": "0.18.0"
68
+ "@konce-pt/backdrop": "0.18.2",
69
+ "@konce-pt/chat": "0.18.2",
70
+ "@konce-pt/datetime": "0.18.2",
71
+ "@konce-pt/mention": "0.18.2",
72
+ "@konce-pt/chart": "0.18.2",
73
+ "@konce-pt/motion": "0.18.2",
74
+ "@konce-pt/i18n": "0.18.2",
75
+ "@konce-pt/roadmap": "0.18.2",
76
+ "@konce-pt/icons": "0.18.2",
77
+ "@konce-pt/speech": "0.18.2",
78
+ "@konce-pt/table": "0.18.2",
79
+ "@konce-pt/map": "0.18.2",
80
+ "@konce-pt/tokens": "0.18.2"
81
81
  },
82
82
  "devDependencies": {
83
83
  "@types/react": "^19.2.0",