mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Per-chart export: copy/download spec and PNG/SVG from the preview header
This commit is contained in:
@@ -194,10 +194,13 @@ ephemeral request state, not durable data:
|
|||||||
|
|
||||||
- **`useConfirmStore`** — the blocking confirm dialog (the `window.confirm` replacement).
|
- **`useConfirmStore`** — the blocking confirm dialog (the `window.confirm` replacement).
|
||||||
- **`useNotificationStore`** — non-blocking toasts (failed saves, etc.).
|
- **`useNotificationStore`** — non-blocking toasts (failed saves, etc.).
|
||||||
- **`useSettingsPopoverStore`** — which per-pane settings disclosure is open (one
|
- **`useSettingsPopoverStore`** — the single-open registry for **all** pane-header
|
||||||
at a time); its imperative `openSettingsPopover(id)` lets the Cmd/Ctrl+, shortcut
|
disclosures (the per-pane settings clusters and the per-chart Export control), keyed
|
||||||
open the editor cluster. The disclosure widget contract (gear + non-modal popover,
|
by id so at most one is open at once; its imperative `openSettingsPopover(id)` lets the
|
||||||
not an ARIA menu; Esc/focus rules) is [10 · Interaction & Feedback](10-interaction-and-feedback.md) §5.
|
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 —
|
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
|
`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;
|
- **Do** pass `actions: false`. Astrolabe owns its own export/copy affordances;
|
||||||
the library's overlay menu does not belong on the preview.
|
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
|
- **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
|
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
|
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
|
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).
|
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
|
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
|
`<body>` and positioned `fixed` because the panes clip their content. The same primitive and
|
||||||
→ NN/g #4 consistency, #6 recognition-over-recall, #8 minimalist; WAI-ARIA APG disclosure +
|
single-open registry serve any pane-header disclosure, not only settings: the per-chart
|
||||||
menu-and-menubar; Carbon popover/overflow-menu/text-toolbar. This bullet is the contract.)_
|
**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
|
**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,
|
remedy onto every failure. A missing dataset reference is **not** a JSON/spec syntax problem,
|
||||||
|
|||||||
@@ -25,7 +25,26 @@
|
|||||||
Newest first. The at-a-glance build-order tracker is §4; per-item detail is §3. This log is
|
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.
|
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
|
- **1A · Actionable hints** shipped: one-click fixes on guidance warnings
|
||||||
(`BuilderWarning.fixes` + `applyWarningFix`), council-reviewed, with focus/announce a11y.
|
(`BuilderWarning.fixes` + `applyWarningFix`), council-reviewed, with focus/announce a11y.
|
||||||
- **Builder UX/perf batch** (from dogfooding the Superstore dataset) shipped: near-fullscreen
|
- **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
|
removed button — resolution recorded in `architecture/10` §5. §06 "Guidance" amended to
|
||||||
document the one-click fixes.
|
document the one-click fixes.
|
||||||
|
|
||||||
**1B · Per-chart export** — _highest value-to-effort overall_
|
**1B · Per-chart export** — _done (2026-06-10)_
|
||||||
Today export is workspace-backup only (§08); there is **no way to get one chart out**.
|
Today export was workspace-backup only (§08); there was **no way to get one chart out**.
|
||||||
Source: Lyra §3.8. The renderer already holds the live Vega `view`
|
Source: Lyra §3.8. Shipped as an **Export disclosure in the Live Preview header**:
|
||||||
(`services/chart-renderer.ts`), so this is small:
|
|
||||||
|
|
||||||
- **Copy spec** (clipboard) + **Download `.vl.json`** for the active snippet (trivial — the
|
- **Copy spec** (clipboard) + **Download `.vl.json`** of the currently-shown text.
|
||||||
snippet _is_ the spec)
|
- **Download PNG / SVG** of the live chart via a new `RenderHandle.toImageURL` wrapping
|
||||||
- **Download PNG / SVG** via `view.toImageURL('png' | 'svg')`
|
`view.toImageURL` (PNG at 2× → `blob:` URL, revoked after download; SVG → `data:` URL),
|
||||||
- _(optional)_ standalone HTML (ties to the BYO-cloud "private-move" direction)
|
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
|
Home decision: the **preview header**, not the plan's original "library row / editor
|
||||||
toolbar), distinct from the workspace Export. _Spec impact: new export surface in §08;
|
toolbar" suggestion — the image formats need the live rendered view, and "export this
|
||||||
arguably §02/§03 (where the affordance lives)._ Note: not strictly a _builder_ feature, but
|
chart" reads best beside the chart. The widget is a disclosure-of-action-buttons (not an
|
||||||
the biggest single miss adjacent to it — sequence it here.
|
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_
|
**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
|
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
|
Phase 1 1A actionable hints ✓ done
|
||||||
1B per-chart export ← next: highest value-to-effort
|
1B per-chart export ✓ done
|
||||||
1C filter (+ calculate) ← closes the loop on warnings the builder already emits
|
1C filter (+ calculate) ← next: closes the loop on warnings the builder already emits
|
||||||
1D data preview ← pairs with 1C
|
1D data preview ← pairs with 1C
|
||||||
1E expr-validate + autocomplete (with 1C)
|
1E expr-validate + autocomplete (with 1C)
|
||||||
Phase 2 2A value-or-field channels (Property model)
|
Phase 2 2A value-or-field channels (Property model)
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ Behavior of the selected mode:
|
|||||||
- The selected mode persists across sessions, stored in _Settings_ as `previewFitMode`.
|
- The selected mode persists across sessions, stored in _Settings_ as `previewFitMode`.
|
||||||
- The default is the natural Original mode.
|
- 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
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# 08 · Import & Export
|
# 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
|
## Export
|
||||||
|
|
||||||
@@ -35,6 +37,29 @@ The downloaded file is a single JSON object: an envelope with a format `version`
|
|||||||
- `exportedBy` — fixed identifier `"Astrolabe"`.
|
- `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.)
|
- `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
|
||||||
|
|
||||||
Import lets the user pick a JSON file from their device; its contents are normalized, merged into the current workspace, and saved.
|
Import lets the user pick a JSON file from their device; its contents are normalized, merged into the current workspace, and saved.
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
/* ChartExport — per-chart export disclosure in the Live Preview header (spec §08). */
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Trigger — a compact ghost button (icon + label), matching the header utilities. */
|
||||||
|
.trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 var(--space-3);
|
||||||
|
border: var(--border-width) solid transparent;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background var(--dur-fast) var(--ease),
|
||||||
|
color var(--dur-fast) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trigger:hover:not(:disabled) {
|
||||||
|
background: var(--layer-01);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trigger[aria-expanded='true'] {
|
||||||
|
background: var(--layer-02);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trigger:focus-visible {
|
||||||
|
outline: 2px solid var(--focus);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trigger:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.triggerIcon {
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The disclosed panel — portaled to <body>, positioned `fixed` (top/right set
|
||||||
|
inline) so it escapes the panes' overflow clipping. Mirrors SettingsPopover. */
|
||||||
|
.pop {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 264px;
|
||||||
|
max-width: min(320px, 92vw);
|
||||||
|
padding: var(--space-3);
|
||||||
|
background: var(--layer-01);
|
||||||
|
border: var(--border-width) solid var(--border-strong);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupTitle {
|
||||||
|
margin: var(--space-3) 0 var(--space-1);
|
||||||
|
padding: 0 var(--space-2);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The first group heading sits flush with the panel top. */
|
||||||
|
.groupTitle:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action rows — full-width, left-aligned ghost buttons. */
|
||||||
|
.action {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-2) var(--space-2);
|
||||||
|
border: var(--border-width) solid transparent;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--dur-fast) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action:hover:not(:disabled) {
|
||||||
|
background: var(--layer-02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action:focus-visible {
|
||||||
|
outline: 2px solid var(--focus);
|
||||||
|
outline-offset: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action:disabled {
|
||||||
|
color: var(--text-placeholder);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The monospace extension hint, trailing the JSON action. */
|
||||||
|
.ext {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action:disabled .ext {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin: var(--space-2) 0 0;
|
||||||
|
padding: 0 var(--space-2);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
/**
|
||||||
|
* Chart export — the per-chart "Export" affordance in the Live Preview header
|
||||||
|
* (spec §08 → Per-chart export). Distinct from the header's *workspace* Export
|
||||||
|
* (whole-library backup): this gets *one* chart out — its spec to the clipboard or
|
||||||
|
* a `.vl.json` file, or its rendered image as PNG/SVG.
|
||||||
|
*
|
||||||
|
* It lives in the preview header on purpose: the image formats need the live Vega
|
||||||
|
* `view` (held by LivePreview), and "export this chart" reads most naturally beside
|
||||||
|
* the chart you're looking at. The spec actions export the *shown* text (draft or
|
||||||
|
* published), so every format matches what's on screen.
|
||||||
|
*
|
||||||
|
* A few options ride along, because export has real choices to make:
|
||||||
|
* - **Resolution** (PNG) — a device-pixel-ratio-aware multiplier, so the default
|
||||||
|
* "1×" is already Retina-crisp (a naive 1× export looks soft on a 2× display).
|
||||||
|
* - **Background** — the chart config is transparent (to show the pane colour),
|
||||||
|
* which would make a naive export transparent; default to the theme colour, with
|
||||||
|
* White and Transparent on offer.
|
||||||
|
* - **Referenced data** (shown only when the spec references saved datasets) —
|
||||||
|
* inline the data so the exported spec renders standalone, or keep the reference.
|
||||||
|
*
|
||||||
|
* Widget: a **disclosure**, not an ARIA menu — like `SettingsPopover`. The container
|
||||||
|
* is a labelled `group` of option controls + action `<button>`s (Tab/Shift+Tab move
|
||||||
|
* between them); the trigger carries `aria-expanded` + `aria-controls`; Esc closes
|
||||||
|
* and restores focus; an outside click closes. It shares the single-open popover
|
||||||
|
* registry, and is portaled to `<body>` (the panes clip their overflow).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
import {
|
||||||
|
chartExportFilename,
|
||||||
|
inlineReferencedDatasets,
|
||||||
|
referencedDatasetNames,
|
||||||
|
} from '@core/chart-export';
|
||||||
|
import { DatasetNotFoundError } from '@core/rendering';
|
||||||
|
import { copyText, downloadJson, downloadUrl } from '../infrastructure/file-transfer';
|
||||||
|
import { useDatasetStore } from '../stores/DatasetStore';
|
||||||
|
import { notify } from '../stores/NotificationStore';
|
||||||
|
import { useSettingsPopoverStore } from '../stores/SettingsPopoverStore';
|
||||||
|
import { selectActiveSnippet, selectShownText, useSnippetStore } from '../stores/SnippetStore';
|
||||||
|
import { Icon } from './Icon';
|
||||||
|
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
||||||
|
import { SettingRow } from './SettingsPopover';
|
||||||
|
import styles from './ChartExport.module.css';
|
||||||
|
|
||||||
|
/** Shared registry id (single popover open at a time across the panes). */
|
||||||
|
const POP_ID = 'chart-export';
|
||||||
|
/** Gap (px) between the trigger and the disclosed panel (matches SettingsPopover). */
|
||||||
|
const GAP = 6;
|
||||||
|
|
||||||
|
type ScaleChoice = '1' | '2' | '3';
|
||||||
|
type BackgroundChoice = 'theme' | 'white' | 'transparent';
|
||||||
|
|
||||||
|
/** PNG resolution multipliers (× device pixel ratio — see ImageExportOptions). */
|
||||||
|
const SCALE_OPTIONS: ReadonlyArray<SegmentedOption<ScaleChoice>> = [
|
||||||
|
{ value: '1', label: '1×', title: '1× — matches your screen (Retina-aware)' },
|
||||||
|
{ value: '2', label: '2×', title: '2× — double resolution, for print or zoom' },
|
||||||
|
{ value: '3', label: '3×', title: '3× — triple resolution' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const BACKGROUND_OPTIONS: ReadonlyArray<SegmentedOption<BackgroundChoice>> = [
|
||||||
|
{ value: 'theme', label: 'Theme', title: 'Match the current theme’s background' },
|
||||||
|
{ value: 'white', label: 'White', title: 'White background' },
|
||||||
|
{ value: 'transparent', label: 'None', title: 'Transparent background' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const REFDATA_OPTIONS: ReadonlyArray<SegmentedOption<'inline' | 'ref'>> = [
|
||||||
|
{ value: 'inline', label: 'Inline', title: 'Embed the data so the file renders standalone' },
|
||||||
|
{
|
||||||
|
value: 'ref',
|
||||||
|
label: 'Keep refs',
|
||||||
|
title: 'Keep the dataset reference (renders only in Astrolabe)',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Resolve a background choice to a CSS colour, or null for transparent. The
|
||||||
|
* "theme" colour is read live from the page so it always tracks the active theme. */
|
||||||
|
function resolveBackground(choice: BackgroundChoice): string | null {
|
||||||
|
if (choice === 'transparent') return null;
|
||||||
|
if (choice === 'white') return '#ffffff';
|
||||||
|
const bg = getComputedStyle(document.documentElement).getPropertyValue('--bg').trim();
|
||||||
|
return bg || '#ffffff';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChartExportProps {
|
||||||
|
/** Whether a chart is currently rendered — gates the image (PNG/SVG) actions.
|
||||||
|
* The spec actions only need text, so they ignore this. */
|
||||||
|
chartReady: boolean;
|
||||||
|
/** Rasterize/serialize the live view to a downloadable URL, or null if the view
|
||||||
|
* isn't available (caller owns the Vega view). */
|
||||||
|
getImageUrl: (
|
||||||
|
format: 'png' | 'svg',
|
||||||
|
options: { scale: number; background: string | null },
|
||||||
|
) => Promise<string | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChartExport({ chartReady, getImageUrl }: ChartExportProps) {
|
||||||
|
const open = useSettingsPopoverStore((s) => s.openId === POP_ID);
|
||||||
|
const toggle = useSettingsPopoverStore((s) => s.toggle);
|
||||||
|
const close = useSettingsPopoverStore((s) => s.close);
|
||||||
|
// Primitive reads only (no fresh objects) so the header doesn't re-render needlessly.
|
||||||
|
const name = useSnippetStore((s) => selectActiveSnippet(s)?.name ?? 'chart');
|
||||||
|
const shownText = useSnippetStore(selectShownText);
|
||||||
|
const datasets = useDatasetStore(useShallow((s) => s.datasets));
|
||||||
|
const hasSpec = shownText.trim() !== '';
|
||||||
|
|
||||||
|
// Export options, persisted while the component is mounted (across opens).
|
||||||
|
const [scale, setScale] = useState<ScaleChoice>('1');
|
||||||
|
const [background, setBackground] = useState<BackgroundChoice>('theme');
|
||||||
|
const [inline, setInline] = useState(true);
|
||||||
|
|
||||||
|
// Which saved datasets the shown spec references — drives the inline-data option.
|
||||||
|
const refs = useMemo(() => referencedDatasetNames(shownText), [shownText]);
|
||||||
|
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const popRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
// Position the fixed panel from the trigger's rect (panes clip overflow → body
|
||||||
|
// portal + fixed). Imperative, like SettingsPopover — no state, no scroll re-render.
|
||||||
|
const place = useCallback(() => {
|
||||||
|
const trigger = triggerRef.current;
|
||||||
|
const pop = popRef.current;
|
||||||
|
if (!trigger || !pop) return;
|
||||||
|
const r = trigger.getBoundingClientRect();
|
||||||
|
pop.style.top = `${r.bottom + GAP}px`;
|
||||||
|
pop.style.right = `${window.innerWidth - r.right}px`;
|
||||||
|
pop.style.left = 'auto';
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
window.addEventListener('resize', place);
|
||||||
|
window.addEventListener('scroll', place, true);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', place);
|
||||||
|
window.removeEventListener('scroll', place, true);
|
||||||
|
};
|
||||||
|
}, [open, place]);
|
||||||
|
|
||||||
|
// Esc closes + restores focus; an outside pointer click closes (APG disclosure).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.stopPropagation();
|
||||||
|
close();
|
||||||
|
triggerRef.current?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onPointer = (e: PointerEvent) => {
|
||||||
|
const t = e.target as Node;
|
||||||
|
if (!popRef.current?.contains(t) && !triggerRef.current?.contains(t)) close();
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', onKey, true);
|
||||||
|
document.addEventListener('pointerdown', onPointer, true);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', onKey, true);
|
||||||
|
document.removeEventListener('pointerdown', onPointer, true);
|
||||||
|
};
|
||||||
|
}, [open, close]);
|
||||||
|
|
||||||
|
// On open: place before paint and move focus to the first control.
|
||||||
|
const setPopNode = useCallback(
|
||||||
|
(node: HTMLDivElement | null) => {
|
||||||
|
popRef.current = node;
|
||||||
|
if (node) {
|
||||||
|
place();
|
||||||
|
node.querySelector<HTMLElement>('button, [role="radio"]')?.focus();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[place],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** The spec text to export — inlined for portability when chosen and refs exist.
|
||||||
|
* Returns null after surfacing an error (a referenced dataset is missing). */
|
||||||
|
const buildSpecText = (): string | null => {
|
||||||
|
if (!inline || refs.length === 0) return shownText;
|
||||||
|
try {
|
||||||
|
return inlineReferencedDatasets(shownText, datasets);
|
||||||
|
} catch (e) {
|
||||||
|
const missing = e instanceof DatasetNotFoundError ? ` "${e.datasetName}"` : '';
|
||||||
|
notify({
|
||||||
|
kind: 'error',
|
||||||
|
title: 'Couldn’t inline data',
|
||||||
|
message: `A referenced dataset${missing} isn’t in your library. Add it, or choose “Keep refs”.`,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copySpec = async () => {
|
||||||
|
close();
|
||||||
|
const text = buildSpecText();
|
||||||
|
if (text == null) return;
|
||||||
|
try {
|
||||||
|
await copyText(text);
|
||||||
|
notify({
|
||||||
|
kind: 'success',
|
||||||
|
title: 'Spec copied',
|
||||||
|
message: 'The chart’s Vega-Lite spec is on your clipboard as JSON.',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
notify({
|
||||||
|
kind: 'error',
|
||||||
|
title: 'Couldn’t copy',
|
||||||
|
message: 'Your browser blocked clipboard access. Select and copy the spec from the editor.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadSpec = () => {
|
||||||
|
close();
|
||||||
|
const text = buildSpecText();
|
||||||
|
if (text == null) return;
|
||||||
|
const filename = chartExportFilename(name, 'vl.json');
|
||||||
|
downloadJson(filename, text);
|
||||||
|
notify({ kind: 'success', title: 'Spec downloaded', message: `Saved ${filename}.` });
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadImage = async (format: 'png' | 'svg') => {
|
||||||
|
close();
|
||||||
|
const url = await getImageUrl(format, {
|
||||||
|
scale: Number(scale),
|
||||||
|
background: resolveBackground(background),
|
||||||
|
});
|
||||||
|
if (!url) {
|
||||||
|
notify({
|
||||||
|
kind: 'error',
|
||||||
|
title: 'Couldn’t export image',
|
||||||
|
message: 'The chart isn’t ready yet. Wait for it to finish rendering, then try again.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const filename = chartExportFilename(name, format);
|
||||||
|
downloadUrl(filename, url);
|
||||||
|
notify({ kind: 'success', title: 'Chart exported', message: `Saved ${filename}.` });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrap}>
|
||||||
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
|
type="button"
|
||||||
|
className={styles.trigger}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={POP_ID}
|
||||||
|
disabled={!hasSpec}
|
||||||
|
title={hasSpec ? 'Export this chart' : 'Select a snippet to export'}
|
||||||
|
onClick={() => toggle(POP_ID)}
|
||||||
|
>
|
||||||
|
<Icon name="export" className={styles.triggerIcon} />
|
||||||
|
<span>Export</span>
|
||||||
|
</button>
|
||||||
|
{open &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
ref={setPopNode}
|
||||||
|
id={POP_ID}
|
||||||
|
className={styles.pop}
|
||||||
|
role="group"
|
||||||
|
aria-label="Export chart"
|
||||||
|
>
|
||||||
|
<h4 className={styles.groupTitle}>Spec</h4>
|
||||||
|
{refs.length > 0 && (
|
||||||
|
<SettingRow label="Referenced data">
|
||||||
|
<SegmentedControl
|
||||||
|
label="Referenced data"
|
||||||
|
options={REFDATA_OPTIONS}
|
||||||
|
value={inline ? 'inline' : 'ref'}
|
||||||
|
onChange={(v) => setInline(v === 'inline')}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
)}
|
||||||
|
<button type="button" className={styles.action} onClick={() => void copySpec()}>
|
||||||
|
Copy spec
|
||||||
|
</button>
|
||||||
|
<button type="button" className={styles.action} onClick={downloadSpec}>
|
||||||
|
Download JSON <span className={styles.ext}>.vl.json</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h4 className={styles.groupTitle}>Image</h4>
|
||||||
|
<SettingRow label="Resolution">
|
||||||
|
<SegmentedControl
|
||||||
|
label="PNG resolution"
|
||||||
|
options={SCALE_OPTIONS}
|
||||||
|
value={scale}
|
||||||
|
onChange={setScale}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow label="Background">
|
||||||
|
<SegmentedControl
|
||||||
|
label="Background"
|
||||||
|
options={BACKGROUND_OPTIONS}
|
||||||
|
value={background}
|
||||||
|
onChange={setBackground}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.action}
|
||||||
|
disabled={!chartReady}
|
||||||
|
onClick={() => void downloadImage('png')}
|
||||||
|
>
|
||||||
|
Download PNG
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.action}
|
||||||
|
disabled={!chartReady}
|
||||||
|
onClick={() => void downloadImage('svg')}
|
||||||
|
>
|
||||||
|
Download SVG
|
||||||
|
</button>
|
||||||
|
{!chartReady && <p className={styles.hint}>Render the chart to export an image.</p>}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,9 +21,14 @@
|
|||||||
border-bottom: var(--border-width) solid var(--border);
|
border-bottom: var(--border-width) solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Push the settings gear (the last child) to the far right. */
|
/* Push the right cluster (export + settings gear) to the far right; Fit hugs
|
||||||
.header > :last-child {
|
the left. The cluster itself is the last child, so any header overflow trims
|
||||||
|
from its right edge, never clipping "Original" on the left. */
|
||||||
|
.headerEnd {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.body {
|
.body {
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ vi.mock('./SettingsPopover', () => ({
|
|||||||
SettingRow: () => null,
|
SettingRow: () => null,
|
||||||
RangeControl: () => null,
|
RangeControl: () => null,
|
||||||
}));
|
}));
|
||||||
|
// Isolate the busy-overlay assertions (which locate the overlay by its
|
||||||
|
// `aria-hidden="true"`) from the export control's own decorative icon: the
|
||||||
|
// per-chart export is a sibling header control, mocked out like SettingsPopover.
|
||||||
|
vi.mock('./ChartExport', () => ({ ChartExport: () => null }));
|
||||||
|
|
||||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
* (M3) plugs into prepareSpecForRender without changing this component.
|
* (M3) plugs into prepareSpecForRender without changing this component.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useRef } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import type { VisualizationSpec } from 'vega-embed';
|
import type { VisualizationSpec } from 'vega-embed';
|
||||||
import type { FitMode } from '@core/rendering';
|
import type { FitMode } from '@core/rendering';
|
||||||
@@ -27,6 +27,7 @@ import { useDatasetStore } from '../stores/DatasetStore';
|
|||||||
import { usePreviewStore } from '../stores/PreviewStore';
|
import { usePreviewStore } from '../stores/PreviewStore';
|
||||||
import { selectShownText, useSnippetStore } from '../stores/SnippetStore';
|
import { selectShownText, useSnippetStore } from '../stores/SnippetStore';
|
||||||
import { useUserSettingsStore } from '../stores/UserSettingsStore';
|
import { useUserSettingsStore } from '../stores/UserSettingsStore';
|
||||||
|
import { ChartExport } from './ChartExport';
|
||||||
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
||||||
import { RangeControl, SettingRow, SettingsPopover } from './SettingsPopover';
|
import { RangeControl, SettingRow, SettingsPopover } from './SettingsPopover';
|
||||||
import styles from './LivePreview.module.css';
|
import styles from './LivePreview.module.css';
|
||||||
@@ -116,6 +117,11 @@ export function LivePreview() {
|
|||||||
const setError = usePreviewStore((s) => s.setError);
|
const setError = usePreviewStore((s) => s.setError);
|
||||||
const busy = usePreviewStore((s) => s.busy);
|
const busy = usePreviewStore((s) => s.busy);
|
||||||
const setBusy = usePreviewStore((s) => s.setBusy);
|
const setBusy = usePreviewStore((s) => s.setBusy);
|
||||||
|
// Mirrors whether `handleRef` currently holds a live view, so the per-chart
|
||||||
|
// export's image actions (which need the view) can enable/disable reactively —
|
||||||
|
// a ref change alone wouldn't re-render. Set true on a successful render, false
|
||||||
|
// on clear/error/unmount.
|
||||||
|
const [chartReady, setChartReady] = useState(false);
|
||||||
|
|
||||||
// Busy-indication timer ref: if a render exceeds ~1s we surface a non-blocking
|
// Busy-indication timer ref: if a render exceeds ~1s we surface a non-blocking
|
||||||
// overlay (arch §10.2 NN/g: >1s owes a busy indication; <1s shows nothing to
|
// overlay (arch §10.2 NN/g: >1s owes a busy indication; <1s shows nothing to
|
||||||
@@ -198,6 +204,7 @@ export function LivePreview() {
|
|||||||
if (isEmpty) {
|
if (isEmpty) {
|
||||||
handleRef.current?.destroy();
|
handleRef.current?.destroy();
|
||||||
handleRef.current = null;
|
handleRef.current = null;
|
||||||
|
setChartReady(false);
|
||||||
setError(null);
|
setError(null);
|
||||||
clearBusy();
|
clearBusy();
|
||||||
return;
|
return;
|
||||||
@@ -218,10 +225,12 @@ export function LivePreview() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
handleRef.current = handle;
|
handleRef.current = handle;
|
||||||
|
setChartReady(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
clearBusy();
|
clearBusy();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mine === generationRef.current) {
|
if (mine === generationRef.current) {
|
||||||
|
setChartReady(false);
|
||||||
clearBusy();
|
clearBusy();
|
||||||
// A missing dataset reference is not a JSON/spec problem, so it gets a
|
// A missing dataset reference is not a JSON/spec problem, so it gets a
|
||||||
// tailored, fixable message instead of the generic syntax hint (council:
|
// tailored, fixable message instead of the generic syntax hint (council:
|
||||||
@@ -253,11 +262,31 @@ export function LivePreview() {
|
|||||||
datasets,
|
datasets,
|
||||||
setError,
|
setError,
|
||||||
setBusy,
|
setBusy,
|
||||||
|
setChartReady,
|
||||||
bufferEpoch,
|
bufferEpoch,
|
||||||
editorView,
|
editorView,
|
||||||
renderDebounce,
|
renderDebounce,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Rasterize/serialize the live view for the per-chart export (spec §08). Reads
|
||||||
|
// `handleRef` (the view LivePreview owns); returns null when no view is live or
|
||||||
|
// the export fails, so the export UI can report it. Stable identity (no deps).
|
||||||
|
const getImageUrl = useCallback(
|
||||||
|
async (
|
||||||
|
format: 'png' | 'svg',
|
||||||
|
options: { scale: number; background: string | null },
|
||||||
|
): Promise<string | null> => {
|
||||||
|
const handle = handleRef.current;
|
||||||
|
if (!handle) return null;
|
||||||
|
try {
|
||||||
|
return await handle.toImageURL(format, options);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
// Re-fit the chart when its container resizes (e.g. a pane drag). Vega doesn't
|
// Re-fit the chart when its container resizes (e.g. a pane drag). Vega doesn't
|
||||||
// observe the element, so we do: one observer on the stable host node for the
|
// observe the element, so we do: one observer on the stable host node for the
|
||||||
// component's life. Only responsive fit modes depend on container size;
|
// component's life. Only responsive fit modes depend on container size;
|
||||||
@@ -280,6 +309,7 @@ export function LivePreview() {
|
|||||||
() => () => {
|
() => () => {
|
||||||
handleRef.current?.destroy();
|
handleRef.current?.destroy();
|
||||||
handleRef.current = null;
|
handleRef.current = null;
|
||||||
|
setChartReady(false);
|
||||||
if (busyTimerRef.current !== null) clearTimeout(busyTimerRef.current);
|
if (busyTimerRef.current !== null) clearTimeout(busyTimerRef.current);
|
||||||
usePreviewStore.getState().setError(null);
|
usePreviewStore.getState().setError(null);
|
||||||
usePreviewStore.getState().setBusy(false);
|
usePreviewStore.getState().setBusy(false);
|
||||||
@@ -291,7 +321,11 @@ export function LivePreview() {
|
|||||||
<div className={styles.preview}>
|
<div className={styles.preview}>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<FitControl />
|
<FitControl />
|
||||||
<PreviewSettings />
|
{/* Right cluster: export this chart, then the preview settings gear. */}
|
||||||
|
<div className={styles.headerEnd}>
|
||||||
|
<ChartExport chartReady={chartReady} getImageUrl={getImageUrl} />
|
||||||
|
<PreviewSettings />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/*
|
{/*
|
||||||
* aria-busy on the chart region tells AT the area is being updated (arch §10.2;
|
* aria-busy on the chart region tells AT the area is being updated (arch §10.2;
|
||||||
|
|||||||
@@ -7,10 +7,13 @@
|
|||||||
* than Blobs and anchors.
|
* than Blobs and anchors.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Trigger a client-side download of `json` text as `filename`. */
|
/**
|
||||||
export function downloadJson(filename: string, json: string): void {
|
* Trigger a download of an already-built object/data `url` as `filename`. A
|
||||||
const blob = new Blob([json], { type: 'application/json' });
|
* `blob:` URL is revoked right after the click; a `data:` URL needs no cleanup.
|
||||||
const url = URL.createObjectURL(blob);
|
* Used by the per-chart export for an image URL produced by the Vega view
|
||||||
|
* (`RenderHandle.toImageURL`).
|
||||||
|
*/
|
||||||
|
export function downloadUrl(filename: string, url: string): void {
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = filename;
|
a.download = filename;
|
||||||
@@ -18,10 +21,22 @@ export function downloadJson(filename: string, json: string): void {
|
|||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
a.remove();
|
a.remove();
|
||||||
URL.revokeObjectURL(url);
|
if (url.startsWith('blob:')) URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trigger a client-side download of `json` text as `filename`. */
|
||||||
|
export function downloadJson(filename: string, json: string): void {
|
||||||
|
const url = URL.createObjectURL(new Blob([json], { type: 'application/json' }));
|
||||||
|
downloadUrl(filename, url);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Read a picked file's text content (rejects on an unreadable file). */
|
/** Read a picked file's text content (rejects on an unreadable file). */
|
||||||
export function readTextFile(file: File): Promise<string> {
|
export function readTextFile(file: File): Promise<string> {
|
||||||
return file.text();
|
return file.text();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Copy `text` to the clipboard (rejects when the browser blocks access). The
|
||||||
|
* one place outside a component that touches the clipboard API. */
|
||||||
|
export function copyText(text: string): Promise<void> {
|
||||||
|
return navigator.clipboard.writeText(text);
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,9 +11,40 @@ import vegaEmbed, { type Result as EmbedResult } from 'vega-embed';
|
|||||||
import type { VisualizationSpec } from 'vega-embed';
|
import type { VisualizationSpec } from 'vega-embed';
|
||||||
import type { Config } from 'vega-lite';
|
import type { Config } from 'vega-lite';
|
||||||
|
|
||||||
|
/** Options for `RenderHandle.toImageURL` (spec §08 → Per-chart export). */
|
||||||
|
export interface ImageExportOptions {
|
||||||
|
/**
|
||||||
|
* Pixel-density multiplier for the **PNG** raster, **relative to the device**.
|
||||||
|
* The image is drawn at `scale × devicePixelRatio` logical-pixel density, so
|
||||||
|
* `scale: 1` already matches on-screen crispness on a Retina display — the fix
|
||||||
|
* for a soft "1×" export (`toImageURL`'s raw `scaleFactor` ignores dpr, so a
|
||||||
|
* naive 1× looks half-resolution on a 2× display). Raise for print/zoom.
|
||||||
|
* Ignored for the resolution-independent SVG. Default `1`.
|
||||||
|
*/
|
||||||
|
scale?: number;
|
||||||
|
/**
|
||||||
|
* Opaque colour to paint behind the chart. The chart config renders a
|
||||||
|
* **transparent** background (so the on-screen chart shows the pane colour),
|
||||||
|
* which makes a naive export transparent; pass a colour to fill it. PNG is
|
||||||
|
* composited onto the colour; SVG gets a background `<rect>`. Null/omitted keeps
|
||||||
|
* it transparent. Default `null`.
|
||||||
|
*/
|
||||||
|
background?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RenderHandle {
|
export interface RenderHandle {
|
||||||
/** Finalize the underlying Vega view and clear the node. */
|
/** Finalize the underlying Vega view and clear the node. */
|
||||||
destroy(): void;
|
destroy(): void;
|
||||||
|
/**
|
||||||
|
* Rasterize/serialize the current view to a downloadable URL (spec §08 →
|
||||||
|
* Per-chart export). `'png'` resolves to a `blob:` object URL (caller revokes
|
||||||
|
* after download); `'svg'` to a `data:` URL. Renderer-agnostic — works from the
|
||||||
|
* SVG-backed LivePreview view as well as a canvas one — because Vega draws to its
|
||||||
|
* own off-screen surface here, independent of the display backend. Honors
|
||||||
|
* `options` (dpr-aware scale, background fill). Rejects if the view was already
|
||||||
|
* finalized.
|
||||||
|
*/
|
||||||
|
toImageURL(format: 'png' | 'svg', options?: ImageExportOptions): Promise<string>;
|
||||||
/**
|
/**
|
||||||
* Re-fit the chart to its container's current size (spec §04 Responsiveness).
|
* Re-fit the chart to its container's current size (spec §04 Responsiveness).
|
||||||
*
|
*
|
||||||
@@ -36,7 +67,7 @@ export interface RenderOptions {
|
|||||||
* main-thread layout/paint per render — measured ~6.5s paint on 9994 rows —
|
* main-thread layout/paint per render — measured ~6.5s paint on 9994 rows —
|
||||||
* because each mark is a DOM node; canvas is a single node and paints in
|
* because each mark is a DOM node; canvas is a single node and paints in
|
||||||
* milliseconds. Canvas is raster (not crisp on zoom) but that's invisible for an
|
* milliseconds. Canvas is raster (not crisp on zoom) but that's invisible for an
|
||||||
* ephemeral preview, and image export (`view.toImageURL`) is renderer-agnostic.
|
* ephemeral preview, and image export (`RenderHandle.toImageURL`) is renderer-agnostic.
|
||||||
*/
|
*/
|
||||||
renderer?: 'svg' | 'canvas';
|
renderer?: 'svg' | 'canvas';
|
||||||
}
|
}
|
||||||
@@ -79,6 +110,41 @@ function canvasLimitPx(): number {
|
|||||||
return MAX_CANVAS_PX / dpr;
|
return MAX_CANVAS_PX / dpr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Encode an SVG string as a `data:` URL (no blob to revoke). */
|
||||||
|
function svgDataUrl(svg: string): string {
|
||||||
|
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Paint a full-bleed background `<rect>` as the first child of the root `<svg>`,
|
||||||
|
* so the exported SVG isn't transparent. Vega emits explicit width/height on the
|
||||||
|
* root, so `100%` resolves to the chart's box. */
|
||||||
|
function withSvgBackground(svg: string, color: string): string {
|
||||||
|
return svg.replace(/(<svg\b[^>]*>)/, `$1<rect width="100%" height="100%" fill="${color}"/>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Composite a (transparent) chart canvas onto an opaque colour, same dimensions. */
|
||||||
|
function compositeOnColor(chart: HTMLCanvasElement, color: string): HTMLCanvasElement {
|
||||||
|
const out = document.createElement('canvas');
|
||||||
|
out.width = chart.width;
|
||||||
|
out.height = chart.height;
|
||||||
|
const ctx = out.getContext('2d');
|
||||||
|
if (!ctx) return chart; // no 2d context — fall back to the transparent chart
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.fillRect(0, 0, out.width, out.height);
|
||||||
|
ctx.drawImage(chart, 0, 0);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A canvas → `blob:` object URL (PNG). The caller revokes it after the download. */
|
||||||
|
function canvasObjectUrl(canvas: HTMLCanvasElement): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
canvas.toBlob((blob) => {
|
||||||
|
if (blob) resolve(URL.createObjectURL(blob));
|
||||||
|
else reject(new Error('Could not encode the chart as a PNG.'));
|
||||||
|
}, 'image/png');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Embed a prepared spec into `node`. Always: no actions menu. Renderer per `options`. */
|
/** Embed a prepared spec into `node`. Always: no actions menu. Renderer per `options`. */
|
||||||
export async function renderSpec(
|
export async function renderSpec(
|
||||||
node: HTMLElement,
|
node: HTMLElement,
|
||||||
@@ -114,6 +180,21 @@ export async function renderSpec(
|
|||||||
result.view.finalize();
|
result.view.finalize();
|
||||||
node.replaceChildren();
|
node.replaceChildren();
|
||||||
},
|
},
|
||||||
|
async toImageURL(format, options = {}) {
|
||||||
|
const { scale = 1, background = null } = options;
|
||||||
|
if (format === 'svg') {
|
||||||
|
// Vector — resolution-independent, so dpr/scale don't apply. A background
|
||||||
|
// is added as a full-bleed rect rather than baked into the live view.
|
||||||
|
const svg = await result.view.toSVG();
|
||||||
|
return svgDataUrl(background ? withSvgBackground(svg, background) : svg);
|
||||||
|
}
|
||||||
|
// Multiply the requested scale by the device pixel ratio so a "1×" export is
|
||||||
|
// as crisp as the chart on screen (the Retina fix — see ImageExportOptions).
|
||||||
|
const dpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1;
|
||||||
|
const chart = await result.view.toCanvas(scale * dpr);
|
||||||
|
const out = background ? compositeOnColor(chart, background) : chart;
|
||||||
|
return canvasObjectUrl(out);
|
||||||
|
},
|
||||||
resize() {
|
resize() {
|
||||||
// Synthesize the window:resize the container signals listen for (see the
|
// Synthesize the window:resize the container signals listen for (see the
|
||||||
// interface doc). The view re-reads containerSize() and re-renders itself;
|
// interface doc). The view re-reads containerSize() and re-renders itself;
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
chartExportFilename,
|
||||||
|
inlineReferencedDatasets,
|
||||||
|
MAX_BASENAME_LEN,
|
||||||
|
referencedDatasetNames,
|
||||||
|
snippetFileBasename,
|
||||||
|
} from './chart-export';
|
||||||
|
import { DatasetNotFoundError, type ResolvableDataset } from './rendering';
|
||||||
|
|
||||||
|
describe('snippetFileBasename', () => {
|
||||||
|
it('keeps the user’s words and case', () => {
|
||||||
|
expect(snippetFileBasename('Sales by Region')).toBe('Sales-by-Region');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('swaps whitespace runs for a single dash', () => {
|
||||||
|
expect(snippetFileBasename(' a b c ')).toBe('a-b-c');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('folds dots into the separator so the extension stays unambiguous', () => {
|
||||||
|
expect(snippetFileBasename('data.v1.final')).toBe('data-v1-final');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips characters illegal on Windows / awkward in URLs', () => {
|
||||||
|
expect(snippetFileBasename('a/b\\c:d*e?f"g<h>i|j')).toBe('abcdefghij');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps non-latin letters (no ASCII folding — full-script support)', () => {
|
||||||
|
expect(snippetFileBasename('Продажи по региону')).toBe('Продажи-по-региону');
|
||||||
|
expect(snippetFileBasename('売上 グラフ')).toBe('売上-グラフ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses and trims dashes', () => {
|
||||||
|
expect(snippetFileBasename('--a -- b--')).toBe('a-b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to "chart" when nothing usable remains', () => {
|
||||||
|
expect(snippetFileBasename('')).toBe('chart');
|
||||||
|
expect(snippetFileBasename(' ')).toBe('chart');
|
||||||
|
expect(snippetFileBasename('/// \\\\\\')).toBe('chart');
|
||||||
|
expect(snippetFileBasename('...')).toBe('chart');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps the length and never leaves a trailing dash from the cut', () => {
|
||||||
|
const long = snippetFileBasename('x'.repeat(200));
|
||||||
|
expect(long.length).toBe(MAX_BASENAME_LEN);
|
||||||
|
// A name whose cap boundary lands on a dash must not end in one.
|
||||||
|
const dashy = snippetFileBasename('a'.repeat(MAX_BASENAME_LEN - 1) + ' bbbb');
|
||||||
|
expect(dashy.endsWith('-')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops control characters', () => {
|
||||||
|
// Build the control byte from a code point so no literal control char is in source.
|
||||||
|
expect(snippetFileBasename(`a${String.fromCharCode(1)}bcd`)).toBe('abcd');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('chartExportFilename', () => {
|
||||||
|
it('appends the format as the extension', () => {
|
||||||
|
expect(chartExportFilename('Sales by Region', 'png')).toBe('Sales-by-Region.png');
|
||||||
|
expect(chartExportFilename('Sales by Region', 'svg')).toBe('Sales-by-Region.svg');
|
||||||
|
expect(chartExportFilename('Sales by Region', 'vl.json')).toBe('Sales-by-Region.vl.json');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the "chart" fallback for an unusable name', () => {
|
||||||
|
expect(chartExportFilename('', 'png')).toBe('chart.png');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('referencedDatasetNames', () => {
|
||||||
|
it('returns the saved-dataset names a spec references, deduped', () => {
|
||||||
|
const spec = JSON.stringify({
|
||||||
|
layer: [
|
||||||
|
{ data: { name: 'sales' } },
|
||||||
|
{ data: { name: 'sales' } },
|
||||||
|
{ data: { name: 'costs' } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(referencedDatasetNames(spec).sort()).toEqual(['costs', 'sales']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes names the spec defines for itself via top-level datasets', () => {
|
||||||
|
const spec = JSON.stringify({
|
||||||
|
datasets: { local: [{ a: 1 }] },
|
||||||
|
data: { name: 'local' },
|
||||||
|
});
|
||||||
|
expect(referencedDatasetNames(spec)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns [] for an inline-data spec (no references)', () => {
|
||||||
|
const spec = JSON.stringify({ data: { values: [{ a: 1 }] }, mark: 'bar' });
|
||||||
|
expect(referencedDatasetNames(spec)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns [] for unparseable text rather than throwing', () => {
|
||||||
|
expect(referencedDatasetNames('{ not json')).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('inlineReferencedDatasets', () => {
|
||||||
|
const sales: ResolvableDataset = {
|
||||||
|
name: 'sales',
|
||||||
|
data: [{ region: 'N', value: 10 }],
|
||||||
|
format: 'json',
|
||||||
|
source: 'inline',
|
||||||
|
};
|
||||||
|
|
||||||
|
const parse = (text: string) => JSON.parse(text) as { data?: unknown; width?: unknown };
|
||||||
|
|
||||||
|
it('replaces a named reference with the dataset’s inline values', () => {
|
||||||
|
const spec = JSON.stringify({ data: { name: 'sales' }, mark: 'bar' });
|
||||||
|
const out = parse(inlineReferencedDatasets(spec, [sales]));
|
||||||
|
expect(out.data).toEqual({ values: [{ region: 'N', value: 10 }] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves the name case-insensitively (mirrors reference resolution)', () => {
|
||||||
|
const spec = JSON.stringify({ data: { name: 'Sales' }, mark: 'bar' });
|
||||||
|
const out = parse(inlineReferencedDatasets(spec, [sales]));
|
||||||
|
expect(out.data).toEqual({ values: [{ region: 'N', value: 10 }] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves sizing as authored — no fit-mode applied', () => {
|
||||||
|
const spec = JSON.stringify({ data: { name: 'sales' }, width: 300, mark: 'bar' });
|
||||||
|
const out = parse(inlineReferencedDatasets(spec, [sales]));
|
||||||
|
expect(out.width).toBe(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws DatasetNotFoundError when a referenced dataset is missing', () => {
|
||||||
|
const spec = JSON.stringify({ data: { name: 'missing' }, mark: 'bar' });
|
||||||
|
expect(() => inlineReferencedDatasets(spec, [sales])).toThrow(DatasetNotFoundError);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* Chart export — pure helpers for the single-chart export affordance
|
||||||
|
* (spec §08 → Per-chart export). Distinct from the workspace export envelope
|
||||||
|
* (`export-envelope.ts`): that backs up the whole library as one JSON file; this
|
||||||
|
* turns *one* snippet into a shareable artifact — its spec as `.vl.json`, or its
|
||||||
|
* rendered image as PNG/SVG.
|
||||||
|
*
|
||||||
|
* The naming is deterministic, and so is producing a **self-contained** spec —
|
||||||
|
* one with every saved-dataset reference replaced by its inline data, so the
|
||||||
|
* exported file renders without Astrolabe. Both live here and are tested. The
|
||||||
|
* clipboard write, the file download, and the image rasterization are browser-side
|
||||||
|
* and stay in `infrastructure/file-transfer` and the chart renderer.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { prepareSpecForRender, type ResolvableDataset } from './rendering';
|
||||||
|
import { extractDatasetRefs } from './spec-refs';
|
||||||
|
|
||||||
|
/** The formats a single chart can be exported as (file extension == the value). */
|
||||||
|
export type ChartExportFormat = 'vl.json' | 'png' | 'svg';
|
||||||
|
|
||||||
|
/** Cap on the derived base name so a very long snippet title can't blow up the
|
||||||
|
* filename (filesystems and download shelves both balk past ~255 chars). */
|
||||||
|
export const MAX_BASENAME_LEN = 60;
|
||||||
|
|
||||||
|
/** C0 control characters + DEL, built from a string so no literal control byte
|
||||||
|
* ever lands in this source file. Stripped from filenames. */
|
||||||
|
// eslint-disable-next-line no-control-regex -- intentional: scrub control chars from names
|
||||||
|
const CONTROL_CHARS = new RegExp('[\\u0000-\\u001f\\u007f]', 'g');
|
||||||
|
|
||||||
|
/** Characters illegal on Windows or awkward across filesystems and URLs. */
|
||||||
|
const ILLEGAL_CHARS = /[\\/:*?"<>|]/g;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn a snippet's display name into a filesystem- and URL-safe base name.
|
||||||
|
*
|
||||||
|
* Keeps the user's words and **case**, and keeps letters of *any* script (a
|
||||||
|
* Cyrillic or CJK title stays itself — we never ASCII-fold, matching the app's
|
||||||
|
* full-script support). Swaps whitespace and dot runs for single dashes (so the
|
||||||
|
* extension stays unambiguous), drops control + illegal characters, collapses and
|
||||||
|
* trims dashes, and caps the length. Falls back to `"chart"` when nothing usable
|
||||||
|
* remains (a name of only punctuation or whitespace).
|
||||||
|
*/
|
||||||
|
export function snippetFileBasename(name: string): string {
|
||||||
|
const cleaned = name
|
||||||
|
.normalize('NFC')
|
||||||
|
.replace(CONTROL_CHARS, '')
|
||||||
|
.replace(ILLEGAL_CHARS, '')
|
||||||
|
.replace(/[.\s]+/g, '-') // dot and whitespace runs → one dash boundary
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
const capped = cleaned.slice(0, MAX_BASENAME_LEN).replace(/-+$/g, '');
|
||||||
|
return capped || 'chart';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download filename for a single chart export, e.g. `sales-by-region.png`. The
|
||||||
|
* base is derived from the snippet name; `format` is both the extension and the
|
||||||
|
* artifact kind. No date or `astrolabe-` prefix (unlike the workspace export) —
|
||||||
|
* the user is exporting *one named chart* and wants its name on the file.
|
||||||
|
*/
|
||||||
|
export function chartExportFilename(name: string, format: ChartExportFormat): string {
|
||||||
|
return `${snippetFileBasename(name)}.${format}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The saved-dataset names a spec references via `{ data: { name } }` (deduped).
|
||||||
|
* A name the spec defines for itself via top-level `datasets` is excluded — those
|
||||||
|
* are already self-contained. Returns `[]` for spec text that doesn't parse. Drives
|
||||||
|
* whether the export offers an "inline referenced data" option at all.
|
||||||
|
*/
|
||||||
|
export function referencedDatasetNames(specText: string): string[] {
|
||||||
|
// extractDatasetRefs already safe-parses a string (→ [] on bad JSON) and dedups.
|
||||||
|
return extractDatasetRefs(specText);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-serialize a spec with every saved-dataset reference replaced by its inline
|
||||||
|
* data, so the exported file renders standalone (outside Astrolabe). Sizing is
|
||||||
|
* left exactly as authored — unlike the preview, no fit-mode is applied. Throws
|
||||||
|
* `DatasetNotFoundError` (from `prepareSpecForRender`) if the spec references a
|
||||||
|
* name not present in `datasets`; the caller surfaces that. `specText` must be
|
||||||
|
* valid JSON (it is the spec the editor is showing).
|
||||||
|
*/
|
||||||
|
export function inlineReferencedDatasets(
|
||||||
|
specText: string,
|
||||||
|
datasets: ReadonlyArray<ResolvableDataset>,
|
||||||
|
): string {
|
||||||
|
const parsed: unknown = JSON.parse(specText);
|
||||||
|
const resolved = prepareSpecForRender(parsed, { datasets, fitMode: 'default' });
|
||||||
|
return JSON.stringify(resolved, null, 2);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user