mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Replace the first-run placeholder seed with an onboarding canvas
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* 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 } from 'react';
|
||||
import type { VisualizationSpec } from 'vega-embed';
|
||||
import { CHART_EXAMPLES, exampleSpecText, type ChartExample } from '@core/examples';
|
||||
import { createSnippet as createSnippetRecord } from '@core/snippet';
|
||||
import { chartConfigFor } from '@core/vega-themes';
|
||||
import { renderSpec, type RenderHandle } from '../services/chart-renderer';
|
||||
import { useAppStore } from '../stores/AppStore';
|
||||
import { usePanesStore } from '../stores/PanesStore';
|
||||
import { useSnippetStore } from '../stores/SnippetStore';
|
||||
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;
|
||||
// TODO: thumbnails render once at mount width and don't call handle.resize(),
|
||||
// so a `width: 'container'` chart won't re-fit when the window resizes and the
|
||||
// grid reflows the card. Cosmetic only (the card clips/letterboxes via overflow
|
||||
// hidden) on a brief first-run surface; wire a resize observer if it ever shows.
|
||||
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.
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
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);
|
||||
|
||||
// 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();
|
||||
};
|
||||
|
||||
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 — authored as JSON, rendered live, and kept on
|
||||
your device.
|
||||
</p>
|
||||
|
||||
<button type="button" className={styles.primary} onClick={handleCreate}>
|
||||
<Icon name="add" /> Create your first snippet
|
||||
</button>
|
||||
|
||||
<div className={styles.galleryHead}>
|
||||
<h3 className={styles.galleryTitle}>Or start from an example</h3>
|
||||
<button type="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
|
||||
type="button"
|
||||
className={styles.add}
|
||||
aria-label={`Add ${example.name}`}
|
||||
onClick={() => handleAdd(example)}
|
||||
>
|
||||
<Icon name="add" /> Add
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user