import type { DiagramIRV2, DiagramPoint } from "../../model/diagram-ir.js"; import { validateDiagramIR } from "../../model/diagram-ir.js"; export interface TubeStation { id: string; label: string; provenance?: Record } export interface TubeLine { id: string; label: string; color: string; stations: readonly string[]; provenance?: Record } export interface TubeMapInput { title: string; stations: readonly TubeStation[]; lines: readonly TubeLine[]; provenance?: Record } export function createTubeMap(input: TubeMapInput): DiagramIRV2 { if (!input || !Array.isArray(input.stations) || input.stations.length === 0) throw new Error("Tube map requires stations"); if (!Array.isArray(input.lines)) throw new Error("Tube map lines must be an array"); const stations: readonly TubeStation[] = input.stations; const lines: readonly TubeLine[] = input.lines; const stationIds = new Set(stations.map((station) => station.id)); if (stationIds.size !== stations.length) throw new Error("Tube station IDs must be unique"); for (const line of lines) { if (line.stations.length < 2) throw new Error(`Tube line ${line.id} requires at least two stations`); for (const id of line.stations) if (!stationIds.has(id)) throw new Error(`Tube line ${line.id} references unknown station ${id}`); if (!/^#[0-9A-Fa-f]{6}$/.test(line.color)) throw new Error(`Tube line ${line.id} color must use strict #RRGGBB format`); } const memberships = new Map(stations.map((station) => [station.id, [] as string[]])); lines.forEach((line) => line.stations.forEach((id) => memberships.get(id)!.push(line.id))); memberships.forEach((lines) => lines.sort()); const firstLine = new Map(); lines.forEach((line, lineIndex) => line.stations.forEach((id) => { if (!firstLine.has(id)) firstLine.set(id, lineIndex); })); const centers = new Map(); const nodes = stations.map((station, index) => { const center = { x: 100 + index * 160, y: 100 + (firstLine.get(station.id) ?? lines.length) * 160 }; centers.set(station.id, center); const stationLines = memberships.get(station.id)!; return { id: station.id, label: station.label, kind: stationLines.length > 1 ? "tube-interchange" : "tube-station", geometry: { x: center.x - 20, y: center.y - 20, width: 40, height: 40 }, style: `ellipse;whiteSpace=wrap;html=1;strokeWidth=${stationLines.length > 1 ? 6 : 3}`, properties: { interchange: stationLines.length > 1, lines: stationLines }, provenance: station.provenance === undefined ? undefined : structuredClone(station.provenance) }; }); const edges = lines.flatMap((line, lineIndex) => line.stations.slice(0, -1).map((source, index) => { const routeCorridorY = 40 + lineIndex * 20; const target = line.stations[index + 1]; const a = centers.get(source)!; const b = centers.get(target)!; const laneOffset = 30 + Math.floor(lineIndex / 2) * 10; const approachX = (lineIndex % 2 === 0 ? 1 : -1) * laneOffset; const sourceApproachY = a.y + Math.sign(routeCorridorY - a.y) * laneOffset; const targetApproachY = b.y + Math.sign(routeCorridorY - b.y) * laneOffset; const waypoints = source === target ? [{ x: a.x + 40, y: a.y }, { x: a.x + 40, y: a.y + 40 }, { x: a.x, y: a.y + 40 }] : [ { x: a.x + approachX, y: sourceApproachY }, { x: a.x + approachX, y: routeCorridorY }, { x: b.x + approachX, y: routeCorridorY }, { x: b.x + approachX, y: targetApproachY }, ]; return { id: `${line.id}-segment-${index + 1}`, source, target, kind: "tube-line", style: `edgeStyle=none;rounded=0;strokeColor=${line.color};strokeWidth=8;endArrow=none`, waypoints, properties: { lineId: line.id, lineLabel: line.label, segment: index + 1 }, provenance: line.provenance === undefined ? undefined : structuredClone(line.provenance) }; })); const ir: DiagramIRV2 = { version: 2, title: input.title, pages: [{ id: "tube-map", title: input.title, nodes, edges, layout: { type: "manual", gridSize: 10 }, width: Math.max(320, stations.length * 160 + 40), height: Math.max(320, (lines.length + 1) * 160), properties: { profile: "tube-map", lines: lines.map((line) => ({ id: line.id, label: line.label, color: line.color })) } }], provenance: input.provenance === undefined ? undefined : structuredClone(input.provenance), properties: { profile: "tube-map" } }; return validateDiagramIR(ir) as DiagramIRV2; }