OpenStreetMap Ingestion, Pathfinding, and Visualization with Meridian
Mix.install([
{:meridian, path: "/home/mafinar/repos/elixir/meridian"},
{:kino, "~> 0.14"},
{:kino_maplibre, "~> 0.1"},
{:jason, "~> 1.4"},
{:req, "~> 0.5"},
{:pbf_parser, "~> 0.1.2"},
{:zog, path: "/home/mafinar/repos/elixir/zog", override: true},
{:zigler, "~> 0.16.0", override: true}
])
Introduction
This notebook demonstrates how to:
-
Ingest real-world road networks directly from OpenStreetMap (OSM) using the Overpass API or local
.osm.pbffiles. - Build a spatially-aware routing graph using Meridian.
- Render the network as an interactive MapLibre wiregraph.
- Calculate shortest paths using Zog-accelerated pathfinding algorithms and overlay routes.
1. Querying OSM via Overpass API
We’ll query a small bounding box in downtown Toronto (around the CN Tower and Rogers Centre) to retrieve road network data.
# Define bounding box coordinates (south-west and north-east corners)
# Format: {latitude, longitude}
sw = {43.6416, -79.3950}
ne = {43.6496, -79.3810}
# Fetch the road network and build a Meridian.Graph
{:ok, graph} =
Meridian.Builder.OSM.from_bbox(
sw: sw,
ne: ne,
highway: ["primary", "secondary", "tertiary", "residential", "motorway", "trunk", "unclassified"],
oneway_as_directed: true
)
# Inspect the resulting graph
inspect(graph)
Let’s look at the counts of parsed nodes and edges:
IO.puts("Nodes (intersections): #{Meridian.Graph.node_count(graph)}")
IO.puts("Edges (road segments): #{Meridian.Graph.edge_count(graph)}")
2. Interactive Wiregraph Visualization
Meridian integrates with MapLibre to draw spatial graphs on top of interactive map tiles. The nodes are represented as points, and edges are drawn as lines linking the intersections.
# Render the graph as an interactive wiregraph layer
Meridian.Render.MapLibre.new(graph,
style: :default,
zoom: 15,
node_color: "#ff3838", # Vibrant red for intersections
node_radius: 5,
edge_color: "#17c0eb", # Sleek cyan for road segments
edge_width: 2
)
3. PBF Ingestion
If you have a local .osm.pbf file (e.g., downloaded from BBBike or Geofabrik), you can load it directly into a Meridian.Graph without using the internet/Overpass API:
# Example syntax:
{:ok, graph} = Meridian.Builder.OSM.from_pbf("/home/mafinar/Downloads/Toronto.osm.pbf")
The ingestion process runs fully in parallel using all available CPU cores, and filters out unused nodes to run 6x faster and use 95% less memory.
Direct Ingestion to Native ResourceGraph
For maximum performance, you can bypass the creation of the Elixir Meridian.Graph entirely and load the PBF data directly into a native Zog.ResourceGraph NIF resource by passing output: :resource_graph:
{:ok, %{graph: zog_graph, x_coords: x_coords, y_coords: y_coords}} =
Meridian.Builder.OSM.from_pbf("/home/mafinar/Downloads/Toronto.osm.pbf", output: :resource_graph)
This returns the native NIF-backed graph resource along with the x_coords and y_coords maps needed for native pathfinding.
node_ids = Map.keys(Meridian.Graph.nodes(graph))
start_node = Enum.at(node_ids, 0)
goal_node = Enum.at(node_ids, min(100000, length(node_ids) - 1))
{time_us, {:ok, path}} = :timer.tc(fn ->
Meridian.Pathfinding.a_star(graph, from: start_node, to: goal_node)
end)
IO.puts("Shortest route of #{length(path.nodes)} segments found in #{time_us /
1000} ms!")
# 1. Compute weights and convert to Zog ONCE
graph_with_weights = Meridian.CRS.compute_edge_weights(graph)
zog_graph = Zog.from_graph(graph_with_weights.graph)
# 2. Extract coordinate maps ONCE
{x_coords, y_coords} =
Enum.reduce(graph.graph.nodes, {%{}, %{}}, fn {id, data}, {xs, ys} ->
case data do
%{geometry: %Geo.Point{coordinates: {lon, lat}}} ->
{Map.put(xs, id, lon), Map.put(ys, id, lat)}
_ -> {xs, ys}
end
end)
# 3. Time ONLY the search (calling the Zog NIF directly!)
{search_time_us, {:ok, {route, weight}}} = :timer.tc(fn ->
Zog.Pathfinding.astar(zog_graph, start_node, goal_node, x_coords, y_coords,
:euclidean)
end)
IO.puts("Pure native search completed in #{search_time_us / 1000} ms!")
4. Zog-Accelerated Pathfinding
Meridian automatically leverages the NIF-backed Zog engine to run high-performance A* and Dijkstra pathfinding algorithms at native machine speeds.
Let’s find the shortest path between two random intersections in our graph:
# Pick two node IDs from the graph
node_ids = Map.keys(Meridian.Graph.nodes(graph))
start_node = Enum.at(node_ids, 0)
goal_node = Enum.at(node_ids, min(50, length(node_ids) - 1))
IO.puts("Routing from intersection #{start_node} to #{goal_node}...")
# Run accelerated A* pathfinding
{:ok, path} =
Meridian.Pathfinding.a_star(graph,
from: start_node,
to: goal_node
)
# Inspect the result
IO.inspect(path)
route_coords =
Enum.map(path.nodes, fn node_id ->
case Meridian.Graph.node(graph, node_id) do
%{geometry: %Geo.Point{coordinates: {lon, lat}}} -> [lon, lat]
_ -> nil
end
end)
|> Enum.reject(&is_nil/1)
# 2. Build GeoJSON for ONLY the route
route_geojson = %{
"type" => "FeatureCollection",
"features" => [
%{
"type" => "Feature",
"geometry" => %{
"type" => "LineString",
"coordinates" => route_coords
},
"properties" => %{}
}
]
}
# 3. Center the map on the start of the route
center =
case route_coords do
[[lon, lat] | _] -> {lon, lat}
_ -> {-79.3871, 43.6426}
end
# 4. Create a clean MapLibre spec containing only the route
ml =
MapLibre.new(center: center, zoom: 12, style: :default)
|> MapLibre.add_source("route", type: :geojson, data: route_geojson)
|> MapLibre.add_layer(
id: "route-line",
type: :line,
source: "route",
paint: [
line_color: "#ff3838", # Bright red route line
line_width: 5
],
layout: [
line_join: "round",
line_cap: "round"
]
)
# 5. Display the map
Kino.MapLibre.new(ml)
5. Overlaying a Route on the Map
We can highlight the calculated shortest path route on top of our road network map by adding a custom GeoJSON LineString layer in MapLibre:
# Convert the route node IDs into coordinates
route_coords =
Enum.map(path.nodes, fn node_id ->
case Meridian.Graph.node(graph, node_id) do
%{geometry: %Geo.Point{coordinates: {lon, lat}}} -> [lon, lat]
_ -> nil
end
end)
|> Enum.reject(&is_nil/1)
# Build a GeoJSON source for the route line
route_geojson = %{
"type" => "FeatureCollection",
"features" => [
%{
"type" => "Feature",
"geometry" => %{
"type" => "LineString",
"coordinates" => route_coords
},
"properties" => %{}
}
]
}
# Determine map center dynamically from route
center =
case route_coords do
[[lon, lat] | _] -> {lon, lat}
_ -> {-79.3871, 43.6426}
end
# To prevent browser freezes, we check the graph size before rendering the full wiregraph.
# If the graph has more than 10,000 edges, rendering the full wiregraph in the browser
# will cause it to freeze/take a long time. So we render ONLY the route instead.
if Meridian.Graph.edge_count(graph) > 10000 do
IO.puts("⚠️ The graph has #{Meridian.Graph.edge_count(graph)} edges, which is too large to render the entire network wiregraph in the browser without freezing. Rendering ONLY the highlighted route instead.")
ml =
MapLibre.new(center: center, zoom: 12, style: :default)
|> MapLibre.add_source("route", type: :geojson, data: route_geojson)
|> MapLibre.add_layer(
id: "route-line",
type: :line,
source: "route",
paint: [line_color: "#ff3838", line_width: 5, line_opacity: 0.85],
layout: [line_join: "round", line_cap: "round"]
)
Kino.MapLibre.new(ml)
else
# Convert the base graph to GeoJSON
geojson =
graph
|> Meridian.Render.GeoJSON.to_string()
|> Jason.decode!()
# Build the MapLibre spec with graph and highlighted route line
ml =
MapLibre.new(center: center, zoom: 15)
|> MapLibre.add_source("graph", type: :geojson, data: geojson)
# Base road segments
|> MapLibre.add_layer(
id: "graph-edges",
type: :line,
source: "graph",
filter: ["==", ["geometry-type"], "LineString"],
paint: [line_color: "#7f8c8d", line_width: 1.5]
)
# Intersections
|> MapLibre.add_layer(
id: "graph-nodes",
type: :circle,
source: "graph",
filter: ["==", ["geometry-type"], "Point"],
paint: [circle_color: "#7f8c8d", circle_radius: 3]
)
# Add route source
|> MapLibre.add_source("route", type: :geojson, data: route_geojson)
# Highlighted route layer on top
|> MapLibre.add_layer(
id: "route-line",
type: :line,
source: "route",
paint: [line_color: "#ff3838", line_width: 5, line_opacity: 0.85],
layout: [line_join: "round", line_cap: "round"]
)
Kino.MapLibre.new(ml)
end