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