NS Line Diagrams, Recreated and Redesigned
A dive down a rabbit hole recreating and redesigning the line diagrams I saw on Dutch railway platforms, in code.
A dive down a rabbit hole recreating and redesigning the line diagrams I saw on Dutch railway platforms, in code.
Often while waiting on the train, I pace around the platform and inevitably end up staring at the line diagram on the travel information sign. Like almost any diagram I see, I start wondering "Could I recreate this using D3?". I am deeply D3-brained, and fiddling around with D3 and SVG is strangely meditative for me.
I could.
I made these on Observable, a reactive notebook environment, which I think is one the best ways for prototyping and playing around in JavaScript. Now, I'm featuring 'm here too.
The first notebook, NS Line Diagrams, recreates the diagram as it appears on the sign. Here are the eastbound lines from Rotterdam Centraal, live from the notebook.
What I appreciate about this diagram, is that it is information-dense without becoming cluttered. It shows at a glance where you can go in a certain direction from this station.
The recreation works in three steps:
The layout turned out surprisingly simple, especially compared to the code for the tidy tree layout. All we need is a single pre-order traversal. The x position always increases per step, forks get an extra x-step, and the branches get a bend. The notebook walks through all of it in more detail and shows more of these diagrams for other lines, plus a geographic version for comparison.
Thinking about this particular diagram for too long while working on the first notebook, I started seeing flaws in it. The diagram has just enough arbitrary choices to convince me it's handmade rather than generated from data.
For instance, why is Breda-Prinsenbeek shown on the line to Eindhoven when the train doesn't stop there? One branch suggests a train from Rotterdam via Lage Zwaluwe to Breda-Prinsenbeek, Breda, and Roosendaal. No such train exists. The train would have to switch direction at Breda. Primarily, the diagram fails to show which train line stops where.
So in the second notebook, Better Line Diagrams, I attempt to design a better diagram. I set myself three constraints:
This is the resulting track diagram.
And here it is with the service lines drawn on top of the tracks.
The main insight is "don't force a tree onto graph". The part of rail network we're dealing with is a graph with cycles.
The layout becomes a graph traversal and graph drawing problem.
The key insight is that the layout decisions happen at the junctions, the stations where the track branches and the terminal stations. Only those need actual placing. The two-neighbor stations are placed in between them.
So the first step is to reduce the graph to just its junctions. I learned that in graph drawing research this is called condensing the graph.
Starting from the root station, the traversal follows each edge through the two-neighbor stations until it hits the next junction.
Edges are marked as used so nothing gets walked twice.
Everything in between becomes a chain, which contains the junction it entered from, the junction it came out at, and the interior stations along the way.
The traversal also assigns each junction a rank, the number of stations between it and the root.
When a junction is reachable along two routes, as in a cycle, it prefers the longer distance ie the one with higher rank.
Chains are laid out from the root outward, shortest route first, so the short way through a junction is always placed before the long way around has to dodge it.
Take the link from the main line at Lage Zwaluwe down to the Roosendaal branch, through Zevenbergen and Oudenbosch. That link steps down two levels rather than one, because the line through Etten-Leur, which leaves the Roosendaal branch and rejoins the main line at Breda, needs the level in between. The layout knows this in advance. When a chain gets bumped off its level, it looks ahead for the chain that closes the loop back to an already-placed junction, and jumps one level for itself plus one for each station on that closing chain. Etten-Leur gets a bump too, so two bumps down in total. Probably not the most optimal solution but it works well enough here.
The condensed graph is then laid out on a grid in three passes.
Cycles in the network are the whole difficulty, and discovering them is exactly what makes the diagram show topology where the original doesn't.
I took the long hard stupid route to get there with detours that in the end where not strictly necessary. The detours are a transit-map-like geographic view made by offsetting the service lines with Turf.js, and an interactive force-directed graph that snaps stations to a grid, which was great for fiddling around with layouts and seeing what works, and just fun to play around with.
I want to be upfront about my AI use.
I am a dilettante. This blog and these notebooks are things I work on out of my own interest, whenever I can find some time. I can't go into depth on every subproblem this diagram touches, and I am not well versed in graph traversal algorithms. I don't have the time for that, and honestly not the motivation either.
LLMs get me over exactly those bumps, the ones where side projects usually die. I exported the Observable notebook code, pointed Claude Code at it, and after some back and forth transferred the good parts back to the notebook. I don't think I could have built these layout algorithms without it, at least not in the time it took.
The full list is in the notebook, but the short version:
Feedback and adaptations are welcome. Both notebooks are public on Observable, you can view the literate code, or fork them and point them at your own corner of the rail network.