mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Landing: dual-pitch overhaul — showcase, editor proof, objections, trust creed
This commit is contained in:
@@ -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:
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+353
-79
@@ -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 (
|
||||
<div className={styles.shot}>
|
||||
<div className={styles.shotBar}>
|
||||
<span className={styles.dots}>
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>{' '}
|
||||
{bar}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div className={styles.appWin}>
|
||||
@@ -130,7 +167,12 @@ function HeroAppWindow({ theme }: { theme: UiTheme }): ReactNode {
|
||||
<JsonCode spec={example.spec} />
|
||||
</div>
|
||||
<div className={styles.col}>
|
||||
<div className={styles.colHead}>Preview</div>
|
||||
<div className={styles.colHead}>
|
||||
Preview
|
||||
<a className={styles.colHeadLink} href={`/app/#example-${example.id}`}>
|
||||
Open in Astrolabe →
|
||||
</a>
|
||||
</div>
|
||||
<div className={styles.preview}>
|
||||
<LandingChart spec={example.spec} config={config} />
|
||||
</div>
|
||||
@@ -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 (
|
||||
<div className={styles.demo}>
|
||||
<h3 className={styles.demoTitle}>{demo.title}</h3>
|
||||
<p className={styles.demoBlurb}>{demo.blurb}</p>
|
||||
<div className={styles.demoRow}>
|
||||
{/* 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). */}
|
||||
<div className={styles.demoCode}>
|
||||
<JsonCode spec={showcaseDisplaySpec(demo)} />
|
||||
</div>
|
||||
<div className={styles.demoChartCard}>
|
||||
<LandingChart spec={demo.spec} config={config} fitMode={showcaseFitMode(demo)} />
|
||||
<p className={styles.demoHint}>{demo.hint}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ShowcaseSection({ theme }: { theme: UiTheme }): ReactNode {
|
||||
return (
|
||||
<section className={styles.showcase} id="capabilities">
|
||||
<div className={styles.showcaseIntro}>
|
||||
<div className={styles.capEyebrow}>Charts as text</div>
|
||||
<h2 className={styles.capH}>
|
||||
A page of JSON is <b>an interactive chart.</b>
|
||||
</h2>
|
||||
<p className={styles.capP}>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
{SHOWCASE_DEMOS.map((demo) => (
|
||||
<ShowcaseDemoRow key={demo.id} demo={demo} theme={theme} />
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<Shot bar="revenue-by-quarter.vl.json · draft">
|
||||
<div className={styles.edBody} aria-hidden="true">
|
||||
<pre className={styles.edLines}>
|
||||
{/* Real CodeLens labels (spec-param-scaffold / spec-transform-scaffold),
|
||||
separated the way Monaco separates lenses. */}
|
||||
<span className={styles.edLens}>
|
||||
+ slider | + dropdown | + point | + interval | + Add transform
|
||||
</span>
|
||||
{'{\n'}
|
||||
{' '}
|
||||
<span className={styles.tokKey}>"data"</span>
|
||||
{': { '}
|
||||
<span className={styles.tokKey}>"name"</span>
|
||||
{': '}
|
||||
<span className={styles.tokStr}>"sales-2025"</span>
|
||||
{' },\n'}
|
||||
{' '}
|
||||
<span className={styles.tokKey}>"mark"</span>
|
||||
{': { '}
|
||||
<span className={styles.tokKey}>"type"</span>
|
||||
{': '}
|
||||
<span className={styles.tokStr}>"area"</span>
|
||||
{', '}
|
||||
<span className={styles.tokKey}>"inter</span>
|
||||
<span className={styles.edCursor} />
|
||||
</pre>
|
||||
<div className={styles.edPopup}>
|
||||
<ul className={styles.edOptions}>
|
||||
<li className={styles.edOptionSel}>interpolate</li>
|
||||
<li>invalid</li>
|
||||
<li>innerRadius</li>
|
||||
</ul>
|
||||
<div className={styles.edDoc}>
|
||||
<b>interpolate</b> · string
|
||||
<br />
|
||||
The line interpolation method for line and area marks: "linear", "monotone",
|
||||
"step-after"…
|
||||
</div>
|
||||
</div>
|
||||
<pre className={styles.edLines}>
|
||||
{' '}
|
||||
<span className={styles.tokKey}>"encoding"</span>
|
||||
{': {\n'}
|
||||
{' '}
|
||||
<span className={styles.tokKey}>"x"</span>
|
||||
{': { '}
|
||||
<span className={styles.tokKey}>"field"</span>
|
||||
{': '}
|
||||
<span className={styles.tokStr}>"quarter"</span>
|
||||
{' },\n'}
|
||||
{' '}
|
||||
<span className={styles.tokKey}>"color"</span>
|
||||
{': { '}
|
||||
<span className={styles.tokKey}>"field"</span>
|
||||
{': '}
|
||||
<span className={styles.tokStr}>"region"</span>
|
||||
{', '}
|
||||
<span className={styles.tokKey}>"type"</span>
|
||||
{': '}
|
||||
<span className={`${styles.tokStr} ${styles.edSquiggle}`}>"nomnal"</span>
|
||||
{' }\n'}
|
||||
{' }\n'}
|
||||
{'}'}
|
||||
</pre>
|
||||
{/* Monaco's marker hover, with the JSON language service's actual message
|
||||
shape — the editor has no separate problems strip. */}
|
||||
<div className={styles.edHover}>
|
||||
Value is not accepted. Valid values: "nominal", "ordinal", "quantitative", "temporal".
|
||||
</div>
|
||||
</div>
|
||||
</Shot>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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<Record<MarkType, Record<string, ChannelMapping>>> = {
|
||||
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<BuilderConfig>(() =>
|
||||
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<BuilderConfig>(() => 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}
|
||||
</button>
|
||||
@@ -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<string, unknown>),
|
||||
color: { field: 'region', type: 'nominal', legend: null },
|
||||
},
|
||||
},
|
||||
}
|
||||
: s,
|
||||
);
|
||||
|
||||
function ThemeDemo({ theme }: { theme: UiTheme }): ReactNode {
|
||||
const [selection, setSelection] = useState<ChartThemeSelection>(customThemeSelection(1));
|
||||
@@ -316,6 +532,10 @@ function ThemeDemo({ theme }: { theme: UiTheme }): ReactNode {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className={styles.themeNote}>
|
||||
Editorial, Blueprint, and Sunset are custom themes made in the Theme Builder; the rest ship
|
||||
as presets.
|
||||
</p>
|
||||
<div className={styles.gallery}>
|
||||
{GALLERY.map((ps) => (
|
||||
<div key={ps.id} className={styles.mini}>
|
||||
@@ -344,6 +564,9 @@ export function Landing(): ReactNode {
|
||||
<div className={`${styles.wrap} ${styles.navIn}`}>
|
||||
<Brand />
|
||||
<span className={styles.navSpacer} />
|
||||
<a className={styles.navLink} href="#capabilities">
|
||||
What it can do
|
||||
</a>
|
||||
<a className={styles.navLink} href="#author">
|
||||
Authoring
|
||||
</a>
|
||||
@@ -368,11 +591,12 @@ export function Landing(): ReactNode {
|
||||
<header className={styles.hero}>
|
||||
<div className={styles.wrap}>
|
||||
<h1>
|
||||
A home for your <b>Vega-Lite charts.</b>
|
||||
Charts written as text. <b>A home to keep them.</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.
|
||||
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.
|
||||
</p>
|
||||
<div className={styles.heroCta}>
|
||||
<a className={`${styles.btn} ${styles.btnPrimary}`} href="/app/">
|
||||
@@ -387,44 +611,89 @@ export function Landing(): ReactNode {
|
||||
<div className={styles.wrap}>
|
||||
<HeroAppWindow theme={theme} />
|
||||
<p className={styles.stageHint}>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className={styles.wrap}>
|
||||
<ShowcaseSection theme={theme} />
|
||||
<section className={styles.cap} id="author">
|
||||
<div className={styles.capText}>
|
||||
<div className={styles.capEyebrow}>Two ways in</div>
|
||||
<div className={styles.capEyebrow}>For the spec author</div>
|
||||
<h2 className={styles.capH}>
|
||||
Write the spec, or <b>build it by clicking.</b>
|
||||
An editor that <b>knows Vega-Lite.</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.
|
||||
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.
|
||||
</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:
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.shot}>
|
||||
<div className={styles.shotBar}>
|
||||
<span className={styles.dots}>
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>{' '}
|
||||
Chart Builder · {SAMPLE_DATASET_NAME}
|
||||
<EditorProof />
|
||||
</section>
|
||||
|
||||
{/* The habit-cluster objections, answered where the skeptic is scrolling
|
||||
(landing-onboarding scope memo): each answer is an on-ramp, not a rebuttal. */}
|
||||
<div className={styles.objections}>
|
||||
<div>
|
||||
<h3>Already using the Vega editor?</h3>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Writing charts in Altair or another wrapper?</h3>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Asking an AI for specs?</h3>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className={`${styles.cap} ${styles.capRev}`} id="builder">
|
||||
<div className={styles.capText}>
|
||||
<div className={styles.capEyebrow}>Rather not start from JSON?</div>
|
||||
<h2 className={styles.capH}>
|
||||
The builder writes <b>the spec for you.</b>
|
||||
</h2>
|
||||
<p className={styles.capP}>
|
||||
Point it at your data and work from the kind of chart you want: pick fields, a mark,
|
||||
or a whole intent (compare, over time, distribution), and it writes ordinary Vega-Lite
|
||||
— the same spec you'd have written by hand, ready to keep clicking on or to open in
|
||||
the editor.
|
||||
</p>
|
||||
<p className={styles.capP}>
|
||||
<a className={styles.inlineLink} href="/app/#build">
|
||||
Open the Chart Builder →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<Shot bar={<>Chart Builder · {SAMPLE_DATASET_NAME}</>}>
|
||||
<div className={styles.shotBody}>
|
||||
<BuilderDemo theme={theme} />
|
||||
</div>
|
||||
</div>
|
||||
</Shot>
|
||||
</section>
|
||||
|
||||
<section className={`${styles.cap} ${styles.capRev}`} id="library">
|
||||
<section className={styles.cap} id="library">
|
||||
<div className={styles.capText}>
|
||||
<div className={styles.capEyebrow}>One library</div>
|
||||
<h2 className={styles.capH}>
|
||||
@@ -435,21 +704,27 @@ export function Landing(): ReactNode {
|
||||
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.
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.shot}>
|
||||
<div className={styles.shotBar}>
|
||||
<span className={styles.dots}>
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>{' '}
|
||||
Datasets
|
||||
</div>
|
||||
<Shot bar="Datasets">
|
||||
<div className={`${styles.shotBody} ${styles.shotBodyFlush}`}>
|
||||
{/* Store-once-reference-many, as a picture: one dataset, the
|
||||
snippets that read it hanging off it. */}
|
||||
<div className={styles.refDiagram} aria-hidden="true">
|
||||
<div className={styles.refDataset}>
|
||||
<span className={styles.refDot} /> sales-2025
|
||||
<span className={styles.refMeta}> — one dataset, stored once</span>
|
||||
</div>
|
||||
<ul className={styles.refTree}>
|
||||
<li>Revenue by quarter</li>
|
||||
<li>Growth trend</li>
|
||||
<li>Regional split</li>
|
||||
<li className={styles.refMore}>…rename it, and every chart follows</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<div className={styles.rowName}>
|
||||
sales-2025 <span className={styles.rowSub}>· CSV · 1,204 rows</span>
|
||||
@@ -484,62 +759,52 @@ export function Landing(): ReactNode {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Shot>
|
||||
</section>
|
||||
|
||||
<section className={styles.cap} id="theme">
|
||||
{/* Stacked (text above, demo full width): the gallery needs the whole
|
||||
column for three charts to breathe. */}
|
||||
<section className={styles.capStack} 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:
|
||||
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.
|
||||
</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.
|
||||
Save a theme and use it on any chart. The first three here are built from scratch. Try
|
||||
a few:
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.shot}>
|
||||
<div className={styles.shotBar}>
|
||||
<span className={styles.dots}>
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>{' '}
|
||||
Theme Builder
|
||||
</div>
|
||||
<Shot bar="Theme Builder">
|
||||
<div className={styles.shotBody}>
|
||||
<ThemeDemo theme={theme} />
|
||||
</div>
|
||||
</div>
|
||||
</Shot>
|
||||
</section>
|
||||
|
||||
<section className={`${styles.cap} ${styles.capRev}`} id="export">
|
||||
<section className={styles.cap} id="export">
|
||||
<div className={styles.capText}>
|
||||
<div className={styles.capEyebrow}>Exporting</div>
|
||||
<div className={styles.capEyebrow}>Getting it out</div>
|
||||
<h2 className={styles.capH}>
|
||||
Save a chart in the <b>format you need.</b>
|
||||
Exports with <b>the settings you reach for.</b>
|
||||
</h2>
|
||||
<p className={styles.capP}>
|
||||
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.
|
||||
</p>
|
||||
<p className={styles.capP}>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.shot}>
|
||||
<div className={styles.shotBar}>
|
||||
<span className={styles.dots}>
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>{' '}
|
||||
Export chart
|
||||
</div>
|
||||
<Shot bar="Export chart">
|
||||
<div className={styles.shotBody}>
|
||||
<div className={styles.exp}>
|
||||
<div className={styles.expRow}>
|
||||
@@ -591,7 +856,7 @@ export function Landing(): ReactNode {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Shot>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -612,7 +877,8 @@ export function Landing(): ReactNode {
|
||||
<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.
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
@@ -620,15 +886,16 @@ export function Landing(): ReactNode {
|
||||
<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.
|
||||
Vega tool. If Astrolabe vanished tomorrow, your charts wouldn't notice.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className={styles.creedKey}>yours</span>
|
||||
<h3>Set up your way</h3>
|
||||
<h3>One file, yours to keep</h3>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -643,6 +910,13 @@ export function Landing(): ReactNode {
|
||||
<a className={`${styles.btn} ${styles.btnPrimary}`} href="/app/">
|
||||
Open Astrolabe
|
||||
</a>
|
||||
<p className={styles.closeNote}>
|
||||
New to Vega-Lite?{' '}
|
||||
<a className={styles.inlineLink} href="/learn/">
|
||||
Start with the deep dives
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<HTMLDivElement>(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 (
|
||||
<div className={className ?? styles.chartHost}>
|
||||
|
||||
@@ -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<typeof compile>[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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
/** 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<ShowcaseDemo> = [
|
||||
{
|
||||
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<string, unknown> {
|
||||
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';
|
||||
}
|
||||
Reference in New Issue
Block a user