@lutrin/core 1.0.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/LICENSE +21 -0
- package/README.md +154 -0
- package/design/layouts/before-after.json +6 -0
- package/design/layouts/funnel.json +6 -0
- package/design/layouts/journey.json +5 -0
- package/design/layouts/key-message.json +5 -0
- package/design/layouts/portfolio.json +8 -0
- package/design/layouts/priority-matrix.json +7 -0
- package/design/layouts/pros-cons.json +6 -0
- package/design/layouts/pyramid.json +6 -0
- package/design/layouts/risk-map.json +8 -0
- package/design/layouts/roadmap.json +6 -0
- package/design/themes/default.json +159 -0
- package/package.json +57 -0
- package/src/cli.mjs +1114 -0
- package/src/deck/assets.mjs +910 -0
- package/src/deck/chart.mjs +424 -0
- package/src/deck/context.mjs +62 -0
- package/src/deck/highlight.mjs +206 -0
- package/src/deck/kit.mjs +342 -0
- package/src/deck/layout.mjs +1599 -0
- package/src/deck/parse.mjs +776 -0
- package/src/deck/suggest.mjs +33 -0
- package/src/deck/theme.mjs +1135 -0
- package/src/deck/tokens.mjs +268 -0
- package/src/deck/validate.mjs +737 -0
- package/src/html/render.mjs +1586 -0
- package/src/kit/archive.mjs +448 -0
- package/src/pptx/anim.mjs +196 -0
- package/src/pptx/fonts.mjs +204 -0
- package/src/pptx/morph.mjs +103 -0
- package/src/pptx/render.mjs +1379 -0
- package/src/vendor.mjs +281 -0
- package/src/worker/protocol.d.ts +82 -0
- package/src/worker/worker.mjs +145 -0
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chart rendering: `chart` block → SVG (then a PNG embedded in the .pptx).
|
|
3
|
+
*
|
|
4
|
+
* Why not PptxGenJS's native OOXML charts? Keynote and QuickLook (macOS)
|
|
5
|
+
* do not display them at all — blank slide, verified empirically. An image
|
|
6
|
+
* is faithful everywhere; the trade-off (not editable in PowerPoint) is
|
|
7
|
+
* documented in the SKILL.
|
|
8
|
+
*
|
|
9
|
+
* Dataviz rules applied (see the CHART_COLORS palette in tokens.mjs,
|
|
10
|
+
* validated: lightness band, chroma, color-blindness ΔE, contrast):
|
|
11
|
+
* - thin marks: bars with a 4 px rounded top anchored to the baseline —
|
|
12
|
+
* the ZERO one, not the bottom of the frame: a loss goes below the
|
|
13
|
+
* axis (or runs leftwards in barh) instead of disappearing;
|
|
14
|
+
* 2 px lines, points ≥ 8 px ringed in white;
|
|
15
|
+
* - 2 px of white breathing room between adjacent fills;
|
|
16
|
+
* - grids and axes understated (neutral), never a frame;
|
|
17
|
+
* - a legend from two identities on, never a label on every point;
|
|
18
|
+
* - text carries the text inks, never the series color.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { COLORS, CHART_COLORS, FONTS } from './tokens.mjs';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Number formatting locale (ticks, legend values).
|
|
25
|
+
* The engine is written for Quebec: "1 500", not "1,500". This is not a
|
|
26
|
+
* brand constant — an English deck must be able to change it without
|
|
27
|
+
* touching the code: chartSvg(block, W, H, { locale }) is the intended
|
|
28
|
+
* extension point, and this value is only its default.
|
|
29
|
+
*/
|
|
30
|
+
const DEFAULT_LOCALE = 'fr-CA';
|
|
31
|
+
|
|
32
|
+
// font and inks read at call time (never copied at load time): a theme
|
|
33
|
+
// applied by applyTheme() must be reflected in the charts of the same
|
|
34
|
+
// process
|
|
35
|
+
const FONT = () => `${FONTS.body}, Helvetica, Arial, sans-serif`;
|
|
36
|
+
const ink = () => `#${COLORS.neutralSecondary}`;
|
|
37
|
+
const grid = () => `#${COLORS.underground2}`;
|
|
38
|
+
const axis = () => `#${COLORS.neutralStroke}`;
|
|
39
|
+
const bg = () => `#${COLORS.ground}`;
|
|
40
|
+
|
|
41
|
+
const esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
42
|
+
const fmt = (v, locale = DEFAULT_LOCALE) => v.toLocaleString(locale, { maximumFractionDigits: 2 });
|
|
43
|
+
const textW = (s, size) => String(s).length * size * 0.58;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The values of a series that can actually be plotted: a value without a
|
|
47
|
+
* category has nowhere to sit. The truncation must happen BEFORE the scale is
|
|
48
|
+
* computed — otherwise a surplus that is never drawn sets the bounds and
|
|
49
|
+
* crushes the visible plot (bars outside the frame, radar vertices collapsed
|
|
50
|
+
* onto the center). The surplus is reported by chartDataDiagnostics(), not
|
|
51
|
+
* lost in silence.
|
|
52
|
+
*/
|
|
53
|
+
const shownValues = (series, cats) => (series.values ?? []).slice(0, cats.length);
|
|
54
|
+
|
|
55
|
+
/** A "round" scale: pleasant bounds and step for ~n ticks. */
|
|
56
|
+
function niceScale(min, max, n = 5) {
|
|
57
|
+
// zero always belongs to the domain: it is the baseline the bars anchor
|
|
58
|
+
// to — an entirely negative series would otherwise have its axis outside
|
|
59
|
+
// the frame
|
|
60
|
+
if (min > 0) min = 0;
|
|
61
|
+
if (max < 0) max = 0;
|
|
62
|
+
if (max <= min) max = min + 1;
|
|
63
|
+
const span = max - min;
|
|
64
|
+
const step0 = span / n;
|
|
65
|
+
const mag = 10 ** Math.floor(Math.log10(step0));
|
|
66
|
+
const step = [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => s >= step0);
|
|
67
|
+
const lo = Math.floor(min / step) * step;
|
|
68
|
+
const hi = Math.ceil(max / step) * step;
|
|
69
|
+
const ticks = [];
|
|
70
|
+
for (let v = lo; v <= hi + step / 2; v += step) ticks.push(Math.round(v * 1e6) / 1e6);
|
|
71
|
+
return { lo, hi, ticks };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const color = (k) => `#${CHART_COLORS[k % CHART_COLORS.length]}`;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A bar with a rounded free end (4 px), anchored to the zero baseline.
|
|
78
|
+
* `x, y, w, h` always describe the rectangle in increasing coordinates;
|
|
79
|
+
* `negative` says which side the anchor is on, hence which corner is rounded:
|
|
80
|
+
* top of a gain, bottom of a loss, right or left in barh.
|
|
81
|
+
*
|
|
82
|
+
* The radius is bounded by the HALF-THICKNESS of the bar — its width when
|
|
83
|
+
* vertical, but its HEIGHT when horizontal — and by its length: a larger
|
|
84
|
+
* rounding would fold the path back onto itself. Confusing the two produced
|
|
85
|
+
* thin horizontal bars with absurdly round ends.
|
|
86
|
+
*/
|
|
87
|
+
function roundedBar(x, y, w, h, r = 4, horizontal = false, fill = '#000', negative = false) {
|
|
88
|
+
if (h <= 0 || w <= 0) return '';
|
|
89
|
+
const rr = horizontal ? Math.min(r, h / 2, w) : Math.min(r, w / 2, h);
|
|
90
|
+
let d;
|
|
91
|
+
if (horizontal && !negative) {
|
|
92
|
+
// anchored on the left (zero), rounded on the right
|
|
93
|
+
d = `M${x},${y} h${w - rr} a${rr},${rr} 0 0 1 ${rr},${rr} v${h - 2 * rr} a${rr},${rr} 0 0 1 -${rr},${rr} h-${w - rr} z`;
|
|
94
|
+
} else if (horizontal) {
|
|
95
|
+
// anchored on the right (zero), rounded on the left
|
|
96
|
+
d = `M${x + w},${y} h-${w - rr} a${rr},${rr} 0 0 0 -${rr},${rr} v${h - 2 * rr} a${rr},${rr} 0 0 0 ${rr},${rr} h${w - rr} z`;
|
|
97
|
+
} else if (!negative) {
|
|
98
|
+
// anchored at the bottom (zero), rounded at the top
|
|
99
|
+
d = `M${x},${y + h} v-${h - rr} a${rr},${rr} 0 0 1 ${rr},-${rr} h${w - 2 * rr} a${rr},${rr} 0 0 1 ${rr},${rr} v${h - rr} z`;
|
|
100
|
+
} else {
|
|
101
|
+
// anchored at the top (zero), rounded at the bottom
|
|
102
|
+
d = `M${x},${y} v${h - rr} a${rr},${rr} 0 0 0 ${rr},${rr} h${w - 2 * rr} a${rr},${rr} 0 0 0 ${rr},-${rr} v-${h - rr} z`;
|
|
103
|
+
}
|
|
104
|
+
return `<path d="${d}" fill="${fill}"/>`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function legendRow(series, x, y, w) {
|
|
108
|
+
const items = series.map((s, k) => ({
|
|
109
|
+
label: s.name,
|
|
110
|
+
c: color(k),
|
|
111
|
+
w: 14 + textW(s.name, 11) + 18,
|
|
112
|
+
}));
|
|
113
|
+
const total = items.reduce((a, b) => a + b.w, 0);
|
|
114
|
+
let cx = x + Math.max(0, (w - total) / 2);
|
|
115
|
+
const out = [];
|
|
116
|
+
for (const it of items) {
|
|
117
|
+
out.push(`<rect x="${cx}" y="${y - 9}" width="10" height="10" rx="2" fill="${it.c}"/>`);
|
|
118
|
+
out.push(
|
|
119
|
+
`<text x="${cx + 14}" y="${y}" font-family="${FONT()}" font-size="11" fill="${ink()}">${esc(it.label)}</text>`,
|
|
120
|
+
);
|
|
121
|
+
cx += it.w;
|
|
122
|
+
}
|
|
123
|
+
return out.join('');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Cartesian: bar, barh, line, area
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
function cartesian(block, W, H, locale) {
|
|
131
|
+
const { categories: cats, series } = block;
|
|
132
|
+
const legend = series.length > 1;
|
|
133
|
+
// values truncated first: the scale must only know what will actually be
|
|
134
|
+
// plotted (see shownValues)
|
|
135
|
+
const shown = series.map((s) => shownValues(s, cats));
|
|
136
|
+
const allVals = shown.flat();
|
|
137
|
+
const { lo, hi, ticks } = niceScale(Math.min(0, ...allVals), Math.max(0, ...allVals));
|
|
138
|
+
const horizontal = block.chartType === 'barh';
|
|
139
|
+
|
|
140
|
+
const tickLabels = ticks.map((t) => fmt(t, locale));
|
|
141
|
+
const valLabelW = Math.max(...tickLabels.map((t) => textW(t, 11)));
|
|
142
|
+
const catLabelW = Math.max(...cats.map((c) => textW(c, 11)));
|
|
143
|
+
const pad = {
|
|
144
|
+
left: 8 + (horizontal ? catLabelW : valLabelW),
|
|
145
|
+
right: 12,
|
|
146
|
+
top: 10,
|
|
147
|
+
bottom: 24 + (legend ? 26 : 0),
|
|
148
|
+
};
|
|
149
|
+
const plot = {
|
|
150
|
+
x: pad.left + 6,
|
|
151
|
+
y: pad.top,
|
|
152
|
+
w: W - pad.left - pad.right - 6,
|
|
153
|
+
h: H - pad.top - pad.bottom,
|
|
154
|
+
};
|
|
155
|
+
const p = [];
|
|
156
|
+
|
|
157
|
+
const vpos = (v) =>
|
|
158
|
+
horizontal
|
|
159
|
+
? plot.x + ((v - lo) / (hi - lo)) * plot.w
|
|
160
|
+
: plot.y + plot.h - ((v - lo) / (hi - lo)) * plot.h;
|
|
161
|
+
|
|
162
|
+
// value grid + ticks
|
|
163
|
+
for (let k = 0; k < ticks.length; k++) {
|
|
164
|
+
const t = ticks[k];
|
|
165
|
+
const label = `<text font-family="${FONT()}" font-size="11" fill="${ink()}"`;
|
|
166
|
+
if (horizontal) {
|
|
167
|
+
const x = vpos(t);
|
|
168
|
+
if (t !== lo)
|
|
169
|
+
p.push(
|
|
170
|
+
`<line x1="${x}" y1="${plot.y}" x2="${x}" y2="${plot.y + plot.h}" stroke="${grid()}" stroke-width="1"/>`,
|
|
171
|
+
);
|
|
172
|
+
p.push(
|
|
173
|
+
`${label} x="${x}" y="${plot.y + plot.h + 16}" text-anchor="middle">${esc(tickLabels[k])}</text>`,
|
|
174
|
+
);
|
|
175
|
+
} else {
|
|
176
|
+
const y = vpos(t);
|
|
177
|
+
if (t !== lo)
|
|
178
|
+
p.push(
|
|
179
|
+
`<line x1="${plot.x}" y1="${y}" x2="${plot.x + plot.w}" y2="${y}" stroke="${grid()}" stroke-width="1"/>`,
|
|
180
|
+
);
|
|
181
|
+
p.push(
|
|
182
|
+
`${label} x="${plot.x - 8}" y="${y + 4}" text-anchor="end">${esc(tickLabels[k])}</text>`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// baseline (category axis)
|
|
187
|
+
p.push(
|
|
188
|
+
horizontal
|
|
189
|
+
? `<line x1="${vpos(0)}" y1="${plot.y}" x2="${vpos(0)}" y2="${plot.y + plot.h}" stroke="${axis()}" stroke-width="1"/>`
|
|
190
|
+
: `<line x1="${plot.x}" y1="${vpos(0)}" x2="${plot.x + plot.w}" y2="${vpos(0)}" stroke="${axis()}" stroke-width="1"/>`,
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
const slot = (horizontal ? plot.h : plot.w) / cats.length;
|
|
194
|
+
const center = (i) => (horizontal ? plot.y : plot.x) + slot * (i + 0.5);
|
|
195
|
+
|
|
196
|
+
// category labels
|
|
197
|
+
cats.forEach((c, i) => {
|
|
198
|
+
p.push(
|
|
199
|
+
horizontal
|
|
200
|
+
? `<text font-family="${FONT()}" font-size="11" fill="${ink()}" x="${plot.x - 8}" y="${center(i) + 4}" text-anchor="end">${esc(c)}</text>`
|
|
201
|
+
: `<text font-family="${FONT()}" font-size="11" fill="${ink()}" x="${center(i)}" y="${plot.y + plot.h + 16}" text-anchor="middle">${esc(c)}</text>`,
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
if (block.chartType === 'bar' || block.chartType === 'barh') {
|
|
206
|
+
// 2 px of white between neighbouring bars of the same group
|
|
207
|
+
const group = Math.min(slot * 0.68, series.length * 64);
|
|
208
|
+
const bw = (group - (series.length - 1) * 2) / series.length;
|
|
209
|
+
shown.forEach((values, si) => {
|
|
210
|
+
values.forEach((v, i) => {
|
|
211
|
+
const c = center(i) - group / 2 + si * (bw + 2);
|
|
212
|
+
// the bar runs from zero to the value, one way or the other: we pass
|
|
213
|
+
// the rectangle in increasing coordinates and the sign separately
|
|
214
|
+
const [z, t] = [vpos(0), vpos(v)];
|
|
215
|
+
const span = Math.abs(t - z);
|
|
216
|
+
if (horizontal) {
|
|
217
|
+
p.push(roundedBar(Math.min(z, t), c, span, bw, 4, true, color(si), v < 0));
|
|
218
|
+
} else {
|
|
219
|
+
p.push(roundedBar(c, Math.min(z, t), bw, span, 4, false, color(si), v < 0));
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
} else {
|
|
224
|
+
// line / area — 2 px stroke, 8 px points ringed in white
|
|
225
|
+
shown.forEach((values, si) => {
|
|
226
|
+
const pts = values.map((v, i) => [center(i), vpos(v)]);
|
|
227
|
+
if (!pts.length) return; // series without a category: nothing to join
|
|
228
|
+
const dLine = pts.map(([x, y], i) => `${i ? 'L' : 'M'}${x},${y}`).join(' ');
|
|
229
|
+
if (block.chartType === 'area') {
|
|
230
|
+
p.push(
|
|
231
|
+
`<path d="${dLine} L${pts[pts.length - 1][0]},${vpos(0)} L${pts[0][0]},${vpos(0)} z" fill="${color(si)}" fill-opacity="0.16"/>`,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
p.push(
|
|
235
|
+
`<path d="${dLine}" fill="none" stroke="${color(si)}" stroke-width="2" stroke-linejoin="round"/>`,
|
|
236
|
+
);
|
|
237
|
+
pts.forEach(([x, y]) =>
|
|
238
|
+
p.push(
|
|
239
|
+
`<circle cx="${x}" cy="${y}" r="4" fill="${color(si)}" stroke="${bg()}" stroke-width="2"/>`,
|
|
240
|
+
),
|
|
241
|
+
);
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (legend) p.push(legendRow(series, plot.x, H - 10, plot.w));
|
|
246
|
+
return p.join('\n');
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
// Circular: pie, doughnut — color follows the share (a single series)
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
function circular(block, W, H, locale) {
|
|
254
|
+
const values = shownValues(block.series[0], block.categories);
|
|
255
|
+
const total = values.reduce((a, b) => a + Math.max(0, b), 0) || 1;
|
|
256
|
+
const legendW = Math.min(W * 0.42, Math.max(...block.categories.map((c) => textW(c, 11))) + 90);
|
|
257
|
+
const cx = (W - legendW) / 2;
|
|
258
|
+
const cy = H / 2;
|
|
259
|
+
const R = Math.min(cx - 10, H / 2 - 10);
|
|
260
|
+
const r0 = block.chartType === 'doughnut' ? R * 0.55 : 0;
|
|
261
|
+
const p = [];
|
|
262
|
+
|
|
263
|
+
let a = -Math.PI / 2;
|
|
264
|
+
const pt = (ang, rad) => [cx + rad * Math.cos(ang), cy + rad * Math.sin(ang)];
|
|
265
|
+
values.forEach((v, k) => {
|
|
266
|
+
const frac = Math.max(0, v) / total;
|
|
267
|
+
const a2 = a + frac * 2 * Math.PI;
|
|
268
|
+
const large = frac > 0.5 ? 1 : 0;
|
|
269
|
+
const [x1, y1] = pt(a, R);
|
|
270
|
+
const [x2, y2] = pt(a2, R);
|
|
271
|
+
let d;
|
|
272
|
+
if (r0) {
|
|
273
|
+
const [x3, y3] = pt(a2, r0);
|
|
274
|
+
const [x4, y4] = pt(a, r0);
|
|
275
|
+
d = `M${x1},${y1} A${R},${R} 0 ${large} 1 ${x2},${y2} L${x3},${y3} A${r0},${r0} 0 ${large} 0 ${x4},${y4} z`;
|
|
276
|
+
} else {
|
|
277
|
+
d = `M${cx},${cy} L${x1},${y1} A${R},${R} 0 ${large} 1 ${x2},${y2} z`;
|
|
278
|
+
}
|
|
279
|
+
// 2 px white edging = breathing room between adjacent shares
|
|
280
|
+
p.push(`<path d="${d}" fill="${color(k)}" stroke="${bg()}" stroke-width="2"/>`);
|
|
281
|
+
if (frac >= 0.07) {
|
|
282
|
+
const [lx, ly] = pt((a + a2) / 2, r0 ? (R + r0) / 2 : R * 0.62);
|
|
283
|
+
p.push(
|
|
284
|
+
`<text x="${lx}" y="${ly + 4}" font-family="${FONT()}" font-size="12" font-weight="bold" fill="${bg()}" text-anchor="middle">${Math.round(frac * 100)} %</text>`,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
a = a2;
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// legend on the right: swatch + category + value — one entry per SHOWN
|
|
291
|
+
// value (never a category without a share, which would only invent a
|
|
292
|
+
// "— 0": validation reports orphan categories, CHART_DATA_IGNORED)
|
|
293
|
+
const lx = cx + R + 24;
|
|
294
|
+
const lh = 22;
|
|
295
|
+
let ly = cy - ((values.length - 1) * lh) / 2;
|
|
296
|
+
values.forEach((v, k) => {
|
|
297
|
+
p.push(`<rect x="${lx}" y="${ly - 9}" width="10" height="10" rx="2" fill="${color(k)}"/>`);
|
|
298
|
+
p.push(
|
|
299
|
+
`<text x="${lx + 16}" y="${ly}" font-family="${FONT()}" font-size="11" fill="${ink()}">${esc(block.categories[k])} — ${esc(fmt(v, locale))}</text>`,
|
|
300
|
+
);
|
|
301
|
+
ly += lh;
|
|
302
|
+
});
|
|
303
|
+
return p.join('\n');
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
// Radar
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
function radar(block, W, H) {
|
|
311
|
+
const { categories: cats, series } = block;
|
|
312
|
+
const legend = series.length > 1;
|
|
313
|
+
const cx = W / 2;
|
|
314
|
+
const cy = (H - (legend ? 26 : 0)) / 2;
|
|
315
|
+
const R = Math.min(cx, cy) - 28;
|
|
316
|
+
// same truncation as cartesian() — a vertex without a spoke is never
|
|
317
|
+
// drawn, but if it entered the scale it would crush all the others onto
|
|
318
|
+
// the center (a major finding: radii on the order of a tenth of a pixel)
|
|
319
|
+
const shown = series.map((s) => shownValues(s, cats));
|
|
320
|
+
const hi = niceScale(0, Math.max(0, ...shown.flat())).hi;
|
|
321
|
+
const ang = (i) => -Math.PI / 2 + (i * 2 * Math.PI) / cats.length;
|
|
322
|
+
const pt = (i, v) => [
|
|
323
|
+
cx + ((R * v) / hi) * Math.cos(ang(i)),
|
|
324
|
+
cy + ((R * v) / hi) * Math.sin(ang(i)),
|
|
325
|
+
];
|
|
326
|
+
const p = [];
|
|
327
|
+
|
|
328
|
+
for (const f of [0.25, 0.5, 0.75, 1]) {
|
|
329
|
+
const ring = cats.map((_, i) => pt(i, hi * f).join(',')).join(' ');
|
|
330
|
+
p.push(`<polygon points="${ring}" fill="none" stroke="${grid()}" stroke-width="1"/>`);
|
|
331
|
+
}
|
|
332
|
+
cats.forEach((c, i) => {
|
|
333
|
+
const [x, y] = pt(i, hi);
|
|
334
|
+
p.push(`<line x1="${cx}" y1="${cy}" x2="${x}" y2="${y}" stroke="${grid()}" stroke-width="1"/>`);
|
|
335
|
+
const [tx, ty] = pt(i, hi * 1.12);
|
|
336
|
+
p.push(
|
|
337
|
+
`<text x="${tx}" y="${ty + 4}" font-family="${FONT()}" font-size="11" fill="${ink()}" text-anchor="middle">${esc(c)}</text>`,
|
|
338
|
+
);
|
|
339
|
+
});
|
|
340
|
+
shown.forEach((values, si) => {
|
|
341
|
+
const pts = cats.map((_, i) => pt(i, values[i] ?? 0));
|
|
342
|
+
p.push(
|
|
343
|
+
`<polygon points="${pts.map((q) => q.join(',')).join(' ')}" fill="${color(si)}" fill-opacity="0.14" stroke="${color(si)}" stroke-width="2"/>`,
|
|
344
|
+
);
|
|
345
|
+
pts.forEach(([x, y]) =>
|
|
346
|
+
p.push(
|
|
347
|
+
`<circle cx="${x}" cy="${y}" r="3.5" fill="${color(si)}" stroke="${bg()}" stroke-width="2"/>`,
|
|
348
|
+
),
|
|
349
|
+
);
|
|
350
|
+
});
|
|
351
|
+
if (legend) p.push(legendRow(series, 0, H - 10, W));
|
|
352
|
+
return p.join('\n');
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ---------------------------------------------------------------------------
|
|
356
|
+
// Entry point
|
|
357
|
+
// ---------------------------------------------------------------------------
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Renders a `chart` block as SVG at the dimensions of the slot (px).
|
|
361
|
+
*
|
|
362
|
+
* @param {object} block `chart` block of the IR
|
|
363
|
+
* @param {number} W width of the slot (px)
|
|
364
|
+
* @param {number} H height of the slot (px)
|
|
365
|
+
* @param {object} [opts] { locale } — number formatting
|
|
366
|
+
* (default DEFAULT_LOCALE, see the module header)
|
|
367
|
+
*/
|
|
368
|
+
export function chartSvg(block, W, H, { locale = DEFAULT_LOCALE } = {}) {
|
|
369
|
+
W = Math.max(240, Math.round(W));
|
|
370
|
+
H = Math.max(160, Math.round(H));
|
|
371
|
+
const body =
|
|
372
|
+
block.chartType === 'pie' || block.chartType === 'doughnut'
|
|
373
|
+
? circular(block, W, H, locale)
|
|
374
|
+
: block.chartType === 'radar'
|
|
375
|
+
? radar(block, W, H, locale)
|
|
376
|
+
: cartesian(block, W, H, locale);
|
|
377
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
|
|
378
|
+
<rect width="${W}" height="${H}" fill="${bg()}"/>
|
|
379
|
+
${body}
|
|
380
|
+
</svg>`;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* What the rendering is going to drop, said out loud as diagnostics — the
|
|
385
|
+
* speaking counterpart of shownValues(). The plot truncates to stay inside the
|
|
386
|
+
* frame; without this channel, the data loss would be mute. Wired into
|
|
387
|
+
* validate.mjs (code CHART_DATA_IGNORED), hence visible to `lutrin validate`,
|
|
388
|
+
* VS Code and Obsidian.
|
|
389
|
+
*
|
|
390
|
+
* The circular charts (pie/doughnut) are left to validate.mjs, which handles
|
|
391
|
+
* them with their own constraints (single series, positive shares): diagnosing
|
|
392
|
+
* them here would duplicate that.
|
|
393
|
+
*
|
|
394
|
+
* @param {object} block `chart` block of the IR — possibly malformed
|
|
395
|
+
* @returns {Array<{severity:'warning', code:'CHART_DATA_IGNORED', message:string, line:number}>}
|
|
396
|
+
*/
|
|
397
|
+
export function chartDataDiagnostics(block) {
|
|
398
|
+
// a single unbroken guard: the type used to be read via `block?.type` then
|
|
399
|
+
// `block.series` dereferenced without a net — a block without series raised
|
|
400
|
+
// "not iterable" in the caller
|
|
401
|
+
if (!block || block.type !== 'chart') return [];
|
|
402
|
+
if (block.chartType === 'pie' || block.chartType === 'doughnut') return [];
|
|
403
|
+
const cats = block.categories;
|
|
404
|
+
const series = block.series;
|
|
405
|
+
if (!Array.isArray(cats) || !Array.isArray(series)) return [];
|
|
406
|
+
|
|
407
|
+
const out = [];
|
|
408
|
+
for (const s of series) {
|
|
409
|
+
const n = s?.values?.length ?? 0;
|
|
410
|
+
const extra = n - cats.length;
|
|
411
|
+
if (extra <= 0) continue;
|
|
412
|
+
out.push({
|
|
413
|
+
severity: 'warning',
|
|
414
|
+
code: 'CHART_DATA_IGNORED',
|
|
415
|
+
message:
|
|
416
|
+
`Chart "${block.chartType}": series "${s.name}" has ${n} value${n > 1 ? 's' : ''} ` +
|
|
417
|
+
`for ${cats.length} ${cats.length === 1 ? 'category' : 'categories'} — ${
|
|
418
|
+
extra === 1 ? 'the last one will be dropped' : `the last ${extra} will be dropped`
|
|
419
|
+
}.`,
|
|
420
|
+
line: block.line ?? 1,
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
return out;
|
|
424
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compilation context of a deck: theme + theme layouts + user layouts.
|
|
3
|
+
*
|
|
4
|
+
* The SINGLE insertion point, called after parseDeck and before buildScenes
|
|
5
|
+
* by every entry point of the pipeline (CLI build/inspect/preview, worker,
|
|
6
|
+
* compileHtml, validateDeck): the tokens and the layout registry are module
|
|
7
|
+
* state mutated in place, and hosts are warm processes shared between
|
|
8
|
+
* decks — so every compilation starts again from a fresh state (user
|
|
9
|
+
* layouts reset, theme re-applied from the default snapshot), then loads
|
|
10
|
+
* what comes with THIS deck: the theme (frontmatter `theme: ./x.json` or
|
|
11
|
+
* `theme: @org/package`, CLI flag `--theme`, or the project default from
|
|
12
|
+
* the nearest package.json — see theme.mjs), the `layouts/` of the resolved
|
|
13
|
+
* theme package, and the deck's own `layouts/*.json`.
|
|
14
|
+
* The theme travels with the document or is installed by npm into the
|
|
15
|
+
* project: no re-packaging of the extensions to theme a deck.
|
|
16
|
+
*
|
|
17
|
+
* Never throws: any problem (theme that could not be read, invalid layout,
|
|
18
|
+
* insufficient contrast) becomes a diagnostic { severity, code, message,
|
|
19
|
+
* suggestion? } WITHOUT a line — the caller positions it (validateDeck: the
|
|
20
|
+
* frontmatter `theme:` line; CLI/worker: stats.warnings).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
OFFICIAL_LAYOUT_DIAGS,
|
|
25
|
+
loadThemeLayouts,
|
|
26
|
+
loadUserLayouts,
|
|
27
|
+
resetUserLayouts,
|
|
28
|
+
} from './layout.mjs';
|
|
29
|
+
import { applyTheme, resolveTheme, themeContrastDiagnostics } from './theme.mjs';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {object} meta deck frontmatter (deck.meta)
|
|
33
|
+
* @param {object} [opts] { baseDir, themePath, defaultTheme } — themePath
|
|
34
|
+
* (CLI --theme) takes precedence over meta.theme;
|
|
35
|
+
* defaultTheme (host) applies only if nothing else names a
|
|
36
|
+
* theme
|
|
37
|
+
* @returns {{ diagnostics: Array, theme: object|null, themeFile: string|null }}
|
|
38
|
+
*/
|
|
39
|
+
export function prepareDeckContext(
|
|
40
|
+
meta = {},
|
|
41
|
+
{ baseDir = process.cwd(), themePath = null, defaultTheme = null } = {},
|
|
42
|
+
) {
|
|
43
|
+
resetUserLayouts();
|
|
44
|
+
// official catalog (design/layouts/): loaded once at startup — a file that
|
|
45
|
+
// could not be read would signal a broken installation, on every deck
|
|
46
|
+
const diagnostics = [...OFFICIAL_LAYOUT_DIAGS];
|
|
47
|
+
const {
|
|
48
|
+
theme,
|
|
49
|
+
path: themeFile,
|
|
50
|
+
layoutsDir,
|
|
51
|
+
kitName,
|
|
52
|
+
diagnostics: themeDiags,
|
|
53
|
+
} = resolveTheme(meta, { baseDir, themePath, defaultTheme });
|
|
54
|
+
diagnostics.push(...themeDiags);
|
|
55
|
+
applyTheme(theme);
|
|
56
|
+
// kit layouts BEFORE the deck's own: a collision is reported on the deck's
|
|
57
|
+
// definition, attributed to the kit
|
|
58
|
+
if (layoutsDir) diagnostics.push(...loadThemeLayouts(layoutsDir, kitName));
|
|
59
|
+
diagnostics.push(...loadUserLayouts(baseDir));
|
|
60
|
+
if (theme) diagnostics.push(...themeContrastDiagnostics());
|
|
61
|
+
return { diagnostics, theme, themeFile };
|
|
62
|
+
}
|