Skip to content

Commit a628752

Browse files
committed
[feature] Change map state on browser go back or forward
1 parent 2f16506 commit a628752

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
@@ -449,24 +449,21 @@ NetJSON format used internally is based on [networkgraph](http://netjson.org/rfc
449449

450450
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).
451451

452-
<!-- Todo: Update this -->
452+
- `bookmarkableActions`
453453

454-
- `hashParams`
455-
456-
Configuration for adding hash parameters to the URL when a node is clicked.
454+
Configuration for adding url fragments when a node is clicked.
457455

458456
```JS
459-
hashParams:{
460-
show: boolean,
461-
type: string
457+
bookmarkableActions:{
458+
enabled: boolean,
459+
id: string
462460
}
463461
```
464462

465-
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:
466-
1. type – A prefix used to uniquely identify the map node.
467-
2. nodeId – The ID of the selected node.
468-
3. zoom – The current zoom level of the map.
469-
**Note: Zoom is only applied when type is set to `geoMap`**
463+
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:
464+
1. id – A prefix used to uniquely identify the map.
465+
2. nodeId – The id of the selected node.
466+
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.
470467

471468
- `onInit`
472469

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
@@ -144,8 +144,8 @@ class NetJSONGraphRender {
144144
// Preserve original NetJSON node for sidebar use
145145
/* eslint-disable no-underscore-dangle */
146146
nodeResult._source = JSON.parse(JSON.stringify(node));
147-
// Store the clicked node in this.selectedNode for easy access later without need for traverse
148-
self.utils.setSelectedNodeFromUrlFragments(self, fragments, node);
147+
// Store the clicked node in this.indexedNode for easy access later without need for traverse
148+
self.utils.setIndexedNodeFromUrlFragments(self, fragments, node);
149149
return nodeResult;
150150
});
151151
const links = JSONData.links.map((link) => {
@@ -245,8 +245,8 @@ class NetJSONGraphRender {
245245
});
246246
}
247247
}
248-
// Store the clicked node in this.selectedNode for easy access later without need for traverse
249-
self.utils.setSelectedNodeFromUrlFragments(self, fragments, node);
248+
// Store the clicked node in this.indexedNode for easy access later without need for traverse
249+
self.utils.setIndexedNodeFromUrlFragments(self, fragments, node);
250250
});
251251
links.forEach((link) => {
252252
if (!flatNodes[link.source]) {

src/js/netjsongraph.util.js

Lines changed: 37 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1200,61 +1200,77 @@ class NetJSONGraphUtil {
12001200
}
12011201

12021202
setUrlFragments(self, params) {
1203-
if (!self.config.bookmarkableActions.enabled) return;
1203+
if (!self.config.bookmarkableActions.enabled || params.data.cluster) return;
12041204
const fragments = this.parseUrlFragments();
12051205
const id = self.config.bookmarkableActions.id;
1206-
let nodeId, zoom;
1206+
let nodeId;
1207+
self.indexedNode = self.indexedNode || {};
12071208
if (params.componentSubType === "graph") {
12081209
nodeId = params.data.id;
1210+
self.indexedNode[nodeId] = params.data;
12091211
}
12101212
if (["scatter", "effectScatter"].includes(params.componentSubType)) {
12111213
nodeId = params.data.node.id;
1214+
self.indexedNode[nodeId] = params.data.node;
12121215
}
1213-
zoom = self?.leaflet?.getZoom();
1214-
if (!fragments[id] || !(fragments[id] instanceof URLSearchParams)) {
1216+
if (!fragments[id]) {
12151217
fragments[id] = new URLSearchParams();
12161218
fragments[id].set("id", id);
12171219
}
12181220
fragments[id].set("nodeId", nodeId);
1219-
if (zoom != null) {
1220-
fragments[id].set("zoom", zoom);
1221-
}
1222-
window.location.hash = this.generateUrlFragments(fragments);
1221+
const newHash = this.generateUrlFragments(fragments);
1222+
const state = self.indexedNode[nodeId];
1223+
history.pushState(state, "", `#${newHash}`);
12231224
}
12241225

1225-
removeUrlFragment(self, id) {
1226-
if (!self.config.bookmarkableActions.enabled) return;
1227-
1226+
removeUrlFragment(id) {
12281227
const fragments = this.parseUrlFragments();
12291228
if (fragments[id]) {
12301229
delete fragments[id];
12311230
}
1232-
window.location.hash = this.generateUrlFragments(fragments);
1231+
const newHash = this.generateUrlFragments(fragments);
1232+
const state = {id};
1233+
history.pushState(state, "", `#${newHash}`);
12331234
}
12341235

1235-
setSelectedNodeFromUrlFragments(self, fragments, node) {
1236-
if (!self.config.bookmarkableActions.enabled || !Object.keys(fragments).length) return;
1236+
setIndexedNodeFromUrlFragments(self, fragments, node) {
1237+
if (!self.config.bookmarkableActions.enabled || !Object.keys(fragments).length)
1238+
return;
12371239
const id = self.config.bookmarkableActions.id;
12381240
const nodeId = fragments[id]?.get("nodeId");
1239-
const zoom = fragments[id]?.get("zoom");
12401241
if (nodeId === node.id) {
1241-
self.selectedNode = node;
1242-
if (zoom != null) self.selectedNode.zoom = Number(zoom);
1242+
self.indexedNode = self.indexedNode || {};
1243+
self.indexedNode[nodeId] = node;
12431244
}
12441245
}
12451246

12461247
applyUrlFragmentState(self) {
12471248
if (!self.config.bookmarkableActions.enabled) return;
1248-
const node = self.selectedNode;
1249-
if (!node) return;
1249+
const id = self.config.bookmarkableActions.id;
1250+
const fragments = self.utils.parseUrlFragments();
1251+
const nodeId = fragments[id]?.get("nodeId");
1252+
if (!self.indexedNode || !self.indexedNode[nodeId]) return;
1253+
const node = self.indexedNode[nodeId];
12501254
const nodeType =
12511255
self.config.graphConfig.series.type || self.config.mapOptions.nodeConfig.type;
1252-
const { location, zoom } = node;
1253-
if (["scatter", "effectScatter"].includes(nodeType) && zoom != null) {
1256+
const {location, cluster} = node;
1257+
if (["scatter", "effectScatter"].includes(nodeType)) {
1258+
const zoom =
1259+
cluster != null ? self.config.disableClusteringAtLevel : self.leaflet.getZoom();
12541260
self.leaflet.setView([location.lat, location.lng], zoom);
12551261
}
12561262
self.config.onClickElement.call(self, "node", node);
12571263
}
1264+
1265+
setupHashChangeHandler(self) {
1266+
window.addEventListener("popstate", () => {
1267+
const currentNode = history.state;
1268+
if (currentNode != null && !self.indexedNode[currentNode.id]) {
1269+
self.indexedNode[currentNode.id] = currentNode;
1270+
}
1271+
this.applyUrlFragmentState(self);
1272+
});
1273+
}
12581274
}
12591275

12601276
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)