mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Chart builder: field-first shelf + value-or-field channels
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -260,3 +260,81 @@ describe('createSnippet', () => {
|
||||
expect(useSnippetStore.getState().snippets).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('field-first assignment (assignField / focusChannel)', () => {
|
||||
test('a clicked field lands on the first empty channel that accepts it', () => {
|
||||
const id = seedDataset('Shop', [{ region: 'E', sales: 5, qty: 2 }]);
|
||||
cb().init(id); // region→X, count→Y (smart default for one category + measures)
|
||||
cb().setChannelColumn('x', null);
|
||||
cb().setChannelColumn('y', null);
|
||||
|
||||
cb().assignField('region'); // first empty channel is X
|
||||
expect(cb().config.encodings.x).toEqual({ field: 'region', type: 'nominal' });
|
||||
cb().assignField('sales'); // X taken → Y
|
||||
expect(cb().config.encodings.y).toEqual({ field: 'sales', type: 'quantitative' });
|
||||
});
|
||||
|
||||
test('the armed channel takes the field, then disarms', () => {
|
||||
const id = seedDataset('Shop', [{ region: 'E', sales: 5 }]);
|
||||
cb().init(id);
|
||||
cb().focusChannel('color');
|
||||
expect(cb().activeChannel).toBe('color');
|
||||
cb().assignField('region');
|
||||
expect(cb().config.encodings.color).toEqual({ field: 'region', type: 'nominal' });
|
||||
expect(cb().activeChannel).toBeNull(); // armed slot cleared after assignment
|
||||
});
|
||||
|
||||
test('skips a channel that cannot take the column (Size needs a measure)', () => {
|
||||
const id = seedDataset('Shop', [{ region: 'E', sales: 5 }]);
|
||||
cb().init(id);
|
||||
cb().focusChannel('size');
|
||||
cb().assignField('region'); // a category can't go on Size → falls through to an empty axis
|
||||
expect(cb().config.encodings.size ?? null).toBeNull();
|
||||
// It landed on the first axis that accepts it instead of being dropped.
|
||||
const onAxis =
|
||||
cb().config.encodings.x?.field === 'region' || cb().config.encodings.y?.field === 'region';
|
||||
expect(onAxis).toBe(true);
|
||||
});
|
||||
|
||||
test('Count of records assigns as a field-less count measure', () => {
|
||||
const id = seedDataset('Shop', [{ region: 'E', sales: 5 }]);
|
||||
cb().init(id);
|
||||
cb().setChannelColumn('x', null);
|
||||
cb().setChannelColumn('y', null);
|
||||
cb().focusChannel('y');
|
||||
cb().assignField(COUNT_FIELD);
|
||||
expect(cb().config.encodings.y).toEqual({ type: 'quantitative', aggregate: 'count' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('constant values (setChannelConstant — the Property model)', () => {
|
||||
test('sets a fixed colour on Color, emitted as a constant', () => {
|
||||
const id = seedDataset('Shop', [{ region: 'E', sales: 5 }]);
|
||||
cb().init(id);
|
||||
cb().setChannelConstant('color', '#c0392b');
|
||||
expect(cb().config.encodings.color).toMatchObject({ value: '#c0392b' });
|
||||
});
|
||||
|
||||
test('coerces a Size constant to a number', () => {
|
||||
const id = seedDataset('Shop', [{ region: 'E', sales: 5 }]);
|
||||
cb().init(id);
|
||||
cb().setChannelConstant('size', '120');
|
||||
expect(cb().config.encodings.size?.value).toBe(120);
|
||||
});
|
||||
|
||||
test('preserves the prior field type so toggling back to a field restores it', () => {
|
||||
const id = seedDataset('Shop', [{ region: 'E', sales: 5 }]);
|
||||
cb().init(id);
|
||||
cb().setChannelColumn('color', 'region'); // nominal field
|
||||
cb().setChannelConstant('color', '#000000');
|
||||
expect(cb().config.encodings.color).toEqual({ value: '#000000', type: 'nominal' });
|
||||
});
|
||||
|
||||
test('refuses a constant on a positional channel (X/Y stay field-only)', () => {
|
||||
const id = seedDataset('Shop', [{ region: 'E', sales: 5 }]);
|
||||
cb().init(id);
|
||||
const before = cb().config.encodings.x;
|
||||
cb().setChannelConstant('x', '50');
|
||||
expect(cb().config.encodings.x).toBe(before); // unchanged — no-op
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,13 +15,17 @@
|
||||
|
||||
import { create } from 'zustand';
|
||||
import {
|
||||
CHANNELS,
|
||||
buildSnippetSpecText,
|
||||
channelAcceptsValue,
|
||||
coerceChannelValue,
|
||||
defaultBuilderConfig,
|
||||
defaultFieldType,
|
||||
effectiveColumns,
|
||||
generateChartName,
|
||||
isBuilderConfigValid,
|
||||
isChannelTypeAllowed,
|
||||
isColumnAllowedOnChannel,
|
||||
pruneEncodings,
|
||||
supportsAggregate,
|
||||
supportsBin,
|
||||
@@ -78,6 +82,13 @@ export interface ChartBuilderState {
|
||||
rowCount: number | null;
|
||||
/** The working configuration the preview and the produced spec read from. */
|
||||
config: BuilderConfig;
|
||||
/**
|
||||
* The channel "armed" to receive the next clicked field (field-first assignment,
|
||||
* spec §06 → Encoding). Clicking an empty channel slot arms it; clicking a field in
|
||||
* the shelf then fills it. Null when no slot is armed (a clicked field lands on the
|
||||
* first empty channel that accepts it).
|
||||
*/
|
||||
activeChannel: ChannelName | null;
|
||||
|
||||
/** Load a dataset and pre-populate a smart default config (spec §06 → Opening). */
|
||||
init: (datasetId: number | null) => void;
|
||||
@@ -87,6 +98,24 @@ export interface ChartBuilderState {
|
||||
* seeds the channel's default type and clears any prior transforms.
|
||||
*/
|
||||
setChannelColumn: (channel: ChannelName, columnName: string | null) => void;
|
||||
/**
|
||||
* Arm (or disarm with `null`) a channel slot to receive the next clicked field —
|
||||
* the field-first assignment focus model.
|
||||
*/
|
||||
focusChannel: (channel: ChannelName | null) => void;
|
||||
/**
|
||||
* Place a clicked shelf field on a channel: the armed channel if it accepts the
|
||||
* column, else the first empty channel that does (X→Y→Color→Size). A no-op when no
|
||||
* channel can take it. Clears the armed slot.
|
||||
*/
|
||||
assignField: (columnName: string) => void;
|
||||
/**
|
||||
* Set/replace a channel's binding with a **constant value** (the Property model,
|
||||
* spec §06) — a fixed colour/size emitted as Vega-Lite `{ value }`. Only Color/Size
|
||||
* accept one; the raw input is coerced by channel (Size → number). Preserves the
|
||||
* prior field type so toggling back to a field restores it.
|
||||
*/
|
||||
setChannelConstant: (channel: ChannelName, raw: string) => void;
|
||||
setChannelType: (channel: ChannelName, type: FieldType) => void;
|
||||
/** Set/clear a channel's aggregate (sum/mean/…); `undefined` clears it. */
|
||||
setChannelAggregate: (channel: ChannelName, aggregate: AggregateOp | undefined) => void;
|
||||
@@ -146,11 +175,47 @@ function updateEncoding(
|
||||
return { config: { ...s.config, encodings: { ...s.config.encodings, [channel]: mapping } } };
|
||||
}
|
||||
|
||||
/**
|
||||
* The fresh mapping for a column placed on a channel: a field-less count for the
|
||||
* sentinel, else the column with a channel-appropriate field type (its natural type if
|
||||
* allowed here, else the first valid type that is — the Size guard). A fresh mapping
|
||||
* carries no transforms. Shared by direct (`setChannelColumn`) and field-first
|
||||
* (`assignField`) assignment so both land on the same mapping.
|
||||
*/
|
||||
function mappingForColumn(
|
||||
s: ChartBuilderState,
|
||||
channel: ChannelName,
|
||||
columnName: string,
|
||||
): ChannelMapping {
|
||||
if (columnName === COUNT_FIELD) return { type: 'quantitative', aggregate: 'count' };
|
||||
const colType = columnType(effCols(s), columnName);
|
||||
const valid = validFieldTypes(colType);
|
||||
const type = valid.find((t) => isChannelTypeAllowed(channel, t)) ?? defaultFieldType(colType);
|
||||
return { field: columnName, type };
|
||||
}
|
||||
|
||||
/**
|
||||
* Which channel a clicked field lands on (field-first assignment): the armed channel
|
||||
* if it accepts the column, else the first empty channel that does, in canonical order.
|
||||
* Returns null when nothing can take it (so a click is a no-op rather than clobbering a
|
||||
* full set). The count sentinel reads as a measure for placement.
|
||||
*/
|
||||
function chooseTargetChannel(s: ChartBuilderState, columnName: string): ChannelName | null {
|
||||
const colType: ColumnType =
|
||||
columnName === COUNT_FIELD ? 'number' : columnType(effCols(s), columnName);
|
||||
const accepts = (ch: ChannelName) => isColumnAllowedOnChannel(ch, colType);
|
||||
const active = s.activeChannel;
|
||||
if (active && accepts(active)) return active;
|
||||
for (const ch of CHANNELS) if (!s.config.encodings[ch] && accepts(ch)) return ch;
|
||||
return null;
|
||||
}
|
||||
|
||||
export const useChartBuilderStore = create<ChartBuilderState>((set, get) => ({
|
||||
datasetId: null,
|
||||
columns: EMPTY_COLUMNS,
|
||||
rowCount: null,
|
||||
config: EMPTY_CONFIG,
|
||||
activeChannel: null,
|
||||
|
||||
init: (datasetId) => {
|
||||
const dataset =
|
||||
@@ -158,7 +223,13 @@ export const useChartBuilderStore = create<ChartBuilderState>((set, get) => ({
|
||||
? undefined
|
||||
: useDatasetStore.getState().datasets.find((d) => d.id === datasetId);
|
||||
if (!dataset) {
|
||||
set({ datasetId: null, columns: EMPTY_COLUMNS, rowCount: null, config: EMPTY_CONFIG });
|
||||
set({
|
||||
datasetId: null,
|
||||
columns: EMPTY_COLUMNS,
|
||||
rowCount: null,
|
||||
config: EMPTY_CONFIG,
|
||||
activeChannel: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const columns: BuilderColumns = {
|
||||
@@ -171,6 +242,7 @@ export const useChartBuilderStore = create<ChartBuilderState>((set, get) => ({
|
||||
columns,
|
||||
rowCount: dataset.rowCount,
|
||||
config: defaultBuilderConfig(dataset.name, columns),
|
||||
activeChannel: null,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -178,26 +250,40 @@ export const useChartBuilderStore = create<ChartBuilderState>((set, get) => ({
|
||||
|
||||
setChannelColumn: (channel, columnName) =>
|
||||
set((s) => {
|
||||
let mapping: ChannelMapping | null;
|
||||
if (columnName === null) {
|
||||
mapping = null;
|
||||
} else if (columnName === COUNT_FIELD) {
|
||||
// The field-less "Count of records" measure.
|
||||
mapping = { type: 'quantitative', aggregate: 'count' };
|
||||
} else {
|
||||
// Default to the column's natural type, but if that type isn't allowed on
|
||||
// this channel (e.g. a category on Size), fall back to the first valid type
|
||||
// that is — the UI also disables unsuitable columns, this is the guard.
|
||||
// Effective columns include calculated fields (which default to numeric).
|
||||
const colType = columnType(effCols(s), columnName);
|
||||
const valid = validFieldTypes(colType);
|
||||
const type =
|
||||
valid.find((t) => isChannelTypeAllowed(channel, t)) ?? defaultFieldType(colType);
|
||||
mapping = { field: columnName, type }; // a fresh mapping clears prior transforms
|
||||
}
|
||||
// null = "None"; otherwise a fresh, transform-free mapping (count or typed field,
|
||||
// its type clamped to one the channel allows). Effective columns include the
|
||||
// config's calculated fields.
|
||||
const mapping = columnName === null ? null : mappingForColumn(s, channel, columnName);
|
||||
return { config: { ...s.config, encodings: { ...s.config.encodings, [channel]: mapping } } };
|
||||
}),
|
||||
|
||||
focusChannel: (channel) => set({ activeChannel: channel }),
|
||||
|
||||
assignField: (columnName) =>
|
||||
set((s) => {
|
||||
const target = chooseTargetChannel(s, columnName);
|
||||
if (!target) return { activeChannel: null }; // nothing can take it → just disarm
|
||||
const mapping = mappingForColumn(s, target, columnName);
|
||||
return {
|
||||
activeChannel: null,
|
||||
config: { ...s.config, encodings: { ...s.config.encodings, [target]: mapping } },
|
||||
};
|
||||
}),
|
||||
|
||||
setChannelConstant: (channel, raw) =>
|
||||
set((s) => {
|
||||
if (!channelAcceptsValue(channel)) return s; // only Color/Size hold a constant
|
||||
const current = s.config.encodings[channel];
|
||||
// Preserve the prior field type so a later toggle back to a field restores it;
|
||||
// default by channel when there was none. A constant carries no field/transforms.
|
||||
const type: FieldType = current?.type ?? (channel === 'size' ? 'quantitative' : 'nominal');
|
||||
const mapping: ChannelMapping = { value: coerceChannelValue(channel, raw), type };
|
||||
return {
|
||||
activeChannel: null,
|
||||
config: { ...s.config, encodings: { ...s.config.encodings, [channel]: mapping } },
|
||||
};
|
||||
}),
|
||||
|
||||
setChannelType: (channel, type) =>
|
||||
set((s) => {
|
||||
const current = s.config.encodings[channel];
|
||||
@@ -389,7 +475,13 @@ export const useChartBuilderStore = create<ChartBuilderState>((set, get) => ({
|
||||
},
|
||||
|
||||
reset: () =>
|
||||
set({ datasetId: null, columns: EMPTY_COLUMNS, rowCount: null, config: EMPTY_CONFIG }),
|
||||
set({
|
||||
datasetId: null,
|
||||
columns: EMPTY_COLUMNS,
|
||||
rowCount: null,
|
||||
config: EMPTY_CONFIG,
|
||||
activeChannel: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user