Add dataset library, extract-to-dataset, and render-time reference resolution

This commit is contained in:
2026-06-05 15:49:40 +03:00
parent 25849461e0
commit a4e4d96d3b
41 changed files with 3909 additions and 19 deletions
+133
View File
@@ -0,0 +1,133 @@
/**
* Extract-inline-data → Dataset state (spec §03F).
*
* Backs the Extract modal: the reverse of a named reference. It captures the
* active snippet draft's inline data, takes a dataset name, and on confirm saves
* the data as a new dataset and rewrites the draft so the inline data is replaced
* by a by-name reference (`{ "data": { "name": … } }`).
*
* Scope (M3): the **top-level** `data` block of the draft spec — the common case
* for a single-view chart. Inline data nested inside layers/concats is left for a
* later pass; `hasInlineData` reflects exactly what `confirm` can lift, so the
* editor only offers the action when this store can act on it.
*/
import { create } from 'zustand';
import type { DataFormat } from '@core/format-detection';
import { createDataset } from '@core/dataset';
import { isNameTaken } from '@core/naming';
import { useDatasetStore } from './DatasetStore';
import { useSnippetStore } from './SnippetStore';
/** The inline `data` block of a parsed spec, if it carries `values`. */
interface InlineData {
values: unknown;
format: DataFormat;
}
/**
* Read the top-level inline data from a draft spec's text, or null when there is
* none (no snippet, unparseable, or no `data.values`). The format comes from an
* explicit `data.format.type` when present (raw CSV/TSV strings), else JSON.
*/
export function readInlineData(draftText: string): InlineData | null {
let parsed: unknown;
try {
parsed = JSON.parse(draftText);
} catch {
return null;
}
if (!parsed || typeof parsed !== 'object') return null;
const data = (parsed as Record<string, unknown>).data;
if (!data || typeof data !== 'object') return null;
const values = (data as Record<string, unknown>).values;
if (values === undefined) return null;
const declared = (data as Record<string, unknown>).format;
const type =
declared && typeof declared === 'object'
? (declared as Record<string, unknown>).type
: undefined;
const format: DataFormat =
type === 'csv' || type === 'tsv' || type === 'topojson' ? type : 'json';
return { values, format };
}
/** True when the active snippet's draft has top-level inline data to extract. */
export function hasInlineData(draftText: string): boolean {
return readInlineData(draftText) !== null;
}
export interface ExtractState {
/** Proposed dataset name (required, unique). */
name: string;
/** The inline data captured at open, for the read-only preview. */
source: InlineData | null;
/** Inline validation message, or null. */
error: string | null;
/** Capture the active snippet's inline data and reset the form. */
init: () => void;
setName: (name: string) => void;
/**
* Validate, create the dataset, and rewrite the active draft to reference it by
* name. Returns whether it committed; on failure `error` is set. `now`
* injectable for tests.
*/
confirm: (now?: Date) => boolean;
reset: () => void;
}
const INITIAL = { name: '', source: null as InlineData | null, error: null as string | null };
export const useExtractStore = create<ExtractState>((set, get) => ({
...INITIAL,
init: () => {
const draft = useSnippetStore.getState().draftText;
set({ name: '', source: readInlineData(draft), error: null });
},
setName: (name) => set({ name, error: null }),
confirm: (now) => {
const name = get().name.trim();
if (name === '') {
set({ error: 'Enter a dataset name.' });
return false;
}
if (isNameTaken(name, useDatasetStore.getState().datasets)) {
set({ error: `A dataset named "${name}" already exists. Choose a different name.` });
return false;
}
const source = get().source;
if (!source) {
set({ error: 'No inline data to extract.' });
return false;
}
// JSON/TopoJSON store the parsed value; CSV/TSV keep raw text — but inline
// `values` is already the right runtime shape for each, so store it directly.
const dataset = createDataset({
name,
data: source.values,
format: source.format,
source: 'inline',
now,
});
useDatasetStore.getState().add(dataset);
// Rewrite the top-level data block to a by-name reference, preserving the rest
// of the spec and its pretty-printed text shape.
const draftText = useSnippetStore.getState().draftText;
const spec = JSON.parse(draftText) as Record<string, unknown>;
spec.data = { name };
useSnippetStore.getState().replaceActiveDraft(JSON.stringify(spec, null, 2), now);
// TODO (M6, spec §03F): success toast "Dataset created" — deferred with the
// other success toasts (see SnippetStore publish/revert breadcrumbs).
set(INITIAL);
return true;
},
reset: () => set(INITIAL),
}));