mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Add aggregation, binning, granularity, sort, and stacking to the Chart Builder
- Per-channel transforms: aggregate (sum/mean/median/min/max), quantitative bin, and temporal timeUnit granularity; bin and aggregate are mutually exclusive. A field-less "Count of records" measure (Voyager's count(*)). - Chart-level sort (rank a categorical axis by its measure) and stacking (zero / 100% normalize), each shown only when it applies. - Field type is a fixed N|O|Q|T segmented control with the column's invalid types disabled; SegmentedControl gains APG-correct disabled options. - A crowded-category-axis warning (a raw measure drawing one mark per row over a large dataset) and a disabled-Create hint (says why it's disabled). - Drop the Create success toast — the new snippet is immediately visible. - Docs: spec §06, research-doc §8 backlog (incl. the cardinality/extent profiling TODO), architecture 01 (stable-selector rule) and 05 (builder-local preview), and a profiling breadcrumb.
This commit is contained in:
@@ -80,9 +80,20 @@
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.channelRow {
|
||||
/* Each channel is a small block: a top row (label + column) and, when mapped, a
|
||||
controls row (N|O|Q|T type + contextual transforms). */
|
||||
.channel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
border: var(--border-width) solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.channelTop {
|
||||
display: grid;
|
||||
grid-template-columns: 48px 1fr auto;
|
||||
grid-template-columns: 44px 1fr;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
@@ -93,8 +104,15 @@
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.select,
|
||||
.typeSelect {
|
||||
.channelControls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding-left: 52px;
|
||||
}
|
||||
|
||||
.select {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: var(--border-width) solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
@@ -102,14 +120,52 @@
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.typeSelect {
|
||||
/* The N|O|Q|T type control: monospace abbreviations so the four segments line up. */
|
||||
.typeSeg button {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
}
|
||||
|
||||
.transform {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.miniLabel {
|
||||
font-size: 11px;
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
|
||||
.mini {
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border: var(--border-width) solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.chartControls {
|
||||
display: flex;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.select:focus-visible,
|
||||
.typeSelect:focus-visible,
|
||||
.mini:focus-visible,
|
||||
.dimInput:focus-visible {
|
||||
outline: 2px solid var(--focus);
|
||||
outline-offset: -1px;
|
||||
@@ -168,6 +224,15 @@
|
||||
color: var(--support-warning, var(--text-secondary));
|
||||
}
|
||||
|
||||
/* Explains the disabled Create action (contract 10: a disabled control must say
|
||||
why). `margin-top: auto` pins it just above the actions so the two read as one. */
|
||||
.createHint {
|
||||
margin: auto 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
@@ -2,12 +2,16 @@
|
||||
* Chart Builder — the modal body (spec §06).
|
||||
*
|
||||
* A two-pane composer: left is the configuration (dataset name, mark selector, one
|
||||
* row per channel, optional dimensions, guidance, Create), right is a live preview
|
||||
* of the spec the configuration produces. All spec logic and Tier-B defaults/guards
|
||||
* come from `@core/chart-builder` via `ChartBuilderStore`; this component is the
|
||||
* view. The preview is builder-local (its own debounced render over the shared
|
||||
* `chart-renderer` service) rather than a reuse of `LivePreview`, which is bound to
|
||||
* the snippet editor's stores.
|
||||
* block per channel, chart-level sort/stacking, optional dimensions, guidance,
|
||||
* Create), right is a live preview of the spec the configuration produces. All spec
|
||||
* logic and Tier-B defaults/guards come from `@core/chart-builder` via
|
||||
* `ChartBuilderStore`; this component is the view. Each channel is a small block:
|
||||
* a column dropdown (with a field-less "Count of records" option), a fixed
|
||||
* `N | O | Q | T` field-type segmented control (the column's invalid types are
|
||||
* disabled), and the transforms that apply to its type (aggregate + bin for a
|
||||
* measure, granularity for a temporal field). The preview is builder-local (its own
|
||||
* debounced render over the shared `chart-renderer` service) rather than a reuse of
|
||||
* `LivePreview`, which is bound to the snippet editor's stores.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
@@ -15,15 +19,25 @@ import { useShallow } from 'zustand/react/shallow';
|
||||
import type { VisualizationSpec } from 'vega-embed';
|
||||
import {
|
||||
CHANNELS,
|
||||
FIELD_TYPES,
|
||||
MARK_TYPES,
|
||||
TIME_UNITS,
|
||||
builderWarnings,
|
||||
defaultFieldType,
|
||||
isBuilderConfigValid,
|
||||
isChannelTypeAllowed,
|
||||
supportsAggregate,
|
||||
supportsBin,
|
||||
supportsSort,
|
||||
supportsStack,
|
||||
supportsTimeUnit,
|
||||
validFieldTypes,
|
||||
type AggregateOp,
|
||||
type ChannelMapping,
|
||||
type ChannelName,
|
||||
type FieldType,
|
||||
type MarkType,
|
||||
type TimeUnit,
|
||||
} from '@core/chart-builder';
|
||||
import type { ColumnType } from '@core/type-inference';
|
||||
import { DatasetNotFoundError, prepareSpecForRender } from '@core/rendering';
|
||||
@@ -33,6 +47,7 @@ import { closeModal } from '../modals/ModalCoordinator';
|
||||
import { useAppStore } from '../stores/AppStore';
|
||||
import { useDatasetStore } from '../stores/DatasetStore';
|
||||
import {
|
||||
COUNT_FIELD,
|
||||
selectBuilderSpecText,
|
||||
selectBuilderValid,
|
||||
useChartBuilderStore,
|
||||
@@ -42,7 +57,7 @@ import styles from './ChartBuilderModal.module.css';
|
||||
|
||||
const RENDER_DEBOUNCE_MS = 300;
|
||||
|
||||
/** Title-case a token for display (e.g. `bar` → `Bar`, `quantitative` → `Quantitative`). */
|
||||
/** Title-case a token for display (e.g. `bar` → `Bar`, `sum` → `Sum`). */
|
||||
function titleCase(s: string): string {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
@@ -59,7 +74,33 @@ const CHANNEL_LABELS: Record<ChannelName, string> = {
|
||||
size: 'Size',
|
||||
};
|
||||
|
||||
/** A compact type indicator for a column option (text · # · date · ✓). */
|
||||
/** The fixed N | O | Q | T field-type segments (terse, with full-name tooltips). */
|
||||
const TYPE_ORDER: readonly FieldType[] = ['nominal', 'ordinal', 'quantitative', 'temporal'];
|
||||
const TYPE_ABBR: Record<FieldType, string> = {
|
||||
nominal: 'N',
|
||||
ordinal: 'O',
|
||||
quantitative: 'Q',
|
||||
temporal: 'T',
|
||||
};
|
||||
|
||||
/** Non-count aggregate operators offered for a quantitative field. */
|
||||
const FIELD_AGGREGATES: readonly AggregateOp[] = ['sum', 'mean', 'median', 'min', 'max'];
|
||||
|
||||
/** Friendly labels for each temporal granularity. */
|
||||
const TIME_UNIT_LABELS: Record<TimeUnit, string> = {
|
||||
year: 'Year',
|
||||
yearquarter: 'Year-Quarter',
|
||||
yearmonth: 'Year-Month',
|
||||
yearmonthdate: 'Year-Month-Day',
|
||||
quarter: 'Quarter',
|
||||
month: 'Month',
|
||||
week: 'Week',
|
||||
date: 'Day of month',
|
||||
day: 'Day of week',
|
||||
hours: 'Hour',
|
||||
};
|
||||
|
||||
/** A compact type indicator for a column option (# / date / bool / text). */
|
||||
function typeBadge(type: ColumnType): string {
|
||||
switch (type) {
|
||||
case 'number':
|
||||
@@ -78,62 +119,145 @@ function columnAllowedOnChannel(channel: ChannelName, colType: ColumnType): bool
|
||||
return isChannelTypeAllowed(channel, defaultFieldType(colType));
|
||||
}
|
||||
|
||||
function ChannelRow({ channel }: { channel: ChannelName }) {
|
||||
/** True when the mapping is the field-less Count-of-records measure. */
|
||||
function isCount(mapping: ChannelMapping | null): boolean {
|
||||
return !!mapping && mapping.aggregate === 'count' && mapping.field === undefined;
|
||||
}
|
||||
|
||||
function ChannelBlock({ channel }: { channel: ChannelName }) {
|
||||
const columns = useChartBuilderStore((s) => s.columns);
|
||||
const mapping = useChartBuilderStore((s) => s.config.encodings[channel] ?? null);
|
||||
const setChannelColumn = useChartBuilderStore((s) => s.setChannelColumn);
|
||||
const setChannelType = useChartBuilderStore((s) => s.setChannelType);
|
||||
const setChannelAggregate = useChartBuilderStore((s) => s.setChannelAggregate);
|
||||
const setChannelBin = useChartBuilderStore((s) => s.setChannelBin);
|
||||
const setChannelTimeUnit = useChartBuilderStore((s) => s.setChannelTimeUnit);
|
||||
|
||||
const colTypeOf = (name: string): ColumnType =>
|
||||
columns.columnTypes.find((c) => c.name === name)?.type ?? 'string';
|
||||
|
||||
// Type options valid for this column AND allowed on this channel (e.g. Size hides
|
||||
// Nominal). Shown only when >1 option and a column is selected (spec §06).
|
||||
const typeOptions: FieldType[] = mapping
|
||||
? validFieldTypes(colTypeOf(mapping.field)).filter((t) => isChannelTypeAllowed(channel, t))
|
||||
: [];
|
||||
// The fixed N|O|Q|T control: a column's invalid types and types disallowed on this
|
||||
// channel (e.g. a category on Size) are disabled, never hidden, so the control keeps
|
||||
// one shape on every channel (APG radio with disabled options).
|
||||
const typeSegments: ReadonlyArray<SegmentedOption<FieldType>> = useMemo(() => {
|
||||
const valid = mapping?.field !== undefined ? validFieldTypes(colTypeOf(mapping.field)) : [];
|
||||
return TYPE_ORDER.filter((t) => FIELD_TYPES.includes(t)).map((t) => ({
|
||||
value: t,
|
||||
label: TYPE_ABBR[t],
|
||||
title: titleCase(t),
|
||||
disabled: !(valid.includes(t) && isChannelTypeAllowed(channel, t)),
|
||||
}));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [channel, mapping?.field, columns]);
|
||||
|
||||
const selectValue =
|
||||
mapping === null ? '' : isCount(mapping) ? COUNT_FIELD : (mapping.field ?? '');
|
||||
|
||||
return (
|
||||
<div className={styles.channelRow}>
|
||||
<label className={styles.channelLabel} htmlFor={`ch-${channel}`}>
|
||||
{CHANNEL_LABELS[channel]}
|
||||
</label>
|
||||
<select
|
||||
id={`ch-${channel}`}
|
||||
className={styles.select}
|
||||
value={mapping?.field ?? ''}
|
||||
onChange={(e) => setChannelColumn(channel, e.target.value === '' ? null : e.target.value)}
|
||||
>
|
||||
<option value="">None</option>
|
||||
{columns.columns.map((name) => {
|
||||
const allowed = columnAllowedOnChannel(channel, colTypeOf(name));
|
||||
return (
|
||||
<option key={name} value={name} disabled={!allowed}>
|
||||
{name} · {typeBadge(colTypeOf(name))}
|
||||
{allowed ? '' : ' (needs a measure)'}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
|
||||
{mapping && typeOptions.length > 1 && (
|
||||
<div className={styles.channel}>
|
||||
<div className={styles.channelTop}>
|
||||
<span className={styles.channelLabel}>{CHANNEL_LABELS[channel]}</span>
|
||||
<select
|
||||
className={styles.typeSelect}
|
||||
aria-label={`${CHANNEL_LABELS[channel]} field type`}
|
||||
value={mapping.type}
|
||||
onChange={(e) => setChannelType(channel, e.target.value as FieldType)}
|
||||
className={styles.select}
|
||||
aria-label={`${CHANNEL_LABELS[channel]} column`}
|
||||
value={selectValue}
|
||||
onChange={(e) => setChannelColumn(channel, e.target.value === '' ? null : e.target.value)}
|
||||
>
|
||||
{typeOptions.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{titleCase(t)}
|
||||
</option>
|
||||
))}
|
||||
<option value="">None</option>
|
||||
<option value={COUNT_FIELD}>Count of records</option>
|
||||
{columns.columns.map((name) => {
|
||||
const allowed = columnAllowedOnChannel(channel, colTypeOf(name));
|
||||
return (
|
||||
<option key={name} value={name} disabled={!allowed}>
|
||||
{name} · {typeBadge(colTypeOf(name))}
|
||||
{allowed ? '' : ' (needs a measure)'}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{mapping && !isCount(mapping) && (
|
||||
<div className={styles.channelControls}>
|
||||
<SegmentedControl
|
||||
label={`${CHANNEL_LABELS[channel]} field type`}
|
||||
options={typeSegments}
|
||||
value={mapping.type}
|
||||
onChange={(t) => setChannelType(channel, t)}
|
||||
className={styles.typeSeg}
|
||||
/>
|
||||
|
||||
{supportsAggregate(mapping.type) && (
|
||||
<label className={styles.transform}>
|
||||
<span className={styles.miniLabel}>Aggregate</span>
|
||||
<select
|
||||
className={styles.mini}
|
||||
value={mapping.aggregate && mapping.aggregate !== 'count' ? mapping.aggregate : ''}
|
||||
onChange={(e) =>
|
||||
setChannelAggregate(
|
||||
channel,
|
||||
(e.target.value || undefined) as AggregateOp | undefined,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">None</option>
|
||||
{FIELD_AGGREGATES.map((op) => (
|
||||
<option key={op} value={op}>
|
||||
{titleCase(op)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{supportsBin(mapping.type) && (
|
||||
<label className={styles.toggle}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!mapping.bin}
|
||||
onChange={(e) => setChannelBin(channel, e.target.checked)}
|
||||
/>
|
||||
Bin
|
||||
</label>
|
||||
)}
|
||||
|
||||
{supportsTimeUnit(mapping.type) && (
|
||||
<label className={styles.transform}>
|
||||
<span className={styles.miniLabel}>Granularity</span>
|
||||
<select
|
||||
className={styles.mini}
|
||||
value={mapping.timeUnit ?? ''}
|
||||
onChange={(e) =>
|
||||
setChannelTimeUnit(channel, (e.target.value || undefined) as TimeUnit | undefined)
|
||||
}
|
||||
>
|
||||
<option value="">None (raw)</option>
|
||||
{TIME_UNITS.map((u) => (
|
||||
<option key={u} value={u}>
|
||||
{TIME_UNIT_LABELS[u]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Sort control values: 'none' maps to an unsorted config. */
|
||||
const SORT_OPTIONS: ReadonlyArray<SegmentedOption<'none' | 'ascending' | 'descending'>> = [
|
||||
{ value: 'none', label: 'None' },
|
||||
{ value: 'ascending', label: 'Asc' },
|
||||
{ value: 'descending', label: 'Desc' },
|
||||
];
|
||||
|
||||
const STACK_OPTIONS: ReadonlyArray<SegmentedOption<'zero' | 'normalize'>> = [
|
||||
{ value: 'zero', label: 'Stacked' },
|
||||
{ value: 'normalize', label: '100%' },
|
||||
];
|
||||
|
||||
function BuilderPreview() {
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const handleRef = useRef<RenderHandle | null>(null);
|
||||
@@ -150,8 +274,6 @@ function BuilderPreview() {
|
||||
const timer = setTimeout(() => {
|
||||
void (async () => {
|
||||
const mine = ++generationRef.current;
|
||||
// Below validation there is nothing to draw — clear the chart and show the
|
||||
// configuration prompt, not an error (spec §06 → Live Preview placeholder).
|
||||
if (!valid) {
|
||||
handleRef.current?.destroy();
|
||||
handleRef.current = null;
|
||||
@@ -189,7 +311,6 @@ function BuilderPreview() {
|
||||
return () => clearTimeout(timer);
|
||||
}, [specText, valid, uiTheme, datasets]);
|
||||
|
||||
// Finalize the view on unmount so the Vega view and its listeners don't leak.
|
||||
useEffect(
|
||||
() => () => {
|
||||
handleRef.current?.destroy();
|
||||
@@ -221,24 +342,30 @@ export function ChartBuilderModal() {
|
||||
const mark = useChartBuilderStore((s) => s.config.mark);
|
||||
const width = useChartBuilderStore((s) => s.config.width);
|
||||
const height = useChartBuilderStore((s) => s.config.height);
|
||||
const sort = useChartBuilderStore((s) => s.config.sort);
|
||||
const stack = useChartBuilderStore((s) => s.config.stack);
|
||||
const setMark = useChartBuilderStore((s) => s.setMark);
|
||||
const swapXY = useChartBuilderStore((s) => s.swapXY);
|
||||
const setSort = useChartBuilderStore((s) => s.setSort);
|
||||
const setStack = useChartBuilderStore((s) => s.setStack);
|
||||
const setWidth = useChartBuilderStore((s) => s.setWidth);
|
||||
const setHeight = useChartBuilderStore((s) => s.setHeight);
|
||||
const runCreate = useChartBuilderStore((s) => s.createSnippet);
|
||||
// Derive validity + guidance from the stable `config` reference via useMemo, NOT
|
||||
// from a store selector: `builderWarnings` builds a fresh array of objects each
|
||||
// call, which no selector-equality (even useShallow, since the element objects
|
||||
// differ every time) can stabilize — subscribing to it would re-render forever.
|
||||
|
||||
// Validity + guidance + which chart-level controls apply are derived from the
|
||||
// stable `config` reference via useMemo, NOT a store selector that would build a
|
||||
// fresh array each render (which loops useSyncExternalStore — see SnippetStore note).
|
||||
const config = useChartBuilderStore((s) => s.config);
|
||||
const rowCount = useChartBuilderStore((s) => s.rowCount);
|
||||
const valid = useMemo(() => isBuilderConfigValid(config), [config]);
|
||||
const warnings = useMemo(() => builderWarnings(config), [config]);
|
||||
const warnings = useMemo(() => builderWarnings(config, rowCount), [config, rowCount]);
|
||||
const canSort = useMemo(() => supportsSort(config), [config]);
|
||||
const canStack = useMemo(() => supportsStack(config), [config]);
|
||||
|
||||
if (datasetId === null) {
|
||||
return <p className={styles.muted}>No dataset loaded. Open this from a dataset in Datasets.</p>;
|
||||
}
|
||||
|
||||
/** Parse a dimension input: blank → undefined, otherwise a non-negative integer. */
|
||||
const parseDim = (raw: string): number | undefined => {
|
||||
if (raw.trim() === '') return undefined;
|
||||
const n = Number(raw);
|
||||
@@ -270,12 +397,39 @@ export function ChartBuilderModal() {
|
||||
</button>
|
||||
</div>
|
||||
{CHANNELS.map((channel) => (
|
||||
<ChannelRow key={channel} channel={channel} />
|
||||
<ChannelBlock key={channel} channel={channel} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(canSort || canStack) && (
|
||||
<div className={styles.chartControls}>
|
||||
{canSort && (
|
||||
<div className={styles.field}>
|
||||
<span className={styles.fieldLabel}>Sort</span>
|
||||
<SegmentedControl
|
||||
label="Sort the category axis by its measure"
|
||||
options={SORT_OPTIONS}
|
||||
value={sort ?? 'none'}
|
||||
onChange={(v) => setSort(v === 'none' ? undefined : v)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{canStack && (
|
||||
<div className={styles.field}>
|
||||
<span className={styles.fieldLabel}>Stacking</span>
|
||||
<SegmentedControl
|
||||
label="Stacking mode"
|
||||
options={STACK_OPTIONS}
|
||||
value={stack ?? 'zero'}
|
||||
onChange={setStack}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.dimensions}>
|
||||
<span className={styles.fieldLabel}>Dimensions (optional)</span>
|
||||
<span className={styles.fieldLabel}>Size (optional)</span>
|
||||
<div className={styles.dimInputs}>
|
||||
<label className={styles.dimField}>
|
||||
<span>Width</span>
|
||||
@@ -312,6 +466,11 @@ export function ChartBuilderModal() {
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{!valid && (
|
||||
<p id="cb-create-hint" className={styles.createHint}>
|
||||
Map at least one channel to a column to create a snippet.
|
||||
</p>
|
||||
)}
|
||||
<div className={styles.actions}>
|
||||
<button type="button" className={styles.action} onClick={() => void closeModal()}>
|
||||
Cancel
|
||||
@@ -320,6 +479,7 @@ export function ChartBuilderModal() {
|
||||
type="button"
|
||||
className={`${styles.action} ${styles.primary}`}
|
||||
disabled={!valid}
|
||||
aria-describedby={!valid ? 'cb-create-hint' : undefined}
|
||||
onClick={() => runCreate()}
|
||||
>
|
||||
Create Snippet
|
||||
|
||||
@@ -40,3 +40,11 @@
|
||||
background: var(--layer-02);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Disabled option: perceivable but inert (dimmed, no hover affordance). */
|
||||
.option[aria-disabled='true'],
|
||||
.option[aria-disabled='true']:hover {
|
||||
color: var(--text-placeholder);
|
||||
background: var(--bg);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,15 @@ import styles from './SegmentedControl.module.css';
|
||||
export interface SegmentedOption<T extends string> {
|
||||
value: T;
|
||||
label: string;
|
||||
/**
|
||||
* When true the option is shown but not selectable: `aria-disabled`, never the
|
||||
* roving tab stop, skipped by Arrow/Home/End, and ignores clicks (APG radio —
|
||||
* disabled options remain perceivable but inert). The selected value must never
|
||||
* be a disabled option (callers keep the active value valid).
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/** Full accessible name + hover tooltip when `label` is a terse abbreviation. */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface SegmentedControlProps<T extends string> {
|
||||
@@ -47,28 +56,38 @@ export function SegmentedControl<T extends string>({
|
||||
}: SegmentedControlProps<T>) {
|
||||
const refs = useRef<Array<HTMLButtonElement | null>>([]);
|
||||
|
||||
/** Select the option at `index` (wrapping) and move focus to it (APG radio). */
|
||||
const selectAt = (index: number) => {
|
||||
const next = (index + options.length) % options.length;
|
||||
onChange(options[next].value);
|
||||
refs.current[next]?.focus();
|
||||
/**
|
||||
* Select the first **enabled** option reached from `from` stepping by `dir`
|
||||
* (wrapping), and move focus to it (APG radio). Skips disabled options so arrow
|
||||
* keys never land on an inert segment; a group with no enabled option is a no-op.
|
||||
*/
|
||||
const move = (from: number, dir: 1 | -1) => {
|
||||
const n = options.length;
|
||||
for (let step = 1; step <= n; step++) {
|
||||
const idx = (((from + dir * step) % n) + n) % n;
|
||||
if (!options[idx].disabled) {
|
||||
onChange(options[idx].value);
|
||||
refs.current[idx]?.focus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent, index: number) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowRight':
|
||||
case 'ArrowDown':
|
||||
selectAt(index + 1);
|
||||
move(index, 1);
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
case 'ArrowUp':
|
||||
selectAt(index - 1);
|
||||
move(index, -1);
|
||||
break;
|
||||
case 'Home':
|
||||
selectAt(0);
|
||||
move(-1, 1); // first enabled from the start
|
||||
break;
|
||||
case 'End':
|
||||
selectAt(options.length - 1);
|
||||
move(0, -1); // last enabled from the end
|
||||
break;
|
||||
default:
|
||||
return; // not ours — let it bubble
|
||||
@@ -93,9 +112,13 @@ export function SegmentedControl<T extends string>({
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
// Roving tabindex: only the selected option is a tab stop; arrows
|
||||
// move within the group.
|
||||
tabIndex={selected ? 0 : -1}
|
||||
aria-disabled={opt.disabled || undefined}
|
||||
aria-label={opt.title}
|
||||
title={opt.title}
|
||||
// Roving tabindex: only the selected (always enabled) option is a tab
|
||||
// stop; arrows move within the group, skipping disabled options. A
|
||||
// disabled option is never a tab stop.
|
||||
tabIndex={selected && !opt.disabled ? 0 : -1}
|
||||
className={[
|
||||
styles.option,
|
||||
optionClassName,
|
||||
@@ -104,7 +127,9 @@ export function SegmentedControl<T extends string>({
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onClick={() => onChange(opt.value)}
|
||||
onClick={() => {
|
||||
if (!opt.disabled) onChange(opt.value);
|
||||
}}
|
||||
onKeyDown={(e) => onKeyDown(e, i)}
|
||||
>
|
||||
{opt.label}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, test } from 'vitest';
|
||||
import { createDataset } from '@core/dataset';
|
||||
import { useChartBuilderStore } from './ChartBuilderStore';
|
||||
import { COUNT_FIELD, useChartBuilderStore } from './ChartBuilderStore';
|
||||
import { useDatasetStore } from './DatasetStore';
|
||||
import { useSnippetStore } from './SnippetStore';
|
||||
|
||||
@@ -71,6 +71,60 @@ describe('channel editing', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('transforms — aggregate / bin / timeUnit / count', () => {
|
||||
test('the Count-of-records option maps a field-less count measure', () => {
|
||||
const id = seedDataset('S', [{ region: 'N', revenue: 5 }]);
|
||||
cb().init(id);
|
||||
cb().setChannelColumn('y', COUNT_FIELD);
|
||||
expect(cb().config.encodings.y).toEqual({ type: 'quantitative', aggregate: 'count' });
|
||||
});
|
||||
|
||||
test('aggregate and bin are mutually exclusive on a channel', () => {
|
||||
const id = seedDataset('S', [{ revenue: 5 }]);
|
||||
cb().init(id);
|
||||
cb().setChannelColumn('y', 'revenue');
|
||||
cb().setChannelAggregate('y', 'sum');
|
||||
expect(cb().config.encodings.y).toMatchObject({ field: 'revenue', aggregate: 'sum' });
|
||||
cb().setChannelBin('y', true);
|
||||
expect(cb().config.encodings.y?.aggregate).toBeUndefined();
|
||||
expect(cb().config.encodings.y?.bin).toBe(true);
|
||||
cb().setChannelAggregate('y', 'mean');
|
||||
expect(cb().config.encodings.y?.bin).toBeUndefined();
|
||||
expect(cb().config.encodings.y?.aggregate).toBe('mean');
|
||||
});
|
||||
|
||||
test('changing field type drops transforms that no longer apply', () => {
|
||||
const id = seedDataset('S', [{ price: 5 }]);
|
||||
cb().init(id);
|
||||
cb().setChannelColumn('x', 'price');
|
||||
cb().setChannelBin('x', true);
|
||||
cb().setChannelType('x', 'nominal'); // leaving quantitative
|
||||
expect(cb().config.encodings.x).toEqual({ field: 'price', type: 'nominal' });
|
||||
});
|
||||
|
||||
test('setChannelTimeUnit sets and clears granularity', () => {
|
||||
const id = seedDataset('S', [{ day: '2026-01-01', v: 1 }]);
|
||||
cb().init(id);
|
||||
cb().setChannelTimeUnit('x', 'yearmonth');
|
||||
expect(cb().config.encodings.x?.timeUnit).toBe('yearmonth');
|
||||
cb().setChannelTimeUnit('x', undefined);
|
||||
expect(cb().config.encodings.x?.timeUnit).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sort / stack', () => {
|
||||
test('setSort and setStack set and clear the chart-level fields', () => {
|
||||
const id = seedDataset('S', [{ region: 'N', revenue: 5 }]);
|
||||
cb().init(id);
|
||||
cb().setSort('descending');
|
||||
expect(cb().config.sort).toBe('descending');
|
||||
cb().setSort(undefined);
|
||||
expect(cb().config.sort).toBeUndefined();
|
||||
cb().setStack('normalize');
|
||||
expect(cb().config.stack).toBe('normalize');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSnippet', () => {
|
||||
test('builds a linked snippet, activates it, and resets the builder', () => {
|
||||
const id = seedDataset('Sales', [
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user