@pepperui/charts 1.1.0
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/README.md +117 -0
- package/dist/pepper-charts.js +1245 -0
- package/dist/pepper-charts.mjs +1236 -0
- package/dist/recipes.json +178 -0
- package/dist/theme.json +106 -0
- package/package.json +41 -0
- package/src/api.js +56 -0
- package/src/chartjs.js +215 -0
- package/src/echarts.js +344 -0
- package/src/format.js +119 -0
- package/src/index.js +28 -0
- package/src/mount.js +71 -0
- package/src/rules.js +306 -0
package/src/rules.js
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/* ============================================================================
|
|
2
|
+
rules — the house chart rules that are not colour, and the recipe geometry.
|
|
3
|
+
|
|
4
|
+
Every number here is drawn on the Figma page `Chart recipes` (281:2) and every
|
|
5
|
+
one of them is a decision with a date. They live in one place so the ECharts
|
|
6
|
+
builder below, Chart.js (3.1.2) and the PPTX exporter (3.1.3) cannot drift
|
|
7
|
+
apart the way the palettes did (G13).
|
|
8
|
+
|
|
9
|
+
THE HOUSE CHART STANDARD (3.8.1, 2026-09-21; rule 12 added 2026-09-21 on his review of
|
|
10
|
+
the built result). Twelve rules decided on rendered
|
|
11
|
+
pairs — `artifacts/requests/2026-09-21-chart-standard/decide.html`, his picks in
|
|
12
|
+
`answers.json`, the spec in `STANDARD.md`. They are stated here, once, because
|
|
13
|
+
the audit that found them (3.8.0) found that a picture fixed on its own comes
|
|
14
|
+
back: the missing baseline was not a slip in one page, it was this file and the
|
|
15
|
+
two renderers saying `axisLine: { show: false }` three times over.
|
|
16
|
+
|
|
17
|
+
1 a column stands on a baseline, heavier than the grid `axis.baseline`
|
|
18
|
+
2 a line at every tick, behind the bars `axis.grid`
|
|
19
|
+
3 a column is rounded on top, square at the foot `bar.radius`
|
|
20
|
+
4 a stacked column rounds its TOP SEGMENT only `barRadius()`
|
|
21
|
+
5 a line carries a dot at every point `line.symbolSize`
|
|
22
|
+
6 a ranked list reads largest first `RANKED_TOP_FIRST`
|
|
23
|
+
7 the row-name lane is measured, not reserved `category`
|
|
24
|
+
8 a donut writes its share in the legend only `donut.labelsOn`
|
|
25
|
+
9 a one-series chart has THREE treatments, all documented `barColors()`
|
|
26
|
+
10 the forecast area is the accent at 12 % `area.opacity`
|
|
27
|
+
11 the dashed forecast line is `chart/4` `area.forecast`
|
|
28
|
+
12 a TWO-series chart is `chart/1` against `chart/rest` `seriesColor()`
|
|
29
|
+
|
|
30
|
+
Rule 11 is a stated exemption: `chart/4` on the 12 % accent reads 1.68 : 1,
|
|
31
|
+
under the 3.0 : 1 floor a meaningful graphical line is held to. He was shown
|
|
32
|
+
the three readings and picked it; `docs/contrast-matrix.md` carries the pair
|
|
33
|
+
with his decision beside it, the way D110's twelve kept pairs are.
|
|
34
|
+
============================================================================ */
|
|
35
|
+
|
|
36
|
+
/** The chart-recipe geometry. Names are the recipe's, values are the ones the
|
|
37
|
+
* plots on `Chart recipes` were drawn at. */
|
|
38
|
+
const GEOMETRY = {
|
|
39
|
+
// RULES 1 and 2 — the lines of a plot. A column stands on a baseline drawn in
|
|
40
|
+
// `chart/rest` at 2px, heavier than the grid behind it, and the grid keeps a
|
|
41
|
+
// line at every tick. The baseline belongs to the CATEGORY axis: the foot of a
|
|
42
|
+
// column chart, the left edge of a ranked one — in both it is the line the bars
|
|
43
|
+
// stand on. The value axis draws none.
|
|
44
|
+
axis: {
|
|
45
|
+
baselineWidth: 2,
|
|
46
|
+
gridWidth: 1,
|
|
47
|
+
},
|
|
48
|
+
bar: {
|
|
49
|
+
// RULE 3 — 4px on the top corners only, square at the foot, in BOTH renderers.
|
|
50
|
+
// The foot used to be rounded too on the Chart.js side, on the reasoning that
|
|
51
|
+
// a fully rounded column would not look like it needed a baseline; it now has
|
|
52
|
+
// one, so the shape follows the baseline back (D2, `chartjs.js` drops
|
|
53
|
+
// `borderSkipped: false`).
|
|
54
|
+
// RULE 4 — a stacked column rounds the TOP of its LAST segment and nothing
|
|
55
|
+
// else: the joints stay square, because a rounded joint draws a gap that is
|
|
56
|
+
// not in the data. See `barRadius()`.
|
|
57
|
+
radius: 4,
|
|
58
|
+
maxWidth: 96,
|
|
59
|
+
},
|
|
60
|
+
line: {
|
|
61
|
+
// RULE 5 — a 4px spline with a dot at every point. This reverses what this
|
|
62
|
+
// file said until 2026-09-21 (*no dots — the dot lane is for end markers
|
|
63
|
+
// only*): one renderer obeyed the written rule and the other did not, and he
|
|
64
|
+
// picked the dots on the pair (D14).
|
|
65
|
+
width: 4,
|
|
66
|
+
symbolSize: 9,
|
|
67
|
+
},
|
|
68
|
+
// RULES 10 and 11 — an area (the forecast band) is its series colour at 12 %,
|
|
69
|
+
// and the dashed line over it is `chart/4` at 3px, 6 on 6 off. The fill used to
|
|
70
|
+
// render black at 22 % in Figma because a bound paint carried `#000000` as its
|
|
71
|
+
// written colour (D4); the opacity is his pick from the three rendered bands.
|
|
72
|
+
// The line on an area carries no dots — the pictures rules 10 and 11 were
|
|
73
|
+
// picked on were drawn that way.
|
|
74
|
+
area: {
|
|
75
|
+
opacity: 0.12,
|
|
76
|
+
forecast: { width: 3, dash: [6, 6] },
|
|
77
|
+
},
|
|
78
|
+
legend: {
|
|
79
|
+
// circular markers, top-right, 18 apart. The Figma page draws the gap at
|
|
80
|
+
// `space/16` and states the 2px difference rather than moving the code.
|
|
81
|
+
itemWidth: 10,
|
|
82
|
+
itemHeight: 10,
|
|
83
|
+
itemGap: 18,
|
|
84
|
+
},
|
|
85
|
+
grid: {
|
|
86
|
+
// the plot's padding inside its box; `top` leaves the label lane above the
|
|
87
|
+
// tallest column (26), and twice that when a legend sits above it (44)
|
|
88
|
+
left: 8,
|
|
89
|
+
right: 40,
|
|
90
|
+
topWithLegend: 44,
|
|
91
|
+
top: 26,
|
|
92
|
+
bottom: 4,
|
|
93
|
+
},
|
|
94
|
+
category: {
|
|
95
|
+
// RULE 7 — the row-name lane is MEASURED, not reserved. 200px used to be held
|
|
96
|
+
// for the name however short the names were, which left the wide empty block
|
|
97
|
+
// on the left of every ranked card (D6). Nothing is reserved now: the lane is
|
|
98
|
+
// as wide as the longest name and `containLabel` fits the grid round it. A
|
|
99
|
+
// caller that must truncate passes `label_width` itself.
|
|
100
|
+
labelGap: 12,
|
|
101
|
+
},
|
|
102
|
+
donut: {
|
|
103
|
+
// RULE 8 — a donut writes its share in the LEGEND ONLY; the ring stays clean.
|
|
104
|
+
// Outside labels used to be on unless a caller turned them off, so a composed
|
|
105
|
+
// donut printed every share twice and ran its leader lines out of the box
|
|
106
|
+
// (D15). The one exception is a donut with no legend to write into
|
|
107
|
+
// (`legend: false`, the KPI progress rings): the share goes back on the ring,
|
|
108
|
+
// because the alternative is a chart that states its number nowhere.
|
|
109
|
+
labelsOn: false,
|
|
110
|
+
// the ring: 72% cutout, 3px borders in the on-fill white, slices in the
|
|
111
|
+
// accent's series order
|
|
112
|
+
cutout: 72,
|
|
113
|
+
segmentBorder: 3,
|
|
114
|
+
radiusInner: '58%',
|
|
115
|
+
radiusOuter: '85%',
|
|
116
|
+
// the composed "ring beside its legend" block (donut_compare): the ring takes
|
|
117
|
+
// 62% of the shorter side, the legend is measured from its real entries at
|
|
118
|
+
// ~10.5px a character, and the pair is centred as ONE group
|
|
119
|
+
groupRingShare: 0.62,
|
|
120
|
+
groupGap: 56,
|
|
121
|
+
groupLegendMax: 380,
|
|
122
|
+
// the fallback when there is no canvas to measure with: Poppins' average
|
|
123
|
+
// advance is about 0.55 of the size. `textWidth()` measures the real string
|
|
124
|
+
// whenever it can — the estimate at 10.5px a character is what cut
|
|
125
|
+
// "MobuppsX — 14%" off the right edge of his donut (D16)
|
|
126
|
+
groupLegendCharRatio: 0.55,
|
|
127
|
+
groupLegendPad: 34,
|
|
128
|
+
groupEdgeMin: 24,
|
|
129
|
+
groupLegend: { itemWidth: 16, itemHeight: 16, itemGap: 22, fontSize: 20 },
|
|
130
|
+
legend: { itemWidth: 14, itemHeight: 14, itemGap: 16, fontSize: 16 },
|
|
131
|
+
// a config that reaches the renderer carrying none of the house flags is
|
|
132
|
+
// restyled to this rather than shipping as a default-ECharts pie
|
|
133
|
+
// labels come from `labelsOn` above — the standard, not a second copy of it
|
|
134
|
+
unstyled: { cutout: 70, segmentBorder: 0, legendPct: true },
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/** The canvas a chart is drawn on decides its text size: 15 on a slide, 12 on
|
|
139
|
+
* web (`type/<canvas>/label/medium`, D36). Slide is the default because the first
|
|
140
|
+
* consumer is a 1920x1080 deck, and an unknown canvas is a slide rather than an
|
|
141
|
+
* error — a chart with no text is worse than a chart with slide-sized text.
|
|
142
|
+
* Shared by both renderers. */
|
|
143
|
+
function chartText(canvas) {
|
|
144
|
+
return THEME.text[canvas] || THEME.text.slide;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Series `i` of `accent`, cycling. The palettes are monochromatic per accent —
|
|
148
|
+
* the accent's own tints — except `multicolor`, which is distinct hues; either
|
|
149
|
+
* way the cycle repeats when a chart has more series than the palette has
|
|
150
|
+
* entries. An unknown accent falls back to blue, the default mode.
|
|
151
|
+
*
|
|
152
|
+
* Every value comes from `THEME.series`, which is generated from
|
|
153
|
+
* `accent/<mode>/chart/1–5` — no palette is typed by hand here or anywhere else
|
|
154
|
+
* (G13, DL-10). */
|
|
155
|
+
function seriesColor(theme, accent, i, count) {
|
|
156
|
+
// RULE 12 — a chart with exactly TWO series is a comparison, and a comparison reads as one thing
|
|
157
|
+
// against the rest of it: series 1 keeps the accent's `chart/1` and series 2 is `chart/rest`, never
|
|
158
|
+
// the accent's second tint. This is the two-slice donut rule (Fedor 2026-08-12) generalised to every
|
|
159
|
+
// chart, at his word on 2026-09-21: *"when you're using two two color charts, so we can use this
|
|
160
|
+
// gray as the second color… the same way a two-coloured donut chart uses"*. `count` is how many
|
|
161
|
+
// series the chart has; a caller that does not know it gets the plain palette.
|
|
162
|
+
if (count === 2 && i === 1) return theme.rest;
|
|
163
|
+
const base = theme.series[accent] || theme.series.blue;
|
|
164
|
+
return base[i % base.length];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** RULE 9 — the colours of a ONE-SERIES chart. Three treatments, all correct,
|
|
168
|
+
* all documented (Fedor 2026-09-21: *"you have multicolor, you have one color,
|
|
169
|
+
* and you have color with different tints. All these three options are
|
|
170
|
+
* correct"*). The design system's job is to make all three available and to show
|
|
171
|
+
* all three, not to pick one.
|
|
172
|
+
*
|
|
173
|
+
* 'flat' every bar in the accent's `chart/1` — the default in an accent
|
|
174
|
+
* mode, which is what "a chart follows the accent of the slide"
|
|
175
|
+
* means, and what the system already did
|
|
176
|
+
* 'tints' the accent's own chart/1–5, cycling — for a comparison across
|
|
177
|
+
* the bars
|
|
178
|
+
* 'multicolor' the red→yellow→blue→gray cycle, hand-set and never automatic
|
|
179
|
+
* (`docs/DESIGN_SYSTEM.md:35`, quoted in STANDARD.md)
|
|
180
|
+
*
|
|
181
|
+
* `accent: 'multicolor'` cycles by default: that palette exists to be cycled, and
|
|
182
|
+
* a single-colour reading of it paints every bar red, which is the deck bug
|
|
183
|
+
* (D17). This function is the ONE place the cycle lives — ECharts had it and
|
|
184
|
+
* Chart.js did not, so the site wrote its own (`docs_gen.py`).
|
|
185
|
+
*
|
|
186
|
+
* @returns {string[]} one colour per bar, `n` long
|
|
187
|
+
*/
|
|
188
|
+
function barColors(theme, accent, n, treatment) {
|
|
189
|
+
const how = treatment || (accent === 'multicolor' ? 'multicolor' : 'flat');
|
|
190
|
+
const out = [];
|
|
191
|
+
for (let i = 0; i < n; i++) {
|
|
192
|
+
if (how === 'flat') out.push(seriesColor(theme, accent, 0));
|
|
193
|
+
else if (how === 'multicolor') out.push(seriesColor(theme, 'multicolor', i));
|
|
194
|
+
else out.push(seriesColor(theme, accent, i));
|
|
195
|
+
}
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** RULES 3 and 4 — the corner radii of one bar, as the four-corner array both
|
|
200
|
+
* renderers take. `horizontal` rotates the rounded pair to the bar's end;
|
|
201
|
+
* `stack` is undefined for an unstacked bar, `'last'` for the top segment of a
|
|
202
|
+
* stack and `'mid'` for every segment below it.
|
|
203
|
+
*
|
|
204
|
+
* The foot of a column is square in every case: it stands on the baseline. */
|
|
205
|
+
function barRadius(horizontal, stack, all) {
|
|
206
|
+
const r = GEOMETRY.bar.radius;
|
|
207
|
+
if (all) return r;
|
|
208
|
+
if (stack === 'mid') return 0;
|
|
209
|
+
return horizontal ? [0, r, r, 0] : [r, r, 0, 0];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** RULE 6 — a ranked list reads largest first: the first row of the data sits at
|
|
213
|
+
* the TOP. ECharts runs a y category axis bottom-up, so a horizontal chart needs
|
|
214
|
+
* `inverse: true` and has been shipping upside down without it (D13). Chart.js
|
|
215
|
+
* and Figma already read top-down, which is why only one renderer was wrong. */
|
|
216
|
+
const RANKED_TOP_FIRST = true;
|
|
217
|
+
|
|
218
|
+
/** A hex colour at `alpha`, as `rgba()`. RULE 10's band is a colour AT an
|
|
219
|
+
* opacity, and the two renderers say that differently — ECharts takes an
|
|
220
|
+
* `areaStyle.opacity`, Chart.js takes one colour value — so the conversion lives
|
|
221
|
+
* here rather than in either of them. A colour that is not a plain `#rrggbb`
|
|
222
|
+
* (already rgba, a CSS name) is returned untouched. */
|
|
223
|
+
function withAlpha(hex, alpha) {
|
|
224
|
+
const m = /^#([0-9a-f]{6})$/i.exec(String(hex || '').trim());
|
|
225
|
+
if (!m) return hex;
|
|
226
|
+
const n = parseInt(m[1], 16);
|
|
227
|
+
return 'rgba(' + ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255) + ',' + alpha + ')';
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** The rendered width of a string in the chart's own type, in px.
|
|
231
|
+
*
|
|
232
|
+
* RULES 7 and D16 both come from a width that was guessed rather than measured:
|
|
233
|
+
* 200px reserved for a row name, 10.5px assumed for a character of a legend
|
|
234
|
+
* entry — his donut cut "MobuppsX" off the right edge. Measured with a canvas
|
|
235
|
+
* when there is a document to make one in (every browser channel), and estimated
|
|
236
|
+
* from `groupLegendCharRatio` when there is not (a Node render, a test). */
|
|
237
|
+
let measureCtx;
|
|
238
|
+
function textWidth(text, size, weight) {
|
|
239
|
+
const str = String(text == null ? '' : text);
|
|
240
|
+
if (typeof document !== 'undefined' && document.createElement) {
|
|
241
|
+
try {
|
|
242
|
+
measureCtx = measureCtx || document.createElement('canvas').getContext('2d');
|
|
243
|
+
measureCtx.font = (weight ? weight + ' ' : '') + size + 'px ' + THEME.font;
|
|
244
|
+
const w = measureCtx.measureText(str).width;
|
|
245
|
+
if (w > 0) return Math.ceil(w);
|
|
246
|
+
} catch (e) { /* no canvas here — fall through to the estimate */ }
|
|
247
|
+
}
|
|
248
|
+
return Math.ceil(str.length * size * GEOMETRY.donut.groupLegendCharRatio);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** The ink of a label written INSIDE a fill (D110, 2026-09-17).
|
|
252
|
+
*
|
|
253
|
+
* The fill decides it. On an accent's main colour the label takes that accent's
|
|
254
|
+
* own `on-accent` — near-black on yellow, white on blue and red — and because the
|
|
255
|
+
* rule is keyed by the fill and not by the mode, multicolor's yellow series flips
|
|
256
|
+
* with it. On any other fill the label stays `onFill`. A white label on
|
|
257
|
+
* yellow/400 read 1.62 : 1; yellow's own ink reads 12.65.
|
|
258
|
+
*
|
|
259
|
+
* Both sides come from `THEME.labelOn`, generated from `accent/<mode>/chart/1`
|
|
260
|
+
* and `accent/<mode>/on-accent`. A caller's explicit `label_color` still wins. */
|
|
261
|
+
function labelInk(theme, fill) {
|
|
262
|
+
const f = typeof fill === 'string' ? fill.toLowerCase() : '';
|
|
263
|
+
for (const accent of Object.keys(theme.labelOn)) {
|
|
264
|
+
if (theme.labelOn[accent].fill.toLowerCase() === f) return theme.labelOn[accent].ink;
|
|
265
|
+
}
|
|
266
|
+
return theme.onFill;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** The narrow-band axis floor (stress-test S04, 2026-07-09).
|
|
270
|
+
*
|
|
271
|
+
* Tight-band data — uptime 99.92–99.99 on a 0–100 axis — renders as identical
|
|
272
|
+
* full bars. When every value sits in a narrow high band, lift the axis floor so
|
|
273
|
+
* the variation is visible. Mixed-sign data is left alone, and an explicit
|
|
274
|
+
* `axis.min` always wins (the caller applies that, not this).
|
|
275
|
+
*
|
|
276
|
+
* @param {number[]} values every numeric value in the chart
|
|
277
|
+
* @returns {number|undefined} the floor, or undefined when the rule does not apply
|
|
278
|
+
*/
|
|
279
|
+
function narrowBandFloor(values) {
|
|
280
|
+
if (!values || values.length <= 1) return undefined;
|
|
281
|
+
const lo = Math.min(...values);
|
|
282
|
+
const hi = Math.max(...values);
|
|
283
|
+
if (!(lo > 0 && hi > 0 && (hi - lo) / hi < 0.05)) return undefined;
|
|
284
|
+
const pad = (hi - lo) || hi * 0.01;
|
|
285
|
+
return Math.max(0, Math.floor((lo - pad) * 100) / 100);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** The two-slice donut rule (Fedor 2026-08-12).
|
|
289
|
+
*
|
|
290
|
+
* A donut with exactly TWO slices reads as share-vs-rest: slice 1 keeps the
|
|
291
|
+
* accent's main colour and slice 2 is always `chart/rest` — never the accent's
|
|
292
|
+
* readable mid tint, which is the rule for every OTHER chart including 3+ slice
|
|
293
|
+
* donuts. An explicit `colors` on the config still wins (the kpi_donuts progress
|
|
294
|
+
* rings, the designed layouts). `pptx_export.chart` mirrors it per point.
|
|
295
|
+
*/
|
|
296
|
+
function isTwoSlice(labels) {
|
|
297
|
+
return (labels || []).length === 2;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** The colour of slice `i` of a donut, with the two-slice rule applied.
|
|
301
|
+
* `explicit` is the config's own `colors` array and always wins. */
|
|
302
|
+
function sliceColor(theme, accent, labels, i, explicit) {
|
|
303
|
+
if (explicit && explicit[i]) return explicit[i];
|
|
304
|
+
if (isTwoSlice(labels) && i === 1) return theme.rest;
|
|
305
|
+
return seriesColor(theme, accent, i);
|
|
306
|
+
}
|