Mix.install([
{:meridian, path: "/home/mafinar/repos/elixir/meridian"},
{:kino, "~> 0.14"},
{:kino_vizjs, "~> 0.8"},
{:kino_maplibre, "~> 0.1"},
{:jason, "~> 1.4"},
{:geohash, "~> 1.3"}
])What is Meridian?
Meridian brings spatial awareness to yog_ex graphs. Every graph carries its coordinate reference system (CRS), nodes can have geometries, and algorithms understand geography.
This Livebook walks through the core API. By the end you'll be able to build, analyze, and visualize spatial graphs in Elixir.
Creating a Spatial Graph
A Meridian.Graph is just a Yog.Graph with extra spatial metadata:
alias Meridian.Graph
graph = Graph.new()By default graphs are directed and use WGS-84 (EPSG:4326). You can override either:
undirected = Graph.new(kind: :undirected, crs: "EPSG:3857")Adding Nodes with Geometry
Nodes are identified by an ID and carry a data map. The :geometry key is special—Meridian uses it for spatial calculations.
graph =
Graph.new()
|> Graph.add_node(:nyc, %{
geometry: %Geo.Point{coordinates: {-74.006, 40.7128}},
name: "New York City"
})
|> Graph.add_node(:la, %{
geometry: %Geo.Point{coordinates: {-118.2437, 34.0522}},
name: "Los Angeles"
})
|> Graph.add_node(:chi, %{
geometry: %Geo.Point{coordinates: {-87.6298, 41.8781}},
name: "Chicago"
})
Graph.node_count(graph)Nodes are Enumerable—iterate over them as {id, data} tuples:
Enum.to_list(graph)Adding Edges
Edges connect nodes. For spatial graphs, the edge weight often represents distance, travel time, or cost.
{:ok, graph} =
graph
|> Graph.add_edge(:nyc, :chi, %{distance_km: 1_145})
|> then(fn {:ok, g} -> Graph.add_edge(g, :chi, :la, %{distance_km: 2_015}) end)
|> then(fn {:ok, g} -> Graph.add_edge(g, :nyc, :la, %{distance_km: 3_944}) end)
Graph.edge_count(graph)Use add_edge_ensure/5 when you want to auto-create missing endpoint nodes:
graph = Graph.add_edge_ensure(graph, :nyc, :bos, %{distance_km: 350}, %{name: "Boston"})
Graph.node_count(graph)Inspecting the Graph
inspect(graph)Coordinate Reference Systems (CRS)
Meridian keeps track of the graph's CRS so you never silently confuse coordinate systems.
Distance between nodes
Meridian.CRS.distance/3 computes the great-circle distance in meters between two nodes that have %Geo.Point{} geometries.
Meridian.CRS.distance(graph, :nyc, :chi)Nodes without point geometries return nil:
plain_graph = Graph.new() |> Graph.add_node(:a, %{foo: 1})
Meridian.CRS.distance(plain_graph, :a, :b)Computing edge weights from geometry
You can automatically replace all edge weights with their geographic distances:
weighted_graph =
Graph.new()
|> Graph.add_node(:a, %{geometry: %Geo.Point{coordinates: {0.0, 0.0}}})
|> Graph.add_node(:b, %{geometry: %Geo.Point{coordinates: {0.0, 1.0}}})
|> Graph.add_edge_ensure(:a, :b, nil)
|> Meridian.CRS.compute_edge_weights()
Graph.edges(weighted_graph)Bounding box
bounded = Graph.recompute_bounds(graph)
Meridian.CRS.bbox(bounded)Geometry Helpers
Meridian.Geometry provides CRS-agnostic geometric operations.
alias Meridian.Geometry
# Euclidean distance
a = %Geo.Point{coordinates: {0.0, 0.0}}
b = %Geo.Point{coordinates: {3.0, 4.0}}
Geometry.euclidean(a, b)# Haversine length of a LineString
line = %Geo.LineString{coordinates: [{0.0, 0.0}, {0.0, 1.0}]}
Geometry.geo_length(line)# Point-in-polygon test
poly = %Geo.Polygon{coordinates: [[{0, 0}, {10, 0}, {10, 10}, {0, 10}, {0, 0}]]}
Geometry.contains?(poly, %Geo.Point{coordinates: {5, 5}})# Centroid
Geometry.centroid(poly)Building Grid Graphs
Geohash Rectangular Grid
Requires the optional :geohash dependency.
geohash_graph =
Graph.new(kind: :undirected)
|> Meridian.Builder.Geohash.grid(
sw: {37.7, -122.5},
ne: {37.8, -122.4},
precision: 5,
topology: :rook
)
Graph.node_count(geohash_graph)GeoJSON I/O
Ingesting GeoJSON
Requires the optional :jason dependency.
geojson = ~s|{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"LineString","coordinates":[[0,0],[0,1]]},"properties":{"name":"road"}}]}|
{:ok, road_graph} = Meridian.IO.GeoJSON.from_string(geojson)
Graph.node_count(road_graph)Rendering to GeoJSON
json = Meridian.Render.GeoJSON.to_string(graph, include_edges: true)
Kino.Markdown.new("```json\n#{json}\n```")Map Visualization
Meridian.Render.MapLibre renders a graph as an interactive MapLibre map inside Livebook — vector tiles, smooth zooming, and full style control.
map_graph =
Graph.new()
|> Graph.add_node(:nyc, %{
geometry: %Geo.Point{coordinates: {-74.006, 40.7128}},
name: "New York City"
})
|> Graph.add_node(:bos, %{
geometry: %Geo.Point{coordinates: {-71.0589, 42.3601}},
name: "Boston"
})
|> Graph.add_node(:dc, %{
geometry: %Geo.Point{coordinates: {-77.0369, 38.9072}},
name: "Washington D.C."
})
#|> Graph.add_edge_ensure(:nyc, :bos, %{distance_km: 350})
|> Graph.add_edge_ensure(:bos, :dc, %{distance_km: 700})
|> Graph.add_edge_ensure(:dc, :nyc, %{distance_km: 360})
Meridian.Render.MapLibre.new(map_graph)With custom styling:
Meridian.Render.MapLibre.new(map_graph,
style: :default,
zoom: 6,
node_color: "#e74c3c",
edge_color: "#3498db",
node_radius: 10,
edge_width: 3
)The map auto-centers on the graph's node centroid. Nodes render as circles and edges as lines.
Note on styles:
:defaultuses MapLibre's free demo tiles and works out of the box.:streetand:terrainrequire your own MapTiler API key — pass it asstyle: :street, key: "your-key". Without a key these styles will raise an error.
You can also use MapLibre's event API to add markers, controls, or fly-to animations dynamically.
Spatial Pathfinding
Meridian wraps yog_ex pathfinding with geographic heuristics.
path_graph =
Graph.new()
|> Graph.add_node(:a, %{geometry: %Geo.Point{coordinates: {0.0, 0.0}}})
|> Graph.add_node(:b, %{geometry: %Geo.Point{coordinates: {0.0, 1.0}}})
|> Graph.add_node(:c, %{geometry: %Geo.Point{coordinates: {1.0, 1.0}}})
|> Graph.add_edge_ensure(:a, :b, 100.0)
|> Graph.add_edge_ensure(:b, :c, 100.0)
|> Graph.add_edge_ensure(:a, :c, 500.0)
{:ok, path} = Meridian.Pathfinding.a_star(path_graph, from: :a, to: :c)
pathThe A* heuristic is haversine distance, so the search is naturally pulled toward the goal in geographic space.
Merging Graphs
You can merge two spatial graphs, but only if they share the same CRS:
a = Graph.new() |> Graph.add_node(1, %{name: "A"})
b = Graph.new() |> Graph.add_node(2, %{name: "B"}) |> Graph.add_edge_ensure(2, 1, 5)
merged = Graph.merge(a, b)
{Graph.node_count(merged), Graph.edge_count(merged)}Mismatched CRS raises an error:
c = Graph.new(crs: "EPSG:3857")
# This would raise:
# Graph.merge(a, c)Visualizing with DOT
If you have kino_vizjs installed, you can render the graph topology as a DOT diagram:
graph
|> Meridian.Graph.to_yog()
|> Yog.Render.DOT.to_dot()
|> Kino.VizJS.render(engine: "neato")Summary
| Concept | Module | Key Function |
|---|---|---|
| Create graph | Meridian.Graph | Graph.new/1 |
| Add node | Meridian.Graph | Graph.add_node/3 |
| Distance | Meridian.CRS | CRS.distance/3 |
| Edge weights | Meridian.CRS | CRS.compute_edge_weights/2 |
| Geohash grid | Meridian.Builder.Geohash | Geohash.grid/2 |
| GeoJSON in | Meridian.IO.GeoJSON | GeoJSON.from_string/2 |
| GeoJSON out | Meridian.Render.GeoJSON | GeoJSON.to_string/2 |
| Map render | Meridian.Render.MapLibre | MapLibre.new/2 |
| Pathfinding | Meridian.Pathfinding | Pathfinding.a_star/2 |
What's Next?
- OSM ingestion — scrape real street networks from OpenStreetMap
- Spatial queries —
within,nearest,network_buffer - Map rendering — interactive Leaflet maps inside Livebook ✓
- Real reprojection — transform coordinates between CRS using PROJ
See ROADMAP.md for the full plan.