Skip to content

Commit

Permalink
[layer-maplibre, layer-leaflet] better bbox management
Browse files Browse the repository at this point in the history
- adding maplibre plugin
- adding sync map to sigma, which is needed for some case, specially
  wehn the graph BBOX is outside the map possibilities
  • Loading branch information
sim51 committed Jul 26, 2024
1 parent f725967 commit c64838e
Show file tree
Hide file tree
Showing 17 changed files with 323,501 additions and 33 deletions.
490 changes: 488 additions & 2 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@
"packages/node-image",
"packages/node-piechart",
"packages/edge-curve",
"packages/layer-leaflet"
"packages/layer-leaflet",
"packages/layer-maplibre"
],
"exports": {
"importConditionDefaultExport": "default"
Expand Down
44 changes: 36 additions & 8 deletions packages/layer-leaflet/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
import Graph from "graphology";
import { Attributes } from "graphology-types";
import L from "leaflet";
import L, { MapOptions } from "leaflet";
import { Sigma } from "sigma";
import { DEFAULT_SETTINGS } from "sigma/settings";

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

/**
* On the graph, we store the 2D projection of the geographical lat/long.
*/
export default function bindLeafletLayer(
sigma: Sigma,
opts?: {
mapOptions?: Omit<MapOptions, "zoomControl" | "zoomSnap" | "zoom" | "maxZoom">;
tileLayer?: { urlTemplate: string; attribution?: string };
getNodeLatLng?: (nodeAttributes: Attributes) => { lat: number; lng: number };
},
Expand All @@ -25,6 +26,7 @@ export default function bindLeafletLayer(

// Initialize the map
const map = L.map(mapContainerId, {
...(opts?.mapOptions || {}),
zoomControl: false,
zoomSnap: 0,
zoom: 0,
Expand All @@ -41,6 +43,14 @@ export default function bindLeafletLayer(
}
L.tileLayer(tileUrl, { attribution: tileAttribution }).addTo(map);

let mapIsMoving = false;
map.on("move", () => {
mapIsMoving = true;
});
map.on("moveend", () => {
mapIsMoving = false;
});

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

Expand All @@ -63,23 +73,40 @@ export default function bindLeafletLayer(
});
}

// Function that do sync sigma->leaflet
function fnSyncLeaflet(animate = false) {
syncLeafletBboxWithGraph(sigma, map, animate);
// Function that sync the map with sigma
function fnSyncMapWithSigma(firstIteration = false) {
syncMapWithSigma(sigma, map, firstIteration, true);
}

// Function that sync sigma with map if it's needed
function fnSyncSigmaWithMap() {
if (!sigma.getCamera().isAnimated() && !mapIsMoving) {
// Check that sigma & leaflet are already in sync
const southWest = graphToLatlng(map, sigma.viewportToGraph({ x: 0, y: sigma.getDimensions().height }));
const northEast = graphToLatlng(map, sigma.viewportToGraph({ x: sigma.getDimensions().width, y: 0 }));
const diff = Math.max(
map.getBounds().getSouthWest().distanceTo(southWest),
map.getBounds().getNorthEast().distanceTo(northEast),
);
if (diff > 10000 / map.getZoom()) {
syncSigmaWithMap(sigma, map);
}
}
}

// 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());
fnSyncSigmaWithMap();
setSigmaRatioBounds(sigma, map);
}

// Clean up function to remove everything
function clean() {
map.remove();
mapContainer.remove();
sigma.off("afterRender", fnSyncLeaflet);
sigma.off("afterRender", fnSyncMapWithSigma);
sigma.off("resize", fnOnResize);
sigma.setSetting("stagePadding", DEFAULT_SETTINGS.stagePadding);
sigma.setSetting("enableCameraRotation", true);
Expand All @@ -91,15 +118,16 @@ export default function bindLeafletLayer(
updateGraphCoordinates(sigma.getGraph());

// Do the first sync
fnSyncLeaflet();
fnSyncMapWithSigma(true);

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

// At each render of sigma, we do the leaflet sync
sigma.on("afterRender", fnSyncLeaflet);
sigma.on("afterRender", fnSyncMapWithSigma);
// Listen on resize
sigma.on("resize", fnOnResize);
// Do the cleanup
Expand Down
69 changes: 47 additions & 22 deletions packages/layer-leaflet/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@ import { LatLngBounds, Map } from "leaflet";
import { Sigma } from "sigma";

export const LEAFLET_MAX_PIXEL = 256 * 2 ** 18;
export const MAX_VALID_LATITUDE = 85.051129;
/**
* Get the world size in pixel
*/
function getWorldPixelSize(map: Map) {
const southWest = map.project({ lat: -MAX_VALID_LATITUDE, lng: -180 });
const northEast = map.project({ lat: MAX_VALID_LATITUDE, lng: 180 });
return { y: Math.abs(southWest.y - northEast.y), x: Math.abs(northEast.x - southWest.x) };
}

/**
* Given a geo point returns its graph coords.
Expand All @@ -24,35 +33,55 @@ export function graphToLatlng(map: Map, coords: { x: number; y: number }): { lat
}

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

// Compute sigma center
const center = sigma.viewportToFramedGraph(sigma.graphToViewport(latlngToGraph(map, mapBound.getCenter())));

// Compute sigma ratio
const northEast = sigma.graphToViewport(latlngToGraph(map, mapBound.getNorthEast()));
const southWest = sigma.graphToViewport(latlngToGraph(map, mapBound.getSouthWest()));
const viewportBoundDimension = {
width: Math.abs(northEast.x - southWest.x),
height: Math.abs(northEast.y - southWest.y),
};
const viewportDim = sigma.getDimensions();
const ratio =
Math.min(viewportBoundDimension.width / viewportDim.width, viewportBoundDimension.height / viewportDim.height) *
sigma.getCamera().getState().ratio;
sigma.getCamera().setState({ ...center, ratio: ratio });
}

/**
* Synchronise map BBOX with the Sigma one.
*/
export function syncMapWithSigma(sigma: Sigma, map: Map, firstIteration = false, animate: boolean = false): 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 });
const graphBottomLeft = sigma.viewportToGraph({ x: 0, y: viewportDimensions.height });
const graphTopRight = sigma.viewportToGraph({ x: viewportDimensions.width, y: 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 };
const opts = animate ? { animate: true, duration: 0.1 } : { 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 });
if (!firstIteration) {
// 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;
if (mapTopY < 0 || mapBottomY > worldSize.y) syncSigmaWithMap(sigma, map);
}
}

Expand All @@ -62,15 +91,11 @@ export function syncLeafletBboxWithGraph(sigma: Sigma, map: Map, animate: boolea
* - Min zoom is when we are at zoom 18 on leaflet
*/
export function setSigmaRatioBounds(sigma: Sigma, map: Map): void {
const worldPixelSize = map.getPixelWorldBounds().getSize();
const worldPixelSize = getWorldPixelSize(map);

// Max zoom
const maxZoomRatio = Math.min(
worldPixelSize.x / sigma.getDimensions().height,
worldPixelSize.y / sigma.getDimensions().width,
);
const maxZoomRatio = worldPixelSize.y / sigma.getDimensions().width;
sigma.setSetting("maxCameraRatio", maxZoomRatio);

// Min zoom
const minZoomRatio = worldPixelSize.y / LEAFLET_MAX_PIXEL;
sigma.setSetting("minCameraRatio", minZoomRatio);
Expand Down
2 changes: 2 additions & 0 deletions packages/layer-maplibre/.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-maplibre/.npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.gitignore
node_modules
src
tsconfig.json
27 changes: 27 additions & 0 deletions packages/layer-maplibre/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Sigma.js - Maplibre background layer

This package contains a maplibre background 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 [maplibre](https://maplibre.org/) in your application.
You can check this [page](https://maplibre.org/maplibre-gl-js/docs/) to see how to do it.
Especially, don't forget to load `maplibre-gl.css` in your application.

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

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

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.351, size: 10, label: "Paris" });
graph.addEdge("nantes", "paris");

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

Please check the related [Storybook](https://github.com/jacomyal/sigma.js/tree/main/packages/storybook/stories/layer-maplibre) for more advanced examples.
47 changes: 47 additions & 0 deletions packages/layer-maplibre/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
{
"name": "@sigma/layer-maplibre",
"version": "3.0.0-beta.1",
"description": "A plugin to set a geographical map in background",
"main": "dist/sigma-layer-maplibre.cjs.js",
"module": "dist/sigma-layer-maplibre.esm.js",
"types": "dist/sigma-layer-maplibre.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"
],
"contributors": [
{
"name": "Benoît Simard",
"url": "http://github.com/sim51"
}
],
"license": "MIT",
"preconstruct": {
"entrypoints": [
"index.ts"
]
},
"peerDependencies": {
"maplibre-gl": "^4.5.0",
"sigma": ">=3.0.0-beta.10"
},
"exports": {
".": {
"module": "./dist/sigma-layer-maplibre.esm.js",
"import": "./dist/sigma-layer-maplibre.cjs.mjs",
"default": "./dist/sigma-layer-maplibre.cjs.js"
},
"./package.json": "./package.json"
}
}
Loading

0 comments on commit c64838e

Please sign in to comment.