mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
190 lines
6.7 KiB
TypeScript
190 lines
6.7 KiB
TypeScript
/**
|
|
* Snippet — the primary user-authored entity (spec §09A).
|
|
*
|
|
* Portable core: no browser APIs, no React. Defines the record shape, the
|
|
* current record schema version, and pure factories for creating new snippets
|
|
* (with the sample bar-chart template and an auto-generated date/time name).
|
|
*
|
|
* Specs are stored as **JSON text** (string). The data model permits a spec to
|
|
* be an object or a string; we standardize on the string form because it is what
|
|
* the Monaco editor edits and what survives round-tripping without reformatting.
|
|
* The preview parses the text into an object before rendering (see rendering.ts).
|
|
*/
|
|
|
|
/** Current schema version for a Snippet record (read-time migration target). */
|
|
export const CURRENT_SNIPPET_VERSION = 1;
|
|
|
|
/**
|
|
* The Vega-Lite schema URL stamped into generated specs (`$schema`). Shared so the
|
|
* sample template and the Chart Builder agree on one version; the Monaco schema
|
|
* service pins the same URI independently (infrastructure/monaco-schema.ts).
|
|
*/
|
|
export const VEGA_LITE_SCHEMA_URL = 'https://vega.github.io/schema/vega-lite/v6.json';
|
|
|
|
export interface Snippet {
|
|
/** Unique, stable identifier. */
|
|
id: string;
|
|
/** Record schema version, for read-time migration. */
|
|
version: number;
|
|
/** Human-readable title shown in the library. */
|
|
name: string;
|
|
/** ISO timestamp — when first created. */
|
|
created: string;
|
|
/** ISO timestamp — when last saved. */
|
|
modified: string;
|
|
/** The published (stable) Vega-Lite spec, as JSON text. */
|
|
spec: string;
|
|
/** The working-draft Vega-Lite spec being edited, as JSON text. */
|
|
draftSpec: string;
|
|
/** Free-form user note. */
|
|
comment: string;
|
|
/** User-assigned labels. */
|
|
tags: string[];
|
|
/** Names of datasets referenced by the draft spec; mirrors the version being
|
|
* edited, recomputed on every draft change (create/auto-save/extract/revert)
|
|
* and on publish (docs/architecture/07 §3). */
|
|
datasetRefs: string[];
|
|
/** Free-form, extensible metadata bag. */
|
|
meta: Record<string, unknown>;
|
|
}
|
|
|
|
/**
|
|
* The sample bar-chart template a fresh snippet starts from (spec §02 → Create
|
|
* New: "a small sample Vega-Lite bar-chart template with a few inline rows").
|
|
* Inline data only — datasets arrive in M3.
|
|
*/
|
|
export const SAMPLE_SPEC = {
|
|
$schema: VEGA_LITE_SCHEMA_URL,
|
|
description: 'A simple bar chart.',
|
|
data: {
|
|
values: [
|
|
{ category: 'A', value: 28 },
|
|
{ category: 'B', value: 55 },
|
|
{ category: 'C', value: 43 },
|
|
{ category: 'D', value: 91 },
|
|
{ category: 'E', value: 81 },
|
|
],
|
|
},
|
|
mark: 'bar',
|
|
encoding: {
|
|
x: { field: 'category', type: 'nominal', axis: { labelAngle: 0 } },
|
|
y: { field: 'value', type: 'quantitative' },
|
|
},
|
|
} as const;
|
|
|
|
/** The sample template rendered as pretty-printed JSON text. */
|
|
export function sampleSpecText(): string {
|
|
return JSON.stringify(SAMPLE_SPEC, null, 2);
|
|
}
|
|
|
|
/** Two-digit zero-pad for the date/time name. */
|
|
function pad(n: number): string {
|
|
return String(n).padStart(2, '0');
|
|
}
|
|
|
|
/**
|
|
* Auto-generated default name from a timestamp, e.g. "Snippet 2026-06-04 14:30:07".
|
|
* Including seconds keeps names unique for snippets created in quick succession.
|
|
*/
|
|
export function generateSnippetName(now: Date): string {
|
|
const date = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
|
const time = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
|
|
return `Snippet ${date} ${time}`;
|
|
}
|
|
|
|
export interface CreateSnippetOptions {
|
|
/** Override the auto-generated name. */
|
|
name?: string;
|
|
/** Override the starting spec text (defaults to the sample template). */
|
|
spec?: string;
|
|
/** Clock injection for deterministic tests; defaults to the current time. */
|
|
now?: Date;
|
|
/** Id injection for deterministic tests; defaults to a random UUID. */
|
|
id?: string;
|
|
/** Seed the extensible metadata bag (e.g. Chart Builder provenance). */
|
|
meta?: Record<string, unknown>;
|
|
}
|
|
|
|
/**
|
|
* Create a new snippet. `spec` and `draftSpec` start identical (nothing to
|
|
* publish yet); timestamps are equal at creation.
|
|
*/
|
|
export function createSnippet(options: CreateSnippetOptions = {}): Snippet {
|
|
const now = options.now ?? new Date();
|
|
const iso = now.toISOString();
|
|
const spec = options.spec ?? sampleSpecText();
|
|
return {
|
|
id: options.id ?? crypto.randomUUID(),
|
|
version: CURRENT_SNIPPET_VERSION,
|
|
name: options.name ?? generateSnippetName(now),
|
|
created: iso,
|
|
modified: iso,
|
|
spec,
|
|
draftSpec: spec,
|
|
comment: '',
|
|
tags: [],
|
|
datasetRefs: [],
|
|
meta: options.meta ?? {},
|
|
};
|
|
}
|
|
|
|
export interface DuplicateSnippetOptions {
|
|
/** Clock injection for deterministic tests; defaults to the current time. */
|
|
now?: Date;
|
|
/** Id injection for deterministic tests; defaults to a random UUID. */
|
|
id?: string;
|
|
}
|
|
|
|
/**
|
|
* Create an independent copy of a snippet (spec §02 → Duplicate). The copy carries
|
|
* over the specification (both published and draft), comment, tags, and dataset
|
|
* references, gets a new identity and fresh created/modified timestamps, and a name
|
|
* suffixed "(copy)". Mutable members are cloned so the copy shares no references
|
|
* with its source.
|
|
*/
|
|
export function duplicateSnippet(source: Snippet, options: DuplicateSnippetOptions = {}): Snippet {
|
|
const iso = (options.now ?? new Date()).toISOString();
|
|
return {
|
|
...source,
|
|
id: options.id ?? crypto.randomUUID(),
|
|
version: CURRENT_SNIPPET_VERSION,
|
|
name: `${source.name} (copy)`,
|
|
created: iso,
|
|
modified: iso,
|
|
tags: [...source.tags],
|
|
datasetRefs: [...source.datasetRefs],
|
|
meta: { ...source.meta },
|
|
};
|
|
}
|
|
|
|
/** True when the snippet's draft differs from its published spec (§03D). */
|
|
export function hasUnpublishedChanges(snippet: Snippet): boolean {
|
|
return snippet.draftSpec !== snippet.spec;
|
|
}
|
|
|
|
/**
|
|
* Approximate payload size of a snippet, in bytes (spec §09 `size`). Measured on
|
|
* the working draft — the version the editor and preview currently show — as its
|
|
* UTF-8 byte length, so the figure tracks what the user is actually editing.
|
|
* `TextEncoder` is a platform global (like `crypto.randomUUID` above), not a
|
|
* browser/DOM API, so it stays within the portable core.
|
|
*/
|
|
export function snippetSizeBytes(snippet: Snippet): number {
|
|
return new TextEncoder().encode(snippet.draftSpec).length;
|
|
}
|
|
|
|
/** Below this we omit the size in the library to reduce clutter (spec §02). */
|
|
const SIZE_DISPLAY_THRESHOLD = 1024;
|
|
|
|
/**
|
|
* Human-readable size for the library row, or `null` when the snippet is small
|
|
* enough that the spec says to omit it (under ~1 KB). Rounded to whole KB/MB —
|
|
* a list hint, not a precise measure.
|
|
*/
|
|
export function formatSnippetSize(bytes: number): string | null {
|
|
if (bytes < SIZE_DISPLAY_THRESHOLD) return null;
|
|
const kb = bytes / 1024;
|
|
if (kb < 1024) return `${Math.round(kb)} KB`;
|
|
return `${Math.round(kb / 1024)} MB`;
|
|
}
|