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