diff --git a/package-lock.json b/package-lock.json index f3034c0..91b8c65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "react-dom": "^19.2.7", "vega": "^6.2.0", "vega-embed": "^7.1.0", + "vega-expression": "^6.1.0", "vega-lite": "^6.4.2", "vega-themes": "3.0.0", "zustand": "^5.0.14" diff --git a/package.json b/package.json index 9cc7b0a..a8a67a1 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "react-dom": "^19.2.7", "vega": "^6.2.0", "vega-embed": "^7.1.0", + "vega-expression": "^6.1.0", "vega-lite": "^6.4.2", "vega-themes": "3.0.0", "zustand": "^5.0.14" diff --git a/src/app/components/DatasetsModal.tsx b/src/app/components/DatasetsModal.tsx index dfe4cfd..ee4dc08 100644 --- a/src/app/components/DatasetsModal.tsx +++ b/src/app/components/DatasetsModal.tsx @@ -14,9 +14,16 @@ import { useMemo, useState } from 'react'; import { useShallow } from 'zustand/react/shallow'; -import { datasetReference, tabularRows, type DataSource, type Dataset } from '@core/dataset'; +import { + cellText, + datasetReference, + tabularRows, + type DataSource, + type Dataset, +} from '@core/dataset'; import { detectFormat, detectFormatFromUrl, type DataFormat } from '@core/format-detection'; import { datasetUsageCounts, snippetsReferencingDataset } from '@core/relationships'; +import { humanizeBytes } from '@core/storage-estimate'; import { closeModal, openModal, resnapshot } from '../modals/ModalCoordinator'; import { fetchRemoteData } from '../infrastructure/remote-data'; import { remoteFetchErrorMessage } from '../services/remote-data-errors'; @@ -38,15 +45,6 @@ function formatLabel(format: DataFormat): string { return format === 'topojson' ? 'TopoJSON' : format.toUpperCase(); } -/** Human-readable byte size (B / KB / MB). */ -function humanBytes(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - const kb = bytes / 1024; - if (kb < 1024) return `${kb < 10 ? kb.toFixed(1) : Math.round(kb)} KB`; - const mb = kb / 1024; - return `${mb < 10 ? mb.toFixed(1) : Math.round(mb)} MB`; -} - const SOURCE_OPTIONS: ReadonlyArray> = [ { value: 'inline', label: 'Inline' }, { value: 'url', label: 'URL' }, @@ -55,14 +53,6 @@ const SOURCE_OPTIONS: ReadonlyArray> = [ /** Rows shown in the tabular preview before truncating (spec §05 → Detail Panel). */ const PREVIEW_ROW_LIMIT = 50; -/** One table cell's text: blank for empty, the string as-is, else JSON (numbers, - * booleans, nested values). Avoids `String()` on objects ("[object Object]"). */ -function cellText(value: unknown): string { - if (value == null) return ''; - if (typeof value === 'string') return value; - return JSON.stringify(value); -} - export function DatasetsModal() { const datasets = useDatasetStore(useShallow((s) => s.datasets)); const view = useDatasetStore((s) => s.view); @@ -137,7 +127,7 @@ function DatasetListItem({ if (unfetched) parts.push('not fetched'); else if (dataset.rowCount !== null) parts.push(`${dataset.rowCount} rows`); parts.push(formatLabel(dataset.format)); - if (!unfetched) parts.push(humanBytes(dataset.size)); + if (!unfetched) parts.push(humanizeBytes(dataset.size)); return (
  • @@ -324,7 +314,7 @@ function DatasetDetail({
    Size
    -
    {dataset.data == null ? 'N/A' : humanBytes(dataset.size)}
    +
    {dataset.data == null ? 'N/A' : humanizeBytes(dataset.size)}
    diff --git a/src/app/components/Icon.tsx b/src/app/components/Icon.tsx index 7bfbeaa..70856d2 100644 --- a/src/app/components/Icon.tsx +++ b/src/app/components/Icon.tsx @@ -47,7 +47,7 @@ export type IconName = | 'status-info'; // Carbon InformationFilled /** Carbon icon scale (arch 09 §5.3). 16px (sm) is the default, paired to 14px body. */ -export type IconSize = 'sm' | 'md' | 'lg' | 'xl'; +type IconSize = 'sm' | 'md' | 'lg' | 'xl'; /** Inner SVG geometry per glyph, on Carbon's 0 0 32 32 grid. fill comes from CSS. */ const GLYPHS: Record = { diff --git a/src/app/infrastructure/db.ts b/src/app/infrastructure/db.ts index 9156afc..6ff4ea6 100644 --- a/src/app/infrastructure/db.ts +++ b/src/app/infrastructure/db.ts @@ -99,9 +99,6 @@ function tx( ); } -export const get = (store: string, key: IDBValidKey): Promise => - tx(store, 'readonly', (s) => s.get(key) as IDBRequest); - export const getAll = (store: string): Promise => tx(store, 'readonly', (s) => s.getAll() as IDBRequest); diff --git a/src/app/infrastructure/url-hash.ts b/src/app/infrastructure/url-hash.ts index 742679a..d9f01cb 100644 --- a/src/app/infrastructure/url-hash.ts +++ b/src/app/infrastructure/url-hash.ts @@ -87,6 +87,11 @@ export function readView(): ViewState { return parseHash(window.location.hash); } +/** The current raw hash, for comparing a serialized view against the URL. */ +export function currentHash(): string { + return window.location.hash; +} + /** * Build the next URL for `view`. The hash is the app's only view-state channel, * so we normalize to a hash-only canonical URL: any stray query string (a shared diff --git a/src/app/modals/ModalCoordinator.ts b/src/app/modals/ModalCoordinator.ts index a61e8a8..4a6c09e 100644 --- a/src/app/modals/ModalCoordinator.ts +++ b/src/app/modals/ModalCoordinator.ts @@ -48,7 +48,7 @@ export function resnapshot(): void { } /** True when the active modal's editable state differs from its baseline. */ -export function hasUnsavedChanges(): boolean { +function hasUnsavedChanges(): boolean { const name = useAppStore.getState().activeModal; if (!name) return false; const current = snapshotOf(name); diff --git a/src/app/modals/UrlStateSync.ts b/src/app/modals/UrlStateSync.ts index fb61d5e..f6b8536 100644 --- a/src/app/modals/UrlStateSync.ts +++ b/src/app/modals/UrlStateSync.ts @@ -34,6 +34,7 @@ import { useChartBuilderStore } from '../stores/ChartBuilderStore'; import { useDatasetStore } from '../stores/DatasetStore'; import { useSnippetStore } from '../stores/SnippetStore'; import { + currentHash, pushView, readView, replaceView, @@ -72,7 +73,7 @@ export function deriveViewState(): ViewState { /** True when `view` already matches the current URL hash. */ function isCurrent(view: ViewState): boolean { - return serializeHash(view) === window.location.hash; + return serializeHash(view) === currentHash(); } /** @@ -174,12 +175,6 @@ export function startRouting(): void { useChartBuilderStore.subscribe(syncUrlFromState); } -/** Test seam: reset the module's start/applying flags between cases. */ -export function resetRoutingForTest(): void { - started = false; - applying = false; -} - // --- Coordinator seam (unchanged call sites) ------------------------------- // The coordinator calls these on modal open/close; both now just re-derive the // hash from state. Kept as named exports so ModalCoordinator needs no edit. diff --git a/src/app/orchestration/panes.ts b/src/app/orchestration/panes.ts index dfdaefc..b0d35fd 100644 --- a/src/app/orchestration/panes.ts +++ b/src/app/orchestration/panes.ts @@ -17,7 +17,7 @@ import { import { usePanesStore } from '../stores/PanesStore'; /** Delay before a settled resize is persisted. */ -export const PANES_PERSIST_DEBOUNCE_MS = 300; +const PANES_PERSIST_DEBOUNCE_MS = 300; /** Hydrate persisted pane widths + visibility into the store. Call before render. */ export function initPanes(): void { diff --git a/src/app/services/chart-renderer.ts b/src/app/services/chart-renderer.ts index dfd9abe..faea204 100644 --- a/src/app/services/chart-renderer.ts +++ b/src/app/services/chart-renderer.ts @@ -12,7 +12,7 @@ import type { VisualizationSpec } from 'vega-embed'; import type { Config } from 'vega-lite'; /** Options for `RenderHandle.toImageURL` (spec §08 → Per-chart export). */ -export interface ImageExportOptions { +interface ImageExportOptions { /** * Pixel-density multiplier for the **PNG** raster, **relative to the device**. * The image is drawn at `scale × devicePixelRatio` logical-pixel density, so @@ -80,7 +80,7 @@ export interface RenderOptions { * size against this (÷ dpr, since the backing store is dpr× the CSS size) and * throws `ChartTooLargeError` rather than handing back a blank canvas. */ -export const MAX_CANVAS_PX = 32767; +const MAX_CANVAS_PX = 32767; /** * Thrown by `renderSpec` when a **canvas**-backed chart resolves to a height larger diff --git a/src/app/services/spec-config-actions.ts b/src/app/services/spec-config-actions.ts index 4146f3c..aac4a96 100644 --- a/src/app/services/spec-config-actions.ts +++ b/src/app/services/spec-config-actions.ts @@ -110,12 +110,20 @@ export function runMergeChartTheme(editor: monaco.editor.IStandaloneCodeEditor): }); } -/** Remove the draft's `config` block, copying it to the clipboard first. */ -export async function runExtractConfig(editor: monaco.editor.IStandaloneCodeEditor): Promise { +/** + * Shared prologue of the two extract actions: the model, the spec parsed off it, + * and the config split out — or null (after toasting) when any step has nothing + * to work with. + */ +function extractableConfig(editor: monaco.editor.IStandaloneCodeEditor): { + model: monaco.editor.ITextModel; + rest: Record; + config: Record; +} | null { const model = editor.getModel(); - if (!model) return; + if (!model) return null; const spec = parseSpecObject(model); - if (!spec) return; + if (!spec) return null; const { spec: rest, config } = extractConfigFromSpec(spec); if (config === null) { @@ -124,8 +132,16 @@ export async function runExtractConfig(editor: monaco.editor.IStandaloneCodeEdit title: 'No config to extract', message: 'This spec has no config block.', }); - return; + return null; } + return { model, rest, config }; +} + +/** Remove the draft's `config` block, copying it to the clipboard first. */ +export async function runExtractConfig(editor: monaco.editor.IStandaloneCodeEditor): Promise { + const extracted = extractableConfig(editor); + if (!extracted) return; + const { model, rest, config } = extracted; // Copy before removing — if the clipboard write fails, the spec keeps its // config and nothing is lost. @@ -157,20 +173,9 @@ export async function runExtractConfig(editor: monaco.editor.IStandaloneCodeEdit * non-overlapping keys stop applying once the new theme replaces it.) */ export function runExtractConfigToTheme(editor: monaco.editor.IStandaloneCodeEditor): void { - const model = editor.getModel(); - if (!model) return; - const spec = parseSpecObject(model); - if (!spec) return; - - const { spec: rest, config } = extractConfigFromSpec(spec); - if (config === null) { - notify({ - kind: 'info', - title: 'No config to extract', - message: 'This spec has no config block.', - }); - return; - } + const extracted = extractableConfig(editor); + if (!extracted) return; + const { model, rest, config } = extracted; const snippet = selectActiveSnippet(useSnippetStore.getState()); const theme = useCustomThemeStore diff --git a/src/app/services/transfer.ts b/src/app/services/transfer.ts index d2b09b7..62aea29 100644 --- a/src/app/services/transfer.ts +++ b/src/app/services/transfer.ts @@ -27,6 +27,7 @@ import { reassignCollidingSnippetIds, } from '@core/import-normalize'; import { snippetSizeBytes } from '@core/snippet'; +import { humanizeBytes } from '@core/storage-estimate'; import { downloadJson, readTextFile } from '../infrastructure/file-transfer'; import { deleteSnippet, saveSnippet, StorageQuotaError } from '../infrastructure/snippet-store'; import { notify } from '../stores/NotificationStore'; @@ -36,13 +37,6 @@ import { useSnippetStore } from '../stores/SnippetStore'; /** Practical snippet-storage budget (spec §02 storage monitor / §08). */ const SNIPPET_BUDGET_BYTES = 5 * 1024 * 1024; -/** Round bytes to a short KB/MB string for the overage warning. */ -function formatBytes(bytes: number): string { - const kb = bytes / 1024; - if (kb < 1024) return `${Math.max(1, Math.round(kb))} KB`; - return `${Math.round(kb / 1024)} MB`; -} - /** * Export the whole workspace to a downloaded JSON file (spec §08 → Export). * Flushes the live editor buffer first so in-progress draft edits are included. @@ -209,7 +203,7 @@ export async function importWorkspace(file: File): Promise { } if (overage > 0) { clauses.push( - `This puts snippet storage about ${formatBytes(overage)} over the ~5 MB budget; ` + + `This puts snippet storage about ${humanizeBytes(overage)} over the ~5 MB budget; ` + `consider deleting some snippets.`, ); } diff --git a/src/app/stores/AppStore.ts b/src/app/stores/AppStore.ts index 899bea0..45f88c1 100644 --- a/src/app/stores/AppStore.ts +++ b/src/app/stores/AppStore.ts @@ -14,11 +14,6 @@ import type { ModalName } from '../modals/types'; * docs/architecture/01-state-and-stores.md. */ -export type { UiTheme }; -// The modal union is defined once in the modal system (docs/architecture/03) and -// re-exported here for the many callers that reach it through the app store. -export type { ModalName }; - export interface AppState { /** Active UI theme; mirrored onto by a subscriber. */ uiTheme: UiTheme; diff --git a/src/app/stores/CustomThemeStore.ts b/src/app/stores/CustomThemeStore.ts index ca04008..19a748e 100644 --- a/src/app/stores/CustomThemeStore.ts +++ b/src/app/stores/CustomThemeStore.ts @@ -25,7 +25,7 @@ import { customThemeSelection } from '@core/vega-themes'; import { useAppStore } from './AppStore'; /** The Theme Builder's in-progress edit of the selected theme. */ -export interface ThemeDraft { +interface ThemeDraft { name: string; /** The config as editable JSON text (pretty-printed on load). */ configText: string; diff --git a/src/app/stores/DatasetStore.ts b/src/app/stores/DatasetStore.ts index 7ef75bf..2183952 100644 --- a/src/app/stores/DatasetStore.ts +++ b/src/app/stores/DatasetStore.ts @@ -9,7 +9,7 @@ * Two layers of action live here: * - Low-level mutators (`add`/`update`/`remove`) are the single place the * `datasets` array changes; the persistence subscriber writes them through to - * IndexedDB, and services (RelationshipService, future import) reuse them. + * IndexedDB, and services (e.g. the import flow in services/transfer) reuse them. * - Form orchestration (`save`) validates the form (name required + unique, * data detectable) and turns it into an `add`/`update`, keeping the modal * component thin. @@ -31,7 +31,7 @@ import { isNameTaken } from '@core/naming'; import { useSnippetStore } from './SnippetStore'; /** Which pane the Datasets manager is showing (spec §05 → Layout). */ -export type DatasetView = 'list' | 'detail' | 'new' | 'edit'; +type DatasetView = 'list' | 'detail' | 'new' | 'edit'; /** The in-progress create/edit form. `input` is the paste area (inline) or URL field. */ export interface DatasetForm { diff --git a/src/app/stores/ExtractStore.ts b/src/app/stores/ExtractStore.ts index cda40b5..3393d22 100644 --- a/src/app/stores/ExtractStore.ts +++ b/src/app/stores/ExtractStore.ts @@ -31,7 +31,7 @@ interface InlineData { * 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 { +function readInlineData(draftText: string): InlineData | null { let parsed: unknown; try { parsed = JSON.parse(draftText); diff --git a/src/app/stores/PanesStore.ts b/src/app/stores/PanesStore.ts index de513f5..17c0c63 100644 --- a/src/app/stores/PanesStore.ts +++ b/src/app/stores/PanesStore.ts @@ -178,7 +178,7 @@ export function shownSideWidths( /** Per-pane visibility (spec §01A). Widths are kept *independently* of visibility, * so a hidden pane keeps its remembered width and re-shows at it (not a default). */ -export interface PaneVisibility { +interface PaneVisibility { library?: boolean; editor?: boolean; preview?: boolean; diff --git a/src/core/chart-builder.ts b/src/core/chart-builder.ts index 4e9dd58..8e091f2 100644 --- a/src/core/chart-builder.ts +++ b/src/core/chart-builder.ts @@ -34,8 +34,7 @@ export const MARK_TYPES = ['bar', 'line', 'point', 'area', 'circle'] as const; export type MarkType = (typeof MARK_TYPES)[number]; /** The four Vega-Lite field types a channel may carry, in override-menu order. */ -export const FIELD_TYPES = ['quantitative', 'nominal', 'ordinal', 'temporal'] as const; -export type FieldType = (typeof FIELD_TYPES)[number]; +export type FieldType = 'quantitative' | 'nominal' | 'ordinal' | 'temporal'; /** The four encoding channels the builder offers, in display order (spec §06). */ export const CHANNELS = ['x', 'y', 'color', 'size'] as const; @@ -49,8 +48,7 @@ export type ChannelName = (typeof CHANNELS)[number]; * reduce a quantitative field; min/max also order a temporal or ordinal one. See * `validAggregateOps` for the per-type menu. */ -export const AGGREGATE_OPS = ['count', 'distinct', 'sum', 'mean', 'median', 'min', 'max'] as const; -export type AggregateOp = (typeof AGGREGATE_OPS)[number]; +export type AggregateOp = 'count' | 'distinct' | 'sum' | 'mean' | 'median' | 'min' | 'max'; /** * Temporal granularities (Vega-Lite `timeUnit`), coarse → fine, with the combined @@ -71,12 +69,10 @@ export const TIME_UNITS = [ export type TimeUnit = (typeof TIME_UNITS)[number]; /** Sort the categorical axis by its measure (spec §06 → Ranking). */ -export const SORT_ORDERS = ['ascending', 'descending'] as const; -export type SortOrder = (typeof SORT_ORDERS)[number]; +export type SortOrder = 'ascending' | 'descending'; /** Part-to-whole stacking for bar/area + a colour series: absolute vs 100%. */ -export const STACK_MODES = ['zero', 'normalize'] as const; -export type StackMode = (typeof STACK_MODES)[number]; +export type StackMode = 'zero' | 'normalize'; /** * Comparison operators a guarded filter predicate offers (Vega-Lite field @@ -85,17 +81,7 @@ export type StackMode = (typeof STACK_MODES)[number]; * `range`; a category offers membership (`oneOf`). `notEqual` is expressed as a * `{ not: { …equal } }` logical wrapper (Vega-Lite has no bare `!=` predicate). */ -export const FILTER_OPS = [ - 'equal', - 'notEqual', - 'lt', - 'lte', - 'gt', - 'gte', - 'range', - 'oneOf', -] as const; -export type FilterOp = (typeof FILTER_OPS)[number]; +export type FilterOp = 'equal' | 'notEqual' | 'lt' | 'lte' | 'gt' | 'gte' | 'range' | 'oneOf'; /** How a filter expresses its predicate: a guarded shelf, or a raw expression. */ export type FilterMode = 'predicate' | 'expression'; diff --git a/src/core/dataset.test.ts b/src/core/dataset.test.ts index 56d0d76..0640c4b 100644 --- a/src/core/dataset.test.ts +++ b/src/core/dataset.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'vitest'; import { + cellText, computeDatasetProfile, CURRENT_DATASET_VERSION, createDataset, @@ -153,6 +154,17 @@ describe('tabularRows', () => { }); }); +describe('cellText', () => { + test('strings as-is, null/undefined blank, others as JSON (no "[object Object]")', () => { + expect(cellText('plain')).toBe('plain'); + expect(cellText(null)).toBe(''); + expect(cellText(undefined)).toBe(''); + expect(cellText(42)).toBe('42'); + expect(cellText(false)).toBe('false'); + expect(cellText({ a: 1 })).toBe('{"a":1}'); + }); +}); + describe('snapshotFromText', () => { test('JSON content is parsed and typed json (content beats extension)', () => { const { data, format } = snapshotFromText('[{"a":1}]', 'https://x/data.txt'); diff --git a/src/core/dataset.ts b/src/core/dataset.ts index 774aa54..8b4e69b 100644 --- a/src/core/dataset.ts +++ b/src/core/dataset.ts @@ -235,6 +235,16 @@ export function tabularRows( return limit != null && limit >= 0 && rows.length > limit ? rows.slice(0, limit) : rows; } +/** + * Render a `tabularRows` cell for a preview table: strings as-is, null/undefined + * empty, anything else (numbers, booleans, nested values) as compact JSON. + */ +export function cellText(value: unknown): string { + if (value == null) return ''; + if (typeof value === 'string') return value; + return JSON.stringify(value); +} + /** * Orchestrate profiling for a dataset payload: compute `size` (always), decide * tabular vs N/A by format, and delegate to `profileData`. Identical for inline diff --git a/src/core/expr-validate.ts b/src/core/expr-validate.ts index 3241a93..cf0c55f 100644 --- a/src/core/expr-validate.ts +++ b/src/core/expr-validate.ts @@ -9,8 +9,8 @@ * grammar. It also extracts the `datum.` references so the UI can flag a typo * against the dataset's actual columns before the chart silently renders empty. * - * `vega-expression` is a pure dependency already in the bundle (Vega pulls it in for - * rendering), so importing it here adds nothing and keeps this module browser-free. + * `vega-expression` is declared as a direct dependency but adds no bundle weight — + * Vega already ships it for rendering; this import reuses the same copy. */ import { parseExpression } from 'vega-expression'; diff --git a/src/core/format-detection.ts b/src/core/format-detection.ts index bce6b10..7c611b9 100644 --- a/src/core/format-detection.ts +++ b/src/core/format-detection.ts @@ -10,7 +10,7 @@ */ export type DataFormat = 'json' | 'csv' | 'tsv' | 'topojson'; -export type DetectionConfidence = 'high' | 'medium' | 'low'; +type DetectionConfidence = 'high' | 'medium' | 'low'; export interface FormatDetection { format: DataFormat | null; diff --git a/src/core/json-format.ts b/src/core/json-format.ts index 9553331..b69ad31 100644 --- a/src/core/json-format.ts +++ b/src/core/json-format.ts @@ -12,7 +12,7 @@ import stringify from 'json-stringify-pretty-compact'; /** Width budget before an array/object wraps onto multiple lines. */ -export const DEFAULT_MAX_LINE = 80; +const DEFAULT_MAX_LINE = 80; /** Default indent (spaces) — matches the editor's default tab size (spec §07). */ const DEFAULT_INDENT = 2; diff --git a/src/core/profile.ts b/src/core/profile.ts index dd3c83b..32c894a 100644 --- a/src/core/profile.ts +++ b/src/core/profile.ts @@ -21,7 +21,7 @@ import { inferColumnType, isEmpty, isNumeric, type ColumnType } from './type-inference'; -export interface ColumnTypeInfo { +interface ColumnTypeInfo { /** The column name. */ name: string; /** The inferred display type for the column. */ diff --git a/src/core/storage-estimate.ts b/src/core/storage-estimate.ts index 7f96ba5..e9e0a81 100644 --- a/src/core/storage-estimate.ts +++ b/src/core/storage-estimate.ts @@ -16,7 +16,7 @@ */ /** A category of stored data and its measured size in bytes. */ -export interface StorageSegment { +interface StorageSegment { key: 'snippets' | 'datasets' | 'app'; /** User-facing label, centralized here so the component stays presentational. */ label: string; diff --git a/src/core/vega-themes.ts b/src/core/vega-themes.ts index eacfc8e..1898820 100644 --- a/src/core/vega-themes.ts +++ b/src/core/vega-themes.ts @@ -181,7 +181,7 @@ const PRESET_IDS = [ 'dark', ] as const; -export type ChartThemePresetId = (typeof PRESET_IDS)[number]; +type ChartThemePresetId = (typeof PRESET_IDS)[number]; /** Empty config — the stock sentinel resolves to "inject nothing". */ const STOCK_CONFIG: Config = {};