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:

LayerLives inResponsibility
Datasrc/data/subway/Immutable config: lines, stations, trains, alerts
Geometry & logicsrc/lib/subway/Octolinear math, pan/zoom, the train reducer
Rendersrc/components/subway/Inline SVG — lines, roundels, interchanges
Interactionsrc/hooks/Drag-pan, wheel-zoom, roving-tabindex keyboard nav
SimulationrAF 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.

#nextjs#react#typescript#svg#accessibility

Related writing