Skip to content

[feature] Support custom CRS in default config #380 #381

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Jul 1, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,12 @@ NetJSON format used internally is based on [networkgraph](http://netjson.org/rfc

The rendering mode. You can render the map by setting it as `map`.

- `crs`

**Default**: `L.CRS.EPSG3857,`

Leaflet uses `L.CRS.EPSG3857` as the default CRS. If no CRS is specified, this default is automatically applied. To support custom projections such as 2D planes you can override this by passing a custom CRS like `L.CRS.Simple`.

- `metadata`

**Default**: `true`
Expand Down
18 changes: 9 additions & 9 deletions public/assets/data/netjsonmap-indoormap.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,47 +8,47 @@
"nodes": [
{
"id": "i3ehd7",
"location": {"lat": 43.89, "lng": 12.098496093750002},
"location": {"lat": -420, "lng": 300},
"name": "Family room"
},
{
"id": "i74rhs",
"location": {"lat": 45.5007394591129, "lng": 28.27153358459473},
"location": {"lat": -352, "lng": 788},
"name": "Lounge"
},
{
"id": "5vjb7b",
"location": {"lat": 44.393304126849265, "lng": 6.094136047363282},
"location": {"lat": -395, "lng": 116},
"name": "Bedroom 1"
},
{
"id": "liwv7p",
"location": {"lat": 39.49718091651456, "lng": 28.644869232177738},
"location": {"lat": -595, "lng": 795},
"name": "Bedroom 4"
},
{
"id": "m7qhnj",
"location": {"lat": 43.930760483351195, "lng": 24.369127044677738},
"location": {"lat": -410, "lng": 665},
"name": "Study Area"
},
{
"id": "a0mg9w",
"location": {"lat": 39.89, "lng": 18.135201263427738},
"location": {"lat": -580, "lng": 482},
"name": "Dining room"
},
{
"id": "dsm2x7",
"location": {"lat": 48.859382175122965, "lng": 13.955840454101564},
"location": {"lat": -205, "lng": 352},
"name": "Bedroom 2"
},
{
"id": "nwlpcd",
"location": {"lat": 48.67788524115151, "lng": 19.039492797851566},
"location": {"lat": -210, "lng": 510},
"name": "Bedroom 3"
},
{
"id": "9p0bos",
"location": {"lat": 49.736490408464164, "lng": 25.516660766601562},
"location": {"lat": -162, "lng": 700},
"name": "Garage"
}
],
Expand Down
48 changes: 20 additions & 28 deletions public/example_templates/netjsonmap-indoormap.html
Original file line number Diff line number Diff line change
Expand Up @@ -45,19 +45,17 @@
"../assets/data/netjsonmap-indoormap.json",
{
render: "map",
crs: L.CRS.Simple,
// set map initial state.
mapOptions: {
center: [48.577, 18.539],
zoom: 5.5,
zoom: 0,
zoomSnap: 0.3,
minZoom: 4,
maxZoom: 9,
minZoom: -1,
maxZoom: 2,
nodeConfig: {
label: {
offset: [0, -10],
fontSize: "14px",
fontWeight: "bold",
color: "#D9644D",
show: false,
},
animation: false,
},
Expand Down Expand Up @@ -122,35 +120,29 @@
onReady: function presentIndoormap() {
const netjsonmap = this.leaflet;
const image = new Image();
image.src = "../assets/images/floorplan.png";
const imageUrl = "../assets/images/floorplan.png";
image.src = imageUrl;

const aspectRatio = image.width / image.height;
const height = 700;
const width = aspectRatio * height;
netjsonmap.on("resize", () => {
if (netjsonmap.getBoundsZoom(bounds) <= netjsonmap.getMaxZoom()) {
netjsonmap.setZoom(netjsonmap.getBoundsZoom(bounds));
}
});
const southWest = {lat: 53, lng: 2};
const swPixel = netjsonmap.latLngToContainerPoint(southWest);
const nePixel = swPixel.add(new L.Point(width, height));
const northEast = netjsonmap.containerPointToLatLng(nePixel);
image.onload = function () {
const aspectRatio = image.width / image.height;
const h = 700;
const w = aspectRatio * h;
const zoom = netjsonmap.getMaxZoom() - 1;
const bottomLeft = netjsonmap.unproject([0, h * 2], zoom);
const upperRight = netjsonmap.unproject([w * 2, 0], zoom);
const bounds = new L.LatLngBounds(bottomLeft, upperRight);

const bounds = new L.LatLngBounds(southWest, northEast);
netjsonmap.setMaxBounds(bounds);
if (netjsonmap.getBoundsZoom(bounds) <= netjsonmap.getMaxZoom()) {
netjsonmap.setZoom(netjsonmap.getBoundsZoom(bounds));
}
L.imageOverlay(imageUrl, bounds).addTo(netjsonmap);
netjsonmap.fitBounds(bounds);
netjsonmap.setMaxBounds(bounds);
};

// remove the geographic map
// Remove any default tile layers and show only the floorplan image.
netjsonmap.eachLayer((layer) => {
if (layer._url) {
netjsonmap.removeLayer(layer);
}
});
// add indoormap image
L.imageOverlay(image, bounds).addTo(netjsonmap);
},
},
);
Expand Down
2 changes: 2 additions & 0 deletions src/js/netjsongraph.config.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import L from "leaflet/dist/leaflet";
/**
* Default options
*
Expand Down Expand Up @@ -40,6 +41,7 @@ const NetJSONGraphDefaultConfig = {
clusterRadius: 80,
showMetaOnNarrowScreens: false,
showLabelsAtZoomLevel: 7,
crs: L.CRS.EPSG3857,
echartsOption: {
aria: {
show: true,
Expand Down
21 changes: 21 additions & 0 deletions src/js/netjsongraph.render.js
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,27 @@ class NetJSONGraphRender {
},
],
});

// Zoom in/out buttons disabled only when it is equal to min/max zoomlevel
// Manually handle zoom control state to ensure correct behavior with float zoom levels
const minZoom = self.leaflet.getMinZoom();
const maxZoom = self.leaflet.getMaxZoom();
const zoomIn = document.querySelector(".leaflet-control-zoom-in");
const zoomOut = document.querySelector(".leaflet-control-zoom-out");

if (zoomIn && zoomOut) {
if (Math.round(currentZoom) >= maxZoom) {
zoomIn.classList.add("leaflet-disabled");
} else {
zoomIn.classList.remove("leaflet-disabled");
}

if (Math.round(currentZoom) <= minZoom) {
zoomOut.classList.add("leaflet-disabled");
} else {
zoomOut.classList.remove("leaflet-disabled");
}
}
});

self.leaflet.on("moveend", async () => {
Expand Down
1 change: 1 addition & 0 deletions test/browser.test.utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export const getDriver = async () => {
export const urls = {
basicUsage: `${url}/examples/netjsongraph.html`,
geographicMap: `${url}/examples/netjsonmap.html`,
indoorMap: `${url}/examples/netjsonmap-indoormap.html`,
};

export const getElementByCss = async (driver, css, waitTime = 1000) => {
Expand Down
17 changes: 17 additions & 0 deletions test/netjsongraph.browser.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,4 +82,21 @@ describe("Chart Rendering Test", () => {
printConsoleErrors(consoleErrors);
expect(consoleErrors.length).toBe(0);
});

test("render floorplan map without console errors", async () => {
driver.get(urls.indoorMap);
const canvas = await getElementByCss(driver, "canvas", 2000);
const floorplanImage = getElementByCss(driver, "leaflet-image-layer");
const consoleErrors = await captureConsoleErrors(driver);
const {nodesRendered, linksRendered} =
await getRenderedNodesAndLinksCount(driver);
const {nodesPresent, linksPresent} =
await getPresentNodesAndLinksCount("Indoor map");
printConsoleErrors(consoleErrors);
expect(consoleErrors.length).toBe(0);
expect(canvas).not.toBeNull();
expect(floorplanImage).not.toBeNull();
expect(nodesRendered).toBe(nodesPresent);
expect(linksRendered).toBe(linksPresent);
});
});
119 changes: 119 additions & 0 deletions test/netjsongraph.render.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -872,3 +872,122 @@ describe("Test disableClusteringAtLevel: 0", () => {
expect(mockMarkerClusterGroupInstance.addTo).not.toHaveBeenCalled();
});
});

describe("Test leaflet zoomend handler and zoom control state", () => {
let renderInstance;
let mockSelf;
let mapContainer;

function setZoomAndTrigger(map, zoom) {
map.setZoom(zoom);
map.fire("zoomend");
}

function getZoomControlButtons(type) {
return document.querySelector(`.leaflet-control-zoom-${type}`);
}

beforeEach(() => {
mapContainer = document.createElement("div");
mapContainer.id = "test-map";
// Leaflet won't render map without height and width
mapContainer.style.width = "400px";
mapContainer.style.height = "400px";
document.body.appendChild(mapContainer);

const leafletMap = L.map(mapContainer, {
center: [0, 0],
zoom: 2,
minZoom: 1,
maxZoom: 4,
zoomControl: true,
});

mockSelf = {
type: "geojson",
data: {type: "FeatureCollection", features: []},
config: {
clustering: false,
disableClusteringAtLevel: 0,
geoOptions: {},
clusteringAttribute: null,
prepareData: jest.fn((d) => d),
onClickElement: jest.fn(),
mapOptions: {},
mapTileConfig: [{}],
showLabelsAtZoomLevel: 3,
},
leaflet: leafletMap,
echarts: {
setOption: jest.fn(),
_api: {
getCoordinateSystems: jest.fn(() => [{getLeaflet: () => leafletMap}]),
},
},
utils: {
deepMergeObj: jest.fn((obj1, obj2) => ({...obj1, ...obj2})),
},
event: {
emit: jest.fn(),
},
el: document.createElement("div"),
};

jest.spyOn(L, "geoJSON").mockImplementation(() => ({
addTo: jest.fn(),
on: jest.fn(),
}));

renderInstance = new NetJSONGraphRender();
});

afterEach(() => {
mockSelf.leaflet.remove();
document.body.removeChild(mapContainer);
jest.restoreAllMocks();
});

test("should disable zoom-in at max zoom and enable zoom-out", () => {
renderInstance.mapRender(mockSelf.data, mockSelf);
setZoomAndTrigger(mockSelf.leaflet, mockSelf.leaflet.getMaxZoom());

const zoomInBtn = getZoomControlButtons("in");
const zoomOutBtn = getZoomControlButtons("out");

expect(zoomInBtn.classList.contains("leaflet-disabled")).toBe(true);
expect(zoomOutBtn.classList.contains("leaflet-disabled")).toBe(false);
});

test("should disable zoom-out at min zoom and enable zoom-in", () => {
renderInstance.mapRender(mockSelf.data, mockSelf);
setZoomAndTrigger(mockSelf.leaflet, mockSelf.leaflet.getMinZoom());

const zoomInBtn = getZoomControlButtons("in");
const zoomOutBtn = getZoomControlButtons("out");

expect(zoomInBtn.classList.contains("leaflet-disabled")).toBe(false);
expect(zoomOutBtn.classList.contains("leaflet-disabled")).toBe(true);
});

test("should enable both zoom-in and zoom-out at intermediate zoom", () => {
renderInstance.mapRender(mockSelf.data, mockSelf);
setZoomAndTrigger(mockSelf.leaflet, 3);

const zoomInBtn = getZoomControlButtons("in");
const zoomOutBtn = getZoomControlButtons("out");

expect(zoomInBtn.classList.contains("leaflet-disabled")).toBe(false);
expect(zoomOutBtn.classList.contains("leaflet-disabled")).toBe(false);
});

test("should disable zoom-in at float zoom value rounded to maxZoom", () => {
renderInstance.mapRender(mockSelf.data, mockSelf);
setZoomAndTrigger(mockSelf.leaflet, 3.9);

const zoomInBtn = getZoomControlButtons("in");
const zoomOutBtn = getZoomControlButtons("out");

expect(zoomInBtn.classList.contains("leaflet-disabled")).toBe(true);
expect(zoomOutBtn.classList.contains("leaflet-disabled")).toBe(false);
});
});
Loading