@pushwoosh/kit-charts 0.0.5 → 0.0.7

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.
@@ -94,8 +94,15 @@ never falls below 1.
94
94
 
95
95
  `labels.allowOverlap` defaults to false on both axes and on data labels. A label
96
96
  whose box would run into one already placed loses its text — Highcharts hides
97
- the label and keeps the tick, and so do we. A data-label formatter returning an
98
- element cannot be measured, so it is always drawn.
97
+ the label and keeps the tick, and so do we.
98
+
99
+ An axis or data-label `formatter` may return a `ReactNode` and not just text; the
100
+ axis draws the node and measures the text inside it, so an edge label still turns
101
+ inwards and the y gutter still widens. A node the walk cannot read text out of —
102
+ a bare shape, a component — measures as a point: it is always drawn, and a text
103
+ label too close to it is dropped as it would be beside any other label. Inside an
104
+ axis label the node lands in an SVG `<text>`, so `<tspan>` and strings render and
105
+ an HTML element does not.
99
106
 
100
107
  ## Plot bands and lines
101
108
 
@@ -339,12 +346,15 @@ the value wrapped in `valuePrefix`/`valueSuffix`. `formatter` replaces the body
339
346
  returns a `ReactNode`; `ctx.points[].prev`/`.next` carry the neighbouring points, so
340
347
  a delta needs no extra lookup. `shared: true` collects every series at the hovered x.
341
348
 
342
- `headerFormat` and `pointFormatter` are taken under their Highcharts names, so a
343
- config that already carries them needs no rewrite: `headerFormat: ''` drops the
344
- header, a template fills `{point.key}`, `{point.x}`, `{point.y}` and
345
- `{series.name}` (markup in it is dropped — the chrome is the library's), and
346
- `pointFormatter` is handed the point and returns a `ReactNode`. `formatter` and
347
- `table` both outrank them. Golden case: `area/header-format`.
349
+ `headerFormat`, `pointFormat` and `pointFormatter` are taken under their Highcharts
350
+ names, so a config that already carries them needs no rewrite: `headerFormat: ''`
351
+ drops the header and `pointFormat` writes the row after the swatch. Both templates
352
+ fill `{point.key}`, `{point.x}`, `{point.y}`, `{point.value}` and `{series.name}`,
353
+ date an x through `{point.x:%b %d}` (a padded pattern is trimmed) and pass numbers
354
+ through the chart's `numberFormat`; markup in them is dropped, because the chrome
355
+ around a tooltip is the library's. `pointFormatter` is handed the point and returns
356
+ a `ReactNode`, and outranks `pointFormat`; `formatter` and `table` outrank all
357
+ three. Golden cases: `area/header-format`, `line/point-format-template`.
348
358
 
349
359
  `ctx.points[].pointMeta` is the hovered point's own `meta` — the third type
350
360
  parameter of `ChartOptions<TChartMeta, TSeriesMeta, TPointMeta>`, the same object
@@ -14,6 +14,13 @@ const ANCHOR = {
14
14
  center: 'middle',
15
15
  right: 'end'
16
16
  };
17
+ /** What a tick draws: its own node if a formatter made one, its text otherwise. */
18
+ const tickContent = tick => {
19
+ if (tick.node !== undefined && tick.node !== null) {
20
+ return tick.node;
21
+ }
22
+ return tick.label === '' ? null : tick.label;
23
+ };
17
24
  /** A tick label, wrapped in its explanation when the axis gave it one. */
18
25
  function TickLabel({
19
26
  hint,
@@ -125,23 +132,26 @@ export function XAxis({
125
132
  y2: crisp(baseline, options.lineWidth ?? 1),
126
133
  stroke: lineColor,
127
134
  strokeWidth: options.lineWidth ?? 1
128
- }), axis.ticks.map(tick => _jsxs("g", {
129
- children: [tickLength > 0 && tickWidth > 0 ? _jsx("line", {
130
- x1: crisp(tick.position, tickWidth),
131
- x2: crisp(tick.position, tickWidth),
132
- y1: crisp(baseline, tickWidth),
133
- y2: crisp(baseline + direction * tickLength, tickWidth),
134
- stroke: options.tickColor ?? lineColor,
135
- strokeWidth: tickWidth
136
- }) : null, labelsEnabled && tick.label ? _jsx(TickLabel, {
137
- hint: tick.hint,
138
- x: tick.position + (options.labels?.x ?? 0),
139
- y: labelY,
140
- anchor: tick.anchor,
141
- style: options.labels?.style,
142
- children: tick.label
143
- }) : null]
144
- }, `x-${tick.value}`)), _jsx(AxisTitle, {
135
+ }), axis.ticks.map(tick => {
136
+ const content = tickContent(tick);
137
+ return _jsxs("g", {
138
+ children: [tickLength > 0 && tickWidth > 0 ? _jsx("line", {
139
+ x1: crisp(tick.position, tickWidth),
140
+ x2: crisp(tick.position, tickWidth),
141
+ y1: crisp(baseline, tickWidth),
142
+ y2: crisp(baseline + direction * tickLength, tickWidth),
143
+ stroke: options.tickColor ?? lineColor,
144
+ strokeWidth: tickWidth
145
+ }) : null, labelsEnabled && content !== null ? _jsx(TickLabel, {
146
+ hint: tick.hint,
147
+ x: tick.position + (options.labels?.x ?? 0),
148
+ y: labelY,
149
+ anchor: tick.anchor,
150
+ style: options.labels?.style,
151
+ children: content
152
+ }) : null]
153
+ }, `x-${tick.value}`);
154
+ }), _jsx(AxisTitle, {
145
155
  title: options.title,
146
156
  x: plot.x + plot.width / 2,
147
157
  y: labelY + direction * ((options.title?.offset ?? 0) + X_AXIS_TITLE_GAP),
@@ -173,14 +183,17 @@ export function YAxis({
173
183
  const titleShift = outwards ? Math.ceil(columnWidth * (anchor === 'middle' ? 0.5 : 1)) + AXIS_TITLE_GAP : 0;
174
184
  const titleX = onRight ? edge + titleShift + (options.title?.offset ?? 0) : edge - titleShift - (options.title?.offset ?? 0);
175
185
  return _jsxs("g", {
176
- children: [labelsEnabled ? axis.ticks.map(tick => tick.label ? _jsx(TickLabel, {
177
- x: edge + (options.labels?.x ?? 0),
178
- y: tick.position + TEXT_BASELINE_SHIFT,
179
- anchor: anchor,
180
- hint: tick.hint,
181
- style: options.labels?.style,
182
- children: tick.label
183
- }, `y-${tick.value}`) : null) : null, _jsx(AxisTitle, {
186
+ children: [labelsEnabled ? axis.ticks.map(tick => {
187
+ const content = tickContent(tick);
188
+ return content === null ? null : _jsx(TickLabel, {
189
+ x: edge + (options.labels?.x ?? 0),
190
+ y: tick.position + TEXT_BASELINE_SHIFT,
191
+ anchor: anchor,
192
+ hint: tick.hint,
193
+ style: options.labels?.style,
194
+ children: content
195
+ }, `y-${tick.value}`);
196
+ }) : null, _jsx(AxisTitle, {
184
197
  title: options.title,
185
198
  x: titleX,
186
199
  y: titleY(options, plot),
@@ -33,32 +33,47 @@ const toCssSize = (value, fallback) => {
33
33
  }
34
34
  return typeof value === 'number' ? `${value}px` : value;
35
35
  };
36
- function tooltipHeader(ctx, headerFormat, formatX) {
36
+ const templateContext = (point, x) => ({
37
+ x,
38
+ y: point?.y ?? null,
39
+ value: point?.value ?? null,
40
+ seriesName: point?.seriesName ?? ''
41
+ });
42
+ function tooltipHeader(ctx, headerFormat, formatX, numberFormat) {
37
43
  if (headerFormat === undefined) {
38
44
  return formatX(ctx.x);
39
45
  }
40
- return applyTooltipTemplate(headerFormat, {
41
- x: ctx.x,
42
- y: ctx.points[0]?.y ?? null,
43
- seriesName: ctx.points[0]?.seriesName ?? ''
44
- });
46
+ return applyTooltipTemplate(headerFormat, templateContext(ctx.points[0], ctx.x), numberFormat);
45
47
  }
46
- function defaultTooltip(ctx, tooltip, formatX, formatValue) {
47
- const header = tooltipHeader(ctx, tooltip?.headerFormat, formatX);
48
+ function pointRow(point, tooltip, formatValue, numberFormat) {
48
49
  const pointFormatter = tooltip?.pointFormatter;
50
+ if (pointFormatter) {
51
+ return pointFormatter(point);
52
+ }
53
+ if (tooltip?.pointFormat !== undefined) {
54
+ return _jsxs(_Fragment, {
55
+ children: [_jsx(TooltipSwatch, {
56
+ "$color": point.color
57
+ }), applyTooltipTemplate(tooltip.pointFormat, templateContext(point, point.x), numberFormat)]
58
+ });
59
+ }
60
+ return _jsxs(_Fragment, {
61
+ children: [_jsx(TooltipSwatch, {
62
+ "$color": point.color
63
+ }), _jsx(TooltipName, {
64
+ children: point.seriesName
65
+ }), _jsx(TooltipValue, {
66
+ children: formatValue(point.value ?? point.y)
67
+ })]
68
+ });
69
+ }
70
+ function defaultTooltip(ctx, tooltip, formatX, formatValue, numberFormat) {
71
+ const header = tooltipHeader(ctx, tooltip?.headerFormat, formatX, numberFormat);
49
72
  return _jsxs("div", {
50
73
  children: [header ? _jsx(TooltipTitle, {
51
74
  children: header
52
75
  }) : null, ctx.points.map(point => _jsx(TooltipRow, {
53
- children: pointFormatter ? pointFormatter(point) : _jsxs(_Fragment, {
54
- children: [_jsx(TooltipSwatch, {
55
- "$color": point.color
56
- }), _jsx(TooltipName, {
57
- children: point.seriesName
58
- }), _jsx(TooltipValue, {
59
- children: formatValue(point.value ?? point.y)
60
- })]
61
- })
76
+ children: pointRow(point, tooltip, formatValue, numberFormat)
62
77
  }, `${point.seriesIndex}-${point.index}`))]
63
78
  });
64
79
  }
@@ -450,7 +465,7 @@ function ChartBody({
450
465
  return formatter(ctx);
451
466
  }
452
467
  const table = options.tooltip?.table;
453
- return table ? tableTooltip(ctx, table, formatX) : defaultTooltip(ctx, options.tooltip, formatX, formatValue);
468
+ return table ? tableTooltip(ctx, table, formatX) : defaultTooltip(ctx, options.tooltip, formatX, formatValue, model.numberFormat);
454
469
  }, [hover, model, options.title?.text, options.meta, options.tooltip, formatX, formatValue]);
455
470
  const hasData = model?.series.some(series => series.points.some(point => point.y !== null)) ?? false;
456
471
  const plotBorderWidth = options.chart?.plotBorderWidth ?? 0;
@@ -1,10 +1,12 @@
1
+ import { type NumberFormatter } from '../types/renderer';
1
2
  export interface TemplateContext {
2
3
  x: number | string;
3
4
  y: number | null;
5
+ value: number | null;
4
6
  seriesName: string;
5
7
  }
6
8
  /**
7
- * Fills a Highcharts header template. Markup in it is dropped rather than rendered:
8
- * the chrome around a tooltip is the library's, and a `<span>` in a config is styling.
9
+ * Fills a Highcharts header or point template. Markup in it is dropped rather than
10
+ * rendered: the chrome around a tooltip is the library's, and a `<span>` is styling.
9
11
  */
10
- export declare function applyTooltipTemplate(format: string, ctx: TemplateContext): string;
12
+ export declare function applyTooltipTemplate(format: string, ctx: TemplateContext, numberFormat?: NumberFormatter): string;
@@ -1,12 +1,14 @@
1
1
  import { formatTime } from './time';
2
2
  import { fillTokens } from './tokens';
3
- const substitute = (name, ctx) => {
3
+ const substitute = (name, ctx, numberFormat) => {
4
4
  switch (name) {
5
5
  case 'point.key':
6
6
  case 'point.x':
7
7
  return String(ctx.x);
8
8
  case 'point.y':
9
- return ctx.y === null ? '' : String(ctx.y);
9
+ return ctx.y === null ? '' : numberFormat(ctx.y);
10
+ case 'point.value':
11
+ return ctx.value === null ? '' : numberFormat(ctx.value);
10
12
  case 'series.name':
11
13
  return ctx.seriesName;
12
14
  default:
@@ -14,13 +16,13 @@ const substitute = (name, ctx) => {
14
16
  }
15
17
  };
16
18
  /**
17
- * Fills a Highcharts header template. Markup in it is dropped rather than rendered:
18
- * the chrome around a tooltip is the library's, and a `<span>` in a config is styling.
19
+ * Fills a Highcharts header or point template. Markup in it is dropped rather than
20
+ * rendered: the chrome around a tooltip is the library's, and a `<span>` is styling.
19
21
  */
20
- export function applyTooltipTemplate(format, ctx) {
22
+ export function applyTooltipTemplate(format, ctx, numberFormat = String) {
21
23
  const stripped = format.replace(/<br\s*\/?>/gi, ' ').replace(/<[^>]*>/g, '');
22
24
  return fillTokens(stripped, (name, pattern) => {
23
- const value = substitute(name, ctx);
25
+ const value = substitute(name, ctx, numberFormat);
24
26
  if (value === undefined) {
25
27
  return undefined;
26
28
  }
@@ -1,3 +1,3 @@
1
1
  export type TokenResolver = (name: string, pattern: string | undefined) => string | undefined;
2
- /** Fills `{name}` and `{name:pattern}` placeholders; an unresolved one is left as it stands. */
2
+ /** Fills `{name}` and `{name:pattern}`; an unresolved token is left as it stands. */
3
3
  export declare function fillTokens(format: string, resolve: TokenResolver): string;
@@ -1,5 +1,5 @@
1
1
  const TOKEN = /\{([a-z][a-z.]*)(?::([^}]*))?\}/gi;
2
- /** Fills `{name}` and `{name:pattern}` placeholders; an unresolved one is left as it stands. */
2
+ /** Fills `{name}` and `{name:pattern}`; an unresolved token is left as it stands. */
3
3
  export function fillTokens(format, resolve) {
4
- return format.replace(TOKEN, (match, name, pattern) => resolve(name, pattern) ?? match);
4
+ return format.replace(TOKEN, (match, name, pattern) => resolve(name, pattern?.trim() || undefined) ?? match);
5
5
  }
@@ -0,0 +1,3 @@
1
+ import { type ReactNode } from 'react';
2
+ /** The text a node prints, for the layout that has to measure it. */
3
+ export declare function nodeText(node: ReactNode): string;
@@ -0,0 +1,14 @@
1
+ import { isValidElement } from 'react';
2
+ /** The text a node prints, for the layout that has to measure it. */
3
+ export function nodeText(node) {
4
+ if (typeof node === 'string' || typeof node === 'number') {
5
+ return String(node);
6
+ }
7
+ if (Array.isArray(node)) {
8
+ return node.map(child => nodeText(child)).join('');
9
+ }
10
+ if (isValidElement(node)) {
11
+ return nodeText(node.props.children);
12
+ }
13
+ return '';
14
+ }
@@ -1,3 +1,4 @@
1
+ import { type ReactNode } from 'react';
1
2
  import { type NormalizedPoint } from '../data/points';
2
3
  import { type TitleEntry } from '../layout/title-layout';
3
4
  import { type Rect, type Size, type SlotGeometry, type Spacing } from '../types/geometry';
@@ -24,6 +25,8 @@ export interface AxisTick {
24
25
  value: number | string;
25
26
  position: number;
26
27
  label: string;
28
+ /** What the axis draws where a formatter returned something other than text. */
29
+ node?: ReactNode;
27
30
  anchor: TickAnchor;
28
31
  /** Sentence this tick explains itself with, opened from the label on hover. */
29
32
  hint?: string;
@@ -4,6 +4,7 @@ import { applyValueFormat, formatTime } from '../format/time';
4
4
  import { measureLegend } from '../layout/legend-layout';
5
5
  import { measureText, textHeight } from '../layout/measure-text';
6
6
  import { AXIS_GUTTER_PADDING, AXIS_LABEL_GAP, AXIS_TITLE_GAP, MARKER_REACH, VALUE_LABEL_HEADROOM, X_AXIS_LABEL_DROP } from '../layout/metrics';
7
+ import { nodeText } from '../layout/node-text';
7
8
  import { computeSlots } from '../layout/point-width';
8
9
  import { layoutTitles } from '../layout/title-layout';
9
10
  import { resolveAxes, resolveSeries, isSeriesVisible } from '../options/normalize';
@@ -17,6 +18,10 @@ export const hiddenKey = (seriesIndex, pointIndex) => pointIndex === undefined ?
17
18
  const DEFAULT_SPACING = [10, 10, 15, 10];
18
19
  const isBand = scale => scale.kind === 'band';
19
20
  const isContinuous = scale => scale.kind === 'linear' || scale.kind === 'time';
21
+ const asText = label => ({
22
+ label,
23
+ node: undefined
24
+ });
20
25
  function tickLabel(value, axis, timePattern, index, count, kind, numberFormat) {
21
26
  const formatter = axis.labels?.formatter;
22
27
  if (formatter) {
@@ -32,17 +37,20 @@ function tickLabel(value, axis, timePattern, index, count, kind, numberFormat) {
32
37
  }
33
38
  });
34
39
  if (typeof node === 'string' || typeof node === 'number') {
35
- return String(node);
40
+ return asText(String(node));
36
41
  }
37
- return '';
42
+ return {
43
+ label: nodeText(node),
44
+ node
45
+ };
38
46
  }
39
47
  if (axis.labels?.format) {
40
- return applyValueFormat(axis.labels.format, value);
48
+ return asText(applyValueFormat(axis.labels.format, value));
41
49
  }
42
50
  if (typeof value === 'number') {
43
- return timePattern ? formatTime(timePattern, value) : numberFormat(value);
51
+ return asText(timePattern ? formatTime(timePattern, value) : numberFormat(value));
44
52
  }
45
- return String(value);
53
+ return asText(String(value));
46
54
  }
47
55
  function collectCategories(axis, series, numeric = false) {
48
56
  if (axis.categories?.length) {
@@ -177,14 +185,17 @@ function dropOverlapping(ticks, axis, along) {
177
185
  // rather than order keeps one rule for both directions.
178
186
  let kept = null;
179
187
  return ticks.map(tick => {
180
- if (!tick.label) {
188
+ if (!tick.label && tick.node === undefined) {
181
189
  return tick;
182
190
  }
183
- const half = (along === 'x' ? measureText(tick.label, style) : textHeight(style)) / 2;
184
- if (kept && Math.abs(tick.position - kept.position) < half + kept.half + AXIS_LABEL_GAP) {
191
+ // A node with no text to read measures as a point rather than dropping out of
192
+ // the walk: it is always drawn, so a label after it still has to clear it.
193
+ const half = tick.label ? (along === 'x' ? measureText(tick.label, style) : textHeight(style)) / 2 : 0;
194
+ if (tick.label && kept && Math.abs(tick.position - kept.position) < half + kept.half + AXIS_LABEL_GAP) {
185
195
  return {
186
196
  ...tick,
187
- label: ''
197
+ label: '',
198
+ node: undefined
188
199
  };
189
200
  }
190
201
  kept = {
@@ -212,11 +223,15 @@ function axisTicks(axis, scale, hint, timePattern, along, numberFormat, bounds)
212
223
  const offset = isBand(scale) ? scale.bandwidth() / 2 : 0;
213
224
  const built = values.map((value, index) => {
214
225
  const position = (isBand(scale) ? scale.map(String(value)) : scale.map(Number(value))) + offset;
215
- const label = tickLabel(value, axis, timePattern, index, values.length, scale.kind, numberFormat);
226
+ const {
227
+ label,
228
+ node
229
+ } = tickLabel(value, axis, timePattern, index, values.length, scale.kind, numberFormat);
216
230
  return {
217
231
  value,
218
232
  position,
219
233
  label,
234
+ node,
220
235
  anchor: tickAnchor(label, position, axis, bounds),
221
236
  hint: axis.labels?.hint?.({
222
237
  value,
@@ -207,6 +207,8 @@ export interface TooltipOptions<TChartMeta = unknown, TSeriesMeta = unknown, TPo
207
207
  formatter?: TooltipFormatter<TChartMeta, TSeriesMeta, TPointMeta>;
208
208
  /** Highcharts' header template: `''` drops the header, `{point.key}` and friends fill. */
209
209
  headerFormat?: string;
210
+ /** Highcharts' per-point template, drawn after the swatch; `pointFormatter` wins. */
211
+ pointFormat?: string;
210
212
  /** One row per point, in place of the default swatch, name and value. */
211
213
  pointFormatter?: TooltipCellFormatter<TSeriesMeta, TPointMeta>;
212
214
  dateFormat?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pushwoosh/kit-charts",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
4
4
  "description": "Small React chart library on SVG, with a Highcharts-shaped configuration model",
5
5
  "license": "MIT",
6
6
  "repository": {