@pushwoosh/kit-charts 0.0.8 → 0.0.9

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.
@@ -349,10 +349,20 @@ a delta needs no extra lookup. `shared: true` collects every series at the hover
349
349
  `headerFormat`, `pointFormat` and `pointFormatter` are taken under their Highcharts
350
350
  names, so a config that already carries them needs no rewrite: `headerFormat: ''`
351
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
352
+ fill `{point.key}`, `{point.x}`, `{point.y}`, `{point.value}`, `{series.name}` and
353
+ `{point.color}`, date an x through `{point.x:%b %d}` (a padded pattern is trimmed)
354
+ and pass numbers through the chart's `numberFormat`.
355
+
356
+ Inline markup in a template is rendered rather than stripped, so the default
357
+ Highcharts `pointFormat` arrives looking like itself. The whitelist is `<br>`,
358
+ `<b>`/`<strong>`, `<i>`/`<em>` and `<span>` carrying a `style` of `color`,
359
+ `font-size`, `font-weight` or `font-style`; anything else is dropped and its text
360
+ kept. Nothing is parsed as HTML — the tags become React elements and a filled token
361
+ is always a text child, so a series name out of the API cannot carry markup in.
362
+ A token may sit inside the `style` attribute, as `{point.color}` does, and the
363
+ filled value is checked against a plain-CSS pattern before it is used.
364
+
365
+ `pointFormatter` is handed the point and returns
356
366
  a `ReactNode`, and outranks `pointFormat`; `formatter` and `table` outrank all
357
367
  three. Golden cases: `area/header-format`, `line/point-format-template`.
358
368
 
@@ -13,6 +13,7 @@ import { MOTION_MS } from '../animation/motion';
13
13
  import { applyTooltipTemplate } from '../format/template';
14
14
  import { formatTime } from '../format/time';
15
15
  import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from '../layout/measure-text';
16
+ import { nodeText } from '../layout/node-text';
16
17
  import { buildChartModel, hiddenKey, isContinuous } from '../model/build-model';
17
18
  import { DEFAULT_GRID_COLOR, DEFAULT_SURFACE_COLOR, DEFAULT_TEXT_COLOR } from '../model/palette';
18
19
  import { resolveRenderers } from '../registry/registry';
@@ -37,7 +38,8 @@ const templateContext = (point, x) => ({
37
38
  x,
38
39
  y: point?.y ?? null,
39
40
  value: point?.value ?? null,
40
- seriesName: point?.seriesName ?? ''
41
+ seriesName: point?.seriesName ?? '',
42
+ color: point?.color ?? DEFAULT_TEXT_COLOR
41
43
  });
42
44
  function tooltipHeader(ctx, headerFormat, formatX, numberFormat) {
43
45
  if (headerFormat === undefined) {
@@ -70,7 +72,7 @@ function pointRow(point, tooltip, formatValue, numberFormat) {
70
72
  function defaultTooltip(ctx, tooltip, formatX, formatValue, numberFormat) {
71
73
  const header = tooltipHeader(ctx, tooltip?.headerFormat, formatX, numberFormat);
72
74
  return _jsxs("div", {
73
- children: [header ? _jsx(TooltipTitle, {
75
+ children: [nodeText(header).trim() ? _jsx(TooltipTitle, {
74
76
  children: header
75
77
  }) : null, ctx.points.map(point => _jsx(TooltipRow, {
76
78
  children: pointRow(point, tooltip, formatValue, numberFormat)
@@ -0,0 +1,3 @@
1
+ import { type ReactNode } from 'react';
2
+ /** Renders the inline markup a Highcharts template carries. */
3
+ export declare function parseMarkup(source: string, fillText: (text: string) => string): ReactNode;
@@ -0,0 +1,112 @@
1
+ import { createElement, Fragment } from 'react';
2
+ const TAG = /<\s*(\/?)\s*([a-z]+)([^>]*)>/gi;
3
+ const VOID_TAGS = new Set(['br']);
4
+ const INLINE_TAGS = new Set(['b', 'strong', 'i', 'em', 'span']);
5
+ /** CSS properties a template may set, mapped to their React names. */
6
+ const STYLE_PROPS = {
7
+ color: 'color',
8
+ 'font-size': 'fontSize',
9
+ 'font-weight': 'fontWeight',
10
+ 'font-style': 'fontStyle'
11
+ };
12
+ // A token can land inside the attribute — Highcharts' own default writes
13
+ // `style="color:{point.color}"` — so the filled value is checked, not trusted.
14
+ const SAFE_VALUE = /^[#\w][\w\s.,%()#-]*$/;
15
+ const styleValue = raw => {
16
+ const value = raw.trim();
17
+ if (!SAFE_VALUE.test(value) || /url/i.test(value)) {
18
+ return undefined;
19
+ }
20
+ return value;
21
+ };
22
+ const parseStyle = (declarations, fillText) => {
23
+ const style = {};
24
+ declarations.split(';').forEach(declaration => {
25
+ const colon = declaration.indexOf(':');
26
+ if (colon === -1) {
27
+ return;
28
+ }
29
+ const property = STYLE_PROPS[declaration.slice(0, colon).trim().toLowerCase()];
30
+ const value = property && styleValue(fillText(declaration.slice(colon + 1)));
31
+ if (property && value) {
32
+ style[property] = value;
33
+ }
34
+ });
35
+ return Object.keys(style).length ? style : undefined;
36
+ };
37
+ const styleOf = (tag, attributes, fillText) => {
38
+ if (tag !== 'span') {
39
+ return undefined;
40
+ }
41
+ const match = /\bstyle\s*=\s*("([^"]*)"|'([^']*)')/i.exec(attributes);
42
+ return match ? parseStyle(match[2] ?? match[3] ?? '', fillText) : undefined;
43
+ };
44
+ const element = (frame, key) => createElement(frame.tag, {
45
+ key,
46
+ style: frame.style
47
+ }, ...frame.children);
48
+ /** Renders the inline markup a Highcharts template carries. */
49
+ export function parseMarkup(source, fillText) {
50
+ const root = {
51
+ tag: '',
52
+ style: undefined,
53
+ children: []
54
+ };
55
+ const stack = [root];
56
+ const top = () => stack[stack.length - 1];
57
+ let cursor = 0;
58
+ let key = 0;
59
+ const closeTo = depth => {
60
+ while (stack.length > depth) {
61
+ const frame = stack.pop();
62
+ key += 1;
63
+ top().children.push(element(frame, key));
64
+ }
65
+ };
66
+ const pushText = text => {
67
+ const filled = fillText(text);
68
+ if (filled) {
69
+ top().children.push(filled);
70
+ }
71
+ };
72
+ TAG.lastIndex = 0;
73
+ for (let match = TAG.exec(source); match; match = TAG.exec(source)) {
74
+ pushText(source.slice(cursor, match.index));
75
+ cursor = TAG.lastIndex;
76
+ const [, closing, name, attributes] = match;
77
+ const tag = name.toLowerCase();
78
+ if (VOID_TAGS.has(tag)) {
79
+ key += 1;
80
+ top().children.push(createElement(tag, {
81
+ key
82
+ }));
83
+ } else if (!INLINE_TAGS.has(tag)) {
84
+ // Unknown tag: dropped, its text left where it stood.
85
+ } else if (closing) {
86
+ let open = stack.length - 1;
87
+ while (open > 0 && stack[open].tag !== tag) {
88
+ open -= 1;
89
+ }
90
+ if (open > 0) {
91
+ closeTo(open);
92
+ }
93
+ } else {
94
+ stack.push({
95
+ tag,
96
+ style: styleOf(tag, attributes, fillText),
97
+ children: []
98
+ });
99
+ // `<span … />` by analogy with the `<br/>` in the Highcharts docs: closed
100
+ // where it stands, or it would adopt every sibling after it.
101
+ if (/\/\s*$/.test(attributes)) {
102
+ closeTo(stack.length - 1);
103
+ }
104
+ }
105
+ }
106
+ pushText(source.slice(cursor));
107
+ closeTo(1);
108
+ if (root.children.length === 1 && typeof root.children[0] === 'string') {
109
+ return root.children[0];
110
+ }
111
+ return root.children.length ? createElement(Fragment, null, ...root.children) : '';
112
+ }
@@ -1,12 +1,11 @@
1
+ import { type ReactNode } from 'react';
1
2
  import { type NumberFormatter } from '../types/renderer';
2
3
  export interface TemplateContext {
3
4
  x: number | string;
4
5
  y: number | null;
5
6
  value: number | null;
6
7
  seriesName: string;
8
+ color: string;
7
9
  }
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;
10
+ /** Fills a Highcharts header or point template, rendering the inline markup in it. */
11
+ export declare function applyTooltipTemplate(format: string, ctx: TemplateContext, numberFormat?: NumberFormatter): ReactNode;
@@ -1,3 +1,4 @@
1
+ import { parseMarkup } from './markup';
1
2
  import { formatTime } from './time';
2
3
  import { fillTokens } from './tokens';
3
4
  const substitute = (name, ctx, numberFormat) => {
@@ -11,22 +12,22 @@ const substitute = (name, ctx, numberFormat) => {
11
12
  return ctx.value === null ? '' : numberFormat(ctx.value);
12
13
  case 'series.name':
13
14
  return ctx.seriesName;
15
+ case 'point.color':
16
+ return ctx.color;
14
17
  default:
15
18
  return undefined;
16
19
  }
17
20
  };
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
- */
21
+ /** Fills a Highcharts header or point template, rendering the inline markup in it. */
22
22
  export function applyTooltipTemplate(format, ctx, numberFormat = String) {
23
- const stripped = format.replace(/<br\s*\/?>/gi, ' ').replace(/<[^>]*>/g, '');
24
- return fillTokens(stripped, (name, pattern) => {
23
+ const fill = text => fillTokens(text, (name, pattern) => {
25
24
  const value = substitute(name, ctx, numberFormat);
26
25
  if (value === undefined) {
27
26
  return undefined;
28
27
  }
29
28
  const dates = name === 'point.key' || name === 'point.x';
30
29
  return pattern && dates && typeof ctx.x === 'number' ? formatTime(pattern, ctx.x) : value;
31
- }).trim();
30
+ });
31
+ const node = parseMarkup(format, fill);
32
+ return typeof node === 'string' ? node.trim() : node;
32
33
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pushwoosh/kit-charts",
3
- "version": "0.0.8",
3
+ "version": "0.0.9",
4
4
  "description": "Small React chart library on SVG, with a Highcharts-shaped configuration model",
5
5
  "license": "MIT",
6
6
  "repository": {