{"spec_id":"network-transport-static","library":"makie","language":"julia","code":"# anyplot.ai\n# network-transport-static: Static Transport Network Diagram\n# Library: makie 0.21.9 | Julia 1.11.9\n# Quality: 81/100 | Created: 2026-09-02\n\nusing CairoMakie\nusing Colors\n\n# Theme tokens — Imprint palette\nconst THEME    = get(ENV, \"ANYPLOT_THEME\", \"light\")\nconst PAGE_BG  = THEME == \"light\" ? colorant\"#FAF8F1\" : colorant\"#1A1A17\"\nconst INK      = THEME == \"light\" ? colorant\"#1A1A17\" : colorant\"#F0EFE8\"\nconst INK_SOFT = THEME == \"light\" ? colorant\"#4A4A44\" : colorant\"#B8B7B0\"\n\nconst IMPRINT_PALETTE = [\n    colorant\"#009E73\",  # 1 — brand green\n    colorant\"#C475FD\",  # 2 — lavender\n    colorant\"#4467A3\",  # 3 — blue\n]\nconst KIND_COLOR = Dict(\n    :express  => IMPRINT_PALETTE[1],\n    :regional => IMPRINT_PALETTE[2],\n    :local    => IMPRINT_PALETTE[3],\n)\n\n# Data — regional rail network: 12 stations, 22 timetabled services.\n# Layout is a hand-placed schematic (x, y are fixed coordinates, not a\n# force-directed layout): one express trunk (Westbrook–Eastport), one\n# regional line crossing it at Central (Highlands–Lakeside), and three local\n# spurs branching off single interchanges — so no station carries more than\n# 4 lines and every junction's spokes sit well apart in angle. r is the\n# node's inset radius in data units (edges stop at the node boundary, not\n# its center) and ms is the marker's on-screen point size.\nconst stations = [\n    (id = :central,    label = \"Central\",    x = 0.0,   y = 0.0,  r = 1.00, ldx = -0.95, ldy = 0.95,  halign = :right,  valign = :bottom, ms = 54),\n    (id = :westfield,  label = \"Westfield\",  x = -5.0,  y = 0.0,  r = 0.65, ldx = 0.0,   ldy = 1.05,  halign = :center, valign = :bottom, ms = 36),\n    (id = :westbrook,  label = \"Westbrook\",  x = -11.0, y = 0.0,  r = 0.65, ldx = 0.0,   ldy = 1.05,  halign = :center, valign = :bottom, ms = 36),\n    (id = :harbor,     label = \"Harbor\",     x = 5.0,   y = 0.0,  r = 0.65, ldx = 0.0,   ldy = -1.05, halign = :center, valign = :top,    ms = 36),\n    (id = :eastport,   label = \"Eastport\",   x = 11.0,  y = 0.0,  r = 0.65, ldx = 0.0,   ldy = 1.05,  halign = :center, valign = :bottom, ms = 36),\n    (id = :northgate,  label = \"Northgate\",  x = 0.0,   y = 4.0,  r = 0.65, ldx = 1.05,  ldy = 0.0,   halign = :left,   valign = :center, ms = 36),\n    (id = :highlands,  label = \"Highlands\",  x = 0.0,   y = 7.5,  r = 0.65, ldx = 1.05,  ldy = 0.0,   halign = :left,   valign = :center, ms = 36),\n    (id = :riverside,  label = \"Riverside\",  x = 0.0,   y = -4.0, r = 0.65, ldx = -1.05, ldy = 0.0,   halign = :right,  valign = :center, ms = 36),\n    (id = :lakeside,   label = \"Lakeside\",   x = 0.0,   y = -7.5, r = 0.65, ldx = 1.05,  ldy = 0.0,   halign = :left,   valign = :center, ms = 36),\n    (id = :hillcrest,  label = \"Hillcrest\",  x = 7.5,   y = 3.2,  r = 0.65, ldx = 1.00,  ldy = 0.70,  halign = :left,   valign = :bottom, ms = 36),\n    (id = :southgate,  label = \"Southgate\",  x = 3.5,   y = -6.5, r = 0.65, ldx = 1.00,  ldy = -0.70, halign = :left,   valign = :top,    ms = 36),\n    (id = :meadowvale, label = \"Meadowvale\", x = -7.5,  y = -3.2, r = 0.65, ldx = -1.00, ldy = -0.70, halign = :right,  valign = :top,    ms = 36),\n]\n\n# Directed services: (source, target, route_id, departure, arrival, kind).\n# Every line segment (trunk, regional, and each local spur) runs in both\n# directions — the resulting pairs get an offset (parallel) pair of lines\n# per the spec's \"curved or offset edges\" guidance for multi-edges.\nconst routes = [\n    (source = :westbrook,  target = :westfield,  route_id = \"EX 1\", dep = \"08:00\", arr = \"08:14\", kind = :express),\n    (source = :westfield,  target = :westbrook,  route_id = \"EX 2\", dep = \"08:20\", arr = \"08:34\", kind = :express),\n    (source = :westfield,  target = :central,    route_id = \"EX 3\", dep = \"08:14\", arr = \"08:26\", kind = :express),\n    (source = :central,    target = :westfield,  route_id = \"EX 4\", dep = \"08:34\", arr = \"08:46\", kind = :express),\n    (source = :central,    target = :harbor,     route_id = \"EX 5\", dep = \"08:26\", arr = \"08:38\", kind = :express),\n    (source = :harbor,     target = :central,    route_id = \"EX 6\", dep = \"08:46\", arr = \"08:58\", kind = :express),\n    (source = :harbor,     target = :eastport,   route_id = \"EX 7\", dep = \"08:38\", arr = \"08:52\", kind = :express),\n    (source = :eastport,   target = :harbor,     route_id = \"EX 8\", dep = \"08:58\", arr = \"09:12\", kind = :express),\n    (source = :northgate,  target = :central,    route_id = \"RE 1\", dep = \"08:05\", arr = \"08:20\", kind = :regional),\n    (source = :central,    target = :northgate,  route_id = \"RE 2\", dep = \"08:25\", arr = \"08:40\", kind = :regional),\n    (source = :central,    target = :riverside,  route_id = \"RE 3\", dep = \"08:10\", arr = \"08:25\", kind = :regional),\n    (source = :riverside,  target = :central,    route_id = \"RE 4\", dep = \"08:30\", arr = \"08:45\", kind = :regional),\n    (source = :highlands,  target = :northgate,  route_id = \"RE 5\", dep = \"07:45\", arr = \"08:05\", kind = :regional),\n    (source = :northgate,  target = :highlands,  route_id = \"RE 6\", dep = \"08:40\", arr = \"09:00\", kind = :regional),\n    (source = :riverside,  target = :lakeside,   route_id = \"RE 7\", dep = \"08:45\", arr = \"09:05\", kind = :regional),\n    (source = :lakeside,   target = :riverside,  route_id = \"RE 8\", dep = \"07:40\", arr = \"08:00\", kind = :regional),\n    (source = :harbor,     target = :hillcrest,  route_id = \"LO 1\", dep = \"08:40\", arr = \"08:55\", kind = :local),\n    (source = :hillcrest,  target = :harbor,     route_id = \"LO 2\", dep = \"09:00\", arr = \"09:15\", kind = :local),\n    (source = :riverside,  target = :southgate,  route_id = \"LO 3\", dep = \"08:48\", arr = \"09:02\", kind = :local),\n    (source = :southgate,  target = :riverside,  route_id = \"LO 4\", dep = \"07:50\", arr = \"08:04\", kind = :local),\n    (source = :westfield,  target = :meadowvale, route_id = \"LO 5\", dep = \"08:16\", arr = \"08:30\", kind = :local),\n    (source = :meadowvale, target = :westfield,  route_id = \"LO 6\", dep = \"08:50\", arr = \"09:04\", kind = :local),\n]\n\nconst pos       = Dict(s.id => (s.x, s.y) for s in stations)\nconst inset     = Dict(s.id => s.r for s in stations)\nconst pair_seen = Set((r.source, r.target) for r in routes)\nconst degree    = Dict(s.id => length(Set(r.target for r in routes if r.source == s.id)) for s in stations)\n\n# Title — mandated token plus a short descriptive prefix; fontsize scales\n# down when the full string runs past the ~67-char baseline.\ntitle_str  = \"Regional Rail Network · network-transport-static · julia · makie · anyplot.ai\"\ntitle_size = round(Int, max(14, 20 * min(1.0, 67 / length(title_str))))\n\n# Figure\nfig = Figure(\n    size            = (1600, 900),\n    fontsize        = 14,\n    backgroundcolor = PAGE_BG,\n)\n\nLabel(fig[0, 1], title_str; fontsize = title_size, color = INK, halign = :center)\nrowsize!(fig.layout, 0, Fixed(70))\n\nax = Axis(\n    fig[1, 1];\n    backgroundcolor    = PAGE_BG,\n    aspect             = DataAspect(),\n    topspinevisible    = false,\n    rightspinevisible  = false,\n    leftspinevisible   = false,\n    bottomspinevisible = false,\n    xgridvisible       = false,\n    ygridvisible       = false,\n    xticksvisible      = false,\n    yticksvisible      = false,\n    xticklabelsvisible = false,\n    yticklabelsvisible = false,\n)\nconst XMIN, XMAX = -12.3, 12.3\nconst YMIN, YMAX = -8.8, 8.8\nxlims!(ax, XMIN, XMAX)\nylims!(ax, YMIN, YMAX)\n\n# Single-column layout: DataAspect makes the network narrower than the full\n# figure width (24.6:17.6 data aspect vs. the wider figure cell), so the\n# axis letterboxes with empty margin on both sides automatically. The\n# legend is inset into that existing right-hand margin below, right next to\n# Eastport, instead of claiming a separate column that stranded it far from\n# the diagram.\n#\n# Explicit Relative(1.0) below is required even for this single column/row —\n# Makie's default Auto sizing collapses the cell to a tiny fraction of the\n# figure when the Axis has `aspect = DataAspect()`, instead of expanding to\n# fill the available space.\ncolsize!(fig.layout, 1, Relative(1.0))\nrowsize!(fig.layout, 1, Relative(1.0))\n\n# --- Label collision avoidance ----------------------------------------------\n# Every edge label is rotated to align with its edge, so its footprint is a\n# rotated rectangle (an oriented bounding box, OBB); station labels are\n# axis-aligned rectangles anchored by (halign, valign) rather than centered.\n# `PX_PER_UNIT` and every label's (w, h) come from real Makie glyph\n# measurements (`Makie.project` / `Makie.boundingbox`) on this exact axis,\n# not an estimated font-metrics guess — an earlier CHAR_W/LINE_H guess ran\n# ~30-40% too large, which made the collision search reject valid candidates\n# and fall back to extreme offsets instead of a nearby clear slot.\nMakie.update_state_before_display!(fig)\nlet p0 = Makie.project(ax.scene, Point2f(0.0, 0.0)),\n    p1 = Makie.project(ax.scene, Point2f(1.0, 0.0)),\n    p2 = Makie.project(ax.scene, Point2f(0.0, 1.0))\n    global PX_PER_UNIT = (abs(p1[1] - p0[1]) + abs(p0[2] - p2[2])) / 2\nend\n\nfunction measure_wh(txt, fontsize)\n    tmp = text!(ax, 0.0, 0.0; text = txt, fontsize = fontsize, align = (:center, :center), rotation = 0.0)\n    Makie.update_state_before_display!(fig)\n    bb = Makie.boundingbox(tmp)\n    delete!(ax, tmp)\n    return bb.widths[1] / PX_PER_UNIT, bb.widths[2] / PX_PER_UNIT\nend\n\nfunction anchor_center_offset(halign, valign, w, h)\n    cx = halign == :left ? w / 2 : halign == :right ? -w / 2 : 0.0\n    cy = valign == :bottom ? h / 2 : valign == :top ? -h / 2 : 0.0\n    return cx, cy\nend\n\nstruct OBB\n    cx::Float64\n    cy::Float64\n    hw::Float64\n    hh::Float64\n    ang::Float64\nend\n\nobb_axes(o::OBB) = ((cos(o.ang), sin(o.ang)), (-sin(o.ang), cos(o.ang)))\n\nfunction obb_corners(o::OBB)\n    (ax1, ay1), (ax2, ay2) = obb_axes(o)\n    return [\n        (o.cx + ax1 * o.hw + ax2 * o.hh, o.cy + ay1 * o.hw + ay2 * o.hh),\n        (o.cx - ax1 * o.hw + ax2 * o.hh, o.cy - ay1 * o.hw + ay2 * o.hh),\n        (o.cx - ax1 * o.hw - ax2 * o.hh, o.cy - ay1 * o.hw - ay2 * o.hh),\n        (o.cx + ax1 * o.hw - ax2 * o.hh, o.cy + ay1 * o.hw - ay2 * o.hh),\n    ]\nend\n\nproject(corners, axis) = (minimum(c -> c[1] * axis[1] + c[2] * axis[2], corners),\n                           maximum(c -> c[1] * axis[1] + c[2] * axis[2], corners))\n\nfunction obb_overlap(a::OBB, b::OBB; margin = 0.0)\n    ca, cb = obb_corners(a), obb_corners(b)\n    for axis in (obb_axes(a)..., obb_axes(b)...)\n        amin, amax = project(ca, axis)\n        bmin, bmax = project(cb, axis)\n        (amax + margin < bmin || bmax + margin < amin) && return false\n    end\n    return true\nend\n\ncross2(ax, ay, bx, by) = ax * by - ay * bx\n\nfunction seg_intersect(p1, p2, p3, p4)\n    d1 = cross2(p4[1] - p3[1], p4[2] - p3[2], p1[1] - p3[1], p1[2] - p3[2])\n    d2 = cross2(p4[1] - p3[1], p4[2] - p3[2], p2[1] - p3[1], p2[2] - p3[2])\n    d3 = cross2(p2[1] - p1[1], p2[2] - p1[2], p3[1] - p1[1], p3[2] - p1[2])\n    d4 = cross2(p2[1] - p1[1], p2[2] - p1[2], p4[1] - p1[1], p4[2] - p1[2])\n    return ((d1 > 0) != (d2 > 0)) && ((d3 > 0) != (d4 > 0))\nend\n\nfunction point_in_obb(p, o::OBB)\n    (ax1, ay1), (ax2, ay2) = obb_axes(o)\n    dx, dy = p[1] - o.cx, p[2] - o.cy\n    return abs(dx * ax1 + dy * ay1) <= o.hw && abs(dx * ax2 + dy * ay2) <= o.hh\nend\n\nfunction seg_obb_overlap(p1, p2, o::OBB; margin = 0.0)\n    oi = OBB(o.cx, o.cy, o.hw + margin, o.hh + margin, o.ang)\n    (point_in_obb(p1, oi) || point_in_obb(p2, oi)) && return true\n    corners = obb_corners(oi)\n    for i in 1:4\n        j = i == 4 ? 1 : i + 1\n        seg_intersect(p1, p2, corners[i], corners[j]) && return true\n    end\n    return false\nend\n\nfunction circle_obb_overlap(cx, cy, r, o::OBB; margin = 0.0)\n    (ax1, ay1), (ax2, ay2) = obb_axes(o)\n    dx, dy = cx - o.cx, cy - o.cy\n    u = clamp(dx * ax1 + dy * ay1, -o.hw, o.hw)\n    v = clamp(dx * ax2 + dy * ay2, -o.hh, o.hh)\n    closest_x, closest_y = o.cx + u * ax1 + v * ax2, o.cy + u * ay1 + v * ay2\n    return (closest_x - cx)^2 + (closest_y - cy)^2 <= (r + margin)^2\nend\n\nfunction obb_in_bounds(o::OBB, xmin, xmax, ymin, ymax)\n    return all(c -> xmin <= c[1] <= xmax && ymin <= c[2] <= ymax, obb_corners(o))\nend\n\n# Candidate (lt, offset-multiplier, side) triples for one edge label, tried\n# distance-tier by distance-tier: every (endpoint, side) combination at the\n# current offset is tried before the offset grows, so a label prefers a\n# nearby clear slot (a flipped side, or the other endpoint) over a distant\n# one on its preferred side — the search used to exhaust one side's offsets\n# first, which cleared collisions but could fling a label several units from\n# its own edge with nothing else in that empty space to relate it back.\ncandidate_list(lt_base, dirsign) = [\n    (lt, m, side)\n    for m in (1.0, 1.3, 1.6, 1.9, 2.2, 2.5, 2.8, 3.1, 3.4, 3.7, 4.0, 4.5, 5.0)\n    for lt in (lt_base, 1 - lt_base, 0.5, 0.15, 0.85, 0.05, 0.95)\n    for side in (dirsign, -dirsign)\n]\n\n# Station-label boxes are placed first — they're fixed, so every edge label\n# must steer around them rather than the other way round. Node markers\n# (`scatter!` circles, sized in screen px like the labels) are a second\n# fixed obstacle class — an edge label landing on top of a station ring is\n# just as unreadable as landing on the station's text.\nplaced_boxes = OBB[]\nfor s in stations\n    w, h = measure_wh(s.label, 15)\n    cxoff, cyoff = anchor_center_offset(s.halign, s.valign, w, h)\n    ax_, ay_ = s.x + s.ldx, s.y + s.ldy\n    push!(placed_boxes, OBB(ax_ + cxoff, ay_ + cyoff, w / 2, h / 2, 0.0))\nend\n\nnode_circles = [(s.x, s.y, (s.ms / 2) / PX_PER_UNIT) for s in stations]\n\n# Edges — offset parallel lines distinguish each bidirectional pair; every\n# edge ends with a direction arrowhead. Line/arrow geometry is drawn\n# immediately; labels are placed in a second pass once every line segment is\n# known, so a label can be checked against *all* edges, not just earlier ones.\nroute_geom = NamedTuple[]\nfor r in routes\n    sx, sy = pos[r.source]\n    tx, ty = pos[r.target]\n    dx, dy = tx - sx, ty - sy\n    dist   = sqrt(dx^2 + dy^2)\n    ux, uy = dx / dist, dy / dist\n\n    # The offset side must be anchored to the *pair* (station A, station B),\n    # not to this route's own direction — a reverse route's (ux, uy) already\n    # points the opposite way, so using its own perpendicular here would flip\n    # sign together with dirsign and cancel out, landing both directions on\n    # the same line. Anchor px/py to the alphabetically-smaller-first vector\n    # instead, which is identical for both directions of a pair.\n    forward       = string(r.source) < string(r.target)\n    dirsign       = forward ? 1.0 : -1.0\n    canon_ux, canon_uy = forward ? (ux, uy) : (-ux, -uy)\n    canon_px, canon_py = -canon_uy, canon_ux\n    has_pair = (r.target, r.source) in pair_seen\n    line_off = has_pair ? 0.80 * dirsign : 0.0\n    ox, oy   = canon_px * line_off, canon_py * line_off\n\n    r_src, r_tgt = inset[r.source], inset[r.target]\n    x0, y0 = sx + ox + ux * r_src, sy + oy + uy * r_src\n    x1, y1 = tx + ox - ux * r_tgt, ty + oy - uy * r_tgt\n\n    col = KIND_COLOR[r.kind]\n    lines!(ax, [x0, x1], [y0, y1]; color = col, linewidth = 2.4)\n    arrows!(ax, [x1], [y1], [ux * 0.001], [uy * 0.001];\n            arrowsize = 20, color = col, linewidth = 0)\n\n    ang = atan(dy, dx)\n    if ang > pi / 2\n        ang -= pi\n    elseif ang < -pi / 2\n        ang += pi\n    end\n\n    txt  = \"$(r.route_id) | $(r.dep) → $(r.arr)\"\n    w, h = measure_wh(txt, 13)\n\n    push!(route_geom, (r = r, x0 = x0, y0 = y0, x1 = x1, y1 = y1,\n                        canon_px = canon_px, canon_py = canon_py,\n                        dirsign = dirsign, line_off = line_off, ang = ang,\n                        txt = txt, w = w, h = h))\nend\n\nline_segments = [((g.x0, g.y0), (g.x1, g.y1)) for g in route_geom]\n\n# Label sits near whichever endpoint has fewer other lines converging on it,\n# not at the midpoint — a busy interchange has every spoke close together,\n# but a quiet terminus has open space for the route/time text. From that\n# starting point, walk `candidate_list` until a position clears every\n# station label, every previously-placed edge label, and every edge line.\nfor g in route_geom\n    r = g.r\n    lt_base = degree[r.target] <= degree[r.source] ? 0.70 : 0.30\n\n    # Track the LEAST-colliding candidate seen so far, not just the last one\n    # tried — if every candidate collides with something, falling back to\n    # the final (most extreme) candidate regardless of its own score can\n    # fling a label onto an unrelated station far down the list, which is\n    # worse than a mild overlap near the original slot. Out-of-bounds\n    # candidates are penalized rather than excluded outright, so a position\n    # is always chosen even in a pathological case.\n    best       = nothing\n    best_score = typemax(Int)\n    for (lt, mult, side) in candidate_list(lt_base, g.dirsign)\n        label_off = (abs(g.line_off) + 0.75) * mult\n        mx = g.x0 * (1 - lt) + g.x1 * lt + g.canon_px * label_off * side\n        my = g.y0 * (1 - lt) + g.y1 * lt + g.canon_py * label_off * side\n        cand = OBB(mx, my, g.w / 2, g.h / 2, g.ang)\n        score = count(b -> obb_overlap(cand, b; margin = 0.08), placed_boxes) +\n                count(seg -> seg_obb_overlap(seg[1], seg[2], cand; margin = 0.08), line_segments) +\n                count(c -> circle_obb_overlap(c[1], c[2], c[3], cand; margin = 0.08), node_circles) +\n                (obb_in_bounds(cand, XMIN, XMAX, YMIN, YMAX) ? 0 : 1000)\n        if score < best_score\n            best, best_score = cand, score\n        end\n        best_score == 0 && break\n    end\n    push!(placed_boxes, best)\n\n    text!(ax, best.cx, best.cy;\n          text = g.txt, fontsize = 13, color = INK_SOFT,\n          align = (:center, :center), rotation = g.ang)\nend\n\n# Nodes — hollow rings so crossing edges stay visible through the station\n# marker; Central is enlarged as the interchange hub (size, not text, carries\n# the emphasis).\nfor s in stations\n    scatter!(ax, [s.x], [s.y];\n             markersize = s.ms, color = PAGE_BG, strokecolor = INK, strokewidth = 2.5)\n    text!(ax, s.x + s.ldx, s.y + s.ldy;\n          text = s.label, fontsize = 15, color = INK, align = (s.halign, s.valign))\nend\n\n# Legend — route type color key, inset into the axis's own letterboxed\n# right margin (next to Eastport) rather than a separate stranded column.\nleg_elements = [LineElement(color = KIND_COLOR[k], linewidth = 3.5) for k in (:express, :regional, :local)]\nLegend(\n    fig[1, 1],\n    leg_elements,\n    [\"Express\", \"Regional\", \"Local\"];\n    title           = \"Route type\",\n    titlesize       = 14,\n    labelsize       = 13,\n    titlecolor      = INK,\n    labelcolor      = INK_SOFT,\n    framevisible    = false,\n    backgroundcolor = :transparent,\n    tellwidth       = false,\n    tellheight      = false,\n    halign          = :right,\n    valign          = :center,\n    patchsize       = (26, 10),\n    margin          = (10, 10, 10, 10),\n)\n\nsave(\"plot-$(THEME).png\", fig; px_per_unit = 2)\n"}