mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Chart builder: data-aware defaults, one-click hint fixes, canvas preview, fullscreen modal
This commit is contained in:
+138
-13
@@ -108,16 +108,20 @@ describe('builderWarnings (Tier B advisories)', () => {
|
||||
expect(w.some((m) => /need both an X and a Y/.test(m.message))).toBe(true);
|
||||
});
|
||||
|
||||
it('warns when two measures are drawn on a non-scatter mark', () => {
|
||||
const w = builderWarnings({
|
||||
it('warns when two measures are drawn on a non-scatter mark, offering [Switch to Point]', () => {
|
||||
const config: BuilderConfig = {
|
||||
datasetName: 'D',
|
||||
mark: 'bar',
|
||||
encodings: {
|
||||
x: { field: 'a', type: 'quantitative' },
|
||||
y: { field: 'b', type: 'quantitative' },
|
||||
},
|
||||
});
|
||||
expect(w.some((m) => /scatter/.test(m.message))).toBe(true);
|
||||
};
|
||||
const w = builderWarnings(config);
|
||||
const hint = w.find((m) => /scatter/.test(m.message));
|
||||
const fix = hint?.fixes?.find((f) => f.label === 'Switch to Point');
|
||||
expect(fix).toBeDefined();
|
||||
expect(fix!.apply(config).mark).toBe('point');
|
||||
});
|
||||
|
||||
it('warns when a bar/line/area has no measure on either axis', () => {
|
||||
@@ -129,8 +133,8 @@ describe('builderWarnings (Tier B advisories)', () => {
|
||||
expect(w.some((m) => /need a measure/.test(m.message))).toBe(true);
|
||||
});
|
||||
|
||||
it('warns when an area chart is split into colour series', () => {
|
||||
const w = builderWarnings({
|
||||
it('warns when an area chart is split into colour series, offering [Stack] / [Remove colour]', () => {
|
||||
const config: BuilderConfig = {
|
||||
datasetName: 'D',
|
||||
mark: 'area',
|
||||
encodings: {
|
||||
@@ -138,8 +142,20 @@ describe('builderWarnings (Tier B advisories)', () => {
|
||||
y: { field: 'v', type: 'quantitative' },
|
||||
color: { field: 'g', type: 'nominal' },
|
||||
},
|
||||
});
|
||||
expect(w.some((m) => m.channel === 'color')).toBe(true);
|
||||
};
|
||||
const w = builderWarnings(config);
|
||||
const hint = w.find((m) => m.channel === 'color');
|
||||
expect(hint).toBeDefined();
|
||||
const labels = hint?.fixes?.map((f) => f.label) ?? [];
|
||||
expect(labels).toEqual(['Stack', 'Remove colour']); // most-recommended first
|
||||
// [Remove colour] clears the colour channel, so the hint re-derives away.
|
||||
const cleared = hint!.fixes!.find((f) => f.label === 'Remove colour')!.apply(config);
|
||||
expect(cleared.encodings.color).toBeNull();
|
||||
expect(builderWarnings(cleared).some((m) => m.channel === 'color')).toBe(false);
|
||||
// [Stack] turns it into a part-to-whole stack, which is no longer flagged.
|
||||
const stacked = hint!.fixes!.find((f) => f.label === 'Stack')!.apply(config);
|
||||
expect(stacked.stack).toBe('zero');
|
||||
expect(builderWarnings(stacked).some((m) => m.channel === 'color')).toBe(false);
|
||||
});
|
||||
|
||||
it('is silent for a clean configuration', () => {
|
||||
@@ -170,7 +186,29 @@ describe('builderWarnings (Tier B advisories)', () => {
|
||||
const hint = w.find((m) => /one mark per row/.test(m.message));
|
||||
expect(hint?.channel).toBe('x'); // the category axis
|
||||
expect(hint?.message).toContain('406 in this dataset');
|
||||
expect(hint?.message).toMatch(/Swap X\/Y/); // bar → horizontal-bar remedy
|
||||
// Remedies are one-click fixes, not prose; most-recommended first.
|
||||
const labels = hint?.fixes?.map((f) => f.label) ?? [];
|
||||
expect(labels).toEqual(['Aggregate as Sum', 'Swap X/Y']); // aggregate before swap
|
||||
});
|
||||
|
||||
it('[Aggregate as Sum] resolves the one-mark-per-row hint', () => {
|
||||
const w = crowded();
|
||||
const hint = w.find((m) => /one mark per row/.test(m.message));
|
||||
const fix = hint?.fixes?.find((f) => f.label === 'Aggregate as Sum');
|
||||
expect(fix).toBeDefined();
|
||||
const fixed = fix!.apply({
|
||||
datasetName: 'D',
|
||||
mark: 'bar',
|
||||
encodings: {
|
||||
x: { field: 'name', type: 'nominal' },
|
||||
y: { field: 'mpg', type: 'quantitative' },
|
||||
},
|
||||
});
|
||||
expect(fixed.encodings.y?.aggregate).toBe('sum');
|
||||
// and the hint is gone once applied
|
||||
expect(builderWarnings(fixed, 406).some((m) => /one mark per row/.test(m.message))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('is silent once the measure is aggregated (one bar per category)', () => {
|
||||
@@ -219,8 +257,9 @@ describe('builderWarnings (Tier B advisories)', () => {
|
||||
);
|
||||
const hint = w.find((m) => /one mark per row/.test(m.message));
|
||||
expect(hint).toBeDefined();
|
||||
expect(hint?.message).not.toMatch(/Swap X\/Y/);
|
||||
expect(hint?.message).toMatch(/reduce the number of categories/);
|
||||
const labels = hint?.fixes?.map((f) => f.label) ?? [];
|
||||
expect(labels).not.toContain('Swap X/Y'); // a horizontal line makes no sense
|
||||
expect(labels).toContain('Aggregate as Sum'); // aggregate still applies
|
||||
});
|
||||
});
|
||||
|
||||
@@ -262,7 +301,7 @@ describe('builderWarnings (Tier B advisories)', () => {
|
||||
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
|
||||
expect(hint?.fixes?.map((f) => f.label)).toContain('Swap X/Y'); // horizontal-bar remedy
|
||||
});
|
||||
|
||||
it('reports "more than 50" when the category cardinality hit the profiler cap', () => {
|
||||
@@ -426,7 +465,8 @@ describe('builderWarnings (Tier B advisories)', () => {
|
||||
});
|
||||
|
||||
describe('defaultBuilderConfig', () => {
|
||||
it('puts the first column on X and the second on Y, each with derived type', () => {
|
||||
it('falls back to first-on-X, second-on-Y when the dataset is unprofiled', () => {
|
||||
// `columns` carries no columnStats → no data-aware pick → positional default.
|
||||
const config = defaultBuilderConfig('Sales', columns);
|
||||
expect(config.mark).toBe('bar');
|
||||
expect(config.datasetName).toBe('Sales');
|
||||
@@ -467,6 +507,91 @@ describe('defaultBuilderConfig', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('defaultBuilderConfig — data-aware "safest bet" (profiled datasets)', () => {
|
||||
/** Stats for one column. */
|
||||
const stat = (name: string, distinct: number, capped = false) => ({
|
||||
name,
|
||||
distinct,
|
||||
distinctCapped: capped,
|
||||
numericExtent: null,
|
||||
});
|
||||
|
||||
it('opens on a low-cardinality category vs a count of records, not the first two columns', () => {
|
||||
// Superstore-shaped: an id-like number first, a high-cardinality id, then tidy
|
||||
// categories — the case a positional first-two-columns default would open as a
|
||||
// 9994-bar degenerate chart.
|
||||
const wide: BuilderColumns = {
|
||||
columns: ['Row ID', 'Order ID', 'Segment', 'Sales'],
|
||||
columnTypes: [
|
||||
{ name: 'Row ID', type: 'number' },
|
||||
{ name: 'Order ID', type: 'string' },
|
||||
{ name: 'Segment', type: 'string' },
|
||||
{ name: 'Sales', type: 'number' },
|
||||
],
|
||||
columnStats: [
|
||||
stat('Row ID', 50, true),
|
||||
stat('Order ID', 50, true), // high cardinality → not a category axis
|
||||
stat('Segment', 3), // tidy category → the pick
|
||||
stat('Sales', 50, true),
|
||||
],
|
||||
};
|
||||
const config = defaultBuilderConfig('Superstore', wide);
|
||||
expect(config.mark).toBe('bar');
|
||||
expect(config.encodings.x).toEqual({ field: 'Segment', type: 'nominal' });
|
||||
expect(config.encodings.y).toEqual({ type: 'quantitative', aggregate: 'count' });
|
||||
});
|
||||
|
||||
it('picks the lowest-cardinality readable category among several', () => {
|
||||
const cols: BuilderColumns = {
|
||||
columns: ['Region', 'Segment', 'City'],
|
||||
columnTypes: [
|
||||
{ name: 'Region', type: 'string' },
|
||||
{ name: 'Segment', type: 'string' },
|
||||
{ name: 'City', type: 'string' },
|
||||
],
|
||||
columnStats: [stat('Region', 4), stat('Segment', 3), stat('City', 50, true)],
|
||||
};
|
||||
const config = defaultBuilderConfig('D', cols);
|
||||
expect(config.encodings.x).toEqual({ field: 'Segment', type: 'nominal' }); // 3 < 4
|
||||
});
|
||||
|
||||
it('falls through to a time series (date vs count) when no tidy category exists', () => {
|
||||
const cols: BuilderColumns = {
|
||||
columns: ['Order ID', 'Order Date', 'Sales'],
|
||||
columnTypes: [
|
||||
{ name: 'Order ID', type: 'string' },
|
||||
{ name: 'Order Date', type: 'date' },
|
||||
{ name: 'Sales', type: 'number' },
|
||||
],
|
||||
columnStats: [
|
||||
stat('Order ID', 50, true),
|
||||
stat('Order Date', 50, true),
|
||||
stat('Sales', 50, true),
|
||||
],
|
||||
};
|
||||
const config = defaultBuilderConfig('D', cols);
|
||||
expect(config.mark).toBe('line');
|
||||
expect(config.encodings.x).toEqual({ field: 'Order Date', type: 'temporal' });
|
||||
expect(config.encodings.y).toEqual({ field: 'Sales', type: 'quantitative' }); // the measure, raw
|
||||
});
|
||||
|
||||
it('falls through to a scatter of two measures when there is no category or date', () => {
|
||||
const cols: BuilderColumns = {
|
||||
columns: ['Order ID', 'Sales', 'Profit'],
|
||||
columnTypes: [
|
||||
{ name: 'Order ID', type: 'string' },
|
||||
{ name: 'Sales', type: 'number' },
|
||||
{ name: 'Profit', type: 'number' },
|
||||
],
|
||||
columnStats: [stat('Order ID', 50, true), stat('Sales', 50, true), stat('Profit', 50, true)],
|
||||
};
|
||||
const config = defaultBuilderConfig('D', cols);
|
||||
expect(config.mark).toBe('point');
|
||||
expect(config.encodings.x).toEqual({ field: 'Sales', type: 'quantitative' });
|
||||
expect(config.encodings.y).toEqual({ field: 'Profit', type: 'quantitative' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('isBuilderConfigValid', () => {
|
||||
const base: BuilderConfig = { datasetName: 'D', mark: 'bar', encodings: {} };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user