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.
- package/LICENSE +21 -0
- package/README.md +162 -0
- package/dist/chart-factory-table.js +1400 -0
- package/dist/chart-factory.css +2368 -0
- package/dist/chart-factory.js +11398 -0
- package/dist/chart-factory.min.js +7 -0
- package/docs/API.md +1815 -0
- package/docs/chart-guide.md +872 -0
- package/docs/color-guide.md +134 -0
- package/docs/conformance-matrix.md +864 -0
- package/docs/theming.md +107 -0
- package/index.d.ts +10453 -0
- package/package.json +83 -0
- package/src/core/base.css +508 -0
- package/src/core/builder-metadata.generated.mjs +406 -0
- package/src/core/d3-base.js +249 -0
- package/src/core/d3-chart-factory.js +18547 -0
- package/src/core/d3-data.js +0 -0
- package/src/core/d3-suggest.js +565 -0
- package/src/core/example-nav.css +62 -0
- package/src/core/example-nav.js +249 -0
- package/src/core/palettes.js +310 -0
- package/src/core/theme-toggle.js +126 -0
- package/src/core/tokens.css +792 -0
- package/src/table/d3-table.js +1570 -0
- package/src/table/table.css +899 -0
- package/src/table/tokens.css +157 -0
|
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
|
+
}
|