@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/src/echarts.js ADDED
@@ -0,0 +1,344 @@
1
+ /* ============================================================================
2
+ ECharts — the Pepper theme and the option builder (SVG renderer).
3
+
4
+ `echartsTheme(accent, canvas)` is the payload for `echarts.registerTheme`: the
5
+ defaults a chart written by hand still picks up. `buildOption(cfg, box)` is the
6
+ whole house chart — the shape Paggy's decks, the editor's preview and the PPTX
7
+ exporter all agree on — built from the same THEME.
8
+
9
+ Charts are static (`animation:false`) so the SVG output is clean vector.
10
+ ============================================================================ */
11
+
12
+ /** The colour of series `i`: the dataset's own `color` wins, then the accent's
13
+ * palette. */
14
+ function palette(accent, series, i, count) {
15
+ return (series && series.color) || seriesColor(THEME, accent, i, count);
16
+ }
17
+
18
+ /** The ECharts theme object — `echarts.registerTheme('pepper-blue', echartsTheme('blue'))`.
19
+ * Everything `buildOption` sets explicitly is set here too, so a chart written
20
+ * by hand against the theme looks like a chart the builder made. */
21
+ function echartsTheme(accent, canvas) {
22
+ const t = chartText(canvas);
23
+ const text = { color: THEME.ink, fontFamily: THEME.font, fontSize: t.size };
24
+ return {
25
+ color: (THEME.series[accent] || THEME.series.blue).slice(),
26
+ backgroundColor: 'transparent',
27
+ animation: false,
28
+ textStyle: text,
29
+ legend: {
30
+ icon: 'circle',
31
+ itemWidth: GEOMETRY.legend.itemWidth,
32
+ itemHeight: GEOMETRY.legend.itemHeight,
33
+ itemGap: GEOMETRY.legend.itemGap,
34
+ textStyle: text,
35
+ },
36
+ // RULE 1 — the baseline is the CATEGORY axis' own line: the foot of a column
37
+ // chart, the left edge of a ranked one. `chart/rest` at 2px, heavier than the
38
+ // grid. RULE 2 — the grid keeps a line at every tick, on the value axis.
39
+ categoryAxis: {
40
+ axisLine: { show: true, lineStyle: { color: THEME.rest, width: GEOMETRY.axis.baselineWidth } },
41
+ axisTick: { show: false },
42
+ splitLine: { show: false },
43
+ axisLabel: text,
44
+ },
45
+ valueAxis: {
46
+ axisLine: { show: false },
47
+ axisTick: { show: false },
48
+ splitLine: { lineStyle: { color: THEME.grid, width: GEOMETRY.axis.gridWidth } },
49
+ axisLabel: text,
50
+ },
51
+ bar: { itemStyle: { borderRadius: barRadius(false) }, barMaxWidth: GEOMETRY.bar.maxWidth },
52
+ // RULE 5 — a dot at every point; stated rather than left to the ECharts default
53
+ line: { smooth: true, showSymbol: true, symbolSize: GEOMETRY.line.symbolSize, lineStyle: { width: GEOMETRY.line.width } },
54
+ pie: {
55
+ radius: [GEOMETRY.donut.radiusInner, GEOMETRY.donut.radiusOuter],
56
+ itemStyle: { borderColor: THEME.onFill, borderWidth: GEOMETRY.donut.segmentBorder },
57
+ },
58
+ };
59
+ }
60
+
61
+ /* ---------- bar / column / line ---------- */
62
+ function axisChart(cfg) {
63
+ const t = chartText(cfg.canvas);
64
+ const textStyle = (size) => ({ color: THEME.ink, fontFamily: THEME.font, fontSize: size || t.size });
65
+ const horizontal = cfg.index_axis === 'y' || cfg.type === 'hbar';
66
+ // an area and a sparkline are lines with a fill and, in the sparkline's case,
67
+ // nothing else drawn — they are chart types the recipes have always shown and
68
+ // the builder never had (D9)
69
+ const isArea = cfg.type === 'area' || cfg.type === 'sparkline';
70
+ const isLine = cfg.type === 'line' || isArea;
71
+ const spark = cfg.type === 'sparkline';
72
+ // axis labels and data labels share ONE formatter (cfg.value_format is the
73
+ // legacy per-label override) — the compact style needs every value to pick
74
+ // its magnitude, so the numbers are gathered before the axis is built
75
+ const allVals = (cfg.datasets || []).flatMap((d) => d.data || []).filter((v) => typeof v === 'number');
76
+ const valFmt = valueFormatter((cfg.axis && cfg.axis.fmt) || cfg.fmt,
77
+ (cfg.axis && cfg.axis.format) || cfg.format, allVals);
78
+ const labFmt = cfg.value_format ? formatter(cfg.value_format) : valFmt;
79
+
80
+ const series = (cfg.datasets || []).map((d, i) => {
81
+ const color = palette(cfg.accent, d, i, (cfg.datasets || []).length); // RULE 12 needs the count
82
+ const s = {
83
+ name: d.label || `Series ${i + 1}`,
84
+ type: isLine ? 'line' : 'bar',
85
+ data: d.data,
86
+ color,
87
+ label: {
88
+ show: cfg.data_labels !== false && !isLine,
89
+ position: cfg.label_inside === 'middle' ? 'inside' : (cfg.label_inside ? 'insideTop' : (horizontal ? 'right' : 'top')),
90
+ formatter: (p) => labFmt(p.value),
91
+ ...textStyle(),
92
+ ...(cfg.label_inside ? { color: cfg.label_color || labelInk(THEME, color) } : {}),
93
+ },
94
+ };
95
+ if (!isLine) {
96
+ s.barMaxWidth = cfg.bar_max || GEOMETRY.bar.maxWidth;
97
+ s.itemStyle = { color, borderRadius: barRadius(horizontal, undefined, cfg.radius_all) };
98
+ if (d.stack || cfg.stacked) {
99
+ s.stack = d.stack || 'total';
100
+ // RULE 4 — the TOP segment rounds, the joints stay square. Every segment
101
+ // used to be square, top included, which is the mark he made on the audit
102
+ // (*"where rounded corners?"*, D3). The last series of a stack is its top.
103
+ const last = i === (cfg.datasets || []).length - 1;
104
+ s.itemStyle.borderRadius = barRadius(horizontal, last ? 'last' : 'mid', cfg.radius_all);
105
+ s.label.position = 'inside';
106
+ s.label.formatter = d.pct_label ? (p) => p.value ? labFmt(p.value) : '' : s.label.formatter;
107
+ if (d.pct_color) s.label.color = d.pct_color;
108
+ }
109
+ } else {
110
+ s.smooth = cfg.smooth !== false;
111
+ s.lineStyle = { width: GEOMETRY.line.width, color };
112
+ // RULE 5 — a dot at every point. An area's own line carries none: the
113
+ // pictures rules 10 and 11 were picked on were drawn that way, and a
114
+ // forecast series says so for itself (`d.forecast`).
115
+ s.showSymbol = !isArea && cfg.dots !== false;
116
+ s.symbolSize = GEOMETRY.line.symbolSize;
117
+ s.label.show = cfg.data_labels === true;
118
+ if (isArea) {
119
+ // RULE 10 — the band is the series colour at 12 %
120
+ s.areaStyle = { color, opacity: cfg.area_opacity != null ? cfg.area_opacity : GEOMETRY.area.opacity };
121
+ }
122
+ if (d.forecast) {
123
+ // RULE 11 — the dashed forecast line is `chart/4`, 3px, 6 on 6 off. Its
124
+ // 1.68 : 1 on the 12 % band is a stated exemption, his pick, recorded in
125
+ // the contrast matrix.
126
+ const fc = seriesColor(THEME, cfg.accent, 3); // `chart/4` of this accent
127
+ s.lineStyle = { width: GEOMETRY.area.forecast.width, color: fc, type: GEOMETRY.area.forecast.dash };
128
+ s.itemStyle = { color: fc };
129
+ s.areaStyle = undefined;
130
+ s.showSymbol = false;
131
+ }
132
+ if (spark) { s.showSymbol = false; s.label.show = false; }
133
+ }
134
+ return s;
135
+ });
136
+
137
+ // RULE 9 — a ONE-SERIES chart has three treatments and all three are correct:
138
+ // one flat colour (the default in an accent mode), the accent's own tints, and
139
+ // multicolor. `barColors` is the one place the per-bar cycle lives, so Chart.js
140
+ // and the site read the same function instead of writing their own (D17). A
141
+ // caller names the treatment with `cfg.treatment`; `accent: 'multicolor'`
142
+ // cycles without being asked, because that palette exists to be cycled and a
143
+ // single-colour reading of it paints every bar red. Lines stay one colour (one
144
+ // line cannot cycle); multi-series charts keep their per-series hues.
145
+ if (series.length === 1 && !isLine && (cfg.treatment || cfg.accent === 'multicolor')) {
146
+ const bars = barColors(THEME, cfg.accent, (cfg.labels || cfg.datasets[0].data || []).length, cfg.treatment);
147
+ series[0].itemStyle.color = (p) => bars[p.dataIndex % bars.length];
148
+ }
149
+
150
+ const catAxis = {
151
+ type: 'category',
152
+ data: cfg.labels || [],
153
+ // RULE 1 — the baseline: `chart/rest` at 2px, the line the bars stand on
154
+ axisLine: { show: true, lineStyle: { color: THEME.rest, width: GEOMETRY.axis.baselineWidth } },
155
+ axisTick: { show: false },
156
+ // RULE 6 — largest first. ECharts runs a y category axis bottom-up, so a
157
+ // ranked chart has been shipping upside down (D13); `inverse` puts row 0 at
158
+ // the top, which is where the deck and Figma already read it.
159
+ inverse: cfg.inverse != null ? cfg.inverse : (horizontal && RANKED_TOP_FIRST),
160
+ axisLabel: {
161
+ ...textStyle(cfg.category_size || t.size),
162
+ rotate: cfg.category_rotation || 0,
163
+ interval: 0,
164
+ // RULE 7 — measured, not reserved: no width is held for the row name, and
165
+ // `containLabel` fits the grid round whatever it measures. A caller that
166
+ // must truncate a very long name passes `label_width` itself (D6).
167
+ ...(horizontal && cfg.label_width ? { width: cfg.label_width, overflow: 'truncate' } : {}),
168
+ },
169
+ };
170
+ // tight-band data (uptime 99.92-99.99 on a 0-100 axis) renders as identical
171
+ // full bars — the narrow-band floor lifts the axis so the variation is visible
172
+ // (rules.js, stress-test S04); an explicit cfg.axis.min always wins.
173
+ const autoMin = narrowBandFloor(allVals);
174
+ const valAxis = {
175
+ type: 'value',
176
+ min: (cfg.axis && cfg.axis.min) != null ? cfg.axis.min : autoMin,
177
+ max: cfg.axis && cfg.axis.max,
178
+ interval: cfg.axis && cfg.axis.interval,
179
+ // RULE 2 — a line at every tick, behind the bars
180
+ splitLine: { show: cfg.grid !== false, lineStyle: { color: THEME.grid, width: GEOMETRY.axis.gridWidth } },
181
+ axisLine: { show: false },
182
+ axisLabel: { ...textStyle(), formatter: (v) => valFmt(v) },
183
+ };
184
+
185
+ if (spark) {
186
+ // a sparkline is the line and nothing else: no axis, no grid, no labels
187
+ catAxis.show = false; valAxis.show = false;
188
+ catAxis.axisLine = { show: false }; catAxis.axisLabel = { show: false };
189
+ valAxis.splitLine = { show: false }; valAxis.axisLabel = { show: false };
190
+ }
191
+
192
+ return {
193
+ animation: false,
194
+ grid: spark ? { left: 0, right: 0, top: 2, bottom: 2 } : {
195
+ left: GEOMETRY.grid.left,
196
+ right: cfg.grid_right != null ? cfg.grid_right : GEOMETRY.grid.right,
197
+ top: cfg.grid_top != null ? cfg.grid_top
198
+ : ((cfg.datasets || []).length > 1 ? GEOMETRY.grid.topWithLegend : GEOMETRY.grid.top),
199
+ bottom: cfg.grid_bottom != null ? cfg.grid_bottom : GEOMETRY.grid.bottom,
200
+ containLabel: true,
201
+ },
202
+ legend: legendBlock(cfg, !spark && (cfg.datasets || []).length > 1 && cfg.legend !== false, textStyle),
203
+ xAxis: horizontal ? valAxis : catAxis,
204
+ yAxis: horizontal ? catAxis : valAxis,
205
+ series,
206
+ textStyle: textStyle(),
207
+ };
208
+ }
209
+
210
+ /** The top-right legend: circular markers, 10px, 18 apart. */
211
+ function legendBlock(cfg, show, textStyle) {
212
+ if (cfg.legend === true) show = true; // skill parity: legend even for one series
213
+ if (show === false) return { show: false };
214
+ return {
215
+ show: true, top: 0, right: 0, icon: 'circle',
216
+ itemWidth: GEOMETRY.legend.itemWidth,
217
+ itemHeight: GEOMETRY.legend.itemHeight,
218
+ itemGap: GEOMETRY.legend.itemGap,
219
+ textStyle: textStyle(),
220
+ };
221
+ }
222
+
223
+ /* ---------- donut (pie is retired) ---------- */
224
+ function pieChart(cfg, box) {
225
+ const t = chartText(cfg.canvas);
226
+ const textStyle = (size) => ({ color: THEME.ink, fontFamily: THEME.font, fontSize: size || t.size });
227
+ const D = GEOMETRY.donut;
228
+ // Pie is retired: every circular chart IS the house donut. Configs written
229
+ // before the write-path normalizers existed (or by anything that slips past
230
+ // them) arrive UNSTYLED — carrying none of the house flags — and used to
231
+ // render as a default-ECharts pie/donut with callout labels. Restyle those
232
+ // to the donut_compare spec here, at render time, so that look can never
233
+ // ship. Designed donut layouts and editor-converted donuts always carry at
234
+ // least one of the flags below and pass through untouched.
235
+ if (cfg.type === 'pie') cfg.type = 'donut';
236
+ if (!cfg.data && cfg.datasets && cfg.datasets[0])
237
+ cfg.data = cfg.datasets[0].data || [];
238
+ if (cfg.legend_pct == null && cfg.labels_on == null
239
+ && cfg.legend == null && cfg.cutout == null) {
240
+ cfg.labels_on = GEOMETRY.donut.labelsOn;
241
+ cfg.seg_border = D.unstyled.segmentBorder;
242
+ cfg.cutout = D.unstyled.cutout;
243
+ cfg.bg = cfg.bg || THEME.page;
244
+ cfg.legend_pct = D.unstyled.legendPct;
245
+ }
246
+ // TWO-slice donuts read as share-vs-rest (Fedor 2026-08-12): slice 1 keeps
247
+ // the accent's MAIN color, slice 2 is always chart/rest — never the accent's
248
+ // readable mid tint (that rule stands for every OTHER chart, incl. 3+ slice
249
+ // donuts). Assigned per slice right here so ECharts can never cycle past it;
250
+ // an explicit cfg.colors (kpi_donuts rings, designed layouts) still wins.
251
+ // PPTX mirrors in pptx_export.chart. See rules.js sliceColor.
252
+ const data = (cfg.labels || []).map((l, i) => ({
253
+ name: l,
254
+ value: (cfg.data || [])[i],
255
+ itemStyle: { color: sliceColor(THEME, cfg.accent, cfg.labels, i, cfg.colors) },
256
+ }));
257
+ // a donut has no axis block, so its format lives at the top level (the axis
258
+ // fallback only catches a config that came from an axis chart type-switch)
259
+ const fmtSrc = cfg.fmt || (cfg.axis && cfg.axis.fmt);
260
+ const fmt = valueFormatter(fmtSrc, cfg.format, cfg.data || []);
261
+ const total = (cfg.data || []).reduce((a, b) => a + (+b || 0), 0);
262
+ const noLegend = cfg.legend === false;
263
+ const ringLabels = cfg.labels_on != null ? cfg.labels_on : (noLegend ? true : GEOMETRY.donut.labelsOn);
264
+ const pctName = (name) => {
265
+ const i = (cfg.labels || []).indexOf(name);
266
+ const v = +((cfg.data || [])[i]) || 0;
267
+ return total ? name + ' — ' + Math.round(v / total * 100) + '%' : name;
268
+ };
269
+ // House composition for editor-converted donuts (legend_pct): a padded ring
270
+ // with the legend right beside it, the pair centred as ONE group in the box
271
+ // (the donut_compare look). Computed in px from the real element size, so a
272
+ // wide chart panel can't strand the legend at the far edge or blow the ring
273
+ // up to full height. Designed donut layouts (no legend_pct) are untouched.
274
+ let group = null;
275
+ if (cfg.legend_pct && cfg.legend !== false && box && box.w > 40 && box.h > 40) {
276
+ const R = Math.round(D.groupRingShare * Math.min(box.w, box.h) / 2);
277
+ const inner = Math.round(R * ((cfg.cutout || D.cutout) / 100));
278
+ // estimate the legend block width from the ACTUAL entries (20px Poppins ≈
279
+ // 10.5px/char + dot/gap) — a fixed guess skews the centring when labels are
280
+ // short ("Jan — 7%") or long ("PHL — Philippines — 14.3%")
281
+ // D16 — his mark, *"be carefull with layout becose mobuppsX numer on the right
282
+ // is cut out"*. The width used to be GUESSED at 10.5px a character and only
283
+ // the LEFT edge was clamped, so an entry wider than the guess ran off the
284
+ // right. Measured now, and clamped at both edges: when the group cannot fit,
285
+ // the ring gives way, not the text.
286
+ const wide = (cfg.labels || []).map(pctName)
287
+ .reduce((m, str) => Math.max(m, textWidth(str, D.groupLegend.fontSize)), 0);
288
+ const legendW = Math.min(D.groupLegendMax, wide + D.groupLegendPad);
289
+ const gap = D.groupGap;
290
+ const free = box.w - legendW - gap - 2 * D.groupEdgeMin;
291
+ const r = Math.max(8, Math.min(R, Math.round(free / 2)));
292
+ const inner2 = Math.round(r * ((cfg.cutout || D.cutout) / 100));
293
+ const left = Math.max(D.groupEdgeMin, Math.round((box.w - (2 * r + gap + legendW)) / 2));
294
+ group = { R: r, inner: inner2, cx: left + r, legendLeft: left + 2 * r + gap };
295
+ }
296
+ return {
297
+ animation: false,
298
+ // bg: the editor's type-switch sets the page white so a converted donut sits
299
+ // on white (designed donut layouts leave it unset — transparent, card decides)
300
+ ...(cfg.bg ? { backgroundColor: cfg.bg } : {}),
301
+ legend: cfg.legend === false ? { show: false } : (group ? {
302
+ orient: 'vertical', left: group.legendLeft, top: 'middle', icon: 'circle',
303
+ itemWidth: D.groupLegend.itemWidth, itemHeight: D.groupLegend.itemHeight,
304
+ itemGap: D.groupLegend.itemGap, textStyle: textStyle(D.groupLegend.fontSize),
305
+ formatter: pctName,
306
+ } : {
307
+ orient: 'vertical', right: 0, top: 'middle', icon: 'circle',
308
+ itemWidth: D.legend.itemWidth, itemHeight: D.legend.itemHeight,
309
+ itemGap: D.legend.itemGap, textStyle: textStyle(D.legend.fontSize),
310
+ // RULE 8 — the legend is where the share is written, so it carries it by
311
+ // default; `legend_pct: false` is how a caller whose labels already say the
312
+ // share (a designed donut layout) turns the suffix off.
313
+ ...(cfg.legend_pct !== false ? { formatter: pctName } : {}),
314
+ }),
315
+ series: [{
316
+ type: 'pie',
317
+ radius: group ? [group.inner, group.R]
318
+ : (cfg.cutout ? [cfg.cutout + '%', '100%'] : [D.radiusInner, D.radiusOuter]),
319
+ center: group ? [group.cx, '50%']
320
+ : (cfg.legend === false ? ['50%', '50%'] : ['38%', '50%']),
321
+ data,
322
+ // RULE 8 — the share is written in the LEGEND only and the ring stays
323
+ // clean. Outside labels used to be ON unless a caller turned them off, so a
324
+ // composed donut printed every share twice and ran leader lines out of the
325
+ // box (D15). The exception is a donut with NO legend to write into: the
326
+ // share goes back on the ring rather than nowhere.
327
+ label: ringLabels ? {
328
+ show: true,
329
+ formatter: (p) => (fmtSrc || cfg.format) ? fmt(p.value) : p.percent.toFixed(0) + '%',
330
+ ...textStyle(), fontWeight: THEME.weightStrong,
331
+ position: 'outside',
332
+ } : { show: false },
333
+ labelLine: { show: ringLabels },
334
+ itemStyle: { borderColor: THEME.onFill, borderWidth: cfg.seg_border != null ? cfg.seg_border : D.segmentBorder },
335
+ }],
336
+ };
337
+ }
338
+
339
+ /** The whole option for one chart config. `box` is the element's current pixel
340
+ * size and is only read by the composed donut. */
341
+ function buildOption(cfg, box) {
342
+ if (cfg.type === 'pie' || cfg.type === 'donut') return pieChart(cfg, box);
343
+ return axisChart(cfg);
344
+ }
package/src/format.js ADDED
@@ -0,0 +1,119 @@
1
+ /* ============================================================================
2
+ format — the number-format OBJECT and the legacy readers.
3
+
4
+ DL-10 names this as part of the package because three renderers have to agree
5
+ on it: the ECharts option below, Chart.js (3.1.2) and `pptx_export.py`, which
6
+ mirrors the same resolution order and the same compact magnitude as an Excel
7
+ number-format code. Moved here from Paggy's `charts.js` unchanged — the comments
8
+ are its own, and every one of them records a bug this code is the fix for.
9
+ ============================================================================ */
10
+
11
+ /* ---------- the legacy strings, kept forever as READERS ----------
12
+ Retired as PRODUCED (Fedor 2026-08-14): nothing mints a `currency_*` any more,
13
+ because a dollar sign baked into the renderer shipped dollar labels on a euro
14
+ column. A deck built before the format object still has to render the way it
15
+ was drawn, so the readers stay. */
16
+ function fmtCurrencyM(v) {
17
+ if (v == null) return '';
18
+ const n = Number(v);
19
+ if (Math.abs(n) >= 1e6) return '$' + (n / 1e6).toFixed(2).replace(/\.?0+$/, '') + 'M';
20
+ if (Math.abs(n) >= 1e3) return '$' + Math.round(n / 1e3) + 'K';
21
+ return '$' + n;
22
+ }
23
+ function fmtCurrencyK(v) {
24
+ if (v == null) return '';
25
+ const n = Number(v);
26
+ if (Math.abs(n) >= 1e3) {
27
+ const k = n / 1e3;
28
+ // sub-10K keeps decimals so $1,000 / $1,040 / $1,215 don't ALL collapse
29
+ // to "$1K" (stress-test T04, 2026-07-09)
30
+ const s = Math.abs(k) < 10 ? k.toFixed(2).replace(/\.?0+$/, '')
31
+ : Math.round(k).toLocaleString('en-US');
32
+ return '$' + s + 'K';
33
+ }
34
+ return '$' + n;
35
+ }
36
+ function fmtPlain(v) { return v == null ? '' : Number(v).toLocaleString('en-US'); }
37
+ function fmtPercent(v) { return v == null ? '' : Number(v).toLocaleString('en-US') + '%'; }
38
+
39
+ function formatter(kind) {
40
+ switch (kind) {
41
+ case 'currency_M': return fmtCurrencyM;
42
+ case 'currency_K': return fmtCurrencyK;
43
+ case 'percent': return fmtPercent;
44
+ default: return fmtPlain;
45
+ }
46
+ }
47
+
48
+ /* ---------- the number FORMAT object (Fedor 2026-08-14) ----------
49
+ The old $K/$M pills baked a DOLLAR sign into the renderer, so a euro column
50
+ shipped dollar labels ("what if the user has euros, K euros, millions or
51
+ pounds?"). The format is currency-neutral instead: a style, a VERBATIM
52
+ prefix/suffix and an optional decimals override.
53
+ fmt = { style: auto|group|group2|compact, prefix, suffix, decimals }
54
+ auto today's plain number group 1,000 (0 decimals)
55
+ group2 1,000.00 (2 decimals) compact 1.2K / 1.2M (1 decimal)
56
+ decimals: null = follow the style, otherwise 0..4 and it wins
57
+ prefix/suffix are cosmetic TEXT, never a unit conversion. */
58
+ // PER VALUE, not one unit for the whole chart (Fedor 2026-08-14). A shared
59
+ // magnitude turns 820,000 into "$0.8M" and 310,000 into "$0.3M" — a lost
60
+ // significant digit and a label nobody writes by hand. The retired
61
+ // currency_K/currency_M readers always shortened per value ($820K beside
62
+ // $1.04M) and that is the look every existing deck has; `compact` is their
63
+ // currency-NEUTRAL twin, so it must shorten the same way. Verified against
64
+ // the T04_table_kpis golden, which is exactly this chart.
65
+ function compactUnit(v) {
66
+ const m = Math.abs(Number(v) || 0);
67
+ if (m >= 1e6) return { div: 1e6, tag: 'M' };
68
+ if (m >= 1e3) return { div: 1e3, tag: 'K' };
69
+ return { div: 1, tag: '' };
70
+ }
71
+
72
+ function fmtObject(f, vals) {
73
+ const style = f.style || 'auto';
74
+ const pre = f.prefix == null ? '' : String(f.prefix);
75
+ const suf = f.suffix == null ? '' : String(f.suffix);
76
+ const dec = (f.decimals == null || f.decimals === '')
77
+ ? null : Math.max(0, Math.min(4, Math.round(Number(f.decimals)) || 0));
78
+ const compact = style === 'compact';
79
+ return function (v) {
80
+ if (v == null || v === '') return '';
81
+ let n = Number(v);
82
+ if (!isFinite(n)) return '';
83
+ let d = dec;
84
+ let unit = compact ? compactUnit(n) : null;
85
+ // 3 significant digits is what the retired currency readers actually did
86
+ // ($820K, $1.04M, $1.22K) — a fixed decimal count cannot do both, and it
87
+ // is the rule that keeps a shortened label as informative as the number.
88
+ if (unit) {
89
+ // ...but the rounding can push a value INTO the next band: 999.5 with
90
+ // 0 decimals prints "1,000" while 1000 prints "1K", so two bars a half
91
+ // apart would wear different units. Re-pick the unit from the ROUNDED
92
+ // value (once is enough — 999.5 → 1000 → K → 1.0 is already stable).
93
+ const dOf = (x) => Math.max(0, 3 - String(Math.floor(Math.abs(x))).length);
94
+ if (dec == null) {
95
+ const r = Math.round(Math.abs(n / unit.div) * 10 ** dOf(n / unit.div)) /
96
+ 10 ** dOf(n / unit.div);
97
+ if (r >= 1000) unit = compactUnit(Math.sign(n) * r * unit.div);
98
+ }
99
+ n = n / unit.div;
100
+ if (d == null) d = dOf(n);
101
+ } else if (d == null && style === 'group') d = 0;
102
+ else if (d == null && style === 'group2') d = 2;
103
+ let s = d == null ? n.toLocaleString('en-US')
104
+ : n.toLocaleString('en-US', { minimumFractionDigits: d, maximumFractionDigits: d });
105
+ // "2.0K"/"1.00M" read as 2K/1M — but a decimals setting typed by hand
106
+ // is honoured exactly as typed
107
+ if (unit) s = (dec == null ? s.replace(/\.(\d*?)0+$/, (m, k) => (k ? '.' + k : '')) : s) + unit.tag;
108
+ return pre + s + suf;
109
+ };
110
+ }
111
+
112
+ /* THE resolution order (locked with the panel, 2026-08-14): the fmt object if
113
+ the chart carries one → else its legacy string → else plain. Nothing ever
114
+ silently repaints a deck built before the panel existed. Shared with the
115
+ editor's chart modal (its live preview IS this function). */
116
+ function valueFormatter(fmt, legacy, vals) {
117
+ if (fmt && typeof fmt === 'object') return fmtObject(fmt, vals);
118
+ return formatter(legacy || 'plain');
119
+ }
package/src/index.js ADDED
@@ -0,0 +1,28 @@
1
+ /** @pepperui/charts — the source parts, in order.
2
+ *
3
+ * `build/charts.js` concatenates these into `dist/pepper-charts.js` (a classic script that
4
+ * defines `window.PepperCharts`, so a bundler-free page — Paggy, the quarter builder, a Blade
5
+ * view — needs no tooling) and `dist/pepper-charts.mjs` (the ES module), with the GENERATED
6
+ * theme (`dist/theme.json`, an output of `npm run build` from `tokens/*.json`) injected ahead
7
+ * of them as `THEME`.
8
+ *
9
+ * The parts share ONE scope. They are not standalone modules and they do not import each
10
+ * other — this file is the order, exactly the way `packages/css/src/index.css` is the order
11
+ * of the stylesheet's parts. Nothing here holds a colour: every value the charts paint with
12
+ * comes from `THEME`, and `THEME` comes from the token source.
13
+ */
14
+ export const parts = [
15
+ // the number-format object and the legacy readers — shared with pptx_export.py (DL-10)
16
+ 'format.js',
17
+ // the house rules that are not colour: the narrow-band axis floor, the two-slice donut,
18
+ // and the chart-recipe geometry the Figma page draws
19
+ 'rules.js',
20
+ // the ECharts theme and option builder
21
+ 'echarts.js',
22
+ // the Chart.js theme — the same values, the pieces that renderer composes with
23
+ 'chartjs.js',
24
+ // the optional DOM adapter: mount every [data-chart] in a root and keep it fitted
25
+ 'mount.js',
26
+ // the public object
27
+ 'api.js',
28
+ ];
package/src/mount.js ADDED
@@ -0,0 +1,71 @@
1
+ /* ============================================================================
2
+ mount — the optional DOM adapter.
3
+
4
+ A page marks a chart with `class="echart" data-chart` and puts its config in a
5
+ child `<script type="application/json">`; the accent comes from the nearest
6
+ `[data-accent]` ancestor, which is the same attribute `tokens.css` switches the
7
+ accent modes on. Nothing here is required to use the theme — a React or Vue
8
+ consumer calls `buildOption` itself — but the two hard-won behaviours below are
9
+ worth carrying rather than rediscovering.
10
+ ============================================================================ */
11
+
12
+ /** Re-fit every chart in `root` to its container's CURRENT size, rebuilding the
13
+ * px-composed donuts (their ring and legend are laid out from the real box). */
14
+ function refit(root) {
15
+ (root || document).querySelectorAll('.echart[data-chart]').forEach((el) => {
16
+ if (!el.__chart) return;
17
+ el.__chart.resize();
18
+ if (el.__cfg && el.__cfg.legend_pct) {
19
+ el.__chart.setOption(buildOption(el.__cfg, { w: el.clientWidth, h: el.clientHeight }));
20
+ }
21
+ });
22
+ }
23
+
24
+ /** Render every `[data-chart]` element inside `root`. */
25
+ function renderAll(root) {
26
+ const nodes = (root || document).querySelectorAll('.echart[data-chart]');
27
+ nodes.forEach((el) => {
28
+ const src = el.querySelector('script[type="application/json"]');
29
+ if (!src) return;
30
+ let cfg;
31
+ try { cfg = JSON.parse(src.textContent); } catch (e) { console.error('chart json', e); return; }
32
+ const slideEl = el.closest('[data-accent]');
33
+ cfg.accent = cfg.accent || (slideEl ? slideEl.getAttribute('data-accent') : 'blue');
34
+ if (el.__ro) { el.__ro.disconnect(); el.__ro = null; }
35
+ if (el.__chart) { el.__chart.dispose(); }
36
+ const chart = echarts.init(el, null, { renderer: 'svg' });
37
+ chart.setOption(buildOption(cfg, { w: el.clientWidth, h: el.clientHeight }));
38
+ el.__chart = chart;
39
+ el.__cfg = cfg; // kept for live re-layout while the host card is resized
40
+ // Keep the chart fitted to its container WHENEVER the container changes size —
41
+ // font load, a caption growing as the user types, a card resize. ECharts otherwise
42
+ // keeps its initial SVG size and the plot overlaps the caption until something else
43
+ // forces a rescale (Fedor 2026-07-14: "the same bug, it goes to normal only if I
44
+ // click layout mode"). The echart is position:absolute inset:0, so resizing it never
45
+ // changes its own box → no loop.
46
+ if (window.ResizeObserver) {
47
+ let raf = 0;
48
+ const ro = new ResizeObserver(() => {
49
+ if (raf) return;
50
+ raf = requestAnimationFrame(() => {
51
+ raf = 0;
52
+ if (!el.__chart) return;
53
+ el.__chart.resize();
54
+ if (el.__cfg && el.__cfg.legend_pct) {
55
+ el.__chart.setOption(buildOption(el.__cfg, { w: el.clientWidth, h: el.clientHeight }));
56
+ }
57
+ });
58
+ });
59
+ ro.observe(el);
60
+ el.__ro = ro;
61
+ }
62
+ });
63
+ // The house readiness flag: a print or export path waits on it before it shoots
64
+ // the page, because an unfinished chart exports as an empty box.
65
+ window.__chartsReady = true;
66
+ // Belt-and-suspenders for the initial paint / no-ResizeObserver: re-fit once
67
+ // webfonts settle (a caption reflows to its real height on font swap).
68
+ if (document.fonts && document.fonts.ready) {
69
+ document.fonts.ready.then(() => refit(root));
70
+ }
71
+ }