Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions apps/editor/src/__tests__/examples.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { describe, it, expect } from 'vitest'
import { renderMarkdown, validate } from 'mindmaply-core'
import { EXAMPLES, exampleVideoUrl, previewSource } from '../examples/manifest'

// These four maps ship on the landing page, so a bad entry is a visibly broken
// card in production. The sources are committed, so all of this is checkable
// offline: nothing here touches the network.

describe('EXAMPLES', () => {
it('is non-empty', () => {
expect(EXAMPLES.length).toBeGreaterThan(0)
})

it('has unique slugs and share ids', () => {
expect(new Set(EXAMPLES.map((e) => e.slug)).size).toBe(EXAMPLES.length)
expect(new Set(EXAMPLES.map((e) => e.shareId)).size).toBe(EXAMPLES.length)
})

it('has url-safe slugs', () => {
for (const e of EXAMPLES) expect(e.slug).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/)
})

it('has share ids the api worker will treat as short ids', () => {
// Mirrors SHORT_ID_RE in api/src/shortlinks.ts. A share id outside this
// shape is read as a full payload and the link 302s to the homepage.
for (const e of EXAMPLES) expect(e.shareId).toMatch(/^[A-Za-z0-9_-]{8,16}$/)
})

it('has 11-character youtube video ids', () => {
for (const e of EXAMPLES) expect(e.videoId).toMatch(/^[A-Za-z0-9_-]{11}$/)
})

it('has a title and a blurb', () => {
for (const e of EXAMPLES) {
expect(e.title.length).toBeGreaterThan(0)
expect(e.blurb.length).toBeGreaterThan(0)
}
})

// The model emits curly quotes, en dashes and non-breaking hyphens. They
// render, but they are miserable to hand-edit, so sources are normalised on
// the way in and stay that way.
it('has ascii-only sources', () => {
for (const e of EXAMPLES) {
const offenders = [...new Set([...e.source].filter((c) => c.charCodeAt(0) > 127))]
expect(offenders, `${e.slug} has non-ascii: ${offenders.join(' ')}`).toEqual([])
}
})
})

describe('example sources', () => {
it('every source is valid markdown for the engine', () => {
for (const e of EXAMPLES) {
const result = validate(e.source, 'markdown')
expect(result.valid, `${e.slug}: ${JSON.stringify(result.errors)}`).toBe(true)
}
})

it('every source renders', () => {
for (const e of EXAMPLES) {
expect(renderMarkdown(e.source, { direction: 'LR' })).toContain('<svg')
}
})
})

describe('previewSource', () => {
it('keeps the heading and drops everything below depth 1', () => {
const trimmed = previewSource(['# Root', '- One', ' - Deep', '- Two', ' - Deeper'].join('\n'))
expect(trimmed).toBe(['# Root', '- One', '- Two'].join('\n'))
})

it('leaves every example a renderable map', () => {
for (const e of EXAMPLES) {
const preview = previewSource(e.source)
expect(validate(preview, 'markdown').valid, e.slug).toBe(true)
expect(renderMarkdown(preview, { direction: 'LR' })).toContain('<svg')
}
})

// The reason previews exist: a full map is about 0.35 wide-to-tall, which in
// a card that is wider than it is tall renders labels a few pixels high.
it('produces card-shaped previews, not tall ones', () => {
for (const e of EXAMPLES) {
const svg = renderMarkdown(previewSource(e.source), { direction: 'LR' })
const vb = svg.match(/viewBox="[^"]*?([\d.]+) ([\d.]+)"/)
expect(vb, e.slug).not.toBeNull()
const aspect = Number(vb![1]) / Number(vb![2])
expect(aspect, `${e.slug} aspect ${aspect.toFixed(2)}`).toBeGreaterThan(0.7)
}
})

it('trims each example to a handful of branches', () => {
for (const e of EXAMPLES) {
const lines = previewSource(e.source).split('\n').length
expect(lines, e.slug).toBeGreaterThan(2)
expect(lines, e.slug).toBeLessThanOrEqual(12)
}
})
})

describe('exampleVideoUrl', () => {
it('points at the source talk', () => {
expect(exampleVideoUrl(EXAMPLES[0])).toBe(
`https://www.youtube.com/watch?v=${EXAMPLES[0].videoId}`,
)
})
})
65 changes: 64 additions & 1 deletion apps/editor/src/editor.css
Original file line number Diff line number Diff line change
Expand Up @@ -638,6 +638,63 @@ body {
}
.landing-yt .landing-cta { margin-top:28px; }

/* Real generated maps, as proof the YouTube flow above actually works. Sits
directly after it on purpose. */
.landing-examples {
max-width:1000px; margin:0 auto 80px; padding:0 24px; text-align:center;
}
.landing-examples h2 { font-size:24px; font-weight:800; letter-spacing:-.4px; margin-bottom:12px; }
.landing-examples-intro {
color:var(--tx2); font-size:16px; line-height:1.7;
max-width:640px; margin:0 auto 28px;
}
.landing-examples-grid {
list-style:none; padding:0; margin:0;
display:grid; grid-template-columns:1fr 1fr; gap:22px;
}
.landing-examples-grid > li { display:flex; flex-direction:column; min-width:0; }
/* flex:1 so cards in a row match height despite blurbs wrapping to two or three
lines, which otherwise leaves the "Watch the talk" links on ragged baselines. */
.landing-examples-card {
flex:1; text-align:left; text-decoration:none; color:inherit;
background:var(--white); border:1px solid var(--bd2); border-radius:16px;
padding:16px 18px 18px; box-shadow:0 12px 40px rgba(0,0,0,.08);
transition:transform .15s ease, box-shadow .15s ease;
}
a.landing-examples-card:hover {
transform:translateY(-2px); box-shadow:0 18px 50px rgba(0,0,0,.12);
}
/* Fixed height so cards line up despite previews ranging from square to 1.7:1;
the SVG scales inside rather than stretching. */
.landing-examples-canvas {
position:relative; height:190px; margin-bottom:14px;
display:flex; align-items:center; justify-content:center;
border-radius:10px; overflow:hidden;
}
.landing-examples-canvas::before {
content:''; position:absolute; inset:0; pointer-events:none;
background-image:radial-gradient(circle, rgba(0,0,0,.048) 1px, transparent 1px);
background-size:20px 20px;
}
.landing-examples-canvas .landing-demo-svg { width:auto; max-width:100%; }
.landing-examples-canvas .landing-demo-svg svg {
width:auto; max-width:100%; height:auto; max-height:190px;
}
.landing-examples-card h3 {
font-size:15px; font-weight:700; margin:0 0 6px; line-height:1.4;
}
.landing-examples-card p {
color:var(--tx2); font-size:13.5px; line-height:1.6; margin:0;
}
.landing-examples-src {
align-self:flex-start; margin-top:10px;
color:var(--tx3); font-size:13px; text-decoration:none;
}
.landing-examples-src:hover { color:var(--brand); }
.landing-examples-caption {
color:var(--tx3); font-size:13px; margin-top:22px;
}

.landing-agents {
max-width:780px; margin:0 auto 80px; padding:0 24px; text-align:center;
}
Expand Down Expand Up @@ -736,9 +793,15 @@ body {
.landing-trio-grid { grid-template-columns:1fr; }
.landing-anywhere-grid { grid-template-columns:1fr; }
.landing-anywhere-shots { grid-template-columns:1fr; }
/* One card per row. The previews are trimmed to depth 1 (see previewSource),
so they stay legible at phone width and this section does NOT need the
sideways scroll .landing-roadmap-canvas falls back to. */
.landing-examples-grid { grid-template-columns:1fr; }
.landing-examples-canvas { height:170px; }
.landing-examples-canvas .landing-demo-svg svg { max-height:170px; }
/* 24px gutters cost the copy a word per line on a 360px phone. */
.landing-trio, .landing-anywhere, .landing-yt, .landing-agents,
.landing-oss, .landing-roadmap { padding:0 16px; }
.landing-oss, .landing-roadmap, .landing-examples { padding:0 16px; }
/* The install command is one unbreakable token pair: size it to the gutter
it has left rather than leaving it to scroll a few characters. */
.landing-agents-cmd { padding:12px; }
Expand Down
66 changes: 66 additions & 0 deletions apps/editor/src/examples/hans-rosling-best-stats.mmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# The best stats you've ever seen | Hans Rosling
- Teaching global health at Karolinska Institute
- Pre-test of Swedish undergraduates
- Question on child mortality by country
- Result: students scored poorly, showing knowledge gap
- Realization of students' misconceptions
- "Western world" vs "third world" view
- Preconceived ideas versus actual data
- Development of bubble visualization tool
- Bubbles represent countries
- Size = population
- X-axis = fertility rate (children per woman)
- Y-axis = life expectancy at birth
- Data source: UN statistics since 1960
- Early pattern (1962)
- Industrialized: low fertility, high life expectancy
- Developing: high fertility, low life expectancy
- Observing change over decades
- 1960s-1980s shifts
- China moving toward lower fertility, higher life expectancy
- Latin America and Arabic countries showing similar trends
- Africa remaining with high fertility, low life expectancy
- Bangladesh's rapid transition after family-planning promotion
- 1990s HIV epidemic lowering African life expectancy
- Comparison United States vs Vietnam
- 1964: US low fertility, high life expectancy; Vietnam opposite
- Post-war Vietnam catches up to US levels by 2003
- Income distribution visualization
- World income per day ($1, $10, $100)
- No clear gap between rich and poor
- Rich 20% own ~74% of global income
- Poor 20% own ~2%
- Middle 60% own ~24%
- Regional breakdowns
- Africa: 10% of world population, most impoverished
- OECD, Latin America, East Europe, East Asia, South Asia
- Historical change
- 1970: Asian poverty dominant
- Today: large middle-income population, fewer in absolute poverty
- Linking income, health, and child survival
- Adding child-survival axis to income bubbles
- Strong linear relationship between money and child survival
- Regional gaps (OECD, Latin America, East Europe, etc.)
- Sub-Saharan Africa split: low health, low income
- Country-level examples
- Sierra Leone, Uganda, Ghana - wide internal variation
- South Asia: India vs Afghanistan vs Sri Lanka
- Arab states: UAE vs Yemen - similar wealth, different health outcomes
- Gapminder and data accessibility
- Creation of non-profit "Linking Data to Design" (Gapminder)
- Software that turns raw UN data into interactive graphics
- Challenges
- Data hidden in databases, passwords, formats
- Need for searchable, open, visual tools
- Cooperation with UN statistical head
- Future projections and patterns
- Income distribution of China vs United States (1970)
- China moving upward, overlapping US
- Internet usage per 1,000 people
- Rapid increase, linked to GDP per capita
- Implications for "$100 computer" and global flattening
- Emphasis on hypothesis-generating visualisation versus traditional statistical analysis
- Conclusions
- World moving toward a large middle-income, middle-life-expectancy, medium-fertility zone
- Health improvements can precede wealth
- Open data plus design tools empower students, policymakers, entrepreneurs
116 changes: 116 additions & 0 deletions apps/editor/src/examples/manifest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// Curated, real mind maps to show on the landing page.
//
// Every one of these was generated by the live YouTube flow
// (POST /yt/transform on the api worker), then hand-picked. The full source is
// committed next to this file rather than fetched from the API at render time,
// for three reasons: the landing page must not depend on the API being up to
// draw itself, `robots.txt` disallows /svg so a crawler would never see an
// API-served thumbnail, and a source in the repo is reviewable in a PR before
// it goes public.
//
// `shareId` is the 11-char content hash under which the api worker stored the
// same payload, so the card can link to the canonical /s/<id> page. It is NOT
// how the preview is drawn.
//
// Adding one: generate a map, save its source as `<slug>.mmd` here (ASCII only
// -- the model emits curly quotes and non-breaking hyphens, normalise them),
// then add an entry below. `pnpm --filter @mindmaply/editor test` checks that
// every source parses and that ids and slugs are well formed.

import jobsSource from './steve-jobs-stanford-2005.mmd?raw'
import roslingSource from './hans-rosling-best-stats.mmd?raw'
import mcravenSource from './mcraven-make-your-bed.mmd?raw'
import pauschSource from './randy-pausch-last-lecture.mmd?raw'
import { SHARE_BASE } from '../share'

export interface Example {
/** URL-safe id. Reserved for the prerendered /examples/<slug>/ pages. */
slug: string
/** Human title. Shorter than the YouTube title, which is often unwieldy. */
title: string
/** One sentence. Doubles as the meta description on the per-example page. */
blurb: string
/** 11-char content-hash id stored by the api worker (see /shorten). */
shareId: string
/** Source video, credited on the card. */
videoId: string
/** The full generated map. */
source: string
}

export const EXAMPLES: Example[] = [
{
slug: 'steve-jobs-stanford-2005',
title: "Steve Jobs' 2005 Stanford Commencement Address",
blurb:
'The three-stories speech, mapped: connecting the dots, love and loss, and death, each with the lesson Jobs drew from it.',
shareId: 'XrSa97dgHck',
videoId: 'UF8uR6Z6KLc',
source: jobsSource,
},
{
slug: 'mcraven-make-your-bed',
title: 'Admiral McRaven: Make Your Bed',
blurb:
'Ten lessons from Navy SEAL training, from making your bed to never ringing the bell, as ten readable branches.',
shareId: 'h2CfqBUw6P4',
videoId: 'pxBQLFLei70',
source: mcravenSource,
},
{
slug: 'hans-rosling-best-stats',
title: "Hans Rosling: The best stats you've ever seen",
blurb:
'Global development in bubbles: what the fertility and life-expectancy axes actually show, and how the world moved across them.',
shareId: '5-v5XwE6o50',
videoId: 'hVimVzgtD6w',
source: roslingSource,
},
{
slug: 'randy-pausch-last-lecture',
title: 'Randy Pausch: The Last Lecture',
blurb:
'Seventy-six minutes on achieving your childhood dreams, including the brick walls, condensed to one page.',
shareId: 'khh1OA8U9xg',
videoId: 'ji5_MqicxSo',
source: pauschSource,
},
]

/**
* Root plus first-level branches only.
*
* These maps are 66 to 74 lines and render about 1500x4300, an aspect around
* 0.35. Fitted whole into a card that is wider than it is tall, the labels come
* out a few pixels high and the card reads as texture rather than as a diagram.
* Trimming to depth 1 leaves 6 to 9 nodes at an aspect of 0.96 to 1.73, which
* is card-shaped and legible. Same trick, and the same reason, as
* MOBILE_DEMO_SOURCE in Landing.tsx.
*
* The card links to the full map; this only governs the thumbnail.
*/
export function previewSource(source: string): string {
return source
.split('\n')
.filter((line) => line.startsWith('# ') || line.startsWith('- '))
.join('\n')
}

/**
* Canonical share page for an example, or null when no share base is
* configured (a local dev build without VITE_SHARE_BASE, or a fork). Built from
* SHARE_BASE rather than a literal domain so forks and zone config win, exactly
* as shortenShareUrl does.
*
* `view=map` matches the links the Chrome extension hands out: the editor opens
* with the source panel collapsed, map first.
*/
export function exampleShareUrl(example: Example): string | null {
if (!SHARE_BASE) return null
return `${SHARE_BASE}s/${example.shareId}?view=map`
}

/** The talk this map was generated from. */
export function exampleVideoUrl(example: Example): string {
return `https://www.youtube.com/watch?v=${example.videoId}`
}
Loading
Loading