@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.
- package/ai-documentation.md +14 -4
- package/core/Chart/Chart.js +4 -2
- package/core/format/markup.d.ts +3 -0
- package/core/format/markup.js +112 -0
- package/core/format/template.d.ts +4 -5
- package/core/format/template.js +8 -7
- package/core/scale/time.js +31 -12
- package/package.json +1 -1
package/ai-documentation.md
CHANGED
|
@@ -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}
|
|
353
|
-
date an x through `{point.x:%b %d}` (a padded pattern is trimmed)
|
|
354
|
-
through the chart's `numberFormat
|
|
355
|
-
|
|
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
|
|
package/core/Chart/Chart.js
CHANGED
|
@@ -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,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
|
-
|
|
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;
|
package/core/format/template.js
CHANGED
|
@@ -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
|
|
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
|
-
})
|
|
30
|
+
});
|
|
31
|
+
const node = parseMarkup(format, fill);
|
|
32
|
+
return typeof node === 'string' ? node.trim() : node;
|
|
32
33
|
}
|
package/core/scale/time.js
CHANGED
|
@@ -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
|
|
91
|
+
const pickRungIndex = (span, count) => {
|
|
88
92
|
const target = span / Math.max(1, count);
|
|
89
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
}
|