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:
@@ -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