Skip to content

Commit eaa1179

Browse files
committed
[feature] Change map state on browser go back or forward
1 parent 84ba376 commit eaa1179

File tree

6 files changed

+79
-60
lines changed

6 files changed

+79
-60
lines changed

README.md

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -465,24 +465,21 @@ NetJSON format used internally is based on [networkgraph](http://netjson.org/rfc
465465

466466
You can customize the style of GeoJSON features using `style` property. The list of all available properties can be found in the [Leaflet documentation](https://leafletjs.com/reference.html#geojson).
467467

468-
<!-- Todo: Update this -->
468+
- `bookmarkableActions`
469469

470-
- `hashParams`
471-
472-
Configuration for adding hash parameters to the URL when a node is clicked.
470+
Configuration for adding url fragments when a node is clicked.
473471

474472
```JS
475-
hashParams:{
476-
show: boolean,
477-
type: string
473+
bookmarkableActions:{
474+
enabled: boolean,
475+
id: string
478476
}
479477
```
480478

481-
You can enable or disable adding hash parameters by setting show to true or false. When enabled, the following parameters are added to the URL:
482-
1. type – A prefix used to uniquely identify the map node.
483-
2. nodeId – The ID of the selected node.
484-
3. zoom – The current zoom level of the map.
485-
**Note: Zoom is only applied when type is set to `geoMap`**
479+
You can enable or disable adding url fragments by setting enabled to true or false. When enabled, the following parameters are added to the URL:
480+
1. id – A prefix used to uniquely identify the map.
481+
2. nodeId – The id of the selected node.
482+
When a URL containing these fragments is opened, the click event associated with the given nodeId is triggered, and if the map is based on Leaflet, the view will automatically center on that node.
486483

487484
- `onInit`
488485

src/js/netjsongraph.core.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ class NetJSONGraph {
1717
// if explicitly set it to somthing like L.CRS.Simple.
1818
this.config.crs = NetJSONGraphDefaultConfig.crs;
1919
this.JSONParam = this.utils.isArray(JSONParam) ? JSONParam : [JSONParam];
20+
this.utils.setupHashChangeHandler(this);
2021
}
2122

2223
/**

src/js/netjsongraph.render.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -152,8 +152,8 @@ class NetJSONGraphRender {
152152
// Preserve original NetJSON node for sidebar use
153153
/* eslint-disable no-underscore-dangle */
154154
nodeResult._source = JSON.parse(JSON.stringify(node));
155-
// Store the clicked node in this.selectedNode for easy access later without need for traverse
156-
self.utils.setSelectedNodeFromUrlFragments(self, fragments, node);
155+
// Store the clicked node in this.indexedNode for easy access later without need for traverse
156+
self.utils.setIndexedNodeFromUrlFragments(self, fragments, node);
157157
return nodeResult;
158158
});
159159
const links = JSONData.links.map((link) => {
@@ -293,8 +293,8 @@ class NetJSONGraphRender {
293293
});
294294
}
295295
}
296-
// Store the clicked node in this.selectedNode for easy access later without need for traverse
297-
self.utils.setSelectedNodeFromUrlFragments(self, fragments, node);
296+
// Store the clicked node in this.indexedNode for easy access later without need for traverse
297+
self.utils.setIndexedNodeFromUrlFragments(self, fragments, node);
298298
});
299299
links.forEach((link) => {
300300
if (!flatNodes[link.source]) {

src/js/netjsongraph.util.js

Lines changed: 37 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1214,61 +1214,77 @@ class NetJSONGraphUtil {
12141214
}
12151215

12161216
setUrlFragments(self, params) {
1217-
if (!self.config.bookmarkableActions.enabled) return;
1217+
if (!self.config.bookmarkableActions.enabled || params.data.cluster) return;
12181218
const fragments = this.parseUrlFragments();
12191219
const id = self.config.bookmarkableActions.id;
1220-
let nodeId, zoom;
1220+
let nodeId;
1221+
self.indexedNode = self.indexedNode || {};
12211222
if (params.componentSubType === "graph") {
12221223
nodeId = params.data.id;
1224+
self.indexedNode[nodeId] = params.data;
12231225
}
12241226
if (["scatter", "effectScatter"].includes(params.componentSubType)) {
12251227
nodeId = params.data.node.id;
1228+
self.indexedNode[nodeId] = params.data.node;
12261229
}
1227-
zoom = self?.leaflet?.getZoom();
1228-
if (!fragments[id] || !(fragments[id] instanceof URLSearchParams)) {
1230+
if (!fragments[id]) {
12291231
fragments[id] = new URLSearchParams();
12301232
fragments[id].set("id", id);
12311233
}
12321234
fragments[id].set("nodeId", nodeId);
1233-
if (zoom != null) {
1234-
fragments[id].set("zoom", zoom);
1235-
}
1236-
window.location.hash = this.generateUrlFragments(fragments);
1235+
const newHash = this.generateUrlFragments(fragments);
1236+
const state = self.indexedNode[nodeId];
1237+
history.pushState(state, "", `#${newHash}`);
12371238
}
12381239

1239-
removeUrlFragment(self, id) {
1240-
if (!self.config.bookmarkableActions.enabled) return;
1241-
1240+
removeUrlFragment(id) {
12421241
const fragments = this.parseUrlFragments();
12431242
if (fragments[id]) {
12441243
delete fragments[id];
12451244
}
1246-
window.location.hash = this.generateUrlFragments(fragments);
1245+
const newHash = this.generateUrlFragments(fragments);
1246+
const state = {id};
1247+
history.pushState(state, "", `#${newHash}`);
12471248
}
12481249

1249-
setSelectedNodeFromUrlFragments(self, fragments, node) {
1250-
if (!self.config.bookmarkableActions.enabled || !Object.keys(fragments).length) return;
1250+
setIndexedNodeFromUrlFragments(self, fragments, node) {
1251+
if (!self.config.bookmarkableActions.enabled || !Object.keys(fragments).length)
1252+
return;
12511253
const id = self.config.bookmarkableActions.id;
12521254
const nodeId = fragments[id]?.get("nodeId");
1253-
const zoom = fragments[id]?.get("zoom");
12541255
if (nodeId === node.id) {
1255-
self.selectedNode = node;
1256-
if (zoom != null) self.selectedNode.zoom = Number(zoom);
1256+
self.indexedNode = self.indexedNode || {};
1257+
self.indexedNode[nodeId] = node;
12571258
}
12581259
}
12591260

12601261
applyUrlFragmentState(self) {
12611262
if (!self.config.bookmarkableActions.enabled) return;
1262-
const node = self.selectedNode;
1263-
if (!node) return;
1263+
const id = self.config.bookmarkableActions.id;
1264+
const fragments = self.utils.parseUrlFragments();
1265+
const nodeId = fragments[id]?.get("nodeId");
1266+
if (!self.indexedNode || !self.indexedNode[nodeId]) return;
1267+
const node = self.indexedNode[nodeId];
12641268
const nodeType =
12651269
self.config.graphConfig.series.type || self.config.mapOptions.nodeConfig.type;
1266-
const { location, zoom } = node;
1267-
if (["scatter", "effectScatter"].includes(nodeType) && zoom != null) {
1270+
const {location, cluster} = node;
1271+
if (["scatter", "effectScatter"].includes(nodeType)) {
1272+
const zoom =
1273+
cluster != null ? self.config.disableClusteringAtLevel : self.leaflet.getZoom();
12681274
self.leaflet.setView([location.lat, location.lng], zoom);
12691275
}
12701276
self.config.onClickElement.call(self, "node", node);
12711277
}
1278+
1279+
setupHashChangeHandler(self) {
1280+
window.addEventListener("popstate", () => {
1281+
const currentNode = history.state;
1282+
if (currentNode != null && !self.indexedNode[currentNode.id]) {
1283+
self.indexedNode[currentNode.id] = currentNode;
1284+
}
1285+
this.applyUrlFragmentState(self);
1286+
});
1287+
}
12721288
}
12731289

12741290
export default NetJSONGraphUtil;

test/netjsongraph.render.test.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,8 @@ describe("generateMapOption - node processing and dynamic styling", () => {
509509
linkStyleConfig: {},
510510
linkEmphasisConfig: {linkStyle: {}},
511511
})),
512+
parseUrlFragments: jest.fn(),
513+
setIndexedNodeFromUrlFragments: jest.fn(),
512514
},
513515
};
514516
});
@@ -980,6 +982,8 @@ describe("Test disableClusteringAtLevel: 0", () => {
980982
nonClusterNodes: [],
981983
nonClusterLinks: [],
982984
})),
985+
parseUrlFragments: jest.fn(),
986+
setIndexedNodeFromUrlFragments: jest.fn(),
983987
},
984988
event: {
985989
emit: jest.fn(),
@@ -1076,6 +1080,8 @@ describe("Test leaflet zoomend handler and zoom control state", () => {
10761080
geojsonToNetjson: jest.fn(() => ({nodes: [], links: []})),
10771081
generateMapOption: jest.fn(() => ({series: []})),
10781082
echartsSetOption: jest.fn(),
1083+
parseUrlFragments: jest.fn(),
1084+
setIndexedNodeFromUrlFragments: jest.fn(),
10791085
},
10801086
event: {
10811087
emit: jest.fn(),
@@ -1217,6 +1223,8 @@ describe("mapRender – polygon overlay & moveend bbox logic", () => {
12171223
echartsSetOption: jest.fn(),
12181224
deepMergeObj: jest.fn((a, b) => ({...a, ...b})),
12191225
getBBoxData: jest.fn(() => Promise.resolve({nodes: [{id: "n1"}], links: []})),
1226+
parseUrlFragments: jest.fn(),
1227+
setIndexedNodeFromUrlFragments: jest.fn(),
12201228
},
12211229
event: {emit: jest.fn()},
12221230
};

test/netjsongraph.util.test.js

Lines changed: 20 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -203,12 +203,11 @@ describe("Test URL fragment utilities", () => {
203203

204204
test("Test parseUrlFragments parses multiple fragments and decodes values", () => {
205205
window.location.hash =
206-
"#id=geoMap&nodeId=abc%3A123&zoom=5;id=indoorMap&nodeId=indoor-node&zoom=5";
206+
"#id=geoMap&nodeId=abc%3A123;id=indoorMap&nodeId=indoor-node";
207207
const fragments = utils.parseUrlFragments();
208208

209209
expect(Object.keys(fragments).sort()).toEqual(["geoMap", "indoorMap"].sort());
210-
expect(fragments.geoMap.get("nodeId")).toBe("abc:123"); // percent-decoded
211-
expect(fragments.geoMap.get("zoom")).toBe("5");
210+
expect(fragments.geoMap.get("nodeId")).toBe("abc:123");
212211
expect(fragments.indoorMap.get("nodeId")).toBe("indoor-node");
213212
});
214213

@@ -217,7 +216,6 @@ describe("Test URL fragment utilities", () => {
217216
config: {
218217
bookmarkableActions: {enabled: true, id: "geoMap"},
219218
},
220-
leaflet: {getZoom: () => 7},
221219
};
222220
const params = {
223221
componentSubType: "effectScatter",
@@ -230,61 +228,58 @@ describe("Test URL fragment utilities", () => {
230228
expect(fragments.geoMap).toBeDefined();
231229
expect(fragments.geoMap.get("id")).toBe("geoMap");
232230
expect(fragments.geoMap.get("nodeId")).toBe("node-1");
233-
expect(fragments.geoMap.get("zoom")).toBe("7");
234231
});
235232

236233
test("Test setUrlFragments updates an existing fragment and preserves others", () => {
237234
window.location.hash = "id=graph&nodeId=node-1";
238235

239236
const self = {
240237
config: {bookmarkableActions: {enabled: true, id: "geo"}},
241-
leaflet: {getZoom: () => 9},
238+
indexedNode: undefined,
242239
};
243240
const params = {
244241
componentSubType: "graph",
245242
data: {id: "node-2"},
246243
};
247244

248245
utils.setUrlFragments(self, params);
249-
250246
const fragments = utils.parseUrlFragments();
247+
251248
expect(fragments.graph).toBeDefined();
252249
expect(fragments.graph.get("nodeId")).toBe("node-1");
253-
254250
expect(fragments.geo.get("nodeId")).toBe("node-2");
255-
expect(fragments.geo.get("zoom")).toBe("9");
256251
});
257252

258253
test("removeUrlFragment deletes the fragment for the given id", () => {
259254
window.location.hash = "id=keep&nodeId=a;id=removeMe&nodeId=b";
260-
const self = {config: {bookmarkableActions: {enabled: true, id: "removeMe"}}};
261-
utils.removeUrlFragment(self, "removeMe");
255+
utils.removeUrlFragment("removeMe");
262256
const fragments = utils.parseUrlFragments();
263257
expect(fragments.keep).toBeDefined();
264258
expect(fragments.removeMe).toBeUndefined();
265259
expect(window.location.hash).not.toContain("removeMe");
266260
});
267261

268-
test("Test setSelectedNodeFromUrlFragments sets selectedNode and numeric zoom", () => {
262+
test("Test setIndexedNodeFromUrlFragments sets indexedNode and numeric zoom", () => {
269263
window.location.hash = "#id=geo&nodeId=abc&zoom=4";
270264
const self = {config: {bookmarkableActions: {enabled: true, id: "geo"}}};
271265
const fragments = utils.parseUrlFragments();
272266

273267
const node = {id: "abc", properties: {}};
274-
utils.setSelectedNodeFromUrlFragments(self, fragments, node);
268+
utils.setIndexedNodeFromUrlFragments(self, fragments, node);
275269

276-
expect(self.selectedNode).toBe(node);
277-
expect(self.selectedNode.zoom).toBe(4);
270+
expect(self.indexedNode).toBeDefined();
271+
expect(self.indexedNode.abc).toBe(node);
272+
expect(self.indexedNode.abc.id).toBe("abc");
278273
});
279274

280-
test("Test applyUrlFragmentState calls map.setView and triggers onClickElement", () => {
275+
test("applyUrlFragmentState calls map.setView and triggers onClickElement", () => {
281276
const mockSetView = jest.fn();
282277
const mockOnClick = jest.fn();
283278

284279
const node = {
285280
id: "n1",
286-
properties: {location: {lat: 12.1, lng: 77.5}},
287-
zoom: 6,
281+
location: {lat: 12.1, lng: 77.5},
282+
cluster: null,
288283
};
289284

290285
const self = {
@@ -294,10 +289,12 @@ describe("Test URL fragment utilities", () => {
294289
mapOptions: {nodeConfig: {type: "scatter"}},
295290
onClickElement: mockOnClick,
296291
},
297-
selectedNode: node,
298-
leaflet: {setView: mockSetView},
292+
indexedNode: {n1: node},
293+
leaflet: {setView: mockSetView, getZoom: () => 6},
294+
utils,
299295
};
300296

297+
window.location.hash = "#id=geo&nodeId=n1";
301298
utils.applyUrlFragmentState(self);
302299

303300
expect(mockSetView).toHaveBeenCalledWith([12.1, 77.5], 6);
@@ -308,12 +305,12 @@ describe("Test URL fragment utilities", () => {
308305
const recorder = [];
309306

310307
const emitter = {
311-
_handlers: {},
308+
handlers: {},
312309
once(event, handler) {
313-
this._handlers[event] = handler;
310+
this.handlers[event] = handler;
314311
},
315312
emit(event) {
316-
const h = this._handlers[event];
313+
const h = this.handlers[event];
317314
if (h) h();
318315
},
319316
};

0 commit comments

Comments
 (0)