chart-factory 0.1.2

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.
Binary file
@@ -0,0 +1,565 @@
1
+ /**
2
+ * ChartFactory.suggest(rows, options?) — runtime chart recommendation.
3
+ *
4
+ * Applies the chart-selection layer (docs/chart-guide.md) to real rows:
5
+ * 1. PROFILE each field: kind (temporal | numeric | categorical | boolean |
6
+ * id), distinct count, null count, numeric/temporal extent.
7
+ * 2. SIGNATURE from the profiles: measures, dimensions, temporal fields,
8
+ * an entity/name field, rows-per-group shape.
9
+ * 3. RULES the guide's signature table as scored rules, adjusted by each
10
+ * builder's cardinality comfort ranges from the conformance
11
+ * metadata (builder-metadata.generated.mjs).
12
+ * 4. SYNTHESIZE a render-ready { data, config } per candidate in the
13
+ * builder's canonical record shape (mapped/grouped/pivoted from
14
+ * the input rows — inputs are never mutated).
15
+ *
16
+ * Returns { fields, signature, candidates } — candidates sorted by score
17
+ * (0..1), each { builder, score, why[], caveats[], data, config, transform,
18
+ * create(selector, overrides?) }.
19
+ *
20
+ * This is a heuristic assistant, not an oracle: it can read shape and
21
+ * cardinality, but not intent. It cannot know that values are parts of one
22
+ * whole, that a center is defensible, or that two same-scale measures are the
23
+ * same unit — those judgments surface as caveats on the affected candidates.
24
+ * Tables are never suggested (they require Table.register()); when precision
25
+ * beats perception, a caveat says so.
26
+ */
27
+
28
+ import { BUILDER_METADATA } from './builder-metadata.generated.mjs';
29
+
30
+ const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}([T ].*)?$/;
31
+ const PROFILE_SAMPLE = 1000;
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // 1. Field profiling
35
+ // ---------------------------------------------------------------------------
36
+
37
+ function profileField(name, values, rowCount) {
38
+ const nonNull = values.filter(v => v !== null && v !== undefined && v !== '');
39
+ const nulls = values.length - nonNull.length;
40
+ if (nonNull.length === 0) return { name, kind: 'empty', distinct: 0, nulls };
41
+
42
+ let dates = 0, numbers = 0, booleans = 0, strings = 0;
43
+ const nums = [];
44
+ for (const v of nonNull) {
45
+ if (v instanceof Date) { dates++; continue; }
46
+ if (typeof v === 'boolean') { booleans++; continue; }
47
+ if (typeof v === 'number' && Number.isFinite(v)) { numbers++; nums.push(v); continue; }
48
+ if (typeof v === 'string') {
49
+ if (ISO_DATE_RE.test(v.trim())) { dates++; continue; }
50
+ const n = Number(v);
51
+ // Numeric strings count as numbers (CSV loaders yield strings),
52
+ // but not ''-coerced or whitespace values.
53
+ if (v.trim() !== '' && Number.isFinite(n)) { numbers++; nums.push(n); continue; }
54
+ strings++;
55
+ continue;
56
+ }
57
+ strings++; // objects/arrays — treat as opaque
58
+ }
59
+
60
+ const distinct = new Set(nonNull.map(v => (v instanceof Date ? v.getTime() : v))).size;
61
+ const share = (c) => c / nonNull.length;
62
+ const base = { name, distinct, nulls, count: nonNull.length };
63
+
64
+ if (share(dates) >= 0.9) {
65
+ const times = nonNull
66
+ .map(v => (v instanceof Date ? v : new Date(v)).getTime())
67
+ .filter(Number.isFinite)
68
+ .sort((a, b) => a - b);
69
+ const gaps = [];
70
+ for (let i = 1; i < Math.min(times.length, 200); i++) gaps.push(times[i] - times[i - 1]);
71
+ gaps.sort((a, b) => a - b);
72
+ const medianGapMs = gaps.length ? gaps[Math.floor(gaps.length / 2)] : null;
73
+ return { ...base, kind: 'temporal', min: times[0], max: times[times.length - 1], medianGapMs };
74
+ }
75
+ if (share(booleans) >= 0.9) return { ...base, kind: 'boolean' };
76
+ if (share(numbers) >= 0.9) {
77
+ const min = Math.min(...nums), max = Math.max(...nums);
78
+ const allInt = nums.every(Number.isInteger);
79
+ // Year-like integers (1500..2100, several distinct values) can serve
80
+ // as the temporal axis when no true date field exists.
81
+ const yearLike = allInt && min >= 1500 && max <= 2100 && distinct >= 3 && distinct === new Set(nums).size;
82
+ return { ...base, kind: 'numeric', min, max, allInt, yearLike };
83
+ }
84
+ // Strings: id-like when (nearly) unique per row, categorical otherwise.
85
+ if (distinct >= rowCount * 0.9 && rowCount > 6) return { ...base, kind: 'id' };
86
+ return { ...base, kind: 'categorical' };
87
+ }
88
+
89
+ export function profileFields(rows) {
90
+ const sample = rows.length > PROFILE_SAMPLE ? rows.slice(0, PROFILE_SAMPLE) : rows;
91
+ const keys = new Set();
92
+ for (const r of sample) for (const k of Object.keys(r || {})) keys.add(k);
93
+ return [...keys].map(k => profileField(k, sample.map(r => (r || {})[k]), rows.length));
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // 2. Signature
98
+ // ---------------------------------------------------------------------------
99
+
100
+ const DAY_MS = 24 * 3600 * 1000;
101
+ const PAIR_PATTERNS = [
102
+ [/^(before|prev|previous|last|old|start|from|open|low)$/i, /^(after|next|current|new|end|to|close|high)$/i],
103
+ ];
104
+
105
+ function buildSignature(rows, fields) {
106
+ const byKind = (k) => fields.filter(f => f.kind === k);
107
+ let temporal = byKind('temporal');
108
+ let measures = byKind('numeric');
109
+ const dimensions = byKind('categorical').concat(byKind('boolean'));
110
+ const idFields = byKind('id');
111
+
112
+ // A year-like numeric stands in as the temporal axis when no real one exists.
113
+ if (temporal.length === 0) {
114
+ const year = measures.find(f => f.yearLike);
115
+ if (year) {
116
+ temporal = [{ ...year, kind: 'temporal', yearStandin: true }];
117
+ measures = measures.filter(f => f !== year);
118
+ }
119
+ }
120
+
121
+ // Same-unit measure pairs (before/after, start/end, low/high, 2023/2024):
122
+ // name patterns, or a year-pair of column names, with overlapping ranges.
123
+ let pair = null;
124
+ for (let i = 0; i < measures.length && !pair; i++) {
125
+ for (let j = 0; j < measures.length && !pair; j++) {
126
+ if (i === j) continue;
127
+ const [a, b] = [measures[i], measures[j]];
128
+ const named = PAIR_PATTERNS.some(([ra, rb]) => ra.test(a.name) && rb.test(b.name));
129
+ const years = /^\d{4}$/.test(a.name) && /^\d{4}$/.test(b.name) && +a.name < +b.name;
130
+ if (named || years) pair = [a, b];
131
+ }
132
+ }
133
+ if (!pair && measures.length === 2) {
134
+ // Range overlap as a weak same-unit proxy (never for named mismatches).
135
+ const [a, b] = measures;
136
+ const lo = Math.max(a.min, b.min), hi = Math.min(a.max, b.max);
137
+ const span = Math.max(a.max, b.max) - Math.min(a.min, b.min);
138
+ if (span > 0 && (hi - lo) / span > 0.5) pair = null; // note only — do not assert pairing
139
+ }
140
+
141
+ // One row per unique string + a single measure IS a category axis (teams →
142
+ // wins): the id/categorical distinction collapses, so promote the id field
143
+ // to the grouping dimension when it's the only candidate.
144
+ let nameField = idFields[0] || null;
145
+ if (dimensions.length === 0 && temporal.length === 0 && measures.length === 1 && nameField &&
146
+ nameField.distinct >= nameField.count * 0.98) {
147
+ dimensions.push({ ...nameField, kind: 'categorical', promoted: true });
148
+ nameField = idFields[1] || null;
149
+ }
150
+
151
+ const primaryDim = dimensions.slice().sort((a, b) => a.distinct - b.distinct)[0] || null;
152
+ const rowsPerGroup = primaryDim ? rows.length / primaryDim.distinct : rows.length;
153
+ const t = temporal[0] || null;
154
+ // Daily = the median gap is about one day; sub-daily (hourly) data must
155
+ // NOT read as daily, or Calendar would aggregate-by-accident.
156
+ const daily = !!(t && !t.yearStandin && t.medianGapMs !== null &&
157
+ t.medianGapMs >= 0.5 * DAY_MS && t.medianGapMs <= 1.5 * DAY_MS);
158
+
159
+ return {
160
+ rowCount: rows.length,
161
+ temporal, measures, dimensions, idFields,
162
+ nameField,
163
+ primaryDim, rowsPerGroup, pair, daily,
164
+ };
165
+ }
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // Helpers: transforms (pure — inputs never mutated) + cardinality scoring
169
+ // ---------------------------------------------------------------------------
170
+
171
+ const val = (row, f) => {
172
+ const v = row[f.name];
173
+ return typeof v === 'string' && f.kind !== 'categorical' && f.kind !== 'id' ? Number(v) : v;
174
+ };
175
+
176
+ function sortByField(rows, f) {
177
+ const key = (r) => {
178
+ const v = r[f.name];
179
+ return v instanceof Date ? v.getTime() : (typeof v === 'string' && ISO_DATE_RE.test(v) ? Date.parse(v) : v);
180
+ };
181
+ return rows.slice().sort((a, b) => key(a) - key(b));
182
+ }
183
+
184
+ function groupBy(rows, f) {
185
+ const m = new Map();
186
+ for (const r of rows) {
187
+ const k = r[f.name];
188
+ if (!m.has(k)) m.set(k, []);
189
+ m.get(k).push(r);
190
+ }
191
+ return m;
192
+ }
193
+
194
+ /** Sum a measure per dimension value → canonical {category, value} rows. */
195
+ function rollupSum(rows, dim, measure) {
196
+ const m = new Map();
197
+ for (const r of rows) {
198
+ const k = r[dim.name];
199
+ m.set(k, (m.get(k) || 0) + (Number(val(r, measure)) || 0));
200
+ }
201
+ return [...m.entries()].map(([category, value]) => ({ category, value }));
202
+ }
203
+
204
+ function fitRange(n, range) {
205
+ if (!range) return 0;
206
+ const [min, max] = range;
207
+ if ((min === null || n >= min) && (max === null || n <= max)) return 1;
208
+ return -1;
209
+ }
210
+
211
+ /**
212
+ * Adjust a candidate's score by the builder's metadata cardinality ranges.
213
+ * dims: { series?, categories?, points?, rows?, ... } — observed counts.
214
+ */
215
+ function applyCardinality(cand, dims) {
216
+ const card = (BUILDER_METADATA[cand.builder] || {}).cardinality || {};
217
+ for (const [dim, n] of Object.entries(dims)) {
218
+ const fit = fitRange(n, card[dim]);
219
+ if (fit === 1) {
220
+ cand.score += 0.03;
221
+ cand.why.push(`${n} ${dim} fits the comfortable ${fmtRange(card[dim])}`);
222
+ } else if (fit === -1) {
223
+ cand.score -= 0.25;
224
+ cand.caveats.push(`${n} ${dim} is outside the comfortable ${fmtRange(card[dim])} — reshape (data.topN / data.rollup / facet) or pick another form`);
225
+ }
226
+ }
227
+ return cand;
228
+ }
229
+
230
+ function fmtRange([min, max]) {
231
+ if (min !== null && max !== null) return `${min}–${max}`;
232
+ if (min !== null) return `${min}+`;
233
+ return `up to ${max}`;
234
+ }
235
+
236
+ // ---------------------------------------------------------------------------
237
+ // 3+4. Rules with config synthesis
238
+ // ---------------------------------------------------------------------------
239
+
240
+ function suggestCandidates(rows, sig) {
241
+ const out = [];
242
+ const push = (builder, score, why, cfg, dims = {}, caveats = [], transform = null) => {
243
+ const cand = { builder, score, why: [].concat(why), caveats: [].concat(caveats), transform, ...cfg };
244
+ out.push(applyCardinality(cand, dims));
245
+ };
246
+ const t = sig.temporal[0] || null;
247
+ const M = sig.measures, D = sig.dimensions, n = sig.rowCount;
248
+ const m1 = M[0], m2 = M[1];
249
+ const dim = sig.primaryDim;
250
+ const name = sig.nameField;
251
+
252
+ // --- temporal + measure(s), no grouping dimension -----------------------
253
+ if (t && m1 && !dim) {
254
+ const sorted = sortByField(rows, t);
255
+ push('Line.createBasic', 0.9,
256
+ `1 temporal (${t.name}) + 1 measure (${m1.name}) — the default trend chart`,
257
+ { data: sorted, config: { x: t.name, y: m1.name } },
258
+ { points: n, series: 1 });
259
+ push('Area.createBasic', 0.55,
260
+ `same signature when filled magnitude-from-zero is the message`,
261
+ { data: sorted, config: { x: t.name, y: m1.name } },
262
+ { points: n, series: 1 },
263
+ 'only if zero is a meaningful baseline for this measure');
264
+ if (M.length >= 2) {
265
+ const series = M.map(m => ({ name: m.name, data: sorted.map(r => ({ x: r[t.name], y: val(r, m) })) }));
266
+ const sameScaleish = Math.min(...M.map(m => m.min)) >= 0 || true;
267
+ push('Line.createMulti', 0.85,
268
+ `${M.length} measures over one temporal axis (${t.name}) — compare as series`,
269
+ { data: rows, config: { series } },
270
+ { series: M.length, pointsPerSeries: n },
271
+ sameScaleish && spanRatio(M) > 100 ? 'measure ranges differ by >100x — likely different units; prefer separate charts in a linkGroup' : [],
272
+ `built series[] from measures [${M.map(m => m.name).join(', ')}]`);
273
+ }
274
+ if (sig.daily && n >= 60) {
275
+ push('Calendar.createHeatmap', 0.7,
276
+ `daily grain over ${n} days — weekday/streak rhythm`,
277
+ { data: rows.map(r => ({ date: r[t.name], value: val(r, m1) })), config: {} },
278
+ { days: n },
279
+ [], `mapped to { date: ${t.name}, value: ${m1.name} }`);
280
+ }
281
+ }
282
+
283
+ // --- temporal + measure + grouping dimension (long/panel data) ----------
284
+ if (t && m1 && dim) {
285
+ const sorted = sortByField(rows, t);
286
+ const groups = groupBy(sorted, dim);
287
+ const series = [...groups.entries()].map(([g, rs]) => ({ name: String(g), data: rs.map(r => ({ x: r[t.name], y: val(r, m1) })) }));
288
+ const periodCount = t.distinct;
289
+ push('Line.createMulti', 0.9,
290
+ `1 temporal (${t.name}) + 1 measure (${m1.name}) grouped by ${dim.name} (${dim.distinct} series)`,
291
+ { data: rows, config: { series } },
292
+ { series: dim.distinct, pointsPerSeries: Math.round(sig.rowsPerGroup) },
293
+ dim.distinct > 6 ? `past ~6 series consider facet('#grid', { data, by: '${dim.name}', ... })` : [],
294
+ `grouped rows by ${dim.name} into series[]`);
295
+ const yearNames = [...groups.keys()].every(k => /^\d{4}$/.test(String(k)));
296
+ if (yearNames) {
297
+ push('Line.createYoY', 0.85,
298
+ `the grouping dimension is years — same measure compared across years wants the recency ramp`,
299
+ { data: rows, config: { series } },
300
+ { series: dim.distinct },
301
+ [], `grouped rows by ${dim.name} (years) into series[]`);
302
+ }
303
+ if (M.length === 1 && M[0].min >= 0) {
304
+ const wideMap = new Map();
305
+ for (const r of sorted) {
306
+ const k = r[t.name] instanceof Date ? r[t.name].getTime() : r[t.name];
307
+ if (!wideMap.has(k)) wideMap.set(k, { x: r[t.name] });
308
+ wideMap.get(k)[r[dim.name]] = val(r, m1);
309
+ }
310
+ const keys = [...groups.keys()].map(String);
311
+ push('Area.createStacked', 0.7,
312
+ `composition over time — total and mix together`,
313
+ { data: [...wideMap.values()], config: { x: 'x', keys } },
314
+ { series: dim.distinct, points: wideMap.size },
315
+ ['stacking assumes the series are parts of one whole'],
316
+ `pivoted wide: one row per ${t.name}, one column per ${dim.name}`);
317
+ }
318
+ if (periodCount === 2) {
319
+ const slopeRows = twoPointRows(sorted, t, dim, m1);
320
+ if (slopeRows) {
321
+ push('Slope.createBasic', 0.85,
322
+ `exactly two ${t.name} values — every ${dim.name}'s change in one glance`,
323
+ { data: slopeRows, config: {} },
324
+ { rows: slopeRows.length, periods: 2 },
325
+ [], `reshaped to { name: ${dim.name}, before, after }`);
326
+ push('Dot.createDumbbell', 0.8,
327
+ `the two-point change as gap-focused rows — scales past slope's label limit`,
328
+ { data: slopeRows.map(r => ({ name: r.name, start: r.before, end: r.after })), config: {} },
329
+ { rows: slopeRows.length },
330
+ [], `reshaped to { name: ${dim.name}, start, end }`);
331
+ }
332
+ }
333
+ }
334
+
335
+ // --- categorical + one measure ------------------------------------------
336
+ if (!t && m1 && dim && M.length === 1) {
337
+ if (sig.rowsPerGroup <= 1.5) {
338
+ const mapped = rows.map(r => ({ category: r[dim.name], value: val(r, m1) }));
339
+ const ranked = mapped.slice().sort((a, b) => b.value - a.value);
340
+ const signed = mapped.some(r => r.value < 0) && mapped.some(r => r.value > 0);
341
+ push('Bar.createBasic', signed ? 0.55 : 0.9,
342
+ `1 categorical (${dim.name}) + 1 measure (${m1.name}), one row per category — ranked bars`,
343
+ { data: ranked, config: {} },
344
+ { rows: dim.distinct },
345
+ signed ? 'values cross zero — Bar.createDiverging shows the sign structure' : [],
346
+ `mapped to { category: ${dim.name}, value: ${m1.name} }, sorted desc`);
347
+ push('Bar.createVertical', 0.6,
348
+ `columns when the category order itself matters — keep the source order`,
349
+ { data: mapped.map(r => ({ x: String(r.category), y: r.value })), config: {} },
350
+ { categories: dim.distinct },
351
+ [], `mapped to { x: ${dim.name}, y: ${m1.name} }`);
352
+ if (mapped.every(r => r.value >= 0)) {
353
+ push('Donut.createBasic', 0.45,
354
+ `part-of-whole reading of the same signature`,
355
+ { data: mapped.map(r => ({ label: String(r.category), value: r.value })), config: {} },
356
+ { slices: dim.distinct },
357
+ ['only if the values are parts of one whole — suggest() cannot verify that'],
358
+ `mapped to { label: ${dim.name}, value: ${m1.name} }`);
359
+ }
360
+ if (mapped.some(r => r.value < 0) && mapped.some(r => r.value > 0)) {
361
+ push('Bar.createDiverging', 0.85,
362
+ `signed values around zero — diverging bars beat plain bars here`,
363
+ { data: mapped, config: {} },
364
+ { rows: dim.distinct },
365
+ [], `mapped to { category: ${dim.name}, value: ${m1.name} }`);
366
+ }
367
+ } else if (D.length === 1) {
368
+ // Distribution reading only when this is the ONLY categorical —
369
+ // a second categorical (day × hour) means the repeats are
370
+ // structured cells, not iid samples; the 2-dimension grid rules
371
+ // below own that signature.
372
+ const mapped = rows.map(r => ({ category: r[dim.name], value: val(r, m1) }));
373
+ push('BoxPlot.createBasic', 0.85,
374
+ `${Math.round(sig.rowsPerGroup)} records per ${dim.name} — distributions per category`,
375
+ { data: mapped, config: {} },
376
+ { categories: dim.distinct, points: Math.round(sig.rowsPerGroup) },
377
+ [], `mapped to { category: ${dim.name}, value: ${m1.name} }`);
378
+ push('Scatter.createBeeswarm', 0.7,
379
+ `same question with every individual visible and exact`,
380
+ { data: mapped, config: {} },
381
+ { categories: dim.distinct, points: n },
382
+ [], `mapped to { category: ${dim.name}, value: ${m1.name} }`);
383
+ if (dim.distinct <= 4) {
384
+ const series = [...groupBy(rows, dim).entries()]
385
+ .map(([g, rs]) => ({ name: String(g), data: rs.map(r => ({ value: val(r, m1) })) }));
386
+ push('Histogram.createOverlay', 0.6,
387
+ `${dim.distinct} groups — distribution shape on shared bins`,
388
+ { data: rows, config: { series } },
389
+ { series: dim.distinct, points: n },
390
+ [], `grouped rows by ${dim.name} into series[] of { value: ${m1.name} }`);
391
+ }
392
+ }
393
+ }
394
+
395
+ // --- one bare measure ----------------------------------------------------
396
+ if (!t && m1 && !dim && M.length === 1) {
397
+ push('Histogram.createBasic', n >= 30 ? 0.9 : 0.6,
398
+ `a single measure (${m1.name}) across ${n} records — distribution shape`,
399
+ { data: rows, config: { valueAccessor: m1.name } },
400
+ { points: n });
401
+ push('Scatter.createBeeswarmHorizontal', 0.65,
402
+ `the same distribution with individuals visible`,
403
+ { data: rows.map(r => ({ value: val(r, m1), name: name ? String(r[name.name]) : undefined })), config: {} },
404
+ { points: n },
405
+ [], `mapped to { value: ${m1.name} }`);
406
+ }
407
+
408
+ // --- two+ measures, no/loose temporal -----------------------------------
409
+ if (!t && m1 && m2) {
410
+ const mapped = rows.map(r => ({
411
+ x: val(r, m1), y: val(r, m2),
412
+ category: dim ? String(r[dim.name]) : undefined,
413
+ name: name ? String(r[name.name]) : undefined,
414
+ }));
415
+ const scatterCaveats = [];
416
+ if (M.length > 2) scatterCaveats.push(`remaining measures unencoded: ${M.slice(2).map(m => m.name).join(', ')}`);
417
+ if (sig.pair) scatterCaveats.push(`${sig.pair[0].name}/${sig.pair[1].name} look like a same-unit pair — a paired form likely reads better`);
418
+ push('Scatter.createBasic', sig.pair ? 0.72 : 0.88,
419
+ `2 measures (${m1.name} vs ${m2.name}) — relationship, clusters, outliers` + (dim ? `, colored by ${dim.name}` : ''),
420
+ { data: mapped, config: {} },
421
+ { points: n },
422
+ scatterCaveats,
423
+ `mapped to { x: ${m1.name}, y: ${m2.name}${dim ? `, category: ${dim.name}` : ''}${name ? `, name: ${name.name}` : ''} }`);
424
+ if (sig.pair && name) {
425
+ const [a, b] = sig.pair;
426
+ push('Dot.createDumbbell', 0.92,
427
+ `${a.name}/${b.name} read as a same-unit pair per ${name.name} — the gap is the message`,
428
+ { data: rows.map(r => ({ name: String(r[name.name]), start: val(r, a), end: val(r, b) })), config: {} },
429
+ { rows: n },
430
+ [], `reshaped to { name: ${name.name}, start: ${a.name}, end: ${b.name} }`);
431
+ push('Slope.createBasic', 0.7,
432
+ `the same pair with crossings and rate-of-change visible`,
433
+ { data: rows.map(r => ({ name: String(r[name.name]), before: val(r, a), after: val(r, b) })), config: {} },
434
+ { rows: n, periods: 2 },
435
+ [], `reshaped to { name: ${name.name}, before: ${a.name}, after: ${b.name} }`);
436
+ }
437
+ const overlap = rangeOverlap(m1, m2);
438
+ if (overlap > 0.5) {
439
+ push('Scatter.createDiagonal', 0.6,
440
+ `the two measures share a scale (${Math.round(overlap * 100)}% range overlap) — above/below y = x as the verdict`,
441
+ { data: mapped, config: {} },
442
+ { points: n },
443
+ 'only meaningful if the measures share a unit — suggest() infers this from range overlap alone');
444
+ }
445
+ if (M.length >= 3) {
446
+ push('Scatter.createQuadrant', 0.55,
447
+ `3+ measures — two on position, ${M[2].name} on bubble size`,
448
+ { data: mapped.map((d, i) => ({ ...d, size: val(rows[i], M[2]) })), config: { sizeAccessor: 'size' } },
449
+ { points: n },
450
+ 'needs a defensible center to earn the quadrant framing — otherwise use Scatter.createBasic');
451
+ }
452
+ }
453
+
454
+ // --- two categorical dimensions + measure -------------------------------
455
+ if (!t && m1 && D.length >= 2 && M.length === 1) {
456
+ const [d1, d2] = D.slice().sort((a, b) => b.distinct - a.distinct);
457
+ const flowNames = D.some(d => /source|from|origin/i.test(d.name)) && D.some(d => /target|to|dest/i.test(d.name));
458
+ if (flowNames) {
459
+ const src = D.find(d => /source|from|origin/i.test(d.name));
460
+ const tgt = D.find(d => /target|to|dest/i.test(d.name));
461
+ push('Sankey.createBasic', 0.9,
462
+ `${src.name} → ${tgt.name} + ${m1.name} reads as flow`,
463
+ { data: rows.map(r => ({ source: String(r[src.name]), target: String(r[tgt.name]), value: val(r, m1) })), config: {} },
464
+ { nodes: src.distinct + tgt.distinct, links: n },
465
+ [], `mapped to { source: ${src.name}, target: ${tgt.name}, value: ${m1.name} }`);
466
+ }
467
+ // Cardinality not scored: the metadata points range describes density
468
+ // mode; categorical mode wants one record per cell.
469
+ push('Scatter.createHeatmap', 0.8,
470
+ `2 categoricals (${d1.name} × ${d2.name}) + 1 measure — a value grid`,
471
+ { data: rows.map(r => ({ x: String(r[d1.name]), y: String(r[d2.name]), value: val(r, m1) })), config: {} },
472
+ {},
473
+ d1.distinct * d2.distinct > 400 ? `${d1.distinct} × ${d2.distinct} cells — consider aggregating first` : [],
474
+ `mapped to { x: ${d1.name}, y: ${d2.name}, value: ${m1.name} } (categorical mode)`);
475
+ if (d1.distinct <= 8 && d2.distinct <= 4) {
476
+ const wide = new Map();
477
+ for (const r of rows) {
478
+ const k = String(r[d1.name]);
479
+ if (!wide.has(k)) wide.set(k, { category: k });
480
+ wide.get(k)[String(r[d2.name])] = val(r, m1);
481
+ }
482
+ const keys = [...new Set(rows.map(r => String(r[d2.name])))];
483
+ push('Bar.createGrouped', 0.7,
484
+ `both dimensions small (${d1.distinct} × ${d2.distinct}) — side-by-side bars with a shared baseline`,
485
+ { data: [...wide.values()], config: { keys } },
486
+ { rows: d1.distinct, series: d2.distinct },
487
+ [], `pivoted wide: one row per ${d1.name}, one column per ${d2.name}`);
488
+ }
489
+ }
490
+
491
+ // --- entity + two temporal fields (interval) -----------------------------
492
+ if (sig.temporal.length >= 2 && (name || dim)) {
493
+ const [ta, tb] = sig.temporal.slice().sort((a, b) => a.min - b.min);
494
+ const label = name || dim;
495
+ push('Bar.createGantt', 0.85,
496
+ `two temporal fields (${ta.name}, ${tb.name}) per ${label.name} — intervals on a timeline`,
497
+ { data: rows.map(r => ({ label: String(r[label.name]), start: r[ta.name], end: r[tb.name] })), config: {} },
498
+ { rows: n },
499
+ [], `mapped to { label: ${label.name}, start: ${ta.name}, end: ${tb.name} }`);
500
+ }
501
+
502
+ return out;
503
+ }
504
+
505
+ function twoPointRows(sortedRows, t, dim, m) {
506
+ const periods = [...new Set(sortedRows.map(r => String(r[t.name] instanceof Date ? r[t.name].getTime() : r[t.name])))];
507
+ if (periods.length !== 2) return null;
508
+ const byGroup = groupBy(sortedRows, dim);
509
+ const rows = [];
510
+ for (const [g, rs] of byGroup) {
511
+ if (rs.length !== 2) continue;
512
+ rows.push({ name: String(g), before: val(rs[0], m), after: val(rs[1], m) });
513
+ }
514
+ return rows.length >= 2 ? rows : null;
515
+ }
516
+
517
+ function rangeOverlap(a, b) {
518
+ const lo = Math.max(a.min, b.min), hi = Math.min(a.max, b.max);
519
+ const span = Math.max(a.max, b.max) - Math.min(a.min, b.min);
520
+ return span > 0 ? Math.max(0, hi - lo) / span : 0;
521
+ }
522
+
523
+ function spanRatio(measures) {
524
+ const spans = measures.map(m => Math.max(Math.abs(m.max), Math.abs(m.min))).filter(s => s > 0);
525
+ return spans.length ? Math.max(...spans) / Math.min(...spans) : 1;
526
+ }
527
+
528
+ // ---------------------------------------------------------------------------
529
+ // Public API
530
+ // ---------------------------------------------------------------------------
531
+
532
+ /**
533
+ * @param {Array<object>} rows Flat row objects (CSV-shaped is fine; numeric
534
+ * strings are coerced during profiling).
535
+ * @param {{ limit?: number, factory?: object }} [options]
536
+ * limit: max candidates returned (default 5).
537
+ * factory: the ChartFactory object create() resolves against (injected by
538
+ * the export wiring; override only in tests).
539
+ * @returns {{ fields, signature, candidates }}
540
+ */
541
+ export function makeSuggest(defaultFactory) {
542
+ return function suggest(rows, { limit = 5, factory = defaultFactory() } = {}) {
543
+ if (!Array.isArray(rows) || rows.length === 0) {
544
+ console.warn('ChartFactory.suggest: rows must be a non-empty array of objects');
545
+ return { fields: [], signature: null, candidates: [] };
546
+ }
547
+ const fields = profileFields(rows);
548
+ const signature = buildSignature(rows, fields);
549
+ const candidates = suggestCandidates(rows, signature)
550
+ .map(c => ({ ...c, score: Math.max(0, Math.min(1, Math.round(c.score * 100) / 100)) }))
551
+ .sort((a, b) => b.score - a.score)
552
+ .slice(0, limit)
553
+ .map(c => ({
554
+ ...c,
555
+ create(selector, overrides = {}) {
556
+ const [family, method] = c.builder.split('.');
557
+ return factory[family][method](selector, { data: c.data, ...c.config, ...overrides });
558
+ },
559
+ }));
560
+ if (candidates.length === 0) {
561
+ console.warn('ChartFactory.suggest: no signature rule matched — see docs/chart-guide.md to choose by hand');
562
+ }
563
+ return { fields, signature, candidates };
564
+ };
565
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Shared Example Navigation Styles
3
+ *
4
+ * Usage: Include this CSS alongside example-nav.js
5
+ * <link rel="stylesheet" href="../../src/core/example-nav.css">
6
+ */
7
+
8
+ /* Navigation bar */
9
+ .example-nav {
10
+ position: fixed;
11
+ top: 0;
12
+ left: 0;
13
+ right: 0;
14
+ background: var(--color-bg-container);
15
+ padding: var(--space-3) var(--space-8);
16
+ border-bottom: 1px solid var(--color-border-light, #e5e5e5);
17
+ display: flex;
18
+ gap: var(--space-4);
19
+ z-index: 100;
20
+ flex-wrap: wrap;
21
+ }
22
+
23
+ .example-nav a {
24
+ font-family: var(--font-family);
25
+ font-size: var(--font-size-sm);
26
+ color: var(--color-text-muted);
27
+ text-decoration: none;
28
+ padding: var(--space-1) var(--space-2);
29
+ }
30
+
31
+ .example-nav a:hover {
32
+ color: var(--color-black);
33
+ }
34
+
35
+ .example-nav a.active {
36
+ color: var(--chart-primary);
37
+ font-weight: var(--font-weight-semibold);
38
+ }
39
+
40
+ /* Hide nav in modal view */
41
+ /* Modal embeds (?modal=1): neutralize the standalone-page body layout so the
42
+ chart container is the iframe's entire content — no viewport-height
43
+ stretching, no gutters, no scrollbar. The parent sizes the iframe from the
44
+ container height the child reports. */
45
+ body.modal-view {
46
+ padding: 0 !important;
47
+ margin: 0 !important;
48
+ min-height: 0 !important;
49
+ display: block !important;
50
+ overflow: hidden;
51
+ background: var(--color-bg-container, #fff);
52
+ }
53
+
54
+ body.modal-view .chart-container {
55
+ width: 100% !important;
56
+ max-width: none !important;
57
+ box-shadow: none !important;
58
+ }
59
+
60
+ body.modal-view .example-nav {
61
+ display: none;
62
+ }