Per-chart export: copy/download spec and PNG/SVG from the preview header

This commit is contained in:
2026-06-10 18:30:31 +03:00
parent cad19445b0
commit 1791ee9f8d
15 changed files with 921 additions and 33 deletions
+7 -4
View File
@@ -194,10 +194,13 @@ ephemeral request state, not durable data:
- **`useConfirmStore`** — the blocking confirm dialog (the `window.confirm` replacement).
- **`useNotificationStore`** — non-blocking toasts (failed saves, etc.).
- **`useSettingsPopoverStore`** — which per-pane settings disclosure is open (one
at a time); its imperative `openSettingsPopover(id)` lets the Cmd/Ctrl+, shortcut
open the editor cluster. The disclosure widget contract (gear + non-modal popover,
not an ARIA menu; Esc/focus rules) is [10 · Interaction & Feedback](10-interaction-and-feedback.md) §5.
- **`useSettingsPopoverStore`** — the single-open registry for **all** pane-header
disclosures (the per-pane settings clusters and the per-chart Export control), keyed
by id so at most one is open at once; its imperative `openSettingsPopover(id)` lets the
Cmd/Ctrl+, shortcut open the editor cluster. Header disclosures share this registry rather
than each carrying their own open-state. The disclosure widget contract (trigger +
non-modal popover, not an ARIA menu; Esc/focus rules) is
[10 · Interaction & Feedback](10-interaction-and-feedback.md) §5.
Each pairs its store with a thin **imperative trigger** exported alongside the hook —
`confirm(opts): Promise<boolean>` and `notify(opts): string` — so orchestration/services can
@@ -101,6 +101,20 @@ async function rerender(node: HTMLElement, spec: TopLevelSpec, config: Config) {
- **Do** pass `actions: false`. Astrolabe owns its own export/copy affordances;
the library's overlay menu does not belong on the preview.
- **The per-chart export goes through the handle, not the raw view.** `RenderHandle`
exposes `toImageURL('png' | 'svg', { scale, background })` so the preview's Export
control can rasterize/serialize the live chart without any component importing
`vega-embed` or touching the `View` directly — the embedding boundary holds. PNG goes
via `view.toCanvas``blob:` URL (revoked after the download); SVG via `view.toSVG`
`data:` URL. Renderer-agnostic: both work from the SVG-backed LivePreview view, since
Vega draws to its own off-screen surface here. Two non-obvious details live in the
handle, not the caller: **(1) dpr-aware scale** — the PNG is drawn at
`scale × devicePixelRatio`, so a `1×` export is as crisp as the chart on a Retina
screen (raw `toImageURL` scaleFactor ignores dpr, so a naive 1× looks half-resolution
on a 2× display). **(2) background fill** — the chart config renders a transparent
background (so the on-screen chart shows the pane colour), which would make a naive
export transparent; an opaque colour is composited under the PNG canvas and added as a
full-bleed `<rect>` to the SVG. The spec-text exports (copy / `.vl.json`) need no view.
- **SVG is the default renderer, canvas is an opt-in for many-mark previews.** SVG is
crisp/inspectable/copyable and stays the default for the editor's LivePreview. But an
SVG chart renders one DOM node per mark, so a many-mark chart (e.g. the Chart Builder's
@@ -395,9 +395,15 @@ gear carries `aria-expanded` + `aria-controls`; Enter/Space toggle; **Esc closes
focus to the gear**; an outside click closes; at most one is open at a time; focus moves to
the first control on open (so `Cmd/Ctrl+,`, which opens the editor cluster, lands inside it).
Non-modal — **no focus trap** (unlike the feature modal above). The panel is portaled to
`<body>` and positioned `fixed` because the panes clip their content. _(Consulted via /council
→ NN/g #4 consistency, #6 recognition-over-recall, #8 minimalist; WAI-ARIA APG disclosure +
menu-and-menubar; Carbon popover/overflow-menu/text-toolbar. This bullet is the contract.)_
`<body>` and positioned `fixed` because the panes clip their content. The same primitive and
single-open registry serve any pane-header disclosure, not only settings: the per-chart
**Export** control (preview header — _Import & Export → Per-chart export_) is a disclosure
whose `group` holds a few **action buttons** (Copy / Download) plus option controls. A small
set of action buttons in a disclosure stays a `group` — an ARIA menu is reserved for true
`menuitem`/`menuitemcheckbox`/`menuitemradio` command lists, which this app does not use.
_(Consulted via /council → NN/g #4 consistency, #6 recognition-over-recall, #8 minimalist;
WAI-ARIA APG disclosure + menu-and-menubar; Carbon popover/overflow-menu/text-toolbar. This
bullet is the contract.)_
**Resolved — an error names the right fix, not a boilerplate one.** Don't staple a generic
remedy onto every failure. A missing dataset reference is **not** a JSON/spec syntax problem,
+36 -15
View File
@@ -25,7 +25,26 @@
Newest first. The at-a-glance build-order tracker is §4; per-item detail is §3. This log is
the quick "where are we" — read it first.
- **2026-06-10** — **Up next: 1B (per-chart export).**
- **2026-06-10** — **Up next: 1C (filter + calculate transforms), paired with 1D (data preview).**
- **1B · Per-chart export** shipped: an **Export** disclosure in the Live Preview header
(distinct from the workspace Export) — **Copy spec** + **Download JSON** (`.vl.json`) of
the shown text, and **Download PNG/SVG** of the live chart. Image output rides a new
`RenderHandle.toImageURL(format, { scale, background })` (PNG via `view.toCanvas`
`blob:` URL; SVG via `view.toSVG``data:` URL), so no component touches the Vega
`view`. Filenames derive from the snippet name — filesystem-safe, script-preserving
(pure `core/chart-export.ts`, tested). Home: the **preview header**, not the plan's
"library row / editor toolbar" — image export needs the live view; the
disclosure-of-controls (not an ARIA menu) mirrors `SettingsPopover`.
- **Export options (from first-round feedback):** PNG **Resolution** `1×/2×/3×` is a
multiplier of `devicePixelRatio`, so the default `1×` is Retina-crisp — the soft-1×
export was a dpr bug (raw `toImageURL` scaleFactor ignores dpr). **Background**
`Theme`(default)/`White`/`None` fixes transparent PNGs (the chart config is
transparent so the on-screen pane colour shows; export composites the chosen colour
under the PNG / adds an SVG `<rect>`). **Referenced data** `Inline`(default)/`Keep
refs` (shown only when the spec references saved datasets) inlines dataset values so
the exported spec renders standalone (`inlineReferencedDatasets`, tested).
- Spec §08 gained a _Per-chart export_ section (with the options); §04 cross-references
it; `architecture/05` §2 records the handle's dpr-aware scale + background compositing.
- **1A · Actionable hints** shipped: one-click fixes on guidance warnings
(`BuilderWarning.fixes` + `applyWarningFix`), council-reviewed, with focus/announce a11y.
- **Builder UX/perf batch** (from dogfooding the Superstore dataset) shipped: near-fullscreen
@@ -116,20 +135,22 @@ ghost buttons, remedy-in-button, polite "Applied: …" announcement, focus moved
removed button — resolution recorded in `architecture/10` §5. §06 "Guidance" amended to
document the one-click fixes.
**1B · Per-chart export**_highest value-to-effort overall_
Today export is workspace-backup only (§08); there is **no way to get one chart out**.
Source: Lyra §3.8. The renderer already holds the live Vega `view`
(`services/chart-renderer.ts`), so this is small:
**1B · Per-chart export**_done (2026-06-10)_
Today export was workspace-backup only (§08); there was **no way to get one chart out**.
Source: Lyra §3.8. Shipped as an **Export disclosure in the Live Preview header**:
- **Copy spec** (clipboard) + **Download `.vl.json`** for the active snippet (trivial — the
snippet _is_ the spec)
- **Download PNG / SVG** via `view.toImageURL('png' | 'svg')`
- _(optional)_ standalone HTML (ties to the BYO-cloud "private-move" direction)
- **Copy spec** (clipboard) + **Download `.vl.json`** of the currently-shown text.
- **Download PNG / SVG** of the live chart via a new `RenderHandle.toImageURL` wrapping
`view.toImageURL` (PNG at 2×`blob:` URL, revoked after download; SVG → `data:` URL),
so the embedding boundary holds — no component touches the raw view.
- Filenames from the snippet name, filesystem-safe and script-preserving (pure
`core/chart-export.ts`, tested). Standalone HTML left out (the deferred optional).
Home: a **snippet-level** "Export / Share" affordance (library row action or editor
toolbar), distinct from the workspace Export. _Spec impact: new export surface in §08;
arguably §02/§03 (where the affordance lives)._ Note: not strictly a _builder_ feature, but
the biggest single miss adjacent to it — sequence it here.
Home decision: the **preview header**, not the plan's original "library row / editor
toolbar" suggestion — the image formats need the live rendered view, and "export this
chart" reads best beside the chart. The widget is a disclosure-of-action-buttons (not an
ARIA menu), mirroring `SettingsPopover` and sharing its single-open registry. _Spec impact:
new §08 "Per-chart export" section; §04 cross-reference; `architecture/05` §2 handle note._
**1C · Filter (+ Calculate) dataset transforms**_closes the loop the builder's own warnings open_
The transform layer the builder doesn't touch: top-level `transform: []`. Source: Lyra
@@ -292,8 +313,8 @@ guardrail: _promote a control only when it is **both common AND awkward in JSON*
```
Phase 1 1A actionable hints ✓ done
1B per-chart export ← next: highest value-to-effort
1C filter (+ calculate) ← closes the loop on warnings the builder already emits
1B per-chart export ✓ done
1C filter (+ calculate) ← next: closes the loop on warnings the builder already emits
1D data preview ← pairs with 1C
1E expr-validate + autocomplete (with 1C)
Phase 2 2A value-or-field channels (Property model)
+4
View File
@@ -36,6 +36,10 @@ Behavior of the selected mode:
- The selected mode persists across sessions, stored in _Settings_ as `previewFitMode`.
- The default is the natural Original mode.
## Export control
The preview pane header also carries a per-chart **Export** control — a disclosure for copying or downloading the current chart's spec, or downloading its rendered image (PNG/SVG). It exports what the preview shows. The behavior is specified in _Import & Export → Per-chart export_; it lives in this header because the image formats are produced from the live rendered view.
## Rendering Contract
Before the chart is drawn, the spec shown in the editor is transformed into the spec actually rendered. Two deterministic transforms are applied in order. They are specified here because reproducing them faithfully is what makes references and fit modes behave correctly; the result is observable as the rendered chart.
+26 -1
View File
@@ -1,6 +1,8 @@
# 08 · Import & Export
Astrolabe lets a user back up or transfer their entire workspace as a single JSON file, and bring data back in by importing such a file. Both actions are triggered from header controls labelled **Import** and **Export**. Import always merges with existing data; it never replaces what is already stored.
Astrolabe lets a user back up or transfer their entire workspace as a single JSON file, and bring data back in by importing such a file. These two whole-workspace actions are triggered from header controls labelled **Import** and **Export**. Import always merges with existing data; it never replaces what is already stored.
Separately, a single chart can be exported on its own — its spec or its rendered image — from the Live Preview pane (see _Per-chart export_ below). That is distinct from the workspace Export: it gets _one_ chart out, not a backup of the library.
## Export
@@ -35,6 +37,29 @@ The downloaded file is a single JSON object: an envelope with a format `version`
- `exportedBy` — fixed identifier `"Astrolabe"`.
- `snippets` / `datasets` — arrays of complete records as defined in _Data Model_, each including its record `version` field. (This is the per-record schema version, not the envelope `version` above.)
## Per-chart export
A single chart can be exported on its own, separately from the whole-workspace Export above. The affordance is an **Export** control in the **Live Preview pane header** — placed there because exporting an image needs the chart that is currently rendered, and "export this chart" reads naturally beside the chart you are looking at. It is a disclosure that reveals four actions in two groups:
**Spec** (always available when a snippet is open):
- **Copy spec** — copies the currently-shown spec (draft or published, matching the editor's view) to the clipboard as JSON. Confirmed by a success toast, since a clipboard write is otherwise invisible.
- **Download JSON** — downloads the currently-shown spec as a `.vl.json` file.
- **Referenced data** (shown only when the spec references one or more saved datasets) — a choice between **Inline** (default) and **Keep refs**. _Inline_ replaces each `{ data: { name } }` reference with the dataset's actual values so the exported spec renders standalone (outside Astrolabe), leaving the authored sizing untouched; _Keep refs_ exports the reference as written (which only resolves inside Astrolabe). It applies to both spec actions. If inlining is requested but a referenced dataset is missing from the library, the export is declined with a clear error.
**Image** (available only when a chart is currently rendered — the actions are disabled, with an explanatory line, while the preview is empty or showing an error):
- **Download PNG** — a rasterized image of the chart as shown.
- **Download SVG** — a vector image of the chart as shown.
- **Resolution** (PNG) — `1×` / `2×` / `3×`, default `1×`. These are multipliers **of the display's pixel density**, so `1×` already matches on-screen crispness on a high-DPI (Retina) display; higher values produce larger images for print or zoom. (SVG is resolution-independent and ignores this.)
- **Background** — `Theme` (default) / `White` / `None`. The chart itself renders on a transparent background (so on screen it shows the pane colour); export therefore fills it: _Theme_ matches the active theme's background, _White_ is always white, _None_ keeps it transparent. Applies to both PNG and SVG.
Every format exports _what is on screen_: the same spec the editor shows and the same chart the preview renders (current fit mode included). With a _Theme_ background the exported image reflects the active light/dark theme.
- **Filename**: derived from the snippet's name, made filesystem-safe (whitespace and illegal characters normalized; letters of any script preserved), with the format as the extension — e.g. `sales-by-region.png`, `sales-by-region.vl.json`. A name with nothing usable falls back to `chart`. No date or `astrolabe-` prefix (unlike the workspace export) — the user is exporting one named chart and wants its name on the file.
- **Feedback**: a success toast naming the saved file (or confirming the copy); a clear error if the clipboard is blocked or the chart is not ready to rasterize.
- **Availability**: the Export control is disabled when no snippet is open. The image actions additionally require a live rendered chart.
## Import
Import lets the user pick a JSON file from their device; its contents are normalized, merged into the current workspace, and saved.