@pushwoosh/kit-charts 0.0.4 → 0.0.6

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.
@@ -339,6 +339,16 @@ the value wrapped in `valuePrefix`/`valueSuffix`. `formatter` replaces the body
339
339
  returns a `ReactNode`; `ctx.points[].prev`/`.next` carry the neighbouring points, so
340
340
  a delta needs no extra lookup. `shared: true` collects every series at the hovered x.
341
341
 
342
+ `headerFormat`, `pointFormat` and `pointFormatter` are taken under their Highcharts
343
+ names, so a config that already carries them needs no rewrite: `headerFormat: ''`
344
+ drops the header and `pointFormat` writes the row after the swatch. Both templates
345
+ fill `{point.key}`, `{point.x}`, `{point.y}`, `{point.value}` and `{series.name}`,
346
+ date an x through `{point.x:%b %d}` (a padded pattern is trimmed) and pass numbers
347
+ through the chart's `numberFormat`; markup in them is dropped, because the chrome
348
+ around a tooltip is the library's. `pointFormatter` is handed the point and returns
349
+ a `ReactNode`, and outranks `pointFormat`; `formatter` and `table` outrank all
350
+ three. Golden cases: `area/header-format`, `line/point-format-template`.
351
+
342
352
  `ctx.points[].pointMeta` is the hovered point's own `meta` — the third type
343
353
  parameter of `ChartOptions<TChartMeta, TSeriesMeta, TPointMeta>`, the same object
344
354
  `onPointClick` reports. `meta` beside it stays the series'. A bar whose height is a
@@ -367,10 +377,22 @@ With `fillOpacity` on the body and `states.hover.fillOpacity` to deepen it, that
367
377
  is the funnel look: the series keeps its real colour, so the legend swatch and
368
378
  the tooltip dot stay readable, and only the bar is washed out.
369
379
 
380
+ `states.hover.color` swaps the fill on hover instead of lightening it, for a body
381
+ too pale for `brightness` to show on; it also wins over a per-point colour, since
382
+ one colour on hover is the point of asking. `column` and `heatmap` read it.
383
+ Golden case: `column/hover-color`.
384
+
370
385
  A data label whose text carries `\n` is drawn as stacked lines, styled by
371
386
  `style` for the first and `secondaryStyle` for the rest. It is one label: it
372
387
  moves, measures and hides as one.
373
388
 
389
+ ## Series opacity
390
+
391
+ `series.opacity` fades a whole series layer — body, border and data labels — where
392
+ `fillOpacity` reaches only the fill a renderer paints. A hover's
393
+ `states.inactive.opacity` overrides it while it lasts. Golden case:
394
+ `column/series-opacity`.
395
+
374
396
  ## Areas
375
397
 
376
398
  `fillColor` paints the body under the line, where the fill is a token of its own
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
3
  import { Grid, XAxis, YAxis } from './Axes';
4
4
  import { Canvas, Centred, Container, PinnedAxes, ResetZoom, ScrollBox, SeriesLayer, TooltipCell, TooltipDivider, TooltipHeadCell, TooltipName, TooltipRow, TooltipSeriesCell, TooltipSeriesInner, TooltipSwatch, TooltipTable, TooltipTitle, TooltipValue, ZoomBand } from './Chart.styles';
@@ -10,6 +10,7 @@ import { Titles } from './Titles';
10
10
  import { Tooltip } from './Tooltip';
11
11
  import { useSize } from './useSize';
12
12
  import { MOTION_MS } from '../animation/motion';
13
+ import { applyTooltipTemplate } from '../format/template';
13
14
  import { formatTime } from '../format/time';
14
15
  import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from '../layout/measure-text';
15
16
  import { buildChartModel, hiddenKey, isContinuous } from '../model/build-model';
@@ -32,18 +33,47 @@ const toCssSize = (value, fallback) => {
32
33
  }
33
34
  return typeof value === 'number' ? `${value}px` : value;
34
35
  };
35
- function defaultTooltip(ctx, formatX, formatValue) {
36
- return _jsxs("div", {
37
- children: [_jsx(TooltipTitle, {
38
- children: formatX(ctx.x)
39
- }), ctx.points.map(point => _jsxs(TooltipRow, {
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) {
43
+ if (headerFormat === undefined) {
44
+ return formatX(ctx.x);
45
+ }
46
+ return applyTooltipTemplate(headerFormat, templateContext(ctx.points[0], ctx.x), numberFormat);
47
+ }
48
+ function pointRow(point, tooltip, formatValue, numberFormat) {
49
+ const pointFormatter = tooltip?.pointFormatter;
50
+ if (pointFormatter) {
51
+ return pointFormatter(point);
52
+ }
53
+ if (tooltip?.pointFormat !== undefined) {
54
+ return _jsxs(_Fragment, {
40
55
  children: [_jsx(TooltipSwatch, {
41
56
  "$color": point.color
42
- }), _jsx(TooltipName, {
43
- children: point.seriesName
44
- }), _jsx(TooltipValue, {
45
- children: formatValue(point.value ?? point.y)
46
- })]
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);
72
+ return _jsxs("div", {
73
+ children: [header ? _jsx(TooltipTitle, {
74
+ children: header
75
+ }) : null, ctx.points.map(point => _jsx(TooltipRow, {
76
+ children: pointRow(point, tooltip, formatValue, numberFormat)
47
77
  }, `${point.seriesIndex}-${point.index}`))]
48
78
  });
49
79
  }
@@ -435,8 +465,8 @@ function ChartBody({
435
465
  return formatter(ctx);
436
466
  }
437
467
  const table = options.tooltip?.table;
438
- return table ? tableTooltip(ctx, table, formatX) : defaultTooltip(ctx, formatX, formatValue);
439
- }, [hover, model, options.title?.text, options.meta, options.tooltip?.formatter, options.tooltip?.table, formatX, formatValue]);
468
+ return table ? tableTooltip(ctx, table, formatX) : defaultTooltip(ctx, options.tooltip, formatX, formatValue, model.numberFormat);
469
+ }, [hover, model, options.title?.text, options.meta, options.tooltip, formatX, formatValue]);
440
470
  const hasData = model?.series.some(series => series.points.some(point => point.y !== null)) ?? false;
441
471
  const plotBorderWidth = options.chart?.plotBorderWidth ?? 0;
442
472
  const scrolls = canvas.width > size.width || canvas.height > size.height;
@@ -537,6 +567,7 @@ function ChartBody({
537
567
  "$collapse": series.renderer.exit === 'collapse',
538
568
  "$selfAnimated": series.renderer.animatesGeometry === true,
539
569
  "$inactive": inactiveOpacity(series),
570
+ "$opacity": series.options.opacity,
540
571
  "$motionMs": motionOf(series),
541
572
  "$cursor": series.options.cursor ?? 'default',
542
573
  "aria-hidden": series.hidden,
@@ -33,6 +33,8 @@ export declare const SeriesLayer: import("styled-components").StyledComponent<"g
33
33
  $selfAnimated: boolean;
34
34
  /** Opacity while another series is hovered, from `states.inactive`. */
35
35
  $inactive?: number;
36
+ /** `series.opacity`: the whole series faded, border and labels included. */
37
+ $opacity?: number;
36
38
  /** `series.animation`; the chart clock unless this series asked for its own. */
37
39
  $motionMs: number;
38
40
  $cursor: string;
@@ -56,12 +56,13 @@ export const SeriesLayer = styled.g.withConfig({
56
56
  componentId: "sc-1pit1b9-4"
57
57
  })(["opacity:", ";pointer-events:", ";cursor:", ";transition:opacity ", "ms ", ";", " @media (prefers-reduced-motion:reduce){transition:none;path,rect,circle,text{transition:none;}}"], ({
58
58
  $hidden,
59
- $inactive
59
+ $inactive,
60
+ $opacity
60
61
  }) => {
61
62
  if ($hidden) {
62
63
  return 0;
63
64
  }
64
- return $inactive ?? 1;
65
+ return $inactive ?? $opacity ?? 1;
65
66
  }, ({
66
67
  $hidden
67
68
  }) => $hidden ? 'none' : 'auto', ({
@@ -0,0 +1,12 @@
1
+ import { type NumberFormatter } from '../types/renderer';
2
+ export interface TemplateContext {
3
+ x: number | string;
4
+ y: number | null;
5
+ value: number | null;
6
+ seriesName: string;
7
+ }
8
+ /**
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.
11
+ */
12
+ export declare function applyTooltipTemplate(format: string, ctx: TemplateContext, numberFormat?: NumberFormatter): string;
@@ -0,0 +1,32 @@
1
+ import { formatTime } from './time';
2
+ import { fillTokens } from './tokens';
3
+ const substitute = (name, ctx, numberFormat) => {
4
+ switch (name) {
5
+ case 'point.key':
6
+ case 'point.x':
7
+ return String(ctx.x);
8
+ case 'point.y':
9
+ return ctx.y === null ? '' : numberFormat(ctx.y);
10
+ case 'point.value':
11
+ return ctx.value === null ? '' : numberFormat(ctx.value);
12
+ case 'series.name':
13
+ return ctx.seriesName;
14
+ default:
15
+ return undefined;
16
+ }
17
+ };
18
+ /**
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.
21
+ */
22
+ export function applyTooltipTemplate(format, ctx, numberFormat = String) {
23
+ const stripped = format.replace(/<br\s*\/?>/gi, ' ').replace(/<[^>]*>/g, '');
24
+ return fillTokens(stripped, (name, pattern) => {
25
+ const value = substitute(name, ctx, numberFormat);
26
+ if (value === undefined) {
27
+ return undefined;
28
+ }
29
+ const dates = name === 'point.key' || name === 'point.x';
30
+ return pattern && dates && typeof ctx.x === 'number' ? formatTime(pattern, ctx.x) : value;
31
+ }).trim();
32
+ }
@@ -1,3 +1,4 @@
1
+ import { fillTokens } from './tokens';
1
2
  const MONTHS_SHORT = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
2
3
  const MONTHS_LONG = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
3
4
  const DAYS_SHORT = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
@@ -29,10 +30,10 @@ export function formatTime(pattern, timestamp) {
29
30
  });
30
31
  }
31
32
  export function applyValueFormat(format, value) {
32
- return format.replace(/\{value(?::([^}]*))?\}/g, (_match, pattern) => {
33
- if (pattern && typeof value === 'number') {
34
- return formatTime(pattern, value);
33
+ return fillTokens(format, (name, pattern) => {
34
+ if (name !== 'value') {
35
+ return undefined;
35
36
  }
36
- return String(value);
37
+ return pattern && typeof value === 'number' ? formatTime(pattern, value) : String(value);
37
38
  });
38
39
  }
@@ -0,0 +1,3 @@
1
+ export type TokenResolver = (name: string, pattern: string | undefined) => string | undefined;
2
+ /** Fills `{name}` and `{name:pattern}`; an unresolved token is left as it stands. */
3
+ export declare function fillTokens(format: string, resolve: TokenResolver): string;
@@ -0,0 +1,5 @@
1
+ const TOKEN = /\{([a-z][a-z.]*)(?::([^}]*))?\}/gi;
2
+ /** Fills `{name}` and `{name:pattern}`; an unresolved token is left as it stands. */
3
+ export function fillTokens(format, resolve) {
4
+ return format.replace(TOKEN, (match, name, pattern) => resolve(name, pattern?.trim() || undefined) ?? match);
5
+ }
@@ -205,6 +205,12 @@ export interface TooltipOptions<TChartMeta = unknown, TSeriesMeta = unknown, TPo
205
205
  shape?: 'callout' | 'box';
206
206
  followPointer?: boolean;
207
207
  formatter?: TooltipFormatter<TChartMeta, TSeriesMeta, TPointMeta>;
208
+ /** Highcharts' header template: `''` drops the header, `{point.key}` and friends fill. */
209
+ headerFormat?: string;
210
+ /** Highcharts' per-point template, drawn after the swatch; `pointFormatter` wins. */
211
+ pointFormat?: string;
212
+ /** One row per point, in place of the default swatch, name and value. */
213
+ pointFormatter?: TooltipCellFormatter<TSeriesMeta, TPointMeta>;
208
214
  dateFormat?: string;
209
215
  valuePrefix?: string;
210
216
  valueSuffix?: string;
@@ -255,6 +261,8 @@ export interface SeriesStatesOptions {
255
261
  brightness?: number;
256
262
  /** Deepens a translucent body on hover, where brightening a pale fill does nothing. */
257
263
  fillOpacity?: number;
264
+ /** The colour to swap to, for a hover that is not a brighter shade of the same one. */
265
+ color?: string;
258
266
  };
259
267
  inactive?: {
260
268
  opacity?: number;
@@ -300,6 +308,8 @@ export interface SeriesOptionsBase<TSeriesMeta = unknown, TPointMeta = unknown>
300
308
  fillOpacity?: number;
301
309
  /** The body's own colour, where the fill is not a wash of the series colour. */
302
310
  fillColor?: string;
311
+ /** Fades the whole series — body, border and labels — unlike `fillOpacity`. */
312
+ opacity?: number;
303
313
  pointPadding?: number;
304
314
  groupPadding?: number;
305
315
  minPointLength?: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pushwoosh/kit-charts",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "description": "Small React chart library on SVG, with a Highcharts-shaped configuration model",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -2,11 +2,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { pointKey } from '../../core/data/points';
3
3
  import { drawable, projectSeries } from '../../core/geometry/path';
4
4
  import { TEXT_BASELINE_SHIFT, VALUE_LABEL_GAP } from '../../core/layout/metrics';
5
- import { brighten } from '../shared/color';
5
+ import { resolveHoverFill } from '../shared/color';
6
6
  import { DataLabels } from '../shared/DataLabels';
7
7
  import { includeZero } from '../shared/domain';
8
8
  import { seriesLegend } from '../shared/legend';
9
- const DEFAULT_HOVER_BRIGHTNESS = 0.1;
10
9
  const SLOT_FALLBACK = {
11
10
  width: 10,
12
11
  offset: 0
@@ -46,7 +45,7 @@ const fillOf = (ctx, bar, hovered) => {
46
45
  if (!hovered) {
47
46
  return base;
48
47
  }
49
- return brighten(base, ctx.series.states?.hover?.brightness ?? DEFAULT_HOVER_BRIGHTNESS);
48
+ return resolveHoverFill(base, ctx.series.states);
50
49
  };
51
50
  /**
52
51
  * A translucent body under a solid rule is the funnel look: the series keeps its
@@ -1,9 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { pointKey } from '../../core/data/points';
3
3
  import { TEXT_BASELINE_SHIFT, VALUE_LABEL_GAP } from '../../core/layout/metrics';
4
- import { brighten } from '../shared/color';
4
+ import { resolveHoverFill } from '../shared/color';
5
5
  import { DataLabels } from '../shared/DataLabels';
6
- const DEFAULT_HOVER_BRIGHTNESS = 0.1;
7
6
  /** Width of one column: a unit step on the x axis, or the whole plot if there is none. */
8
7
  function columnWidth(ctx) {
9
8
  const scale = ctx.scales.x;
@@ -79,7 +78,7 @@ const fillOf = (ctx, cell, hovered) => {
79
78
  if (!hovered) {
80
79
  return base;
81
80
  }
82
- return brighten(base, ctx.series.states?.hover?.brightness ?? DEFAULT_HOVER_BRIGHTNESS);
81
+ return resolveHoverFill(base, ctx.series.states);
83
82
  };
84
83
  const labelX = (ctx, cell) => {
85
84
  const padding = ctx.series.dataLabels?.padding ?? VALUE_LABEL_GAP;
@@ -1 +1,7 @@
1
+ import { type SeriesStatesOptions } from '../../core/types/options';
1
2
  export declare function brighten(color: string, amount: number): string;
3
+ /**
4
+ * An explicit hover colour wins over brightening, and over a per-point colour:
5
+ * asking for one colour on hover means one colour, not one per point.
6
+ */
7
+ export declare function resolveHoverFill(base: string, states: SeriesStatesOptions | undefined): string;
@@ -1,3 +1,4 @@
1
+ const DEFAULT_HOVER_BRIGHTNESS = 0.1;
1
2
  const clamp = value => Math.min(255, Math.max(0, Math.round(value)));
2
3
  const parseHex = color => {
3
4
  const match = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(color.trim());
@@ -16,4 +17,12 @@ export function brighten(color, amount) {
16
17
  const ratio = Math.abs(amount);
17
18
  const [r, g, b] = rgb.map(channel => clamp(channel + (target - channel) * ratio));
18
19
  return `#${[r, g, b].map(channel => channel.toString(16).padStart(2, '0')).join('')}`;
20
+ }
21
+ /**
22
+ * An explicit hover colour wins over brightening, and over a per-point colour:
23
+ * asking for one colour on hover means one colour, not one per point.
24
+ */
25
+ export function resolveHoverFill(base, states) {
26
+ const hover = states?.hover;
27
+ return hover?.color ?? brighten(base, hover?.brightness ?? DEFAULT_HOVER_BRIGHTNESS);
19
28
  }