diff --git a/docs/architecture/05-rendering-theming-preview.md b/docs/architecture/05-rendering-theming-preview.md index c1b0496..6ebeafe 100644 --- a/docs/architecture/05-rendering-theming-preview.md +++ b/docs/architecture/05-rendering-theming-preview.md @@ -576,6 +576,14 @@ It does two deterministic things, on a **deep copy** of the spec: and **removes** `width`; Full sets both — see spec §04 → Fit-mode sizing), recursing the same way. +The fit recursion has one Vega-Lite limit: **`"container"` sizing only works on +single and layered views** — facet children fall back with a warning (panel +widths become timing-dependent) and concat children fall back to pad-autosize +(axes overflow a fixed card). A surface that renders a composed spec at a fixed +size must ask for `fitMode: 'default'` and let the spec's declared per-view +`width`/`height` stand — the landing's `LandingChart` exposes this as a prop +for its composed demos. + This is _content_ preparation, not embedding, and it is fully covered by the _Live Preview_ spec. The only invariant this doc cares about: diff --git a/src/landing/Landing.module.css b/src/landing/Landing.module.css index 47ae816..3e85ffc 100644 --- a/src/landing/Landing.module.css +++ b/src/landing/Landing.module.css @@ -220,6 +220,20 @@ align-items: center; gap: var(--space-3); } +/* "Open in Astrolabe →" beside the Preview column head — the hand-off from + playing with the demo to holding the same chart in the app. */ +.colHeadLink { + margin-left: auto; + color: var(--accent); + text-decoration: none; + text-transform: none; + letter-spacing: normal; + font-size: 12px; +} +.colHeadLink:hover { + text-decoration: underline; +} + .libList { flex: 1 1 auto; min-height: 0; @@ -461,9 +475,11 @@ } /* theme demo */ +/* Compact grid — the theme *switch* is the demo; three charts in one glance, + not a page of scroll. */ .gallery { - display: flex; - flex-direction: column; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-4); margin-top: var(--space-5); } @@ -598,6 +614,248 @@ margin-left: auto; } +/* ── editor proof: staged still of the editor mid-thought (no Monaco) ─────── */ +.edBody { + position: relative; + padding: var(--space-4) var(--space-5); + font-family: var(--font-mono); + font-size: 12px; + overflow-x: auto; +} +.edLines { + margin: 0; + line-height: 1.7; + white-space: pre; + color: var(--text); +} +.edLens { + display: block; + font-size: 11px; + color: var(--accent); + margin-bottom: 2px; +} +.edCursor { + display: inline-block; + width: 2px; + height: 13px; + vertical-align: text-bottom; + background: var(--accent); +} +.edPopup { + display: flex; + margin: var(--space-2) 0 var(--space-3) var(--space-8); + max-width: 420px; + border: 1px solid var(--border); + background: var(--layer-01); + box-shadow: 0 8px 24px -12px rgba(0, 0, 0, 0.4); + font-size: 12px; +} +.edOptions { + list-style: none; + margin: 0; + padding: var(--space-1) 0; + min-width: 110px; + border-right: 1px solid var(--border); +} +.edOptions li { + padding: 2px var(--space-3); +} +.edOptionSel { + background: var(--accent-soft); + color: var(--accent); +} +.edDoc { + padding: var(--space-2) var(--space-3); + color: var(--text-secondary); + line-height: 1.5; +} +.edSquiggle { + text-decoration: underline wavy var(--support-error); + text-underline-offset: 3px; +} +/* Marker hover, anchored under the squiggled token like Monaco's. */ +.edHover { + margin: var(--space-2) 0 0 var(--space-8); + max-width: 420px; + padding: var(--space-2) var(--space-3); + border: 1px solid var(--border); + border-left: 3px solid var(--support-error); + background: var(--layer-01); + box-shadow: 0 8px 24px -12px rgba(0, 0, 0, 0.4); + font-size: 12px; + color: var(--text-secondary); + line-height: 1.5; +} + +/* ── objections: the habit cluster, answered as on-ramps ──────────────────── */ +.objections { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--space-7); + padding: 0 0 var(--space-9); +} +.objections h3 { + font-size: 15px; + font-weight: 600; + margin: 0 0 var(--space-2); +} +.objections p { + margin: 0; + font-size: 14px; + line-height: 1.55; + color: var(--text-secondary); +} + +/* Inline accent link inside body copy (builder CTA, close note). */ +.inlineLink { + color: var(--accent); + text-decoration: none; +} +.inlineLink:hover { + text-decoration: underline; +} + +/* Store-once-reference-many diagram: a dataset with its snippets as a tree. */ +.refDiagram { + padding: var(--space-4); + border-bottom: 1px solid var(--border); +} +.refDataset { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: 13px; + font-weight: 600; +} +.refDot { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--accent); + flex: 0 0 auto; +} +.refMeta { + font-weight: 400; + color: var(--text-secondary); +} +.refTree { + list-style: none; + margin: var(--space-1) 0 0 4px; + padding: 0; +} +.refTree li { + position: relative; + padding: 3px 0 3px 22px; + font-size: 13px; +} +/* Tree connectors: a vertical rail plus an elbow per row. */ +.refTree li::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + border-left: 1px solid var(--border-strong); +} +.refTree li:last-child::before { + bottom: auto; + height: 14px; +} +.refTree li::after { + content: ''; + position: absolute; + left: 0; + top: 14px; + width: 14px; + border-top: 1px solid var(--border-strong); +} +.refMore { + color: var(--text-secondary); + font-style: italic; +} + +/* Stacked capability section: text intro above, demo at full column width. */ +.capStack { + padding: var(--space-9) 0; +} +.capStack .shot { + margin-top: var(--space-6); +} + +.themeNote { + margin: var(--space-3) 0 0; + font-size: 13px; + color: var(--text-secondary); +} + +.closeNote { + margin-top: var(--space-4); + font-size: 14px; + color: var(--text-secondary); +} + +/* ── showcase: live spec ↔ chart demos ("What Vega-Lite can do") ─────────── */ +.showcase { + padding: var(--space-9) 0 var(--space-5); +} +.showcaseIntro { + max-width: 62ch; + margin-bottom: var(--space-8); +} +.demo { + margin-bottom: var(--space-9); +} +.demoTitle { + font-size: 18px; + font-weight: 600; + margin: 0 0 var(--space-2); +} +.demoBlurb { + color: var(--text-secondary); + font-size: 14px; + line-height: 1.55; + max-width: 66ch; + margin: 0 0 var(--space-5); +} +.demoRow { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr); + gap: var(--space-6); + align-items: stretch; +} +/* The spec pane: same chrome as the hero window's code column; the inner .code + pre scrolls when the spec outgrows the cap. */ +.demoCode { + display: flex; + min-width: 0; + border: 1px solid var(--border); + background: var(--layer-01); + max-height: 400px; + overflow: hidden; +} +.demoCode .code { + min-width: 0; +} +.demoChartCard { + min-width: 0; + border: 1px solid var(--border); + background: var(--bg); + box-shadow: 0 16px 40px -20px rgba(0, 0, 0, 0.3); + padding: var(--space-5); + display: flex; + flex-direction: column; + justify-content: center; + /* Fixed-size composed charts (the facet demo) scroll inside the card rather + than widening the page on small screens. */ + overflow-x: auto; +} +.demoHint { + margin: var(--space-4) 0 0; + font-family: var(--font-mono); + font-size: 12px; + color: var(--accent); +} + @media (max-width: 760px) { .appBody { grid-template-columns: 1fr; @@ -612,11 +870,24 @@ .code { max-height: 320px; } + /* minmax(0, …): a plain 1fr track lets pre-formatted code set the column's + min-content width and push the page wider than the viewport. */ .cap, - .creedGrid { - grid-template-columns: 1fr; + .creedGrid, + .demoRow, + .objections, + .gallery { + grid-template-columns: minmax(0, 1fr); gap: var(--space-6); } + .demoCode { + max-height: 300px; + } + /* Section anchors don't fit a phone-width nav row; the page is one scroll + anyway — keep brand, theme toggle, and the CTA. */ + .navLink { + display: none; + } .capRev .capText { order: 0; } diff --git a/src/landing/Landing.tsx b/src/landing/Landing.tsx index 12621a7..7e22d26 100644 --- a/src/landing/Landing.tsx +++ b/src/landing/Landing.tsx @@ -27,10 +27,18 @@ import { } from '@core/sample-dataset'; import { DEMO_CUSTOM_THEMES } from './demo-themes'; import { LandingChart } from './LandingChart'; +import { + SHOWCASE_DEMOS, + showcaseDisplaySpec, + showcaseFitMode, + type ShowcaseDemo, +} from './showcase-specs'; import styles from './Landing.module.css'; // TODO: import { UiTheme } from '@core/theme' instead of redeclaring it — the // learn entry already uses the canonical one. +// TODO: this file is the repo's second-largest; on the next added section, split +// into per-section files under src/landing/ (eng-council 2026-07). type UiTheme = 'light' | 'dark'; // Minimal JSON syntax highlighter for the read-only spec view: keys, strings, @@ -85,12 +93,41 @@ function Brand(): ReactNode { ); } +/** The framed app-window chrome (traffic-dot title bar) every section shot sits in. */ +function Shot({ bar, children }: { bar: ReactNode; children: ReactNode }): ReactNode { + return ( +
+
+ + + + + {' '} + {bar} +
+ {children} +
+ ); +} + // ── Hero: switch snippets, the editor + chart follow ──────────────────────────── function HeroAppWindow({ theme }: { theme: UiTheme }): ReactNode { - const [selected, setSelected] = useState(0); + // Open on the brushed scatter: the first thing a visitor sees is a chart that + // responds to them — the "this is live" argument made without words. + const [selected, setSelected] = useState(() => + Math.max( + 0, + CHART_EXAMPLES.findIndex((e) => e.id === 'brush'), + ), + ); const example = CHART_EXAMPLES[selected]; - const config = useMemo(() => chartConfigForSelection('astrolabe', theme), [theme]); + // A demo custom theme (not stock blue): the window is also the first proof + // that charts here look designed out of the box. + const config = useMemo( + () => chartConfigForSelection(customThemeSelection(1), theme, DEMO_CUSTOM_THEMES), + [theme], + ); return (
@@ -130,7 +167,12 @@ function HeroAppWindow({ theme }: { theme: UiTheme }): ReactNode {
-
Preview
+
+ Preview + + Open in Astrolabe → + +
@@ -140,6 +182,136 @@ function HeroAppWindow({ theme }: { theme: UiTheme }): ReactNode { ); } +// ── Showcase: live spec ↔ chart pairs — "a page of JSON is an interactive chart" ─ + +function ShowcaseDemoRow({ demo, theme }: { demo: ShowcaseDemo; theme: UiTheme }): ReactNode { + const config = useMemo( + () => chartConfigForSelection(customThemeSelection(demo.themeId), theme, DEMO_CUSTOM_THEMES), + [demo.themeId, theme], + ); + return ( +
+

{demo.title}

+

{demo.blurb}

+
+ {/* The displayed spec references data by name — the app's idiom — so the + text stays the length of the idea, not of the rows (showcase-specs.ts). */} +
+ +
+
+ +

{demo.hint}

+
+
+
+ ); +} + +function ShowcaseSection({ theme }: { theme: UiTheme }): ReactNode { + return ( +
+
+
Charts as text
+

+ A page of JSON is an interactive chart. +

+

+ Vega-Lite is a grammar: declare what the data is and how it maps to marks, and the + rendering — axes, legends, interaction — follows. The three charts below are live, and the + text beside each one is its whole program — the data referenced by name, the way a snippet + in Astrolabe reads. +

+
+ {SHOWCASE_DEMOS.map((demo) => ( + + ))} +
+ ); +} + +// ── Editor proof: a staged (no-Monaco) still of the editor mid-thought ────────── +// The landing must not import Monaco (post-build gate keeps `/` light), so the +// editor's intelligence is shown as a hand-built still: schema autocomplete with +// inline docs, a CodeLens scaffold row, and a validation catch. Purely +// presentational; the real behaviors live in the app (arch 08). + +function EditorProof(): ReactNode { + return ( + + + + ); +} + // ── Chart Builder demo: pick fields, a mark, or an intent; the chart rebuilds ──── const MARK_OPTIONS: ReadonlyArray<{ type: MarkType; label: string }> = [ @@ -175,12 +347,34 @@ function columnType(name: string): (typeof BUILDER_TYPES)[number]['type'] { function fieldMapping(name: string): ChannelMapping { return { field: name, type: defaultFieldType(columnType(name)) }; } +/** A summed measure — rows repeat per (month, region, channel), so an + unaggregated y reads as noise on every mark but the scatter. */ +function summed(name: string): ChannelMapping { + return { ...fieldMapping(name), aggregate: 'sum' }; +} + +// Each mark tab shows an archetype that makes sense for sales-2024 — switching +// the mark alone would leave mappings that read as noise (a temporal bar stack, +// an unaggregated line). The demo must never show a meaningless chart; the +// selects below stay free for exploration. +const MARK_PRESET_ENCODINGS: Partial>> = { + bar: { x: fieldMapping('region'), y: summed('revenue'), color: fieldMapping('region') }, + line: { x: fieldMapping('month'), y: summed('revenue'), color: fieldMapping('region') }, + point: { x: fieldMapping('units'), y: fieldMapping('revenue'), color: fieldMapping('region') }, + area: { x: fieldMapping('month'), y: summed('revenue'), color: fieldMapping('region') }, +}; + +function markPreset(mark: MarkType): BuilderConfig { + const base = defaultBuilderConfig(SAMPLE_DATASET_NAME, SAMPLE_DATASET_COLUMNS); + const preset = MARK_PRESET_ENCODINGS[mark]; + return { ...base, mark, encodings: preset ? { ...base.encodings, ...preset } : base.encodings }; +} function BuilderDemo({ theme }: { theme: UiTheme }): ReactNode { const columns = SAMPLE_DATASET_COLUMNS; - const [config, setConfig] = useState(() => - defaultBuilderConfig(SAMPLE_DATASET_NAME, columns), - ); + // First frame: the line archetype (revenue over time by region) — the demo's + // opening state is its argument. + const [config, setConfig] = useState(() => markPreset('line')); const active = activeIntent(config, columns); const chartConfig = useMemo(() => chartConfigForSelection('astrolabe', theme), [theme]); const spec = useMemo(() => { @@ -205,7 +399,7 @@ function BuilderDemo({ theme }: { theme: UiTheme }): ReactNode { type="button" aria-pressed={config.mark === m.type} className={config.mark === m.type ? styles.on : undefined} - onClick={() => setConfig({ ...config, mark: m.type })} + onClick={() => setConfig(markPreset(m.type))} > {m.label} @@ -235,7 +429,11 @@ function BuilderDemo({ theme }: { theme: UiTheme }): ReactNode { 'y', e.target.value === COUNT ? { type: 'quantitative', aggregate: 'count' } - : fieldMapping(e.target.value), + : // Rows repeat per (month, region, channel): keep measures summed + // everywhere except the scatter, which shows the raw rows. + config.mark === 'point' + ? fieldMapping(e.target.value) + : summed(e.target.value), ) } > @@ -292,7 +490,25 @@ const THEME_CHOICES: ReadonlyArray<{ selection: ChartThemeSelection; label: stri { selection: 'vox', label: 'Vox' }, { selection: 'dark', label: 'Vega Dark' }, ]; -const GALLERY = THEME_PREVIEW_SPECS.filter((s) => ['bar', 'line', 'scatter'].includes(s.id)); +// The core bar preview deliberately carries no colour encoding (it exercises +// title/axes/grid, so bars take the mark default) — on a marketing page that +// reads as "default blue". Give the landing's copy a colour channel so every +// gallery chart shows the selected theme's palette. +const GALLERY = THEME_PREVIEW_SPECS.filter((s) => ['bar', 'line', 'scatter'].includes(s.id)).map( + (s) => + s.id === 'bar' + ? { + ...s, + spec: { + ...s.spec, + encoding: { + ...(s.spec.encoding as Record), + color: { field: 'region', type: 'nominal', legend: null }, + }, + }, + } + : s, +); function ThemeDemo({ theme }: { theme: UiTheme }): ReactNode { const [selection, setSelection] = useState(customThemeSelection(1)); @@ -316,6 +532,10 @@ function ThemeDemo({ theme }: { theme: UiTheme }): ReactNode { ))}
+

+ Editorial, Blueprint, and Sunset are custom themes made in the Theme Builder; the rest ship + as presets. +

{GALLERY.map((ps) => (
@@ -344,6 +564,9 @@ export function Landing(): ReactNode {
+ + What it can do + Authoring @@ -368,11 +591,12 @@ export function Landing(): ReactNode {

- A home for your Vega-Lite charts. + Charts written as text. A home to keep them.

- Astrolabe is a local studio for Vega-Lite. Write a spec by hand or build one by - clicking, give it a theme, and keep all your charts in one searchable library. + Astrolabe is a local studio for Vega-Lite — the grammar that turns a short JSON spec + into an interactive chart. Write specs by hand or build them by clicking, give them a + theme, and keep every chart in one searchable library.

@@ -387,44 +611,89 @@ export function Landing(): ReactNode {

- Pick a snippet on the left — the editor and chart follow. + This is the real thing, not a screenshot: pick a snippet on the left, drag across the + brushed scatter — it's Vega rendering live.

+
-
Two ways in
+
For the spec author

- Write the spec, or build it by clicking. + An editor that knows Vega-Lite.

- The editor is Monaco with the Vega-Lite schema loaded, so you get validation, - autocompletion, and inline docs without going online. Each chart keeps an editable - draft alongside a published version you can revert to. + The editor is Monaco with the Vega-Lite schema loaded: autocompletion, inline docs, + and validation as you type, all of it offline. One click scaffolds a working parameter + or data transform to build on.

- If you'd rather not start from JSON, the builder works from the kind of chart you - want. Pick fields, a mark, or a whole intent — it writes the spec for you. Try it: + When a chart misbehaves, the data inspector shows the rows going in and the rows the + marks actually draw — the fastest answer to "why is my chart empty". And every snippet + keeps an editable draft alongside its published version, so you can always revert to + the last good chart.

-
-
- - - - - {' '} - Chart Builder · {SAMPLE_DATASET_NAME} -
+ +
+ + {/* The habit-cluster objections, answered where the skeptic is scrolling + (landing-onboarding scope memo): each answer is an on-ramp, not a rebuttal. */} +
+
+

Already using the Vega editor?

+

+ It's a fine scratchpad — one spec at a time, nothing kept. Astrolabe is the home: a + library of specs with their datasets, themes, and drafts. +

+
+
+

Writing charts in Altair or another wrapper?

+

+ A wrapper's output already is a Vega-Lite spec. Paste it in, polish the last mile + (label formats, axis tweaks you'd otherwise translate back through the wrapper's API), + and keep the result. +

+
+
+

Asking an AI for specs?

+

+ Paste what it gave you: live preview, schema validation, and the data inspector are + where an almost-right spec becomes right. There is no AI inside Astrolabe to see your + data. +

+
+
+ +
+ + Chart Builder · {SAMPLE_DATASET_NAME}}>
-
+ -
+
One library

@@ -435,21 +704,27 @@ export function Landing(): ReactNode { it and Astrolabe updates every chart that used it.

- Load data by pasting CSV or JSON, or by fetching a URL. You can also lift inline data - out of a spec into a shared dataset. Search and sort as the collection grows, and - duplicate a snippet to start a variant. + Paste CSV or JSON, fetch a URL, or lift the inline data already sitting in a spec out + into a shared dataset. Search and sort as the collection grows, and duplicate a + snippet to start a variant.

-
-
- - - - - {' '} - Datasets -
+
+ {/* Store-once-reference-many, as a picture: one dataset, the + snippets that read it hanging off it. */} +
sales-2025 · CSV · 1,204 rows @@ -484,62 +759,52 @@ export function Landing(): ReactNode {
-
+
-
+ {/* Stacked (text above, demo full width): the gallery needs the whole + column for three charts to breathe. */} +
Make it yours

Give your charts a look of their own.

- Sixteen presets to start from, or build your own in the Theme Builder — colour, type, - axes, legend, and layout. The first three here are built from scratch. Try a few: + Upload your own fonts (variable fonts included) and apply one across a whole theme at + once. Sixteen presets to start from, or build your own in the Theme Builder: colour, + type, axes, legends, marks, titles, number formats.

- Upload your own fonts, variable fonts included, and apply one across a whole theme at - once. Save the theme and use it on any chart. + Save a theme and use it on any chart. The first three here are built from scratch. Try + a few:

-
-
- - - - - {' '} - Theme Builder -
+
-
+
-
+
-
Exporting
+
Getting it out

- Save a chart in the format you need. + Exports with the settings you reach for.

- Export as a PNG at 1–3×, as an SVG, or as a Vega-Lite spec. The spec can carry its - data inline, so the file renders on its own wherever it lands. + Every embedded Vega chart ships the same tiny menu (save PNG, save SVG). Astrolabe's + export adds the settings that menu is missing: 1–3× scale for slides and print, + background control, and the spec itself as a file.

- Your whole workspace exports and re-imports as a single JSON file. + The spec export can inline its data: one self-contained .vl.json that renders anywhere + Vega-Lite runs. That file is how you share a chart from a tool with no server behind + it.

-
-
- - - - - {' '} - Export chart -
+
@@ -591,7 +856,7 @@ export function Landing(): ReactNode {
-
+
@@ -612,7 +877,8 @@ export function Landing(): ReactNode {

Local to your browser

Charts, data, and themes are saved in this browser and nowhere else — there is no - server behind Astrolabe to receive them. It keeps working offline once installed. + server behind Astrolabe to receive them. Install it and switch off the network: it + keeps working. That's the privacy claim in a form you can test.

@@ -620,15 +886,16 @@ export function Landing(): ReactNode {

Ordinary Vega-Lite

Export a single chart or the whole library as standard JSON and open it in any other - Vega tool. + Vega tool. If Astrolabe vanished tomorrow, your charts wouldn't notice.

yours -

Set up your way

+

One file, yours to keep

- Author by hand or by clicking, with your own fonts and themes. Arrange the library - to match how you work. + The whole workspace — snippets, datasets, themes, fonts — exports as a single JSON + file. Back it up like any file you own and import it on any machine. No sync service + to trust.

@@ -643,6 +910,13 @@ export function Landing(): ReactNode { Open Astrolabe +

+ New to Vega-Lite?{' '} + + Start with the deep dives + + . +

diff --git a/src/landing/LandingChart.tsx b/src/landing/LandingChart.tsx index 07d8ec5..9d7938d 100644 --- a/src/landing/LandingChart.tsx +++ b/src/landing/LandingChart.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState, type ReactNode } from 'react'; import type { Config } from 'vega-lite'; import type { VisualizationSpec } from 'vega-embed'; -import { prepareSpecForRender } from '@core/rendering'; +import { prepareSpecForRender, type FitMode } from '@core/rendering'; import styles from './Landing.module.css'; /** @@ -18,10 +18,20 @@ export function LandingChart({ spec, config, className, + fitMode = 'width', }: { spec: Record; config: Config; className?: string; + /** + * Sizing contract passed to `prepareSpecForRender`. The `'width'` default + * fits every single-view surface here; a *composed* spec (facet/concat) must + * pass `'default'` and declare its own sizes — the fit recursion would set + * `width: 'container'` on the children, which Vega-Lite only supports on + * single and layered views (facet panels go timing-dependent, concat children + * fall back to pad and overflow). + */ + fitMode?: FitMode; }): ReactNode { const hostRef = useRef(null); const [failed, setFailed] = useState(false); @@ -37,7 +47,7 @@ export function LandingChart({ // Width-responsive: the chart fills its host (a definite-width `.chartNode`) // and keeps a natural height — right for every landing surface (hero pane, // builder, theme gallery). - const prepared: unknown = prepareSpecForRender(spec, { fitMode: 'width' }); + const prepared: unknown = prepareSpecForRender(spec, { fitMode }); handle = await renderSpec(node, prepared as VisualizationSpec, config); if (cancelled) { handle.destroy(); @@ -57,7 +67,7 @@ export function LandingChart({ handle?.destroy(); handle = null; }; - }, [spec, config]); + }, [spec, config, fitMode]); return (
diff --git a/src/landing/showcase-specs.test.ts b/src/landing/showcase-specs.test.ts new file mode 100644 index 0000000..b18f534 --- /dev/null +++ b/src/landing/showcase-specs.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from 'vitest'; +import { compile } from 'vega-lite'; +import { SHOWCASE_DEMOS, showcaseDisplaySpec, showcaseFitMode } from './showcase-specs'; + +// Same promise as the example gallery (examples.test.ts): every showcase chart +// must render — each spec is valid, self-contained Vega-Lite. +describe('landing showcase demos', () => { + test('ids are unique and demos are non-empty', () => { + const ids = SHOWCASE_DEMOS.map((d) => d.id); + expect(ids.length).toBeGreaterThan(0); + expect(new Set(ids).size).toBe(ids.length); + }); + + test('composed demos render at their declared sizes, single views fit the card', () => { + const byId = (id: string) => SHOWCASE_DEMOS.find((d) => d.id === id)!; + expect(showcaseFitMode(byId('tooltip'))).toBe('width'); // single view + expect(showcaseFitMode(byId('brush'))).toBe('default'); // vconcat + expect(showcaseFitMode(byId('facet'))).toBe('default'); // facet + }); + + describe.each(SHOWCASE_DEMOS.map((d) => [d.id, d] as const))('demo: %s', (_id, demo) => { + test('carries inline data and compiles as valid Vega-Lite', () => { + const data = demo.spec.data as { values?: unknown[] }; + expect(Array.isArray(data?.values)).toBe(true); + expect(() => compile(demo.spec as unknown as Parameters[0])).not.toThrow(); + }); + + test('the displayed spec swaps inline data for a name reference, all else equal', () => { + const display = showcaseDisplaySpec(demo); + expect(display.data).toEqual({ name: demo.datasetName }); + const { data: _a, ...restDisplay } = display; + const { data: _b, ...restReal } = demo.spec; + expect(restDisplay).toEqual(restReal); + }); + }); +}); diff --git a/src/landing/showcase-specs.ts b/src/landing/showcase-specs.ts new file mode 100644 index 0000000..589d290 --- /dev/null +++ b/src/landing/showcase-specs.ts @@ -0,0 +1,207 @@ +/** + * The "What Vega-Lite can do" showcase — the landing's popularization + * centerpiece. Three live, *interactive* charts, each beside the spec that + * produces it: the argument is that a page of JSON is a working chart. + * + * Same constraints as the app's example gallery (`@core/examples`): inline + * `data.values` (self-contained, renders offline), valid Vega-Lite (asserted in + * showcase-specs.test.ts). Rendered specs carry the data inline; the *displayed* + * spec shows `data: { name: … }` instead — the app's own dataset-by-reference + * idiom — so the text beside each chart stays the length of the idea, not of + * the data, and reads exactly like a snippet in Astrolabe. + */ + +import type { FitMode } from '@core/rendering'; +import { VEGA_LITE_SCHEMA_URL } from '@core/snippet'; + +export interface ShowcaseDemo { + /** Stable key — React list key and test identity. */ + id: string; + /** Short heading over the demo row. */ + title: string; + /** One or two sentences: the capability this demo argues. */ + blurb: string; + /** The try-it nudge shown under the live chart. */ + hint: string; + /** Dataset name shown in the displayed spec's `data: { name: … }`. */ + datasetName: string; + /** The full spec (inline data) that actually renders. */ + spec: Record; + /** Demo custom-theme id (see demo-themes.ts) this chart renders in. */ + themeId: number; +} + +const TEMPS = [ + { month: '2025-01-01', city: 'Kyiv', temp: -3.1 }, + { month: '2025-02-01', city: 'Kyiv', temp: -1.4 }, + { month: '2025-03-01', city: 'Kyiv', temp: 4.2 }, + { month: '2025-04-01', city: 'Kyiv', temp: 11.3 }, + { month: '2025-05-01', city: 'Kyiv', temp: 17.6 }, + { month: '2025-06-01', city: 'Kyiv', temp: 20.8 }, + { month: '2025-07-01', city: 'Kyiv', temp: 22.9 }, + { month: '2025-08-01', city: 'Kyiv', temp: 22.1 }, + { month: '2025-01-01', city: 'Lisbon', temp: 11.6 }, + { month: '2025-02-01', city: 'Lisbon', temp: 12.4 }, + { month: '2025-03-01', city: 'Lisbon', temp: 14.5 }, + { month: '2025-04-01', city: 'Lisbon', temp: 15.9 }, + { month: '2025-05-01', city: 'Lisbon', temp: 18.2 }, + { month: '2025-06-01', city: 'Lisbon', temp: 21.4 }, + { month: '2025-07-01', city: 'Lisbon', temp: 23.6 }, + { month: '2025-08-01', city: 'Lisbon', temp: 24.0 }, + { month: '2025-01-01', city: 'Oslo', temp: -4.8 }, + { month: '2025-02-01', city: 'Oslo', temp: -4.1 }, + { month: '2025-03-01', city: 'Oslo', temp: 0.4 }, + { month: '2025-04-01', city: 'Oslo', temp: 5.9 }, + { month: '2025-05-01', city: 'Oslo', temp: 11.8 }, + { month: '2025-06-01', city: 'Oslo', temp: 15.7 }, + { month: '2025-07-01', city: 'Oslo', temp: 17.4 }, + { month: '2025-08-01', city: 'Oslo', temp: 16.3 }, +]; + +const ENGINES = [ + { power: 68, efficiency: 22.4, origin: 'Japan' }, + { power: 75, efficiency: 20.8, origin: 'Japan' }, + { power: 88, efficiency: 19.1, origin: 'Japan' }, + { power: 97, efficiency: 17.5, origin: 'Japan' }, + { power: 110, efficiency: 15.9, origin: 'Japan' }, + { power: 130, efficiency: 13.6, origin: 'Japan' }, + { power: 118, efficiency: 14.8, origin: 'Japan' }, + { power: 72, efficiency: 18.9, origin: 'Europe' }, + { power: 85, efficiency: 17.8, origin: 'Europe' }, + { power: 100, efficiency: 16.2, origin: 'Europe' }, + { power: 115, efficiency: 14.7, origin: 'Europe' }, + { power: 140, efficiency: 12.1, origin: 'Europe' }, + { power: 165, efficiency: 10.4, origin: 'Europe' }, + { power: 90, efficiency: 15.3, origin: 'USA' }, + { power: 105, efficiency: 14.1, origin: 'USA' }, + { power: 125, efficiency: 12.8, origin: 'USA' }, + { power: 150, efficiency: 11.2, origin: 'USA' }, + { power: 200, efficiency: 8.6, origin: 'USA' }, +]; + +const SIGNUPS = [ + { week: '2025-05-05', plan: 'Free', signups: 84 }, + { week: '2025-05-12', plan: 'Free', signups: 96 }, + { week: '2025-05-19', plan: 'Free', signups: 110 }, + { week: '2025-05-26', plan: 'Free', signups: 103 }, + { week: '2025-06-02', plan: 'Free', signups: 121 }, + { week: '2025-06-09', plan: 'Free', signups: 137 }, + { week: '2025-05-05', plan: 'Pro', signups: 22 }, + { week: '2025-05-12', plan: 'Pro', signups: 27 }, + { week: '2025-05-19', plan: 'Pro', signups: 25 }, + { week: '2025-05-26', plan: 'Pro', signups: 34 }, + { week: '2025-06-02', plan: 'Pro', signups: 39 }, + { week: '2025-06-09', plan: 'Pro', signups: 45 }, + { week: '2025-05-05', plan: 'Team', signups: 6 }, + { week: '2025-05-12', plan: 'Team', signups: 9 }, + { week: '2025-05-19', plan: 'Team', signups: 11 }, + { week: '2025-05-26', plan: 'Team', signups: 10 }, + { week: '2025-06-02', plan: 'Team', signups: 14 }, + { week: '2025-06-09', plan: 'Team', signups: 18 }, +]; + +export const SHOWCASE_DEMOS: ReadonlyArray = [ + { + id: 'tooltip', + title: 'Tooltips are one word', + blurb: + 'Interaction is declared like any other property. The tooltips on this chart cost a single one: "tooltip": true.', + hint: 'Hover the points.', + datasetName: 'city-temperatures', + themeId: 1, // Editorial + spec: { + $schema: VEGA_LITE_SCHEMA_URL, + description: 'Monthly average temperature by city.', + data: { values: TEMPS }, + mark: { type: 'line', point: true, tooltip: true }, + encoding: { + x: { field: 'month', type: 'temporal', title: 'Month' }, + y: { field: 'temp', type: 'quantitative', title: '°C' }, + color: { field: 'city', type: 'nominal' }, + }, + }, + }, + { + id: 'brush', + title: 'Selections drive other views', + blurb: + 'A named selection in one view becomes a filter in another. Declare a param and a filter, and the views are linked — there is no event-handler code to write.', + hint: 'Drag a rectangle on the scatter — the bars recount.', + datasetName: 'engines', + themeId: 2, // Blueprint + spec: { + $schema: VEGA_LITE_SCHEMA_URL, + description: 'Engine power vs efficiency; brushing filters the count below.', + data: { values: ENGINES }, + // Declared sizes, not container fit: Vega-Lite can't fit-size concat + // children (autosize falls back to pad and the axes overflow the card). + vconcat: [ + { + width: 380, + height: 210, + params: [{ name: 'brush', select: 'interval' }], + mark: 'point', + encoding: { + x: { field: 'power', type: 'quantitative', title: 'Power (hp)' }, + y: { field: 'efficiency', type: 'quantitative', title: 'km / l' }, + color: { + condition: { param: 'brush', field: 'origin', type: 'nominal' }, + value: 'lightgray', + }, + }, + }, + { + width: 380, + height: 80, + transform: [{ filter: { param: 'brush' } }], + mark: 'bar', + encoding: { + y: { field: 'origin', type: 'nominal', title: null }, + x: { aggregate: 'count', title: 'Engines in brush' }, + color: { field: 'origin', type: 'nominal', legend: null }, + }, + }, + ], + }, + }, + { + id: 'facet', + title: 'One spec, a chart per group', + blurb: + 'Add a facet and the grammar repeats the chart for every value of a field — same axes, same scales, honest comparison for free.', + hint: 'One declaration made three charts.', + datasetName: 'weekly-signups', + themeId: 3, // Sunset + spec: { + $schema: VEGA_LITE_SCHEMA_URL, + description: 'Weekly signups, one panel per plan.', + data: { values: SIGNUPS }, + facet: { column: { field: 'plan', title: null } }, + spec: { + width: 130, + height: 150, + mark: { type: 'area', line: true, tooltip: true }, + encoding: { + x: { field: 'week', type: 'temporal', title: null }, + y: { field: 'signups', type: 'quantitative', title: 'Signups' }, + color: { field: 'plan', type: 'nominal', legend: null }, + }, + }, + }, + }, +]; + +/** The spec as shown beside the chart: data by name, the app's own idiom. */ +export function showcaseDisplaySpec(demo: ShowcaseDemo): Record { + return { ...demo.spec, data: { name: demo.datasetName } }; +} + +/** + * Sizing contract for a demo's live render. Composed specs (facet/concat/repeat) + * keep their declared per-view sizes — the width-fit recursion can't size their + * children (see LandingChart) — while single and layered views fit the card. + */ +export function showcaseFitMode(demo: ShowcaseDemo): FitMode { + const composed = ['facet', 'vconcat', 'hconcat', 'concat', 'repeat']; + return composed.some((k) => k in demo.spec) ? 'default' : 'width'; +}