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:
@@ -169,16 +169,17 @@ A **profile** is the set of derived summary fields stored on a dataset record so
|
|||||||
the UI can describe it without re-parsing the payload. Per the data model, a
|
the UI can describe it without re-parsing the payload. Per the data model, a
|
||||||
profiled dataset carries:
|
profiled dataset carries:
|
||||||
|
|
||||||
| Field | Type | Meaning |
|
| Field | Type | Meaning |
|
||||||
| ------------- | ----------------------- | ---------------------------------- |
|
| ------------- | ---------------------------------------------------------- | ------------------------------------------------------------------ |
|
||||||
| `rowCount` | `number \| null` | Data rows, or `null` when N/A. |
|
| `rowCount` | `number \| null` | Data rows, or `null` when N/A. |
|
||||||
| `columnCount` | `number \| null` | Columns, or `null` when N/A. |
|
| `columnCount` | `number \| null` | Columns, or `null` when N/A. |
|
||||||
| `columns` | `string[]` | Column names, in order. |
|
| `columns` | `string[]` | Column names, in order. |
|
||||||
| `columnTypes` | `Array<{ name; type }>` | Per-column inferred type (see §2). |
|
| `columnTypes` | `Array<{ name; type }>` | Per-column inferred type (see §2). |
|
||||||
| `size` | `number` | Approximate payload size in bytes. |
|
| `columnStats` | `Array<{ name; distinct; distinctCapped; numericExtent }>` | Per-column cardinality + numeric range (see §3.3). Empty when N/A. |
|
||||||
|
| `size` | `number` | Approximate payload size in bytes. |
|
||||||
|
|
||||||
`null` row/column counts and an empty `columns`/`columnTypes` are how the UI
|
`null` row/column counts and an empty `columns`/`columnTypes`/`columnStats` are
|
||||||
shows **"N/A"** — see §3.2.
|
how the UI shows **"N/A"** — see §3.2.
|
||||||
|
|
||||||
### 3.1 What gets profiled
|
### 3.1 What gets profiled
|
||||||
|
|
||||||
@@ -208,12 +209,14 @@ of the stored payload), but `rowCount` and `columnCount` are `null`, and
|
|||||||
- `json` that is a non-empty **array of objects** → use it directly.
|
- `json` that is a non-empty **array of objects** → use it directly.
|
||||||
- anything else (`topojson`, a lone JSON object, an empty array) → not
|
- anything else (`topojson`, a lone JSON object, an empty array) → not
|
||||||
tabular; return the N/A profile (`rowCount: null`, `columnCount: null`,
|
tabular; return the N/A profile (`rowCount: null`, `columnCount: null`,
|
||||||
`columns: []`, `columnTypes: []`, plus `size`).
|
`columns: []`, `columnTypes: []`, `columnStats: []`, plus `size`).
|
||||||
3. **Derive columns** from the union of keys across the rows (or the CSV/TSV
|
3. **Derive columns** from the union of keys across the rows (or the CSV/TSV
|
||||||
header), preserving first-seen order.
|
header), preserving first-seen order.
|
||||||
4. **Infer each column's type** by collecting that column's values across the
|
4. **Infer each column's type and stats** by collecting that column's values across
|
||||||
rows and calling `inferColumnType` (§2), sampling per §4.
|
the rows (sampling per §4) and calling `inferColumnType` (§2) plus deriving its
|
||||||
5. **Assemble** `rowCount`, `columnCount`, `columns`, `columnTypes`, `size`.
|
cardinality + numeric extent (§3.3) — one pass per column over the same sample.
|
||||||
|
5. **Assemble** `rowCount`, `columnCount`, `columns`, `columnTypes`, `columnStats`,
|
||||||
|
`size`.
|
||||||
|
|
||||||
### Sketch
|
### Sketch
|
||||||
|
|
||||||
@@ -226,6 +229,12 @@ export interface DatasetProfile {
|
|||||||
columnCount: number | null;
|
columnCount: number | null;
|
||||||
columns: string[];
|
columns: string[];
|
||||||
columnTypes: Array<{ name: string; type: ColumnType }>;
|
columnTypes: Array<{ name: string; type: ColumnType }>;
|
||||||
|
columnStats: Array<{
|
||||||
|
name: string;
|
||||||
|
distinct: number; // capped at DISTINCT_CAP
|
||||||
|
distinctCapped: boolean; // true ⇒ real cardinality ≥ DISTINCT_CAP
|
||||||
|
numericExtent: { min: number; max: number } | null; // numeric columns only
|
||||||
|
}>;
|
||||||
size: number;
|
size: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,6 +243,7 @@ const NA = (size: number): DatasetProfile => ({
|
|||||||
columnCount: null,
|
columnCount: null,
|
||||||
columns: [],
|
columns: [],
|
||||||
columnTypes: [],
|
columnTypes: [],
|
||||||
|
columnStats: [],
|
||||||
size,
|
size,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -259,16 +269,22 @@ export function profileData(
|
|||||||
if (columns.length === 0) return NA(size);
|
if (columns.length === 0) return NA(size);
|
||||||
|
|
||||||
const sample = sampleRows(rows);
|
const sample = sampleRows(rows);
|
||||||
const columnTypes = columns.map((name) => ({
|
// One pass per column over the sample: type + stats (cardinality, numeric extent).
|
||||||
name,
|
const columnTypes = [];
|
||||||
type: inferColumnType(sample.map((r) => r[name])),
|
const columnStats = [];
|
||||||
}));
|
for (const name of columns) {
|
||||||
|
const values = sample.map((r) => r[name]);
|
||||||
|
const type = inferColumnType(values);
|
||||||
|
columnTypes.push({ name, type });
|
||||||
|
columnStats.push(columnStatsFor(name, values, type)); // see §3.3
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rowCount: rows.length,
|
rowCount: rows.length,
|
||||||
columnCount: columns.length,
|
columnCount: columns.length,
|
||||||
columns,
|
columns,
|
||||||
columnTypes,
|
columnTypes,
|
||||||
|
columnStats,
|
||||||
size,
|
size,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -278,17 +294,41 @@ Parsing CSV/TSV text and detecting the payload shape happen _upstream_ of
|
|||||||
`profileData`; this function takes already-parsed rows so it stays pure and
|
`profileData`; this function takes already-parsed rows so it stays pure and
|
||||||
trivially testable. The caller passes `null` for URL and non-tabular datasets.
|
trivially testable. The caller passes `null` for URL and non-tabular datasets.
|
||||||
|
|
||||||
|
### 3.3 Column stats: cardinality + numeric extent
|
||||||
|
|
||||||
|
Alongside the display type, each column carries the two data-shape signals the
|
||||||
|
**Chart Builder** needs for its data-aware Tier-B hints (spec §06; see
|
||||||
|
`chart-builder.ts` `builderWarnings`):
|
||||||
|
|
||||||
|
- **`distinct`** — the count of distinct non-empty values **in the sample**,
|
||||||
|
counted only up to `DISTINCT_CAP` (50). Past the cap the exact number stops
|
||||||
|
mattering — a legend or category axis with 50 entries is already unreadable — so
|
||||||
|
counting stops and **`distinctCapped`** flags that the real cardinality is at
|
||||||
|
least the cap. Powers the crowded-legend (discrete colour) and crowded-category
|
||||||
|
warnings.
|
||||||
|
- **`numericExtent`** — `{ min, max }` over the numeric values, for `number`
|
||||||
|
columns only (`null` otherwise), using the **same numeric test** as
|
||||||
|
`inferColumnType` so the two never disagree. Powers the negative-value **Size**
|
||||||
|
guard (size encodes magnitude → negatives mislead; Draco `hard.lp:56`).
|
||||||
|
|
||||||
|
Both are derived in the same sample pass as type inference, so they are nearly
|
||||||
|
free, and both are **estimates** (sampled from the head, like the type) — fine for
|
||||||
|
soft advisories, not guarantees. URL / non-tabular data and datasets stored before
|
||||||
|
this field carry empty `columnStats`, and the dependent hints simply skip.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Sampling vs. full scan
|
## 4. Sampling vs. full scan
|
||||||
|
|
||||||
`rowCount`/`columnCount`/`size` always reflect the **whole** dataset — they're
|
`rowCount`/`columnCount`/`size` always reflect the **whole** dataset — they're
|
||||||
cheap (a length and a byte count). Only **type inference** has a per-value cost,
|
cheap (a length and a byte count). Only the **per-value** work — type inference
|
||||||
and it's the one place a huge dataset could hurt.
|
and the §3.3 column stats — has a per-row cost, and it's the one place a huge
|
||||||
|
dataset could hurt.
|
||||||
|
|
||||||
So: infer types from a **bounded sample** of rows, not the full column. A fixed
|
So: derive types **and** stats from a **bounded sample** of rows, not the full
|
||||||
cap (e.g. the first ~200 rows) keeps profiling fast and predictable on large
|
column. A fixed cap (e.g. the first ~200 rows) keeps profiling fast and
|
||||||
pasted datasets while still being more than enough signal to classify a column.
|
predictable on large pasted datasets while still being more than enough signal to
|
||||||
|
classify a column and estimate its cardinality / range.
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
const SAMPLE_SIZE = 200;
|
const SAMPLE_SIZE = 200;
|
||||||
@@ -306,7 +346,8 @@ the same paste profiles the same way twice.
|
|||||||
### Do / Don't
|
### Do / Don't
|
||||||
|
|
||||||
- **Do** count rows/columns and size over the full payload.
|
- **Do** count rows/columns and size over the full payload.
|
||||||
- **Do** cap type-inference sampling at a fixed head slice for determinism.
|
- **Do** cap type-inference **and column-stats** sampling at a fixed head slice for
|
||||||
|
determinism (so `distinct` / `numericExtent` are estimates, like the type).
|
||||||
- **Don't** randomly sample — non-deterministic profiles break tests and confuse
|
- **Don't** randomly sample — non-deterministic profiles break tests and confuse
|
||||||
users.
|
users.
|
||||||
- **Don't** scan every value of a million-row paste to guess a type.
|
- **Don't** scan every value of a million-row paste to guess a type.
|
||||||
|
|||||||
@@ -214,30 +214,29 @@ as of 2026-06-06.
|
|||||||
un-aggregated case. A general "long labels → go horizontal" suggestion on _any_ vertical
|
un-aggregated case. A general "long labels → go horizontal" suggestion on _any_ vertical
|
||||||
bar is still deferred (needs a label-length / cardinality signal); a blanket warning was
|
bar is still deferred (needs a label-length / cardinality signal); a blanket warning was
|
||||||
rejected — it would fire on every ordinary vertical bar.
|
rejected — it would fire on every ordinary vertical bar.
|
||||||
- **A3 · Crowded-axis & high-cardinality warnings** _(partly done)_ —
|
- **A3 · Crowded-axis & high-cardinality warnings** _(done)_ —
|
||||||
- _Done:_ the **un-aggregated crowded axis** — a bar/line/area with a category axis and a
|
- _Done:_ the **un-aggregated crowded axis** — a bar/line/area with a category axis and a
|
||||||
**raw** measure draws one mark (and one label) per row, so over `CROWDED_CATEGORY_ROWS`
|
**raw** measure draws one mark (and one label) per row, so over `CROWDED_CATEGORY_ROWS`
|
||||||
(30) rows it warns and points to aggregating, or a horizontal bar. Row-count-based:
|
(30) rows it warns and points to aggregating, or a horizontal bar. Row-count-based:
|
||||||
`builderWarnings(config, rowCount)`, with `rowCount` from the loaded dataset; detects
|
detects exactly the mark-count == row-count case (URL/non-tabular → `rowCount` null → skipped).
|
||||||
exactly the mark-count == row-count case (URL/non-tabular → `rowCount` null → skipped).
|
- _Done (via the profiling extension below):_ an **aggregated** axis that still has many
|
||||||
- _Remaining (needs the profiling extension below):_ an **aggregated** axis that still has
|
distinct **categories** (`CROWDED_CATEGORY_DISTINCT` = 30) and an unreadable **Color
|
||||||
many distinct **categories**, an unreadable **Color legend** (>10/>20 categories), and
|
legend** (`CROWDED_LEGEND_DISTINCT` = 12, discrete colour only) now warn from per-column
|
||||||
**number-typed-Nominal**. All need per-column **cardinality**, which the profile lacks.
|
cardinality. (Number-typed-Nominal remains a possible future nudge; not yet flagged.)
|
||||||
- **A4 · Data-aware Size guard** _(deferred — needs the profiling extension)_ — exclude
|
- **A4 · Data-aware Size guard** _(done)_ — Size mapped to a field whose profiled numeric
|
||||||
**negative**-valued columns from Size (Draco `hard.lp:56`; size implies positive
|
**extent** goes negative warns (Draco `hard.lp:56`; size implies positive magnitude),
|
||||||
magnitude). Today only the type-level Size discipline is enforced.
|
on top of the type-level Size discipline.
|
||||||
|
|
||||||
> **TODO — profiling extension (the A3-remaining + A4 enabler).** `profile.ts` computes only
|
> **Done — profiling extension (the A3 / A4 enabler).** `profile.ts` now derives, in the
|
||||||
> `rowCount` / `columnCount` / `columnTypes`. Extend it, in the **same sample pass** that
|
> **same sample pass** that feeds `inferColumnType`, a per-column **capped distinct count**
|
||||||
> already feeds `inferColumnType` (so it's nearly free), to also derive per column: a
|
> (`DISTINCT_CAP` = 50, with a `distinctCapped` overflow flag) and a **numeric extent**
|
||||||
> **capped distinct count** (cardinality — cap at ~50; a sampled count is enough for a
|
> (min/max, numeric columns only), surfaced on `DatasetProfile.columnStats` and the `Dataset`
|
||||||
> ">N categories" threshold, no full scan) and a **numeric extent** (min/max → sign).
|
> record. `builderWarnings(config, rowCount, columns)` consumes them for the legend/axis
|
||||||
> Surface them on `DatasetProfile` (alongside `columnTypes`). Then: `builderWarnings` consumes
|
> crowding hints and the negative-value Size guard. **Caveats handled:** URL / non-tabular
|
||||||
> cardinality (legend/axis crowding) and the Size gate consumes sign (A4). **Caveats:**
|
> data has no rows → `columnStats` is `[]` and the dependent hints skip; datasets stored
|
||||||
> URL / non-tabular datasets have no rows at profile time → these fields are null and the
|
> before the field default to `[]` via `dataset-migrations` / import normalization (no forced
|
||||||
> dependent warnings simply skip; and stored datasets predate the field, so this needs a
|
> re-profile on read — they pick up stats on next save). Thresholds live in `chart-builder.ts`
|
||||||
> recompute-on-read or a `dataset-migrations` bump (see architecture 02 / 06). Keep
|
> constants alongside `CROWDED_CATEGORY_ROWS`.
|
||||||
> thresholds in `chart-builder.ts` constants like `CROWDED_CATEGORY_ROWS`.
|
|
||||||
|
|
||||||
**B · Transform-enabled coverage (new core capability + §06 extension)** _(done)_
|
**B · Transform-enabled coverage (new core capability + §06 extension)** _(done)_
|
||||||
|
|
||||||
|
|||||||
@@ -358,8 +358,14 @@ export function ChartBuilderModal() {
|
|||||||
// fresh array each render (which loops useSyncExternalStore — see SnippetStore note).
|
// fresh array each render (which loops useSyncExternalStore — see SnippetStore note).
|
||||||
const config = useChartBuilderStore((s) => s.config);
|
const config = useChartBuilderStore((s) => s.config);
|
||||||
const rowCount = useChartBuilderStore((s) => s.rowCount);
|
const rowCount = useChartBuilderStore((s) => s.rowCount);
|
||||||
|
// `columns` is a stable reference set once at init (not rebuilt per render), so
|
||||||
|
// subscribing to it won't loop useSyncExternalStore.
|
||||||
|
const columns = useChartBuilderStore((s) => s.columns);
|
||||||
const valid = useMemo(() => isBuilderConfigValid(config), [config]);
|
const valid = useMemo(() => isBuilderConfigValid(config), [config]);
|
||||||
const warnings = useMemo(() => builderWarnings(config, rowCount), [config, rowCount]);
|
const warnings = useMemo(
|
||||||
|
() => builderWarnings(config, rowCount, columns),
|
||||||
|
[config, rowCount, columns],
|
||||||
|
);
|
||||||
const canSort = useMemo(() => supportsSort(config), [config]);
|
const canSort = useMemo(() => supportsSort(config), [config]);
|
||||||
const canStack = useMemo(() => supportsStack(config), [config]);
|
const canStack = useMemo(() => supportsStack(config), [config]);
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
import { CURRENT_DATASET_VERSION, type DataSource, type Dataset } from '@core/dataset';
|
import { CURRENT_DATASET_VERSION, type DataSource, type Dataset } from '@core/dataset';
|
||||||
import type { DataFormat } from '@core/format-detection';
|
import type { DataFormat } from '@core/format-detection';
|
||||||
|
import type { ColumnStats } from '@core/profile';
|
||||||
import type { ColumnType } from '@core/type-inference';
|
import type { ColumnType } from '@core/type-inference';
|
||||||
|
|
||||||
const FORMATS: ReadonlyArray<DataFormat> = ['json', 'csv', 'tsv', 'topojson'];
|
const FORMATS: ReadonlyArray<DataFormat> = ['json', 'csv', 'tsv', 'topojson'];
|
||||||
@@ -44,6 +45,10 @@ export function migrateDataset(raw: unknown): Dataset {
|
|||||||
columnTypes: Array.isArray(r.columnTypes)
|
columnTypes: Array.isArray(r.columnTypes)
|
||||||
? (r.columnTypes as Array<{ name: string; type: ColumnType }>)
|
? (r.columnTypes as Array<{ name: string; type: ColumnType }>)
|
||||||
: [],
|
: [],
|
||||||
|
// Records predating cardinality/extent profiling default to empty stats; the
|
||||||
|
// Chart Builder's data-aware hints simply stay quiet until the dataset is
|
||||||
|
// re-saved (which re-profiles it) — graceful, no forced re-profile on read.
|
||||||
|
columnStats: Array.isArray(r.columnStats) ? (r.columnStats as ColumnStats[]) : [],
|
||||||
size: typeof r.size === 'number' ? r.size : 0,
|
size: typeof r.size === 'number' ? r.size : 0,
|
||||||
created: typeof r.created === 'string' ? r.created : new Date(0).toISOString(),
|
created: typeof r.created === 'string' ? r.created : new Date(0).toISOString(),
|
||||||
modified: typeof r.modified === 'string' ? r.modified : new Date(0).toISOString(),
|
modified: typeof r.modified === 'string' ? r.modified : new Date(0).toISOString(),
|
||||||
|
|||||||
Binary file not shown.
@@ -223,6 +223,206 @@ describe('builderWarnings (Tier B advisories)', () => {
|
|||||||
expect(hint?.message).toMatch(/reduce the number of categories/);
|
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', () => {
|
describe('defaultBuilderConfig', () => {
|
||||||
|
|||||||
+113
-30
@@ -22,6 +22,8 @@
|
|||||||
* editor and preview consume — `buildSnippetSpecText` serializes it.
|
* editor and preview consume — `buildSnippetSpecText` serializes it.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { ColumnStats } from './profile';
|
||||||
|
import { DISTINCT_CAP } from './profile';
|
||||||
import type { ColumnType } from './type-inference';
|
import type { ColumnType } from './type-inference';
|
||||||
import { VEGA_LITE_SCHEMA_URL } from './snippet';
|
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
|
* 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
|
* (`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.
|
* 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
|
* (Negative-value exclusion, `hard.lp:56`, needs row data — `builderWarnings`
|
||||||
* data-aware layer; this type-level gate is what the builder enforces.)
|
* 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 {
|
export function isChannelTypeAllowed(channel: ChannelName, type: FieldType): boolean {
|
||||||
if (channel === 'size') return type === 'quantitative' || type === 'ordinal';
|
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 {
|
export interface BuilderColumns {
|
||||||
columns: readonly string[];
|
columns: readonly string[];
|
||||||
columnTypes: ReadonlyArray<{ name: string; type: ColumnType }>;
|
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. */
|
/** 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;
|
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
|
* 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
|
* 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.
|
* 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.
|
* 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;
|
* `rowCount` (the dataset's row count, when known) powers the one-mark-per-row hint;
|
||||||
* pass it from the loaded dataset. Omitted/`null` (URL or non-tabular data) simply
|
* `columns` (with per-column `columnStats`) powers the data-aware hints — crowded
|
||||||
* skips that one hint.
|
* 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 warnings: BuilderWarning[] = [];
|
||||||
const x = config.encodings.x ?? null;
|
const x = config.encodings.x ?? null;
|
||||||
const y = config.encodings.y ?? 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
|
// Crowded category axis: a bar/line/area whose discrete category axis carries too
|
||||||
// *raw* (un-aggregated, un-binned) measure draws one mark — and one axis label —
|
// many entries to label legibly. Two ways it happens, mutually exclusive:
|
||||||
// per row, so a large dataset becomes an unreadable picket fence of labels (the
|
// 1. A *raw* (un-aggregated, un-binned) measure draws one mark per row, so the
|
||||||
// builder's own default does this: first column on X, second on Y, no aggregate).
|
// label count == rowCount — flagged from rowCount alone (the builder's own
|
||||||
// We can only flag the un-aggregated case, where mark-count == rowCount exactly;
|
// default does this: first column on X, second on Y, no aggregate).
|
||||||
// an *aggregated* axis that still has many distinct categories needs per-column
|
// 2. Otherwise (aggregated/binned), one mark per *category* — crowded only when
|
||||||
// distinct counts we don't profile yet (backlog A2/A3). The fix follows the canon:
|
// the category column itself has many distinct values, which needs the
|
||||||
// aggregate the measure to one mark per category, or — for a bar — flip to a
|
// profiled cardinality (columnStats) and is skipped without it.
|
||||||
// horizontal bar where long labels stay readable (FT Visual Vocabulary: bar is
|
// The remedy follows the canon: for case 1 aggregate to one mark per category; for
|
||||||
// "good when … labels have long category names"; Datawrapper: long category lists
|
// either, a bar can flip to horizontal where long labels stay readable (FT Visual
|
||||||
// belong on a horizontal bar).
|
// Vocabulary: bar is "good when … labels have long category names"; Datawrapper:
|
||||||
if (
|
// long category lists belong on a horizontal bar).
|
||||||
(mark === 'bar' || mark === 'line' || mark === 'area') &&
|
if (mark === 'bar' || mark === 'line' || mark === 'area') {
|
||||||
typeof rowCount === 'number' &&
|
const category = sortableCategoryChannel(config); // discrete axis of a category-vs-measure pair
|
||||||
rowCount > CROWDED_CATEGORY_ROWS
|
|
||||||
) {
|
|
||||||
const category = sortableCategoryChannel(config); // the discrete axis of a category-vs-measure pair
|
|
||||||
const measure = category ? config.encodings[category === 'x' ? 'y' : 'x'] : null;
|
const measure = category ? config.encodings[category === 'x' ? 'y' : 'x'] : null;
|
||||||
if (category && measure && !measure.aggregate && !measure.bin) {
|
if (category && measure) {
|
||||||
const fix =
|
const rawMeasure = !measure.aggregate && !measure.bin;
|
||||||
mark === 'bar'
|
if (rawMeasure && typeof rowCount === 'number' && rowCount > CROWDED_CATEGORY_ROWS) {
|
||||||
? '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.'
|
const fix =
|
||||||
: 'Aggregate the measure (e.g. Sum or Mean) so there is one mark per category, or reduce the number of categories.';
|
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({
|
warnings.push({
|
||||||
channel: category,
|
channel: 'color',
|
||||||
message: `This draws one mark per row (${rowCount} in this dataset), so the category-axis labels will overlap. ${fix}`,
|
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.`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-4
@@ -18,7 +18,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { DataFormat } from './format-detection';
|
import type { DataFormat } from './format-detection';
|
||||||
import { profileData, type DatasetProfile } from './profile';
|
import { profileData, type ColumnStats, type DatasetProfile } from './profile';
|
||||||
import type { ColumnType } from './type-inference';
|
import type { ColumnType } from './type-inference';
|
||||||
|
|
||||||
/** Current schema version for a Dataset record (read-time migration target). */
|
/** Current schema version for a Dataset record (read-time migration target). */
|
||||||
@@ -53,6 +53,8 @@ export interface Dataset {
|
|||||||
columns: string[];
|
columns: string[];
|
||||||
/** Per-column inferred type. */
|
/** Per-column inferred type. */
|
||||||
columnTypes: Array<{ name: string; type: ColumnType }>;
|
columnTypes: Array<{ name: string; type: ColumnType }>;
|
||||||
|
/** Per-column cardinality + numeric range (empty for URL / non-tabular). */
|
||||||
|
columnStats: ColumnStats[];
|
||||||
/** Approximate payload size in bytes. */
|
/** Approximate payload size in bytes. */
|
||||||
size: number;
|
size: number;
|
||||||
/** ISO timestamp — when first added. */
|
/** ISO timestamp — when first added. */
|
||||||
@@ -243,9 +245,10 @@ export interface CreateDatasetOptions {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a Dataset: stamps version/timestamps and runs `computeDatasetProfile` to
|
* Create a Dataset: stamps version/timestamps and runs `computeDatasetProfile` to
|
||||||
* fill the derived summary fields. `id` defaults to `Date.now()` (numeric;
|
* fill the derived summary fields. `id` defaults to `Date.now()` (numeric) and is
|
||||||
* collision-prone for batch creation — see the DatasetStore.add TODO) and is
|
* injectable for tests; note that `DatasetStore` is the id authority and reassigns
|
||||||
* injectable for tests.
|
* a collision-free id on insertion (`nextDatasetId`), so this default never reaches
|
||||||
|
* storage through the normal create paths.
|
||||||
*/
|
*/
|
||||||
export function createDataset(options: CreateDatasetOptions): Dataset {
|
export function createDataset(options: CreateDatasetOptions): Dataset {
|
||||||
const now = options.now ?? new Date();
|
const now = options.now ?? new Date();
|
||||||
@@ -264,6 +267,7 @@ export function createDataset(options: CreateDatasetOptions): Dataset {
|
|||||||
columnCount: profile.columnCount,
|
columnCount: profile.columnCount,
|
||||||
columns: profile.columns,
|
columns: profile.columns,
|
||||||
columnTypes: profile.columnTypes,
|
columnTypes: profile.columnTypes,
|
||||||
|
columnStats: profile.columnStats,
|
||||||
size: profile.size,
|
size: profile.size,
|
||||||
created: iso,
|
created: iso,
|
||||||
modified: iso,
|
modified: iso,
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ function datasetRecord(over: Partial<Dataset> = {}): Dataset {
|
|||||||
{ name: 'a', type: 'number' },
|
{ name: 'a', type: 'number' },
|
||||||
{ name: 'b', type: 'number' },
|
{ name: 'b', type: 'number' },
|
||||||
],
|
],
|
||||||
|
columnStats: [
|
||||||
|
{ name: 'a', distinct: 1, distinctCapped: false, numericExtent: { min: 1, max: 1 } },
|
||||||
|
{ name: 'b', distinct: 1, distinctCapped: false, numericExtent: { min: 2, max: 2 } },
|
||||||
|
],
|
||||||
size: 7,
|
size: 7,
|
||||||
created: '2025-01-01T00:00:00.000Z',
|
created: '2025-01-01T00:00:00.000Z',
|
||||||
modified: '2025-01-01T00:00:00.000Z',
|
modified: '2025-01-01T00:00:00.000Z',
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
import { CURRENT_DATASET_VERSION, type DataSource, type Dataset } from './dataset';
|
import { CURRENT_DATASET_VERSION, type DataSource, type Dataset } from './dataset';
|
||||||
import type { DataFormat } from './format-detection';
|
import type { DataFormat } from './format-detection';
|
||||||
import { makeUniqueName } from './naming';
|
import { makeUniqueName } from './naming';
|
||||||
|
import type { ColumnStats } from './profile';
|
||||||
import { CURRENT_SNIPPET_VERSION, type Snippet } from './snippet';
|
import { CURRENT_SNIPPET_VERSION, type Snippet } from './snippet';
|
||||||
import { extractDatasetRefs, recomputeDatasetRefs, renameDatasetInSpec } from './spec-refs';
|
import { extractDatasetRefs, recomputeDatasetRefs, renameDatasetInSpec } from './spec-refs';
|
||||||
import type { ColumnType } from './type-inference';
|
import type { ColumnType } from './type-inference';
|
||||||
@@ -173,6 +174,7 @@ function normalizeDataset(raw: unknown, nowIso: string): Dataset {
|
|||||||
columnTypes: Array.isArray(r.columnTypes)
|
columnTypes: Array.isArray(r.columnTypes)
|
||||||
? (r.columnTypes as Array<{ name: string; type: ColumnType }>)
|
? (r.columnTypes as Array<{ name: string; type: ColumnType }>)
|
||||||
: [],
|
: [],
|
||||||
|
columnStats: Array.isArray(r.columnStats) ? (r.columnStats as ColumnStats[]) : [],
|
||||||
size: typeof r.size === 'number' ? r.size : 0,
|
size: typeof r.size === 'number' ? r.size : 0,
|
||||||
created,
|
created,
|
||||||
modified,
|
modified,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from 'vitest';
|
import { describe, expect, test } from 'vitest';
|
||||||
import { profileData } from './profile';
|
import { DISTINCT_CAP, profileData } from './profile';
|
||||||
|
|
||||||
describe('profileData', () => {
|
describe('profileData', () => {
|
||||||
test('profiles a JSON-array dataset fully', () => {
|
test('profiles a JSON-array dataset fully', () => {
|
||||||
@@ -53,3 +53,47 @@ describe('profileData', () => {
|
|||||||
expect(profile.columnTypes).toEqual([{ name: 'v', type: 'number' }]);
|
expect(profile.columnTypes).toEqual([{ name: 'v', type: 'number' }]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('profileData — column stats (cardinality + numeric extent)', () => {
|
||||||
|
test('counts distinct values and reports numeric extent for number columns', () => {
|
||||||
|
const rows = [
|
||||||
|
{ region: 'N', sales: 10 },
|
||||||
|
{ region: 'S', sales: -4 },
|
||||||
|
{ region: 'N', sales: 25 }, // region repeats → 2 distinct, not 3
|
||||||
|
];
|
||||||
|
const stats = profileData(rows, 0).columnStats;
|
||||||
|
expect(stats).toEqual([
|
||||||
|
{ name: 'region', distinct: 2, distinctCapped: false, numericExtent: null },
|
||||||
|
{ name: 'sales', distinct: 3, distinctCapped: false, numericExtent: { min: -4, max: 25 } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('distinct counting is empty-insensitive and case/whitespace literal', () => {
|
||||||
|
const rows = [{ c: 'A' }, { c: ' A ' }, { c: '' }, { c: null }, { c: 'b' }];
|
||||||
|
// ' A ' trims to 'A' (one key); '' and null are empties (no signal) → 2 distinct.
|
||||||
|
expect(profileData(rows, 0).columnStats[0]).toEqual({
|
||||||
|
name: 'c',
|
||||||
|
distinct: 2,
|
||||||
|
distinctCapped: false,
|
||||||
|
numericExtent: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('caps distinct at DISTINCT_CAP and flags the overflow', () => {
|
||||||
|
const rows = Array.from({ length: DISTINCT_CAP + 25 }, (_, i) => ({ id: `v${i}` }));
|
||||||
|
const stat = profileData(rows, 0).columnStats[0];
|
||||||
|
expect(stat.distinct).toBe(DISTINCT_CAP);
|
||||||
|
expect(stat.distinctCapped).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('numericExtent is null for a non-numeric column even if some cells parse', () => {
|
||||||
|
// A 'string' column (one non-numeric value knocks the type down) carries no extent.
|
||||||
|
const rows = [{ v: '1' }, { v: '2' }, { v: 'oops' }];
|
||||||
|
const stat = profileData(rows, 0).columnStats[0];
|
||||||
|
expect(stat.numericExtent).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('N/A profile carries empty column stats', () => {
|
||||||
|
expect(profileData(null, 9).columnStats).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+83
-9
@@ -19,7 +19,7 @@
|
|||||||
* determinism (arch 06 §4).
|
* determinism (arch 06 §4).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { inferColumnType, type ColumnType } from './type-inference';
|
import { inferColumnType, isEmpty, isNumeric, type ColumnType } from './type-inference';
|
||||||
|
|
||||||
export interface ColumnTypeInfo {
|
export interface ColumnTypeInfo {
|
||||||
/** The column name. */
|
/** The column name. */
|
||||||
@@ -28,6 +28,33 @@ export interface ColumnTypeInfo {
|
|||||||
type: ColumnType;
|
type: ColumnType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cap on distinct values counted per column. Past this the exact count stops
|
||||||
|
* mattering for the chart-guidance hints (a legend or category axis with 50
|
||||||
|
* entries is already unreadable), so counting stops and `distinctCapped` flags
|
||||||
|
* that the true cardinality is at least this. Sampled like type inference (head of
|
||||||
|
* the data), so it is an estimate — fine for soft advisories, not a guarantee.
|
||||||
|
*/
|
||||||
|
export const DISTINCT_CAP = 50;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-column cardinality + numeric range, derived in the same sample pass as type
|
||||||
|
* inference (arch 06 §4) to power the Chart Builder's data-aware Tier-B hints:
|
||||||
|
* a crowded colour legend / category axis (`distinct`), and the negative-value
|
||||||
|
* Size guard (`numericExtent`). Distinct from `ColumnTypeInfo` because it answers
|
||||||
|
* "what shape is this data" rather than "what type to encode it as".
|
||||||
|
*/
|
||||||
|
export interface ColumnStats {
|
||||||
|
/** Column name (matches a `columns` entry). */
|
||||||
|
name: string;
|
||||||
|
/** Distinct non-empty values in the sample, capped at `DISTINCT_CAP`. */
|
||||||
|
distinct: number;
|
||||||
|
/** True when `distinct` hit the cap — the real cardinality is ≥ `DISTINCT_CAP`. */
|
||||||
|
distinctCapped: boolean;
|
||||||
|
/** Min/max over numeric values, for numeric columns only; `null` otherwise. */
|
||||||
|
numericExtent: { min: number; max: number } | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DatasetProfile {
|
export interface DatasetProfile {
|
||||||
/** Data rows, or `null` when N/A (URL / non-tabular). */
|
/** Data rows, or `null` when N/A (URL / non-tabular). */
|
||||||
rowCount: number | null;
|
rowCount: number | null;
|
||||||
@@ -37,6 +64,8 @@ export interface DatasetProfile {
|
|||||||
columns: string[];
|
columns: string[];
|
||||||
/** Per-column inferred type. */
|
/** Per-column inferred type. */
|
||||||
columnTypes: ColumnTypeInfo[];
|
columnTypes: ColumnTypeInfo[];
|
||||||
|
/** Per-column cardinality + numeric range (empty when N/A). */
|
||||||
|
columnStats: ColumnStats[];
|
||||||
/** Approximate payload size in bytes. */
|
/** Approximate payload size in bytes. */
|
||||||
size: number;
|
size: number;
|
||||||
}
|
}
|
||||||
@@ -47,6 +76,7 @@ const naProfile = (size: number): DatasetProfile => ({
|
|||||||
columnCount: null,
|
columnCount: null,
|
||||||
columns: [],
|
columns: [],
|
||||||
columnTypes: [],
|
columnTypes: [],
|
||||||
|
columnStats: [],
|
||||||
size,
|
size,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -59,6 +89,45 @@ const SAMPLE_SIZE = 200;
|
|||||||
const sampleRows = <T>(rows: ReadonlyArray<T>): ReadonlyArray<T> =>
|
const sampleRows = <T>(rows: ReadonlyArray<T>): ReadonlyArray<T> =>
|
||||||
rows.length <= SAMPLE_SIZE ? rows : rows.slice(0, SAMPLE_SIZE);
|
rows.length <= SAMPLE_SIZE ? rows : rows.slice(0, SAMPLE_SIZE);
|
||||||
|
|
||||||
|
/** A canonical key for distinct-counting (trim strings; stringify the rest). */
|
||||||
|
const distinctKey = (v: unknown): string => (typeof v === 'string' ? v.trim() : String(v));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cardinality + numeric extent for one column's sampled values. Distinct counting
|
||||||
|
* stops growing the set once it passes `DISTINCT_CAP` (the extra value reveals the
|
||||||
|
* overflow), but the extent scan continues over every value. Extent is reported
|
||||||
|
* only for `number` columns — the only ones the Size channel can encode — using
|
||||||
|
* the same numeric test as type inference, so the two never disagree.
|
||||||
|
*/
|
||||||
|
function columnStatsFor(name: string, values: readonly unknown[], type: ColumnType): ColumnStats {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
let capped = false;
|
||||||
|
let min = Infinity;
|
||||||
|
let max = -Infinity;
|
||||||
|
let sawNumber = false;
|
||||||
|
|
||||||
|
for (const v of values) {
|
||||||
|
if (isEmpty(v)) continue;
|
||||||
|
if (!capped) {
|
||||||
|
seen.add(distinctKey(v));
|
||||||
|
if (seen.size > DISTINCT_CAP) capped = true;
|
||||||
|
}
|
||||||
|
if (type === 'number' && isNumeric(v)) {
|
||||||
|
sawNumber = true;
|
||||||
|
const n = typeof v === 'number' ? v : Number(String(v).trim());
|
||||||
|
if (n < min) min = n;
|
||||||
|
if (n > max) max = n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
distinct: capped ? DISTINCT_CAP : seen.size,
|
||||||
|
distinctCapped: capped,
|
||||||
|
numericExtent: sawNumber ? { min, max } : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Profile a dataset payload. `rows` is the tabular form (CSV/TSV/JSON-array)
|
* Profile a dataset payload. `rows` is the tabular form (CSV/TSV/JSON-array)
|
||||||
* already parsed to rows-of-objects, or `null` for URL / non-tabular data;
|
* already parsed to rows-of-objects, or `null` for URL / non-tabular data;
|
||||||
@@ -84,20 +153,25 @@ export function profileData(
|
|||||||
if (columns.length === 0) return naProfile(size);
|
if (columns.length === 0) return naProfile(size);
|
||||||
|
|
||||||
const sample = sampleRows(rows);
|
const sample = sampleRows(rows);
|
||||||
// TODO (backlog: docs/chart-builder-research.md §8 — A3/A4 enabler): in this same
|
// One pass per column over the sample: the inferred display type, plus the
|
||||||
// sample pass, also derive a capped per-column distinct count (cardinality, cap ~50)
|
// cardinality + numeric extent that power the Chart Builder's data-aware hints
|
||||||
// and numeric extent (min/max → sign), surfaced on DatasetProfile, to power the Chart
|
// (crowded legend / category axis, negative-value Size guard — see chart-builder
|
||||||
// Builder's crowded-legend / high-cardinality warnings and its negative-value Size guard.
|
// `builderWarnings`; docs/chart-builder-research.md §8).
|
||||||
const columnTypes = columns.map((name) => ({
|
const columnTypes: ColumnTypeInfo[] = [];
|
||||||
name,
|
const columnStats: ColumnStats[] = [];
|
||||||
type: inferColumnType(sample.map((r) => r[name])),
|
for (const name of columns) {
|
||||||
}));
|
const values = sample.map((r) => r[name]);
|
||||||
|
const type = inferColumnType(values);
|
||||||
|
columnTypes.push({ name, type });
|
||||||
|
columnStats.push(columnStatsFor(name, values, type));
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rowCount: rows.length,
|
rowCount: rows.length,
|
||||||
columnCount: columns.length,
|
columnCount: columns.length,
|
||||||
columns,
|
columns,
|
||||||
columnTypes,
|
columnTypes,
|
||||||
|
columnStats,
|
||||||
size,
|
size,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,11 +24,11 @@
|
|||||||
export type ColumnType = 'number' | 'string' | 'date' | 'boolean';
|
export type ColumnType = 'number' | 'string' | 'date' | 'boolean';
|
||||||
|
|
||||||
/** Empty cells (null/undefined/whitespace-only string) carry no type signal. */
|
/** Empty cells (null/undefined/whitespace-only string) carry no type signal. */
|
||||||
const isEmpty = (v: unknown): boolean =>
|
export const isEmpty = (v: unknown): boolean =>
|
||||||
v === null || v === undefined || (typeof v === 'string' && v.trim() === '');
|
v === null || v === undefined || (typeof v === 'string' && v.trim() === '');
|
||||||
|
|
||||||
/** Native numbers pass when finite; strings must parse to a finite, non-NaN number. */
|
/** Native numbers pass when finite; strings must parse to a finite, non-NaN number. */
|
||||||
const isNumeric = (v: unknown): boolean => {
|
export const isNumeric = (v: unknown): boolean => {
|
||||||
if (typeof v === 'number') return Number.isFinite(v);
|
if (typeof v === 'number') return Number.isFinite(v);
|
||||||
if (typeof v !== 'string') return false;
|
if (typeof v !== 'string') return false;
|
||||||
const t = v.trim();
|
const t = v.trim();
|
||||||
|
|||||||
Reference in New Issue
Block a user