@bonnard/mcp-charts 0.1.3 → 0.2.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.
@@ -0,0 +1,706 @@
1
+ import {
2
+ inferFields,
3
+ looksLikeLooseDate
4
+ } from "./chunk-IXRGLZX2.js";
5
+
6
+ // src/resolve/detect.ts
7
+ function detectChartType(fields) {
8
+ if (fields.some((f) => f.role === "time")) return "line";
9
+ if (fields.some((f) => f.role === "dimension")) return "bar";
10
+ return "table";
11
+ }
12
+
13
+ // src/resolve/pivot.ts
14
+ function pivotData(data, xKey, pivotKey, valueKey) {
15
+ const seriesKeys = [];
16
+ const seen = /* @__PURE__ */ new Set();
17
+ for (const row of data) {
18
+ const raw = row[pivotKey];
19
+ const pivotValue = raw == null || raw === "" ? "(No value)" : String(raw);
20
+ if (!seen.has(pivotValue)) {
21
+ seen.add(pivotValue);
22
+ seriesKeys.push(pivotValue);
23
+ }
24
+ }
25
+ const groups = /* @__PURE__ */ new Map();
26
+ let collapsed = 0;
27
+ for (const row of data) {
28
+ const xValue = String(row[xKey] ?? "");
29
+ const raw = row[pivotKey];
30
+ const pivotValue = raw == null || raw === "" ? "(No value)" : String(raw);
31
+ if (!groups.has(xValue)) groups.set(xValue, { [xKey]: row[xKey] });
32
+ const group = groups.get(xValue);
33
+ if (Object.hasOwn(group, pivotValue)) {
34
+ collapsed++;
35
+ group[pivotValue] = (Number(group[pivotValue]) || 0) + (Number(row[valueKey]) || 0);
36
+ } else {
37
+ group[pivotValue] = row[valueKey];
38
+ }
39
+ }
40
+ return { data: Array.from(groups.values()), seriesKeys, collapsed };
41
+ }
42
+
43
+ // src/resolve/fill-time.ts
44
+ function fillMissingTimeIntervals(data, xKey, measureKeys, granularity) {
45
+ if (data.length < 2) return data;
46
+ const entries = [];
47
+ for (const row of data) {
48
+ const date = parseUTC(String(row[xKey]));
49
+ if (date) entries.push({ date });
50
+ }
51
+ if (entries.length < 2) return data;
52
+ entries.sort((a, b) => a.date.getTime() - b.date.getTime());
53
+ const min = entries[0].date;
54
+ const max = entries[entries.length - 1].date;
55
+ const allDates = generateSequence(min, max, granularity);
56
+ const anchor = new Date(Date.UTC(min.getUTCFullYear(), min.getUTCMonth(), min.getUTCDate()));
57
+ const key = (d) => dateKey(d, granularity, anchor);
58
+ const existing = /* @__PURE__ */ new Map();
59
+ const emitted = /* @__PURE__ */ new Set();
60
+ for (const row of data) {
61
+ const date = parseUTC(String(row[xKey]));
62
+ if (!date) continue;
63
+ const k = key(date);
64
+ const list = existing.get(k) ?? [];
65
+ list.push(row);
66
+ existing.set(k, list);
67
+ }
68
+ const result = [];
69
+ for (const date of allDates) {
70
+ const found = existing.get(key(date));
71
+ if (found) {
72
+ for (const row of found) {
73
+ result.push(row);
74
+ emitted.add(row);
75
+ }
76
+ } else {
77
+ const nullRow = { [xKey]: toISOish(date) };
78
+ for (const mk of measureKeys) nullRow[mk] = null;
79
+ result.push(nullRow);
80
+ }
81
+ }
82
+ for (const row of data) if (!emitted.has(row)) result.push(row);
83
+ return result;
84
+ }
85
+ function parseUTC(str) {
86
+ let s = str.trim();
87
+ if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(s)) s = s.replace(" ", "T");
88
+ if (/^\d{4}-\d{2}-\d{2}T/.test(s) && !s.endsWith("Z") && !/[+-]\d{2}:\d{2}$/.test(s)) {
89
+ s += "Z";
90
+ } else if (/^\d{4}-\d{2}-\d{2}$/.test(s)) {
91
+ s += "T00:00:00.000Z";
92
+ }
93
+ const d = new Date(s);
94
+ return isNaN(d.getTime()) ? null : d;
95
+ }
96
+ var WEEK_MS = 7 * 864e5;
97
+ function dateKey(d, granularity, anchor) {
98
+ const y = d.getUTCFullYear();
99
+ switch (granularity) {
100
+ case "year":
101
+ return String(y);
102
+ case "quarter":
103
+ return `${y}-Q${Math.floor(d.getUTCMonth() / 3) + 1}`;
104
+ case "month":
105
+ return `${y}-${pad(d.getUTCMonth() + 1)}`;
106
+ case "week":
107
+ return `W${Math.floor((d.getTime() - anchor.getTime()) / WEEK_MS)}`;
108
+ default:
109
+ return `${y}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`;
110
+ }
111
+ }
112
+ function toISOish(d) {
113
+ return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}T00:00:00.000`;
114
+ }
115
+ function pad(n) {
116
+ return n < 10 ? `0${n}` : String(n);
117
+ }
118
+ function generateSequence(start, end, granularity) {
119
+ const dates = [];
120
+ const endTime = end.getTime();
121
+ const MAX_INTERVALS = 1e4;
122
+ let y = start.getUTCFullYear();
123
+ let m = start.getUTCMonth();
124
+ let d = start.getUTCDate();
125
+ while (dates.length < MAX_INTERVALS) {
126
+ const current = new Date(Date.UTC(y, m, d));
127
+ if (current.getTime() > endTime) break;
128
+ dates.push(current);
129
+ switch (granularity) {
130
+ case "day":
131
+ d += 1;
132
+ break;
133
+ case "week":
134
+ d += 7;
135
+ break;
136
+ case "month":
137
+ m += 1;
138
+ d = 1;
139
+ break;
140
+ case "quarter":
141
+ m += 3;
142
+ d = 1;
143
+ break;
144
+ case "year":
145
+ y += 1;
146
+ break;
147
+ default:
148
+ d += 1;
149
+ break;
150
+ }
151
+ }
152
+ return dates;
153
+ }
154
+
155
+ // src/resolve/resolve.ts
156
+ var HORIZONTAL_CATEGORY_THRESHOLD = 8;
157
+ var MAX_PIE_SLICES = 8;
158
+ var MIN_PIE_FRACTION = 0.02;
159
+ var MAX_BARS = 30;
160
+ var MAX_SERIES = 12;
161
+ var MAX_POINTS = 2e3;
162
+ var MAX_SCATTER_POINTS = 2e3;
163
+ var FRACTION_MAX = 1.5;
164
+ function isFractionScale(rows, keys) {
165
+ let max = -Infinity;
166
+ for (const r of rows) {
167
+ for (const k of keys) {
168
+ const v = r[k];
169
+ if (v == null) continue;
170
+ const n = Number(v);
171
+ if (Number.isFinite(n) && Math.abs(n) > max) max = Math.abs(n);
172
+ }
173
+ }
174
+ return max >= 0 && max <= FRACTION_MAX;
175
+ }
176
+ function resolve(data, opts = {}) {
177
+ const encode = data.encode ?? {};
178
+ const fields = inferFields(data);
179
+ const byName = new Map(fields.map((f) => [f.name, f]));
180
+ const encodeRefs = (v) => v == null ? [] : Array.isArray(v) ? v : [v];
181
+ const unknownCols = [
182
+ ...new Set(
183
+ [
184
+ encode.x,
185
+ ...encodeRefs(encode.y),
186
+ encode.series,
187
+ ...encodeRefs(encode.y2),
188
+ ...encodeRefs(encode.line),
189
+ encode.size
190
+ ].filter((c) => !!c && !byName.has(c))
191
+ )
192
+ ];
193
+ if (unknownCols.length) {
194
+ const msg = `Ignored unknown encode column${unknownCols.length > 1 ? "s" : ""} ${unknownCols.map((c) => `"${c}"`).join(", ")}; available: ${fields.map((f) => f.name).join(", ")}.`;
195
+ if (opts.strict) throw new Error(msg);
196
+ data.notes = [msg, ...data.notes ?? []];
197
+ }
198
+ const isScatter = opts.chartType === "scatter";
199
+ if (!isScatter && !encode.x && data.rows.length > 1 && fields.length >= 2 && fields.every((f) => f.role === "measure")) {
200
+ const distinct = (k) => new Set(data.rows.map((r) => r[k])).size;
201
+ let best = fields[0];
202
+ for (const f of fields) if (distinct(f.name) < distinct(best.name)) best = f;
203
+ best.role = "dimension";
204
+ }
205
+ const measureNames = new Set(fields.filter((f) => f.role === "measure").map((f) => f.name));
206
+ let rows = data.rows.map((row) => {
207
+ const out = { ...row };
208
+ for (const k of Object.keys(out)) {
209
+ if (measureNames.has(k) && out[k] != null) {
210
+ const n = Number(out[k]);
211
+ if (!Number.isNaN(n)) out[k] = n;
212
+ }
213
+ }
214
+ return out;
215
+ });
216
+ if (isScatter) return withNotes(resolveScatter(rows, fields, encode, opts), data.notes);
217
+ if (opts.chartType === "funnel") return withNotes(resolveFunnel(rows, fields, encode, opts), data.notes);
218
+ if (opts.chartType === "waterfall") return withNotes(resolveWaterfall(rows, fields, encode, opts), data.notes);
219
+ const timeField = fields.find((f) => f.role === "time");
220
+ const dimField = fields.find((f) => f.role === "dimension");
221
+ const xField = encode.x ? byName.get(encode.x) : timeField ?? dimField;
222
+ const x = xField?.name ?? "";
223
+ const y2Names = encode.y2 ? Array.isArray(encode.y2) ? encode.y2 : [encode.y2] : [];
224
+ const yNames = (encode.y ? (Array.isArray(encode.y) ? encode.y : [encode.y]).filter((n) => byName.has(n)) : fields.filter((f) => f.role === "measure" && f.name !== x).map((f) => f.name)).filter((n) => !y2Names.includes(n));
225
+ let chartType = !opts.chartType || opts.chartType === "auto" ? detectChartType(fields) : opts.chartType;
226
+ if (!x && chartType !== "table") chartType = "table";
227
+ if (chartType === "table") {
228
+ return {
229
+ chartType: "table",
230
+ data: rows,
231
+ x: "",
232
+ series: [],
233
+ legend: false,
234
+ ...data.notes?.length && { notes: data.notes },
235
+ ...opts.title && { title: opts.title },
236
+ columns: fields.map((f) => ({
237
+ key: f.name,
238
+ label: f.label ?? f.name,
239
+ ...f.format && { format: f.format },
240
+ ...f.format === "percent" && { fraction: isFractionScale(rows, [f.name]) },
241
+ ...f.currency && { currency: f.currency },
242
+ ...f.granularity && { granularity: f.granularity }
243
+ }))
244
+ };
245
+ }
246
+ if (x) {
247
+ const isBool = xField?.kind === "boolean";
248
+ let allEmpty = rows.length > 0;
249
+ for (const row of rows) {
250
+ const v = row[x];
251
+ if (v == null || v === "") row[x] = "(No value)";
252
+ else {
253
+ allEmpty = false;
254
+ if (isBool) row[x] = v === true || v === "true" ? "Yes" : "No";
255
+ }
256
+ }
257
+ if (allEmpty) throw new Error(`Column "${x}" is entirely empty \u2014 nothing to put on the x-axis.`);
258
+ }
259
+ const pivotDim = encode.series ?? (timeField && dimField && dimField.name !== x && dimField.kind === "string" ? dimField.name : void 0);
260
+ const notes = [...data.notes ?? []];
261
+ if (x && xField?.kind === "string" && rows.some((r) => looksLikeLooseDate(r[x]))) {
262
+ notes.push(
263
+ `Column "${x}" looks like non-ISO dates; plotted as unordered categories. Return ISO dates (YYYY-MM-DD) for a sorted time axis.`
264
+ );
265
+ }
266
+ let series;
267
+ let yAxisRight;
268
+ if (pivotDim && yNames.length === 1) {
269
+ const result = pivotData(rows, x, pivotDim, yNames[0]);
270
+ rows = result.data;
271
+ series = result.seriesKeys.map((key) => ({ key, label: key }));
272
+ if (result.collapsed > 0)
273
+ notes.push(
274
+ `Summed ${result.collapsed} row(s) that shared the same ${x} + ${pivotDim} \u2014 the data looked unaggregated.`
275
+ );
276
+ if (y2Names.length > 0)
277
+ notes.push(
278
+ `Secondary-axis measure(s) ${y2Names.map((n) => `"${n}"`).join(", ")} were dropped because the chart is split into series by ${pivotDim}.`
279
+ );
280
+ if (series.length > MAX_SERIES) {
281
+ const grand = (key) => rows.reduce((a, r) => a + (Number(r[key]) || 0), 0);
282
+ const drop = [...series].sort((a, b) => grand(b.key) - grand(a.key)).slice(MAX_SERIES - 1);
283
+ const dropKeys = new Set(drop.map((s) => s.key));
284
+ for (const r of rows) {
285
+ let other = 0;
286
+ for (const k of dropKeys) {
287
+ other += Number(r[k]) || 0;
288
+ delete r[k];
289
+ }
290
+ r.Other = other;
291
+ }
292
+ series = [...series.filter((s) => !dropKeys.has(s.key)), { key: "Other", label: "Other" }];
293
+ notes.push(`Grouped ${drop.length} smaller series into "Other".`);
294
+ }
295
+ } else {
296
+ const lineNames = encode.line ? Array.isArray(encode.line) ? encode.line : [encode.line] : [];
297
+ series = yNames.map((key) => ({
298
+ key,
299
+ label: byName.get(key)?.label ?? key,
300
+ ...lineNames.includes(key) && { type: "line" }
301
+ }));
302
+ for (const key of lineNames) {
303
+ if (yNames.includes(key) || y2Names.includes(key) || !byName.has(key)) continue;
304
+ series.push({ key, label: byName.get(key)?.label ?? key, type: "line" });
305
+ }
306
+ for (const key of y2Names) series.push({ key, label: byName.get(key)?.label ?? key, axis: "right" });
307
+ if (y2Names.length > 0) {
308
+ const f = byName.get(y2Names[0]);
309
+ yAxisRight = {
310
+ ...f?.label && { label: f.label },
311
+ ...f?.format && { format: f.format },
312
+ ...f?.currency && { currency: f.currency }
313
+ };
314
+ }
315
+ if (x && series.length > 0) {
316
+ const agg = aggregateByX(
317
+ rows,
318
+ x,
319
+ series.map((s) => s.key)
320
+ );
321
+ rows = agg.rows;
322
+ if (agg.collapsed > 0) {
323
+ notes.push(`Summed ${agg.collapsed} row(s) that shared the same ${x} \u2014 the data looked unaggregated.`);
324
+ const rateCols = series.filter((s) => byName.get(s.key)?.format === "percent").map((s) => s.key);
325
+ if (rateCols.length)
326
+ notes.push(
327
+ `${rateCols.map((c) => `"${c}"`).join(", ")} looks like a rate; summing rates is usually wrong \u2014 compute SUM(numerator)/SUM(denominator) in SQL instead.`
328
+ );
329
+ }
330
+ }
331
+ }
332
+ if (series.length === 0) {
333
+ const msg = "No measure column to plot - the chart has no data series. Check that value columns contain numbers (not strings), or declare types via fields.";
334
+ if (opts.strict) throw new Error(msg);
335
+ notes.push(msg);
336
+ }
337
+ if (opts.chartType === "pie" && series.length > 1) {
338
+ const msg = `A pie needs one category + one measure; got ${series.length} measures - showing them as separate slices, which is usually not what a pie means.`;
339
+ if (opts.strict) throw new Error(msg);
340
+ notes.push(msg);
341
+ }
342
+ if (opts.chartType === "line" && xField?.kind === "string" && series.length > 0) {
343
+ const msg = `A line over categorical "${x}" implies an order that may not exist; consider a bar chart.`;
344
+ if (opts.strict) throw new Error(msg);
345
+ notes.push(msg);
346
+ }
347
+ if (x && chartType !== "pie") rows = sortRowsByX(rows, x, xField);
348
+ if (xField?.role === "time" && xField.granularity && x) {
349
+ rows = fillMissingTimeIntervals(
350
+ rows,
351
+ x,
352
+ series.map((s) => s.key),
353
+ xField.granularity
354
+ );
355
+ }
356
+ if (chartType === "bar" && series.length > 0 && rows.length > MAX_BARS) {
357
+ const origLen = rows.length;
358
+ const total = (r) => series.reduce((a, s) => a + (Number(r[s.key]) || 0), 0);
359
+ const keep = new Set([...rows].sort((a, b) => total(b) - total(a)).slice(0, MAX_BARS));
360
+ rows = rows.filter((r) => keep.has(r));
361
+ notes.push(`Showing the top ${rows.length} of ${origLen} categories by value.`);
362
+ }
363
+ if ((chartType === "line" || chartType === "area") && rows.length > MAX_POINTS) {
364
+ const origLen = rows.length;
365
+ const step = Math.ceil(origLen / MAX_POINTS);
366
+ const sampled = rows.filter((_, i) => i % step === 0);
367
+ if (sampled[sampled.length - 1] !== rows[origLen - 1]) sampled.push(rows[origLen - 1]);
368
+ rows = sampled;
369
+ notes.push(`Downsampled ${origLen} points to ${rows.length} for display.`);
370
+ }
371
+ if ((opts.stacking === "stacked" || opts.stacking === "stacked100") && series.length > 0) {
372
+ for (const row of rows) for (const s of series) if (row[s.key] == null) row[s.key] = 0;
373
+ }
374
+ if (chartType === "pie" && series.length > 0) {
375
+ const k = series[0].key;
376
+ if (!rows.some((r) => Number(r[k]) > 0)) {
377
+ const neg = rows.filter((r) => r[k] != null && Number(r[k]) < 0);
378
+ rows = neg.map((r) => ({ ...r, [k]: Math.abs(Number(r[k])) }));
379
+ if (rows.length > 0) notes.push("All values were negative \u2014 showing their magnitudes.");
380
+ } else {
381
+ rows = rows.filter((r) => r[k] != null && Number(r[k]) > 0);
382
+ }
383
+ rows = rows.sort((a, b) => (Number(b[k]) || 0) - (Number(a[k]) || 0));
384
+ const total = rows.reduce((sum, r) => sum + (Number(r[k]) || 0), 0);
385
+ const isSmall = (r, i) => i >= MAX_PIE_SLICES - 1 || total > 0 && (Number(r[k]) || 0) / total < MIN_PIE_FRACTION;
386
+ const small = rows.filter(isSmall);
387
+ if (small.length > 1) {
388
+ const kept = rows.filter((r, i) => !isSmall(r, i));
389
+ const other = small.reduce((sum, r) => sum + (Number(r[k]) || 0), 0);
390
+ rows = [...kept, { [x]: "Other", [k]: other }];
391
+ notes.push(`Grouped ${small.length} small slices into "Other".`);
392
+ }
393
+ }
394
+ const firstMeasure = byName.get(yNames[0] ?? "");
395
+ const yAxis = {
396
+ ...series.length === 1 && { label: series[0].label },
397
+ ...firstMeasure?.format && { format: firstMeasure.format },
398
+ ...firstMeasure?.format === "percent" && {
399
+ fraction: isFractionScale(
400
+ rows,
401
+ series.filter((s) => s.axis !== "right").map((s) => s.key)
402
+ )
403
+ },
404
+ ...firstMeasure?.currency && { currency: firstMeasure.currency }
405
+ };
406
+ if (yAxisRight?.format === "percent") yAxisRight.fraction = isFractionScale(rows, y2Names);
407
+ const xAxis = {
408
+ ...xField && { label: xField.label },
409
+ ...xField?.granularity && { granularity: xField.granularity },
410
+ // Numeric (non-time) x: signal the renderer to use a linear scale on line/area, so points
411
+ // sit at their true positions (irregular gaps show) instead of evenly-spaced categories.
412
+ ...xField?.kind === "number" && { numeric: true }
413
+ };
414
+ const hasComboLine = series.some((s) => s.type === "line");
415
+ let horizontal = opts.horizontal;
416
+ if (horizontal == null && chartType === "bar" && xField?.kind === "string" && rows.length > HORIZONTAL_CATEGORY_THRESHOLD) {
417
+ horizontal = true;
418
+ }
419
+ if (hasComboLine) horizontal = false;
420
+ const columns = buildColumns(x, xField, series, byName, rows);
421
+ const reference = [];
422
+ if (opts.reference) {
423
+ const primary = series.find((s) => s.axis !== "right") ?? series[0];
424
+ if (opts.reference.average && primary) {
425
+ const vals = rows.map((r) => Number(r[primary.key])).filter((v) => Number.isFinite(v));
426
+ if (vals.length)
427
+ reference.push({
428
+ value: Math.round(vals.reduce((a, b) => a + b, 0) / vals.length * 100) / 100,
429
+ label: "Avg"
430
+ });
431
+ }
432
+ if (opts.reference.target != null) reference.push({ value: opts.reference.target, label: "Target" });
433
+ }
434
+ return {
435
+ chartType,
436
+ data: rows,
437
+ x,
438
+ series,
439
+ ...reference.length > 0 && { reference },
440
+ ...Object.keys(xAxis).length > 0 && { xAxis },
441
+ ...Object.keys(yAxis).length > 0 && { yAxis },
442
+ ...yAxisRight && Object.keys(yAxisRight).length > 0 && { yAxisRight },
443
+ legend: series.length > 1,
444
+ ...opts.stacking && { stacking: opts.stacking },
445
+ ...horizontal != null && { horizontal },
446
+ ...opts.title && { title: opts.title },
447
+ columns,
448
+ ...notes.length > 0 && { notes }
449
+ };
450
+ }
451
+ function withNotes(spec, notes) {
452
+ return notes?.length ? { ...spec, notes: [...notes, ...spec.notes ?? []] } : spec;
453
+ }
454
+ function resolveScatter(rows, fields, encode, opts) {
455
+ const byName = new Map(fields.map((f) => [f.name, f]));
456
+ const measures = fields.filter((f) => f.role === "measure");
457
+ const yEnc = Array.isArray(encode.y) ? encode.y[0] : encode.y;
458
+ const x = encode.x ?? measures[0]?.name;
459
+ const y = yEnc ?? measures.find((m) => m.name !== x)?.name;
460
+ if (!x || !y) {
461
+ throw new Error(
462
+ "A scatter chart needs two numeric columns. Select at least two measures (x and y), e.g. orders and revenue."
463
+ );
464
+ }
465
+ const xField = byName.get(x);
466
+ const yField = byName.get(y);
467
+ const isNum = (f) => f?.kind === "number";
468
+ if (!isNum(xField) || !isNum(yField)) {
469
+ throw new Error(`A scatter chart needs numeric x and y; "${x}" or "${y}" is not numeric.`);
470
+ }
471
+ const size = encode.size && byName.get(encode.size)?.kind === "number" ? encode.size : void 0;
472
+ const groupField = encode.series ? byName.get(encode.series) : void 0;
473
+ const groupKey = groupField && groupField.kind !== "number" ? groupField.name : void 0;
474
+ const pointLabel = fields.find((f) => f.role === "dimension" && f.name !== groupKey)?.name;
475
+ const scatterNotes = [];
476
+ if (rows.length > MAX_SCATTER_POINTS) {
477
+ const origLen = rows.length;
478
+ const step = Math.ceil(origLen / MAX_SCATTER_POINTS);
479
+ rows = rows.filter((_, i) => i % step === 0);
480
+ scatterNotes.push(`Showing a sample of ${rows.length} of ${origLen} points.`);
481
+ }
482
+ const colMeta = (k, f = byName.get(k)) => ({
483
+ key: k,
484
+ label: f?.label ?? k,
485
+ ...f?.format && { format: f.format },
486
+ ...f?.currency && { currency: f.currency }
487
+ });
488
+ const xAxis = {
489
+ ...xField?.label && { label: xField.label },
490
+ ...xField?.format && { format: xField.format },
491
+ ...xField?.currency && { currency: xField.currency },
492
+ numeric: true
493
+ };
494
+ const yAxis = {
495
+ ...yField?.label && { label: yField.label },
496
+ ...yField?.format && { format: yField.format },
497
+ ...yField?.currency && { currency: yField.currency }
498
+ };
499
+ if (groupKey) {
500
+ const counts = /* @__PURE__ */ new Map();
501
+ for (const r of rows) {
502
+ const g = String(r[groupKey] ?? "(No value)");
503
+ counts.set(g, (counts.get(g) ?? 0) + 1);
504
+ }
505
+ const ordered = [...counts.keys()].sort((a, b) => counts.get(b) - counts.get(a));
506
+ const capped = ordered.length > MAX_SERIES;
507
+ const kept = new Set(capped ? ordered.slice(0, MAX_SERIES - 1) : ordered);
508
+ const groupCols = capped ? [...kept, "Other"] : [...kept];
509
+ const data = rows.map((r) => {
510
+ const g = String(r[groupKey] ?? "(No value)");
511
+ const col = kept.has(g) ? g : "Other";
512
+ return {
513
+ [x]: r[x],
514
+ [col]: r[y],
515
+ ...size && { [size]: r[size] },
516
+ ...pointLabel && { [pointLabel]: r[pointLabel] }
517
+ };
518
+ });
519
+ const notes = [
520
+ ...scatterNotes,
521
+ ...capped ? [`Grouped ${ordered.length - kept.size} smaller categories into "Other".`] : []
522
+ ];
523
+ return {
524
+ chartType: "scatter",
525
+ data,
526
+ x,
527
+ series: groupCols.map((g) => ({ key: g, label: g })),
528
+ ...size && { size },
529
+ ...pointLabel && { pointLabel },
530
+ xAxis,
531
+ yAxis,
532
+ legend: true,
533
+ ...opts.title && { title: opts.title },
534
+ ...notes.length && { notes },
535
+ columns: [
536
+ colMeta(x),
537
+ ...groupCols.map((g) => ({
538
+ key: g,
539
+ label: g,
540
+ ...yField?.format && { format: yField.format },
541
+ ...yField?.currency && { currency: yField.currency }
542
+ })),
543
+ ...size ? [colMeta(size)] : [],
544
+ ...pointLabel ? [colMeta(pointLabel)] : []
545
+ ]
546
+ };
547
+ }
548
+ const columns = [x, y, ...size ? [size] : [], ...pointLabel ? [pointLabel] : []].map((k) => colMeta(k));
549
+ return {
550
+ chartType: "scatter",
551
+ data: rows,
552
+ x,
553
+ series: [{ key: y, label: yField?.label ?? y }],
554
+ ...size && { size },
555
+ ...pointLabel && { pointLabel },
556
+ xAxis,
557
+ yAxis,
558
+ legend: false,
559
+ ...opts.title && { title: opts.title },
560
+ ...scatterNotes.length && { notes: scatterNotes },
561
+ columns
562
+ };
563
+ }
564
+ function resolveFunnel(rows, fields, encode, opts) {
565
+ const byName = new Map(fields.map((f) => [f.name, f]));
566
+ const dim = encode.x ?? fields.find((f) => f.role === "dimension")?.name;
567
+ const yEnc = Array.isArray(encode.y) ? encode.y[0] : encode.y;
568
+ const value = yEnc ?? fields.find((f) => f.role === "measure")?.name;
569
+ if (!dim || !value) {
570
+ throw new Error("A funnel chart needs a stage/label column and a numeric value column (e.g. stage + count).");
571
+ }
572
+ const valField = byName.get(value);
573
+ const positive = rows.filter((r) => (Number(r[value]) || 0) > 0);
574
+ const data = positive.length > 0 ? positive : rows;
575
+ const colMeta = (k) => {
576
+ const f = byName.get(k);
577
+ return {
578
+ key: k,
579
+ label: f?.label ?? k,
580
+ ...f?.format && { format: f.format },
581
+ ...f?.currency && { currency: f.currency }
582
+ };
583
+ };
584
+ const notes = [];
585
+ if (!encode.x && byName.get(dim)?.kind === "number") {
586
+ const msg = `A funnel needs a stage/label column and one measure; got only measures - using "${dim}" values as stage labels.`;
587
+ if (opts.strict) throw new Error(msg);
588
+ notes.push(msg);
589
+ }
590
+ return {
591
+ chartType: "funnel",
592
+ data,
593
+ x: dim,
594
+ series: [{ key: value, label: valField?.label ?? value }],
595
+ legend: false,
596
+ ...opts.title && { title: opts.title },
597
+ columns: [dim, value].map(colMeta),
598
+ ...notes.length && { notes }
599
+ };
600
+ }
601
+ var TOTAL_RE = /^\s*(total|subtotal|opening|closing|start|end|net|balance)\b/i;
602
+ function resolveWaterfall(rows, fields, encode, opts) {
603
+ const byName = new Map(fields.map((f) => [f.name, f]));
604
+ const dim = encode.x ?? fields.find((f) => f.role === "dimension")?.name;
605
+ const value = (Array.isArray(encode.y) ? encode.y[0] : encode.y) ?? fields.find((f) => f.role === "measure" && f.name !== dim)?.name;
606
+ if (!dim || !value) {
607
+ throw new Error(
608
+ "A waterfall needs a step label and a numeric value. The value should be the SIGNED change at each step (e.g. -240000 for a loss); mark the start/end rows as totals."
609
+ );
610
+ }
611
+ if (rows.length < 2) {
612
+ throw new Error("A waterfall needs at least a start total and one change step.");
613
+ }
614
+ const markerCol = fields.find(
615
+ (f) => f.name !== dim && f.name !== value && f.role === "dimension" && rows.some((r) => TOTAL_RE.test(String(r[f.name])))
616
+ );
617
+ const totals = markerCol ? rows.filter((r) => TOTAL_RE.test(String(r[markerCol.name]))).map((r) => String(r[dim])) : [String(rows[0][dim]), String(rows[rows.length - 1][dim])];
618
+ const notes = markerCol ? [] : [
619
+ "No totals column found, so the first and last rows are treated as the opening and closing totals. Mark a column (e.g. type = total) to change this."
620
+ ];
621
+ const valField = byName.get(value);
622
+ const colMeta = (k) => {
623
+ const f = byName.get(k);
624
+ return {
625
+ key: k,
626
+ label: f?.label ?? k,
627
+ ...f?.format && { format: f.format },
628
+ ...f?.currency && { currency: f.currency }
629
+ };
630
+ };
631
+ return {
632
+ chartType: "waterfall",
633
+ data: rows,
634
+ x: dim,
635
+ series: [{ key: value, label: valField?.label ?? value }],
636
+ ...totals.length > 0 && { totals: [...new Set(totals)] },
637
+ ...notes.length && { notes },
638
+ yAxis: {
639
+ ...valField?.label && { label: valField.label },
640
+ ...valField?.format && { format: valField.format },
641
+ ...valField?.currency && { currency: valField.currency }
642
+ },
643
+ legend: false,
644
+ ...opts.title && { title: opts.title },
645
+ columns: [dim, value].map(colMeta)
646
+ };
647
+ }
648
+ function sortRowsByX(rows, xKey, xField) {
649
+ if (xField?.role === "time") {
650
+ const t = (v) => {
651
+ let s = String(v);
652
+ if (/^\d{4}-\d{2}-\d{2}$/.test(s)) s += "T00:00:00.000Z";
653
+ else if (/^\d{4}-\d{2}-\d{2}T/.test(s) && !/[Z+-]\d{0,2}:?\d{0,2}$/.test(s.slice(10))) s += "Z";
654
+ const ms = new Date(s).getTime();
655
+ return Number.isNaN(ms) ? Infinity : ms;
656
+ };
657
+ return [...rows].sort((a, b) => t(a[xKey]) - t(b[xKey]));
658
+ }
659
+ if (xField?.kind === "number") {
660
+ return [...rows].sort((a, b) => (Number(a[xKey]) || 0) - (Number(b[xKey]) || 0));
661
+ }
662
+ return rows;
663
+ }
664
+ function aggregateByX(rows, xKey, measureKeys) {
665
+ const groups = /* @__PURE__ */ new Map();
666
+ let collapsed = 0;
667
+ for (const row of rows) {
668
+ const key = String(row[xKey]);
669
+ const existing = groups.get(key);
670
+ if (!existing) {
671
+ groups.set(key, { ...row });
672
+ } else {
673
+ collapsed++;
674
+ for (const m of measureKeys) {
675
+ existing[m] = (Number(existing[m]) || 0) + (Number(row[m]) || 0);
676
+ }
677
+ }
678
+ }
679
+ return { rows: Array.from(groups.values()), collapsed };
680
+ }
681
+ function buildColumns(x, xField, series, byName, rows) {
682
+ const cols = [];
683
+ if (x) {
684
+ cols.push({
685
+ key: x,
686
+ label: xField?.label ?? x,
687
+ ...xField?.granularity && { granularity: xField.granularity }
688
+ });
689
+ }
690
+ for (const s of series) {
691
+ const f = byName.get(s.key);
692
+ cols.push({
693
+ key: s.key,
694
+ label: s.label,
695
+ ...f?.format && { format: f.format },
696
+ ...f?.format === "percent" && { fraction: isFractionScale(rows, [s.key]) },
697
+ ...f?.currency && { currency: f.currency }
698
+ });
699
+ }
700
+ return cols;
701
+ }
702
+
703
+ export {
704
+ resolve
705
+ };
706
+ //# sourceMappingURL=chunk-EZYN3JQ4.js.map