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),
|
and **removes** `width`; Full sets both — see spec §04 → Fit-mode sizing),
|
||||||
recursing the same way.
|
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
|
This is _content_ preparation, not embedding, and it is fully covered by the
|
||||||
_Live Preview_ spec. The only invariant this doc cares about:
|
_Live Preview_ spec. The only invariant this doc cares about:
|
||||||
|
|
||||||
|
|||||||
@@ -220,6 +220,20 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-3);
|
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 {
|
.libList {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -461,9 +475,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* theme demo */
|
/* theme demo */
|
||||||
|
/* Compact grid — the theme *switch* is the demo; three charts in one glance,
|
||||||
|
not a page of scroll. */
|
||||||
.gallery {
|
.gallery {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: column;
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
margin-top: var(--space-5);
|
margin-top: var(--space-5);
|
||||||
}
|
}
|
||||||
@@ -598,6 +614,248 @@
|
|||||||
margin-left: auto;
|
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) {
|
@media (max-width: 760px) {
|
||||||
.appBody {
|
.appBody {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -612,11 +870,24 @@
|
|||||||
.code {
|
.code {
|
||||||
max-height: 320px;
|
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,
|
.cap,
|
||||||
.creedGrid {
|
.creedGrid,
|
||||||
grid-template-columns: 1fr;
|
.demoRow,
|
||||||
|
.objections,
|
||||||
|
.gallery {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
gap: var(--space-6);
|
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 {
|
.capRev .capText {
|
||||||
order: 0;
|
order: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
+353
-79
@@ -27,10 +27,18 @@ import {
|
|||||||
} from '@core/sample-dataset';
|
} from '@core/sample-dataset';
|
||||||
import { DEMO_CUSTOM_THEMES } from './demo-themes';
|
import { DEMO_CUSTOM_THEMES } from './demo-themes';
|
||||||
import { LandingChart } from './LandingChart';
|
import { LandingChart } from './LandingChart';
|
||||||
|
import {
|
||||||
|
SHOWCASE_DEMOS,
|
||||||
|
showcaseDisplaySpec,
|
||||||
|
showcaseFitMode,
|
||||||
|
type ShowcaseDemo,
|
||||||
|
} from './showcase-specs';
|
||||||
import styles from './Landing.module.css';
|
import styles from './Landing.module.css';
|
||||||
|
|
||||||
// TODO: import { UiTheme } from '@core/theme' instead of redeclaring it — the
|
// TODO: import { UiTheme } from '@core/theme' instead of redeclaring it — the
|
||||||
// learn entry already uses the canonical one.
|
// 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';
|
type UiTheme = 'light' | 'dark';
|
||||||
|
|
||||||
// Minimal JSON syntax highlighter for the read-only spec view: keys, strings,
|
// 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 ────────────────────────────
|
// ── Hero: switch snippets, the editor + chart follow ────────────────────────────
|
||||||
|
|
||||||
function HeroAppWindow({ theme }: { theme: UiTheme }): ReactNode {
|
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 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 (
|
return (
|
||||||
<div className={styles.appWin}>
|
<div className={styles.appWin}>
|
||||||
@@ -130,7 +167,12 @@ function HeroAppWindow({ theme }: { theme: UiTheme }): ReactNode {
|
|||||||
<JsonCode spec={example.spec} />
|
<JsonCode spec={example.spec} />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.col}>
|
<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}>
|
<div className={styles.preview}>
|
||||||
<LandingChart spec={example.spec} config={config} />
|
<LandingChart spec={example.spec} config={config} />
|
||||||
</div>
|
</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 ────
|
// ── Chart Builder demo: pick fields, a mark, or an intent; the chart rebuilds ────
|
||||||
|
|
||||||
const MARK_OPTIONS: ReadonlyArray<{ type: MarkType; label: string }> = [
|
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 {
|
function fieldMapping(name: string): ChannelMapping {
|
||||||
return { field: name, type: defaultFieldType(columnType(name)) };
|
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 {
|
function BuilderDemo({ theme }: { theme: UiTheme }): ReactNode {
|
||||||
const columns = SAMPLE_DATASET_COLUMNS;
|
const columns = SAMPLE_DATASET_COLUMNS;
|
||||||
const [config, setConfig] = useState<BuilderConfig>(() =>
|
// First frame: the line archetype (revenue over time by region) — the demo's
|
||||||
defaultBuilderConfig(SAMPLE_DATASET_NAME, columns),
|
// opening state is its argument.
|
||||||
);
|
const [config, setConfig] = useState<BuilderConfig>(() => markPreset('line'));
|
||||||
const active = activeIntent(config, columns);
|
const active = activeIntent(config, columns);
|
||||||
const chartConfig = useMemo(() => chartConfigForSelection('astrolabe', theme), [theme]);
|
const chartConfig = useMemo(() => chartConfigForSelection('astrolabe', theme), [theme]);
|
||||||
const spec = useMemo(() => {
|
const spec = useMemo(() => {
|
||||||
@@ -205,7 +399,7 @@ function BuilderDemo({ theme }: { theme: UiTheme }): ReactNode {
|
|||||||
type="button"
|
type="button"
|
||||||
aria-pressed={config.mark === m.type}
|
aria-pressed={config.mark === m.type}
|
||||||
className={config.mark === m.type ? styles.on : undefined}
|
className={config.mark === m.type ? styles.on : undefined}
|
||||||
onClick={() => setConfig({ ...config, mark: m.type })}
|
onClick={() => setConfig(markPreset(m.type))}
|
||||||
>
|
>
|
||||||
{m.label}
|
{m.label}
|
||||||
</button>
|
</button>
|
||||||
@@ -235,7 +429,11 @@ function BuilderDemo({ theme }: { theme: UiTheme }): ReactNode {
|
|||||||
'y',
|
'y',
|
||||||
e.target.value === COUNT
|
e.target.value === COUNT
|
||||||
? { type: 'quantitative', aggregate: '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: 'vox', label: 'Vox' },
|
||||||
{ selection: 'dark', label: 'Vega Dark' },
|
{ 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 {
|
function ThemeDemo({ theme }: { theme: UiTheme }): ReactNode {
|
||||||
const [selection, setSelection] = useState<ChartThemeSelection>(customThemeSelection(1));
|
const [selection, setSelection] = useState<ChartThemeSelection>(customThemeSelection(1));
|
||||||
@@ -316,6 +532,10 @@ function ThemeDemo({ theme }: { theme: UiTheme }): ReactNode {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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}>
|
<div className={styles.gallery}>
|
||||||
{GALLERY.map((ps) => (
|
{GALLERY.map((ps) => (
|
||||||
<div key={ps.id} className={styles.mini}>
|
<div key={ps.id} className={styles.mini}>
|
||||||
@@ -344,6 +564,9 @@ export function Landing(): ReactNode {
|
|||||||
<div className={`${styles.wrap} ${styles.navIn}`}>
|
<div className={`${styles.wrap} ${styles.navIn}`}>
|
||||||
<Brand />
|
<Brand />
|
||||||
<span className={styles.navSpacer} />
|
<span className={styles.navSpacer} />
|
||||||
|
<a className={styles.navLink} href="#capabilities">
|
||||||
|
What it can do
|
||||||
|
</a>
|
||||||
<a className={styles.navLink} href="#author">
|
<a className={styles.navLink} href="#author">
|
||||||
Authoring
|
Authoring
|
||||||
</a>
|
</a>
|
||||||
@@ -368,11 +591,12 @@ export function Landing(): ReactNode {
|
|||||||
<header className={styles.hero}>
|
<header className={styles.hero}>
|
||||||
<div className={styles.wrap}>
|
<div className={styles.wrap}>
|
||||||
<h1>
|
<h1>
|
||||||
A home for your <b>Vega-Lite charts.</b>
|
Charts written as text. <b>A home to keep them.</b>
|
||||||
</h1>
|
</h1>
|
||||||
<p>
|
<p>
|
||||||
Astrolabe is a local studio for Vega-Lite. Write a spec by hand or build one by
|
Astrolabe is a local studio for Vega-Lite — the grammar that turns a short JSON spec
|
||||||
clicking, give it a theme, and keep all your charts in one searchable library.
|
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>
|
</p>
|
||||||
<div className={styles.heroCta}>
|
<div className={styles.heroCta}>
|
||||||
<a className={`${styles.btn} ${styles.btnPrimary}`} href="/app/">
|
<a className={`${styles.btn} ${styles.btnPrimary}`} href="/app/">
|
||||||
@@ -387,44 +611,89 @@ export function Landing(): ReactNode {
|
|||||||
<div className={styles.wrap}>
|
<div className={styles.wrap}>
|
||||||
<HeroAppWindow theme={theme} />
|
<HeroAppWindow theme={theme} />
|
||||||
<p className={styles.stageHint}>
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className={styles.wrap}>
|
<div className={styles.wrap}>
|
||||||
|
<ShowcaseSection theme={theme} />
|
||||||
<section className={styles.cap} id="author">
|
<section className={styles.cap} id="author">
|
||||||
<div className={styles.capText}>
|
<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}>
|
<h2 className={styles.capH}>
|
||||||
Write the spec, or <b>build it by clicking.</b>
|
An editor that <b>knows Vega-Lite.</b>
|
||||||
</h2>
|
</h2>
|
||||||
<p className={styles.capP}>
|
<p className={styles.capP}>
|
||||||
The editor is Monaco with the Vega-Lite schema loaded, so you get validation,
|
The editor is Monaco with the Vega-Lite schema loaded: autocompletion, inline docs,
|
||||||
autocompletion, and inline docs without going online. Each chart keeps an editable
|
and validation as you type, all of it offline. One click scaffolds a working parameter
|
||||||
draft alongside a published version you can revert to.
|
or data transform to build on.
|
||||||
</p>
|
</p>
|
||||||
<p className={styles.capP}>
|
<p className={styles.capP}>
|
||||||
If you'd rather not start from JSON, the builder works from the kind of chart you
|
When a chart misbehaves, the data inspector shows the rows going in and the rows the
|
||||||
want. Pick fields, a mark, or a whole intent — it writes the spec for you. Try it:
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.shot}>
|
<EditorProof />
|
||||||
<div className={styles.shotBar}>
|
</section>
|
||||||
<span className={styles.dots}>
|
|
||||||
<i />
|
{/* The habit-cluster objections, answered where the skeptic is scrolling
|
||||||
<i />
|
(landing-onboarding scope memo): each answer is an on-ramp, not a rebuttal. */}
|
||||||
<i />
|
<div className={styles.objections}>
|
||||||
</span>{' '}
|
<div>
|
||||||
Chart Builder · {SAMPLE_DATASET_NAME}
|
<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>
|
||||||
|
<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}>
|
<div className={styles.shotBody}>
|
||||||
<BuilderDemo theme={theme} />
|
<BuilderDemo theme={theme} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Shot>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className={`${styles.cap} ${styles.capRev}`} id="library">
|
<section className={styles.cap} id="library">
|
||||||
<div className={styles.capText}>
|
<div className={styles.capText}>
|
||||||
<div className={styles.capEyebrow}>One library</div>
|
<div className={styles.capEyebrow}>One library</div>
|
||||||
<h2 className={styles.capH}>
|
<h2 className={styles.capH}>
|
||||||
@@ -435,21 +704,27 @@ export function Landing(): ReactNode {
|
|||||||
it and Astrolabe updates every chart that used it.
|
it and Astrolabe updates every chart that used it.
|
||||||
</p>
|
</p>
|
||||||
<p className={styles.capP}>
|
<p className={styles.capP}>
|
||||||
Load data by pasting CSV or JSON, or by fetching a URL. You can also lift inline data
|
Paste CSV or JSON, fetch a URL, or lift the inline data already sitting in a spec out
|
||||||
out of a spec into a shared dataset. Search and sort as the collection grows, and
|
into a shared dataset. Search and sort as the collection grows, and duplicate a
|
||||||
duplicate a snippet to start a variant.
|
snippet to start a variant.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.shot}>
|
<Shot bar="Datasets">
|
||||||
<div className={styles.shotBar}>
|
|
||||||
<span className={styles.dots}>
|
|
||||||
<i />
|
|
||||||
<i />
|
|
||||||
<i />
|
|
||||||
</span>{' '}
|
|
||||||
Datasets
|
|
||||||
</div>
|
|
||||||
<div className={`${styles.shotBody} ${styles.shotBodyFlush}`}>
|
<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.row}>
|
||||||
<div className={styles.rowName}>
|
<div className={styles.rowName}>
|
||||||
sales-2025 <span className={styles.rowSub}>· CSV · 1,204 rows</span>
|
sales-2025 <span className={styles.rowSub}>· CSV · 1,204 rows</span>
|
||||||
@@ -484,62 +759,52 @@ export function Landing(): ReactNode {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Shot>
|
||||||
</section>
|
</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.capText}>
|
||||||
<div className={styles.capEyebrow}>Make it yours</div>
|
<div className={styles.capEyebrow}>Make it yours</div>
|
||||||
<h2 className={styles.capH}>
|
<h2 className={styles.capH}>
|
||||||
Give your charts a <b>look of their own.</b>
|
Give your charts a <b>look of their own.</b>
|
||||||
</h2>
|
</h2>
|
||||||
<p className={styles.capP}>
|
<p className={styles.capP}>
|
||||||
Sixteen presets to start from, or build your own in the Theme Builder — colour, type,
|
Upload your own fonts (variable fonts included) and apply one across a whole theme at
|
||||||
axes, legend, and layout. The first three here are built from scratch. Try a few:
|
once. Sixteen presets to start from, or build your own in the Theme Builder: colour,
|
||||||
|
type, axes, legends, marks, titles, number formats.
|
||||||
</p>
|
</p>
|
||||||
<p className={styles.capP}>
|
<p className={styles.capP}>
|
||||||
Upload your own fonts, variable fonts included, and apply one across a whole theme at
|
Save a theme and use it on any chart. The first three here are built from scratch. Try
|
||||||
once. Save the theme and use it on any chart.
|
a few:
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.shot}>
|
<Shot bar="Theme Builder">
|
||||||
<div className={styles.shotBar}>
|
|
||||||
<span className={styles.dots}>
|
|
||||||
<i />
|
|
||||||
<i />
|
|
||||||
<i />
|
|
||||||
</span>{' '}
|
|
||||||
Theme Builder
|
|
||||||
</div>
|
|
||||||
<div className={styles.shotBody}>
|
<div className={styles.shotBody}>
|
||||||
<ThemeDemo theme={theme} />
|
<ThemeDemo theme={theme} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Shot>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className={`${styles.cap} ${styles.capRev}`} id="export">
|
<section className={styles.cap} id="export">
|
||||||
<div className={styles.capText}>
|
<div className={styles.capText}>
|
||||||
<div className={styles.capEyebrow}>Exporting</div>
|
<div className={styles.capEyebrow}>Getting it out</div>
|
||||||
<h2 className={styles.capH}>
|
<h2 className={styles.capH}>
|
||||||
Save a chart in the <b>format you need.</b>
|
Exports with <b>the settings you reach for.</b>
|
||||||
</h2>
|
</h2>
|
||||||
<p className={styles.capP}>
|
<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
|
Every embedded Vega chart ships the same tiny menu (save PNG, save SVG). Astrolabe's
|
||||||
data inline, so the file renders on its own wherever it lands.
|
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>
|
||||||
<p className={styles.capP}>
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.shot}>
|
<Shot bar="Export chart">
|
||||||
<div className={styles.shotBar}>
|
|
||||||
<span className={styles.dots}>
|
|
||||||
<i />
|
|
||||||
<i />
|
|
||||||
<i />
|
|
||||||
</span>{' '}
|
|
||||||
Export chart
|
|
||||||
</div>
|
|
||||||
<div className={styles.shotBody}>
|
<div className={styles.shotBody}>
|
||||||
<div className={styles.exp}>
|
<div className={styles.exp}>
|
||||||
<div className={styles.expRow}>
|
<div className={styles.expRow}>
|
||||||
@@ -591,7 +856,7 @@ export function Landing(): ReactNode {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Shot>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -612,7 +877,8 @@ export function Landing(): ReactNode {
|
|||||||
<h3>Local to your browser</h3>
|
<h3>Local to your browser</h3>
|
||||||
<p>
|
<p>
|
||||||
Charts, data, and themes are saved in this browser and nowhere else — there is no
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -620,15 +886,16 @@ export function Landing(): ReactNode {
|
|||||||
<h3>Ordinary Vega-Lite</h3>
|
<h3>Ordinary Vega-Lite</h3>
|
||||||
<p>
|
<p>
|
||||||
Export a single chart or the whole library as standard JSON and open it in any other
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className={styles.creedKey}>yours</span>
|
<span className={styles.creedKey}>yours</span>
|
||||||
<h3>Set up your way</h3>
|
<h3>One file, yours to keep</h3>
|
||||||
<p>
|
<p>
|
||||||
Author by hand or by clicking, with your own fonts and themes. Arrange the library
|
The whole workspace — snippets, datasets, themes, fonts — exports as a single JSON
|
||||||
to match how you work.
|
file. Back it up like any file you own and import it on any machine. No sync service
|
||||||
|
to trust.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -643,6 +910,13 @@ export function Landing(): ReactNode {
|
|||||||
<a className={`${styles.btn} ${styles.btnPrimary}`} href="/app/">
|
<a className={`${styles.btn} ${styles.btnPrimary}`} href="/app/">
|
||||||
Open Astrolabe
|
Open Astrolabe
|
||||||
</a>
|
</a>
|
||||||
|
<p className={styles.closeNote}>
|
||||||
|
New to Vega-Lite?{' '}
|
||||||
|
<a className={styles.inlineLink} href="/learn/">
|
||||||
|
Start with the deep dives
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||||
import type { Config } from 'vega-lite';
|
import type { Config } from 'vega-lite';
|
||||||
import type { VisualizationSpec } from 'vega-embed';
|
import type { VisualizationSpec } from 'vega-embed';
|
||||||
import { prepareSpecForRender } from '@core/rendering';
|
import { prepareSpecForRender, type FitMode } from '@core/rendering';
|
||||||
import styles from './Landing.module.css';
|
import styles from './Landing.module.css';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -18,10 +18,20 @@ export function LandingChart({
|
|||||||
spec,
|
spec,
|
||||||
config,
|
config,
|
||||||
className,
|
className,
|
||||||
|
fitMode = 'width',
|
||||||
}: {
|
}: {
|
||||||
spec: Record<string, unknown>;
|
spec: Record<string, unknown>;
|
||||||
config: Config;
|
config: Config;
|
||||||
className?: string;
|
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 {
|
}): ReactNode {
|
||||||
const hostRef = useRef<HTMLDivElement>(null);
|
const hostRef = useRef<HTMLDivElement>(null);
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
@@ -37,7 +47,7 @@ export function LandingChart({
|
|||||||
// Width-responsive: the chart fills its host (a definite-width `.chartNode`)
|
// Width-responsive: the chart fills its host (a definite-width `.chartNode`)
|
||||||
// and keeps a natural height — right for every landing surface (hero pane,
|
// and keeps a natural height — right for every landing surface (hero pane,
|
||||||
// builder, theme gallery).
|
// builder, theme gallery).
|
||||||
const prepared: unknown = prepareSpecForRender(spec, { fitMode: 'width' });
|
const prepared: unknown = prepareSpecForRender(spec, { fitMode });
|
||||||
handle = await renderSpec(node, prepared as VisualizationSpec, config);
|
handle = await renderSpec(node, prepared as VisualizationSpec, config);
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
handle.destroy();
|
handle.destroy();
|
||||||
@@ -57,7 +67,7 @@ export function LandingChart({
|
|||||||
handle?.destroy();
|
handle?.destroy();
|
||||||
handle = null;
|
handle = null;
|
||||||
};
|
};
|
||||||
}, [spec, config]);
|
}, [spec, config, fitMode]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className ?? styles.chartHost}>
|
<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