Chart builder: field-first shelf + value-or-field channels

This commit is contained in:
2026-06-11 23:56:40 +03:00
parent 9ebe398e75
commit 4dcff4601d
11 changed files with 1192 additions and 184 deletions
+342 -57
View File
@@ -390,18 +390,6 @@
color: var(--text-secondary);
}
.channels {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.channelsHeader {
display: flex;
align-items: baseline;
justify-content: space-between;
}
.swap {
border: none;
background: transparent;
@@ -422,56 +410,13 @@
outline-offset: 1px;
}
/* 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: 44px 1fr;
align-items: center;
gap: var(--space-2);
}
/* Channel name label (Marks card rows + the on-chart Columns/Rows shelves). */
.channelLabel {
font-size: 12px;
font-weight: 600;
color: var(--text);
}
.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);
background: var(--bg);
color: var(--text);
font: inherit;
font-size: 13px;
width: 100%;
}
/* 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;
@@ -506,7 +451,6 @@
gap: var(--space-5);
}
.select:focus-visible,
.mini:focus-visible,
.dimInput:focus-visible {
outline: 2px solid var(--focus);
@@ -672,9 +616,19 @@
/* ── Right: live preview ─────────────────────────────────────────────── */
/* Right side = on-chart shelves stacked above the live preview (spec §06). */
.previewSide {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
background: var(--bg);
}
.previewPane {
display: flex;
flex-direction: column;
flex: 1;
padding: var(--space-5);
min-width: 0;
min-height: 0;
@@ -741,3 +695,334 @@
white-space: pre-wrap;
word-break: break-word;
}
/* ── Encoding: field shelf + Tableau-style shelves/pills (spec §06 → Encoding) ─── */
/* Field shelf — the dataset's columns as clickable, type-glyphed chips (field-first). */
.fieldShelf {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.shelfGroupHead {
margin-top: var(--space-2);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-secondary);
}
.shelfList {
display: flex;
flex-direction: column;
gap: 2px;
}
.shelfField {
display: flex;
align-items: center;
gap: var(--space-2);
width: 100%;
padding: var(--space-2) var(--space-3);
border: var(--border-width) solid var(--border);
border-radius: var(--radius);
background: var(--bg);
color: var(--text);
font: inherit;
font-size: 13px;
text-align: left;
cursor: pointer;
}
.shelfField:hover {
border-color: var(--accent);
background: var(--layer-01);
}
.shelfField:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 1px;
}
.shelfFieldUsed {
opacity: 0.55;
}
.shelfGlyph {
min-width: 22px;
padding: 1px var(--space-2);
border: var(--border-width) solid var(--border-strong);
background: var(--layer-01);
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 11px;
text-align: center;
}
.shelfFieldName {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Marks card — the non-positional encodings (Colour, Size). */
.marksCard {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-3);
border: var(--border-width) solid var(--border);
border-radius: var(--radius);
}
.marksRow {
display: grid;
grid-template-columns: 44px 1fr;
align-items: start;
gap: var(--space-2);
}
/* Pill — a bound channel (field/count or constant). */
.pillWrap {
display: flex;
flex-direction: column;
gap: var(--space-2);
min-width: 0;
}
.pill {
display: inline-flex;
align-items: center;
max-width: 100%;
background: var(--accent);
color: var(--accent-contrast);
border-radius: var(--radius);
}
.pillConst {
background: var(--layer-02);
color: var(--text);
}
.pillType {
padding: var(--space-2);
border: none;
background: color-mix(in srgb, #000 18%, var(--accent));
color: var(--accent-contrast);
font-family: var(--font-mono);
font-size: 11px;
line-height: 1;
cursor: pointer;
}
.pillType:hover:not(:disabled) {
background: color-mix(in srgb, #000 30%, var(--accent));
}
.pillType:disabled {
cursor: default;
opacity: 0.85;
}
.pillType:focus-visible {
outline: 2px solid var(--accent-contrast);
outline-offset: -2px;
}
.pillTag {
padding: var(--space-2);
color: var(--text-secondary);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.pillName {
min-width: 0;
padding: var(--space-2) var(--space-3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
}
.pillRemove {
display: inline-flex;
align-items: center;
justify-content: center;
padding: var(--space-2);
border: none;
background: transparent;
color: inherit;
cursor: pointer;
}
.pillRemove:hover {
background: color-mix(in srgb, var(--text) 14%, transparent);
}
.pillRemove:focus-visible {
outline: 2px solid var(--focus);
outline-offset: -2px;
}
.constColor {
width: 28px;
height: 22px;
margin: 0 var(--space-2);
padding: 0;
border: var(--border-width) solid var(--border-strong);
background: var(--bg);
cursor: pointer;
}
.constNumber {
width: 64px;
margin: 0 var(--space-2);
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;
}
.pillControls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
}
/* Empty assign target — armed by click, then filled by a clicked field. */
.slot {
display: flex;
align-items: center;
gap: var(--space-2);
min-height: 32px;
padding: var(--space-1) var(--space-2);
border: var(--border-width) dashed var(--border-strong);
background: var(--layer-01);
}
.slotActive {
border-style: solid;
border-color: var(--accent);
box-shadow: inset 0 0 0 1px var(--accent);
}
.slotAssign {
flex: 1;
min-width: 0;
padding: var(--space-1);
border: none;
background: transparent;
color: var(--text-placeholder);
font: inherit;
font-size: 12px;
text-align: left;
cursor: pointer;
}
.slotAssign:hover {
color: var(--text-secondary);
}
.slotAssign:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 1px;
}
.slotConst {
padding: var(--space-1);
border: none;
background: none;
color: var(--accent);
font: inherit;
font-size: 11px;
white-space: nowrap;
text-decoration: underline;
cursor: pointer;
}
.slotConst:hover {
color: var(--accent-hover);
}
.slotConst:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 1px;
}
/* On-chart Columns / Rows shelves (X/Y), stacked above the preview. */
.shelves {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-5) var(--space-5) 0;
}
.shelvesHead {
display: flex;
align-items: baseline;
justify-content: space-between;
}
.shelfStrip {
display: flex;
align-items: stretch;
min-height: 42px;
border: var(--border-width) solid var(--border);
}
.shelfName {
flex: 0 0 84px;
display: flex;
align-items: center;
padding: 0 var(--space-3);
border-right: var(--border-width) solid var(--border);
background: var(--layer-01);
color: var(--text-secondary);
font-size: 12px;
}
.shelfSlots {
flex: 1;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-3);
min-width: 0;
padding: var(--space-2) var(--space-3);
}
.shelfSlots .slot,
.shelfSlots .pillWrap {
min-width: 190px;
}
/* Reserved faceting placeholder (Phase 4) — non-interactive, signals where it'll live. */
.facetSlot {
display: flex;
align-items: center;
gap: var(--space-2);
min-width: 150px;
min-height: 32px;
padding: var(--space-1) var(--space-3);
border: var(--border-width) dashed var(--border);
color: var(--text-placeholder);
font-size: 12px;
user-select: none;
}
.facetTag {
padding: 0 var(--space-2);
border: var(--border-width) solid var(--border);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.03em;
}
@@ -296,4 +296,74 @@ describe('ChartBuilderModal', () => {
expect(refLink()).toBeDefined();
expect(refLink()!.getAttribute('href')).toContain('vega.github.io');
});
test('clicking a field in the shelf assigns it to a channel as a pill (field-first, 2B)', async () => {
const ds = createDataset({
name: 'Shop',
data: [{ region: 'E', sales: 5 }],
format: 'json',
source: 'inline',
now: T,
});
useDatasetStore.getState().add(ds);
const id = useDatasetStore.getState().datasets[0].id;
const store = useChartBuilderStore.getState();
store.init(id);
// Clear the smart-default axes so the click lands on the first empty channel (X).
store.setChannelColumn('x', null);
store.setChannelColumn('y', null);
await act(async () => {
root.render(<ChartBuilderModal />);
await Promise.resolve();
});
const fieldButton = Array.from(container.querySelectorAll('button')).find((b) =>
b.textContent?.includes('region'),
);
expect(fieldButton).toBeDefined();
await act(async () => {
fieldButton!.click();
await Promise.resolve();
});
expect(useChartBuilderStore.getState().config.encodings.x).toEqual({
field: 'region',
type: 'nominal',
});
});
test('Colour can be switched to a constant value (the Property model, 2A/2B)', async () => {
const ds = createDataset({
name: 'Shop',
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();
});
// The empty Colour slot offers an "or constant" affordance (Colour is first in Marks).
const constButton = Array.from(container.querySelectorAll('button')).find(
(b) => b.textContent === 'or constant',
);
expect(constButton).toBeDefined();
await act(async () => {
constButton!.click();
await Promise.resolve();
});
expect(useChartBuilderStore.getState().config.encodings.color?.value).toBeDefined();
// A colour picker renders for the constant.
expect(container.querySelector('input[type="color"]')).not.toBeNull();
});
});
+312 -79
View File
@@ -19,15 +19,17 @@ import { useShallow } from 'zustand/react/shallow';
import type { VisualizationSpec } from 'vega-embed';
import {
CHANNELS,
FIELD_TYPES,
MARK_TYPES,
TIME_UNITS,
builderWarnings,
channelAcceptsValue,
defaultChannelValue,
defaultFieldType,
effectiveColumns,
filterOpArity,
isBuilderConfigValid,
isChannelTypeAllowed,
isValueMapping,
supportsAggregate,
supportsBin,
supportsSort,
@@ -114,8 +116,7 @@ const CHANNEL_LABELS: Record<ChannelName, string> = {
size: 'Size',
};
/** The fixed N | O | Q | T field-type segments (terse, with full-name tooltips). */
const TYPE_ORDER: readonly FieldType[] = ['nominal', 'ordinal', 'quantitative', 'temporal'];
/** Terse N | O | Q | T abbreviations for a field type (with full-name tooltips). */
const TYPE_ABBR: Record<FieldType, string> = {
nominal: 'N',
ordinal: 'O',
@@ -205,86 +206,126 @@ function typeBadge(type: ColumnType): string {
}
}
/** Whether a column may be placed on a channel at all (Size discipline, §06). */
function columnAllowedOnChannel(channel: ChannelName, colType: ColumnType): boolean {
return isChannelTypeAllowed(channel, defaultFieldType(colType));
}
/** 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 baseColumns = useChartBuilderStore((s) => s.columns);
const calculates = useChartBuilderStore((s) => s.config.calculates);
// Effective columns = the dataset's columns plus any calculated fields, so a derived
// field is selectable on a channel like any real column.
const columns = useMemo(
() => effectiveColumns(baseColumns, calculates),
[baseColumns, calculates],
);
const mapping = useChartBuilderStore((s) => s.config.encodings[channel] ?? null);
/** Past this many columns the field shelf splits into Dimensions / Measures groups;
* below it (and unless both groups are non-empty) it stays a single flat list. */
const FIELD_SHELF_SPLIT_MIN = 7;
/** The field types a mapping may cycle through on its channel (its column's valid
* types, narrowed by the channel — e.g. Size keeps only the measure types). */
function channelTypeOptions(
channel: ChannelName,
mapping: ChannelMapping,
columns: BuilderColumns,
): FieldType[] {
if (mapping.field === undefined) return [];
const colType = columns.columnTypes.find((c) => c.name === mapping.field)?.type ?? 'string';
return validFieldTypes(colType).filter((t) => isChannelTypeAllowed(channel, t));
}
/**
* A bound channel rendered as a Tableau-style **pill**: a leading type chip, the field
* (or "Count") label, and a remove (×). The type chip *is the control* — clicking it
* cycles the field's type within the set valid for this channel (disabled when only one
* type applies, e.g. a date). Any applicable transforms (aggregate / bin / granularity)
* sit in a compact row beneath the pill. A **constant** binding (Colour/Size only)
* shows the value editor instead — a colour picker or a number — emitting `{ value }`.
*/
function ChannelPill({
channel,
mapping,
columns,
}: {
channel: ChannelName;
mapping: ChannelMapping;
columns: BuilderColumns;
}) {
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 setChannelConstant = useChartBuilderStore((s) => s.setChannelConstant);
const colTypeOf = (name: string): ColumnType =>
columns.columnTypes.find((c) => c.name === name)?.type ?? 'string';
const clearLabel = `Remove ${CHANNEL_LABELS[channel]}`;
const clear = () => setChannelColumn(channel, null);
// 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]);
// A constant value (the Property model) — Colour or Size only.
if (isValueMapping(mapping)) {
const isColor = channel === 'color';
return (
<div className={`${styles.pill} ${styles.pillConst}`}>
<span className={styles.pillTag}>value</span>
{isColor ? (
<input
type="color"
className={styles.constColor}
aria-label={`${CHANNEL_LABELS[channel]} constant colour`}
value={typeof mapping.value === 'string' ? mapping.value : '#000000'}
onChange={(e) => setChannelConstant(channel, e.target.value)}
/>
) : (
<input
type="number"
className={styles.constNumber}
aria-label={`${CHANNEL_LABELS[channel]} constant size`}
value={String(mapping.value ?? '')}
onChange={(e) => setChannelConstant(channel, e.target.value)}
/>
)}
<button type="button" className={styles.pillRemove} aria-label={clearLabel} onClick={clear}>
<Icon name="close" />
</button>
</div>
);
}
const selectValue =
mapping === null ? '' : isCount(mapping) ? COUNT_FIELD : (mapping.field ?? '');
const count = isCount(mapping);
const typeOptions = count ? [] : channelTypeOptions(channel, mapping, columns);
const canCycle = typeOptions.length > 1;
const currentType: FieldType = count ? 'quantitative' : mapping.type;
const label = count ? 'Count' : (mapping.field ?? '');
const cycleType = () => {
if (!canCycle) return;
const i = typeOptions.indexOf(mapping.type);
setChannelType(channel, typeOptions[(i + 1) % typeOptions.length]);
};
const hasTransforms =
!count &&
(supportsAggregate(mapping.type) ||
supportsBin(mapping.type) ||
supportsTimeUnit(mapping.type));
return (
<div className={styles.channel}>
<div className={styles.channelTop}>
<span className={styles.channelLabel}>{CHANNEL_LABELS[channel]}</span>
<select
className={styles.select}
aria-label={`${CHANNEL_LABELS[channel]} column`}
value={selectValue}
onChange={(e) => setChannelColumn(channel, e.target.value === '' ? null : e.target.value)}
<div className={styles.pillWrap}>
<div className={styles.pill}>
{/* TODO(ux-second-pass): the type chip cycles N→O→Q→T — no direct pick for
keyboard/SR users. Cycle vs. explicit radio is parked for a batched council
review (docs/ux-second-pass.md). */}
<button
type="button"
className={styles.pillType}
aria-label={`Field type: ${titleCase(currentType)}${canCycle ? ' — activate to change' : ''}`}
disabled={!canCycle}
onClick={cycleType}
>
<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>
{TYPE_ABBR[currentType]}
</button>
<span className={styles.pillName} title={label}>
{label}
</span>
<button type="button" className={styles.pillRemove} aria-label={clearLabel} onClick={clear}>
<Icon name="close" />
</button>
</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}
/>
{hasTransforms && (
<div className={styles.pillControls}>
{supportsAggregate(mapping.type) && (
<label className={styles.transform}>
<span className={styles.miniLabel}>Aggregate</span>
@@ -344,6 +385,203 @@ function ChannelBlock({ channel }: { channel: ChannelName }) {
);
}
/**
* One encoding target. When the channel is bound it shows its `ChannelPill`; when empty
* it is an **assign target** — a button that arms the channel (field-first: click it,
* then click a field in the shelf to fill it), plus an "or constant" affordance on the
* channels that take a fixed value (Colour/Size). `hint` is the empty-state prompt.
*/
function ChannelSlot({ channel, hint }: { channel: ChannelName; hint?: string }) {
const baseColumns = useChartBuilderStore((s) => s.columns);
const calculates = useChartBuilderStore((s) => s.config.calculates);
const columns = useMemo(
() => effectiveColumns(baseColumns, calculates),
[baseColumns, calculates],
);
const mapping = useChartBuilderStore((s) => s.config.encodings[channel] ?? null);
const active = useChartBuilderStore((s) => s.activeChannel === channel);
const focusChannel = useChartBuilderStore((s) => s.focusChannel);
const setChannelConstant = useChartBuilderStore((s) => s.setChannelConstant);
if (mapping) {
return <ChannelPill channel={channel} mapping={mapping} columns={columns} />;
}
return (
<div className={`${styles.slot} ${active ? styles.slotActive : ''}`}>
<button
type="button"
className={styles.slotAssign}
aria-pressed={active}
aria-label={`${CHANNEL_LABELS[channel]}: ${
active ? 'pick a field from the list' : 'arm to assign a field'
}`}
onClick={() => focusChannel(active ? null : channel)}
>
{active ? 'Pick a field…' : (hint ?? 'Add a field')}
</button>
{channelAcceptsValue(channel) && (
<button
type="button"
className={styles.slotConst}
onClick={() => setChannelConstant(channel, String(defaultChannelValue(channel)))}
>
or constant
</button>
)}
</div>
);
}
/**
* The field shelf (spec §06 → Encoding, field-first): the dataset's columns (plus any
* calculated fields and a field-less "Count of records") as clickable chips with a type
* glyph. Clicking a field assigns it to the armed channel, else the first empty channel
* that accepts it (`assignField`). Past `FIELD_SHELF_SPLIT_MIN` columns it groups into
* Dimensions (categories/dates) and Measures (numerics); a small dataset stays flat.
* Already-mapped fields are dimmed (a field may still be placed on several channels).
*/
function FieldShelf() {
const baseColumns = useChartBuilderStore((s) => s.columns);
const calculates = useChartBuilderStore((s) => s.config.calculates);
const encodings = useChartBuilderStore((s) => s.config.encodings);
const assignField = useChartBuilderStore((s) => s.assignField);
const columns = useMemo(
() => effectiveColumns(baseColumns, calculates),
[baseColumns, calculates],
);
const assigned = useMemo(() => {
const set = new Set<string>();
for (const ch of CHANNELS) {
const m = encodings[ch];
if (m?.field) set.add(m.field);
}
return set;
}, [encodings]);
const colTypeOf = (name: string): ColumnType =>
columns.columnTypes.find((c) => c.name === name)?.type ?? 'string';
const fieldButton = (name: string) => (
<button
key={name}
type="button"
className={`${styles.shelfField} ${assigned.has(name) ? styles.shelfFieldUsed : ''}`}
onClick={() => assignField(name)}
>
<span className={styles.shelfGlyph} aria-hidden="true">
{TYPE_ABBR[defaultFieldType(colTypeOf(name))]}
</span>
<span className={styles.shelfFieldName}>{name}</span>
</button>
);
const countButton = (
<button
key="__count"
type="button"
className={styles.shelfField}
onClick={() => assignField(COUNT_FIELD)}
>
<span className={styles.shelfGlyph} aria-hidden="true">
</span>
<span className={styles.shelfFieldName}>Count of records</span>
</button>
);
const dimensions = columns.columns.filter((n) => colTypeOf(n) !== 'number');
const measures = columns.columns.filter((n) => colTypeOf(n) === 'number');
const split =
columns.columns.length >= FIELD_SHELF_SPLIT_MIN && dimensions.length > 0 && measures.length > 0;
return (
<div className={styles.fieldShelf}>
<span className={styles.fieldLabel}>Fields</span>
{split ? (
<>
<span className={styles.shelfGroupHead}>Dimensions</span>
<div className={styles.shelfList}>{dimensions.map(fieldButton)}</div>
<span className={styles.shelfGroupHead}>Measures</span>
<div className={styles.shelfList}>
{measures.map(fieldButton)}
{countButton}
</div>
</>
) : (
<div className={styles.shelfList}>
{columns.columns.map(fieldButton)}
{countButton}
</div>
)}
</div>
);
}
/** The non-positional encodings (Colour, Size) — Tableau's "Marks" card. Each is a
* `ChannelSlot`, so it takes a field or a constant. */
function MarksCard() {
return (
<div className={styles.marksCard}>
<span className={styles.fieldLabel}>Marks</span>
<div className={styles.marksRow}>
<span className={styles.channelLabel}>{CHANNEL_LABELS.color}</span>
<ChannelSlot channel="color" />
</div>
<div className={styles.marksRow}>
<span className={styles.channelLabel}>{CHANNEL_LABELS.size}</span>
<ChannelSlot channel="size" />
</div>
</div>
);
}
/** A reserved, non-interactive shelf slot for faceting (small multiples) — Phase 4.
* Shown so the layout telegraphs where row/column faceting will live. */
function FacetSlot({ kind }: { kind: 'column' | 'row' }) {
return (
<div className={styles.facetSlot} title="Faceting → small multiples (coming later)">
<span>+ {kind} facet</span>
<span className={styles.facetTag}>later</span>
</div>
);
}
/**
* The on-chart **Columns** (X) and **Rows** (Y) shelves stacked above the preview,
* Tableau-style — position is a property of the chart, so its controls sit on the
* chart. Each shelf holds the axis slot plus a reserved faceting placeholder. A Swap
* X/Y action flips the two axes.
*/
function OnChartShelves() {
const swapXY = useChartBuilderStore((s) => s.swapXY);
return (
<div className={styles.shelves}>
<div className={styles.shelvesHead}>
<span className={styles.fieldLabel}>Axes</span>
<button type="button" className={styles.swap} onClick={swapXY}>
Swap X/Y
</button>
</div>
<div className={styles.shelfStrip}>
<span className={styles.shelfName}>Columns</span>
<div className={styles.shelfSlots}>
<ChannelSlot channel="x" hint="Add a field for the X axis" />
<FacetSlot kind="column" />
</div>
</div>
<div className={styles.shelfStrip}>
<span className={styles.shelfName}>Rows</span>
<div className={styles.shelfSlots}>
<ChannelSlot channel="y" hint="Add a field for the Y axis" />
<FacetSlot kind="row" />
</div>
</div>
</div>
);
}
/**
* Inline feedback for an expression input (filter expression / calculated field):
* a parse error takes priority, else a soft warning for `datum.<field>` references
@@ -881,7 +1119,6 @@ export function ChartBuilderModal() {
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);
@@ -961,17 +1198,8 @@ export function ChartBuilderModal() {
/>
</div>
<div className={styles.channels}>
<div className={styles.channelsHeader}>
<span className={styles.fieldLabel}>Encoding</span>
<button type="button" className={styles.swap} onClick={swapXY}>
Swap X/Y
</button>
</div>
{CHANNELS.map((channel) => (
<ChannelBlock key={channel} channel={channel} />
))}
</div>
<FieldShelf />
<MarksCard />
{(canSort || canStack) && (
<div className={styles.chartControls}>
@@ -1001,7 +1229,9 @@ export function ChartBuilderModal() {
)}
<div className={styles.dimensions}>
<span className={styles.fieldLabel}>Size (optional)</span>
{/* "Chart size", not just "Size" — the Marks card now has a Size *encoding*
channel; this is the rendered chart's width/height. */}
<span className={styles.fieldLabel}>Chart size (optional)</span>
<div className={styles.dimInputs}>
<label className={styles.dimField}>
<span>Width</span>
@@ -1081,7 +1311,10 @@ export function ChartBuilderModal() {
</div>
</div>
<BuilderPreview />
<div className={styles.previewSide}>
<OnChartShelves />
<BuilderPreview />
</div>
</div>
);
}