@pushwoosh/kit-charts 0.0.7 → 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
  }
@@ -63,6 +63,10 @@ const LADDER = [{
63
63
  unit: 'week',
64
64
  step: 1,
65
65
  approx: WEEK
66
+ }, {
67
+ unit: 'week',
68
+ step: 2,
69
+ approx: 2 * WEEK
66
70
  }, {
67
71
  unit: 'month',
68
72
  step: 1,
@@ -84,9 +88,10 @@ const LADDER = [{
84
88
  approx: 365 * DAY,
85
89
  calendar: true
86
90
  }];
87
- const pickRung = (span, count) => {
91
+ const pickRungIndex = (span, count) => {
88
92
  const target = span / Math.max(1, count);
89
- return LADDER.find(rung => rung.approx >= target) ?? LADDER[LADDER.length - 1];
93
+ const found = LADDER.findIndex(rung => rung.approx >= target);
94
+ return found === -1 ? LADDER.length - 1 : found;
90
95
  };
91
96
  const alignUp = (from, rung) => {
92
97
  const date = new Date(from);
@@ -119,6 +124,20 @@ const advance = (timestamp, rung) => {
119
124
  return Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + months, date.getUTCDate(), date.getUTCHours(), date.getUTCMinutes(), date.getUTCSeconds());
120
125
  };
121
126
  const rungFor = unit => LADDER.find(rung => rung.unit === unit && rung.step === 1) ?? LADDER[LADDER.length - 1];
127
+ const ticksFor = (min, max, rung) => {
128
+ const ticks = [];
129
+ // Calendar alignment lands on the start of the current unit, which can sit
130
+ // before the domain — a tick there would be drawn outside the plot.
131
+ let cursor = alignUp(min, rung);
132
+ if (cursor < min) {
133
+ cursor = advance(cursor, rung);
134
+ }
135
+ for (let i = 0; cursor <= max && i < 1000; i += 1) {
136
+ ticks.push(cursor);
137
+ cursor = advance(cursor, rung);
138
+ }
139
+ return ticks;
140
+ };
122
141
  export function timeTicks(min, max, options = {}) {
123
142
  const {
124
143
  count = 6,
@@ -130,17 +149,17 @@ export function timeTicks(min, max, options = {}) {
130
149
  if (min === max) {
131
150
  return [min];
132
151
  }
133
- const rung = unit ? rungFor(unit) : pickRung(max - min, count);
134
- const ticks = [];
135
- // Calendar alignment lands on the start of the current unit, which can sit
136
- // before the domain — a tick there would be drawn outside the plot.
137
- let cursor = alignUp(min, rung);
138
- if (cursor < min) {
139
- cursor = advance(cursor, rung);
152
+ if (unit) {
153
+ const fixed = ticksFor(min, max, rungFor(unit));
154
+ return fixed.length ? fixed : [min, max];
140
155
  }
141
- for (let i = 0; cursor <= max && i < 1000; i += 1) {
142
- ticks.push(cursor);
143
- cursor = advance(cursor, rung);
156
+ // A rung as coarse as the span lands a single tick inside it — a month step on
157
+ // a month of data — so step down until the axis has something to read.
158
+ let index = pickRungIndex(max - min, count);
159
+ let ticks = ticksFor(min, max, LADDER[index]);
160
+ while (ticks.length < 2 && index > 0) {
161
+ index -= 1;
162
+ ticks = ticksFor(min, max, LADDER[index]);
144
163
  }
145
164
  return ticks.length ? ticks : [min, max];
146
165
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pushwoosh/kit-charts",
3
- "version": "0.0.7",
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": {