Getting Started with Meridian

Copy Markdown View Source
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: :default uses MapLibre's free demo tiles and works out of the box. :street and :terrain require your own MapTiler API key — pass it as style: :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)
path

The 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

ConceptModuleKey Function
Create graphMeridian.GraphGraph.new/1
Add nodeMeridian.GraphGraph.add_node/3
DistanceMeridian.CRSCRS.distance/3
Edge weightsMeridian.CRSCRS.compute_edge_weights/2
Geohash gridMeridian.Builder.GeohashGeohash.grid/2
GeoJSON inMeridian.IO.GeoJSONGeoJSON.from_string/2
GeoJSON outMeridian.Render.GeoJSONGeoJSON.to_string/2
Map renderMeridian.Render.MapLibreMapLibre.new/2
PathfindingMeridian.PathfindingPathfinding.a_star/2

What's Next?

  • OSM ingestion — scrape real street networks from OpenStreetMap
  • Spatial querieswithin, 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.