<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Flightmussy</title>
    <description>The latest articles on DEV Community by Flightmussy (@flightmussy).</description>
    <link>https://dev.to/flightmussy</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4029065%2Fc5d27405-96ad-48f1-af67-ef95f3046c1b.png</url>
      <title>DEV Community: Flightmussy</title>
      <link>https://dev.to/flightmussy</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/flightmussy"/>
    <language>en</language>
    <item>
      <title>How I built an interactive atlas of 767 train routes (React + MapLibre + open data)</title>
      <dc:creator>Flightmussy</dc:creator>
      <pubDate>Tue, 14 Jul 2026 17:24:43 +0000</pubDate>
      <link>https://dev.to/flightmussy/how-i-built-an-interactive-atlas-of-767-train-routes-react-maplibre-open-data-315n</link>
      <guid>https://dev.to/flightmussy/how-i-built-an-interactive-atlas-of-767-train-routes-react-maplibre-open-data-315n</guid>
      <description>&lt;p&gt;I spent a summer building &lt;a href="https://trainrouter.com" rel="noopener noreferrer"&gt;TrainRouter&lt;/a&gt; — a free, no-signup interactive map of &lt;strong&gt;767 of the world's notable train routes across 118 countries&lt;/strong&gt;, from the Glacier Express and the Trans-Siberian to the new night trains stitching Europe back together. Every line carries its distance, fastest journey time, top speed, operator, rolling stock and a short story.&lt;/p&gt;

&lt;p&gt;It's a solo project, and the interesting engineering wasn't the map — it was everything around it: keeping 700+ routes fast, making a JavaScript map discoverable by search engines and AI crawlers, and turning the whole thing into open data. Here's how it went together.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data model comes first
&lt;/h2&gt;

&lt;p&gt;Everything derives from one flat list of routes. Each record is deliberately boring:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csvs"&gt;&lt;code&gt;&lt;span class="k"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;train&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;operator&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="k"&gt;distance&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;km&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;top&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;speed&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;kmh&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;opened&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="k"&gt;pax&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;per&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;countries&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;iso&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;highlight&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;fame&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;rank&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;category&lt;/code&gt; is one of &lt;code&gt;high-speed · classic · night · scenic&lt;/code&gt;, which drives the colour-coding. &lt;code&gt;fame_rank&lt;/code&gt; (1 = most famous) is a hand-curated renown ordering — it decides what you see first when the map is zoomed out, so the Shinkansen and the Orient Express surface before a regional branch line.&lt;/p&gt;

&lt;p&gt;The geometry is separate: a hand-traced &lt;code&gt;LineString&lt;/code&gt; of lon/lat waypoints per route. I smooth those with a &lt;strong&gt;centripetal Catmull-Rom&lt;/strong&gt; spline rather than a plain Catmull-Rom — the centripetal variant never overshoots hairpins like the Glacier Express' Chur reversal, which a naive spline turns into a loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rendering 767 routes on MapLibre + OpenFreeMap
&lt;/h2&gt;

&lt;p&gt;The map is &lt;a href="https://maplibre.org" rel="noopener noreferrer"&gt;MapLibre GL JS&lt;/a&gt; on &lt;strong&gt;&lt;a href="https://openfreemap.org" rel="noopener noreferrer"&gt;OpenFreeMap&lt;/a&gt;&lt;/strong&gt; vector tiles — a free, no-API-key, OpenStreetMap-based tile host. That combination means the whole map costs nothing to run and has no per-tile billing surprise.&lt;/p&gt;

&lt;p&gt;Two things that mattered for making 700+ overlapping lines readable:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Line-offset by strand.&lt;/strong&gt; Where several routes share a corridor, drawing them on top of each other is mud. I group routes into "strands" and offset parallel lines to the side, transit-diagram style, so shared track fans into distinct ribbons.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Endpoint markers, Oslo-T-bane style.&lt;/strong&gt; Termini get a capsule per terminating strand; a busy interchange where many lines fan out is detected (doubling each bearing collapses a line and its reverse to one axis — the mean-resultant length tells you "corridor" vs "radial hub") and drawn as one clean roundel instead of a lumpy pile of capsules.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Making a JS map rank: prerender ~1,500 static pages
&lt;/h2&gt;

&lt;p&gt;A single-page app is invisible to crawlers that don't run JavaScript — which includes most AI crawlers. My homepage was a &lt;code&gt;&amp;lt;div id="root"&amp;gt;&amp;lt;/div&amp;gt;&lt;/code&gt; and nothing else.&lt;/p&gt;

&lt;p&gt;So a build step prerenders &lt;strong&gt;~1,500 static HTML pages&lt;/strong&gt; — one per route, plus country hubs, city-pair pages, and index hubs — each with unique titles/descriptions, canonical URLs, Open Graph + Twitter cards, and JSON-LD (&lt;code&gt;TouristTrip&lt;/code&gt;, &lt;code&gt;BreadcrumbList&lt;/code&gt;, &lt;code&gt;FAQPage&lt;/code&gt;, &lt;code&gt;ItemList&lt;/code&gt;). The homepage &lt;code&gt;#root&lt;/code&gt; gets a static atlas index injected at build time; &lt;code&gt;createRoot().render()&lt;/code&gt; replaces it the moment the interactive map hydrates, so users get the app and crawlers get real content.&lt;/p&gt;

&lt;p&gt;One subtlety worth stealing: &lt;strong&gt;don't stamp every page's &lt;code&gt;lastmod&lt;/code&gt; with the build date.&lt;/strong&gt; That teaches Google your sitemap is noise. Instead I hash each page's content and only bump &lt;code&gt;lastmod&lt;/code&gt; when the content actually changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  A data story falls out of the data
&lt;/h2&gt;

&lt;p&gt;Once the atlas is structured data, you can ask it questions. My favourite: &lt;strong&gt;&lt;a href="https://trainrouter.com/fastest-trains" rel="noopener noreferrer"&gt;the fastest train in every country&lt;/a&gt;&lt;/strong&gt; — ranked not by headline top speed but by the speed each country's quickest train &lt;em&gt;actually averages&lt;/em&gt; end to end (route length ÷ fastest scheduled journey).&lt;/p&gt;

&lt;p&gt;The results are counter-intuitive: China's Beijing–Shanghai averages 307 km/h over ~1,300 km; the Shanghai Maglev tops 431 km/h but averages just 225 as a 30 km airport hop; and the &lt;strong&gt;US's fastest train, the Acela, averages 109 km/h — behind 33 other countries.&lt;/strong&gt; The page and its chart are generated from the same module that feeds the map, so it updates itself as the atlas grows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Open-sourcing it: dataset + an MCP server
&lt;/h2&gt;

&lt;p&gt;Two things I'd recommend to anyone building a data-driven site:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Release the data.&lt;/strong&gt; The whole atlas is an open dataset (CC BY 4.0) — CSV, JSON and GeoJSON — at &lt;a href="https://github.com/Flightmussy/trainrouter-atlas" rel="noopener noreferrer"&gt;github.com/Flightmussy/trainrouter-atlas&lt;/a&gt;, archived on Zenodo with a DOI, mirrored on Kaggle and Hugging Face. There's a landing page at &lt;a href="https://trainrouter.com/dataset" rel="noopener noreferrer"&gt;trainrouter.com/dataset&lt;/a&gt;. It costs nothing and it's the most reusable thing I made.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expose an MCP server.&lt;/strong&gt; &lt;a href="https://trainrouter.com/mcp-server/" rel="noopener noreferrer"&gt;Model Context Protocol&lt;/a&gt; lets AI assistants call your tools. TrainRouter runs a free remote MCP endpoint so you can ask Claude "which night trains leave Vienna, and how long do they take?" and it answers from real atlas data. It was ~200 lines to wrap the existing data access.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd tell past-me
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The map is the demo; the &lt;strong&gt;structured data and static pages are the product.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Free infrastructure exists for all of this (MapLibre + OpenFreeMap + a cheap VPS) — you don't need a mapping-API budget.&lt;/li&gt;
&lt;li&gt;Ship the data as open data on day one. It's the cheapest durable thing you can do.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The map is at &lt;strong&gt;&lt;a href="https://trainrouter.com" rel="noopener noreferrer"&gt;trainrouter.com&lt;/a&gt;&lt;/strong&gt; and the data is at &lt;strong&gt;&lt;a href="https://github.com/Flightmussy/trainrouter-atlas" rel="noopener noreferrer"&gt;github.com/Flightmussy/trainrouter-atlas&lt;/a&gt;&lt;/strong&gt; — free to explore, reuse, or pick apart. Happy to answer questions in the comments.&lt;/p&gt;

</description>
      <category>react</category>
      <category>maplibre</category>
      <category>opendata</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
