@pushwoosh/kit-charts 0.0.4 → 0.0.5

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,13 @@ 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` 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`.
348
+
342
349
  `ctx.points[].pointMeta` is the hovered point's own `meta` — the third type
343
350
  parameter of `ChartOptions<TChartMeta, TSeriesMeta, TPointMeta>`, the same object
344
351
  `onPointClick` reports. `meta` beside it stays the series'. A bar whose height is a
@@ -367,10 +374,22 @@ With `fillOpacity` on the body and `states.hover.fillOpacity` to deepen it, that
367
374
  is the funnel look: the series keeps its real colour, so the legend swatch and
368
375
  the tooltip dot stay readable, and only the bar is washed out.
369
376
 
377
+ `states.hover.color` swaps the fill on hover instead of lightening it, for a body
378
+ too pale for `brightness` to show on; it also wins over a per-point colour, since
379
+ one colour on hover is the point of asking. `column` and `heatmap` read it.
380
+ Golden case: `column/hover-color`.
381
+
370
382
  A data label whose text carries `\n` is drawn as stacked lines, styled by
371
383
  `style` for the first and `secondaryStyle` for the rest. It is one label: it
372
384
  moves, measures and hides as one.
373
385
 
386
+ ## Series opacity
387
+
388
+ `series.opacity` fades a whole series layer — body, border and data labels — where
389
+ `fillOpacity` reaches only the fill a renderer paints. A hover's
390
+ `states.inactive.opacity` overrides it while it lasts. Golden case:
391
+ `column/series-opacity`.
392
+
374
393
  ## Areas
375
394
 
376
395
  `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,32 @@ const toCssSize = (value, fallback) => {
32
33
  }
33
34
  return typeof value === 'number' ? `${value}px` : value;
34
35
  };
35
- function defaultTooltip(ctx, formatX, formatValue) {
36
+ function tooltipHeader(ctx, headerFormat, formatX) {
37
+ if (headerFormat === undefined) {
38
+ return formatX(ctx.x);
39
+ }
40
+ return applyTooltipTemplate(headerFormat, {
41
+ x: ctx.x,
42
+ y: ctx.points[0]?.y ?? null,
43
+ seriesName: ctx.points[0]?.seriesName ?? ''
44
+ });
45
+ }
46
+ function defaultTooltip(ctx, tooltip, formatX, formatValue) {
47
+ const header = tooltipHeader(ctx, tooltip?.headerFormat, formatX);
48
+ const pointFormatter = tooltip?.pointFormatter;
36
49
  return _jsxs("div", {
37
- children: [_jsx(TooltipTitle, {
38
- children: formatX(ctx.x)
39
- }), ctx.points.map(point => _jsxs(TooltipRow, {
40
- children: [_jsx(TooltipSwatch, {
41
- "$color": point.color
42
- }), _jsx(TooltipName, {
43
- children: point.seriesName
44
- }), _jsx(TooltipValue, {
45
- children: formatValue(point.value ?? point.y)
46
- })]
50
+ children: [header ? _jsx(TooltipTitle, {
51
+ children: header
52
+ }) : 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
+ })
47
62
  }, `${point.seriesIndex}-${point.index}`))]
48
63
  });
49
64
  }
@@ -435,8 +450,8 @@ function ChartBody({
435
450
  return formatter(ctx);
436
451
  }
437
452
  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]);
453
+ return table ? tableTooltip(ctx, table, formatX) : defaultTooltip(ctx, options.tooltip, formatX, formatValue);
454
+ }, [hover, model, options.title?.text, options.meta, options.tooltip, formatX, formatValue]);
440
455
  const hasData = model?.series.some(series => series.points.some(point => point.y !== null)) ?? false;
441
456
  const plotBorderWidth = options.chart?.plotBorderWidth ?? 0;
442
457
  const scrolls = canvas.width > size.width || canvas.height > size.height;
@@ -537,6 +552,7 @@ function ChartBody({
537
552
  "$collapse": series.renderer.exit === 'collapse',
538
553
  "$selfAnimated": series.renderer.animatesGeometry === true,
539
554
  "$inactive": inactiveOpacity(series),
555
+ "$opacity": series.options.opacity,
540
556
  "$motionMs": motionOf(series),
541
557
  "$cursor": series.options.cursor ?? 'default',
542
558
  "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,10 @@
1
+ export interface TemplateContext {
2
+ x: number | string;
3
+ y: number | null;
4
+ seriesName: string;
5
+ }
6
+ /**
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
+ */
10
+ export declare function applyTooltipTemplate(format: string, ctx: TemplateContext): string;
@@ -0,0 +1,30 @@
1
+ import { formatTime } from './time';
2
+ import { fillTokens } from './tokens';
3
+ const substitute = (name, ctx) => {
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 ? '' : String(ctx.y);
10
+ case 'series.name':
11
+ return ctx.seriesName;
12
+ default:
13
+ return undefined;
14
+ }
15
+ };
16
+ /**
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
+ */
20
+ export function applyTooltipTemplate(format, ctx) {
21
+ const stripped = format.replace(/<br\s*\/?>/gi, ' ').replace(/<[^>]*>/g, '');
22
+ return fillTokens(stripped, (name, pattern) => {
23
+ const value = substitute(name, ctx);
24
+ if (value === undefined) {
25
+ return undefined;
26
+ }
27
+ const dates = name === 'point.key' || name === 'point.x';
28
+ return pattern && dates && typeof ctx.x === 'number' ? formatTime(pattern, ctx.x) : value;
29
+ }).trim();
30
+ }
@@ -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}` placeholders; an unresolved one 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}` placeholders; an unresolved one is left as it stands. */
3
+ export function fillTokens(format, resolve) {
4
+ return format.replace(TOKEN, (match, name, pattern) => resolve(name, pattern) ?? match);
5
+ }
@@ -205,6 +205,10 @@ 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
+ /** One row per point, in place of the default swatch, name and value. */
211
+ pointFormatter?: TooltipCellFormatter<TSeriesMeta, TPointMeta>;
208
212
  dateFormat?: string;
209
213
  valuePrefix?: string;
210
214
  valueSuffix?: string;
@@ -255,6 +259,8 @@ export interface SeriesStatesOptions {
255
259
  brightness?: number;
256
260
  /** Deepens a translucent body on hover, where brightening a pale fill does nothing. */
257
261
  fillOpacity?: number;
262
+ /** The colour to swap to, for a hover that is not a brighter shade of the same one. */
263
+ color?: string;
258
264
  };
259
265
  inactive?: {
260
266
  opacity?: number;
@@ -300,6 +306,8 @@ export interface SeriesOptionsBase<TSeriesMeta = unknown, TPointMeta = unknown>
300
306
  fillOpacity?: number;
301
307
  /** The body's own colour, where the fill is not a wash of the series colour. */
302
308
  fillColor?: string;
309
+ /** Fades the whole series — body, border and labels — unlike `fillOpacity`. */
310
+ opacity?: number;
303
311
  pointPadding?: number;
304
312
  groupPadding?: number;
305
313
  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.5",
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
  }