Building a Subway-Map Portfolio
June 15, 2026 · 3 min read
Building a Subway-Map Portfolio
This site used to be a retro AMI-BIOS desktop — a boot sequence, draggable
windows, a hidden Snake game. It was fun, but it buried the actual content under
a novelty shell and leaned on eval-style expression parsing I never loved. So I
tore it down and rebuilt the whole thing as an interactive Tokyo-Metro subway
map, because a platform engineer's career genuinely is a network: lines that
run in parallel, interchanges where two threads meet, branches that are still
under construction.
Education, career, projects, and side interests are lines. Milestones and case studies are stations. Animated trains show what's "now serving," and service alerts announce in-progress work.
The layering that makes it maintainable
The codebase is strictly layered, lowest to highest, and the bottom layers are completely DOM-free so they can be unit-tested without a browser:
| Layer | Lives in | Responsibility |
|---|---|---|
| Data | src/data/subway/ | Immutable config: lines, stations, trains, alerts |
| Geometry & logic | src/lib/subway/ | Octolinear math, pan/zoom, the train reducer |
| Render | src/components/subway/ | Inline SVG — lines, roundels, interchanges |
| Interaction | src/hooks/ | Drag-pan, wheel-zoom, roving-tabindex keyboard nav |
| Simulation | — | rAF train loop, service alerts, departure board |
The rule that keeps it honest: render code never hard-codes a station — it iterates config. Adding a station is a data edit, not a component edit.
Config is the single source of truth
Every station is a plain object on an integer grid, and a validateConfig() gate
runs as a test before any rendering is trusted. It checks that every segment is
octolinear (0/45/90°), that transfers and trains reference real stations, that no
two roundels collide, and that interchange "peanuts" sit a sane distance apart.
export function validateConfig(): ConfigIssue[] {
const issues: ConfigIssue[] = [];
for (const line of LINES) {
for (const poly of resolveLinePolylines(line)) {
for (const v of validateGridPolyline(poly.grid)) {
issues.push({
kind: "octolinear",
detail: `Line ${line.code} hop #${v.index} is not 0/45/90°.`,
});
}
}
}
return issues;
}No getPointAtLength, ever
The one hard rule in the geometry layer: never call the SVG path APIs
getPointAtLength / getTotalLength. jsdom doesn't implement them, so they'd
break the test suite, and they invite the rendered path and the train animation to
drift apart. Instead, all path math is pure TypeScript over the authored
coordinates — the <path d> and the train's position are derived from the same
polyline, so they cannot disagree.
Pan and zoom are a hand-rolled { x, y, k } transform where a screen point is just
s = u * k + { x, y } — about forty lines of pure functions, no d3-zoom. The
trains run on a pure reducer driven by requestAnimationFrame deltas, and the
whole simulation freezes when the visitor prefers reduced motion.
Accessibility as a parallel layer, not an afterthought
The <svg> is role="img", and a screen-reader-only outline mirrors the visual
map as a real list of lines and stops you can walk with the arrow keys. Line
identity is carried by a letter on the roundel, never by color alone, so the
map survives colorblindness and grayscale printing. Station panels are
focus-trapped dialogs.
What I'd tell my past self
Rebuilding around a metaphor that does real work — instead of one bolted on top —
made every later decision easier. The config gate caught layout mistakes before
they ever reached the screen, and keeping logic in pure modules meant the
interesting parts were testable without a headless browser. Tightening the CSP to
'self' with no 'unsafe-eval' fell out for free once the BIOS expression parser
was gone.
The source is on GitHub if you want to dig in.