Profile per-column cardinality and extent; add data-aware chart warnings (M4, A3/A4)

This commit is contained in:
2026-06-07 23:03:17 +03:00
parent dcb7037a71
commit 39555322e4
13 changed files with 552 additions and 90 deletions
+200
View File
@@ -223,6 +223,206 @@ describe('builderWarnings (Tier B advisories)', () => {
expect(hint?.message).toMatch(/reduce the number of categories/);
});
});
describe('data-aware hints (from profiled column stats)', () => {
/** A BuilderColumns carrying stats for the named field. */
const withStats = (
name: string,
stats: {
distinct?: number;
distinctCapped?: boolean;
numericExtent?: { min: number; max: number } | null;
},
): BuilderColumns => ({
columns: [name],
columnTypes: [{ name, type: 'string' }],
columnStats: [
{
name,
distinct: stats.distinct ?? 1,
distinctCapped: stats.distinctCapped ?? false,
numericExtent: stats.numericExtent ?? null,
},
],
});
it('flags an aggregated category axis that still has too many distinct values', () => {
const w = builderWarnings(
{
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'sku', type: 'nominal' },
y: { field: 'qty', type: 'quantitative', aggregate: 'sum' }, // aggregated, so not one-per-row
},
},
500,
withStats('sku', { distinct: 48 }),
);
const hint = w.find((m) => /distinct values/.test(m.message));
expect(hint?.channel).toBe('x');
expect(hint?.message).toMatch(/48 distinct values/);
expect(hint?.message).toMatch(/Swap X\/Y/); // bar → horizontal-bar remedy
});
it('reports "more than 50" when the category cardinality hit the profiler cap', () => {
const w = builderWarnings(
{
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'sku', type: 'nominal' },
y: { field: 'qty', type: 'quantitative', aggregate: 'sum' },
},
},
500,
withStats('sku', { distinct: 50, distinctCapped: true }),
);
expect(w.some((m) => /more than 50 distinct values/.test(m.message))).toBe(true);
});
it('is silent for a small-cardinality aggregated category axis', () => {
const w = builderWarnings(
{
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'sku', type: 'nominal' },
y: { field: 'qty', type: 'quantitative', aggregate: 'sum' },
},
},
500,
withStats('sku', { distinct: 6 }),
);
expect(w.some((m) => /distinct values/.test(m.message))).toBe(false);
});
it('warns when a discrete colour series has too many categories', () => {
const w = builderWarnings(
{
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'sku', type: 'nominal' },
y: { field: 'qty', type: 'quantitative', aggregate: 'sum' },
color: { field: 'tag', type: 'nominal' },
},
},
500,
{
columns: ['tag'],
columnTypes: [{ name: 'tag', type: 'string' }],
columnStats: [{ name: 'tag', distinct: 20, distinctCapped: false, numericExtent: null }],
},
);
const hint = w.find((m) => m.channel === 'color' && /legend/.test(m.message));
expect(hint?.message).toMatch(/20 categories/);
});
it('does not flag a continuous (quantitative) colour ramp for cardinality', () => {
const w = builderWarnings(
{
datasetName: 'D',
mark: 'point',
encodings: {
x: { field: 'a', type: 'quantitative' },
y: { field: 'b', type: 'quantitative' },
color: { field: 'score', type: 'quantitative' }, // a ramp, no per-value legend
},
},
500,
{
columns: ['score'],
columnTypes: [{ name: 'score', type: 'number' }],
columnStats: [
{
name: 'score',
distinct: 50,
distinctCapped: true,
numericExtent: { min: 0, max: 9 },
},
],
},
);
expect(w.some((m) => /legend/.test(m.message))).toBe(false);
});
it('guards Size against a field whose values go negative', () => {
const w = builderWarnings(
{
datasetName: 'D',
mark: 'point',
encodings: {
x: { field: 'a', type: 'quantitative' },
y: { field: 'b', type: 'quantitative' },
size: { field: 'delta', type: 'quantitative' },
},
},
500,
{
columns: ['delta'],
columnTypes: [{ name: 'delta', type: 'number' }],
columnStats: [
{
name: 'delta',
distinct: 30,
distinctCapped: false,
numericExtent: { min: -12, max: 40 },
},
],
},
);
const hint = w.find((m) => m.channel === 'size');
expect(hint?.message).toMatch(/can't show negative values/);
expect(hint?.message).toMatch(/down to -12/);
});
it('does not guard Size when the field is wholly non-negative', () => {
const w = builderWarnings(
{
datasetName: 'D',
mark: 'point',
encodings: {
x: { field: 'a', type: 'quantitative' },
y: { field: 'b', type: 'quantitative' },
size: { field: 'amount', type: 'quantitative' },
},
},
500,
{
columns: ['amount'],
columnTypes: [{ name: 'amount', type: 'number' }],
columnStats: [
{
name: 'amount',
distinct: 30,
distinctCapped: false,
numericExtent: { min: 0, max: 40 },
},
],
},
);
expect(w.some((m) => m.channel === 'size')).toBe(false);
});
it('skips data-aware hints entirely when no column stats are supplied', () => {
// Old/URL datasets: same crowded config, but without stats the hints stay quiet.
const w = builderWarnings(
{
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'sku', type: 'nominal' },
y: { field: 'qty', type: 'quantitative', aggregate: 'sum' },
size: { field: 'qty', type: 'quantitative' },
},
},
500,
);
expect(w.some((m) => /distinct values/.test(m.message))).toBe(false);
expect(w.some((m) => m.channel === 'size')).toBe(false);
});
});
});
describe('defaultBuilderConfig', () => {