listsof30 production

Clem's Travels

Two decades of travel writing on a spinnable 3D globe. Arcs draw in travel order, and a plans section answers where to go next.

ViteReactTypeScriptTailwind CSS v4MapLibre GLanime.jsMotionLeafletCloudflare Pages
Landing on the globe: 52 countries, 121 places, 74 stories.
Landing on the globe: 52 countries, 121 places, 74 stories.
Scroll and the guides slide in beside the globe, continent by continent.
Scroll and the guides slide in beside the globe, continent by continent.
The index replays every arc, then filters by continent, status or trip type.
The index replays every arc, then filters by continent, status or trip type.
Two plans so far. Filter by whether I went, how long you have, the season, the kind of trip.
Two plans so far. Filter by whether I went, how long you have, the season, the kind of trip.
The atlas. Red is written up, blue is been but untold, teal is still to come.
The atlas. Red is written up, blue is been but untold, teal is still to come.

Twenty years of travel writing was sitting inside one flat index post. Roughly 135 named places, about 60 with real published guides, the rest listed but never written. It read like a packing list. The travel was a story, and a bulleted page could not tell it.

What It Is

The landing page is a 3D globe you can spin. On load, flight paths draw outward from home in the order the trips actually happened, markers popping in as each arc lands. Scroll, and the globe flies continent to continent, the guides for each place sliding in beside it. Click a marker and it opens the post written about that spot, the real one, on Substack.

It ends where a directory would have started: a browsable index, an atlas, and a plain street map, for when you already know where you want to go.

The Data Problem

Everything on the globe came from three things I already owned, and nothing was scraped. The index post gave the taglines in my own words. A Substack export gave the exact links, titles, and dates. An old Google My Maps gave the coordinates. Reconciling the three surfaced the good stuff a single source hides: the publish date is almost never the travel date. A post that went out in 2024 covers a 2013 trip. So the globe animates in the order I traveled, not the order I published, which is the difference between a story and a changelog.

One honest find fell out of the build. Five of the links point at posts that do not exist yet, and the old blog’s certificate is broken, so its links warn in the browser. Both are written down rather than papered over.

The Golden Source

That old Google My Maps was a one-time coordinate dump at first. Now it is the living record of where I have been, and it is the one thing I keep updated. After a trip I drop a pin there and nowhere else, and a sync reconciles the map into the dataset for me. New pins arrive as plain markers. Everything else it notices, a moved pin, an unmatched place, goes into a report. Nothing I wrote by hand is touched.

It stays honest about what it cannot know. The country on a new pin is filled in from its coordinates and flagged for me to confirm. A pin it cannot place, it reports instead of guessing. A place already in the dataset that it cannot find on the map is a line in that report, never a deletion. Geography is pulled in. The words stay mine to write later.

Where Should I Go?

The archive answers where I have been. It never answered the question people actually ask me, so there is now a plans section that does.

Two plans are in it so far, both built in Wanderlog. Ten days in New York, which I took, laid out as an open trip anyone can copy. Three days out to the Great Sand Dunes, which I have not done yet. Whether I went is a field on every plan rather than something you infer, because the map has to know: a trip I took is already drawn on the been-there layer, and drawing it again as “want to go” would say the opposite of the truth.

The chips filter on that, plus continent, length, season and vibe. Five axes over two plans is more picker than the content needs today. It stays because the content is the part that grows.

The full agenda opens in Wanderlog. On a plan’s own page their map loads only when you press the button, so nothing of theirs runs on my page unless you ask for it.

The atlas puts both halves on one map, either layer switchable off, and the layer state lives in the URL, so a shortlist is a link I can send someone.

Building it caught a label I had got wrong. I had badged thirty-nine places “someday”, which reads like a wish. They are places I went and have not written up, and their own taglines say so in the past tense. They read “untold” now. Without that fix, “someday” and “want to go” would have sat on the same map meaning two different things.

The grey I drew them in scored 1.90 against the water on that map, which is why I could never find them. They are ink blue now, and the number is 4.81.

The Discipline

The site links out, it never mirrors. Post bodies are never copied. The preview cards use each post’s own share image and its one-line subtitle, self-hosted so the page depends on nothing at runtime except free OpenStreetMap tiles. No Google, no API keys, no backend, no accounts.

Reduced motion is a real path, not an afterthought. Turn it on and the globe still renders, the arcs are simply all there at once, and the index is always the plain, complete fallback.

Status

Live at travel.listsof30.com. New travel guides publish on the fifteenth of the month, and adding one is a single paste: the entry is derived from the URL, validated against the schema, its thumbnail fetched, and shown as a diff before it ever ships.

Adding a plan takes one Wanderlog share link and the fields I have to write myself: the sentence, the length, the season, whether I went. The map embed is worked out from that same link, because the id in the share URL and the id in the embed URL turn out to be the same id.

The refresh that fetches a cover has to check more than the response code. A wrong Wanderlog trip id returns a perfectly healthy 200 with their generic marketing page, so a link with a typo in it would have cached as a success. It looks for the description tag instead, and skips anything that does not have one.

Stack

Vite, React, and TypeScript with Tailwind. The globe is MapLibre GL on free vector tiles; the flat map is Leaflet. Animation is anime.js for the arc timing and Motion for the scroll. The dataset is one YAML file, checked by Zod at build time, so a malformed entry fails the build instead of reaching the map.

Development timeline

20 logged updates over 15 days, 20 Jul 2026 to 3 Aug 2026.

  1. Site-anatomy exportfeature

    Read-only export from prd/lineage/roadmap plus the project page source. Redacted for public use: no commit hashes, no deployment IDs, no hosting-project names.

  2. Append "Epic 56 Hours in NY" to the nyc cardfeature

    First run of the Option B content-ingestion path decided 2026-07-21: Clemens pasted the post URL, the entry was derived, appended, validated, and shown as a diff. Post is listsof30.com/p/epic-56-hours-in-ny, published 2021-12-07, added as…

  3. Remove dead seelemons archive links, retire legacy-onlyfeature

    seelemons.com is no longer active, so every source: seelemons post rendered a dead … ↗ (archive) link. 15 destinations kept their live listsof30 post.

  4. Six new travel posts: previews + globe OG share cardfeature

    Ran pnpm fetch:previews --resume — resolved 5 new og:image thumbnails (sag-harbor, glenwood-springs, moab, mexico-city, monterey-bay); Phuket rides under the existing bangkok pin. 63/63 published destinations now have previews.

  5. Decision: pnpm sync:map, reconcile-only geography syncfeature

    pnpm sync:map closes that gap without touching curation.

  6. Phase 3 shipped; decision: self-hosted link-preview cardsfeature

    Phase 3 (hero SVG map engine) built and approved. Reduced motion renders the whole map complete and still.

  7. Phase 4 built: chapters, explore mode, preview cardsfeature

    Scenes 2-4 shipped. Continent chapters are a sticky world map with scrolling steps; entering a chapter animates the SVG viewBox to frame that continent (Motion), and off-continent arcs/markers dim.

  8. Pivot: the 3D globe is the main pagefeature

    After comparing three globe styles (MapLibre, Globe.gl photo, Globe.gl passport), Clemens chose MapLibre (V1) and asked to make it the landing experience, weaving the posts off it, with the arc animation on the globe.

Show the earlier 12 entries
  1. Decision: journey.order is keyed on travel date, not publish datefeature

    This is the most consequential decision in the project.

  2. Decision: anime.js v4 + Motion, not GSAPfeature

    Considered GSAP-only (single library, ScrollTrigger included).

  3. Decision: build-time arc precomputation, no geo library shipsfeature

    The world SVG is generated from Natural Earth 110m via the world-atlas package. Underlying data is public domain, package is ISC.

  4. Decision: dual home anchorsfeature

    The index post says of Colorado: "I live here now!" Home changed.

  5. Decision: Miami / South Beach ships as a stubfeature

    The index post links South Beach to listsof30. The only unclaimed slug in the export was travel-advice-clemens-guide-to-haggling, which reads as a general how-to rather than a Miami post.

  6. Decision: three-source reconciliation, nothing scrapedfeature

    The dataset is built from three sources, all owned by Clemens, none scraped.

  7. Decision: two deliberate divergences from wanessalabs-gamesfeature

    Also considered and declined: TypeScript 7.0.2, the latest release. It is the Go-based rewrite and toolchain support is still settling.

  8. Decision: validation failures block the buildfeature

    Zod covers types; three assertions cover invariants Zod cannot express:

  9. Finding: legacy-only collapsed from 11 to 3feature

    Only Ohio, Valencia, and Sonoma are seelemons-only.

  10. Finding: entry count is 114, not the estimated 74feature

    The Phase 1 roster table estimated 74 entries with per-continent counts. That estimate was wrong and was presented with false precision.

  11. Finding: the posts.csv export is stale and its is_published column liesfix

    Two data-integrity issues, both worked around, both recorded.

  12. Phase 0: auditdocs

    Reference project for Cloudflare Pages conventions: wanessalabs-games/ (pnpm, Vite, wrangler.toml with pages_build_output_dir).

Written from this project's decision log as work happened, not afterwards. 1 entry was withheld from publication. See every project →

What's next

1 open item

Plans, not promises. Taken from this project's own roadmap.