diff --git a/.claude/rules/releasing.md b/.claude/rules/releasing.md index cfab12b3..31601187 100644 --- a/.claude/rules/releasing.md +++ b/.claude/rules/releasing.md @@ -13,7 +13,7 @@ node scripts/release.mjs 6.15.0 # explicit version The script: 1. Bumps `version` in all 6 `packages/*/package.json` files -2. Updates `CHANGELOG.md` in each package from conventional commits +2. Copies the reviewed version entry from the root `CHANGELOG.md` into each package changelog; falls back to conventional commits when no reviewed entry exists 3. Commits as `release: openchart vX.Y.Z` 4. Creates per-package git tags via GitHub API (`core-vX.Y.Z`, `engine-vX.Y.Z`, etc.) so they get verified signatures 5. Pushes the commit to origin @@ -57,6 +57,8 @@ working tree is unchanged. CI and deploy-examples skip on release commits (prefix `release:`). The Release workflow handles build/typecheck/test verification before publishing. +Before releasing, write the target version section in the root changelog and review it in the release-preparation PR. Run `bun run test:scripts` to verify dry-run behavior and changelog selection. + ## Choosing bump type - `patch` for bug fixes only diff --git a/.claude/rules/svg-animation.md b/.claude/rules/svg-animation.md index 2e2a7111..00b69704 100644 --- a/.claude/rules/svg-animation.md +++ b/.claude/rules/svg-animation.md @@ -62,6 +62,6 @@ Data-update transitions live in `packages/vanilla/src/transition/` (barrel: `tra **Never-tween-opacity-on-updates rule.** Updated marks (same key in prev and next) should NOT have their opacity tweened. The renderer may have set `opacity="0"` on points suppressed under endpoint markers. Tweening opacity would make those points flash visible during the transition. Only enter/exit tweens use opacity. -**Path-morph freeze-and-crossfade on interruption.** When `.update()` is called while a line/area series is mid-morph, the interpolated point array matches neither layout's data. Re-matching is intractable. Instead: `snapshot()` captures the current interpolated path `d` string, creates a ghost carrying that frozen path, crossfades to the new final path. Rects/points retarget smoothly from their current interpolated position. +**Path-morph interruption.** Line snapshots retain sampled points and point keys, so the next update retargets from visible geometry when correspondence is available. Area snapshots (and line snapshots without point correspondence) retain the current path as a frozen ghost and crossfade to the new final path. Rects and points retarget from their sampled position. Preserve missing-observation gaps in both paths. **Arc transitions.** Pie/donut slices tween angles and radii (plus the pie center), rebuilding the path per frame via the engine's `buildArcPath` — geometry-based, so interruption retargets like rects (the snapshot stores angles/radii/center; no freeze-and-crossfade needed). Snap-to-final writes the engine's exact `mark.path`. Exiting slices ghost and collapse to their midpoint angle while fading. Sparklines are no longer excluded from transitions — a live-updating sparkline is the canonical real-time tile. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 84ef2985..8ebe0ca7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,13 +38,17 @@ jobs: LINT_PID=$! bun run build || { echo "::error::Build failed"; exit 1; } wait $LINT_PID || { echo "::error::Lint failed"; exit 1; } + bun run check:boundaries || exit 1 + bun run check:library-bundle || exit 1 bun run typecheck || { echo "::error::Typecheck failed"; exit 1; } # Fail if spec.ts changed without regenerating the schema / llms.txt # (runs after build: generate:llms imports the built core). bun run check:generated || { echo "::error::Generated schema or llms.txt is stale. Run: bun run generate"; exit 1; } - name: Test - run: bun run test + run: | + bun run test + bun run test:scripts invariants: name: Playwright layout invariants @@ -85,42 +89,3 @@ jobs: name: playwright-report path: playwright-report/ retention-days: 7 - - workbench: - name: Workbench production browser QA - runs-on: ubuntu-latest - timeout-minutes: 15 - permissions: - contents: read - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - name: Setup - uses: ./.github/actions/setup-bun - - name: Install browsers - run: bunx playwright install --with-deps chromium webkit - - name: Test production artifact - run: | - bun run check:boundaries - bun run build - bun run --cwd apps/studio typecheck - bun run --cwd apps/studio lint - bun run --cwd apps/studio test - bun run --cwd apps/studio test:e2e - bun run check:library-bundle - - name: Verify independent checkout and nested hosting - run: | - bun run extract:studio "$RUNNER_TEMP/OpenChart Studio" - cd "$RUNNER_TEMP/OpenChart Studio" - STUDIO_TEST_BASE_PATH=/tools/studio/ bun run test:e2e - - name: Upload browser evidence - if: failure() - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: workbench-browser-evidence - path: | - apps/studio/test-results/ - ${{ runner.temp }}/OpenChart Studio/test-results/ - retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 85f980a2..4435ecf7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,7 +36,9 @@ jobs: run: bun run typecheck - name: Test - run: bun run test + run: | + bun run test + bun run test:scripts - name: Extract version from commit message id: version diff --git a/CHANGELOG.md b/CHANGELOG.md index 47f51567..89df0062 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this ## [Unreleased] +## [8.9.0] - 2026-10-14 + +### Added + +- **authoring APIs:** field metadata, structured design diagnostics, deterministic composition suggestions, guarded spec patches, observation comparisons and explicit dataset bindings. +- **reader:** a framework-independent reader controller with preview/pins and sampled geometry, plus persistent observation identities. +- **motion:** opt-in `fluid`, `subtle` and `legacy` timing presets; bounded spring motion; compatible SVG scene retention and coordinated label transitions. +- **responsive:** final-tick preservation, wrapped endpoint summaries, growing chrome and priority-column mobile tables. Added the opt-in `clarity` theme and `legacyEditorial` preset. + ### Fixed - **library:** rejected updates preserve the last valid chart/table; animated maps, tile maps and tables resume resizing after entrance completion. Sankey updates no longer replay entrances, and disabling tooltips no longer disables callbacks. @@ -15,13 +24,34 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this - **export:** specialized SVG/raster exports render settled geometry without transient hover effects; maps preserve the current camera and clipping. Font embedding respects inline typography and inherited span fonts. Table CSV includes every filtered/sorted row across pagination. - **typography:** bar lists and Sankey use shared chrome wrapping and measured font styles, keeping headings inside narrow containers. +- **rendering:** retained canvas charts keep axes and annotations above the canvas after updates and resizing; donut labels follow their slice when the center moves. +- **reader:** initial Left/Up navigation starts at the final observation; group links accept ordinary labels such as `constructor`. +- **scales:** continuous legends, layered domains and map point palettes now agree with the measurements and classes used to draw marks. Window/density grouping preserves typed keys, and cumulative sums skip booleans. +- **diagnostics:** `onDiagnostic` accepts inline `data.values` and transformed fields while keeping fixes guarded against the authored document. +- **motion:** physically equivalent spring parameters remain finite even at very large or small scales. + ### Changed -- **graph!:** `highlight()` now layers over the sticky category filter instead of replacing it. In v8.0 the two shared one slot and the last writer won, so hovering a custom legend row threw away the filter the reader had just clicked. The effective emphasis is now the intersection of filter and highlight, except that a highlight disjoint from the filter previews on its own and a highlight matching no nodes is a no-op layer. Two consequences: `clearHighlight()` releases only the transient highlight and returns to the filtered view (clearing the filter is `setActiveCategories([])`), and `onHighlightChange` reports the effective set, so it can fire with an array where it previously fired `null`. Legend `active` flags now track the filter alone. See [migration guide](docs/migrating-v8.md#24-graph-highlight-layers-over-the-category-filter-81). +- **Studio:** the standalone authoring app moved to [OpenStudio](https://github.com/tryopendata/openstudio). OpenChart ships the reusable visualization library and public authoring APIs; Studio UI, dependencies, persistence and hosting live in their own repository. +- **tooling:** development uses Node.js 24, TypeScript 7 and Vitest 5, with the TypeScript 6 compatibility API retained for build tooling. React and optional Three.js development dependencies were updated. Published package runtime requirements are unchanged. -### Features +## [8.8.0] - 2026-09-28 + +### Added - **map:** `geo.zoom` lets readers zoom and pan a GeoMap. Opt-in (`zoom: true`, or `{ maxZoom?: number, controls?: boolean }`), so existing maps are unchanged. Ctrl/cmd + wheel and trackpad pinch zoom around the cursor while a plain wheel still scrolls the page (with a brief hint), drag pans, double-click zooms, two-finger pinch zooms on touch (one finger pans only once zoomed in), and `+`/`-`/arrows/`0` work when the map has focus. On-screen zoom in / zoom out / reset buttons follow the theme in light and dark. Zoom is clamped to 1x..`maxZoom` (default 12), panning can't leave the frame, points keep their screen size, and gestures share one camera with `zoomTo`/`setCamera`/focus. New `onCameraChange` mount option (and `` / `camera-change` / `oncamerachange` in React, Vue, Svelte). +- **docs:** Built for Agents examples and walkthrough. + +Releases 8.1.1 through 8.7.0 are recorded in the [package changelog](packages/core/CHANGELOG.md). + +## [8.1.0] - 2026-08-15 + +### Changed + +- **graph!:** `highlight()` now layers over the sticky category filter instead of replacing it. In v8.0 the two shared one slot and the last writer won, so hovering a custom legend row threw away the filter the reader had just clicked. The effective emphasis is now the intersection of filter and highlight, except that a highlight disjoint from the filter previews on its own and a highlight matching no nodes is a no-op layer. Two consequences: `clearHighlight()` releases only the transient highlight and returns to the filtered view (clearing the filter is `setActiveCategories([])`), and `onHighlightChange` reports the effective set, so it can fire with an array where it previously fired `null`. Legend `active` flags now track the filter alone. See [migration guide](docs/migrating-v8.md#24-graph-highlight-layers-over-the-category-filter-81). + +### Added + - **graph:** `seedNode` on `GraphSpec` — name the graph's seed/root node (`seedNode: 'id'`, or `{ id, style }`) and it gets a ring in the theme text color, a 2px stroke, and an always-on label, plus exemption from focus dimming (highlight, legend filter, hover, and selection; search still dims it). Only the seed is exempt; its neighbors dim normally, so a hub seed doesn't light half the graph. Radius stays with the `nodeSize` encoding unless `style.radius` is set. `style` layers under any `nodeOverrides` entry for the same id, and an id matching no node warns rather than throwing. Unrelated to `layout.seed`. - **graph:** `legend: false` set on the spec (rather than as a mount option) now actually suppresses the built-in legend. The field was typed and documented but only the mount option was read, so a spec-only host got two legends. The mount option still wins when both are set. - **graph:** `onWarn` mount option routes compiler advisories (unknown `seedNode` id, deprecations) somewhere other than `console.warn`. Each distinct message is emitted at most once per instance, so a standing warning doesn't repeat on every `update()`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 54af173e..749d934f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,11 +28,14 @@ it only changes bundled type-checking sources, not Ladle's runtime. ```bash bun run dev # Start the examples dev server (Ladle) at localhost:6006 bun run build # Build all packages in dependency order -bun run test # Run all tests +bun run test # Run library and example tests +bun run test:scripts # Test release tooling bun run typecheck # Type-check all packages bun run lint # Lint all packages ``` +The browser authoring app lives in [OpenStudio](https://github.com/tryopendata/openstudio), with its own dependencies, tests and GitHub Pages deployment. + The dev server uses [Ladle](https://ladle.dev) to render stories from `examples/src/`. Each story is a standalone React component that demonstrates a feature. Open it in a browser to see your changes live. ## Project structure @@ -44,11 +47,13 @@ openchart/ engine/ Headless compiler: spec in, layout out vanilla/ DOM rendering: SVG charts, HTML tables react/ React components wrapping vanilla + vue/ Vue 3 components wrapping vanilla + svelte/ Svelte 5 components wrapping vanilla examples/ Ladle stories for visual development docs/ Documentation ``` -Dependency direction: `core <- engine <- vanilla <- react`. Never import upstream. +Dependency direction: `core <- engine <- vanilla <- react / vue / svelte`. Never import upstream. For a deeper look at how the packages fit together, see [docs/architecture.md](docs/architecture.md). @@ -111,18 +116,17 @@ export function computeMyTypeLabels( ### Step 4: Register the renderer -`index.ts` composes compute + labels into a `ChartRenderer` and registers it: +`index.ts` composes compute + labels into an exported `ChartRenderer`: ```ts // packages/engine/src/charts//index.ts import type { Mark } from "@opendata-ai/openchart-core"; -import { registerChartRenderer } from "../registry"; import type { ChartRenderer } from "../registry"; import { computeMyTypeMarks } from "./compute"; import { computeMyTypeLabels } from "./labels"; -const myTypeRenderer: ChartRenderer = (spec, scales, chartArea, strategy) => { +export const myTypeRenderer: ChartRenderer = (spec, scales, chartArea, strategy) => { const marks = computeMyTypeMarks(spec, scales, chartArea, strategy); const labels = computeMyTypeLabels(marks, chartArea); for (let i = 0; i < marks.length && i < labels.length; i++) { @@ -131,26 +135,18 @@ const myTypeRenderer: ChartRenderer = (spec, scales, chartArea, strategy) => { return marks as Mark[]; }; -registerChartRenderer("", myTypeRenderer); - export { computeMyTypeMarks } from "./compute"; export { computeMyTypeLabels } from "./labels"; ``` ### Step 5: Wire it up -1. Add the import to `packages/engine/src/index.ts`: - - ```ts - import "./charts//index"; - ``` - - This import triggers self-registration at module load time. +1. Import `myTypeRenderer` in `packages/engine/src/charts/builtin.ts` and add `registerChartRenderer("", myTypeRenderer)` inside `registerBuiltinRenderers()`. Keeping registration there also lets tests restore built-ins after clearing the registry. -2. Add the type string to the `ChartType` union in `packages/core/src/types/spec.ts`: +2. Add the type string to the `MarkType` union in `packages/core/src/types/spec.ts`: ```ts - export type ChartType = 'line' | 'area' | 'bar' | ... | ''; + export type MarkType = 'line' | 'area' | 'bar' | ... | ''; ``` 3. Add encoding rules in `packages/core/src/types/encoding.ts` (which channels are required/optional for this type). @@ -266,7 +262,7 @@ import { describe, it, expect } from "vitest"; // Fixture factories at the top of the file function createTestSpec(): ChartSpec { return { - type: "line", + mark: "line", data: [{ x: 1, y: 2 }], encoding: { x: { field: "x", type: "quantitative" }, @@ -287,7 +283,7 @@ Run tests from the repo root: ```bash bun run test # All tests -bun run test -- --watch # Watch mode +bunx vitest --watch # Watch mode bun run test -- path/to/file # Single file ``` @@ -313,7 +309,7 @@ node scripts/release.mjs 8.1.0-rc.1 # explicit version node scripts/release.mjs minor --dry-run # preview, writes nothing ``` -The script bumps every `packages/*/package.json`, generates changelogs from conventional commits, commits as `release: openchart vX.Y.Z`, creates per-package tags (`core-vX.Y.Z`, ...) via the GitHub API, and pushes. +The script bumps every `packages/*/package.json`, copies the reviewed version entry from the root changelog (or generates notes from conventional commits when no entry exists), commits as `release: openchart vX.Y.Z`, creates per-package tags (`core-vX.Y.Z`, ...) via the GitHub API, and pushes. Don't edit `package.json` versions by hand and don't create release tags yourself. Pushing the `release:` commit triggers the Release workflow, which builds, tests, rewrites `workspace:*` dependencies to real versions, and publishes all six packages to npm. Prerelease versions (anything with a `-`) publish under the `next` dist-tag, so they never take `latest`. diff --git a/README.md b/README.md index d7432006..5c171a3e 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ Release candidates publish under the `next` dist-tag, so a plain install always | Doc | What's in it | | --- | ------------ | -| [Studio workbench](docs/workbench.md) | Build and deploy the portable authoring application | +| [OpenStudio](https://github.com/tryopendata/openstudio) | Separate browser authoring app and GitHub Pages demo | | [Getting started](docs/getting-started.md) | Hands-on tutorial from first chart to custom themes, tables, and graphs | | [Chart types](docs/chart-types.md) | Catalog of every mark and spec type, with when to reach for each | | [Spec reference](docs/spec-reference.md) | Field-by-field type reference for ChartSpec, TableSpec, and GraphSpec | diff --git a/apps/studio/.gitignore b/apps/studio/.gitignore deleted file mode 100644 index 1a639a63..00000000 --- a/apps/studio/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -node_modules/ -dist/ -test-results/ -playwright-report/ -*.tsbuildinfo diff --git a/apps/studio/README.md b/apps/studio/README.md deleted file mode 100644 index 3649a36a..00000000 --- a/apps/studio/README.md +++ /dev/null @@ -1,28 +0,0 @@ -# OpenChart Studio - -A private, client-side visualization authoring app. OpenChart remains a separate framework-independent library. Studio owns its React/Motion UI, data import, local projects, datasets, fonts and build tooling; it consumes only public OpenChart package exports. - -## Develop here - -From the repository root, run `bun install --frozen-lockfile` and `bun run build`. Then, from this directory: - -```sh -bun run dev # http://127.0.0.1:6007/ -bun run typecheck -bun run lint -bun run test -bun run test:e2e # builds and tests the production artifact -bun run build # dist/ -``` - -## Move or deploy independently - -From the repository root, `bun run extract:studio /tmp/my-studio-checkout` creates an independent checkout with packed public library builds. It verifies package identity, typechecks, tests and builds outside the monorepo. Commit the generated lockfile in the new repository. Replace packed dependencies with pinned registry releases once a compatible version is published. - -In that checkout, `STUDIO_TEST_BASE_PATH=/tools/studio/ bun run test:e2e` verifies nested hosting, including fonts, charts, maps, image downloads and project recovery. Serve `dist/` on a static HTTPS host at an origin or trailing-slash path. Cache hashed assets immutably and revalidate HTML. Development, preview and QA servers are not production servers. - -## Documents and hosting - -Chart documents are ordinary `VizSpec`. Download Studio project preserves source rows, preparation and chart together; the JSON drawer exposes the chart alone. Plain CSV/TSV/JSON accepts 5 MB and 20,000 rows; project backups accept 20 MB. Files stay in the browser. Local drafts belong to the current origin and browser; download a project before moving hosts. - -The app supports data correction/filtering/summaries, composition changes, axis/color/annotation controls, table and map controls, undo, drafts, recommendations, design checks, responsive preview and exports. It does not provide accounts, cloud persistence, collaboration, publish/revoke or an embed service. A future host should adapt project load/save around the app boundary. See the source repository's `docs/workbench.md` for the capability matrix and library contracts. diff --git a/apps/studio/biome.json b/apps/studio/biome.json deleted file mode 100644 index 890d3797..00000000 --- a/apps/studio/biome.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "$schema": "https://biomejs.dev/schemas/2.0.0/schema.json", - "files": { - "includes": [ - "src/**" - ] - }, - "formatter": { - "indentStyle": "space", - "indentWidth": 2, - "lineWidth": 100 - }, - "css": { - "formatter": { - "enabled": true - }, - "linter": { - "enabled": true - } - }, - "javascript": { - "formatter": { - "quoteStyle": "single", - "trailingCommas": "all", - "semicolons": "always" - } - }, - "linter": { - "rules": { - "recommended": true, - "correctness": { - "noUnusedImports": "error", - "noUnusedVariables": "error", - "useExhaustiveDependencies": "warn" - }, - "style": { - "noNonNullAssertion": "off", - "useImportType": "error" - }, - "suspicious": { - "noExplicitAny": "on", - "noAssignInExpressions": "off" - } - } - } -} diff --git a/apps/studio/e2e/authoring.spec.ts b/apps/studio/e2e/authoring.spec.ts deleted file mode 100644 index 9fcf1607..00000000 --- a/apps/studio/e2e/authoring.spec.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { readFile } from 'node:fs/promises'; -import { expect, test, type Page } from '@playwright/test'; - -async function inspector(page: Page) { - const edit = page.getByRole('button', { name: 'Edit', exact: true }); - if (await edit.isVisible()) await edit.click(); - const panel = page - .getByRole('dialog', { name: 'Refine your visualization' }) - .or(page.locator('.studio-inspector:visible')); - await panel.getByRole('tab', { name: 'Design', exact: true }).click(); - return panel; -} -async function closeSheet(page: Page) { - const dialog = page.getByRole('dialog'); - if (await dialog.isVisible()) await dialog.getByRole('button', { name: /^Close / }).click(); -} -async function importData(page: Page, data: string) { - await page.getByRole('button', { name: 'Import data' }).click(); - await page.getByLabel('Or paste data').fill(data); - await page.getByLabel('Import action').selectOption('new'); - await page.getByRole('button', { name: 'Preview your data →' }).click(); -} -async function projectBackup(page: Page) { - const download = page.waitForEvent('download'); - await page.getByRole('button', { name: 'Download Studio project' }).click(); - return JSON.parse(await readFile((await (await download).path())!, 'utf8')); -} -test.beforeEach(async ({ page }, info) => { - if (info.project.metadata.cpuThrottle) { - const session = await page.context().newCDPSession(page); - await session.send('Emulation.setCPUThrottlingRate', { rate: 4 }); - } - await page.goto('./'); -}); - -test('summaries retain source data, export/reopen, and compose into an annotated chart', async ({ - page, -}, info) => { - await importData( - page, - 'region,year,value\nNorth,2024,10\nNorth,2025,\nSouth,2024,20\nSouth,2025,40', - ); - await page.getByRole('combobox', { name: 'Summary', exact: true }).selectOption('mean'); - await page.getByLabel('Summarize field').selectOption('value'); - await page.getByRole('group', { name: 'Group by' }).getByLabel('region', { exact: true }).check(); - await page.getByRole('button', { name: 'Apply preparation' }).click(); - await page.getByRole('button', { name: 'Prepared data', exact: true }).click(); - await expect(page.locator('.studio-prepared-table tbody tr')).toHaveText(['North10', 'South30']); - await page.getByRole('button', { name: 'JSON document' }).click(); - await page.getByRole('button', { name: 'Apply document' }).click(); - const backup = await projectBackup(page); - expect(backup.source).toHaveLength(4); - expect(backup.preparation.summary.op).toBe('mean'); - await importData(page, JSON.stringify(backup)); - await page.getByRole('button', { name: 'Prepared data', exact: true }).click(); - await expect(page.locator('.studio-prepared-table tbody tr')).toHaveText(['North10', 'South30']); - const panel = await inspector(page); - await panel.getByText('Change composition', { exact: true }).click(); - await panel.getByRole('combobox', { name: 'Composition', exact: true }).selectOption('bar'); - await panel.getByLabel('Horizontal field').selectOption('value'); - await panel.getByLabel('Vertical field').selectOption('region'); - await panel.getByRole('button', { name: 'Apply composition' }).click(); - await panel.getByText('Annotations & reference lines', { exact: true }).click(); - await panel.getByLabel('Reference axis').selectOption('x'); - await panel.getByLabel('X anchor').fill('20'); - await panel.getByLabel('Annotation text').fill('Target'); - await panel.getByRole('button', { name: 'Add annotation' }).click(); - await closeSheet(page); - await page.getByRole('button', { name: 'Canvas', exact: true }).click(); - await expect(page.locator('.studio-paper')).toContainText('Target'); - const svg = page.waitForEvent('download'); - await page.getByLabel('Export format').selectOption('svg'); - await page.getByRole('button', { name: 'Export ↗' }).click(); - expect(await readFile((await (await svg).path())!, 'utf8')).toContain('Target'); - const final = await projectBackup(page); - expect(final.source).toHaveLength(4); - expect(final.spec.data.map((row: { value: number }) => row.value)).toEqual([10, 30]); - await page.screenshot({ path: info.outputPath('authored-comparison.png'), fullPage: true }); -}); - -test('replacement data can remap columns while retaining design and undoing the transaction', async ({ - page, -}) => { - await importData(page, 'region,value\nA,10\nB,30'); - const panel = await inspector(page); - await panel.getByRole('textbox', { name: 'Headline', exact: true }).fill('A reusable comparison'); - await panel.getByRole('textbox', { name: 'Headline', exact: true }).press('Tab'); - await closeSheet(page); - await page.getByRole('button', { name: 'Import data' }).click(); - await page.getByLabel('Or paste data').fill('area,amount\nC,70\nD,90'); - await page.getByLabel('Import action').selectOption('replace'); - const mapping = page.getByRole('group', { name: 'Match replacement columns' }); - await mapping.getByRole('combobox', { name: 'region', exact: true }).selectOption('area'); - await mapping.getByRole('combobox', { name: 'value', exact: true }).selectOption('amount'); - await page.getByRole('button', { name: 'Preview your data →' }).click(); - await expect(page.getByLabel('region, row 1', { exact: true })).toHaveValue('C'); - let backup = await projectBackup(page); - expect(backup.spec.chrome.title).toBe('A reusable comparison'); - expect(backup.spec.type).toBe('table'); - await page.getByRole('button', { name: 'Undo', exact: true }).click(); - await expect(page.getByLabel('region, row 1', { exact: true })).toHaveValue('A'); - backup = await projectBackup(page); - expect(backup.source.map((row: { value: number }) => row.value)).toEqual([10, 30]); -}); - -test('phone inspector preserves unfinished composition and annotation choices', async ({ - page, -}) => { - let panel = await inspector(page); - await panel.getByText('Change composition', { exact: true }).click(); - await panel.getByRole('combobox', { name: 'Composition', exact: true }).selectOption('point'); - await panel.getByLabel('Horizontal field').selectOption('revenue'); - await panel.getByText('Annotations & reference lines', { exact: true }).click(); - await panel.getByLabel('Y anchor').fill('300'); - await panel.getByLabel('Annotation text').fill('Unfinished annotation'); - await closeSheet(page); - panel = await inspector(page); - if (!(await panel.getByRole('combobox', { name: 'Composition', exact: true }).isVisible())) - await panel.getByText('Change composition', { exact: true }).click(); - if (!(await panel.getByLabel('Annotation text').isVisible())) - await panel.getByText('Annotations & reference lines', { exact: true }).click(); - await expect(panel.getByRole('combobox', { name: 'Composition', exact: true })).toHaveValue( - 'point', - ); - await expect(panel.getByLabel('Horizontal field')).toHaveValue('revenue'); - await expect(panel.getByLabel('Y anchor')).toHaveValue('300'); - await expect(panel.getByLabel('Annotation text')).toHaveValue('Unfinished annotation'); - await closeSheet(page); - await page.getByRole('button', { name: 'Data', exact: true }).click(); - await page.getByLabel('Filter field').selectOption('company'); - await importData(page, 'region,value\nNew,10'); - await expect(page.getByLabel('Filter field')).toHaveValue(''); -}); - -test('prepared tables align sparse JSON rows and counts discard percentage formatting', async ({ - page, -}, info) => { - await importData( - page, - JSON.stringify({ - type: 'table', - data: [ - { name: 'A', value: 0.2 }, - { value: 0.3, name: 'A', note: 'estimate' }, - { name: 'B', note: 'missing' }, - ], - fields: { value: { type: 'number', unit: '%', representation: 'proportion' } }, - columns: [{ key: 'name' }, { key: 'value', format: '.1%' }], - pagination: true, - }), - ); - await page.getByRole('button', { name: 'Prepared data', exact: true }).click(); - await expect(page.locator('.studio-prepared-table thead')).toHaveText('namevaluenote'); - await expect(page.locator('.studio-prepared-table tbody tr')).toHaveText([ - 'A0.2—', - 'A0.3estimate', - 'B—missing', - ]); - await page.getByRole('combobox', { name: 'Summary', exact: true }).selectOption('count'); - await page.getByRole('group', { name: 'Group by' }).getByLabel('name', { exact: true }).check(); - await page.getByRole('button', { name: 'Apply preparation' }).click(); - const panel = await inspector(page); - await expect(panel.getByLabel('Rows per page')).toHaveValue('25'); - await expect(panel.getByLabel('Phone layout')).toHaveValue('cards'); - await closeSheet(page); - await page.getByRole('button', { name: 'Canvas', exact: true }).click(); - await expect(page.locator('.studio-paper')).toContainText('Row count'); - await expect(page.locator('.studio-paper')).not.toContainText('%'); - await page.screenshot({ path: info.outputPath('count-table.png'), fullPage: true }); - await page.getByRole('button', { name: 'Data', exact: true }).click(); - await page.getByRole('button', { name: 'Restore all source rows' }).click(); - const backup = await projectBackup(page); - expect(backup.spec.columns).toHaveLength(2); - expect(backup.spec.columns[1].format).toBe('.1%'); -}); - -test('map palette controls change actual fills and keep missing-data hatches valid during updates', async ({ - page, -}, info) => { - const patterns = page.getByRole('button', { name: 'Patterns', exact: true }); - if (await patterns.isVisible()) await patterns.click(); - await page.getByRole('button', { name: /A geographic lens/ }).click(); - const country = page.locator('.oc-map-feature[data-feature-id="840"]'); - const before = await country.getAttribute('fill'); - const panel = await inspector(page); - await panel.getByRole('combobox', { name: 'Map palette', exact: true }).selectOption('teal'); - await panel.getByRole('combobox', { name: 'Number format', exact: true }).selectOption('$,.0f'); - await closeSheet(page); - const invalidPaint = await page.evaluate(async () => { - const failures: string[] = []; - for (let i = 0; i < 30; i++) { - await new Promise(requestAnimationFrame); - for (const mark of document.querySelectorAll('.oc-map-feature[data-nodata]')) { - const fill = mark.getAttribute('fill') ?? ''; - const id = /^url\(#(.+)\)$/.exec(fill)?.[1]; - if (!id || !document.getElementById(id)) failures.push(fill); - } - } - return failures; - }); - expect(invalidPaint).toEqual([]); - await expect(country).not.toHaveAttribute('fill', before!); - const labels = await page - .locator('.studio-paper svg text') - .evaluateAll((nodes) => - nodes - .filter((node) => /^\$[\d,]+$/.test(node.textContent ?? '')) - .map((node) => ({ - text: node.textContent, - left: node.getBoundingClientRect().left, - right: node.getBoundingClientRect().right, - top: node.getBoundingClientRect().top, - })), - ); - expect(labels.length).toBeGreaterThan(0); - for (let i = 1; i < labels.length; i++) - if (Math.abs(labels[i].top - labels[i - 1].top) < 1) - expect(labels[i].left).toBeGreaterThan(labels[i - 1].right); - await page.screenshot({ path: info.outputPath('authored-map.png'), fullPage: true }); -}); - -test('table search keeps keyboard focus after filtering changes the preview height', async ({ - page, -}) => { - const patterns = page.getByRole('button', { name: 'Patterns', exact: true }); - if (await patterns.isVisible()) await patterns.click(); - await page.getByRole('button', { name: /The details matter/ }).click(); - const search = page.getByRole('searchbox', { name: 'Search table' }); - await search.fill('MSFT'); - await expect(page.locator('.studio-paper tbody tr')).toHaveCount(1); - // The table's ResizeObserver follows the debounced search render by 100 ms. - await page.waitForTimeout(250); - await expect(search).toBeFocused(); - await search.press('Backspace'); - await expect(search).toHaveValue('MSF'); -}); diff --git a/apps/studio/e2e/studio.spec.ts b/apps/studio/e2e/studio.spec.ts deleted file mode 100644 index 90308683..00000000 --- a/apps/studio/e2e/studio.spec.ts +++ /dev/null @@ -1,331 +0,0 @@ -import { expect, test, type Page } from '@playwright/test'; - -async function openInspector(page: Page) { - const edit = page.getByRole('button', { name: 'Edit', exact: true }); - if (await edit.isVisible()) await edit.click(); - return page - .getByRole('dialog', { name: 'Refine your visualization' }) - .or(page.locator('.studio-inspector:visible')); -} - -async function closeSheet(page: Page) { - const sheet = page.getByRole('dialog'); - if (await sheet.isVisible()) await sheet.getByRole('button', { name: /^Close / }).click(); -} - -async function choosePattern(page: Page, name: string) { - const patterns = page.getByRole('button', { name: 'Patterns', exact: true }); - if (await patterns.isVisible()) await patterns.click(); - await page - .getByRole('button', { name: new RegExp(name) }) - .filter({ visible: true }) - .click(); -} - -test.beforeEach(async ({ page }, info) => { - if (info.project.metadata.cpuThrottle) { - const session = await page.context().newCDPSession(page); - await session.send('Emulation.setCPUThrottlingRate', { rate: 4 }); - } - await page.goto('./'); - await expect(page.getByRole('button', { name: 'Import data' })).toBeVisible(); - await page.evaluate(() => document.fonts.ready); -}); - -test('all eight compositions render within the viewport and keep stable height', async ({ - page, -}, info) => { - const errors: string[] = []; - page.on('pageerror', (error) => errors.push(error.message)); - const patterns = [ - 'Change over time', - 'A clear comparison', - 'Parts of the whole', - 'Spread & outliers', - 'Before & after', - 'A geographic lens', - 'The details matter', - 'Build a narrative', - ]; - for (const [index, name] of patterns.entries()) { - await choosePattern(page, name); - const paper = page.locator('.studio-paper'); - await expect(paper).toBeVisible(); - await expect(page.getByRole('alert')).toHaveCount(0); - await expect - .poll(async () => paper.evaluate((el) => el.getBoundingClientRect().height)) - .toBeGreaterThan(200); - // The renderer removes its entrance state after the finite settling window. - // Do not await every document Animation.finished: Chromium can suspend those - // promises for sparklines inside closed mobile-table details. - await expect(paper.locator('.oc-animate')).toHaveCount(0); - await page.evaluate(async () => { - await new Promise((resolve) => - requestAnimationFrame(() => requestAnimationFrame(() => resolve())), - ); - }); - const height = await paper.evaluate((el) => el.getBoundingClientRect().height); - expect(height).toBeLessThan(1800); - await page.evaluate(async () => { - for (let i = 0; i < 8; i++) - await new Promise((resolve) => requestAnimationFrame(() => resolve())); - }); - expect( - Math.abs((await paper.evaluate((el) => el.getBoundingClientRect().height)) - height), - ).toBeLessThan(1); - expect( - await page.evaluate( - () => document.documentElement.scrollWidth - document.documentElement.clientWidth, - ), - ).toBeLessThanOrEqual(1); - if (index === 2) - await expect(paper.locator('svg text').filter({ hasText: /^2022$/ })).toBeVisible(); - if (index === 5) await expect(paper.locator('.oc-map-feature').first()).toBeVisible(); - if (index === 0) - await expect(paper.locator('svg text').filter({ hasText: /^2024$/ })).toBeVisible(); - await page.screenshot({ - path: info.outputPath(`composition-${index + 1}.png`), - fullPage: true, - }); - } - expect(errors).toEqual([]); -}); - -test('imports, corrects data, recommends a chart, edits, undoes, reloads, and exports without JSON', async ({ - page, -}) => { - await page.getByRole('button', { name: 'Import data' }).click(); - await page - .getByLabel('Or paste data') - .fill('country,value,code\nJapan,84.7,001\nBrazil,75.8,002'); - await page.getByRole('button', { name: 'Preview your data' }).click(); - await expect(page.getByLabel('code, row 1')).toHaveValue('001'); - await page.getByLabel('Type of value').selectOption('text'); - await expect(page.getByLabel('Type of value')).toHaveValue('text'); - await page.getByLabel('Type of value').selectOption('number'); - await page.getByLabel('value, row 1').fill('85.1'); - await page.getByLabel('value, row 1').press('Tab'); - await expect(page.getByLabel('value, row 1')).toHaveValue('85.1'); - const inspector = await openInspector(page); - await inspector.getByRole('tab', { name: 'Suggest' }).click(); - await inspector - .getByRole('combobox', { name: 'Categories', exact: true }) - .selectOption('country'); - await inspector.getByRole('combobox', { name: 'Measure', exact: true }).selectOption('value'); - await inspector - .getByRole('article') - .filter({ hasText: 'Compare on a shared baseline' }) - .getByRole('button', { name: 'Use this composition' }) - .click(); - await inspector.getByRole('tab', { name: 'Design', exact: true }).click(); - await inspector - .getByRole('textbox', { name: 'Headline', exact: true }) - .fill('A considered comparison'); - await inspector.getByRole('textbox', { name: 'Headline', exact: true }).press('Tab'); - await closeSheet(page); - await page.getByRole('button', { name: 'Canvas', exact: true }).click(); - await expect(page.locator('.studio-paper')).toContainText('A considered comparison'); - await page.getByRole('button', { name: 'Undo', exact: true }).click(); - await expect(page.locator('.studio-paper')).toContainText('What does your data show?'); - await page.getByRole('button', { name: 'Redo', exact: true }).click(); - await expect(page.getByText('Saved in this browser', { exact: true })).toHaveCount(1); - await page.reload(); - await expect(page.locator('.studio-paper')).toContainText('A considered comparison'); - await page.getByLabel('Export format').selectOption('svg'); - const download = page.waitForEvent('download'); - await page.getByRole('button', { name: 'Export ↗', exact: true }).click(); - const exported = await download; - expect(await exported.failure()).toBeNull(); - const stream = await exported.createReadStream(); - const chunks = []; - for await (const chunk of stream!) chunks.push(chunk); - const svg = Buffer.concat(chunks).toString(); - expect(svg).toContain('A considered comparison'); - expect(svg).toContain('85.1'); - expect(svg).not.toContain('NaN'); -}); - -test('invalid imports are actionable and do not replace the current document', async ({ page }) => { - await page.getByRole('button', { name: 'Import data' }).click(); - await page.getByLabel('Or paste data').fill('value,value\n10,20'); - await page.getByRole('button', { name: 'Preview your data' }).click(); - await expect(page.getByRole('alert')).toContainText('unique'); - await closeSheet(page); - await expect(page.locator('.studio-paper')).toContainText('Amazon pulls away'); -}); - -test('keyboard inspection pins exact source values and Escape dismisses them', async ({ page }) => { - const chart = page.locator('.studio-chart'); - await chart.focus(); - await page.keyboard.press('ArrowRight'); - await page.keyboard.press('Enter'); - const details = page.getByRole('region', { name: 'Observation details' }); - await expect(details).toContainText('Pinned evidence'); - await expect(details).toContainText('280'); - await expect(details).toContainText('Amazon'); - await page.keyboard.press('Escape'); - await expect(details).toContainText('Inspect the evidence'); -}); - -test('reduced motion and resize round trips preserve the terminal chart', async ({ page }) => { - await page.emulateMedia({ reducedMotion: 'reduce' }); - await page.getByRole('button', { name: /Replay/ }).click(); - const svg = page.locator('.studio-paper svg.oc-chart'); - const before = await svg.getAttribute('viewBox'); - await page.getByLabel('Preview width').selectOption('320'); - await expect.poll(() => svg.getAttribute('viewBox')).not.toBeNull(); - await page.getByLabel('Preview width').selectOption('768'); - await expect(svg).toHaveAttribute('viewBox', before!); - await expect(page.locator('.studio-paper svg text').filter({ hasText: /^2024$/ })).toBeVisible(); -}); - -test('mobile inspector choices survive dismissal and data-view exports retain preview geometry', async ({ - page, -}) => { - let inspector = await openInspector(page); - await inspector.getByRole('tab', { name: 'Suggest' }).click(); - await inspector - .getByRole('combobox', { name: 'What do you want to show?' }) - .selectOption('change'); - await inspector.getByRole('combobox', { name: 'Date', exact: true }).selectOption('year'); - await closeSheet(page); - inspector = await openInspector(page); - await expect(inspector.getByRole('combobox', { name: 'Date', exact: true })).toHaveValue('year'); - await closeSheet(page); - const svg = page.locator('.studio-paper svg.oc-chart'); - const width = (await svg.getAttribute('viewBox'))!.split(' ')[2]; - await page.getByRole('button', { name: 'Data', exact: true }).click(); - await page.getByLabel('revenue, row 1', { exact: true }).fill('281'); - await page.getByLabel('revenue, row 1', { exact: true }).press('Tab'); - await expect.poll(async () => (await svg.getAttribute('viewBox'))!.split(' ')[2]).toBe(width); - const download = page.waitForEvent('download'); - await page.getByRole('button', { name: 'Export ↗', exact: true }).click(); - const stream = await (await download).createReadStream(); - const chunks = []; - for await (const chunk of stream!) chunks.push(chunk); - expect(Buffer.concat(chunks).toString()).toContain(`viewBox="0 0 ${width} `); - await page.getByRole('button', { name: 'Canvas', exact: true }).click(); - const slider = page.getByRole('slider', { name: 'Preview through year' }); - await slider.fill('2'); - await page.getByLabel('Export format').selectOption('csv'); - const csvDownload = page.waitForEvent('download'); - await page.getByRole('button', { name: 'Export ↗', exact: true }).click(); - const csvStream = await (await csvDownload).createReadStream(); - const csvChunks = []; - for await (const chunk of csvStream!) csvChunks.push(chunk); - const csv = Buffer.concat(csvChunks).toString(); - expect(csv).toContain('2024-01-01'); - expect(csv.trim().split('\n')).toHaveLength(31); - await slider.fill('5'); - await page.getByRole('button', { name: 'Data', exact: true }).click(); - await expect(page.getByLabel('revenue, row 1', { exact: true })).toHaveValue('281'); -}); - -test('table pins never display stale evidence after corrections or imports', async ({ page }) => { - await choosePattern(page, 'The details matter'); - const paper = page.locator('.studio-paper'); - await expect(paper.locator('table')).toBeVisible(); - await expect(paper.locator('.oc-animate')).toHaveCount(0); - const more = page.locator('.studio-paper summary').first(); - if (page.viewportSize()!.width < 600) { - await expect(more).toBeVisible(); - // Resize can rebuild priority details. Measure both boxes atomically and - // retry missing controls rather than dereferencing a detached element. - await expect - .poll(() => - paper.evaluate((element) => { - const bounds = element.getBoundingClientRect(); - const control = element.querySelector('summary')?.getBoundingClientRect(); - return !!control && control.width > 0 && control.right <= bounds.right; - }), - ) - .toBe(true); - await more.click(); - await expect(page.locator('.studio-paper details[open]')).toContainText('Company'); - await more.click(); - } - await page.locator('.studio-paper').getByText('MSFT', { exact: true }).first().click(); - await expect(page.getByRole('region', { name: 'Observation details' })).toContainText( - 'Pinned evidence', - ); - await page.getByRole('button', { name: 'Data', exact: true }).click(); - await page.getByLabel('price, row 2', { exact: true }).fill('1'); - await page.getByLabel('price, row 2', { exact: true }).press('Tab'); - await page.getByRole('button', { name: 'Canvas', exact: true }).click(); - await expect(page.getByRole('region', { name: 'Observation details' })).not.toContainText( - '378.91', - ); - await expect(page.getByRole('region', { name: 'Observation details' })).toContainText( - 'Inspect the evidence', - ); - await page.locator('.studio-paper').getByText('MSFT', { exact: true }).first().click(); - await expect(page.getByRole('region', { name: 'Observation details' })).toContainText( - 'Pinned evidence', - ); - await page.getByRole('button', { name: 'Import data' }).click(); - await page.getByLabel('Or paste data').fill('name,value\nNew row,7'); - await page.getByRole('button', { name: 'Preview your data' }).click(); - await page.getByRole('button', { name: 'Canvas', exact: true }).click(); - await expect(page.getByRole('region', { name: 'Observation details' })).not.toContainText('MSFT'); -}); - -test('malformed chrome imports and saved drafts are recoverable', async ({ page }) => { - const malformed = JSON.stringify({ - mark: 'bar', - data: [{ name: 'A', value: 1 }], - encoding: { - x: { field: 'name', type: 'nominal' }, - y: { field: 'value', type: 'quantitative' }, - }, - chrome: { title: { text: { bad: true } } }, - }); - await page.getByRole('button', { name: 'Import data' }).click(); - await page.getByLabel('Or paste data').fill(malformed); - await page.getByRole('button', { name: 'Preview your data' }).click(); - await expect(page.getByRole('alert')).toContainText('string text'); - await closeSheet(page); - await expect(page.locator('.studio-paper')).toContainText('Amazon pulls away'); - await page.evaluate((raw) => localStorage.setItem('openchart.studio.draft.v1', raw), malformed); - await page.reload(); - await expect( - page.getByText('The saved draft could not be opened.', { exact: false }), - ).toBeVisible(); - await expect(page.locator('.studio-paper')).toContainText('Amazon pulls away'); - expect(await page.evaluate(() => localStorage.getItem('openchart.studio.draft.v1'))).toBe( - malformed, - ); - await page.getByRole('button', { name: 'Import data' }).click(); - await page.getByLabel('Or paste data').fill('name,value\nRecovered,1'); - await page.getByRole('button', { name: 'Preview your data' }).click(); - await expect(page.getByLabel('name, row 1', { exact: true })).toHaveValue('Recovered'); -}); - -test('map PNG exports preserve the authored background and meaningful country details', async ({ - page, -}) => { - await choosePattern(page, 'A geographic lens'); - await page - .getByRole('listitem', { name: 'United States of America: $80,035', exact: true }) - .click(); - const details = page.getByRole('region', { name: 'Observation details' }); - await expect(details).toContainText('United States of America'); - await expect(details).toContainText('GDP per capita (USD)'); - await page.getByLabel('Export format').selectOption('png'); - const download = page.waitForEvent('download'); - await page.getByRole('button', { name: 'Export ↗', exact: true }).click(); - const stream = await (await download).createReadStream(); - const chunks = []; - for await (const chunk of stream!) chunks.push(chunk); - const pixel = await page.evaluate(async (base64) => { - const image = new Image(); - image.src = `data:image/png;base64,${base64}`; - await image.decode(); - const canvas = document.createElement('canvas'); - canvas.width = image.width; - canvas.height = image.height; - const context = canvas.getContext('2d')!; - context.drawImage(image, 0, 0); - return Array.from(context.getImageData(1, 1, 1, 1).data); - }, Buffer.concat(chunks).toString('base64')); - expect(pixel).toEqual([255, 255, 255, 255]); -}); diff --git a/apps/studio/index.html b/apps/studio/index.html deleted file mode 100644 index 6139dfd9..00000000 --- a/apps/studio/index.html +++ /dev/null @@ -1,2 +0,0 @@ - -OpenChart Studio
diff --git a/apps/studio/package.json b/apps/studio/package.json deleted file mode 100644 index 0b041838..00000000 --- a/apps/studio/package.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "name": "@opendata-ai/studio", - "version": "0.1.0", - "private": true, - "type": "module", - "scripts": { - "dev": "vite --host 127.0.0.1 --port 6007 --strictPort", - "build": "vite build", - "preview": "vite preview --host 127.0.0.1 --port 6008 --strictPort", - "typecheck": "tsc --noEmit", - "lint": "biome check src", - "test": "vitest run", - "test:e2e": "playwright test" - }, - "dependencies": { - "@opendata-ai/openchart-core": "workspace:*", - "@opendata-ai/openchart-engine": "workspace:*", - "@opendata-ai/openchart-vanilla": "workspace:*", - "d3-dsv": "3.0.1", - "motion": "14.1.0", - "react": "19.3.0", - "react-dom": "19.3.0", - "world-atlas": "2.0.2", - "gifenc": "1.0.3" - }, - "devDependencies": { - "@biomejs/biome": "2.4.4", - "@playwright/test": "1.59.1", - "@types/d3-dsv": "3.0.7", - "@types/node": "24.19.2", - "@types/react": "19.2.14", - "@types/react-dom": "19.2.3", - "typescript": "7.0.2", - "vite": "7.3.1", - "vitest": "5.0.4" - } -} diff --git a/apps/studio/playwright.config.ts b/apps/studio/playwright.config.ts deleted file mode 100644 index d05a0d64..00000000 --- a/apps/studio/playwright.config.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { defineConfig } from '@playwright/test'; - -/** Exercise the deployable artifact, including WebKit and a throttled phone. */ -export default defineConfig({ - testDir: './e2e', - outputDir: './test-results', - fullyParallel: false, - workers: 1, - retries: 0, - timeout: 45_000, - reporter: [['list'], ['html', { outputFolder: 'playwright-report', open: 'never' }]], - use: { - baseURL: `http://127.0.0.1:6008${process.env.STUDIO_TEST_BASE_PATH || '/'}`, - trace: 'retain-on-failure', - screenshot: 'only-on-failure', - video: 'retain-on-failure', - deviceScaleFactor: 1, - }, - projects: [ - { - name: 'studio-desktop', - use: { browserName: 'chromium', viewport: { width: 1280, height: 900 } }, - }, - { - name: 'studio-phone', - use: { browserName: 'chromium', viewport: { width: 390, height: 844 }, hasTouch: true }, - metadata: { cpuThrottle: 4 }, - }, - { - name: 'studio-narrow', - use: { browserName: 'chromium', viewport: { width: 320, height: 740 }, hasTouch: true }, - }, - { - name: 'studio-webkit', - use: { browserName: 'webkit', viewport: { width: 390, height: 844 }, hasTouch: true }, - }, - ], - webServer: { - command: 'bun run build && node scripts/serve-dist.mjs', - url: `http://127.0.0.1:6008${process.env.STUDIO_TEST_BASE_PATH || '/'}`, - reuseExistingServer: false, - timeout: 120_000, - }, -}); diff --git a/apps/studio/scripts/serve-dist.mjs b/apps/studio/scripts/serve-dist.mjs deleted file mode 100644 index 049f43ac..00000000 --- a/apps/studio/scripts/serve-dist.mjs +++ /dev/null @@ -1,28 +0,0 @@ -/** Local QA server that proves the artifact works under a real nested mount. */ -import { createServer } from 'node:http'; -import { readFile } from 'node:fs/promises'; -import { extname, resolve, sep } from 'node:path'; -const root = resolve('dist'); -const base = process.env.STUDIO_TEST_BASE_PATH || '/'; -const types = { - '.html': 'text/html', - '.js': 'text/javascript', - '.css': 'text/css', - '.woff2': 'font/woff2', - '.json': 'application/json', - '.svg': 'image/svg+xml', -}; -createServer(async (request, response) => { - try { - const path = decodeURIComponent(new URL(request.url, 'http://localhost').pathname); - if (!path.startsWith(base)) throw new Error('Outside mount'); - const file = resolve(root, path.slice(base.length) || 'index.html'); - if (!file.startsWith(root + sep)) throw new Error('Outside artifact'); - const contents = await readFile(file); - response.writeHead(200, { 'Content-Type': types[extname(file)] ?? 'application/octet-stream' }); - response.end(contents); - } catch { - response.writeHead(404); - response.end('Not found'); - } -}).listen(6008, '127.0.0.1', () => console.log(`Studio QA: http://127.0.0.1:6008${base}`)); diff --git a/apps/studio/src/AuthoringDrafts.tsx b/apps/studio/src/AuthoringDrafts.tsx deleted file mode 100644 index f2f45df6..00000000 --- a/apps/studio/src/AuthoringDrafts.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import type { DataRow } from '@opendata-ai/openchart-core'; -import { - createContext, - type Dispatch, - type ReactNode, - type SetStateAction, - useContext, - useEffect, - useState, -} from 'react'; - -type Drafts = Record; -const Context = createContext<{ - drafts: Drafts; - setDrafts: Dispatch>; -} | null>(null); - -/** Both desktop and sheet inspectors edit the same unfinished, document-scoped choices. */ -export function AuthoringDrafts({ source, children }: { source: DataRow[]; children: ReactNode }) { - const [drafts, setDrafts] = useState({}); - // A replacement source invalidates pending roles and anchors from the old document. - useEffect(() => { - void source; - setDrafts({}); - }, [source]); - return {children}; -} -export function useAuthoringDraft(key: string, initial: T): [T, (next: T) => void] { - const context = useContext(Context); - if (!context) throw new Error('Authoring controls require a Studio document.'); - return [ - (context.drafts[key] as T | undefined) ?? initial, - (next) => context.setDrafts((current) => ({ ...current, [key]: next })), - ]; -} diff --git a/apps/studio/src/ChartControls.tsx b/apps/studio/src/ChartControls.tsx deleted file mode 100644 index ae16d1d9..00000000 --- a/apps/studio/src/ChartControls.tsx +++ /dev/null @@ -1,511 +0,0 @@ -import type { - Annotation, - ChartSpec, - Encoding, - EncodingChannel, - LabelSpec, -} from '@opendata-ai/openchart-core'; -import { useMemo, useState } from 'react'; -import { useAuthoringDraft } from './AuthoringDrafts'; -import { - type Composition, - type CompositionRoles, - compose, - compositionNames, - fieldChannel, - visibleFields, -} from './composition'; -import type { SupportedSpec } from './Preview'; -import { changeSeriesColor, seriesColors } from './series-style'; - -export function CompositionControl({ - spec, - onChange, -}: { - spec: SupportedSpec; - onChange: (spec: SupportedSpec) => void; -}) { - const fields = visibleFields(spec); - const chart = !('type' in spec) ? spec : null; - const [kind, setKind] = useAuthoringDraft('composition.kind', 'bar'); - const [roles, setRoles] = useAuthoringDraft('composition.roles', { - x: chart?.encoding.x?.field ?? fields[0] ?? '', - y: chart?.encoding.y?.field ?? fields[1] ?? '', - series: - chart?.encoding.color && 'field' in chart.encoding.color ? chart.encoding.color.field : '', - end: chart?.encoding.x2?.field ?? '', - }); - const [error, setError] = useState(''); - return ( -
- Change composition -

- Keep your data, headline and source. Choose roles explicitly. Axes, labels and annotations - are reset for the new composition; Undo restores them. Lines use dates or ordered categories - along X. -

- - {kind !== 'table' && - ( - [ - 'x', - ...(kind === 'histogram' ? [] : ['y']), - 'series', - ...(kind === 'range' ? ['end'] : []), - ] as Array - ).map((role) => ( - - ))} - {error && ( -

- {error} -

- )} - -
- ); -} -const formats = [ - ['', 'Automatic'], - [',.0f', 'Whole numbers'], - [',.2f', 'Two decimals'], - ['.1%', 'Percent (fraction × 100)'], - ['.1f', 'Percentage values (0–100)'], - ['$,.0f', 'US dollars'], - ['.3~s', 'Compact'], -]; -export function NumberFormat({ - value, - onChange, - label = 'Number format', -}: { - value?: string; - onChange: (value: string | undefined) => void; - label?: string; -}) { - return ( - - ); -} -export function ChartControls({ - chart, - onChange, -}: { - chart: ChartSpec; - onChange: (spec: ChartSpec) => void; -}) { - const fields = visibleFields(chart); - const mark = typeof chart.mark === 'string' ? chart.mark : chart.mark.type; - const setEncoding = (encoding: Encoding) => onChange({ ...chart, encoding } as ChartSpec); - const setChannel = (axis: 'x' | 'y', channel: EncodingChannel) => - setEncoding({ ...chart.encoding, [axis]: channel }); - const color = - chart.encoding.color && 'field' in chart.encoding.color ? chart.encoding.color : null; - const colors = useMemo(() => seriesColors(chart), [chart]); - const series = color ? [...new Set(chart.data.map((row) => String(row[color.field] ?? '')))] : []; - return ( - <> -
- Fields & axes - {(['x', 'y'] as const).map((axis) => { - const channel = chart.encoding[axis]; - if (!channel) return null; - const config = typeof channel.axis === 'object' && channel.axis ? channel.axis : {}; - return ( -
- -
- {axis.toUpperCase()} axis options - - - {channel.type === 'quantitative' ? ( - <> - - setChannel(axis, { ...channel, format, axis: { ...config, format } }) - } - /> - - - ) : channel.type === 'nominal' || channel.type === 'ordinal' ? ( - - ) : null} -
-
- ); - })} - {mark === 'range' && ( - - )} -
-
- Series, labels & small multiples - - {color?.type === 'nominal' && - series.length <= 20 && - series - .filter((name) => colors.has(name)) - .map((name) => ( - - ))} - - - {mark === 'area' && ( - - )} -
- - - ); -} -function AnnotationControls({ - chart, - onChange, -}: { - chart: ChartSpec; - onChange: (spec: ChartSpec) => void; -}) { - const [kind, setKind] = useAuthoringDraft<'refline' | 'text'>('annotation.kind', 'refline'); - const [axis, setAxis] = useAuthoringDraft<'x' | 'y'>('annotation.axis', 'y'); - const [x, setX] = useAuthoringDraft('annotation.x', ''); - const [y, setY] = useAuthoringDraft('annotation.y', ''); - const [text, setText] = useAuthoringDraft('annotation.text', ''); - const [error, setError] = useState(''); - const coordinate = (key: 'x' | 'y', value: string) => { - if (!value.trim()) throw new Error('Enter an anchor value.'); - if (chart.encoding[key]?.type === 'quantitative') { - if (!Number.isFinite(Number(value))) - throw new Error('Use a numeric anchor on a numeric axis.'); - return Number(value); - } - return value; - }; - return ( -
- Annotations & reference lines - {chart.annotations?.map((annotation, index) => ( -
- - {annotation.type === 'text' ? annotation.text : (annotation.label ?? annotation.type)} - - -
- ))} - - {kind === 'refline' && ( - - )} - {(kind === 'text' || axis === 'x') && ( - - )} - {(kind === 'text' || axis === 'y') && ( - - )} - - {error &&

{error}

} - -
- ); -} diff --git a/apps/studio/src/DataEditor.tsx b/apps/studio/src/DataEditor.tsx deleted file mode 100644 index de55eff2..00000000 --- a/apps/studio/src/DataEditor.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import type { DataRow, FieldMetadata } from '@opendata-ai/openchart-core'; -import { useState } from 'react'; -import { coerceField, type FieldType, fieldType } from './document'; - -export function DataEditor({ - data, - fields, - onChange, -}: { - data: DataRow[]; - fields?: Record; - onChange: (rows: DataRow[], fields?: Record) => void; -}) { - const [error, setError] = useState(''); - const [page, setPage] = useState(0); - const columns = [...new Set(data.flatMap(Object.keys))].filter( - (key) => fields?.[key]?.role !== 'identity', - ); - const start = Math.min(page * 20, Math.max(0, Math.floor((data.length - 1) / 20) * 20)); - const changeType = (key: string, type: FieldType) => { - try { - onChange( - data.map((row) => ({ ...row, [key]: coerceField(row[key], type) })), - { ...fields, [key]: { ...fields?.[key], type } }, - ); - setError(''); - } catch (reason) { - setError(reason instanceof Error ? reason.message : String(reason)); - } - }; - return ( -
-
-

Your data

- - {data.length} rows · {columns.length} fields - -
-

- Edit a value or correct a field type. Blank cells remain missing observations. -

- {error && ( -

- {error} -

- )} -
- - - - {columns.map((key) => ( - - ))} - - - - {data.slice(start, start + 20).map((row, index) => ( - - {columns.map((key) => ( - - ))} - - ))} - -
- {key} - -
- {Array.isArray(row[key]) ? ( - {JSON.stringify(row[key])} - ) : ( - { - if (event.target.value === String(row[key] ?? '')) return; - try { - const next = [...data]; - next[start + index] = { - ...row, - [key]: coerceField(event.target.value, fieldType(data, key, fields)), - }; - onChange(next, fields); - setError(''); - } catch (reason) { - setError(reason instanceof Error ? reason.message : String(reason)); - } - }} - /> - )} -
-
- {!data.length &&

No observations. Import data to start a visualization.

} -
- - - {data.length ? start + 1 : 0}–{Math.min(start + 20, data.length)} of {data.length} - - -
-
- ); -} diff --git a/apps/studio/src/Inspector.tsx b/apps/studio/src/Inspector.tsx deleted file mode 100644 index c40bb197..00000000 --- a/apps/studio/src/Inspector.tsx +++ /dev/null @@ -1,367 +0,0 @@ -import type { - DesignDiagnostic, - DesignQuestion, - SpecPatch, - VizSpec, -} from '@opendata-ai/openchart-core'; -import { type RecommendationRoles, recommendCompositions } from '@opendata-ai/openchart-engine'; -import { motion } from 'motion/react'; -import { useId } from 'react'; -import { ChartControls, CompositionControl } from './ChartControls'; -import { isSupported, type SupportedSpec } from './Preview'; -import { MapControls, TableControls } from './TableMapControls'; - -type InspectorTab = 'design' | 'recommend' | 'review'; - -/** Panels stay mounted so changing tabs never discards chosen field roles. */ -export function Inspector({ - spec, - tab, - onTabChange, - onChange, - diagnostics, - onPreview, - question, - setQuestion, - roles, - setRoles, -}: { - spec: SupportedSpec; - tab: InspectorTab; - onTabChange: (tab: InspectorTab) => void; - onChange: (spec: VizSpec) => void; - diagnostics: DesignDiagnostic[]; - onPreview: (patch: SpecPatch) => void; - question: DesignQuestion; - setQuestion: (question: DesignQuestion) => void; - roles: RecommendationRoles; - setRoles: (roles: RecommendationRoles) => void; -}) { - const id = useId(); - const tabs = ['design', 'recommend', 'review'] as const; - return ( - <> -
{ - const direction = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0; - if (!direction && event.key !== 'Home' && event.key !== 'End') return; - event.preventDefault(); - const index = - event.key === 'Home' - ? 0 - : event.key === 'End' - ? 2 - : (tabs.indexOf(tab) + direction + tabs.length) % tabs.length; - onTabChange(tabs[index]); - (event.currentTarget.querySelectorAll('button')[index] as HTMLButtonElement).focus(); - }} - > - {tabs.map((name) => ( - - ))} -
- {tabs.map((name) => ( - - ))} - - ); -} - -function chromeText(spec: SupportedSpec, key: 'title' | 'subtitle' | 'source' | 'footer'): string { - const value = spec.chrome?.[key]; - return typeof value === 'string' ? value : (value?.text ?? ''); -} -export function DesignInspector({ - spec, - onChange, -}: { - spec: SupportedSpec; - onChange: (next: VizSpec) => void; -}) { - const chart = !('type' in spec) ? spec : null; - return ( -
-

Make the message clear

-

Start with what readers should understand.

- {(['title', 'subtitle', 'source', 'footer'] as const).map((key) => ( -