Project: Multi-Modal Transit Route Planner & Isochrone Engine
Mix.install([
{:yog_ex, path: "../.."},
{:jason, "~> 1.4"},
{:kino_vizjs, "~> 0.9.0"}
])
Introduction
Urban transit systems are among the most intricate real-world networks. Modern metropolitan areas combine diverse transit modalities:
- Express Rail / Metro: Fast, high-frequency, higher capacity, accessible.
- City & Commuter Buses: Flexible, widespread coverage, economical, subject to surface traffic.
- Ferries & Water Taxis: High-speed waterway corridors connecting islands and harborfronts.
- Pedestrian Walkways: Short transfer corridors, greenways, and staircases linking adjacent districts.
Between any two stations, passengers frequently have multiple transportation choices. For example, traveling from Central Hub to the Waterfront might offer:
- A 5-minute Metro ride ($2.50)
- A 12-minute Heritage Streetcar ($1.25)
- An 18-minute scenic Promenade Walk ($0.00)
Because multiple distinct connections exist between the same pair of stations, this domain cannot be accurately modeled with a simple graph without loss of information. It is a textbook application for a Multigraph (Yog.Multi).
Key Engineering Questions Addressed
- Multimodal Representation: How do we model multiple concurrent transit links with heterogeneous metadata (duration, fare, accessibility, line names)?
- Multi-Criteria Optimization: How do we collapse parallel multigraph edges into simple graphs tailored to individual passenger preferences (fastest trip vs. cheapest budget vs. wheelchair-only)?
- Shortest-Path Itineraries: How do we compute end-to-end multi-leg transit itineraries using Dijkstra pathfinding?
- Isochrone Mapping: How do we calculate and visualize travel-time reachability zones from a central hub?
- Network Resilience & Detour Routing: How does the transit network adapt when key corridors suffer service disruptions?
Yog Concepts Covered
By the end, you will know how to:
- Model parallel transit choices with
Yog.Multi. - Store rich route metadata on multigraph edges.
- Collapse a multigraph into simple weighted graphs for different optimization goals.
- Run Dijkstra shortest paths over time- and cost-weighted graphs.
- Recover leg-by-leg itineraries from selected edge metadata.
- Compute single-source travel-time reachability for isochrone-style analysis.
- Simulate service disruption by removing nodes and recomputing routes.
- Render multigraphs and highlighted paths with DOT.
Why Yog Fits This Problem
Transit systems are naturally multigraphs: two stations may be connected by a metro, a bus, a walking path, and a ferry, all with different costs and constraints. Yog.Multi lets you model those choices directly, then collapse them into ordinary Yog graphs only when an algorithm such as shortest path needs a single edge weight per station pair.
Section 1: Ingesting the Transit Network into a Multigraph
We begin by loading the municipal transit dataset. Stations represent physical locations (with geographic zones and facility types), and routes represent directional or bidirectional transit options.
alias Yog.Multi
# Load transit manifest from disk with inline fallback for self-contained execution
manifest_path =
cond do
File.exists?(Path.expand("data/transit_network.json", __DIR__)) ->
Path.expand("data/transit_network.json", __DIR__)
File.exists?("livebooks/projects/data/transit_network.json") ->
"livebooks/projects/data/transit_network.json"
true ->
nil
end
raw_manifest =
if manifest_path && File.exists?(manifest_path) do
File.read!(manifest_path)
else
# Embedded fallback network
~s"""
{
"stations": [
{"id": "central", "name": "Central Hub", "zone": 1, "type": "intermodal_hub"},
{"id": "uptown", "name": "Uptown Financial", "zone": 1, "type": "metro_station"},
{"id": "waterfront", "name": "Waterfront Pier", "zone": 1, "type": "ferry_terminal"},
{"id": "arts_district", "name": "Arts & Theater", "zone": 1, "type": "metro_station"},
{"id": "tech_park", "name": "Innovation Tech Park", "zone": 2, "type": "commuter_station"},
{"id": "medical_center", "name": "Regional Medical Center", "zone": 2, "type": "transit_center"},
{"id": "university", "name": "Metropolitan University", "zone": 2, "type": "campus_station"},
{"id": "stadium", "name": "Civic Stadium & Arena", "zone": 2, "type": "event_station"},
{"id": "suburb_north", "name": "Northgate Heights", "zone": 3, "type": "park_and_ride"},
{"id": "suburb_east", "name": "Eastfield Junction", "zone": 3, "type": "park_and_ride"},
{"id": "airport", "name": "International Airport", "zone": 3, "type": "terminal_station"},
{"id": "harbor_island", "name": "Harbor Island Park", "zone": 1, "type": "island_dock"}
],
"routes": [
{"from": "central", "to": "waterfront", "mode": "metro", "line": "Red Line", "duration_min": 5, "cost": 2.50, "accessible": true},
{"from": "central", "to": "waterfront", "mode": "streetcar", "line": "Heritage Tram", "duration_min": 12, "cost": 1.25, "accessible": true},
{"from": "central", "to": "waterfront", "mode": "walk", "line": "Harbor Promenade", "duration_min": 18, "cost": 0.00, "accessible": true},
{"from": "central", "to": "arts_district", "mode": "metro", "line": "Green Line", "duration_min": 6, "cost": 2.50, "accessible": true},
{"from": "central", "to": "arts_district", "mode": "bus", "line": "City Bus 10", "duration_min": 14, "cost": 1.50, "accessible": true},
{"from": "central", "to": "arts_district", "mode": "walk", "line": "Cultural Boulevard", "duration_min": 16, "cost": 0.00, "accessible": true},
{"from": "central", "to": "uptown", "mode": "metro", "line": "Red Line", "duration_min": 7, "cost": 2.50, "accessible": true},
{"from": "central", "to": "uptown", "mode": "bus", "line": "City Bus 14", "duration_min": 15, "cost": 1.50, "accessible": true},
{"from": "central", "to": "tech_park", "mode": "metro", "line": "Blue Line", "duration_min": 11, "cost": 2.75, "accessible": true},
{"from": "central", "to": "tech_park", "mode": "bus", "line": "Express Bus 42", "duration_min": 22, "cost": 1.75, "accessible": true},
{"from": "central", "to": "stadium", "mode": "metro", "line": "Red Line", "duration_min": 8, "cost": 2.50, "accessible": true},
{"from": "central", "to": "stadium", "mode": "walk", "line": "Stadium Greenway", "duration_min": 24, "cost": 0.00, "accessible": true},
{"from": "central", "to": "airport", "mode": "express_rail", "line": "Airport Express", "duration_min": 16, "cost": 7.50, "accessible": true},
{"from": "central", "to": "airport", "mode": "bus", "line": "Bus 100 Airport", "duration_min": 42, "cost": 2.00, "accessible": true},
{"from": "arts_district", "to": "uptown", "mode": "metro", "line": "Green Line", "duration_min": 5, "cost": 2.50, "accessible": true},
{"from": "arts_district", "to": "uptown", "mode": "walk", "line": "Historic Alley (Stairs)", "duration_min": 9, "cost": 0.00, "accessible": false},
{"from": "arts_district", "to": "university", "mode": "metro", "line": "Green Line", "duration_min": 7, "cost": 2.50, "accessible": true},
{"from": "arts_district", "to": "university", "mode": "bus", "line": "Campus Shuttle", "duration_min": 11, "cost": 0.00, "accessible": true},
{"from": "arts_district", "to": "university", "mode": "walk", "line": "University Way", "duration_min": 18, "cost": 0.00, "accessible": true},
{"from": "uptown", "to": "medical_center", "mode": "metro", "line": "Red Line", "duration_min": 8, "cost": 2.50, "accessible": true},
{"from": "uptown", "to": "medical_center", "mode": "bus", "line": "City Bus 88", "duration_min": 16, "cost": 1.50, "accessible": true},
{"from": "uptown", "to": "suburb_north", "mode": "metro", "line": "Yellow Line", "duration_min": 15, "cost": 2.75, "accessible": true},
{"from": "uptown", "to": "suburb_north", "mode": "bus", "line": "Commuter Bus 12", "duration_min": 28, "cost": 1.75, "accessible": true},
{"from": "suburb_north", "to": "stadium", "mode": "bus", "line": "Crosstown Bus 7", "duration_min": 18, "cost": 1.75, "accessible": true},
{"from": "tech_park", "to": "suburb_east", "mode": "metro", "line": "Blue Line", "duration_min": 14, "cost": 2.75, "accessible": true},
{"from": "tech_park", "to": "suburb_east", "mode": "bus", "line": "Commuter Bus 50", "duration_min": 24, "cost": 1.75, "accessible": true},
{"from": "medical_center", "to": "university", "mode": "bus", "line": "Health Link Shuttle", "duration_min": 9, "cost": 1.00, "accessible": true},
{"from": "medical_center", "to": "university", "mode": "walk", "line": "Campus Medical Trail", "duration_min": 14, "cost": 0.00, "accessible": true},
{"from": "waterfront", "to": "harbor_island", "mode": "ferry", "line": "Harbor Ferry Line", "duration_min": 14, "cost": 3.50, "accessible": true},
{"from": "waterfront", "to": "harbor_island", "mode": "water_taxi", "line": "Express Water Taxi", "duration_min": 6, "cost": 8.00, "accessible": false},
{"from": "stadium", "to": "airport", "mode": "bus", "line": "South Link 33", "duration_min": 26, "cost": 2.00, "accessible": true}
]
}
"""
end
network_data = Jason.decode!(raw_manifest)
stations = network_data["stations"]
routes = network_data["routes"]
IO.puts("Loaded #{length(stations)} stations and #{length(routes)} route segments.")
Now we construct an undirected multigraph using Yog.Multi.undirected(). In an undirected multigraph, each route can be traversed in either direction (bidirectional transit lines), and adding parallel edges preserves each route independently with a unique EdgeId.
# Initialize undirected multigraph
transit_mg =
Enum.reduce(stations, Multi.undirected(), fn station, g ->
Multi.add_node(g, station["id"], %{
name: station["name"],
zone: station["zone"],
type: station["type"]
})
end)
# Insert all routes as parallel edges
transit_mg =
Enum.reduce(routes, transit_mg, fn r, g ->
edge_data = %{
mode: r["mode"],
line: r["line"],
duration: r["duration_min"],
cost: r["cost"],
accessible: r["accessible"]
}
{updated_g, _edge_id} = Multi.add_edge(g, r["from"], r["to"], edge_data)
updated_g
end)
IO.puts("=== Multigraph Overview ===")
IO.puts("Stations (Nodes): #{Multi.order(transit_mg)}")
IO.puts("Physical Route Segments (Edges): #{Multi.size(transit_mg)}")
Expected result: the multigraph should preserve every route option as its own edge, including parallel alternatives between the same two stations.
Let's inspect the parallel edges between Central Hub and Waterfront Pier:
central_waterfront_options = Multi.edges_between(transit_mg, "central", "waterfront")
Enum.each(central_waterfront_options, fn {eid, data} ->
IO.puts("• [Edge ##{eid}] #{data.line} (#{data.mode}) -> #{data.duration} mins, $#{:erlang.float_to_binary(data.cost * 1.0, decimals: 2)}")
end)
Section 2: Multimodal Network Visualization
To help transit planners and commuters understand the system, we can render the multigraph using Yog.Multi.DOT.
We apply custom visual encoding:
- Metro & Express Rail: Solid thick lines with vibrant line colors (Red
#e11d48, Green#16a34a, Blue#2563eb, Yellow#ca8a04, Indigo#4f46e5). - City & Commuter Bus: Amber dashed lines (
#ea580c,dashed). - Ferry & Water Taxi: Cyan dotted lines (
#0891b2,dotted). - Walking Paths: Neutral gray thin dotted lines (
#94a3b8,dotted). - Hub Stations: Distinct shapes (e.g.
doublecirclefor intermodal hubs,boxfor terminals).
mode_color = fn data ->
case {data.mode, data.line} do
{"metro", "Red Line"} -> "#e11d48"
{"metro", "Green Line"} -> "#16a34a"
{"metro", "Blue Line"} -> "#2563eb"
{"metro", "Yellow Line"} -> "#ca8a04"
{"express_rail", _} -> "#4f46e5"
{"streetcar", _} -> "#d97706"
{"bus", _} -> "#ea580c"
{"ferry", _} -> "#0891b2"
{"water_taxi", _} -> "#0284c7"
{"walk", _} -> "#94a3b8"
_ -> "#64748b"
end
end
mode_style = fn mode ->
case mode do
"walk" -> "dotted"
"bus" -> "dashed"
"water_taxi" -> "dashed"
_ -> "solid"
end
end
dot_opts = %{
Yog.Multi.DOT.default_options()
| rankdir: :lr,
node_shape: :box,
node_style: :filled,
node_color: "#f1f5f9",
node_fontname: "Inter,Helvetica,Arial",
node_fontsize: 11,
node_attributes: fn node_id, data ->
case data.type do
"intermodal_hub" ->
[{:shape, :doublecircle}, {:fillcolor, "#fef3c7"}, {:color, "#d97706"}, {:penwidth, 2.5}]
"terminal_station" ->
[{:shape, :component}, {:fillcolor, "#ede9fe"}, {:color, "#6d28d9"}, {:penwidth, 2}]
"ferry_terminal" ->
[{:shape, :hexagon}, {:fillcolor, "#e0f2fe"}, {:color, "#0284c7"}, {:penwidth, 2}]
_ ->
[]
end
end,
edge_attributes: fn _from, _to, _eid, data ->
color = mode_color.(data)
style = mode_style.(data.mode)
penwidth = if data.mode in ["metro", "express_rail"], do: 2.5, else: 1.5
label = "#{data.duration}m ($#{:erlang.float_to_binary(data.cost * 1.0, decimals: 2)})"
[
{:color, color},
{:style, style},
{:penwidth, penwidth},
{:label, label},
{:fontcolor, color},
{:fontsize, 9}
]
end
}
dot_source = Yog.Multi.DOT.to_dot(transit_mg, dot_opts)
Kino.VizJS.render(dot_source)
Expected result: the visualization should show multiple styled connections between several station pairs, making mode choice visible instead of hidden inside one collapsed edge.
Notice how parallel edges between stations like central $\leftrightarrow$ waterfront and central $\leftrightarrow$ arts_district are naturally distinguished with individual curves, styles, and timing labels.
Section 3: Multi-Criteria Edge Collapsing Strategies
Different travelers have fundamentally different routing objectives:
- The Commuter (Time-Conscious): Prioritizes minimal travel duration, willing to pay standard or express fares.
- The Budget Traveler: Prioritizes lowest monetary expense, willing to walk or ride buses.
- The Mobility-Limited Traveler: Requires 100% wheelchair-accessible vehicles and elevators, strictly avoiding staircases or inaccessible docks.
Yog.Multi.to_simple_graph/2 allows us to collapse the multigraph into simple graphs using custom selection/reduction functions.
Strategy 1: Time Minimization (Fastest)
For each pair of connected stations with parallel routes, select the route with the smallest duration:
fastest_route_combiner = fn r1, r2 ->
if r1.duration <= r2.duration, do: r1, else: r2
end
# Collapse into simple graph with winning route object
fastest_simple_mg = Multi.to_simple_graph(transit_mg, fastest_route_combiner)
# Transform into standard numerical graph where edge weight = duration (in minutes)
time_graph =
Enum.reduce(stations, Yog.undirected(), fn s, g ->
Yog.add_node(g, s["id"], s["name"])
end)
time_graph =
Enum.reduce(Yog.Model.all_edges(fastest_simple_mg), time_graph, fn {src, dst, route}, g ->
# In an undirected graph, add edge with duration as weight
Yog.Model.add_edge!(g, src, dst, route.duration)
end)
IO.puts("Time-optimized simple graph ready with #{Yog.Model.edge_count(time_graph)} edges.")
Strategy 2: Cost Minimization (Cheapest)
Select the route with the lowest cost:
cheapest_route_combiner = fn r1, r2 ->
if r1.cost <= r2.cost, do: r1, else: r2
end
cheapest_simple_mg = Multi.to_simple_graph(transit_mg, cheapest_route_combiner)
# Transform into standard numerical graph where edge weight = cost (in dollars)
cost_graph =
Enum.reduce(stations, Yog.undirected(), fn s, g ->
Yog.add_node(g, s["id"], s["name"])
end)
cost_graph =
Enum.reduce(Yog.Model.all_edges(cheapest_simple_mg), cost_graph, fn {src, dst, route}, g ->
Yog.Model.add_edge!(g, src, dst, route.cost)
end)
IO.puts("Cost-optimized simple graph ready with #{Yog.Model.edge_count(cost_graph)} edges.")
Strategy 3: Accessibility Filtering
Before collapsing, remove any transit segment that is not wheelchair accessible (accessible: false):
accessible_routes = Enum.filter(routes, & &1["accessible"])
accessible_mg =
Enum.reduce(stations, Multi.undirected(), fn station, g ->
Multi.add_node(g, station["id"], station["name"])
end)
accessible_mg =
Enum.reduce(accessible_routes, accessible_mg, fn r, g ->
edge_data = %{
mode: r["mode"],
line: r["line"],
duration: r["duration_min"],
cost: r["cost"],
accessible: r["accessible"]
}
{updated_g, _eid} = Multi.add_edge(g, r["from"], r["to"], edge_data)
updated_g
end)
IO.puts("Accessible multigraph has #{Multi.size(accessible_mg)} edges (#{Multi.size(transit_mg) - Multi.size(accessible_mg)} inaccessible paths pruned).")
Expected result: each collapsed simple graph has one chosen route per connected station pair, but the chosen route differs depending on whether you optimize for duration, cost, or accessibility.
Section 4: Optimal Route Solving (Fastest vs. Cheapest)
Let's plan a journey from the northern suburbs (Northgate Heights, "suburb_north") across the metropolitan area to Harbor Island Park ("harbor_island").
We apply Yog.Pathfinding.shortest_path/1 using Dijkstra's algorithm.
origin = "suburb_north"
destination = "harbor_island"
# 1. Solve for fastest journey
{:ok, fastest_path} = Yog.Pathfinding.shortest_path(in: time_graph, from: origin, to: destination)
# 2. Solve for cheapest journey
{:ok, cheapest_path} = Yog.Pathfinding.shortest_path(in: cost_graph, from: origin, to: destination)
IO.puts("==================================================")
IO.puts("Trip Planning: #{origin} -> #{destination}")
IO.puts("==================================================")
IO.puts("⚡ FASTEST ROUTE: #{fastest_path.weight} minutes")
IO.puts(" Stations: #{Enum.join(fastest_path.nodes, " ➔ ")}")
IO.puts("--------------------------------------------------")
IO.puts("💰 CHEAPEST ROUTE: $#{:erlang.float_to_binary(cheapest_path.weight * 1.0, decimals: 2)}")
IO.puts(" Stations: #{Enum.join(cheapest_path.nodes, " ➔ ")}")
IO.puts("==================================================")
Expected result: fastest and cheapest routes may use different station sequences or different route modes. This demonstrates why preserving parallel edges until the optimization criterion is known matters.
Reconstructing Detailed Leg-by-Leg Itineraries
To display a human-readable itinerary for our passenger, we look up the specific vehicle leg chosen on each segment of the journey:
format_itinerary = fn path_nodes, simple_mg ->
legs =
path_nodes
|> Enum.chunk_every(2, 1, :discard)
|> Enum.map(fn [from, to] ->
route = Yog.Model.edge_data(simple_mg, from, to)
{from, to, route}
end)
total_duration = Enum.sum(Enum.map(legs, fn {_, _, r} -> r.duration end))
total_cost = Enum.sum(Enum.map(legs, fn {_, _, r} -> r.cost end))
{legs, total_duration, total_cost}
end
{fastest_legs, f_dur, f_cost} = format_itinerary.(fastest_path.nodes, fastest_simple_mg)
{cheapest_legs, c_dur, c_cost} = format_itinerary.(cheapest_path.nodes, cheapest_simple_mg)
IO.puts("=== FASTEST ITINERARY DETAILS ===")
Enum.each(fastest_legs, fn {from, to, r} ->
IO.puts(" • [#{String.upcase(r.mode)}] #{r.line}: #{from} -> #{to} (#{r.duration}m, $#{:erlang.float_to_binary(r.cost * 1.0, decimals: 2)})")
end)
IO.puts("Total: #{f_dur} mins, $#{:erlang.float_to_binary(f_cost * 1.0, decimals: 2)}\n")
IO.puts("=== CHEAPEST ITINERARY DETAILS ===")
Enum.each(cheapest_legs, fn {from, to, r} ->
IO.puts(" • [#{String.upcase(r.mode)}] #{r.line}: #{from} -> #{to} (#{r.duration}m, $#{:erlang.float_to_binary(r.cost * 1.0, decimals: 2)})")
end)
IO.puts("Total: #{c_dur} mins, $#{:erlang.float_to_binary(c_cost * 1.0, decimals: 2)}")
Expected result: the itinerary step reconstructs the human-readable route metadata that was selected during multigraph collapse, such as line name, mode, duration, and fare.
Notice the compelling trade-off:
- The Fastest route utilizes high-speed rail lines (Yellow Line to Red Line) into the downtown core, reaching Harbor Island in 41 minutes for $8.75.
- The Cheapest route utilizes a commuter bus and city connections combined with a scenic walking transfer, reaching Harbor Island for only $6.75 at the expense of an extra 17 minutes!
Section 5: Route Visualization & Itinerary Highlighting
Now let's visualize the winning fastest path on the municipal transit diagram. We highlight:
- The active path in bold neon emerald (
#10b981,penwidth: 4.5). - The Origin and Destination stations with distinctive badges.
- All unused background lines dimmed to soft slate gray (
#cbd5e1).
path_edges =
fastest_path.nodes
|> Enum.chunk_every(2, 1, :discard)
|> Enum.reduce(MapSet.new(), fn [u, v], acc ->
acc |> MapSet.put({u, v}) |> MapSet.put({v, u})
end)
path_nodes_set = MapSet.new(fastest_path.nodes)
itinerary_dot_opts = %{
Yog.Render.DOT.default_options()
| rankdir: :lr,
node_shape: :box,
node_style: :filled,
node_color: "#f8fafc",
node_fontname: "Inter,Helvetica,Arial",
node_fontsize: 10,
node_attributes: fn id, name ->
cond do
id == origin ->
[{:fillcolor, "#dcfce7"}, {:color, "#15803d"}, {:penwidth, 3}, {:label, "★ ORIGIN\n#{name}"}]
id == destination ->
[{:fillcolor, "#fee2e2"}, {:color, "#b91c1c"}, {:penwidth, 3}, {:label, "🏁 DESTINATION\n#{name}"}]
MapSet.member?(path_nodes_set, id) ->
[{:fillcolor, "#ecfdf5"}, {:color, "#059669"}, {:penwidth, 2}, {:label, "● #{name}"}]
true ->
[{:fontcolor, "#94a3b8"}, {:color, "#e2e8f0"}, {:fillcolor, "#ffffff"}]
end
end,
edge_attributes: fn u, v, weight ->
if MapSet.member?(path_edges, {u, v}) do
[
{:color, "#059669"},
{:penwidth, 4.0},
{:label, "#{weight} min"},
{:fontcolor, "#059669"},
{:fontsize, 11},
{:style, "solid"}
]
else
[
{:color, "#e2e8f0"},
{:penwidth, 1.0},
{:style, "dotted"},
{:fontcolor, "#cbd5e1"},
{:label, "#{weight}m"}
]
end
end
}
highlighted_dot = Yog.Render.DOT.to_dot(time_graph, itinerary_dot_opts)
Kino.VizJS.render(highlighted_dot)
Section 6: Commuter Isochrone Analysis (Travel Time Reachability)
Urban planners and home seekers frequently rely on isochrones — contours connecting places reachable within equal travel times.
Using Yog.Pathfinding.single_source_distances/1, we can calculate the exact fastest travel time from Central Hub to every other station across the entire metropolitan region.
central_distances = Yog.Pathfinding.single_source_distances(in: time_graph, from: "central")
# Sort stations by commute duration from Central
sorted_isochrones =
central_distances
|> Enum.sort_by(fn {_station, minutes} -> minutes end)
IO.puts("=== Isochrone Reachability from Central Hub ===")
Enum.each(sorted_isochrones, fn {station_id, mins} ->
bar = String.duplicate("■", max(1, div(mins, 2)))
IO.puts("#{String.pad_trailing(station_id, 16)} : #{String.pad_leading("#{mins} min", 7)} | #{bar}")
end)
Classifying Commute Tiers
We group stations into municipal commuter tiers:
- Tier 1 (Inner Core $\le 7$ mins): Immediate downtown connections (Waterfront, Arts District, Uptown).
- Tier 2 (Mid-City $8 - 15$ mins): Rapid transit access (Stadium, Tech Park, University, Medical Center).
- Tier 3 (Regional Outer Belt $> 15$ mins): Airport, Suburbs, Offshore Island.
tier_color = fn minutes ->
cond do
minutes <= 7 -> {"#10b981", "Tier 1: Core (≤7m)"}
minutes <= 15 -> {"#3b82f6", "Tier 2: Mid-City (8-15m)"}
true -> {"#8b5cf6", "Tier 3: Outer (16m+)"}
end
end
isochrone_dot_opts = %{
Yog.Render.DOT.default_options()
| rankdir: :lr,
node_attributes: fn id, name ->
mins = Map.get(central_distances, id, 999)
{color, tier_label} = tier_color.(mins)
if id == "central" do
[{:shape, :doublecircle}, {:fillcolor, "#fef3c7"}, {:color, "#d97706"}, {:penwidth, 3}, {:label, "CENTRAL HUB\n(0 min)"}]
else
[
{:shape, :box},
{:style, "filled,rounded"},
{:fillcolor, "#f8fafc"},
{:color, color},
{:penwidth, 2.5},
{:label, "#{name}\n+#{mins} min (#{tier_label})"}
]
end
end,
edge_attributes: fn _u, _v, weight ->
[{:color, "#cbd5e1"}, {:label, "#{weight}m"}, {:fontsize, 9}]
end
}
isochrone_dot = Yog.Render.DOT.to_dot(time_graph, isochrone_dot_opts)
Kino.VizJS.render(isochrone_dot)
Expected result: stations closer to Central Hub should fall into lower commute tiers, while airport/suburban/island destinations appear in outer travel-time bands.
Section 7: Network Resilience & Service Disruption Detours
Transportation networks must handle unexpected emergencies: track maintenance, signal failures, or power outages.
Simulating a Central Hub Rail Shutdown
Suppose a major electrical breakdown disables Central Hub, completely shutting down all rail and transit connections through the central terminal.
Can a commuter at Northgate Heights ("suburb_north") still reach the International Airport ("airport")?
# Disconnect Central Hub to simulate complete terminal closure
disrupted_graph = Yog.remove_node(time_graph, "central")
IO.puts("Central Hub removed. Remaining stations: #{Yog.order(disrupted_graph)}")
# Attempt to find an alternative detour path
case Yog.Pathfinding.shortest_path(in: disrupted_graph, from: "suburb_north", to: "airport") do
{:ok, detour_path} ->
IO.puts("\n✅ DETOUR ROUTE FOUND!")
IO.puts(" Duration: #{detour_path.weight} minutes")
IO.puts(" Routing: #{Enum.join(detour_path.nodes, " ➔ ")}")
# Compare with normal route through Central
{:ok, normal_path} = Yog.Pathfinding.shortest_path(in: time_graph, from: "suburb_north", to: "airport")
delay = detour_path.weight - normal_path.weight
IO.puts(" Normal duration (via Central): #{normal_path.weight} minutes")
IO.puts(" Disruption Delay Penalty: +#{delay} minutes")
:error ->
IO.puts("\n❌ NO DETOUR AVAILABLE: Network partitioned!")
end
Expected result: if redundancy exists, the system should find a longer detour rather than declaring the trip impossible.
The system automatically discovers an outer ring detour: Northgate Heights $\to$ Civic Stadium (via Crosstown Bus 7) $\to$ Airport (via South Link Bus 33), bypassing the central downtown bottleneck entirely!
# Visualize the detour routing
detour_edges =
case Yog.Pathfinding.shortest_path(in: disrupted_graph, from: "suburb_north", to: "airport") do
{:ok, path} ->
path.nodes
|> Enum.chunk_every(2, 1, :discard)
|> Enum.reduce(MapSet.new(), fn [u, v], acc ->
acc |> MapSet.put({u, v}) |> MapSet.put({v, u})
end)
_ ->
MapSet.new()
end
detour_dot_opts = %{
Yog.Render.DOT.default_options()
| rankdir: :lr,
node_attributes: fn id, name ->
cond do
id == "suburb_north" ->
[{:fillcolor, "#dbeafe"}, {:color, "#1d4ed8"}, {:penwidth, 3}, {:label, "ORIGIN\n#{name}"}]
id == "airport" ->
[{:fillcolor, "#fef3c7"}, {:color, "#d97706"}, {:penwidth, 3}, {:label, "AIRPORT\n#{name}"}]
id in ["stadium"] ->
[{:fillcolor, "#fef08a"}, {:color, "#ca8a04"}, {:penwidth, 2.5}, {:label, "DETOUR TRANSFER\n#{name}"}]
true ->
[{:color, "#94a3b8"}]
end
end,
edge_attributes: fn u, v, weight ->
if MapSet.member?(detour_edges, {u, v}) do
[{:color, "#dc2626"}, {:penwidth, 3.5}, {:label, "DETOUR: #{weight}m"}, {:fontcolor, "#dc2626"}]
else
[{:color, "#cbd5e1"}, {:label, "#{weight}m"}, {:style, "dotted"}]
end
end
}
Kino.VizJS.render(Yog.Render.DOT.to_dot(disrupted_graph, detour_dot_opts))
Try Changing This
- Change
originanddestinationto plan a different trip. - Modify
fastest_route_combinerto penalize walking or transfers instead of using duration alone. - Change
cheapest_route_combinerto prefer accessible routes when costs tie. - Remove a different station in the disruption section and see whether the network remains connected.
- Add a new route to
transit_network.jsonand observe how it affects the fastest, cheapest, and accessible graphs.
Conclusion & Key Takeaways
In this project, we built a full-featured transit optimization and routing engine with Yog:
- Multigraph Modeling: Modeled complex transportation corridors supporting multiple modes (rail, bus, ferry, walking) using
Yog.Multi. - Multi-Criteria Collapsing: Used
Yog.Multi.to_simple_graph/2to transform multigraphs into specialized simple graphs tuned to fastest travel, cheapest budget, or accessible routes. - Dijkstra Pathfinding: Solved optimal multi-leg journeys and generated transparent passenger itineraries with
Yog.Pathfinding.shortest_path/1. - Commuter Isochrones: Mapped urban reachability zones across the metropolitan area using
single_source_distances/1. - Resilience & Fault Tolerance: Verified network redundancy and computed outer-ring detours when critical transit hubs experience outages.