@colixsystems/widget-sdk 0.245.0 → 0.247.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.
Files changed (56) hide show
  1. package/README.md +60 -2
  2. package/dist/button-tokens.js +154 -0
  3. package/dist/button-view.js +251 -93
  4. package/dist/button.js +5 -3
  5. package/dist/button.native.js +6 -4
  6. package/dist/chart-logic.js +575 -0
  7. package/dist/chart-tokens.js +131 -0
  8. package/dist/chart-view.js +262 -0
  9. package/dist/chart.js +21 -0
  10. package/dist/chart.native.js +10 -0
  11. package/dist/contract.cjs +132 -35
  12. package/dist/contract.js +132 -35
  13. package/dist/detail-parts-view.js +31 -2
  14. package/dist/file-image-logic.js +162 -0
  15. package/dist/file-image-source.js +71 -0
  16. package/dist/file-image-view.js +119 -0
  17. package/dist/file-image.js +8 -0
  18. package/dist/file-image.native.js +8 -0
  19. package/dist/form-control-logic.js +23 -0
  20. package/dist/form-control-tokens.js +25 -10
  21. package/dist/grid-logic.js +222 -0
  22. package/dist/grid-view.js +109 -0
  23. package/dist/grid.js +7 -0
  24. package/dist/grid.native.js +7 -0
  25. package/dist/host.d.ts +6 -0
  26. package/dist/host.js +2 -0
  27. package/dist/index.d.ts +481 -20
  28. package/dist/index.js +14 -1
  29. package/dist/index.native.js +14 -1
  30. package/dist/menu-view.js +168 -0
  31. package/dist/overlay-tokens.js +50 -0
  32. package/dist/overlay-view.js +67 -24
  33. package/dist/overlay.native.js +3 -1
  34. package/dist/people-picker-logic.js +203 -0
  35. package/dist/people-picker-view.js +247 -0
  36. package/dist/people-picker.js +11 -0
  37. package/dist/people-picker.native.js +9 -0
  38. package/dist/popover-tokens.js +145 -0
  39. package/dist/popover-view.js +252 -0
  40. package/dist/popover.js +23 -0
  41. package/dist/popover.native.js +22 -0
  42. package/dist/radio-group-logic.js +61 -0
  43. package/dist/radio-group-tokens.js +62 -0
  44. package/dist/radio-group-view.js +262 -0
  45. package/dist/radio-group.js +12 -0
  46. package/dist/radio-group.native.js +7 -0
  47. package/dist/select-search-logic.js +150 -0
  48. package/dist/select-view.js +255 -43
  49. package/dist/select.js +6 -2
  50. package/dist/select.native.js +5 -3
  51. package/dist/tabs-tokens.js +59 -0
  52. package/dist/tabs-view.js +9 -43
  53. package/dist/tooltip-view.js +137 -0
  54. package/dist/web-modal.js +95 -23
  55. package/package.json +1 -1
  56. package/src/contract.js +132 -35
@@ -0,0 +1,575 @@
1
+ // The geometry `<Chart>` draws, as plain numbers and SVG path strings. No
2
+ // React and no renderer, so both hosts draw the same marks and node tests
3
+ // can pin them.
4
+
5
+ export const CHART_TYPES = Object.freeze(["bar", "hbar", "line", "area", "donut", "sparkline"]);
6
+ export const DEFAULT_CHART_TYPE = "bar";
7
+ export const DEFAULT_CHART_HEIGHT = 220;
8
+ export const DEFAULT_SPARKLINE_HEIGHT = 48;
9
+ export const VALUE_FORMATS = Object.freeze(["number", "compact", "percent"]);
10
+
11
+ // The single series a chart draws when the caller names none.
12
+ export const DEFAULT_SERIES_KEY = "value";
13
+ // A screen reader hears at most this many values, over all series.
14
+ export const SPOKEN_VALUES_MAX = 24;
15
+ /** The tail of a cut-short spoken list, until a widget passes `moreText`. */
16
+ export const DEFAULT_MORE_TEXT = (count) => `and ${count} more`;
17
+
18
+ const TYPE_NAMES = Object.freeze({
19
+ bar: "Bar chart",
20
+ hbar: "Horizontal bar chart",
21
+ line: "Line chart",
22
+ area: "Area chart",
23
+ donut: "Donut chart",
24
+ sparkline: "Sparkline",
25
+ });
26
+
27
+ // Average glyph width as a share of the font size, for fitting labels.
28
+ const GLYPH_WIDTH = 0.58;
29
+ const TICK_COUNT = 4;
30
+ // A crowded axis keeps every n-th label once labels would shrink below this.
31
+ const LABEL_MIN_CHARS = 6;
32
+ const TICK_GAP = 6;
33
+ const GROUP_SHARE = 0.72;
34
+ const BAR_MAX_WIDTH = 48;
35
+ const BAR_MIN_WIDTH = 2;
36
+ const DONUT_HOLE = 0.62;
37
+ const DOTS_MAX_POINTS = 24;
38
+ const HBAR_LABEL_SHARE = 0.38;
39
+ const HBAR_ROW_SHARE = 0.7;
40
+ // A horizontal bar's label needs its line height; crowded rows label every n-th.
41
+ const HBAR_LABEL_GAP = 4;
42
+
43
+ export function normaliseChartType(type) {
44
+ return CHART_TYPES.includes(type) ? type : DEFAULT_CHART_TYPE;
45
+ }
46
+
47
+ /** The plot's height: a positive number, else the type's own default. */
48
+ export function chartHeight(type, height) {
49
+ if (typeof height === "number" && Number.isFinite(height) && height > 0) return height;
50
+ return type === "sparkline" ? DEFAULT_SPARKLINE_HEIGHT : DEFAULT_CHART_HEIGHT;
51
+ }
52
+
53
+ function labelText(value) {
54
+ if (value === null || value === undefined) return "";
55
+ if (typeof value === "object" && typeof value.label === "string") return value.label;
56
+ return String(value);
57
+ }
58
+
59
+ function numberOrNull(value) {
60
+ if (value === null || value === undefined || value === "") return null;
61
+ const n = typeof value === "number" ? value : Number(value);
62
+ return Number.isFinite(n) ? n : null;
63
+ }
64
+
65
+ /**
66
+ * Turn `data` (and the optional `series`) into the labels and the value rows
67
+ * every chart type reads. Without `series` each item is `{ label, value }`;
68
+ * with it, each item carries one number per series key.
69
+ *
70
+ * @returns {{ labels: string[], series: Array<{ key: string, label: string, color?: string, values: Array<number|null> }> }}
71
+ */
72
+ export function normaliseChartData(data, series) {
73
+ const rows = Array.isArray(data) ? data.filter((row) => row && typeof row === "object") : [];
74
+ const declared = Array.isArray(series)
75
+ ? series.filter((s) => s && typeof s === "object" && typeof s.key === "string" && s.key)
76
+ : [];
77
+ const lines = declared.length
78
+ ? declared.map((s) => ({
79
+ key: s.key,
80
+ label: labelText(s.label ?? s.key),
81
+ color: typeof s.color === "string" && s.color ? s.color : undefined,
82
+ }))
83
+ : [{ key: DEFAULT_SERIES_KEY, label: "", color: undefined }];
84
+ return {
85
+ labels: rows.map((row) => labelText(row.label)),
86
+ series: lines.map((line) => ({ ...line, values: rows.map((row) => numberOrNull(row[line.key])) })),
87
+ };
88
+ }
89
+
90
+ /** The values a donut draws: the first series, positive values only. */
91
+ function donutValues(model) {
92
+ const first = model.series[0];
93
+ return first ? first.values.map((v) => (v !== null && v > 0 ? v : 0)) : [];
94
+ }
95
+
96
+ /** True when there is nothing to plot, so the chart shows its empty state. */
97
+ export function isChartEmpty(model, type) {
98
+ if (!model || model.labels.length === 0) return true;
99
+ if (type === "donut") return donutValues(model).reduce((sum, v) => sum + v, 0) <= 0;
100
+ return !model.series.some((s) => s.values.some((v) => v !== null));
101
+ }
102
+
103
+ // `locale` is the app's language; an unknown one falls back to the device's.
104
+ function localNumber(value, digits, locale) {
105
+ try {
106
+ return value.toLocaleString(locale, { maximumFractionDigits: digits });
107
+ } catch {
108
+ return String(Number(value.toFixed(digits)));
109
+ }
110
+ }
111
+
112
+ function compactNumber(value, locale) {
113
+ const abs = Math.abs(value);
114
+ for (const [size, suffix] of [
115
+ [1e12, "T"],
116
+ [1e9, "B"],
117
+ [1e6, "M"],
118
+ [1e3, "k"],
119
+ ]) {
120
+ if (abs >= size) return `${localNumber(value / size, abs / size < 10 ? 1 : 0, locale)}${suffix}`;
121
+ }
122
+ return localNumber(value, abs < 10 ? 2 : 1, locale);
123
+ }
124
+
125
+ /**
126
+ * One value as text. `format` is a function of the number, or `"number"`
127
+ * (default, grouped), `"compact"` (1.2k, 3M) or `"percent"`, which appends a
128
+ * percent sign to a value that is already a percentage (42 reads "42%"; it
129
+ * never multiplies by 100). Digits are grouped for `locale`, the app's language.
130
+ */
131
+ export function formatChartValue(value, format, locale) {
132
+ if (typeof value !== "number" || !Number.isFinite(value)) return "";
133
+ if (typeof format === "function") {
134
+ try {
135
+ return String(format(value));
136
+ } catch {
137
+ return localNumber(value, 2, locale);
138
+ }
139
+ }
140
+ if (format === "compact") return compactNumber(value, locale);
141
+ if (format === "percent") return `${localNumber(value, 2, locale)}%`;
142
+ return localNumber(value, 2, locale);
143
+ }
144
+
145
+ /** An axis tick as text: compact unless the caller formats values itself. */
146
+ export function formatChartTick(value, format, locale) {
147
+ if (typeof format === "function" || format === "percent") return formatChartValue(value, format, locale);
148
+ return compactNumber(value, locale);
149
+ }
150
+
151
+ function niceStep(raw) {
152
+ const power = 10 ** Math.floor(Math.log10(raw));
153
+ const fraction = raw / power;
154
+ const nice = fraction < 1.5 ? 1 : fraction < 3 ? 2 : fraction < 7 ? 5 : 10;
155
+ return nice * power;
156
+ }
157
+
158
+ function cleanFloat(value) {
159
+ return Number(value.toPrecision(12));
160
+ }
161
+
162
+ /**
163
+ * Round a value range out to "nice" tick values (0, 25, 50 …).
164
+ *
165
+ * @returns {{ min: number, max: number, ticks: number[] }}
166
+ */
167
+ export function niceTicks(min, max, count = TICK_COUNT) {
168
+ let low = Number.isFinite(min) ? min : 0;
169
+ let high = Number.isFinite(max) ? max : 0;
170
+ if (low > high) [low, high] = [high, low];
171
+ if (low === high) {
172
+ if (low === 0) high = 1;
173
+ else if (low > 0) low = 0;
174
+ else high = 0;
175
+ }
176
+ const step = niceStep((high - low) / Math.max(1, count));
177
+ const niceMin = cleanFloat(Math.floor(low / step) * step);
178
+ const niceMax = cleanFloat(Math.ceil(high / step) * step);
179
+ const ticks = [];
180
+ for (let tick = niceMin; tick <= niceMax + step / 2; tick += step) ticks.push(cleanFloat(tick));
181
+ return { min: niceMin, max: niceMax, ticks };
182
+ }
183
+
184
+ /** Map `[d0, d1]` onto `[r0, r1]`; a zero-width domain maps to the middle. */
185
+ export function linearScale(d0, d1, r0, r1) {
186
+ const span = d1 - d0;
187
+ if (!span) return () => (r0 + r1) / 2;
188
+ return (value) => r0 + ((value - d0) / span) * (r1 - r0);
189
+ }
190
+
191
+ function round(value) {
192
+ return Math.round(value * 100) / 100;
193
+ }
194
+
195
+ /**
196
+ * A line through the points, broken where a point is null (a missing value).
197
+ *
198
+ * @param {Array<{x:number,y:number}|null>} points
199
+ */
200
+ export function linePath(points) {
201
+ let d = "";
202
+ let pen = false;
203
+ for (const point of points) {
204
+ if (!point) {
205
+ pen = false;
206
+ continue;
207
+ }
208
+ d += `${d ? " " : ""}${pen ? "L" : "M"} ${round(point.x)} ${round(point.y)}`;
209
+ pen = true;
210
+ }
211
+ return d;
212
+ }
213
+
214
+ /** The area under each unbroken run of points, closed down to `baseY`. */
215
+ export function areaPath(points, baseY) {
216
+ const runs = [];
217
+ let run = [];
218
+ for (const point of points) {
219
+ if (point) run.push(point);
220
+ else if (run.length) {
221
+ runs.push(run);
222
+ run = [];
223
+ }
224
+ }
225
+ if (run.length) runs.push(run);
226
+ return runs
227
+ .map((part) => {
228
+ const first = part[0];
229
+ const last = part[part.length - 1];
230
+ const top = part.map((p) => `L ${round(p.x)} ${round(p.y)}`).join(" ");
231
+ return `M ${round(first.x)} ${round(baseY)} ${top} L ${round(last.x)} ${round(baseY)} Z`;
232
+ })
233
+ .join(" ");
234
+ }
235
+
236
+ /**
237
+ * A bar rounded only at its far end (`edge`: "top" | "bottom" | "right" |
238
+ * "left"), so it stands square on its baseline.
239
+ */
240
+ export function barPath({ x, y, width, height }, radius, edge = "top") {
241
+ const across = edge === "top" || edge === "bottom" ? width : height;
242
+ const along = edge === "top" || edge === "bottom" ? height : width;
243
+ const r = Math.max(0, Math.min(radius || 0, across / 2, along));
244
+ const [x0, y0, x1, y1] = [x, y, x + width, y + height].map(round);
245
+ if (r <= 0) return `M ${x0} ${y0} L ${x1} ${y0} L ${x1} ${y1} L ${x0} ${y1} Z`;
246
+ const q = round(r);
247
+ const arc = (tx, ty) => `A ${q} ${q} 0 0 1 ${round(tx)} ${round(ty)}`;
248
+ if (edge === "top") {
249
+ return `M ${x0} ${y1} L ${x0} ${round(y + r)} ${arc(x + r, y)} L ${round(x + width - r)} ${y0} ${arc(x + width, y + r)} L ${x1} ${y1} Z`;
250
+ }
251
+ if (edge === "bottom") {
252
+ return `M ${x1} ${y0} L ${x1} ${round(y + height - r)} ${arc(x + width - r, y + height)} L ${round(x + r)} ${y1} ${arc(x, y + height - r)} L ${x0} ${y0} Z`;
253
+ }
254
+ if (edge === "right") {
255
+ return `M ${x0} ${y0} L ${round(x + width - r)} ${y0} ${arc(x + width, y + r)} L ${x1} ${round(y + height - r)} ${arc(x + width - r, y + height)} L ${x0} ${y1} Z`;
256
+ }
257
+ return `M ${x1} ${y1} L ${round(x + r)} ${y1} ${arc(x, y + height - r)} L ${x0} ${round(y + r)} ${arc(x + r, y)} L ${x1} ${y0} Z`;
258
+ }
259
+
260
+ function polar(cx, cy, r, angle) {
261
+ return { x: cx + r * Math.cos(angle), y: cy + r * Math.sin(angle) };
262
+ }
263
+
264
+ function circlePath(cx, cy, r, sweep) {
265
+ const top = `${round(cx)} ${round(cy - r)}`;
266
+ const bottom = `${round(cx)} ${round(cy + r)}`;
267
+ const q = round(r);
268
+ return `M ${top} A ${q} ${q} 0 1 ${sweep} ${bottom} A ${q} ${q} 0 1 ${sweep} ${top} Z`;
269
+ }
270
+
271
+ /** One ring segment from `start` to `end` (radians, 0 = 3 o'clock, clockwise). */
272
+ export function ringSlicePath(cx, cy, outer, inner, start, end) {
273
+ if (end - start >= Math.PI * 2 - 1e-6) {
274
+ // A whole ring: the hole is cut out by the opposite winding.
275
+ return `${circlePath(cx, cy, outer, 1)} ${inner > 0 ? circlePath(cx, cy, inner, 0) : ""}`.trim();
276
+ }
277
+ const large = end - start > Math.PI ? 1 : 0;
278
+ const a = polar(cx, cy, outer, start);
279
+ const b = polar(cx, cy, outer, end);
280
+ const c = polar(cx, cy, inner, end);
281
+ const d = polar(cx, cy, inner, start);
282
+ const o = round(outer);
283
+ const i = round(inner);
284
+ return (
285
+ `M ${round(a.x)} ${round(a.y)} A ${o} ${o} 0 ${large} 1 ${round(b.x)} ${round(b.y)} ` +
286
+ `L ${round(c.x)} ${round(c.y)} A ${i} ${i} 0 ${large} 0 ${round(d.x)} ${round(d.y)} Z`
287
+ );
288
+ }
289
+
290
+ function estimateWidth(text, fontSize) {
291
+ return String(text).length * fontSize * GLYPH_WIDTH;
292
+ }
293
+
294
+ /** Shorten a label to fit `maxWidth`, ending it with an ellipsis. */
295
+ export function fitLabel(text, maxWidth, fontSize) {
296
+ const value = String(text);
297
+ if (estimateWidth(value, fontSize) <= maxWidth) return value;
298
+ const chars = Math.max(1, Math.floor(maxWidth / (fontSize * GLYPH_WIDTH)) - 1);
299
+ return `${value.slice(0, chars)}…`;
300
+ }
301
+
302
+ function valueExtent(model) {
303
+ let min = Infinity;
304
+ let max = -Infinity;
305
+ for (const s of model.series) {
306
+ for (const v of s.values) {
307
+ if (v === null) continue;
308
+ if (v < min) min = v;
309
+ if (v > max) max = v;
310
+ }
311
+ }
312
+ return Number.isFinite(min) ? { min, max } : { min: 0, max: 0 };
313
+ }
314
+
315
+ // Bars and areas stand on zero; a line or sparkline shows its own range.
316
+ function valueDomain(model, type) {
317
+ const { min, max } = valueExtent(model);
318
+ if (type === "line" || type === "sparkline") return { min, max };
319
+ return { min: Math.min(0, min), max: Math.max(0, max) };
320
+ }
321
+
322
+ // A bar's thickness: its share of the group, at least a hairline, never past its slot.
323
+ function barThickness(groupSize, groups, slot) {
324
+ return Math.min(Math.max(BAR_MIN_WIDTH, groupSize / groups), slot / groups);
325
+ }
326
+
327
+ // Every n-th category label, so neighbouring labels never overlap.
328
+ function labelStride(labels, slot, fontSize) {
329
+ const longest = labels.reduce((m, l) => Math.max(m, Math.min(String(l).length, LABEL_MIN_CHARS)), 1);
330
+ const needed = longest * fontSize * GLYPH_WIDTH + TICK_GAP;
331
+ return Math.max(1, Math.ceil(needed / Math.max(1, slot)));
332
+ }
333
+
334
+ function pointsFor(values, xAt, scale) {
335
+ return values.map((v, i) => (v === null ? null : { x: xAt(i), y: scale(v) }));
336
+ }
337
+
338
+ function verticalLayout({ type, width, height, model, fontSize, valueFormat, locale }) {
339
+ const sparkline = type === "sparkline";
340
+ const domain = valueDomain(model, type);
341
+ const nice = sparkline ? { ...domain, ticks: [] } : niceTicks(domain.min, domain.max);
342
+ const tickTexts = nice.ticks.map((t) => formatChartTick(t, valueFormat, locale));
343
+ const stroke = sparkline ? 2 : 0;
344
+ const left = sparkline ? stroke : Math.ceil(tickTexts.reduce((m, t) => Math.max(m, estimateWidth(t, fontSize)), 0)) + TICK_GAP;
345
+ const right = sparkline ? stroke : Math.ceil(fontSize / 2);
346
+ const top = sparkline ? stroke : Math.ceil(fontSize / 2) + 1;
347
+ const bottom = sparkline ? stroke : fontSize + TICK_GAP + 2;
348
+ const plotW = Math.max(1, width - left - right);
349
+ const plotH = Math.max(1, height - top - bottom);
350
+ const scale = linearScale(nice.min, nice.max, top + plotH, top);
351
+ const baseY = scale(Math.min(Math.max(0, nice.min), nice.max));
352
+ const count = model.labels.length;
353
+
354
+ const out = {
355
+ plot: { left, top, width: plotW, height: plotH },
356
+ gridLines: nice.ticks.map((t) => ({ x1: left, x2: left + plotW, y: round(scale(t)) })),
357
+ tickLabels: nice.ticks.map((t, i) => ({ x: left - TICK_GAP, y: round(scale(t) + fontSize / 3), text: tickTexts[i], anchor: "end" })),
358
+ baseline: sparkline ? null : { x1: left, x2: left + plotW, y: round(baseY) },
359
+ categoryLabels: [],
360
+ bars: [],
361
+ lines: [],
362
+ areas: [],
363
+ dots: [],
364
+ };
365
+
366
+ if (type === "bar") {
367
+ const slot = plotW / Math.max(1, count);
368
+ const groups = model.series.length;
369
+ const groupW = Math.min(slot * GROUP_SHARE, BAR_MAX_WIDTH * groups);
370
+ const barW = barThickness(groupW, groups, slot);
371
+ model.series.forEach((s, si) => {
372
+ s.values.forEach((v, i) => {
373
+ if (v === null) return;
374
+ const x = left + i * slot + (slot - barW * groups) / 2 + si * barW;
375
+ const y = scale(v);
376
+ const up = y <= baseY;
377
+ out.bars.push({
378
+ seriesIndex: si,
379
+ index: i,
380
+ rect: { x, y: up ? y : baseY, width: barW, height: Math.abs(baseY - y) },
381
+ edge: up ? "top" : "bottom",
382
+ });
383
+ });
384
+ });
385
+ const stride = labelStride(model.labels, slot, fontSize);
386
+ model.labels.forEach((label, i) => {
387
+ if (i % stride) return;
388
+ out.categoryLabels.push({
389
+ x: round(left + i * slot + slot / 2),
390
+ y: round(top + plotH + TICK_GAP + fontSize * 0.8),
391
+ text: fitLabel(label, slot * stride - TICK_GAP, fontSize),
392
+ anchor: "middle",
393
+ });
394
+ });
395
+ return out;
396
+ }
397
+
398
+ const xAt = count > 1 ? (i) => left + (i * plotW) / (count - 1) : () => left + plotW / 2;
399
+ model.series.forEach((s, si) => {
400
+ const points = pointsFor(s.values, xAt, scale);
401
+ out.lines.push({ seriesIndex: si, d: linePath(points) });
402
+ if (type === "area") out.areas.push({ seriesIndex: si, d: areaPath(points, baseY) });
403
+ if (sparkline) {
404
+ const last = [...points].reverse().find(Boolean);
405
+ if (last) out.dots.push({ seriesIndex: si, cx: round(last.x), cy: round(last.y) });
406
+ } else if (count <= DOTS_MAX_POINTS) {
407
+ points.forEach((p) => p && out.dots.push({ seriesIndex: si, cx: round(p.x), cy: round(p.y) }));
408
+ }
409
+ });
410
+ if (!sparkline) {
411
+ const slot = count > 1 ? plotW / (count - 1) : plotW;
412
+ const stride = labelStride(model.labels, slot, fontSize);
413
+ model.labels.forEach((label, i) => {
414
+ if (i % stride) return;
415
+ const first = i === 0 && count > 1;
416
+ const last = i === count - 1 && count > 1;
417
+ out.categoryLabels.push({
418
+ x: round(xAt(i)),
419
+ y: round(top + plotH + TICK_GAP + fontSize * 0.8),
420
+ text: fitLabel(label, Math.max(slot * stride - TICK_GAP, fontSize * 3), fontSize),
421
+ anchor: first ? "start" : last ? "end" : "middle",
422
+ });
423
+ });
424
+ }
425
+ return out;
426
+ }
427
+
428
+ function horizontalLayout({ width, height, model, fontSize, valueFormat, locale }) {
429
+ const domain = valueDomain(model, "hbar");
430
+ const nice = niceTicks(domain.min, domain.max);
431
+ const count = model.labels.length;
432
+ const longest = model.labels.reduce((m, l) => Math.max(m, estimateWidth(l, fontSize)), 0);
433
+ const left = Math.ceil(Math.min(longest, width * HBAR_LABEL_SHARE)) + TICK_GAP;
434
+ const lastTick = formatChartTick(nice.ticks[nice.ticks.length - 1], valueFormat, locale);
435
+ const right = Math.ceil(estimateWidth(lastTick, fontSize) / 2) + 2;
436
+ const top = 2;
437
+ const bottom = fontSize + TICK_GAP + 2;
438
+ const plotW = Math.max(1, width - left - right);
439
+ const plotH = Math.max(1, height - top - bottom);
440
+ const scale = linearScale(nice.min, nice.max, left, left + plotW);
441
+ const baseX = scale(Math.min(Math.max(0, nice.min), nice.max));
442
+ const slot = plotH / Math.max(1, count);
443
+ const groups = model.series.length;
444
+ const groupH = Math.min(slot * HBAR_ROW_SHARE, BAR_MAX_WIDTH * groups);
445
+ const barH = barThickness(groupH, groups, slot);
446
+ // The chart keeps its height, so rows thinner than a line of text label every n-th.
447
+ const stride = Math.max(1, Math.ceil((fontSize + HBAR_LABEL_GAP) / Math.max(slot, 1e-6)));
448
+ const out = {
449
+ plot: { left, top, width: plotW, height: plotH },
450
+ gridLines: nice.ticks.map((t) => ({ y1: top, y2: top + plotH, x: round(scale(t)) })),
451
+ tickLabels: nice.ticks.map((t) => ({ x: round(scale(t)), y: round(top + plotH + TICK_GAP + fontSize * 0.8), text: formatChartTick(t, valueFormat, locale), anchor: "middle" })),
452
+ baseline: { y1: top, y2: top + plotH, x: round(baseX) },
453
+ categoryLabels: model.labels.flatMap((label, i) =>
454
+ i % stride
455
+ ? []
456
+ : [{ x: left - TICK_GAP, y: round(top + i * slot + slot / 2 + fontSize / 3), text: fitLabel(label, left - TICK_GAP, fontSize), anchor: "end" }],
457
+ ),
458
+ bars: [],
459
+ lines: [],
460
+ areas: [],
461
+ dots: [],
462
+ };
463
+ model.series.forEach((s, si) => {
464
+ s.values.forEach((v, i) => {
465
+ if (v === null) return;
466
+ const x = scale(v);
467
+ const forward = x >= baseX;
468
+ out.bars.push({
469
+ seriesIndex: si,
470
+ index: i,
471
+ rect: { x: forward ? baseX : x, y: top + i * slot + (slot - barH * groups) / 2 + si * barH, width: Math.abs(x - baseX), height: barH },
472
+ edge: forward ? "right" : "left",
473
+ });
474
+ });
475
+ });
476
+ return out;
477
+ }
478
+
479
+ /**
480
+ * Everything a bar, horizontal bar, line, area or sparkline chart draws, in
481
+ * the pixel space of a `width` x `height` SVG.
482
+ */
483
+ export function cartesianLayout({ type, width, height, model, fontSize = 12, valueFormat, locale }) {
484
+ const kind = normaliseChartType(type);
485
+ if (kind === "hbar") return horizontalLayout({ width, height, model, fontSize, valueFormat, locale });
486
+ return verticalLayout({ type: kind, width, height, model, fontSize, valueFormat, locale });
487
+ }
488
+
489
+ /** The ring segments of a donut, one per positive value of the first series. */
490
+ export function donutLayout({ width, height, model }) {
491
+ const values = donutValues(model);
492
+ const total = values.reduce((sum, v) => sum + v, 0);
493
+ const size = Math.max(0, Math.min(width, height));
494
+ const cx = width / 2;
495
+ const cy = height / 2;
496
+ const outer = size / 2;
497
+ const inner = outer * DONUT_HOLE;
498
+ const slices = [];
499
+ let angle = -Math.PI / 2;
500
+ values.forEach((v, index) => {
501
+ if (v <= 0 || total <= 0) return;
502
+ const sweep = (v / total) * Math.PI * 2;
503
+ slices.push({ index, d: ringSlicePath(cx, cy, outer, inner, angle, angle + sweep), share: v / total });
504
+ angle += sweep;
505
+ });
506
+ return { cx, cy, outer, inner, total, slices };
507
+ }
508
+
509
+ function spokenValues(labels, values, { format, locale, moreText, budget }) {
510
+ const said = [];
511
+ values.forEach((v, i) => {
512
+ if (v === null || said.length >= budget) return;
513
+ said.push(`${labels[i]} ${formatChartValue(v, format, locale)}`.trim());
514
+ });
515
+ const shown = values.filter((v) => v !== null).length;
516
+ const rest = shown - said.length;
517
+ return rest > 0 ? `${said.join(", ")}, ${moreText(rest)}` : said.join(", ");
518
+ }
519
+
520
+ function highest(labels, values) {
521
+ let best = -1;
522
+ values.forEach((v, i) => {
523
+ if (v !== null && (best === -1 || v > values[best])) best = i;
524
+ });
525
+ return best === -1 ? null : { label: labels[best], value: values[best] };
526
+ }
527
+
528
+ function plural(count, one, many) {
529
+ return `${count} ${count === 1 ? one : many}`;
530
+ }
531
+
532
+ /**
533
+ * What a screen reader hears: a one-line summary, then the values, at most
534
+ * `SPOKEN_VALUES_MAX` of them over all series. `lead` replaces the summary (a
535
+ * widget passes its translated title there) and `moreText(count)` the tail
536
+ * of a list cut short, so a widget can say both in the app's language.
537
+ */
538
+ export function chartSummary({ type, model, valueFormat, lead, locale, moreText }) {
539
+ const kind = normaliseChartType(type);
540
+ const name = TYPE_NAMES[kind];
541
+ const labels = model.labels;
542
+ const tail = typeof moreText === "function" ? moreText : DEFAULT_MORE_TEXT;
543
+ const say = (values, budget = SPOKEN_VALUES_MAX) =>
544
+ spokenValues(labels, values, { format: valueFormat, locale, moreText: tail, budget });
545
+ const value = (v) => formatChartValue(v, valueFormat, locale);
546
+ if (kind === "donut") {
547
+ const values = donutValues(model).map((v) => (v > 0 ? v : null));
548
+ const total = values.reduce((sum, v) => sum + (v || 0), 0);
549
+ const top = highest(labels, values);
550
+ const share = top && total > 0 ? Math.round((top.value / total) * 100) : 0;
551
+ const summary = top
552
+ ? `${name}, ${plural(values.filter((v) => v !== null).length, "slice", "slices")}, largest: ${top.label} ${value(top.value)} (${share}%)`
553
+ : name;
554
+ return `${lead || summary}. ${say(values)}`;
555
+ }
556
+ if (model.series.length > 1) {
557
+ let best = null;
558
+ for (const s of model.series) {
559
+ const top = highest(labels, s.values);
560
+ if (top && (!best || top.value > best.value)) best = { ...top, series: s.label };
561
+ }
562
+ const summary = best
563
+ ? `${name}, ${plural(model.series.length, "series", "series")} of ${plural(labels.length, "value", "values")}, highest: ${best.series}, ${best.label} ${value(best.value)}`
564
+ : name;
565
+ const each = Math.max(1, Math.floor(SPOKEN_VALUES_MAX / model.series.length));
566
+ const spoken = model.series.map((s) => `${s.label}: ${say(s.values, each)}`).join("; ");
567
+ return `${lead || summary}. ${spoken}`;
568
+ }
569
+ const values = model.series[0] ? model.series[0].values : [];
570
+ const top = highest(labels, values);
571
+ const summary = top
572
+ ? `${name}, ${plural(values.filter((v) => v !== null).length, "value", "values")}, highest: ${top.label} ${value(top.value)}`
573
+ : name;
574
+ return `${lead || summary}. ${say(values)}`;
575
+ }