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:
# Leafletnpm install @sigma/layer-leaflet leaflet
# MapLibrenpm install @sigma/layer-maplibre maplibre-glLeaflet
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: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <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 GeoJSONconst 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 mapL.geoJSON(channelTunnelGeoJSON).addTo(map);
// Click on the stage to place a markerlet 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 GeoJSONconst 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 loadsmap.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.