mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
296 lines
12 KiB
TypeScript
296 lines
12 KiB
TypeScript
/**
|
|
* Onboarding canvas — the empty-workspace surface (spec §02 → First-Run & Empty
|
|
* Workspace).
|
|
*
|
|
* Shown by `App` in place of the editor + preview whenever the library is empty
|
|
* (first run, or after the last snippet is deleted). Rather than seeding a
|
|
* placeholder snippet, it greets the user and offers two deliberate ways in: a
|
|
* primary "Create your first snippet" (the sample template) and a gallery of
|
|
* example snippets, each previewed live and addable on its own or all at once.
|
|
*
|
|
* The previews render through the shared `chart-renderer` — the one place that
|
|
* touches vega-embed — so there is no parallel embed path. Each card's chart
|
|
* lives in its own node and owns its handle's lifecycle (finalized on unmount),
|
|
* independent of `LivePreview`'s single-host render serialization.
|
|
*/
|
|
|
|
import { useEffect, useRef, useState } from 'react';
|
|
import type { VisualizationSpec } from 'vega-embed';
|
|
import { CHART_EXAMPLES, exampleSpecText, type ChartExample } from '@core/examples';
|
|
import { createSnippet as createSnippetRecord, deriveSnippetName } from '@core/snippet';
|
|
import { chartConfigFor } from '@core/vega-themes';
|
|
import { openModal } from '../modals/ModalCoordinator';
|
|
import { renderSpec, type RenderHandle } from '../services/chart-renderer';
|
|
import { importWorkspace } from '../services/transfer';
|
|
import { useAppStore } from '../stores/AppStore';
|
|
import { usePanesStore } from '../stores/PanesStore';
|
|
import { useSnippetStore } from '../stores/SnippetStore';
|
|
import { Button } from './Button';
|
|
import { Icon } from './Icon';
|
|
import styles from './Onboarding.module.css';
|
|
|
|
/** Fixed thumbnail height; width fills the card via Vega's `container` sizing. */
|
|
const THUMB_HEIGHT = 140;
|
|
|
|
/**
|
|
* Live preview of one example. Renders the example spec at card width through the
|
|
* shared renderer, finalizing the Vega view on unmount or when the spec/theme
|
|
* changes — a chart that isn't finalized leaks its timers and listeners.
|
|
*/
|
|
function ExampleThumbnail({ spec }: { spec: Record<string, unknown> }) {
|
|
const hostRef = useRef<HTMLDivElement>(null);
|
|
const uiTheme = useAppStore((s) => s.uiTheme);
|
|
|
|
useEffect(() => {
|
|
const node = hostRef.current;
|
|
if (!node) return;
|
|
let handle: RenderHandle | null = null;
|
|
let cancelled = false;
|
|
// `container` width fits the card; a fixed height keeps every card uniform.
|
|
// Spread onto a copy so the shared example object is never mutated.
|
|
const sized = { ...spec, width: 'container', height: THUMB_HEIGHT } as VisualizationSpec;
|
|
void renderSpec(node, sized, chartConfigFor(uiTheme))
|
|
.then((h) => {
|
|
if (cancelled) h.destroy();
|
|
else handle = h;
|
|
})
|
|
.catch(() => {
|
|
// Examples are schema-validated (examples.test.ts), but a thumbnail that
|
|
// somehow fails to render must never break onboarding — leave the card
|
|
// image blank and let the name + description carry it.
|
|
});
|
|
// A `container`-width chart only re-reads its size on a window resize, so the
|
|
// grid reflowing a card (window resize → new column width) needs the same
|
|
// synthesized re-fit LivePreview uses. The observer reads `handle` lazily, so
|
|
// it no-ops until the async render resolves.
|
|
let ro: ResizeObserver | undefined;
|
|
if (typeof ResizeObserver !== 'undefined') {
|
|
ro = new ResizeObserver(() => handle?.resize());
|
|
ro.observe(node);
|
|
}
|
|
return () => {
|
|
cancelled = true;
|
|
ro?.disconnect();
|
|
handle?.destroy();
|
|
};
|
|
}, [spec, uiTheme]);
|
|
|
|
// Decorative: the name, description, and Add button carry the meaning, so a
|
|
// screen reader hears "Bar chart … Add", not a tree of chart SVG nodes (arch §10).
|
|
return (
|
|
<div
|
|
ref={hostRef}
|
|
className={styles.thumb}
|
|
style={{ height: THUMB_HEIGHT }}
|
|
aria-hidden="true"
|
|
/>
|
|
);
|
|
}
|
|
|
|
export function Onboarding() {
|
|
const createSnippet = useSnippetStore((s) => s.createSnippet);
|
|
const addSnippets = useSnippetStore((s) => s.addSnippets);
|
|
const applyOnboardingSplit = usePanesStore((s) => s.applyOnboardingSplit);
|
|
|
|
// The paste door (spec §02): a disclosure (WAI-ARIA APG disclosure pattern —
|
|
// button + aria-expanded/aria-controls, no focus trap), not a modal: the canvas
|
|
// has the whole workspace to itself, so revealing the paste surface in place
|
|
// costs no context. The panel stays mounted (`hidden`) so a draft paste
|
|
// survives a collapse.
|
|
const [pasteOpen, setPasteOpen] = useState(false);
|
|
const [pasteText, setPasteText] = useState('');
|
|
const pasteTriggerRef = useRef<HTMLButtonElement>(null);
|
|
const pasteAreaRef = useRef<HTMLTextAreaElement>(null);
|
|
const importInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// The revealed panel exists only for immediate input, so focus follows the
|
|
// expand; Cancel returns it to the trigger (NN/g #3 — a clearly marked exit).
|
|
useEffect(() => {
|
|
if (pasteOpen) pasteAreaRef.current?.focus();
|
|
}, [pasteOpen]);
|
|
|
|
// Leaving onboarding lays the workspace out at the default 25·25·50 split, so
|
|
// the first chart opens with a generous preview (spec §02). The canvas fills
|
|
// the window here, so its width is a good proxy for the panes container.
|
|
const layoutWorkspace = () => applyOnboardingSplit(window.innerWidth);
|
|
|
|
// Primary path and per-example add both go through the normal create action,
|
|
// so an added example opens in the editor exactly like any new snippet.
|
|
const handleCreate = () => {
|
|
createSnippet();
|
|
layoutWorkspace();
|
|
};
|
|
const handleAdd = (example: ChartExample) => {
|
|
createSnippet({ name: example.name, spec: exampleSpecText(example) });
|
|
layoutWorkspace();
|
|
};
|
|
|
|
// The data-first door (spec §02): straight into the Chart Builder, which picks
|
|
// up the freshest dataset — or, with none yet, explains itself and offers "Add a
|
|
// dataset". The workspace split is applied up front: if the builder creates the
|
|
// first snippet, onboarding unmounts without another chance to lay the panes out.
|
|
const handleBuild = () => {
|
|
layoutWorkspace();
|
|
openModal('chartBuilder');
|
|
};
|
|
|
|
// Pasted text is accepted as-is: the editor is the product's validator, and an
|
|
// almost-right spec opening with live schema errors is the feature working.
|
|
// The name derives from the spec (title → "Mark chart of y by x"), with
|
|
// `nameSource: 'auto'` so it keeps tracking the spec until the user renames.
|
|
const handlePasteAdd = () => {
|
|
const text = pasteText.trim();
|
|
if (!text) return;
|
|
createSnippet({
|
|
name: deriveSnippetName(text) ?? 'Pasted spec',
|
|
nameSource: 'auto',
|
|
spec: text,
|
|
});
|
|
layoutWorkspace();
|
|
};
|
|
const closePaste = () => {
|
|
setPasteOpen(false);
|
|
pasteTriggerRef.current?.focus();
|
|
};
|
|
|
|
// Same hidden-picker pattern as the header's Import (spec §08 — the browser
|
|
// file dialog is the only chrome); reset so re-picking the same file re-fires.
|
|
// TODO: third hidden-file-picker site (App.tsx header, TypeControls.tsx) — past
|
|
// the shared-piece threshold; eng-council proposed a useFilePicker/HiddenFileInput
|
|
// shape. Consult before building (new hook/component kind).
|
|
const handleImportFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
e.target.value = '';
|
|
if (file) void importWorkspace(file);
|
|
};
|
|
|
|
const handleAddAll = () => {
|
|
// Stagger the timestamps so the first example (the bar chart) is the newest:
|
|
// it then sorts to the top of the library and `addSnippets` makes it active
|
|
// (it selects the newest when nothing is active — true during onboarding).
|
|
const base = Date.now();
|
|
const records = CHART_EXAMPLES.map((example, i) =>
|
|
createSnippetRecord({
|
|
name: example.name,
|
|
spec: exampleSpecText(example),
|
|
now: new Date(base - i * 1000),
|
|
}),
|
|
);
|
|
addSnippets(records);
|
|
layoutWorkspace();
|
|
};
|
|
|
|
return (
|
|
<div className={styles.onboarding}>
|
|
<div className={styles.inner}>
|
|
<h2 className={styles.title}>Welcome to Astrolabe</h2>
|
|
<p className={styles.tagline}>
|
|
A local library for your Vega-Lite charts — write the spec as JSON, watch it render live,
|
|
and keep it all on your device.
|
|
</p>
|
|
|
|
<div className={styles.ctaRow}>
|
|
<Button variant="primary" size="lg" onClick={handleCreate}>
|
|
<Icon name="add" /> Create your first snippet
|
|
</Button>
|
|
<Button size="lg" onClick={handleBuild}>
|
|
<Icon name="chart" /> Build a chart from your data
|
|
</Button>
|
|
<Button
|
|
size="lg"
|
|
ref={pasteTriggerRef}
|
|
aria-expanded={pasteOpen}
|
|
aria-controls="onboarding-paste-panel"
|
|
onClick={() => setPasteOpen((open) => !open)}
|
|
>
|
|
<Icon name="import" /> Paste a spec you already have
|
|
</Button>
|
|
</div>
|
|
|
|
<div id="onboarding-paste-panel" className={styles.pastePanel} hidden={!pasteOpen}>
|
|
{/* Visible label above the field (GOV.UK textarea — placeholder text is
|
|
not a substitute for a label). */}
|
|
<label className={styles.pasteLabel} htmlFor="onboarding-paste-input">
|
|
Paste a Vega-Lite spec
|
|
</label>
|
|
<p className={styles.pasteHint} id="onboarding-paste-hint">
|
|
Vega-Lite JSON from anywhere — a notebook, the Vega editor, an AI chat. It becomes your
|
|
first snippet and opens in the editor, live errors and all.
|
|
</p>
|
|
<textarea
|
|
ref={pasteAreaRef}
|
|
id="onboarding-paste-input"
|
|
className={styles.pasteInput}
|
|
aria-describedby="onboarding-paste-hint"
|
|
rows={8}
|
|
spellCheck={false}
|
|
value={pasteText}
|
|
onChange={(e) => setPasteText(e.target.value)}
|
|
/>
|
|
<div className={styles.pasteActions}>
|
|
<Button onClick={handlePasteAdd} disabled={pasteText.trim() === ''}>
|
|
Add to library
|
|
</Button>
|
|
<Button variant="ghost" onClick={closePaste}>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Secondary paths as quiet links below the doors (Carbon empty-states —
|
|
secondary calls to action are links, not more buttons). */}
|
|
<p className={styles.altPaths}>
|
|
Restoring from an export?{' '}
|
|
<button
|
|
type="button"
|
|
className={styles.linkButton}
|
|
onClick={() => importInputRef.current?.click()}
|
|
>
|
|
Import your workspace
|
|
</button>
|
|
<span aria-hidden="true"> · </span>
|
|
New to Vega-Lite?{' '}
|
|
<a className={styles.linkButton} href="/learn/" target="_blank" rel="noopener noreferrer">
|
|
Read the deep dives
|
|
</a>
|
|
</p>
|
|
<input
|
|
ref={importInputRef}
|
|
type="file"
|
|
accept="application/json,.json"
|
|
className={styles.hiddenInput}
|
|
onChange={handleImportFile}
|
|
aria-hidden="true"
|
|
tabIndex={-1}
|
|
/>
|
|
|
|
<div className={styles.galleryHead}>
|
|
<h3 className={styles.galleryTitle}>Or start from an example</h3>
|
|
<Button className={styles.addAll} onClick={handleAddAll}>
|
|
Add all
|
|
</Button>
|
|
</div>
|
|
|
|
<ul className={styles.gallery}>
|
|
{CHART_EXAMPLES.map((example) => (
|
|
<li key={example.id} className={styles.card}>
|
|
<ExampleThumbnail spec={example.spec} />
|
|
<div className={styles.cardBody}>
|
|
<span className={styles.cardName}>{example.name}</span>
|
|
<span className={styles.cardDesc}>{example.description}</span>
|
|
</div>
|
|
<Button
|
|
className={styles.add}
|
|
aria-label={`Add ${example.name}`}
|
|
onClick={() => handleAdd(example)}
|
|
>
|
|
<Icon name="add" /> Add
|
|
</Button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|