import { useMemo, useState, type ReactNode } from 'react'; import { applyIntent, activeIntent, buildChartSpec, CHART_INTENTS, defaultBuilderConfig, defaultFieldType, intentApplicable, type BuilderConfig, type ChannelMapping, type ChannelName, type ChartIntent, type MarkType, } from '@core/chart-builder'; import { CHART_EXAMPLES } from '@core/examples'; import { chartConfigForSelection, customThemeSelection, type ChartThemeSelection, } from '@core/vega-themes'; import { THEME_PREVIEW_SPECS } from '@core/theme-preview-specs'; import { SAMPLE_DATASET_COLUMNS, SAMPLE_DATASET_NAME, SAMPLE_DATASET_ROWS, } from '@core/sample-dataset'; import { DEMO_CUSTOM_THEMES } from './demo-themes'; import { LandingChart } from './LandingChart'; import styles from './Landing.module.css'; // TODO: import { UiTheme } from '@core/theme' instead of redeclaring it — the // learn entry already uses the canonical one. type UiTheme = 'light' | 'dark'; // Minimal JSON syntax highlighter for the read-only spec view: keys, strings, // numbers, and literals get a token class; punctuation and whitespace pass // through untouched. A read-only marketing snippet doesn't warrant a highlighter // dependency. `matchAll` over a global regex is stateless per call. const JSON_TOKEN = /("(?:[^"\\]|\\.)*"(?=\s*:))|("(?:[^"\\]|\\.)*")|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)|\b(?:true|false|null)\b/g; function JsonCode({ spec }: { spec: unknown }): ReactNode { const text = JSON.stringify(spec, null, 2); const out: ReactNode[] = []; let last = 0; let key = 0; for (const m of text.matchAll(JSON_TOKEN)) { const idx = m.index ?? 0; if (idx > last) out.push(text.slice(last, idx)); const cls = m[1] ? styles.tokKey : m[2] ? styles.tokStr : styles.tokNum; out.push( {m[0]} , ); last = idx + m[0].length; } if (last < text.length) out.push(text.slice(last)); return
{out}
; } /** A snippet-style filename from an example name. */ function specFilename(name: string): string { return `${name .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-|-$/g, '')}.vl.json`; } /** The mark name of an example spec (string mark or `{ type }` form). */ function markLabel(spec: Record): string { const mark = spec.mark; if (typeof mark === 'string') return mark; if (mark && typeof mark === 'object' && 'type' in mark) return String(mark.type); return 'chart'; } function Brand(): ReactNode { return ( {' '} Astrolabe ); } // ── Hero: switch snippets, the editor + chart follow ──────────────────────────── function HeroAppWindow({ theme }: { theme: UiTheme }): ReactNode { const [selected, setSelected] = useState(0); const example = CHART_EXAMPLES[selected]; const config = useMemo(() => chartConfigForSelection('astrolabe', theme), [theme]); return (
{CHART_EXAMPLES.length} snippets live preview
Library
Search snippets…
{CHART_EXAMPLES.map((ex, i) => ( ))}
{specFilename(example.name)}
Preview
); } // ── Chart Builder demo: pick fields, a mark, or an intent; the chart rebuilds ──── const MARK_OPTIONS: ReadonlyArray<{ type: MarkType; label: string }> = [ { type: 'bar', label: 'Bar' }, { type: 'line', label: 'Line' }, { type: 'point', label: 'Point' }, { type: 'area', label: 'Area' }, ]; const INTENT_LABELS: Record = { compare: 'Compare', ranking: 'Ranking', time: 'Over time', correlation: 'Correlation', distribution: 'Distribution', partToWhole: 'Part-to-whole', heatmap: 'Heatmap', }; const BUILDER_TYPES = SAMPLE_DATASET_COLUMNS.columnTypes; const ALL_COLUMNS = SAMPLE_DATASET_COLUMNS.columns; const MEASURE_COLUMNS = BUILDER_TYPES.filter((c) => c.type === 'number').map((c) => c.name); const CATEGORY_COLUMNS = BUILDER_TYPES.filter( (c) => c.type === 'string' || c.type === 'boolean', ).map((c) => c.name); const COUNT = '__count__'; const NONE = '__none__'; function columnType(name: string): (typeof BUILDER_TYPES)[number]['type'] { return BUILDER_TYPES.find((c) => c.name === name)?.type ?? 'string'; } /** A field mapping with the field type the builder would derive for that column. */ function fieldMapping(name: string): ChannelMapping { return { field: name, type: defaultFieldType(columnType(name)) }; } function BuilderDemo({ theme }: { theme: UiTheme }): ReactNode { const columns = SAMPLE_DATASET_COLUMNS; const [config, setConfig] = useState(() => defaultBuilderConfig(SAMPLE_DATASET_NAME, columns), ); const active = activeIntent(config, columns); const chartConfig = useMemo(() => chartConfigForSelection('astrolabe', theme), [theme]); const spec = useMemo(() => { const built = buildChartSpec(config); // Inline the sample rows so the spec renders standalone (the builder otherwise // emits a by-name dataset reference the app resolves from its library). built.data = { values: SAMPLE_DATASET_ROWS }; return built; }, [config]); const setEncoding = (channel: ChannelName, mapping: ChannelMapping | null): void => setConfig({ ...config, encodings: { ...config.encodings, [channel]: mapping } }); const yValue = config.encodings.y?.aggregate === 'count' ? COUNT : (config.encodings.y?.field ?? COUNT); return ( <>
{MARK_OPTIONS.map((m) => ( ))}
{CHART_INTENTS.map((intent) => ( ))}
); } // ── Theme Builder demo: pick a theme, the gallery re-renders ───────────────────── const THEME_CHOICES: ReadonlyArray<{ selection: ChartThemeSelection; label: string }> = [ ...DEMO_CUSTOM_THEMES.map((t) => ({ selection: customThemeSelection(t.id), label: t.label })), { selection: 'astrolabe', label: 'Astrolabe' }, { selection: 'fivethirtyeight', label: 'FiveThirtyEight' }, { selection: 'vox', label: 'Vox' }, { selection: 'dark', label: 'Vega Dark' }, ]; const GALLERY = THEME_PREVIEW_SPECS.filter((s) => ['bar', 'line', 'scatter'].includes(s.id)); function ThemeDemo({ theme }: { theme: UiTheme }): ReactNode { const [selection, setSelection] = useState(customThemeSelection(1)); const config = useMemo( () => chartConfigForSelection(selection, theme, DEMO_CUSTOM_THEMES), [selection, theme], ); return ( <>
{THEME_CHOICES.map((choice) => ( ))}
{GALLERY.map((ps) => (
))}
); } // ── Page ───────────────────────────────────────────────────────────────────── export function Landing(): ReactNode { const [theme, setTheme] = useState('light'); function toggleTheme(): void { const next = theme === 'dark' ? 'light' : 'dark'; setTheme(next); document.documentElement.dataset.theme = next; } return (

A home for your Vega-Lite charts.

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.

Open Astrolabe Free, no account, works offline

Pick a snippet on the left — the editor and chart follow.

Two ways in

Write the spec, or build it by clicking.

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.

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:

{' '} Chart Builder · {SAMPLE_DATASET_NAME}
One library

Every chart you make is saved, with the data behind it.

Store a dataset once and reference it by name from as many charts as you want. Rename 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.

{' '} Datasets
sales-2025 · CSV · 1,204 rows
used by 4 snippets · quarter, region, revenue, units
cohorts · JSON · 96 rows
used by 1 snippet · cohort, week, retained
events · URL · refreshed today
used by 3 snippets · day, type, count
survey · CSV · 512 rows
used by 2 snippets · score, segment
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. Save the theme and use it on any chart.

{' '} Theme Builder
Exporting

Save a chart in the format you need.

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.

Your whole workspace exports and re-imports as a single JSON file.

{' '} Export chart
Format
Scale
Background
Data

Your charts are yours.

Astrolabe runs in your browser, and your charts and data stay there — no account, no server, no AI behind it to send them to — so work that has to stay confidential is safe here from the first chart. And what it makes is ordinary Vega-Lite JSON: read it, edit it in other tools, or take it elsewhere whenever you want.

private

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.

portable

Ordinary Vega-Lite

Export a single chart or the whole library as standard JSON and open it in any other Vega tool.

yours

Set up your way

Author by hand or by clicking, with your own fonts and themes. Arrange the library to match how you work.

Open it and start a chart.

Open Astrolabe
); }