mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Chart builder: intent front door, Heatmap mark, role-aware guidance
This commit is contained in:
@@ -58,6 +58,79 @@
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
/* ── Intent front door (spec §06 → Intent) ───────────────────────────────
|
||||
A persistent "what do you want to show?" strip under the dataset picker. A
|
||||
quiet accent-soft wash marks it as the guided on-ramp without competing with
|
||||
the primary Create action (--accent stays reserved for primary actions). */
|
||||
.intentStrip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-4);
|
||||
border: var(--border-width) solid var(--border);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.intentQ {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.intentSub {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.intentChips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.intentChip {
|
||||
appearance: none;
|
||||
height: var(--control-height);
|
||||
border: var(--border-width) solid var(--border-strong);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
padding: 0 var(--space-3);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--dur-fast) var(--ease),
|
||||
box-shadow var(--dur-fast) var(--ease);
|
||||
}
|
||||
|
||||
.intentChip:hover {
|
||||
background: var(--field);
|
||||
}
|
||||
|
||||
/* The chip whose layout the live chart matches (council-reserved: selection uses an
|
||||
accent ring, never an accent fill — fill stays the primary-action signal). */
|
||||
.intentChip[aria-pressed='true'] {
|
||||
border-color: var(--accent);
|
||||
box-shadow: inset 0 0 0 1px var(--accent);
|
||||
}
|
||||
|
||||
/* Disabled = the dataset can't satisfy this intent: perceivable but inert (the chip's
|
||||
accessible name carries the reason). */
|
||||
.intentChip[aria-disabled='true'] {
|
||||
color: var(--text-placeholder);
|
||||
border-color: var(--border);
|
||||
background: var(--bg);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.intentChip:focus-visible {
|
||||
outline: 2px solid var(--focus);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* ── Data section: filters, calculated fields, row preview (spec §06 → Data) ──── */
|
||||
|
||||
.dataSection {
|
||||
@@ -346,6 +419,18 @@
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
/* The mark picker carries six segments — too many for one 320–360px row. Override the
|
||||
SegmentedControl's fixed single-row height so it wraps; each option keeps the control
|
||||
height so the two rows read as even segments inside the shared border. */
|
||||
.markPicker {
|
||||
flex-wrap: wrap;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.markPickerOption {
|
||||
min-height: var(--control-height);
|
||||
}
|
||||
|
||||
.fieldLabel {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
|
||||
@@ -475,4 +475,117 @@ describe('ChartBuilderModal', () => {
|
||||
// A colour picker renders for the constant.
|
||||
expect(container.querySelector('input[type="color"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
// The intent front door's core logic (which chip lights, what each applies, gating)
|
||||
// is covered in core/store; these check only the parts that live in the component —
|
||||
// the toolbar's roving tabindex and arrow navigation (a hand-rolled handler, not a
|
||||
// shared primitive), and that a click reshapes the chart.
|
||||
describe('intent front door (the "What do you want to show?" strip)', () => {
|
||||
// Two categories + one date + two measures → every intent applies; enough shape to
|
||||
// light a default and to exercise an applied intent (Heatmap needs two categories).
|
||||
const seedSuperstore = () => {
|
||||
const ds = createDataset({
|
||||
name: 'Superstore',
|
||||
data: [
|
||||
{ region: 'E', segment: 'A', date: '2026-01-01', sales: 5, profit: 1 },
|
||||
{ region: 'W', segment: 'B', date: '2026-02-01', sales: 9, profit: 3 },
|
||||
],
|
||||
format: 'json',
|
||||
source: 'inline',
|
||||
now: T,
|
||||
});
|
||||
useDatasetStore.getState().add(ds);
|
||||
const id = useDatasetStore.getState().datasets[0].id;
|
||||
useChartBuilderStore.getState().init(id);
|
||||
return id;
|
||||
};
|
||||
|
||||
const chips = (): HTMLButtonElement[] =>
|
||||
Array.from(container.querySelectorAll<HTMLButtonElement>('[role="toolbar"] button'));
|
||||
|
||||
test('renders one tab stop and lights the active intent (roving tabindex)', async () => {
|
||||
seedSuperstore();
|
||||
await act(async () => {
|
||||
root.render(<ChartBuilderModal />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const all = chips();
|
||||
expect(all.length).toBe(7); // every intent shows (Tableau Show Me: never hidden)
|
||||
// Exactly one chip is in the tab order; the rest are roving (-1).
|
||||
expect(all.filter((b) => b.tabIndex === 0)).toHaveLength(1);
|
||||
// The smart default for a category+count shape is Compare, so its chip is pressed.
|
||||
const pressed = all.filter((b) => b.getAttribute('aria-pressed') === 'true');
|
||||
expect(pressed).toHaveLength(1);
|
||||
expect(pressed[0].textContent).toBe('Compare');
|
||||
});
|
||||
|
||||
test('an inapplicable intent is disabled and names its reason', async () => {
|
||||
// One category, one measure, no date and no second measure → Correlation/Time/
|
||||
// Heatmap/Part-to-whole cannot apply.
|
||||
const ds = createDataset({
|
||||
name: 'Thin',
|
||||
data: [{ region: 'E', sales: 5 }],
|
||||
format: 'json',
|
||||
source: 'inline',
|
||||
now: T,
|
||||
});
|
||||
useDatasetStore.getState().add(ds);
|
||||
const id = useDatasetStore.getState().datasets[0].id;
|
||||
useChartBuilderStore.getState().init(id);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<ChartBuilderModal />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const correlation = chips().find((b) => b.textContent === 'Correlation')!;
|
||||
expect(correlation.getAttribute('aria-disabled')).toBe('true');
|
||||
expect(correlation.getAttribute('aria-label')).toMatch(/needs two number columns/);
|
||||
});
|
||||
|
||||
test('clicking an enabled chip reshapes the chart to that intent', async () => {
|
||||
seedSuperstore();
|
||||
await act(async () => {
|
||||
root.render(<ChartBuilderModal />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const heatmap = chips().find((b) => b.textContent === 'Heatmap')!;
|
||||
await act(async () => {
|
||||
heatmap.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(useChartBuilderStore.getState().config.mark).toBe('rect');
|
||||
expect(useChartBuilderStore.getState().config.encodings.color).toEqual({
|
||||
type: 'quantitative',
|
||||
aggregate: 'count',
|
||||
});
|
||||
});
|
||||
|
||||
test('ArrowRight moves focus along the toolbar without applying (focus-only)', async () => {
|
||||
seedSuperstore();
|
||||
await act(async () => {
|
||||
root.render(<ChartBuilderModal />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const all = chips();
|
||||
const start = all.findIndex((b) => b.tabIndex === 0);
|
||||
const markBefore = useChartBuilderStore.getState().config.mark;
|
||||
|
||||
await act(async () => {
|
||||
all[start].focus();
|
||||
all[start].dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }),
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// Focus moved to the next chip; the chart is untouched (arrows navigate, Enter applies).
|
||||
expect(document.activeElement).toBe(all[(start + 1) % all.length]);
|
||||
expect(useChartBuilderStore.getState().config.mark).toBe(markBefore);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,14 +23,17 @@ import { useShallow } from 'zustand/react/shallow';
|
||||
import type { VisualizationSpec } from 'vega-embed';
|
||||
import {
|
||||
CHANNELS,
|
||||
CHART_INTENTS,
|
||||
MARK_TYPES,
|
||||
TIME_UNITS,
|
||||
activeIntent,
|
||||
builderWarnings,
|
||||
channelAcceptsValue,
|
||||
defaultChannelValue,
|
||||
defaultFieldType,
|
||||
effectiveColumns,
|
||||
filterOpArity,
|
||||
intentApplicable,
|
||||
isBuilderConfigValid,
|
||||
isChannelTypeAllowed,
|
||||
isColumnAllowedOnChannel,
|
||||
@@ -50,6 +53,7 @@ import {
|
||||
type BuilderWarningFix,
|
||||
type ChannelMapping,
|
||||
type ChannelName,
|
||||
type ChartIntent,
|
||||
type FieldType,
|
||||
type FilterOp,
|
||||
type MarkType,
|
||||
@@ -113,11 +117,46 @@ function titleCase(s: string): string {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
/** User-facing mark labels. All but `rect` are their geometry name title-cased; `rect`
|
||||
* is shown as **Heatmap** — the chart it makes, since "Rect" is opaque to the no-JSON
|
||||
* audience the builder serves (the spec's mark-set table notes the rect↔heatmap pairing). */
|
||||
const MARK_LABELS: Record<MarkType, string> = {
|
||||
bar: 'Bar',
|
||||
line: 'Line',
|
||||
point: 'Point',
|
||||
area: 'Area',
|
||||
circle: 'Circle',
|
||||
rect: 'Heatmap',
|
||||
};
|
||||
|
||||
const MARK_OPTIONS: ReadonlyArray<SegmentedOption<MarkType>> = MARK_TYPES.map((m) => ({
|
||||
value: m,
|
||||
label: titleCase(m),
|
||||
label: MARK_LABELS[m],
|
||||
title: m === 'rect' ? 'Heatmap (rect mark)' : undefined,
|
||||
}));
|
||||
|
||||
/** Intent front-door copy (spec §06 → Intent). Label = the chip; needs = what the
|
||||
* dataset must have for the intent to apply (shown when the chip is disabled). */
|
||||
const INTENT_LABELS: Record<ChartIntent, string> = {
|
||||
compare: 'Compare',
|
||||
ranking: 'Ranking',
|
||||
time: 'Change over time',
|
||||
correlation: 'Correlation',
|
||||
distribution: 'Distribution',
|
||||
partToWhole: 'Part-to-whole',
|
||||
heatmap: 'Heatmap',
|
||||
};
|
||||
|
||||
const INTENT_NEEDS: Record<ChartIntent, string> = {
|
||||
compare: 'a category column',
|
||||
ranking: 'a category column',
|
||||
time: 'a date column',
|
||||
correlation: 'two number columns',
|
||||
distribution: 'a number column',
|
||||
partToWhole: 'two category columns',
|
||||
heatmap: 'two category columns',
|
||||
};
|
||||
|
||||
const CHANNEL_LABELS: Record<ChannelName, string> = {
|
||||
x: 'X',
|
||||
y: 'Y',
|
||||
@@ -1277,6 +1316,112 @@ function DatasetPicker({ datasetId }: { datasetId: number | null }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The intent-first front door (spec §06 → Intent): a persistent "what do you want to
|
||||
* show?" strip pinned under the dataset picker. Each chip applies a recommended mark +
|
||||
* channel layout — the "do it for me" (Tableau "Show Me"). Intents the dataset can't
|
||||
* satisfy are disabled (with the reason in their name), and the chip whose layout the
|
||||
* live chart matches stays highlighted; once the user edits away from any, none is — a
|
||||
* "Custom" chart. It seeds the builder; it never gates it (the user can ignore it and
|
||||
* drive the channels directly). The active intent is derived from the config, so no
|
||||
* intent state is stored.
|
||||
*/
|
||||
function IntentStrip() {
|
||||
const columns = useChartBuilderStore((s) => s.columns);
|
||||
const config = useChartBuilderStore((s) => s.config);
|
||||
const setIntent = useChartBuilderStore((s) => s.setIntent);
|
||||
|
||||
const active = useMemo(() => activeIntent(config, columns), [config, columns]);
|
||||
const applicable = useMemo(
|
||||
() => new Set(CHART_INTENTS.filter((i) => intentApplicable(i, columns))),
|
||||
[columns],
|
||||
);
|
||||
|
||||
// APG toolbar (arch 10 §5): the chips are a single tab stop with a roving tabindex,
|
||||
// NOT seven independently-tabbable buttons (the pane-toggle precedent). Arrows MOVE
|
||||
// focus only — Enter/Space activates — because applying an
|
||||
// intent reshapes the whole chart; a radiogroup's select-on-arrow would do that on
|
||||
// every keypress. Disabled chips stay arrow-reachable so their "needs …" reason is
|
||||
// discoverable (APG: focusable disabled controls where discoverability is crucial).
|
||||
const btnRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
||||
const tabStop = useMemo(() => {
|
||||
if (active) return CHART_INTENTS.indexOf(active); // an active intent is always enabled
|
||||
const firstEnabled = CHART_INTENTS.findIndex((i) => applicable.has(i));
|
||||
return firstEnabled >= 0 ? firstEnabled : 0;
|
||||
}, [active, applicable]);
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent, index: number) => {
|
||||
const n = CHART_INTENTS.length;
|
||||
let next: number;
|
||||
switch (e.key) {
|
||||
case 'ArrowRight':
|
||||
case 'ArrowDown':
|
||||
next = (index + 1) % n;
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
case 'ArrowUp':
|
||||
next = (index - 1 + n) % n;
|
||||
break;
|
||||
case 'Home':
|
||||
next = 0;
|
||||
break;
|
||||
case 'End':
|
||||
next = n - 1;
|
||||
break;
|
||||
default:
|
||||
return; // not ours — let it bubble (Enter/Space activate the native button)
|
||||
}
|
||||
e.preventDefault();
|
||||
btnRefs.current[next]?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.intentStrip}>
|
||||
<span className={styles.intentQ} id="cb-intent-q">
|
||||
What do you want to show?
|
||||
</span>
|
||||
<p className={styles.intentSub}>
|
||||
{active ? (
|
||||
<>
|
||||
Starting point: <strong>{INTENT_LABELS[active]}</strong>. Pick another, or adjust the
|
||||
controls below.
|
||||
</>
|
||||
) : (
|
||||
<>Pick a starting point and we will set the chart up — or build it yourself below.</>
|
||||
)}
|
||||
</p>
|
||||
<div className={styles.intentChips} role="toolbar" aria-labelledby="cb-intent-q">
|
||||
{CHART_INTENTS.map((intent, i) => {
|
||||
const enabled = applicable.has(intent);
|
||||
return (
|
||||
<button
|
||||
key={intent}
|
||||
ref={(el) => {
|
||||
btnRefs.current[i] = el;
|
||||
}}
|
||||
type="button"
|
||||
className={styles.intentChip}
|
||||
aria-pressed={active === intent}
|
||||
aria-disabled={!enabled || undefined}
|
||||
aria-label={
|
||||
enabled ? undefined : `${INTENT_LABELS[intent]} — needs ${INTENT_NEEDS[intent]}`
|
||||
}
|
||||
title={enabled ? undefined : `Needs ${INTENT_NEEDS[intent]}`}
|
||||
tabIndex={i === tabStop ? 0 : -1}
|
||||
onKeyDown={(e) => onKeyDown(e, i)}
|
||||
onClick={() => {
|
||||
if (enabled) setIntent(intent);
|
||||
}}
|
||||
>
|
||||
{INTENT_LABELS[intent]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The no-datasets state (spec §06 → Opening; Carbon no-data empty state): says
|
||||
* what the builder does and offers the one next step — never a dead end. The
|
||||
@@ -1377,6 +1522,8 @@ export function ChartBuilderModal() {
|
||||
</div>
|
||||
<DatasetPicker datasetId={datasetId} />
|
||||
|
||||
<IntentStrip />
|
||||
|
||||
<DataSection />
|
||||
|
||||
<div className={styles.field}>
|
||||
@@ -1386,6 +1533,10 @@ export function ChartBuilderModal() {
|
||||
options={MARK_OPTIONS}
|
||||
value={mark}
|
||||
onChange={setMark}
|
||||
// Six marks don't fit one 320–360px row: wrap to two, each segment kept at
|
||||
// the control height (the base look is fixed-height, single-row).
|
||||
className={styles.markPicker}
|
||||
optionClassName={styles.markPickerOption}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user