HomeMaps

PreviousNext

Map (Geographic)

Track geometry as GeoJSON for Tube, Elizabeth, Overground, DLR, and Tram. Use the packaged MapLibre map, or draw the same files in your own SDK.

Map
Uses foundationsColours

Preview

Stale data

MapLibre GL JS on OpenFreeMap Positron (vector). No API key. Station names appear as you zoom.

Usage

import { TflGeographicMap } from "@/components/tfl/geography/tfl-geographic-map"

<div className="h-100">
  <TflGeographicMap />
</div>

<TflGeographicMap modes={["tube", "elizabeth"]} showStations={false} />
Installable via shadcn CLI

Installation

pnpm dlx shadcn@latest add https://tfl.manglekuo.com/r/tfl-geographic-map.json

This copies the component, types, and credit helpers. The GeoJSON still has to live somewhere your app can fetch. Copy public/data/geography/*.json into the project, or host those files on a CDN.

Unique corridors, not every variant

OpenStreetMap stores each timetable pattern as its own route. Paint all of them and the Elizabeth line stacks 24 times on the same tracks. Tube would be 208 lines on 42 corridors.

What the map draws is the unique track. Longest spine, leftover branches, then simplified. Those files are served from /data/geography/. Full OSM variants stay in the repo for analysis. Do not put them on the map.

FileUnique tracksOSM variantsStations
tube-geometry.json42208270
elizabeth-geometry.json62441
overground-geometry.json1250112
dlr-geometry.json61245
tram-geometry.json31038

all-stations.json is the same 456 stations with merged line ids. Rebuild unique-track with pnpm geography:unique-track.

type TransitGeometryBundle = {
  lines: FeatureCollection<LineString, {
    featureId: string   // "elizabeth-track-0"
    lineId: string      // "victoria"
    lineName: string    // "Victoria line"
    color: string       // "#0098D4"
  }>
  stations: FeatureCollection<Point, {
    featureId: string   // "940GZZLUBST"
    name: string        // "Baker Street Underground Station"
    label: string       // "Baker Street"
    lineIds: string[]   // ["bakerloo", "circle", ...]
    zone?: string       // "1"
  }>
}

Coming next

Bus route geometry and live bus or train positions belong here as optional geographic layers, not as separate mode-specific map products. Data coverage, freshness, vehicle identity, and stale positions still need investigation before those layers can ship.

Draw it in your own map

The files are GeoJSON. MapLibre and Leaflet need no key. Mapbox and Google Maps need a token from your project.

MapLibre GL JS

import maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";

// After install: import geometry from your project
import tubeGeometry from "./data/geography/tube-geometry.json";

const map = new maplibregl.Map({
  container: "map",
  style: "https://tiles.openfreemap.org/styles/positron",
  center: [-0.12, 51.51],
  zoom: 10,
  cooperativeGestures: true,
});

map.on("load", () => {
  map.addSource("tube-lines", { type: "geojson", data: tubeGeometry.lines });
  map.addLayer({
    id: "tube-lines",
    type: "line",
    source: "tube-lines",
    layout: { "line-join": "round", "line-cap": "round" },
    paint: {
      "line-color": ["get", "color"],
      "line-width": 3,
    },
  });

  map.addSource("tube-stations", { type: "geojson", data: tubeGeometry.stations });
  map.addLayer({
    id: "tube-stations",
    type: "circle",
    source: "tube-stations",
    paint: {
      "circle-radius": 3,
      "circle-color": "#ffffff",
      "circle-stroke-width": 1.25,
      "circle-stroke-color": "#111827",
    },
  });
  map.addLayer({
    id: "tube-stations-label",
    type: "symbol",
    source: "tube-stations",
    layout: {
      "text-field": ["coalesce", ["get", "label"], ["get", "name"], ""],
      "text-font": ["Noto Sans Regular"],
      "text-size": 11,
      "text-offset": [0, 1.15],
      "text-anchor": "top",
      "text-optional": true,
    },
    paint: {
      "text-color": "#111827",
      "text-halo-color": "#ffffff",
      "text-halo-width": 1.6,
    },
  });
});

Leaflet

import L from "leaflet";
import "leaflet/dist/leaflet.css";

import tubeGeometry from "./data/geography/tube-geometry.json";

const map = L.map("map").setView([51.51, -0.12], 10);

L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
  attribution: "© OpenStreetMap contributors",
}).addTo(map);

// Lines
L.geoJSON(tubeGeometry.lines, {
  style: (feature) => ({
    color: feature?.properties?.color ?? "#0019A8",
    weight: 3,
    opacity: 0.9,
  }),
}).addTo(map);

// Stations
L.geoJSON(tubeGeometry.stations, {
  pointToLayer: (_feature, latlng) =>
    L.circleMarker(latlng, {
      radius: 3,
      fillColor: "#fff",
      color: "#111827",
      weight: 1.25,
      fillOpacity: 1,
    }),
}).addTo(map);

Mapbox GL JS

Needs a public access token.

import mapboxgl from "mapbox-gl";
import "mapbox-gl/dist/mapbox-gl.css";

import tubeGeometry from "./data/geography/tube-geometry.json";

// Your Mapbox access token
mapboxgl.accessToken = process.env.NEXT_PUBLIC_MAPBOX_TOKEN!;

const map = new mapboxgl.Map({
  container: "map",
  style: "mapbox://styles/mapbox/light-v11",
  center: [-0.12, 51.51],
  zoom: 10,
});

map.on("load", () => {
  map.addSource("tube-lines", { type: "geojson", data: tubeGeometry.lines });
  map.addLayer({
    id: "tube-lines",
    type: "line",
    source: "tube-lines",
    paint: {
      "line-color": ["get", "color"],
      "line-width": 3,
    },
  });

  map.addSource("tube-stations", { type: "geojson", data: tubeGeometry.stations });
  map.addLayer({
    id: "tube-stations",
    type: "circle",
    source: "tube-stations",
    paint: {
      "circle-radius": 3,
      "circle-color": "#ffffff",
      "circle-stroke-width": 1.25,
      "circle-stroke-color": "#111827",
    },
  });
});

Google Maps

Needs a Maps JavaScript API key.

import { setOptions, importLibrary } from "@googlemaps/js-api-loader";

import tubeGeometry from "./data/geography/tube-geometry.json";

// Your Google Maps API key
setOptions({ key: process.env.NEXT_PUBLIC_GOOGLE_MAPS_KEY! });

const { Map } = await importLibrary("maps");
const { SymbolPath } = await importLibrary("core");

const map = new Map(document.getElementById("map")!, {
  center: { lat: 51.51, lng: -0.12 },
  zoom: 10,
});

map.data.addGeoJson(tubeGeometry.lines);
map.data.addGeoJson(tubeGeometry.stations);
map.data.setStyle((feature) => {
  if (feature.getGeometry()?.getType() === "Point") {
    return {
      icon: {
        path: SymbolPath.CIRCLE,
        scale: 3,
        fillColor: "#ffffff",
        fillOpacity: 1,
        strokeColor: "#111827",
        strokeWeight: 1.25,
      },
    };
  }
  return {
    strokeColor: feature.getProperty("color") ?? "#0019A8",
    strokeWeight: 3,
    strokeOpacity: 0.9,
  };
});

Props

TflGeographicMap fills its parent. Give the wrapper a height.

PropTypeDefault
dataRecord<TransitMode, Bundle>fetches vendored JSON
modesTransitMode[]all five
showStationsbooleantrue
showLinesbooleantrue
showNavigationbooleantrue
center[lng, lat][-0.12, 51.51]
zoomnumber10.2
classNamestring

Track data still belongs to OSM

Geometry is © OpenStreetMap contributors, ODbL 1.0. Station metadata where present is © Transport for London, TfL Open Data. The Positron basemap is © OpenStreetMap contributors and © OpenFreeMap. The full declaration is data/geography/ORIGIN.md.

In code

Cycle hire docks · Schematic & network · Colours