@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.
@@ -0,0 +1,1236 @@
1
+ /**
2
+ * pepper-charts.mjs — the ES module. Nothing runs on import; call renderAll yourself.
3
+ * @pepperui/charts 1.1.0 — the Mobupps design system's chart theme: one palette, four renderers (DL-10).
4
+ * GENERATED by `npm run build` from packages/charts/src and the token source — do not edit by hand; edit the
5
+ * part named in each section's header, or tokens/*.json for a value. THEME below is generated from
6
+ * @pepperui/tokens 1.2.0; every entry's token path is in THEME.$sources.
7
+ * Source of truth: bitbucket.org/rashidmobupps/pepper-ui — 6 parts.
8
+ */
9
+ const VERSION = "1.1.0";
10
+ const THEME = {
11
+ "font": "Poppins, sans-serif",
12
+ "text": {
13
+ "slide": {
14
+ "size": 15,
15
+ "weight": 400
16
+ },
17
+ "web-desktop": {
18
+ "size": 12,
19
+ "weight": 500
20
+ }
21
+ },
22
+ "weightStrong": 600,
23
+ "ink": "#0b0202",
24
+ "grid": "#00000012",
25
+ "rest": "#dad9d9",
26
+ "onFill": "#ffffff",
27
+ "labelOn": {
28
+ "blue": {
29
+ "fill": "#2b25d9",
30
+ "ink": "#ffffff"
31
+ },
32
+ "red": {
33
+ "fill": "#e42521",
34
+ "ink": "#ffffff"
35
+ },
36
+ "yellow": {
37
+ "fill": "#f9c426",
38
+ "ink": "#0b0202"
39
+ }
40
+ },
41
+ "page": "#ffffff",
42
+ "series": {
43
+ "blue": [
44
+ "#2b25d9",
45
+ "#6a66e4",
46
+ "#d4d3f7",
47
+ "#b5b3b3",
48
+ "#dad9d9"
49
+ ],
50
+ "red": [
51
+ "#e42521",
52
+ "#ec6663",
53
+ "#f9d3d2",
54
+ "#b5b3b3",
55
+ "#dad9d9"
56
+ ],
57
+ "yellow": [
58
+ "#f9c426",
59
+ "#858080",
60
+ "#dad9d9",
61
+ "#fad567",
62
+ "#b5b3b3"
63
+ ],
64
+ "multicolor": [
65
+ "#e42521",
66
+ "#f9c426",
67
+ "#2b25d9",
68
+ "#b5b3b3",
69
+ "#ec6663",
70
+ "#6a66e4",
71
+ "#fad567",
72
+ "#858080"
73
+ ]
74
+ },
75
+ "$sources": {
76
+ "series.blue.0": "accent.blue.chart.1",
77
+ "series.blue.1": "accent.blue.chart.2",
78
+ "series.blue.2": "accent.blue.chart.3",
79
+ "series.blue.3": "accent.blue.chart.4",
80
+ "series.blue.4": "accent.blue.chart.5",
81
+ "series.red.0": "accent.red.chart.1",
82
+ "series.red.1": "accent.red.chart.2",
83
+ "series.red.2": "accent.red.chart.3",
84
+ "series.red.3": "accent.red.chart.4",
85
+ "series.red.4": "accent.red.chart.5",
86
+ "series.yellow.0": "accent.yellow.chart.1",
87
+ "series.yellow.1": "accent.yellow.chart.2",
88
+ "series.yellow.2": "accent.yellow.chart.3",
89
+ "series.yellow.3": "accent.yellow.chart.4",
90
+ "series.yellow.4": "accent.yellow.chart.5",
91
+ "series.multicolor.0": "accent.multicolor.chart.1",
92
+ "series.multicolor.1": "accent.multicolor.chart.2",
93
+ "series.multicolor.2": "accent.multicolor.chart.3",
94
+ "series.multicolor.3": "accent.multicolor.chart.4",
95
+ "series.multicolor.4": "accent.multicolor.chart.5",
96
+ "series.multicolor.5": "accent.blue.chart.2",
97
+ "series.multicolor.6": "accent.yellow.chart.4",
98
+ "series.multicolor.7": "accent.yellow.chart.2",
99
+ "labelOn.blue.fill": "accent.blue.chart.1",
100
+ "labelOn.blue.ink": "accent.blue.on-accent",
101
+ "labelOn.red.fill": "accent.red.chart.1",
102
+ "labelOn.red.ink": "accent.red.on-accent",
103
+ "labelOn.yellow.fill": "accent.yellow.chart.1",
104
+ "labelOn.yellow.ink": "accent.yellow.on-accent",
105
+ "text.slide": "type.slide.label.medium",
106
+ "text.web-desktop": "type.web-desktop.label.medium",
107
+ "font": "type.slide.label.medium",
108
+ "weightStrong": "font.weight.600",
109
+ "ink": "chart.text",
110
+ "grid": "chart.grid",
111
+ "rest": "chart.rest",
112
+ "onFill": "chart.on-fill",
113
+ "page": "surface.page"
114
+ }
115
+ };
116
+
117
+ /* ============================================================================
118
+ format — the number-format OBJECT and the legacy readers.
119
+
120
+ DL-10 names this as part of the package because three renderers have to agree
121
+ on it: the ECharts option below, Chart.js (3.1.2) and `pptx_export.py`, which
122
+ mirrors the same resolution order and the same compact magnitude as an Excel
123
+ number-format code. Moved here from Paggy's `charts.js` unchanged — the comments
124
+ are its own, and every one of them records a bug this code is the fix for.
125
+ ============================================================================ */
126
+
127
+ /* ---------- the legacy strings, kept forever as READERS ----------
128
+ Retired as PRODUCED (Fedor 2026-08-14): nothing mints a `currency_*` any more,
129
+ because a dollar sign baked into the renderer shipped dollar labels on a euro
130
+ column. A deck built before the format object still has to render the way it
131
+ was drawn, so the readers stay. */
132
+ function fmtCurrencyM(v) {
133
+ if (v == null) return '';
134
+ const n = Number(v);
135
+ if (Math.abs(n) >= 1e6) return '$' + (n / 1e6).toFixed(2).replace(/\.?0+$/, '') + 'M';
136
+ if (Math.abs(n) >= 1e3) return '$' + Math.round(n / 1e3) + 'K';
137
+ return '$' + n;
138
+ }
139
+ function fmtCurrencyK(v) {
140
+ if (v == null) return '';
141
+ const n = Number(v);
142
+ if (Math.abs(n) >= 1e3) {
143
+ const k = n / 1e3;
144
+ // sub-10K keeps decimals so $1,000 / $1,040 / $1,215 don't ALL collapse
145
+ // to "$1K" (stress-test T04, 2026-07-09)
146
+ const s = Math.abs(k) < 10 ? k.toFixed(2).replace(/\.?0+$/, '')
147
+ : Math.round(k).toLocaleString('en-US');
148
+ return '$' + s + 'K';
149
+ }
150
+ return '$' + n;
151
+ }
152
+ function fmtPlain(v) { return v == null ? '' : Number(v).toLocaleString('en-US'); }
153
+ function fmtPercent(v) { return v == null ? '' : Number(v).toLocaleString('en-US') + '%'; }
154
+
155
+ function formatter(kind) {
156
+ switch (kind) {
157
+ case 'currency_M': return fmtCurrencyM;
158
+ case 'currency_K': return fmtCurrencyK;
159
+ case 'percent': return fmtPercent;
160
+ default: return fmtPlain;
161
+ }
162
+ }
163
+
164
+ /* ---------- the number FORMAT object (Fedor 2026-08-14) ----------
165
+ The old $K/$M pills baked a DOLLAR sign into the renderer, so a euro column
166
+ shipped dollar labels ("what if the user has euros, K euros, millions or
167
+ pounds?"). The format is currency-neutral instead: a style, a VERBATIM
168
+ prefix/suffix and an optional decimals override.
169
+ fmt = { style: auto|group|group2|compact, prefix, suffix, decimals }
170
+ auto today's plain number group 1,000 (0 decimals)
171
+ group2 1,000.00 (2 decimals) compact 1.2K / 1.2M (1 decimal)
172
+ decimals: null = follow the style, otherwise 0..4 and it wins
173
+ prefix/suffix are cosmetic TEXT, never a unit conversion. */
174
+ // PER VALUE, not one unit for the whole chart (Fedor 2026-08-14). A shared
175
+ // magnitude turns 820,000 into "$0.8M" and 310,000 into "$0.3M" — a lost
176
+ // significant digit and a label nobody writes by hand. The retired
177
+ // currency_K/currency_M readers always shortened per value ($820K beside
178
+ // $1.04M) and that is the look every existing deck has; `compact` is their
179
+ // currency-NEUTRAL twin, so it must shorten the same way. Verified against
180
+ // the T04_table_kpis golden, which is exactly this chart.
181
+ function compactUnit(v) {
182
+ const m = Math.abs(Number(v) || 0);
183
+ if (m >= 1e6) return { div: 1e6, tag: 'M' };
184
+ if (m >= 1e3) return { div: 1e3, tag: 'K' };
185
+ return { div: 1, tag: '' };
186
+ }
187
+
188
+ function fmtObject(f, vals) {
189
+ const style = f.style || 'auto';
190
+ const pre = f.prefix == null ? '' : String(f.prefix);
191
+ const suf = f.suffix == null ? '' : String(f.suffix);
192
+ const dec = (f.decimals == null || f.decimals === '')
193
+ ? null : Math.max(0, Math.min(4, Math.round(Number(f.decimals)) || 0));
194
+ const compact = style === 'compact';
195
+ return function (v) {
196
+ if (v == null || v === '') return '';
197
+ let n = Number(v);
198
+ if (!isFinite(n)) return '';
199
+ let d = dec;
200
+ let unit = compact ? compactUnit(n) : null;
201
+ // 3 significant digits is what the retired currency readers actually did
202
+ // ($820K, $1.04M, $1.22K) — a fixed decimal count cannot do both, and it
203
+ // is the rule that keeps a shortened label as informative as the number.
204
+ if (unit) {
205
+ // ...but the rounding can push a value INTO the next band: 999.5 with
206
+ // 0 decimals prints "1,000" while 1000 prints "1K", so two bars a half
207
+ // apart would wear different units. Re-pick the unit from the ROUNDED
208
+ // value (once is enough — 999.5 → 1000 → K → 1.0 is already stable).
209
+ const dOf = (x) => Math.max(0, 3 - String(Math.floor(Math.abs(x))).length);
210
+ if (dec == null) {
211
+ const r = Math.round(Math.abs(n / unit.div) * 10 ** dOf(n / unit.div)) /
212
+ 10 ** dOf(n / unit.div);
213
+ if (r >= 1000) unit = compactUnit(Math.sign(n) * r * unit.div);
214
+ }
215
+ n = n / unit.div;
216
+ if (d == null) d = dOf(n);
217
+ } else if (d == null && style === 'group') d = 0;
218
+ else if (d == null && style === 'group2') d = 2;
219
+ let s = d == null ? n.toLocaleString('en-US')
220
+ : n.toLocaleString('en-US', { minimumFractionDigits: d, maximumFractionDigits: d });
221
+ // "2.0K"/"1.00M" read as 2K/1M — but a decimals setting typed by hand
222
+ // is honoured exactly as typed
223
+ if (unit) s = (dec == null ? s.replace(/\.(\d*?)0+$/, (m, k) => (k ? '.' + k : '')) : s) + unit.tag;
224
+ return pre + s + suf;
225
+ };
226
+ }
227
+
228
+ /* THE resolution order (locked with the panel, 2026-08-14): the fmt object if
229
+ the chart carries one → else its legacy string → else plain. Nothing ever
230
+ silently repaints a deck built before the panel existed. Shared with the
231
+ editor's chart modal (its live preview IS this function). */
232
+ function valueFormatter(fmt, legacy, vals) {
233
+ if (fmt && typeof fmt === 'object') return fmtObject(fmt, vals);
234
+ return formatter(legacy || 'plain');
235
+ }
236
+
237
+ /* ============================================================================
238
+ rules — the house chart rules that are not colour, and the recipe geometry.
239
+
240
+ Every number here is drawn on the Figma page `Chart recipes` (281:2) and every
241
+ one of them is a decision with a date. They live in one place so the ECharts
242
+ builder below, Chart.js (3.1.2) and the PPTX exporter (3.1.3) cannot drift
243
+ apart the way the palettes did (G13).
244
+
245
+ THE HOUSE CHART STANDARD (3.8.1, 2026-09-21; rule 12 added 2026-09-21 on his review of
246
+ the built result). Twelve rules decided on rendered
247
+ pairs — `artifacts/requests/2026-09-21-chart-standard/decide.html`, his picks in
248
+ `answers.json`, the spec in `STANDARD.md`. They are stated here, once, because
249
+ the audit that found them (3.8.0) found that a picture fixed on its own comes
250
+ back: the missing baseline was not a slip in one page, it was this file and the
251
+ two renderers saying `axisLine: { show: false }` three times over.
252
+
253
+ 1 a column stands on a baseline, heavier than the grid `axis.baseline`
254
+ 2 a line at every tick, behind the bars `axis.grid`
255
+ 3 a column is rounded on top, square at the foot `bar.radius`
256
+ 4 a stacked column rounds its TOP SEGMENT only `barRadius()`
257
+ 5 a line carries a dot at every point `line.symbolSize`
258
+ 6 a ranked list reads largest first `RANKED_TOP_FIRST`
259
+ 7 the row-name lane is measured, not reserved `category`
260
+ 8 a donut writes its share in the legend only `donut.labelsOn`
261
+ 9 a one-series chart has THREE treatments, all documented `barColors()`
262
+ 10 the forecast area is the accent at 12 % `area.opacity`
263
+ 11 the dashed forecast line is `chart/4` `area.forecast`
264
+ 12 a TWO-series chart is `chart/1` against `chart/rest` `seriesColor()`
265
+
266
+ Rule 11 is a stated exemption: `chart/4` on the 12 % accent reads 1.68 : 1,
267
+ under the 3.0 : 1 floor a meaningful graphical line is held to. He was shown
268
+ the three readings and picked it; `docs/contrast-matrix.md` carries the pair
269
+ with his decision beside it, the way D110's twelve kept pairs are.
270
+ ============================================================================ */
271
+
272
+ /** The chart-recipe geometry. Names are the recipe's, values are the ones the
273
+ * plots on `Chart recipes` were drawn at. */
274
+ const GEOMETRY = {
275
+ // RULES 1 and 2 — the lines of a plot. A column stands on a baseline drawn in
276
+ // `chart/rest` at 2px, heavier than the grid behind it, and the grid keeps a
277
+ // line at every tick. The baseline belongs to the CATEGORY axis: the foot of a
278
+ // column chart, the left edge of a ranked one — in both it is the line the bars
279
+ // stand on. The value axis draws none.
280
+ axis: {
281
+ baselineWidth: 2,
282
+ gridWidth: 1,
283
+ },
284
+ bar: {
285
+ // RULE 3 — 4px on the top corners only, square at the foot, in BOTH renderers.
286
+ // The foot used to be rounded too on the Chart.js side, on the reasoning that
287
+ // a fully rounded column would not look like it needed a baseline; it now has
288
+ // one, so the shape follows the baseline back (D2, `chartjs.js` drops
289
+ // `borderSkipped: false`).
290
+ // RULE 4 — a stacked column rounds the TOP of its LAST segment and nothing
291
+ // else: the joints stay square, because a rounded joint draws a gap that is
292
+ // not in the data. See `barRadius()`.
293
+ radius: 4,
294
+ maxWidth: 96,
295
+ },
296
+ line: {
297
+ // RULE 5 — a 4px spline with a dot at every point. This reverses what this
298
+ // file said until 2026-09-21 (*no dots — the dot lane is for end markers
299
+ // only*): one renderer obeyed the written rule and the other did not, and he
300
+ // picked the dots on the pair (D14).
301
+ width: 4,
302
+ symbolSize: 9,
303
+ },
304
+ // RULES 10 and 11 — an area (the forecast band) is its series colour at 12 %,
305
+ // and the dashed line over it is `chart/4` at 3px, 6 on 6 off. The fill used to
306
+ // render black at 22 % in Figma because a bound paint carried `#000000` as its
307
+ // written colour (D4); the opacity is his pick from the three rendered bands.
308
+ // The line on an area carries no dots — the pictures rules 10 and 11 were
309
+ // picked on were drawn that way.
310
+ area: {
311
+ opacity: 0.12,
312
+ forecast: { width: 3, dash: [6, 6] },
313
+ },
314
+ legend: {
315
+ // circular markers, top-right, 18 apart. The Figma page draws the gap at
316
+ // `space/16` and states the 2px difference rather than moving the code.
317
+ itemWidth: 10,
318
+ itemHeight: 10,
319
+ itemGap: 18,
320
+ },
321
+ grid: {
322
+ // the plot's padding inside its box; `top` leaves the label lane above the
323
+ // tallest column (26), and twice that when a legend sits above it (44)
324
+ left: 8,
325
+ right: 40,
326
+ topWithLegend: 44,
327
+ top: 26,
328
+ bottom: 4,
329
+ },
330
+ category: {
331
+ // RULE 7 — the row-name lane is MEASURED, not reserved. 200px used to be held
332
+ // for the name however short the names were, which left the wide empty block
333
+ // on the left of every ranked card (D6). Nothing is reserved now: the lane is
334
+ // as wide as the longest name and `containLabel` fits the grid round it. A
335
+ // caller that must truncate passes `label_width` itself.
336
+ labelGap: 12,
337
+ },
338
+ donut: {
339
+ // RULE 8 — a donut writes its share in the LEGEND ONLY; the ring stays clean.
340
+ // Outside labels used to be on unless a caller turned them off, so a composed
341
+ // donut printed every share twice and ran its leader lines out of the box
342
+ // (D15). The one exception is a donut with no legend to write into
343
+ // (`legend: false`, the KPI progress rings): the share goes back on the ring,
344
+ // because the alternative is a chart that states its number nowhere.
345
+ labelsOn: false,
346
+ // the ring: 72% cutout, 3px borders in the on-fill white, slices in the
347
+ // accent's series order
348
+ cutout: 72,
349
+ segmentBorder: 3,
350
+ radiusInner: '58%',
351
+ radiusOuter: '85%',
352
+ // the composed "ring beside its legend" block (donut_compare): the ring takes
353
+ // 62% of the shorter side, the legend is measured from its real entries at
354
+ // ~10.5px a character, and the pair is centred as ONE group
355
+ groupRingShare: 0.62,
356
+ groupGap: 56,
357
+ groupLegendMax: 380,
358
+ // the fallback when there is no canvas to measure with: Poppins' average
359
+ // advance is about 0.55 of the size. `textWidth()` measures the real string
360
+ // whenever it can — the estimate at 10.5px a character is what cut
361
+ // "MobuppsX — 14%" off the right edge of his donut (D16)
362
+ groupLegendCharRatio: 0.55,
363
+ groupLegendPad: 34,
364
+ groupEdgeMin: 24,
365
+ groupLegend: { itemWidth: 16, itemHeight: 16, itemGap: 22, fontSize: 20 },
366
+ legend: { itemWidth: 14, itemHeight: 14, itemGap: 16, fontSize: 16 },
367
+ // a config that reaches the renderer carrying none of the house flags is
368
+ // restyled to this rather than shipping as a default-ECharts pie
369
+ // labels come from `labelsOn` above — the standard, not a second copy of it
370
+ unstyled: { cutout: 70, segmentBorder: 0, legendPct: true },
371
+ },
372
+ };
373
+
374
+ /** The canvas a chart is drawn on decides its text size: 15 on a slide, 12 on
375
+ * web (`type/<canvas>/label/medium`, D36). Slide is the default because the first
376
+ * consumer is a 1920x1080 deck, and an unknown canvas is a slide rather than an
377
+ * error — a chart with no text is worse than a chart with slide-sized text.
378
+ * Shared by both renderers. */
379
+ function chartText(canvas) {
380
+ return THEME.text[canvas] || THEME.text.slide;
381
+ }
382
+
383
+ /** Series `i` of `accent`, cycling. The palettes are monochromatic per accent —
384
+ * the accent's own tints — except `multicolor`, which is distinct hues; either
385
+ * way the cycle repeats when a chart has more series than the palette has
386
+ * entries. An unknown accent falls back to blue, the default mode.
387
+ *
388
+ * Every value comes from `THEME.series`, which is generated from
389
+ * `accent/<mode>/chart/1–5` — no palette is typed by hand here or anywhere else
390
+ * (G13, DL-10). */
391
+ function seriesColor(theme, accent, i, count) {
392
+ // RULE 12 — a chart with exactly TWO series is a comparison, and a comparison reads as one thing
393
+ // against the rest of it: series 1 keeps the accent's `chart/1` and series 2 is `chart/rest`, never
394
+ // the accent's second tint. This is the two-slice donut rule (Fedor 2026-08-12) generalised to every
395
+ // chart, at his word on 2026-09-21: *"when you're using two two color charts, so we can use this
396
+ // gray as the second color… the same way a two-coloured donut chart uses"*. `count` is how many
397
+ // series the chart has; a caller that does not know it gets the plain palette.
398
+ if (count === 2 && i === 1) return theme.rest;
399
+ const base = theme.series[accent] || theme.series.blue;
400
+ return base[i % base.length];
401
+ }
402
+
403
+ /** RULE 9 — the colours of a ONE-SERIES chart. Three treatments, all correct,
404
+ * all documented (Fedor 2026-09-21: *"you have multicolor, you have one color,
405
+ * and you have color with different tints. All these three options are
406
+ * correct"*). The design system's job is to make all three available and to show
407
+ * all three, not to pick one.
408
+ *
409
+ * 'flat' every bar in the accent's `chart/1` — the default in an accent
410
+ * mode, which is what "a chart follows the accent of the slide"
411
+ * means, and what the system already did
412
+ * 'tints' the accent's own chart/1–5, cycling — for a comparison across
413
+ * the bars
414
+ * 'multicolor' the red→yellow→blue→gray cycle, hand-set and never automatic
415
+ * (`docs/DESIGN_SYSTEM.md:35`, quoted in STANDARD.md)
416
+ *
417
+ * `accent: 'multicolor'` cycles by default: that palette exists to be cycled, and
418
+ * a single-colour reading of it paints every bar red, which is the deck bug
419
+ * (D17). This function is the ONE place the cycle lives — ECharts had it and
420
+ * Chart.js did not, so the site wrote its own (`docs_gen.py`).
421
+ *
422
+ * @returns {string[]} one colour per bar, `n` long
423
+ */
424
+ function barColors(theme, accent, n, treatment) {
425
+ const how = treatment || (accent === 'multicolor' ? 'multicolor' : 'flat');
426
+ const out = [];
427
+ for (let i = 0; i < n; i++) {
428
+ if (how === 'flat') out.push(seriesColor(theme, accent, 0));
429
+ else if (how === 'multicolor') out.push(seriesColor(theme, 'multicolor', i));
430
+ else out.push(seriesColor(theme, accent, i));
431
+ }
432
+ return out;
433
+ }
434
+
435
+ /** RULES 3 and 4 — the corner radii of one bar, as the four-corner array both
436
+ * renderers take. `horizontal` rotates the rounded pair to the bar's end;
437
+ * `stack` is undefined for an unstacked bar, `'last'` for the top segment of a
438
+ * stack and `'mid'` for every segment below it.
439
+ *
440
+ * The foot of a column is square in every case: it stands on the baseline. */
441
+ function barRadius(horizontal, stack, all) {
442
+ const r = GEOMETRY.bar.radius;
443
+ if (all) return r;
444
+ if (stack === 'mid') return 0;
445
+ return horizontal ? [0, r, r, 0] : [r, r, 0, 0];
446
+ }
447
+
448
+ /** RULE 6 — a ranked list reads largest first: the first row of the data sits at
449
+ * the TOP. ECharts runs a y category axis bottom-up, so a horizontal chart needs
450
+ * `inverse: true` and has been shipping upside down without it (D13). Chart.js
451
+ * and Figma already read top-down, which is why only one renderer was wrong. */
452
+ const RANKED_TOP_FIRST = true;
453
+
454
+ /** A hex colour at `alpha`, as `rgba()`. RULE 10's band is a colour AT an
455
+ * opacity, and the two renderers say that differently — ECharts takes an
456
+ * `areaStyle.opacity`, Chart.js takes one colour value — so the conversion lives
457
+ * here rather than in either of them. A colour that is not a plain `#rrggbb`
458
+ * (already rgba, a CSS name) is returned untouched. */
459
+ function withAlpha(hex, alpha) {
460
+ const m = /^#([0-9a-f]{6})$/i.exec(String(hex || '').trim());
461
+ if (!m) return hex;
462
+ const n = parseInt(m[1], 16);
463
+ return 'rgba(' + ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255) + ',' + alpha + ')';
464
+ }
465
+
466
+ /** The rendered width of a string in the chart's own type, in px.
467
+ *
468
+ * RULES 7 and D16 both come from a width that was guessed rather than measured:
469
+ * 200px reserved for a row name, 10.5px assumed for a character of a legend
470
+ * entry — his donut cut "MobuppsX" off the right edge. Measured with a canvas
471
+ * when there is a document to make one in (every browser channel), and estimated
472
+ * from `groupLegendCharRatio` when there is not (a Node render, a test). */
473
+ let measureCtx;
474
+ function textWidth(text, size, weight) {
475
+ const str = String(text == null ? '' : text);
476
+ if (typeof document !== 'undefined' && document.createElement) {
477
+ try {
478
+ measureCtx = measureCtx || document.createElement('canvas').getContext('2d');
479
+ measureCtx.font = (weight ? weight + ' ' : '') + size + 'px ' + THEME.font;
480
+ const w = measureCtx.measureText(str).width;
481
+ if (w > 0) return Math.ceil(w);
482
+ } catch (e) { /* no canvas here — fall through to the estimate */ }
483
+ }
484
+ return Math.ceil(str.length * size * GEOMETRY.donut.groupLegendCharRatio);
485
+ }
486
+
487
+ /** The ink of a label written INSIDE a fill (D110, 2026-09-17).
488
+ *
489
+ * The fill decides it. On an accent's main colour the label takes that accent's
490
+ * own `on-accent` — near-black on yellow, white on blue and red — and because the
491
+ * rule is keyed by the fill and not by the mode, multicolor's yellow series flips
492
+ * with it. On any other fill the label stays `onFill`. A white label on
493
+ * yellow/400 read 1.62 : 1; yellow's own ink reads 12.65.
494
+ *
495
+ * Both sides come from `THEME.labelOn`, generated from `accent/<mode>/chart/1`
496
+ * and `accent/<mode>/on-accent`. A caller's explicit `label_color` still wins. */
497
+ function labelInk(theme, fill) {
498
+ const f = typeof fill === 'string' ? fill.toLowerCase() : '';
499
+ for (const accent of Object.keys(theme.labelOn)) {
500
+ if (theme.labelOn[accent].fill.toLowerCase() === f) return theme.labelOn[accent].ink;
501
+ }
502
+ return theme.onFill;
503
+ }
504
+
505
+ /** The narrow-band axis floor (stress-test S04, 2026-07-09).
506
+ *
507
+ * Tight-band data — uptime 99.92–99.99 on a 0–100 axis — renders as identical
508
+ * full bars. When every value sits in a narrow high band, lift the axis floor so
509
+ * the variation is visible. Mixed-sign data is left alone, and an explicit
510
+ * `axis.min` always wins (the caller applies that, not this).
511
+ *
512
+ * @param {number[]} values every numeric value in the chart
513
+ * @returns {number|undefined} the floor, or undefined when the rule does not apply
514
+ */
515
+ function narrowBandFloor(values) {
516
+ if (!values || values.length <= 1) return undefined;
517
+ const lo = Math.min(...values);
518
+ const hi = Math.max(...values);
519
+ if (!(lo > 0 && hi > 0 && (hi - lo) / hi < 0.05)) return undefined;
520
+ const pad = (hi - lo) || hi * 0.01;
521
+ return Math.max(0, Math.floor((lo - pad) * 100) / 100);
522
+ }
523
+
524
+ /** The two-slice donut rule (Fedor 2026-08-12).
525
+ *
526
+ * A donut with exactly TWO slices reads as share-vs-rest: slice 1 keeps the
527
+ * accent's main colour and slice 2 is always `chart/rest` — never the accent's
528
+ * readable mid tint, which is the rule for every OTHER chart including 3+ slice
529
+ * donuts. An explicit `colors` on the config still wins (the kpi_donuts progress
530
+ * rings, the designed layouts). `pptx_export.chart` mirrors it per point.
531
+ */
532
+ function isTwoSlice(labels) {
533
+ return (labels || []).length === 2;
534
+ }
535
+
536
+ /** The colour of slice `i` of a donut, with the two-slice rule applied.
537
+ * `explicit` is the config's own `colors` array and always wins. */
538
+ function sliceColor(theme, accent, labels, i, explicit) {
539
+ if (explicit && explicit[i]) return explicit[i];
540
+ if (isTwoSlice(labels) && i === 1) return theme.rest;
541
+ return seriesColor(theme, accent, i);
542
+ }
543
+
544
+ /* ============================================================================
545
+ ECharts — the Pepper theme and the option builder (SVG renderer).
546
+
547
+ `echartsTheme(accent, canvas)` is the payload for `echarts.registerTheme`: the
548
+ defaults a chart written by hand still picks up. `buildOption(cfg, box)` is the
549
+ whole house chart — the shape Paggy's decks, the editor's preview and the PPTX
550
+ exporter all agree on — built from the same THEME.
551
+
552
+ Charts are static (`animation:false`) so the SVG output is clean vector.
553
+ ============================================================================ */
554
+
555
+ /** The colour of series `i`: the dataset's own `color` wins, then the accent's
556
+ * palette. */
557
+ function palette(accent, series, i, count) {
558
+ return (series && series.color) || seriesColor(THEME, accent, i, count);
559
+ }
560
+
561
+ /** The ECharts theme object — `echarts.registerTheme('pepper-blue', echartsTheme('blue'))`.
562
+ * Everything `buildOption` sets explicitly is set here too, so a chart written
563
+ * by hand against the theme looks like a chart the builder made. */
564
+ function echartsTheme(accent, canvas) {
565
+ const t = chartText(canvas);
566
+ const text = { color: THEME.ink, fontFamily: THEME.font, fontSize: t.size };
567
+ return {
568
+ color: (THEME.series[accent] || THEME.series.blue).slice(),
569
+ backgroundColor: 'transparent',
570
+ animation: false,
571
+ textStyle: text,
572
+ legend: {
573
+ icon: 'circle',
574
+ itemWidth: GEOMETRY.legend.itemWidth,
575
+ itemHeight: GEOMETRY.legend.itemHeight,
576
+ itemGap: GEOMETRY.legend.itemGap,
577
+ textStyle: text,
578
+ },
579
+ // RULE 1 — the baseline is the CATEGORY axis' own line: the foot of a column
580
+ // chart, the left edge of a ranked one. `chart/rest` at 2px, heavier than the
581
+ // grid. RULE 2 — the grid keeps a line at every tick, on the value axis.
582
+ categoryAxis: {
583
+ axisLine: { show: true, lineStyle: { color: THEME.rest, width: GEOMETRY.axis.baselineWidth } },
584
+ axisTick: { show: false },
585
+ splitLine: { show: false },
586
+ axisLabel: text,
587
+ },
588
+ valueAxis: {
589
+ axisLine: { show: false },
590
+ axisTick: { show: false },
591
+ splitLine: { lineStyle: { color: THEME.grid, width: GEOMETRY.axis.gridWidth } },
592
+ axisLabel: text,
593
+ },
594
+ bar: { itemStyle: { borderRadius: barRadius(false) }, barMaxWidth: GEOMETRY.bar.maxWidth },
595
+ // RULE 5 — a dot at every point; stated rather than left to the ECharts default
596
+ line: { smooth: true, showSymbol: true, symbolSize: GEOMETRY.line.symbolSize, lineStyle: { width: GEOMETRY.line.width } },
597
+ pie: {
598
+ radius: [GEOMETRY.donut.radiusInner, GEOMETRY.donut.radiusOuter],
599
+ itemStyle: { borderColor: THEME.onFill, borderWidth: GEOMETRY.donut.segmentBorder },
600
+ },
601
+ };
602
+ }
603
+
604
+ /* ---------- bar / column / line ---------- */
605
+ function axisChart(cfg) {
606
+ const t = chartText(cfg.canvas);
607
+ const textStyle = (size) => ({ color: THEME.ink, fontFamily: THEME.font, fontSize: size || t.size });
608
+ const horizontal = cfg.index_axis === 'y' || cfg.type === 'hbar';
609
+ // an area and a sparkline are lines with a fill and, in the sparkline's case,
610
+ // nothing else drawn — they are chart types the recipes have always shown and
611
+ // the builder never had (D9)
612
+ const isArea = cfg.type === 'area' || cfg.type === 'sparkline';
613
+ const isLine = cfg.type === 'line' || isArea;
614
+ const spark = cfg.type === 'sparkline';
615
+ // axis labels and data labels share ONE formatter (cfg.value_format is the
616
+ // legacy per-label override) — the compact style needs every value to pick
617
+ // its magnitude, so the numbers are gathered before the axis is built
618
+ const allVals = (cfg.datasets || []).flatMap((d) => d.data || []).filter((v) => typeof v === 'number');
619
+ const valFmt = valueFormatter((cfg.axis && cfg.axis.fmt) || cfg.fmt,
620
+ (cfg.axis && cfg.axis.format) || cfg.format, allVals);
621
+ const labFmt = cfg.value_format ? formatter(cfg.value_format) : valFmt;
622
+
623
+ const series = (cfg.datasets || []).map((d, i) => {
624
+ const color = palette(cfg.accent, d, i, (cfg.datasets || []).length); // RULE 12 needs the count
625
+ const s = {
626
+ name: d.label || `Series ${i + 1}`,
627
+ type: isLine ? 'line' : 'bar',
628
+ data: d.data,
629
+ color,
630
+ label: {
631
+ show: cfg.data_labels !== false && !isLine,
632
+ position: cfg.label_inside === 'middle' ? 'inside' : (cfg.label_inside ? 'insideTop' : (horizontal ? 'right' : 'top')),
633
+ formatter: (p) => labFmt(p.value),
634
+ ...textStyle(),
635
+ ...(cfg.label_inside ? { color: cfg.label_color || labelInk(THEME, color) } : {}),
636
+ },
637
+ };
638
+ if (!isLine) {
639
+ s.barMaxWidth = cfg.bar_max || GEOMETRY.bar.maxWidth;
640
+ s.itemStyle = { color, borderRadius: barRadius(horizontal, undefined, cfg.radius_all) };
641
+ if (d.stack || cfg.stacked) {
642
+ s.stack = d.stack || 'total';
643
+ // RULE 4 — the TOP segment rounds, the joints stay square. Every segment
644
+ // used to be square, top included, which is the mark he made on the audit
645
+ // (*"where rounded corners?"*, D3). The last series of a stack is its top.
646
+ const last = i === (cfg.datasets || []).length - 1;
647
+ s.itemStyle.borderRadius = barRadius(horizontal, last ? 'last' : 'mid', cfg.radius_all);
648
+ s.label.position = 'inside';
649
+ s.label.formatter = d.pct_label ? (p) => p.value ? labFmt(p.value) : '' : s.label.formatter;
650
+ if (d.pct_color) s.label.color = d.pct_color;
651
+ }
652
+ } else {
653
+ s.smooth = cfg.smooth !== false;
654
+ s.lineStyle = { width: GEOMETRY.line.width, color };
655
+ // RULE 5 — a dot at every point. An area's own line carries none: the
656
+ // pictures rules 10 and 11 were picked on were drawn that way, and a
657
+ // forecast series says so for itself (`d.forecast`).
658
+ s.showSymbol = !isArea && cfg.dots !== false;
659
+ s.symbolSize = GEOMETRY.line.symbolSize;
660
+ s.label.show = cfg.data_labels === true;
661
+ if (isArea) {
662
+ // RULE 10 — the band is the series colour at 12 %
663
+ s.areaStyle = { color, opacity: cfg.area_opacity != null ? cfg.area_opacity : GEOMETRY.area.opacity };
664
+ }
665
+ if (d.forecast) {
666
+ // RULE 11 — the dashed forecast line is `chart/4`, 3px, 6 on 6 off. Its
667
+ // 1.68 : 1 on the 12 % band is a stated exemption, his pick, recorded in
668
+ // the contrast matrix.
669
+ const fc = seriesColor(THEME, cfg.accent, 3); // `chart/4` of this accent
670
+ s.lineStyle = { width: GEOMETRY.area.forecast.width, color: fc, type: GEOMETRY.area.forecast.dash };
671
+ s.itemStyle = { color: fc };
672
+ s.areaStyle = undefined;
673
+ s.showSymbol = false;
674
+ }
675
+ if (spark) { s.showSymbol = false; s.label.show = false; }
676
+ }
677
+ return s;
678
+ });
679
+
680
+ // RULE 9 — a ONE-SERIES chart has three treatments and all three are correct:
681
+ // one flat colour (the default in an accent mode), the accent's own tints, and
682
+ // multicolor. `barColors` is the one place the per-bar cycle lives, so Chart.js
683
+ // and the site read the same function instead of writing their own (D17). A
684
+ // caller names the treatment with `cfg.treatment`; `accent: 'multicolor'`
685
+ // cycles without being asked, because that palette exists to be cycled and a
686
+ // single-colour reading of it paints every bar red. Lines stay one colour (one
687
+ // line cannot cycle); multi-series charts keep their per-series hues.
688
+ if (series.length === 1 && !isLine && (cfg.treatment || cfg.accent === 'multicolor')) {
689
+ const bars = barColors(THEME, cfg.accent, (cfg.labels || cfg.datasets[0].data || []).length, cfg.treatment);
690
+ series[0].itemStyle.color = (p) => bars[p.dataIndex % bars.length];
691
+ }
692
+
693
+ const catAxis = {
694
+ type: 'category',
695
+ data: cfg.labels || [],
696
+ // RULE 1 — the baseline: `chart/rest` at 2px, the line the bars stand on
697
+ axisLine: { show: true, lineStyle: { color: THEME.rest, width: GEOMETRY.axis.baselineWidth } },
698
+ axisTick: { show: false },
699
+ // RULE 6 — largest first. ECharts runs a y category axis bottom-up, so a
700
+ // ranked chart has been shipping upside down (D13); `inverse` puts row 0 at
701
+ // the top, which is where the deck and Figma already read it.
702
+ inverse: cfg.inverse != null ? cfg.inverse : (horizontal && RANKED_TOP_FIRST),
703
+ axisLabel: {
704
+ ...textStyle(cfg.category_size || t.size),
705
+ rotate: cfg.category_rotation || 0,
706
+ interval: 0,
707
+ // RULE 7 — measured, not reserved: no width is held for the row name, and
708
+ // `containLabel` fits the grid round whatever it measures. A caller that
709
+ // must truncate a very long name passes `label_width` itself (D6).
710
+ ...(horizontal && cfg.label_width ? { width: cfg.label_width, overflow: 'truncate' } : {}),
711
+ },
712
+ };
713
+ // tight-band data (uptime 99.92-99.99 on a 0-100 axis) renders as identical
714
+ // full bars — the narrow-band floor lifts the axis so the variation is visible
715
+ // (rules.js, stress-test S04); an explicit cfg.axis.min always wins.
716
+ const autoMin = narrowBandFloor(allVals);
717
+ const valAxis = {
718
+ type: 'value',
719
+ min: (cfg.axis && cfg.axis.min) != null ? cfg.axis.min : autoMin,
720
+ max: cfg.axis && cfg.axis.max,
721
+ interval: cfg.axis && cfg.axis.interval,
722
+ // RULE 2 — a line at every tick, behind the bars
723
+ splitLine: { show: cfg.grid !== false, lineStyle: { color: THEME.grid, width: GEOMETRY.axis.gridWidth } },
724
+ axisLine: { show: false },
725
+ axisLabel: { ...textStyle(), formatter: (v) => valFmt(v) },
726
+ };
727
+
728
+ if (spark) {
729
+ // a sparkline is the line and nothing else: no axis, no grid, no labels
730
+ catAxis.show = false; valAxis.show = false;
731
+ catAxis.axisLine = { show: false }; catAxis.axisLabel = { show: false };
732
+ valAxis.splitLine = { show: false }; valAxis.axisLabel = { show: false };
733
+ }
734
+
735
+ return {
736
+ animation: false,
737
+ grid: spark ? { left: 0, right: 0, top: 2, bottom: 2 } : {
738
+ left: GEOMETRY.grid.left,
739
+ right: cfg.grid_right != null ? cfg.grid_right : GEOMETRY.grid.right,
740
+ top: cfg.grid_top != null ? cfg.grid_top
741
+ : ((cfg.datasets || []).length > 1 ? GEOMETRY.grid.topWithLegend : GEOMETRY.grid.top),
742
+ bottom: cfg.grid_bottom != null ? cfg.grid_bottom : GEOMETRY.grid.bottom,
743
+ containLabel: true,
744
+ },
745
+ legend: legendBlock(cfg, !spark && (cfg.datasets || []).length > 1 && cfg.legend !== false, textStyle),
746
+ xAxis: horizontal ? valAxis : catAxis,
747
+ yAxis: horizontal ? catAxis : valAxis,
748
+ series,
749
+ textStyle: textStyle(),
750
+ };
751
+ }
752
+
753
+ /** The top-right legend: circular markers, 10px, 18 apart. */
754
+ function legendBlock(cfg, show, textStyle) {
755
+ if (cfg.legend === true) show = true; // skill parity: legend even for one series
756
+ if (show === false) return { show: false };
757
+ return {
758
+ show: true, top: 0, right: 0, icon: 'circle',
759
+ itemWidth: GEOMETRY.legend.itemWidth,
760
+ itemHeight: GEOMETRY.legend.itemHeight,
761
+ itemGap: GEOMETRY.legend.itemGap,
762
+ textStyle: textStyle(),
763
+ };
764
+ }
765
+
766
+ /* ---------- donut (pie is retired) ---------- */
767
+ function pieChart(cfg, box) {
768
+ const t = chartText(cfg.canvas);
769
+ const textStyle = (size) => ({ color: THEME.ink, fontFamily: THEME.font, fontSize: size || t.size });
770
+ const D = GEOMETRY.donut;
771
+ // Pie is retired: every circular chart IS the house donut. Configs written
772
+ // before the write-path normalizers existed (or by anything that slips past
773
+ // them) arrive UNSTYLED — carrying none of the house flags — and used to
774
+ // render as a default-ECharts pie/donut with callout labels. Restyle those
775
+ // to the donut_compare spec here, at render time, so that look can never
776
+ // ship. Designed donut layouts and editor-converted donuts always carry at
777
+ // least one of the flags below and pass through untouched.
778
+ if (cfg.type === 'pie') cfg.type = 'donut';
779
+ if (!cfg.data && cfg.datasets && cfg.datasets[0])
780
+ cfg.data = cfg.datasets[0].data || [];
781
+ if (cfg.legend_pct == null && cfg.labels_on == null
782
+ && cfg.legend == null && cfg.cutout == null) {
783
+ cfg.labels_on = GEOMETRY.donut.labelsOn;
784
+ cfg.seg_border = D.unstyled.segmentBorder;
785
+ cfg.cutout = D.unstyled.cutout;
786
+ cfg.bg = cfg.bg || THEME.page;
787
+ cfg.legend_pct = D.unstyled.legendPct;
788
+ }
789
+ // TWO-slice donuts read as share-vs-rest (Fedor 2026-08-12): slice 1 keeps
790
+ // the accent's MAIN color, slice 2 is always chart/rest — never the accent's
791
+ // readable mid tint (that rule stands for every OTHER chart, incl. 3+ slice
792
+ // donuts). Assigned per slice right here so ECharts can never cycle past it;
793
+ // an explicit cfg.colors (kpi_donuts rings, designed layouts) still wins.
794
+ // PPTX mirrors in pptx_export.chart. See rules.js sliceColor.
795
+ const data = (cfg.labels || []).map((l, i) => ({
796
+ name: l,
797
+ value: (cfg.data || [])[i],
798
+ itemStyle: { color: sliceColor(THEME, cfg.accent, cfg.labels, i, cfg.colors) },
799
+ }));
800
+ // a donut has no axis block, so its format lives at the top level (the axis
801
+ // fallback only catches a config that came from an axis chart type-switch)
802
+ const fmtSrc = cfg.fmt || (cfg.axis && cfg.axis.fmt);
803
+ const fmt = valueFormatter(fmtSrc, cfg.format, cfg.data || []);
804
+ const total = (cfg.data || []).reduce((a, b) => a + (+b || 0), 0);
805
+ const noLegend = cfg.legend === false;
806
+ const ringLabels = cfg.labels_on != null ? cfg.labels_on : (noLegend ? true : GEOMETRY.donut.labelsOn);
807
+ const pctName = (name) => {
808
+ const i = (cfg.labels || []).indexOf(name);
809
+ const v = +((cfg.data || [])[i]) || 0;
810
+ return total ? name + ' — ' + Math.round(v / total * 100) + '%' : name;
811
+ };
812
+ // House composition for editor-converted donuts (legend_pct): a padded ring
813
+ // with the legend right beside it, the pair centred as ONE group in the box
814
+ // (the donut_compare look). Computed in px from the real element size, so a
815
+ // wide chart panel can't strand the legend at the far edge or blow the ring
816
+ // up to full height. Designed donut layouts (no legend_pct) are untouched.
817
+ let group = null;
818
+ if (cfg.legend_pct && cfg.legend !== false && box && box.w > 40 && box.h > 40) {
819
+ const R = Math.round(D.groupRingShare * Math.min(box.w, box.h) / 2);
820
+ const inner = Math.round(R * ((cfg.cutout || D.cutout) / 100));
821
+ // estimate the legend block width from the ACTUAL entries (20px Poppins ≈
822
+ // 10.5px/char + dot/gap) — a fixed guess skews the centring when labels are
823
+ // short ("Jan — 7%") or long ("PHL — Philippines — 14.3%")
824
+ // D16 — his mark, *"be carefull with layout becose mobuppsX numer on the right
825
+ // is cut out"*. The width used to be GUESSED at 10.5px a character and only
826
+ // the LEFT edge was clamped, so an entry wider than the guess ran off the
827
+ // right. Measured now, and clamped at both edges: when the group cannot fit,
828
+ // the ring gives way, not the text.
829
+ const wide = (cfg.labels || []).map(pctName)
830
+ .reduce((m, str) => Math.max(m, textWidth(str, D.groupLegend.fontSize)), 0);
831
+ const legendW = Math.min(D.groupLegendMax, wide + D.groupLegendPad);
832
+ const gap = D.groupGap;
833
+ const free = box.w - legendW - gap - 2 * D.groupEdgeMin;
834
+ const r = Math.max(8, Math.min(R, Math.round(free / 2)));
835
+ const inner2 = Math.round(r * ((cfg.cutout || D.cutout) / 100));
836
+ const left = Math.max(D.groupEdgeMin, Math.round((box.w - (2 * r + gap + legendW)) / 2));
837
+ group = { R: r, inner: inner2, cx: left + r, legendLeft: left + 2 * r + gap };
838
+ }
839
+ return {
840
+ animation: false,
841
+ // bg: the editor's type-switch sets the page white so a converted donut sits
842
+ // on white (designed donut layouts leave it unset — transparent, card decides)
843
+ ...(cfg.bg ? { backgroundColor: cfg.bg } : {}),
844
+ legend: cfg.legend === false ? { show: false } : (group ? {
845
+ orient: 'vertical', left: group.legendLeft, top: 'middle', icon: 'circle',
846
+ itemWidth: D.groupLegend.itemWidth, itemHeight: D.groupLegend.itemHeight,
847
+ itemGap: D.groupLegend.itemGap, textStyle: textStyle(D.groupLegend.fontSize),
848
+ formatter: pctName,
849
+ } : {
850
+ orient: 'vertical', right: 0, top: 'middle', icon: 'circle',
851
+ itemWidth: D.legend.itemWidth, itemHeight: D.legend.itemHeight,
852
+ itemGap: D.legend.itemGap, textStyle: textStyle(D.legend.fontSize),
853
+ // RULE 8 — the legend is where the share is written, so it carries it by
854
+ // default; `legend_pct: false` is how a caller whose labels already say the
855
+ // share (a designed donut layout) turns the suffix off.
856
+ ...(cfg.legend_pct !== false ? { formatter: pctName } : {}),
857
+ }),
858
+ series: [{
859
+ type: 'pie',
860
+ radius: group ? [group.inner, group.R]
861
+ : (cfg.cutout ? [cfg.cutout + '%', '100%'] : [D.radiusInner, D.radiusOuter]),
862
+ center: group ? [group.cx, '50%']
863
+ : (cfg.legend === false ? ['50%', '50%'] : ['38%', '50%']),
864
+ data,
865
+ // RULE 8 — the share is written in the LEGEND only and the ring stays
866
+ // clean. Outside labels used to be ON unless a caller turned them off, so a
867
+ // composed donut printed every share twice and ran leader lines out of the
868
+ // box (D15). The exception is a donut with NO legend to write into: the
869
+ // share goes back on the ring rather than nowhere.
870
+ label: ringLabels ? {
871
+ show: true,
872
+ formatter: (p) => (fmtSrc || cfg.format) ? fmt(p.value) : p.percent.toFixed(0) + '%',
873
+ ...textStyle(), fontWeight: THEME.weightStrong,
874
+ position: 'outside',
875
+ } : { show: false },
876
+ labelLine: { show: ringLabels },
877
+ itemStyle: { borderColor: THEME.onFill, borderWidth: cfg.seg_border != null ? cfg.seg_border : D.segmentBorder },
878
+ }],
879
+ };
880
+ }
881
+
882
+ /** The whole option for one chart config. `box` is the element's current pixel
883
+ * size and is only read by the composed donut. */
884
+ function buildOption(cfg, box) {
885
+ if (cfg.type === 'pie' || cfg.type === 'donut') return pieChart(cfg, box);
886
+ return axisChart(cfg);
887
+ }
888
+
889
+ /* ============================================================================
890
+ Chart.js — the same theme, the other renderer (3.1.2).
891
+
892
+ Chart.js has no option builder here the way ECharts does, and deliberately: its
893
+ consumers (the quarter builder's six chart layouts) each compose their own chart
894
+ from a generated template, and what they were repeating was not a chart — it was
895
+ the same ink, the same grid, the same 15px Poppins and the same 4px bar top,
896
+ typed out thirty-odd times. So this part hands out those PIECES, each one a plain
897
+ object ready to spread into a Chart.js option:
898
+
899
+ ticks: PepperCharts.chartjs.text(),
900
+ grid: PepperCharts.chartjs.grid(), border: { display: false },
901
+ ...PepperCharts.chartjs.bar(),
902
+
903
+ `defaults()` sets the globals once, and everything below is what a chart still has
904
+ to say for itself. Same THEME as the ECharts side, so the two renderers cannot
905
+ disagree about a colour (DL-10, G13).
906
+ ============================================================================ */
907
+
908
+ /** Apply the house globals to `Chart.defaults`. Call it once, after Chart.js loads.
909
+ * Returns what it set, so a caller can log or assert it. */
910
+ function chartjsDefaults(canvas, Chart) {
911
+ const C = Chart || (typeof window !== 'undefined' ? window.Chart : undefined);
912
+ const t = chartText(canvas);
913
+ const set = { 'font.family': THEME.font, 'font.size': t.size, color: THEME.ink };
914
+ if (C && C.defaults) {
915
+ C.defaults.font.family = THEME.font;
916
+ C.defaults.font.size = t.size;
917
+ C.defaults.color = THEME.ink;
918
+ }
919
+ return set;
920
+ }
921
+
922
+ /** The accent's series colours, as Chart.js wants them: a plain array. `count` is how many series
923
+ * the chart draws — with exactly two, RULE 12 makes the second one `chart/rest`. */
924
+ function chartjsPalette(accent, count) {
925
+ const base = (THEME.series[accent] || THEME.series.blue).slice();
926
+ if (count === 2) return [base[0], THEME.rest];
927
+ return base;
928
+ }
929
+
930
+ /** A font block on its own: the canvas role's size (or an override) and the family.
931
+ * A total drawn bigger than its bars is the chart's own decision, not the theme's,
932
+ * so a size may be passed; the family never varies. */
933
+ function chartjsFont(canvas, size, weight) {
934
+ return { size: size || chartText(canvas).size, family: THEME.font, ...(weight ? { weight } : {}) };
935
+ }
936
+
937
+ /** Text in a chart — ticks, legend labels, anything Chart.js styles with
938
+ * `{ font, color }`. No family: `defaults()` set it globally, and the smaller the
939
+ * option the easier it is to read what a chart actually overrides. */
940
+ function chartjsText(canvas, size) {
941
+ return { font: { size: size || chartText(canvas).size }, color: THEME.ink };
942
+ }
943
+
944
+ /** The same, with the family stated. The datalabels plugin draws on its own canvas
945
+ * context and does not inherit `Chart.defaults.font.family`, so a data label says
946
+ * the family itself. */
947
+ function chartjsLabel(canvas, size, weight) {
948
+ return { font: chartjsFont(canvas, size, weight), color: THEME.ink };
949
+ }
950
+
951
+ /** RULE 2 — a light line at every tick, behind the bars. */
952
+ function chartjsGrid() { return { color: THEME.grid, lineWidth: GEOMETRY.axis.gridWidth }; }
953
+
954
+ /** A value axis draws no line of its own. */
955
+ function chartjsBorder() { return { display: false }; }
956
+
957
+ /** RULE 1 — the baseline the columns stand on: `chart/rest` at 2px, heavier than
958
+ * the grid. It goes on the CATEGORY axis, whose border is the line at zero. Until
959
+ * 2026-09-21 this file said *"the axis line that is never drawn"* and meant it —
960
+ * the first thing he marked on the audit (D1). */
961
+ function chartjsBaseline() {
962
+ return { display: true, color: THEME.rest, width: GEOMETRY.axis.baselineWidth };
963
+ }
964
+
965
+ /** RULE 3 — a bar dataset's house shape: 4px on the top corners, square at the
966
+ * foot. `borderSkipped: false` used to round the bottom too, so a deck's column
967
+ * floated as a pill while the same column in a dashboard sat flat (D2); Chart.js'
968
+ * own default skips the start edge, which is exactly the rule. */
969
+ function chartjsBar() {
970
+ return { borderRadius: GEOMETRY.bar.radius };
971
+ }
972
+
973
+ /** RULE 4 — a segment of a STACKED bar: the top one rounds, the joints stay
974
+ * square. `last` is true for the dataset drawn at the top of the stack. */
975
+ function chartjsStacked(last) {
976
+ return { borderRadius: last ? GEOMETRY.bar.radius : 0 };
977
+ }
978
+
979
+ /** RULE 5 — a line dataset: the 4px spline with a dot at every point. The dots
980
+ * reverse what this renderer drew until 2026-09-21; ECharts drew them, Chart.js
981
+ * did not, and he picked them on the pair (D14). */
982
+ function chartjsLine() {
983
+ return {
984
+ borderWidth: GEOMETRY.line.width,
985
+ pointRadius: GEOMETRY.line.symbolSize / 2,
986
+ pointHoverRadius: GEOMETRY.line.symbolSize / 2,
987
+ tension: 0.35,
988
+ };
989
+ }
990
+
991
+ /** RULES 10 and 11 — the forecast band and the dashed line over it, as the two
992
+ * dataset blocks a Chart.js consumer spreads. `accent` picks the band's colour;
993
+ * the line is that accent's `chart/4`. */
994
+ function chartjsArea(accent) {
995
+ return {
996
+ ...chartjsLine(),
997
+ pointRadius: 0,
998
+ fill: 'origin',
999
+ backgroundColor: withAlpha(seriesColor(THEME, accent, 0), GEOMETRY.area.opacity),
1000
+ borderColor: seriesColor(THEME, accent, 0),
1001
+ };
1002
+ }
1003
+
1004
+ function chartjsForecast(accent) {
1005
+ const c = seriesColor(THEME, accent, 3);
1006
+ return {
1007
+ borderWidth: GEOMETRY.area.forecast.width,
1008
+ borderDash: GEOMETRY.area.forecast.dash.slice(),
1009
+ borderColor: c,
1010
+ pointRadius: 0,
1011
+ fill: false,
1012
+ tension: 0.35,
1013
+ };
1014
+ }
1015
+
1016
+ /** RULE 9 — the three treatments of a ONE-SERIES chart, as the per-bar colour
1017
+ * array Chart.js takes. This is what the renderer did not have: a deck painted a
1018
+ * multicolor chart entirely red and could not follow the three-colour rule, and
1019
+ * the site wrote its own cycler because the package handed it none (D17). One
1020
+ * rule, one place, three consumers. */
1021
+ function chartjsBarColors(accent, n, treatment) {
1022
+ return barColors(THEME, accent, n, treatment);
1023
+ }
1024
+
1025
+ /** A doughnut: the ring and its borders. `cutout` overrides the house 72% for a
1026
+ * consumer that draws a thinner ring, and is a stated substitution when it does. */
1027
+ function chartjsDoughnut(cutout) {
1028
+ return {
1029
+ cutout: (cutout == null ? GEOMETRY.donut.cutout : cutout) + '%',
1030
+ borderWidth: GEOMETRY.donut.segmentBorder,
1031
+ borderColor: THEME.onFill,
1032
+ };
1033
+ }
1034
+
1035
+ /** The legend block: circular markers in the chart's own text. `extra` is merged
1036
+ * into `labels`, which is where a consumer puts its padding or its
1037
+ * `generateLabels`. */
1038
+ function chartjsLegendLabels(canvas, extra) {
1039
+ return {
1040
+ ...chartjsText(canvas),
1041
+ usePointStyle: true,
1042
+ pointStyle: 'circle',
1043
+ ...(extra || {}),
1044
+ };
1045
+ }
1046
+
1047
+ function chartjsLegend(canvas, extra) {
1048
+ return { labels: chartjsLegendLabels(canvas, extra) };
1049
+ }
1050
+
1051
+ /** A value axis: the grid, no border, the ticks in chart text, and — when the data
1052
+ * is handed in — the narrow-band floor as `min`, so a Chart.js chart of uptime
1053
+ * reads the way the ECharts one does. An explicit `min` always wins. */
1054
+ function chartjsValueAxis(canvas, opts) {
1055
+ const o = opts || {};
1056
+ const floor = o.values ? narrowBandFloor(o.values) : undefined;
1057
+ const min = o.min != null ? o.min : floor;
1058
+ return {
1059
+ ...(min != null ? { min } : {}),
1060
+ ...(o.max != null ? { max: o.max } : {}),
1061
+ ticks: { ...chartjsText(canvas), ...(o.ticks || {}) },
1062
+ grid: chartjsGrid(),
1063
+ border: chartjsBorder(),
1064
+ };
1065
+ }
1066
+
1067
+ /** A category axis: no grid, the baseline, every label kept. */
1068
+ function chartjsCategoryAxis(canvas, opts) {
1069
+ const o = opts || {};
1070
+ return {
1071
+ ticks: { autoSkip: false, ...chartjsText(canvas, o.size), ...(o.ticks || {}) },
1072
+ grid: { display: false },
1073
+ // RULE 1 — the category axis carries the baseline
1074
+ border: chartjsBaseline(),
1075
+ };
1076
+ }
1077
+
1078
+ /** Everything above, as one object — `PepperCharts.chartjs.*`. */
1079
+ const chartjs = {
1080
+ defaults: chartjsDefaults,
1081
+ palette: chartjsPalette,
1082
+ color: (accent, i, count) => seriesColor(THEME, accent, i, count),
1083
+ ink: () => THEME.ink,
1084
+ family: () => THEME.font,
1085
+ size: (canvas) => chartText(canvas).size,
1086
+ font: chartjsFont,
1087
+ text: chartjsText,
1088
+ label: chartjsLabel,
1089
+ labels: chartjsLegendLabels,
1090
+ grid: chartjsGrid,
1091
+ border: chartjsBorder,
1092
+ baseline: chartjsBaseline,
1093
+ bar: chartjsBar,
1094
+ stacked: chartjsStacked,
1095
+ line: chartjsLine,
1096
+ area: chartjsArea,
1097
+ forecast: chartjsForecast,
1098
+ barColors: chartjsBarColors,
1099
+ doughnut: chartjsDoughnut,
1100
+ legend: chartjsLegend,
1101
+ valueAxis: chartjsValueAxis,
1102
+ categoryAxis: chartjsCategoryAxis,
1103
+ };
1104
+
1105
+ /* ============================================================================
1106
+ mount — the optional DOM adapter.
1107
+
1108
+ A page marks a chart with `class="echart" data-chart` and puts its config in a
1109
+ child `<script type="application/json">`; the accent comes from the nearest
1110
+ `[data-accent]` ancestor, which is the same attribute `tokens.css` switches the
1111
+ accent modes on. Nothing here is required to use the theme — a React or Vue
1112
+ consumer calls `buildOption` itself — but the two hard-won behaviours below are
1113
+ worth carrying rather than rediscovering.
1114
+ ============================================================================ */
1115
+
1116
+ /** Re-fit every chart in `root` to its container's CURRENT size, rebuilding the
1117
+ * px-composed donuts (their ring and legend are laid out from the real box). */
1118
+ function refit(root) {
1119
+ (root || document).querySelectorAll('.echart[data-chart]').forEach((el) => {
1120
+ if (!el.__chart) return;
1121
+ el.__chart.resize();
1122
+ if (el.__cfg && el.__cfg.legend_pct) {
1123
+ el.__chart.setOption(buildOption(el.__cfg, { w: el.clientWidth, h: el.clientHeight }));
1124
+ }
1125
+ });
1126
+ }
1127
+
1128
+ /** Render every `[data-chart]` element inside `root`. */
1129
+ function renderAll(root) {
1130
+ const nodes = (root || document).querySelectorAll('.echart[data-chart]');
1131
+ nodes.forEach((el) => {
1132
+ const src = el.querySelector('script[type="application/json"]');
1133
+ if (!src) return;
1134
+ let cfg;
1135
+ try { cfg = JSON.parse(src.textContent); } catch (e) { console.error('chart json', e); return; }
1136
+ const slideEl = el.closest('[data-accent]');
1137
+ cfg.accent = cfg.accent || (slideEl ? slideEl.getAttribute('data-accent') : 'blue');
1138
+ if (el.__ro) { el.__ro.disconnect(); el.__ro = null; }
1139
+ if (el.__chart) { el.__chart.dispose(); }
1140
+ const chart = echarts.init(el, null, { renderer: 'svg' });
1141
+ chart.setOption(buildOption(cfg, { w: el.clientWidth, h: el.clientHeight }));
1142
+ el.__chart = chart;
1143
+ el.__cfg = cfg; // kept for live re-layout while the host card is resized
1144
+ // Keep the chart fitted to its container WHENEVER the container changes size —
1145
+ // font load, a caption growing as the user types, a card resize. ECharts otherwise
1146
+ // keeps its initial SVG size and the plot overlaps the caption until something else
1147
+ // forces a rescale (Fedor 2026-07-14: "the same bug, it goes to normal only if I
1148
+ // click layout mode"). The echart is position:absolute inset:0, so resizing it never
1149
+ // changes its own box → no loop.
1150
+ if (window.ResizeObserver) {
1151
+ let raf = 0;
1152
+ const ro = new ResizeObserver(() => {
1153
+ if (raf) return;
1154
+ raf = requestAnimationFrame(() => {
1155
+ raf = 0;
1156
+ if (!el.__chart) return;
1157
+ el.__chart.resize();
1158
+ if (el.__cfg && el.__cfg.legend_pct) {
1159
+ el.__chart.setOption(buildOption(el.__cfg, { w: el.clientWidth, h: el.clientHeight }));
1160
+ }
1161
+ });
1162
+ });
1163
+ ro.observe(el);
1164
+ el.__ro = ro;
1165
+ }
1166
+ });
1167
+ // The house readiness flag: a print or export path waits on it before it shoots
1168
+ // the page, because an unfinished chart exports as an empty box.
1169
+ window.__chartsReady = true;
1170
+ // Belt-and-suspenders for the initial paint / no-ResizeObserver: re-fit once
1171
+ // webfonts settle (a caption reflows to its real height on font swap).
1172
+ if (document.fonts && document.fonts.ready) {
1173
+ document.fonts.ready.then(() => refit(root));
1174
+ }
1175
+ }
1176
+
1177
+ /* ============================================================================
1178
+ api — what the package is, from the outside.
1179
+
1180
+ `dist/pepper-charts.js` assigns this to `window.PepperCharts`; the ESM build
1181
+ exports it as the default and every member by name. The four names Paggy's own
1182
+ code already calls — `renderAll`, `buildOption`, `refit`, `valueFormatter` —
1183
+ keep their signatures exactly, so adopting the package is a delivery, not a
1184
+ rewrite.
1185
+ ============================================================================ */
1186
+ const PepperCharts = {
1187
+ /** the package version, and the token build it was generated from */
1188
+ version: VERSION,
1189
+ /** the GENERATED theme: palettes, ink, grid, rest, on-fill, type. Read it, do
1190
+ * not write it — every value traces to a token through `theme.$sources`. */
1191
+ theme: THEME,
1192
+ /** the chart-recipe geometry (radii, legend markers, grid lanes, donut ring) */
1193
+ geometry: GEOMETRY,
1194
+
1195
+ /* the palette */
1196
+ /** series `i` of `accent`; pass `count` so a TWO-series chart gets RULE 12's `chart/rest` */
1197
+ seriesColor: (accent, i, count) => seriesColor(THEME, accent, i, count),
1198
+ palette,
1199
+ sliceColor: (accent, labels, i, explicit) => sliceColor(THEME, accent, labels, i, explicit),
1200
+ /** the ink of a label written inside `fill` — the accent's own on a main fill, `onFill` elsewhere (D110) */
1201
+ labelInk: (fill) => labelInk(THEME, fill),
1202
+
1203
+ /* the rules */
1204
+ narrowBandFloor,
1205
+ isTwoSlice,
1206
+ /** RULE 9 — one colour per bar for a one-series chart: 'flat' (the accent's
1207
+ * chart/1), 'tints' (its chart/1–5) or 'multicolor' (the red→yellow→blue→gray
1208
+ * cycle). All three are correct; this is where every channel reads them. */
1209
+ barColors: (accent, n, treatment) => barColors(THEME, accent, n, treatment),
1210
+ /** RULES 3 and 4 — the four-corner radius of a bar; `stack` is 'last' for the
1211
+ * top segment of a stack and 'mid' for the ones below it */
1212
+ barRadius,
1213
+ /** the rendered width of a string in the chart's own type (RULE 7, D16) */
1214
+ textWidth,
1215
+ /** a hex at an opacity, as rgba — RULE 10's band in the renderer that wants one value */
1216
+ withAlpha,
1217
+
1218
+ /* the numbers */
1219
+ valueFormatter,
1220
+ formatter,
1221
+
1222
+ /* ECharts */
1223
+ echartsTheme,
1224
+ buildOption,
1225
+
1226
+ /* Chart.js — the same theme as pieces to spread into its options */
1227
+ chartjs,
1228
+
1229
+ /* the DOM adapter */
1230
+ renderAll,
1231
+ refit,
1232
+ };
1233
+
1234
+ export { PepperCharts as default, PepperCharts, THEME, GEOMETRY, VERSION,
1235
+ buildOption, echartsTheme, chartjs, valueFormatter, formatter, palette, seriesColor, sliceColor,
1236
+ narrowBandFloor, isTwoSlice, chartText, renderAll, refit };