Landing: add marketing page at /, move the app to /app/

Multi-page Vite build: index.html serves a new standalone landing
(src/landing), app/index.html serves the app at /app/. The PWA service
worker and manifest are scoped to /app/, so the landing stays
uncontrolled and always-fresh; hash view-state routing is unchanged.

The landing reuses src/core and the chart-renderer service only (never
stores/orchestration/modals/components) and lazy-loads Vega. Its demos
drive the real core: a hero snippet switcher over CHART_EXAMPLES, an
interactive Chart Builder over a new profiled core fixture
(sample-dataset), and a Theme Builder gallery with three from-scratch
custom themes plus built-in presets.
This commit is contained in:
2026-06-17 16:43:01 +03:00
parent d8a7bcb7c1
commit 223646398e
11 changed files with 1645 additions and 4 deletions
+654
View File
@@ -0,0 +1,654 @@
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';
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(
<span key={key++} className={cls}>
{m[0]}
</span>,
);
last = idx + m[0].length;
}
if (last < text.length) out.push(text.slice(last));
return <pre className={styles.code}>{out}</pre>;
}
/** 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, unknown>): 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 (
<span className={styles.brand}>
<img className={styles.brandMark} src="/favicon.svg" alt="" width={20} height={20} />{' '}
Astrolabe
</span>
);
}
// ── 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 (
<div className={styles.appWin}>
<div className={styles.appWinBar}>
<span className={styles.dots}>
<i />
<i />
<i />
</span>
<span className={styles.grow} />
<span className={styles.pill}>{CHART_EXAMPLES.length} snippets</span>
<span className={styles.pill}>live preview</span>
</div>
<div className={styles.appBody}>
<div className={styles.col}>
<div className={styles.colHead}>Library</div>
<div className={styles.libSearch}>Search snippets</div>
<div className={styles.libList}>
{CHART_EXAMPLES.map((ex, i) => (
<button
key={ex.id}
type="button"
className={`${styles.row} ${i === selected ? styles.rowSel : ''}`}
onClick={() => setSelected(i)}
aria-current={i === selected ? 'true' : undefined}
>
<div className={styles.rowName}>{ex.name}</div>
<div className={styles.rowMeta}>
<span className={styles.chip}>{markLabel(ex.spec)}</span>
</div>
</button>
))}
</div>
</div>
<div className={styles.col}>
<div className={styles.colHead}>{specFilename(example.name)}</div>
<JsonCode spec={example.spec} />
</div>
<div className={styles.col}>
<div className={styles.colHead}>Preview</div>
<div className={styles.preview}>
<LandingChart spec={example.spec} config={config} />
</div>
</div>
</div>
</div>
);
}
// ── 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<ChartIntent, string> = {
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<BuilderConfig>(() =>
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 (
<>
<div className={styles.seg}>
{MARK_OPTIONS.map((m) => (
<button
key={m.type}
type="button"
aria-pressed={config.mark === m.type}
className={config.mark === m.type ? styles.on : undefined}
onClick={() => setConfig({ ...config, mark: m.type })}
>
{m.label}
</button>
))}
</div>
<label className={styles.shelf}>
<b>X</b>
<select
className={styles.select}
value={config.encodings.x?.field ?? ''}
onChange={(e) => setEncoding('x', fieldMapping(e.target.value))}
>
{ALL_COLUMNS.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</label>
<label className={styles.shelf}>
<b>Y</b>
<select
className={styles.select}
value={yValue}
onChange={(e) =>
setEncoding(
'y',
e.target.value === COUNT
? { type: 'quantitative', aggregate: 'count' }
: fieldMapping(e.target.value),
)
}
>
<option value={COUNT}>count of records</option>
{MEASURE_COLUMNS.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</label>
<label className={styles.shelf}>
<b>Color</b>
<select
className={styles.select}
value={config.encodings.color?.field ?? NONE}
onChange={(e) =>
setEncoding('color', e.target.value === NONE ? null : fieldMapping(e.target.value))
}
>
<option value={NONE}>none</option>
{CATEGORY_COLUMNS.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</label>
<div className={styles.intent}>
{CHART_INTENTS.map((intent) => (
<button
key={intent}
type="button"
disabled={!intentApplicable(intent, columns)}
aria-pressed={active === intent}
className={active === intent ? styles.intentOn : undefined}
onClick={() => setConfig(applyIntent(config, intent, columns))}
>
{INTENT_LABELS[intent]}
</button>
))}
</div>
<LandingChart spec={spec} config={chartConfig} />
</>
);
}
// ── 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<ChartThemeSelection>(customThemeSelection(1));
const config = useMemo(
() => chartConfigForSelection(selection, theme, DEMO_CUSTOM_THEMES),
[selection, theme],
);
return (
<>
<div className={styles.intent}>
{THEME_CHOICES.map((choice) => (
<button
key={choice.selection}
type="button"
aria-pressed={selection === choice.selection}
className={selection === choice.selection ? styles.intentOn : undefined}
onClick={() => setSelection(choice.selection)}
>
{choice.label}
</button>
))}
</div>
<div className={styles.gallery}>
{GALLERY.map((ps) => (
<div key={ps.id} className={styles.mini}>
<LandingChart spec={ps.spec} config={config} className={styles.miniHost} />
</div>
))}
</div>
</>
);
}
// ── Page ─────────────────────────────────────────────────────────────────────
export function Landing(): ReactNode {
const [theme, setTheme] = useState<UiTheme>('light');
function toggleTheme(): void {
const next = theme === 'dark' ? 'light' : 'dark';
setTheme(next);
document.documentElement.dataset.theme = next;
}
return (
<div className={styles.landing}>
<nav className={styles.nav}>
<div className={`${styles.wrap} ${styles.navIn}`}>
<Brand />
<span className={styles.navSpacer} />
<a className={styles.navLink} href="#author">
Authoring
</a>
<a className={styles.navLink} href="#library">
Library
</a>
<a className={styles.navLink} href="#theme">
Theming
</a>
<button className={`${styles.btn} ${styles.btnSm}`} type="button" onClick={toggleTheme}>
{theme === 'dark' ? 'Light' : 'Dark'}
</button>
<a className={`${styles.btn} ${styles.btnSm} ${styles.navCta}`} href="/app/">
Open Astrolabe
</a>
</div>
</nav>
<header className={styles.hero}>
<div className={styles.wrap}>
<h1>
A home for your <b>Vega-Lite charts.</b>
</h1>
<p>
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 library in the browser,
with no account and no server.
</p>
<div className={styles.heroCta}>
<a className={`${styles.btn} ${styles.btnPrimary}`} href="/app/">
Open Astrolabe
</a>
<span className={styles.heroNote}>Free, no account, works offline</span>
</div>
</div>
</header>
<section className={styles.stage}>
<div className={styles.wrap}>
<HeroAppWindow theme={theme} />
<p className={styles.stageHint}>
Pick a snippet on the left the editor and chart follow.
</p>
</div>
</section>
<div className={styles.wrap}>
<section className={styles.cap} id="author">
<div className={styles.capText}>
<div className={styles.capEyebrow}>Two ways in</div>
<h2 className={styles.capH}>
Write the spec, or <b>build it by clicking.</b>
</h2>
<p className={styles.capP}>
The editor is Monaco with the Vega-Lite schema loaded, so you get validation,
autocompletion, and inline docs without going online. Each chart keeps a draft and a
published version: edit the draft, publish when it's ready, revert when it isn't.
</p>
<p className={styles.capP}>
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:
</p>
</div>
<div className={styles.shot}>
<div className={styles.shotBar}>
<span className={styles.dots}>
<i />
<i />
<i />
</span>{' '}
Chart Builder · {SAMPLE_DATASET_NAME}
</div>
<div className={styles.shotBody}>
<BuilderDemo theme={theme} />
</div>
</div>
</section>
<section className={`${styles.cap} ${styles.capRev}`} id="library">
<div className={styles.capText}>
<div className={styles.capEyebrow}>One library</div>
<h2 className={styles.capH}>
Every chart you make <b>stays</b>, with the data behind it.
</h2>
<p className={styles.capP}>
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.
</p>
<p className={styles.capP}>
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, sort, and duplicate as the collection
grows.
</p>
</div>
<div className={styles.shot}>
<div className={styles.shotBar}>
<span className={styles.dots}>
<i />
<i />
<i />
</span>{' '}
Datasets
</div>
<div className={`${styles.shotBody} ${styles.shotBodyFlush}`}>
<div className={styles.row}>
<div className={styles.rowName}>
sales-2025 <span className={styles.rowSub}>· CSV · 1,204 rows</span>
</div>
<div className={styles.rowMeta}>
used by <span className={styles.chip}>4 snippets</span> · quarter, region,
revenue, units
</div>
</div>
<div className={styles.row}>
<div className={styles.rowName}>
cohorts <span className={styles.rowSub}>· JSON · 96 rows</span>
</div>
<div className={styles.rowMeta}>
used by <span className={styles.chip}>1 snippet</span> · cohort, week, retained
</div>
</div>
<div className={styles.row}>
<div className={styles.rowName}>
events <span className={styles.rowSub}>· URL · refreshed today</span>
</div>
<div className={styles.rowMeta}>
used by <span className={styles.chip}>3 snippets</span> · day, type, count
</div>
</div>
<div className={`${styles.row} ${styles.rowLast}`}>
<div className={styles.rowName}>
survey <span className={styles.rowSub}>· CSV · 512 rows</span>
</div>
<div className={styles.rowMeta}>
used by <span className={styles.chip}>2 snippets</span> · score, segment
</div>
</div>
</div>
</div>
</section>
<section className={styles.cap} id="theme">
<div className={styles.capText}>
<div className={styles.capEyebrow}>Make it yours</div>
<h2 className={styles.capH}>
Give your charts a <b>look of their own.</b>
</h2>
<p className={styles.capP}>
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:
</p>
<p className={styles.capP}>
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.
</p>
</div>
<div className={styles.shot}>
<div className={styles.shotBar}>
<span className={styles.dots}>
<i />
<i />
<i />
</span>{' '}
Theme Builder
</div>
<div className={styles.shotBody}>
<ThemeDemo theme={theme} />
</div>
</div>
</section>
<section className={`${styles.cap} ${styles.capRev}`} id="export">
<div className={styles.capText}>
<div className={styles.capEyebrow}>Exporting</div>
<h2 className={styles.capH}>
Save a chart in the <b>format you need.</b>
</h2>
<p className={styles.capP}>
Export as a PNG at 13×, 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.
</p>
<p className={styles.capP}>
Your whole workspace exports and re-imports as a single JSON file.
</p>
</div>
<div className={styles.shot}>
<div className={styles.shotBar}>
<span className={styles.dots}>
<i />
<i />
<i />
</span>{' '}
Export chart
</div>
<div className={styles.shotBody}>
<div className={styles.exp}>
<div className={styles.expRow}>
<span className={styles.expLab}>Format</span>
<span className={styles.grp}>
<button type="button" className={styles.on}>
PNG
</button>
<button type="button">SVG</button>
<button type="button">Spec</button>
</span>
</div>
<div className={styles.expRow}>
<span className={styles.expLab}>Scale</span>
<span className={styles.grp}>
<button type="button">1×</button>
<button type="button" className={styles.on}>
2×
</button>
<button type="button">3×</button>
</span>
</div>
<div className={styles.expRow}>
<span className={styles.expLab}>Background</span>
<span className={styles.grp}>
<button type="button" className={styles.on}>
Theme
</button>
<button type="button">White</button>
<button type="button">None</button>
</span>
</div>
<div className={styles.expRow}>
<span className={styles.expLab}>Data</span>
<span className={styles.grp}>
<button type="button" className={styles.on}>
Inline
</button>
<button type="button">Keep refs</button>
</span>
</div>
<div className={styles.expDownload}>
<button
type="button"
className={`${styles.btn} ${styles.btnSm} ${styles.btnFill}`}
>
Download .png
</button>
</div>
</div>
</div>
</div>
</section>
</div>
<section className={styles.creed}>
<div className={styles.wrap}>
<h2>
Your charts are <b>yours.</b>
</h2>
<p className={styles.creedLede}>
Astrolabe runs in your browser and stores everything locally — no account, no server.
What it produces is ordinary Vega-Lite JSON, so you can read it, edit it in other tools,
or keep it long after you've stopped using Astrolabe.
</p>
<div className={styles.creedGrid}>
<div>
<span className={styles.creedKey}>private</span>
<h3>Stays on your machine</h3>
<p>
Charts, data, and themes are saved in your browser. Install it and it works with no
connection.
</p>
</div>
<div>
<span className={styles.creedKey}>portable</span>
<h3>Ordinary Vega-Lite</h3>
<p>
Export a single chart or the whole library as standard JSON and open it in any other
Vega tool.
</p>
</div>
<div>
<span className={styles.creedKey}>yours</span>
<h3>Set up your way</h3>
<p>
Two ways to author, your own fonts and themes, your own library. Arrange it to match
how you work.
</p>
</div>
</div>
</div>
</section>
<section className={styles.close}>
<div className={styles.wrap}>
<h2>
Open it and start a <b>chart.</b>
</h2>
<a className={`${styles.btn} ${styles.btnPrimary}`} href="/app/">
Open Astrolabe
</a>
</div>
</section>
<footer className={styles.footer}>
<div className={`${styles.wrap} ${styles.footerIn}`}>
<Brand />
<span className={styles.footerSp} />
<a href="/app/">Open the app</a>
<a href="https://vega.github.io/vega-lite/">Built on Vega-Lite</a>
</div>
</footer>
</div>
);
}