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
+113 -30
View File
@@ -22,6 +22,8 @@
* editor and preview consume — `buildSnippetSpecText` serializes it.
*/
import type { ColumnStats } from './profile';
import { DISTINCT_CAP } from './profile';
import type { ColumnType } from './type-inference';
import { VEGA_LITE_SCHEMA_URL } from './snippet';
@@ -157,8 +159,9 @@ function isContinuous(type: FieldType): boolean {
* implies ordered magnitude). Draco makes this a hard constraint
* (`hard.lp:53` size_nominal); we surface it as a UI gate that disables Size for
* unsuitable columns rather than letting the user produce the bad encoding.
* (Negative-value exclusion, `hard.lp:56`, needs row data and is left to the
* data-aware layer; this type-level gate is what the builder enforces.)
* (Negative-value exclusion, `hard.lp:56`, needs row data — `builderWarnings`
* surfaces it as a soft hint from the column's profiled numeric extent; this
* type-level gate is the part the builder can enforce structurally.)
*/
export function isChannelTypeAllowed(channel: ChannelName, type: FieldType): boolean {
if (channel === 'size') return type === 'quantitative' || type === 'ordinal';
@@ -212,6 +215,13 @@ export function defaultMark(xType: FieldType | null, yType: FieldType | null): M
export interface BuilderColumns {
columns: readonly string[];
columnTypes: ReadonlyArray<{ name: string; type: ColumnType }>;
/**
* Per-column cardinality + numeric range, when the dataset was profiled with it
* (datasets created before this profiling, and URL/non-tabular data, omit it).
* The data-aware `builderWarnings` use it where present and silently skip those
* hints where absent.
*/
columnStats?: ReadonlyArray<ColumnStats>;
}
/** The derived field type for a named column, defaulting to Nominal if unknown. */
@@ -336,6 +346,31 @@ export interface BuilderWarning {
*/
const CROWDED_CATEGORY_ROWS = 30;
/**
* Above this many *distinct* category values, even an aggregated category axis (one
* mark per category, not per row) has too many labels to read. Matched to
* `CROWDED_CATEGORY_ROWS`: both flag a category axis that overruns its labels.
*/
const CROWDED_CATEGORY_DISTINCT = 30;
/**
* Above this many distinct colour values a discrete legend is too long to scan and
* the palette starts recycling hues. Qualitative colour scales top out around 812
* across the canon (Datawrapper, FT Visual Vocabulary); 12 is the generous end.
*/
const CROWDED_LEGEND_DISTINCT = 12;
/** Profiled stats for a mapped field, when the dataset carries them. */
function statsFor(field: string | undefined, columns?: BuilderColumns): ColumnStats | undefined {
if (!field || !columns?.columnStats) return undefined;
return columns.columnStats.find((s) => s.name === field);
}
/** "N" or "more than 50" — distinct count, honouring the profiler's cap. */
function cardinalityText(stats: ColumnStats): string {
return stats.distinctCapped ? `more than ${DISTINCT_CAP}` : `${stats.distinct}`;
}
/**
* Non-blocking advisories for the current configuration (spec §06 → Tier B): the
* encodings that render but read poorly, drawn from the research's soft rules
@@ -343,11 +378,17 @@ const CROWDED_CATEGORY_ROWS = 30;
* ConfigValid` is the only gate — they just steer the user toward a better chart.
* Returned in a stable order so the UI list doesn't jitter as config changes.
*
* `rowCount` (the dataset's row count, when known) powers the crowded-axis hint;
* pass it from the loaded dataset. Omitted/`null` (URL or non-tabular data) simply
* skips that one hint.
* `rowCount` (the dataset's row count, when known) powers the one-mark-per-row hint;
* `columns` (with per-column `columnStats`) powers the data-aware hints — crowded
* legend / category axis (cardinality) and the negative-value Size guard (numeric
* extent). Either omitted (URL / non-tabular / pre-cardinality datasets) simply
* skips the hints that need it.
*/
export function builderWarnings(config: BuilderConfig, rowCount?: number | null): BuilderWarning[] {
export function builderWarnings(
config: BuilderConfig,
rowCount?: number | null,
columns?: BuilderColumns,
): BuilderWarning[] {
const warnings: BuilderWarning[] = [];
const x = config.encodings.x ?? null;
const y = config.encodings.y ?? null;
@@ -400,32 +441,74 @@ export function builderWarnings(config: BuilderConfig, rowCount?: number | null)
});
}
// Crowded category axis: a bar/line/area pairing a discrete category against a
// *raw* (un-aggregated, un-binned) measure draws one mark — and one axis label —
// per row, so a large dataset becomes an unreadable picket fence of labels (the
// builder's own default does this: first column on X, second on Y, no aggregate).
// We can only flag the un-aggregated case, where mark-count == rowCount exactly;
// an *aggregated* axis that still has many distinct categories needs per-column
// distinct counts we don't profile yet (backlog A2/A3). The fix follows the canon:
// aggregate the measure to one mark per category, or — for a bar — flip to a
// horizontal bar where long labels stay readable (FT Visual Vocabulary: bar is
// "good when … labels have long category names"; Datawrapper: long category lists
// belong on a horizontal bar).
if (
(mark === 'bar' || mark === 'line' || mark === 'area') &&
typeof rowCount === 'number' &&
rowCount > CROWDED_CATEGORY_ROWS
) {
const category = sortableCategoryChannel(config); // the discrete axis of a category-vs-measure pair
// Crowded category axis: a bar/line/area whose discrete category axis carries too
// many entries to label legibly. Two ways it happens, mutually exclusive:
// 1. A *raw* (un-aggregated, un-binned) measure draws one mark per row, so the
// label count == rowCount — flagged from rowCount alone (the builder's own
// default does this: first column on X, second on Y, no aggregate).
// 2. Otherwise (aggregated/binned), one mark per *category* — crowded only when
// the category column itself has many distinct values, which needs the
// profiled cardinality (columnStats) and is skipped without it.
// The remedy follows the canon: for case 1 aggregate to one mark per category; for
// either, a bar can flip to horizontal where long labels stay readable (FT Visual
// Vocabulary: bar is "good when … labels have long category names"; Datawrapper:
// long category lists belong on a horizontal bar).
if (mark === 'bar' || mark === 'line' || mark === 'area') {
const category = sortableCategoryChannel(config); // discrete axis of a category-vs-measure pair
const measure = category ? config.encodings[category === 'x' ? 'y' : 'x'] : null;
if (category && measure && !measure.aggregate && !measure.bin) {
const fix =
mark === 'bar'
? 'Aggregate the measure (e.g. Sum or Mean) for one bar per category, or use Swap X/Y for a horizontal bar where long labels stay readable.'
: 'Aggregate the measure (e.g. Sum or Mean) so there is one mark per category, or reduce the number of categories.';
if (category && measure) {
const rawMeasure = !measure.aggregate && !measure.bin;
if (rawMeasure && typeof rowCount === 'number' && rowCount > CROWDED_CATEGORY_ROWS) {
const fix =
mark === 'bar'
? 'Aggregate the measure (e.g. Sum or Mean) for one bar per category, or use Swap X/Y for a horizontal bar where long labels stay readable.'
: 'Aggregate the measure (e.g. Sum or Mean) so there is one mark per category, or reduce the number of categories.';
warnings.push({
channel: category,
message: `This draws one mark per row (${rowCount} in this dataset), so the category-axis labels will overlap. ${fix}`,
});
} else {
const stats = statsFor(config.encodings[category]?.field, columns);
if (stats && stats.distinct > CROWDED_CATEGORY_DISTINCT) {
const fix =
mark === 'bar'
? 'Use Swap X/Y for a horizontal bar where long lists stay readable, or filter to fewer categories.'
: 'Filter to fewer categories, or group the long tail into an "Other".';
warnings.push({
channel: category,
message: `This category axis has ${cardinalityText(stats)} distinct values, so its labels will overlap. ${fix}`,
});
}
}
}
}
// Crowded colour legend: a discrete colour series with many distinct values makes
// the legend too long to scan and forces the palette to recycle hues. A continuous
// colour ramp (quantitative/temporal) has no per-value legend, so this is for
// discrete colour only (Datawrapper/FT cap categorical colour low).
const color = config.encodings.color ?? null;
if (color && !isMeasureMapping(color)) {
const stats = statsFor(color.field, columns);
if (stats && stats.distinct > CROWDED_LEGEND_DISTINCT) {
warnings.push({
channel: category,
message: `This draws one mark per row (${rowCount} in this dataset), so the category-axis labels will overlap. ${fix}`,
channel: 'color',
message: `Colour has ${cardinalityText(stats)} categories — a legend that long is hard to scan and the palette will repeat hues. Group rarer categories, or map this field to the X axis instead.`,
});
}
}
// Negative-value Size guard (Draco hard.lp:56 size_negative): symbol size encodes
// magnitude, so negatives render as zero/clipped area and mislead. The type gate
// (isChannelTypeAllowed) can't catch this — it needs the data — so we flag it from
// the profiled numeric extent.
const size = config.encodings.size ?? null;
if (size?.field) {
const stats = statsFor(size.field, columns);
if (stats?.numericExtent && stats.numericExtent.min < 0) {
warnings.push({
channel: 'size',
message: `Size can't show negative values, but "${size.field}" goes down to ${stats.numericExtent.min}. Negative magnitudes render as tiny or clipped symbols — encode this field with Colour (a diverging scale) instead, or filter to non-negative values.`,
});
}
}