Skip to content

Commit

Permalink
[@sigma/layer-leaflet] Adding leaflet map in graph background
Browse files Browse the repository at this point in the history
* Update the sigma's graph with the projected lat/lng coordinates
* Sync the sigma & leaflet view box
* Add limits on sigma zoom ratio to not be outside the leaflet
  capabilities
  • Loading branch information
sim51 committed Jul 19, 2024
1 parent 3394765 commit 09bc389
Show file tree
Hide file tree
Showing 16 changed files with 323,015 additions and 3 deletions.
54 changes: 52 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"scripts": {
"clean": "npm exec --workspaces -- npx rimraf node_modules && npx rimraf node_modules",
"build": "preconstruct build && npm run build-bundle --workspace=sigma",
"preconstruct": "preconstruct",
"prettify": "prettier --write .",
"lint": "eslint .",
"test": "npm run test --workspace=@sigma/test",
Expand Down Expand Up @@ -40,7 +41,8 @@
"packages/node-border",
"packages/node-image",
"packages/node-piechart",
"packages/edge-curve"
"packages/edge-curve",
"packages/layer-leaflet"
],
"exports": {
"importConditionDefaultExport": "default"
Expand Down
2 changes: 2 additions & 0 deletions packages/layer-leaflet/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules
dist
4 changes: 4 additions & 0 deletions packages/layer-leaflet/.npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.gitignore
node_modules
src
tsconfig.json
26 changes: 26 additions & 0 deletions packages/layer-leaflet/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Sigma.js Leaflet background layer

This package contains a leaflet backgournd layer for [sigma.js](https://sigmajs.org).

It displays a map on the graph's background and handle the camera synchronisation.

## How to use

First you need to install [leaflet](https://leafletjs.com/) in your application.
You can check this [page](https://leafletjs.com/download.html) to see how to do it.

Then, within your application that uses sigma.js, you can use [`@sigma/layer-leaflet`](https://www.npmjs.com/package/@sigma/layer-leaflet) as following:

```typescript
import bindLeafletLayer from "@sigma/layer-leaflet";

const graph = new Graph();
graph.addNode("nantes", { x: 0, y: 0, lat:47.2308, lng:1.5566, size: 10, label: "nantes" });
graph.addNode("paris", { x: 0, y: 0, lat: 48.8567, lng:2.3510, size: 10, label: "Paris" });
graph.addEdge("nantes", "paris");

const sigma = new Sigma(graph, container);
bindLeafletLayer(sigma);
```

Please check the related [Storybook](https://github.com/jacomyal/sigma.js/tree/main/packages/storybook/stories/layer-leaflet) for more advanced examples.
44 changes: 44 additions & 0 deletions packages/layer-leaflet/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
"name": "@sigma/layer-leaflet",
"version": "3.0.0-beta.13",
"description": "A plugin to set a geographical map in background",
"main": "dist/sigma-layer-leaflet.cjs.js",
"module": "dist/sigma-layer-leaflet.esm.js",
"types": "dist/sigma-layer-leaflet.cjs.d.ts",
"files": [
"/dist"
],
"sideEffects": false,
"homepage": "https://www.sigmajs.org",
"bugs": "http://github.com/jacomyal/sigma.js/issues",
"repository": {
"type": "git",
"url": "http://github.com/jacomyal/sigma.js.git"
},
"keywords": [
"graph",
"graphology",
"sigma"
],
"license": "MIT",
"preconstruct": {
"entrypoints": [
"index.ts"
]
},
"peerDependencies": {
"leaflet": "^1.9.4",
"sigma": ">=3.0.0-beta.10"
},
"exports": {
".": {
"module": "./dist/sigma-layer-leaflet.esm.js",
"import": "./dist/sigma-layer-leaflet.cjs.mjs",
"default": "./dist/sigma-layer-leaflet.cjs.js"
},
"./package.json": "./package.json"
},
"devDependencies": {
"@types/leaflet": "^1.9.12"
}
}
112 changes: 112 additions & 0 deletions packages/layer-leaflet/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import Graph from "graphology";
import { Attributes } from "graphology-types";
import L from "leaflet";
import { Sigma } from "sigma";
import { DEFAULT_SETTINGS } from "sigma/settings";

import { graphToLatlng, latlngToGraph, setSigmaRatioBounds, syncLeafletBboxWithGraph } from "./utils";

/**
* On the graph, we store the 2D projection of the geographical lat/long.
*/
export default function bindLeafletLayer(
sigma: Sigma,
opts?: {
tileLayer?: { urlTemplate: string; attribution?: string };
getNodeLatLng?: (nodeAttributes: Attributes) => { lat: number; lng: number };
},
) {
// Creating map container for leaflet
const mapContainer = document.createElement("div");
const mapContainerId = `${sigma.getContainer().id}-map`;
mapContainer.setAttribute("id", mapContainerId);
mapContainer.setAttribute("style", "position: relative; top:0; left:0; width: 100%; height:100%; z-index:-1");
sigma.getContainer().appendChild(mapContainer);

// Initialize the map
const map = L.map(mapContainerId, {
zoomControl: false,
zoomSnap: 0,
zoom: 0,
// we force the maxZoom with a higher tile value so leaflet function are not stucks
// in a restricted area. It avoids side effect
maxZoom: 20,
}).setView([0, 0], 0);
let tileUrl = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png";
let tileAttribution: string | undefined =
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
if (opts?.tileLayer) {
tileUrl = opts.tileLayer.urlTemplate;
tileAttribution = opts.tileLayer.attribution;
}
L.tileLayer(tileUrl, { attribution: tileAttribution }).addTo(map);

// `stagePadding: 0` is mandatory, so the bbox of the map & Sigma is the same.
sigma.setSetting("stagePadding", 0);

// Function that change the given graph by generating the sigma x,y coords by taking the geo coordinates
// and project them in the 2D space of the map
function updateGraphCoordinates(graph: Graph) {
graph.updateEachNodeAttributes((_node, attrs) => {
const coords = latlngToGraph(
map,
opts?.getNodeLatLng ? opts.getNodeLatLng(attrs) : { lat: attrs.lat, lng: attrs.lng },
);
return {
...attrs,
x: coords.x,
y: coords.y,
};
});
}

// Function that do sync sigma->leaflet
function fnSyncLeaflet(animate = false) {
syncLeafletBboxWithGraph(sigma, map, animate);
}

// When sigma is resize, we need to update the graph coordinate (the ref has changed)
// and recompute the zoom bounds
function fnOnResize() {
updateGraphCoordinates(sigma.getGraph());
setSigmaRatioBounds(sigma, map);
}

// Clean up function to remove everything
function clean() {
map.remove();
mapContainer.remove();
sigma.off("afterRender", fnSyncLeaflet);
sigma.off("resize", fnOnResize);
sigma.setSetting("stagePadding", DEFAULT_SETTINGS.stagePadding);
}

// WHen the map is ready
map.whenReady(() => {
// Update the sigma graph for geopspatial coords
updateGraphCoordinates(sigma.getGraph());

// Do the first sync
fnSyncLeaflet();

// Compute sigma ratio bounds
map.once("moveend", () => {
setSigmaRatioBounds(sigma, map);
});

// At each render of sigma, we do the leaflet sync
sigma.on("afterRender", fnSyncLeaflet);
// Listen on resize
sigma.on("resize", fnOnResize);
// Do the cleanup
sigma.on("kill", clean);
});

return {
clean,
map,
updateGraphCoordinates,
};
}

export { graphToLatlng, latlngToGraph };
77 changes: 77 additions & 0 deletions packages/layer-leaflet/src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { LatLngBounds, Map } from "leaflet";
import { Sigma } from "sigma";

export const LEAFLET_MAX_PIXEL = 256 * 2 ** 18;

/**
* Given a geo point returns its graph coords.
*/
export function latlngToGraph(map: Map, coord: { lat: number; lng: number }): { x: number; y: number } {
const data = map.project({ lat: coord.lat, lng: coord.lng }, 0);
return {
x: data.x,
// Y are reversed between geo / sigma
y: map.getContainer().clientHeight - data.y,
};
}

/**
* Given a graph coords returns it's lat/lng coords.
*/
export function graphToLatlng(map: Map, coords: { x: number; y: number }): { lat: number; lng: number } {
const data = map.unproject([coords.x, map.getContainer().clientHeight - coords.y], 0);
return { lat: data.lat, lng: data.lng };
}

/**
* Synchronise the sigma BBOX with the leaflet one.
*/
export function syncLeafletBboxWithGraph(sigma: Sigma, map: Map, animate: boolean): void {
const viewportDimensions = sigma.getDimensions();

// Graph BBOX
const graphBottomLeft = sigma.viewportToGraph({ x: 0, y: viewportDimensions.height }, { padding: 0 });
const graphTopRight = sigma.viewportToGraph({ x: viewportDimensions.width, y: 0 }, { padding: 0 });

// Geo BBOX
const geoSouthWest = graphToLatlng(map, graphBottomLeft);
const geoNorthEast = graphToLatlng(map, graphTopRight);

// Set map BBOX
const bounds = new LatLngBounds(geoSouthWest, geoNorthEast);
const opts = animate ? { animate: true, duration: 0.001 } : { animate: false };
map.flyToBounds(bounds, opts);

// Handle side effects when bounds have some "void" area on top or bottom of the map
// When it happens, flyToBound don't really do its job and there is a translation of the graph that match the void height.
// So we have to do a pan in pixel...
const worldSize = map.getPixelWorldBounds().getSize();
const mapBottomY = map.getPixelBounds().getBottomLeft().y;
const mapTopY = map.getPixelBounds().getTopRight().y;
const panVector: [number, number] = [0, 0];
if (mapTopY < 0) panVector[1] = mapTopY;
if (mapBottomY > worldSize.y) panVector[1] = mapBottomY - worldSize.y + panVector[1];
if (panVector[1] !== 0) {
map.panBy(panVector, { animate: false });
}
}

/**
* Settings the min & max camera ratio of sigma to not be hover the possibility of leaflet
* - Max zoom is when whe can see the whole map
* - Min zoom is when we are at zoom 18 on leaflet
*/
export function setSigmaRatioBounds(sigma: Sigma, map: Map): void {
const worldPixelSize = map.getPixelWorldBounds().getSize();

// Max zoom
const maxZoomRatio = worldPixelSize.y / sigma.getDimensions().width;
sigma.setSetting("maxCameraRatio", maxZoomRatio);
// Min zoom
const minZoomRatio = worldPixelSize.y / LEAFLET_MAX_PIXEL;
sigma.setSetting("minCameraRatio", minZoomRatio);

const currentRatio = sigma.getCamera().ratio;
if (currentRatio > maxZoomRatio) sigma.getCamera().setState({ ratio: maxZoomRatio });
if (currentRatio < minZoomRatio) sigma.getCamera().setState({ ratio: minZoomRatio });
}
Loading

0 comments on commit 09bc389

Please sign in to comment.