mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Profile per-column cardinality and extent; add data-aware chart warnings (M4, A3/A4)
This commit is contained in:
+113
-30
@@ -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 8–12
|
||||
* 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.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user