@pepperui/charts 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +117 -0
- package/dist/pepper-charts.js +1245 -0
- package/dist/pepper-charts.mjs +1236 -0
- package/dist/recipes.json +178 -0
- package/dist/theme.json +106 -0
- package/package.json +41 -0
- package/src/api.js +56 -0
- package/src/chartjs.js +215 -0
- package/src/echarts.js +344 -0
- package/src/format.js +119 -0
- package/src/index.js +28 -0
- package/src/mount.js +71 -0
- package/src/rules.js +306 -0
package/src/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
|
+
}
|