Files
astrolabe/src/landing/Landing.tsx
T

657 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(
<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 searchable library.
</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 an editable
draft alongside a published version you can revert to.
</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 is <b>saved</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 and sort as the collection grows, and
duplicate a snippet to start a variant.
</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 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.
</p>
<div className={styles.creedGrid}>
<div>
<span className={styles.creedKey}>private</span>
<h3>Local to your browser</h3>
<p>
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.
</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>
Author by hand or by clicking, with your own fonts and themes. Arrange the library
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>
);
}