Skip to content
This is the alpha v4 version website. Looking for the v3 documentation?

Geographic map layers

Sigma.js can render graphs on top of geographic maps using the @sigma/layer-leaflet or @sigma/layer-maplibre packages. Both work the same way: they synchronize a map layer with sigma’s camera so that nodes are positioned by latitude and longitude.

Installation

Pick the package that matches your preferred map provider:

Terminal window
# Leaflet
npm install @sigma/layer-leaflet leaflet
# MapLibre
npm install @sigma/layer-maplibre maplibre-gl

Leaflet

Nodes positioned by latitude/longitude on an OpenStreetMap base layer:

import bindLeafletLayer from "@sigma/layer-leaflet";
import Graph from "graphology";
import { nodeExtent } from "graphology-metrics/graph";
import type { Attributes, SerializedGraph } from "graphology-types";
import Sigma from "sigma";
import { DEFAULT_STYLES } from "sigma/types";
const container = document.getElementById("sigma-container") as HTMLElement;
const res = await fetch("/data/airports.json");
const data = await res.json();
const graph = Graph.from(data as SerializedGraph);
graph.updateEachNodeAttributes((node, attributes) => ({
...attributes,
label: attributes.fullName,
degree: graph.degree(node),
x: 0,
y: 0,
}));
const [minDegree, maxDegree] = nodeExtent(graph, "degree");
const renderer = new Sigma(graph, container, {
settings: {
labelRenderedSizeThreshold: 20,
minEdgeThickness: 1,
},
styles: {
edges: [DEFAULT_STYLES.edges, { color: "#ffaeaf", size: 0.0001 }],
nodes: [
DEFAULT_STYLES.nodes,
{
color: "#e22352",
size: {
attribute: "degree",
min: 0.001,
max: 0.005,
minValue: minDegree,
maxValue: maxDegree,
},
},
],
},
});
bindLeafletLayer(renderer, {
getNodeLatLng: (attrs: Attributes) => ({ lat: attrs.latitude, lng: attrs.longitude }),
});

Set x: 0, y: 0 on every node. The map layer overwrites these coordinates with projected positions. If you omit getNodeLatLng, the layer reads lat and lng directly from node attributes.

MapLibre

The same concept using MapLibre GL as the map provider. The mapOptions object is passed directly to the MapLibre Map constructor:

import bindMaplibreLayer from "@sigma/layer-maplibre";
import Graph from "graphology";
import { nodeExtent } from "graphology-metrics/graph";
import type { Attributes, SerializedGraph } from "graphology-types";
import Sigma from "sigma";
import { DEFAULT_STYLES } from "sigma/types";
const container = document.getElementById("sigma-container") as HTMLElement;
const res = await fetch("/data/airports.json");
const data = await res.json();
const graph = Graph.from(data as SerializedGraph);
graph.updateEachNodeAttributes((node, attributes) => ({
...attributes,
label: attributes.fullName,
degree: graph.degree(node),
x: 0,
y: 0,
}));
const [minDegree, maxDegree] = nodeExtent(graph, "degree");
const renderer = new Sigma(graph, container, {
settings: {
labelRenderedSizeThreshold: 20,
minEdgeThickness: 1,
},
styles: {
edges: [DEFAULT_STYLES.edges, { color: "#ffaeaf", size: 0.0001 }],
nodes: [
DEFAULT_STYLES.nodes,
{
color: "#e22352",
size: {
attribute: "degree",
min: 0.001,
max: 0.005,
minValue: minDegree,
maxValue: maxDegree,
},
},
],
},
});
bindMaplibreLayer(renderer, {
getNodeLatLng: (attrs: Attributes) => ({ lat: attrs.latitude, lng: attrs.longitude }),
});

Custom map styles

Both map layers support custom styling. For Leaflet, you can provide a different tile source via the tileLayer option:

import bindLeafletLayer from "@sigma/layer-leaflet";
import Graph from "graphology";
import { nodeExtent } from "graphology-metrics/graph";
import type { Attributes, SerializedGraph } from "graphology-types";
import Sigma from "sigma";
import { DEFAULT_STYLES } from "sigma/types";
const container = document.getElementById("sigma-container") as HTMLElement;
const res = await fetch("/data/airports.json");
const data = await res.json();
const graph = Graph.from(data as SerializedGraph);
graph.updateEachNodeAttributes((node, attributes) => ({
...attributes,
label: attributes.fullName,
degree: graph.degree(node),
x: 0,
y: 0,
}));
const [minDegree, maxDegree] = nodeExtent(graph, "degree");
const renderer = new Sigma(graph, container, {
settings: {
labelRenderedSizeThreshold: 20,
minEdgeThickness: 1,
},
styles: {
edges: [DEFAULT_STYLES.edges, { color: "#ffaeaf", size: 0.0001 }],
nodes: [
DEFAULT_STYLES.nodes,
{
color: "#e22352",
size: {
attribute: "degree",
min: 0.001,
max: 0.005,
minValue: minDegree,
maxValue: maxDegree,
},
},
],
},
});
bindLeafletLayer(renderer, {
tileLayer: {
urlTemplate: "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png",
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>',
},
getNodeLatLng: (attrs: Attributes) => ({ lat: attrs.latitude, lng: attrs.longitude }),
});

For MapLibre, pass a custom style object through mapOptions:

import bindMaplibreLayer from "@sigma/layer-maplibre";
import Graph from "graphology";
import { nodeExtent } from "graphology-metrics/graph";
import type { Attributes, SerializedGraph } from "graphology-types";
import type { StyleSpecification } from "maplibre-gl";
import Sigma from "sigma";
import { DEFAULT_STYLES } from "sigma/types";
const mapStyle: StyleSpecification = {
version: 8,
name: "Custom styles",
sources: {
demotiles: {
type: "vector",
url: "https://demotiles.maplibre.org/tiles/tiles.json",
},
},
glyphs: "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf",
layers: [
{
id: "background",
type: "background",
paint: { "background-color": "#f5f5f5" },
},
{
id: "countries-fill",
type: "fill",
source: "demotiles",
"source-layer": "countries",
paint: { "fill-color": "#ffffff" },
},
{
id: "countries-boundary",
type: "line",
source: "demotiles",
"source-layer": "countries",
paint: { "line-color": "#dee2e6", "line-width": 1 },
},
{
id: "geolines",
type: "line",
source: "demotiles",
"source-layer": "geolines",
paint: { "line-color": "#dee2e6", "line-width": 1.5 },
},
{
id: "country-labels",
type: "symbol",
source: "demotiles",
"source-layer": "centroids",
layout: {
"text-field": "{NAME}",
"text-font": ["Open Sans Semibold"],
"text-size": 12,
},
paint: {
"text-color": "#adb5bd",
"text-halo-color": "#ffffff",
"text-halo-width": 1.5,
},
},
],
};
const container = document.getElementById("sigma-container") as HTMLElement;
const res = await fetch("/data/airports.json");
const data = await res.json();
const graph = Graph.from(data as SerializedGraph);
graph.updateEachNodeAttributes((node, attributes) => ({
...attributes,
label: attributes.fullName,
degree: graph.degree(node),
x: 0,
y: 0,
}));
const [minDegree, maxDegree] = nodeExtent(graph, "degree");
const renderer = new Sigma(graph, container, {
settings: {
labelRenderedSizeThreshold: 20,
minEdgeThickness: 1,
},
styles: {
edges: [DEFAULT_STYLES.edges, { color: "#ffaeaf", size: 0.0001 }],
nodes: [
DEFAULT_STYLES.nodes,
{
color: "#e22352",
size: {
attribute: "degree",
min: 0.001,
max: 0.005,
minValue: minDegree,
maxValue: maxDegree,
},
},
],
},
});
bindMaplibreLayer(renderer, {
mapOptions: { style: mapStyle },
getNodeLatLng: (attrs: Attributes) => ({ lat: attrs.latitude, lng: attrs.longitude }),
});

GeoJSON overlays

Combining graph nodes with GeoJSON polygon overlays:

import bindLeafletLayer, { graphToLatlng } from "@sigma/layer-leaflet";
import type { FeatureCollection } from "geojson";
import Graph from "graphology";
import L from "leaflet";
import Sigma from "sigma";
import { DEFAULT_STYLES } from "sigma/types";
const container = document.getElementById("sigma-container") as HTMLElement;
const graph = new Graph();
graph.addNode("paris", { x: 0, y: 0, lat: 48.8566, lng: 2.3522, label: "Paris" });
graph.addNode("london", { x: 0, y: 0, lat: 51.5074, lng: -0.1278, label: "London" });
graph.addEdge("paris", "london");
const renderer = new Sigma(graph, container, {
settings: {
labelRenderedSizeThreshold: 0,
},
styles: {
nodes: [DEFAULT_STYLES.nodes, { color: "black", size: 0.004 }],
edges: [DEFAULT_STYLES.edges, { color: "black", size: 0.001 }],
},
});
const { map } = bindLeafletLayer(renderer);
// Approximate Channel Tunnel route as inline GeoJSON
const channelTunnelGeoJSON: FeatureCollection = {
type: "FeatureCollection",
features: [
{
type: "Feature",
geometry: {
type: "LineString",
coordinates: [
[1.8, 50.93],
[1.6, 50.95],
[1.4, 50.97],
[1.3, 50.98],
[1.2, 50.99],
[1.1, 51.0],
[1.0, 51.01],
[0.9, 51.02],
],
},
properties: { name: "Channel Tunnel (approximate)" },
},
],
};
// Add GeoJSON layer to the map
L.geoJSON(channelTunnelGeoJSON).addTo(map);
// Click on the stage to place a marker
let markerOnClick: null | L.Marker = null;
renderer.on("clickStage", (e) => {
const graphCoords = renderer.viewportToGraph({ x: e.event.x, y: e.event.y });
const geoCoords = graphToLatlng(graphCoords);
if (markerOnClick) markerOnClick.remove();
markerOnClick = L.marker(geoCoords);
markerOnClick.addTo(map);
});
import bindMaplibreLayer, { graphToLatlng } from "@sigma/layer-maplibre";
import Graph from "graphology";
import { Marker } from "maplibre-gl";
import Sigma from "sigma";
import { DEFAULT_STYLES } from "sigma/types";
import type { FeatureCollection } from "geojson";
const container = document.getElementById("sigma-container") as HTMLElement;
const graph = new Graph();
graph.addNode("paris", { x: 0, y: 0, lat: 48.8566, lng: 2.3522, label: "Paris" });
graph.addNode("london", { x: 0, y: 0, lat: 51.5074, lng: -0.1278, label: "London" });
graph.addEdge("paris", "london");
// Approximate Channel Tunnel route as inline GeoJSON
const channelTunnelGeoJSON: FeatureCollection = {
type: "FeatureCollection",
features: [
{
type: "Feature",
geometry: {
type: "LineString",
coordinates: [
[1.8, 50.93],
[1.6, 50.95],
[1.4, 50.97],
[1.3, 50.98],
[1.2, 50.99],
[1.1, 51.0],
[1.0, 51.01],
[0.9, 51.02],
],
},
properties: { name: "Channel Tunnel (approximate)" },
},
],
};
const renderer = new Sigma(graph, container, {
settings: {
labelRenderedSizeThreshold: 0,
},
styles: {
nodes: [DEFAULT_STYLES.nodes, { color: "black", size: 0.004 }],
edges: [DEFAULT_STYLES.edges, { color: "black", size: 0.001 }],
},
});
const { map } = bindMaplibreLayer(renderer, {
getNodeLatLng: (attrs) => ({ lat: attrs.lat, lng: attrs.lng }),
});
// Add GeoJSON layer and click-to-marker after map loads
map.once("load", () => {
map.addSource("channel-tunnel", { type: "geojson", data: channelTunnelGeoJSON }).addLayer({
id: "channel-tunnel-line",
type: "line",
source: "channel-tunnel",
paint: {
"line-color": "red",
"line-width": 3,
"line-dasharray": [2, 2],
},
});
// Click on the stage to place a marker
let markerOnClick: null | Marker = null;
renderer.on("clickStage", (e) => {
const graphCoords = renderer.viewportToGraph({ x: e.event.x, y: e.event.y });
const geoCoords = graphToLatlng(graphCoords);
if (!markerOnClick) {
markerOnClick = new Marker();
markerOnClick.setLngLat(geoCoords);
markerOnClick.addTo(map);
} else {
markerOnClick.setLngLat(geoCoords);
}
});
});

Cleanup

Both bindLeafletLayer and bindMaplibreLayer return a clean function that removes the map layer and restores sigma’s original settings:

const { clean } = bindLeafletLayer(renderer, {
/* ... */
});
// Later, to remove the map:
clean();

Key constraints

  • Camera rotation is disabled while the map layer is active.
  • Stage padding is set to 0 so that sigma and the map share the same bounding box.
  • Both constraints are restored when you call clean().

Precision at high zoom levels

The map layers use a fixed Web Mercator coordinate space covering the entire world. When your graph nodes are clustered in a small geographic area and you zoom in deeply, the WebGL shaders may produce visual artifacts due to float32 precision limits. Nodes and edges can then appear to jitter or misalign.

This affects both @sigma/layer-leaflet and @sigma/layer-maplibre.

To minimize precision issues, design your graph so that nodes span a reasonably large portion of the world map rather than being concentrated in a very small area. For graphs that cover a city block or less, consider whether a geographic map layer is the right fit.