@neo4j-nvl/interaction-handlers 1.2.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -19,6 +19,7 @@ describe('HoverInteraction', () => {
19
19
  hoverInteraction.destroy();
20
20
  myNVL.destroy();
21
21
  callbackMock.mockReset();
22
+ jest.restoreAllMocks();
22
23
  });
23
24
  describe('handleHover', () => {
24
25
  it('should register hover event on canvas', () => {
@@ -27,7 +28,7 @@ describe('HoverInteraction', () => {
27
28
  clientY: 10
28
29
  });
29
30
  hoverInteraction.handleHover(mouseEvent);
30
- expect(callbackMock).toHaveBeenCalledWith(undefined, { nodes: [], relationships: [] }, mouseEvent);
31
+ expect(callbackMock).toHaveBeenCalledWith(undefined, { nodes: [], relationships: [], clusters: [] }, mouseEvent);
31
32
  expect(callbackMock).toHaveBeenCalledTimes(1);
32
33
  });
33
34
  it('should register hover event for node', () => {
@@ -55,7 +56,8 @@ describe('HoverInteraction', () => {
55
56
  toTargetCoordinates: { x: 200, y: 200 },
56
57
  pointerCoordinates: { x: 100, y: 100 }
57
58
  }
58
- ]
59
+ ],
60
+ clusters: []
59
61
  }, mouseEvent);
60
62
  expect(callbackMock).toHaveBeenCalledTimes(1);
61
63
  });
@@ -75,7 +77,8 @@ describe('HoverInteraction', () => {
75
77
  toTargetCoordinates: { x: 200, y: 200 },
76
78
  pointerCoordinates: { x: 150, y: 150 }
77
79
  }
78
- ]
80
+ ],
81
+ clusters: []
79
82
  }, mouseEvent);
80
83
  expect(callbackMock).toHaveBeenCalledTimes(1);
81
84
  });
@@ -104,7 +107,8 @@ describe('HoverInteraction', () => {
104
107
  toTargetCoordinates: { x: 200, y: 200 },
105
108
  pointerCoordinates: { x: 100, y: 100 }
106
109
  }
107
- ]
110
+ ],
111
+ clusters: []
108
112
  }, mouseEvent);
109
113
  expect(callbackMock).toHaveBeenCalledTimes(1);
110
114
  const mouseEvent2 = new MouseEvent('mousemove', {
@@ -131,9 +135,10 @@ describe('HoverInteraction', () => {
131
135
  toTargetCoordinates: { x: 200, y: 200 },
132
136
  pointerCoordinates: { x: 100, y: 100 }
133
137
  }
134
- ]
138
+ ],
139
+ clusters: []
135
140
  }, mouseEvent2);
136
- expect(callbackMock).toHaveBeenCalledWith(undefined, { nodes: [], relationships: [] }, mouseEvent2);
141
+ expect(callbackMock).toHaveBeenCalledWith(undefined, { nodes: [], relationships: [], clusters: [] }, mouseEvent2);
137
142
  expect(callbackMock).toHaveBeenCalledTimes(2);
138
143
  });
139
144
  it('should register un-hover event for relationship', () => {
@@ -152,7 +157,8 @@ describe('HoverInteraction', () => {
152
157
  toTargetCoordinates: { x: 200, y: 200 },
153
158
  pointerCoordinates: { x: 150, y: 150 }
154
159
  }
155
- ]
160
+ ],
161
+ clusters: []
156
162
  }, mouseEvent);
157
163
  expect(callbackMock).toHaveBeenCalledTimes(1);
158
164
  const mouseEvent2 = new MouseEvent('mousemove', {
@@ -170,9 +176,10 @@ describe('HoverInteraction', () => {
170
176
  toTargetCoordinates: { x: 200, y: 200 },
171
177
  pointerCoordinates: { x: 150, y: 150 }
172
178
  }
173
- ]
179
+ ],
180
+ clusters: []
174
181
  }, mouseEvent2);
175
- expect(callbackMock).toHaveBeenCalledWith(undefined, { nodes: [], relationships: [] }, mouseEvent2);
182
+ expect(callbackMock).toHaveBeenCalledWith(undefined, { nodes: [], relationships: [], clusters: [] }, mouseEvent2);
176
183
  expect(callbackMock).toHaveBeenCalledTimes(2);
177
184
  });
178
185
  it('should un-hover node and hover relationships when moving mouse from node to relationship', () => {
@@ -200,7 +207,8 @@ describe('HoverInteraction', () => {
200
207
  toTargetCoordinates: { x: 200, y: 200 },
201
208
  pointerCoordinates: { x: 100, y: 100 }
202
209
  }
203
- ]
210
+ ],
211
+ clusters: []
204
212
  }, mouseEvent);
205
213
  expect(callbackMock).toHaveBeenCalledTimes(1);
206
214
  const mouseEvent2 = new MouseEvent('mousemove', {
@@ -227,7 +235,8 @@ describe('HoverInteraction', () => {
227
235
  toTargetCoordinates: { x: 200, y: 200 },
228
236
  pointerCoordinates: { x: 100, y: 100 }
229
237
  }
230
- ]
238
+ ],
239
+ clusters: []
231
240
  }, mouseEvent2);
232
241
  expect(callbackMock).toHaveBeenCalledWith({ id: '10', from: '0', to: '1', hovered: true }, {
233
242
  nodes: [],
@@ -239,7 +248,8 @@ describe('HoverInteraction', () => {
239
248
  toTargetCoordinates: { x: 200, y: 200 },
240
249
  pointerCoordinates: { x: 150, y: 150 }
241
250
  }
242
- ]
251
+ ],
252
+ clusters: []
243
253
  }, mouseEvent2);
244
254
  expect(callbackMock).toHaveBeenCalledTimes(2);
245
255
  });
@@ -259,7 +269,8 @@ describe('HoverInteraction', () => {
259
269
  toTargetCoordinates: { x: 200, y: 200 },
260
270
  pointerCoordinates: { x: 150, y: 150 }
261
271
  }
262
- ]
272
+ ],
273
+ clusters: []
263
274
  }, mouseEvent);
264
275
  expect(callbackMock).toHaveBeenCalledTimes(1);
265
276
  const mouseEvent2 = new MouseEvent('mousemove', {
@@ -286,7 +297,8 @@ describe('HoverInteraction', () => {
286
297
  toTargetCoordinates: { x: 200, y: 200 },
287
298
  pointerCoordinates: { x: 100, y: 100 }
288
299
  }
289
- ]
300
+ ],
301
+ clusters: []
290
302
  }, mouseEvent2);
291
303
  expect(callbackMock).toHaveBeenCalledWith({ id: '10', from: '0', to: '1', hovered: false }, {
292
304
  nodes: [],
@@ -298,9 +310,65 @@ describe('HoverInteraction', () => {
298
310
  toTargetCoordinates: { x: 200, y: 200 },
299
311
  pointerCoordinates: { x: 150, y: 150 }
300
312
  }
301
- ]
313
+ ],
314
+ clusters: []
302
315
  }, mouseEvent2);
303
316
  expect(callbackMock).toHaveBeenCalledTimes(2);
304
317
  });
318
+ it('should report a hovered cluster', () => {
319
+ const nvlTargets = {
320
+ nodes: [],
321
+ relationships: [],
322
+ clusters: [
323
+ {
324
+ data: { id: 'group-a', nodeIds: ['0', '1'] },
325
+ pointerCoordinates: { x: 50, y: 50 },
326
+ hull: [
327
+ { x: 0, y: 0 },
328
+ { x: 100, y: 0 },
329
+ { x: 100, y: 100 },
330
+ { x: 0, y: 100 }
331
+ ]
332
+ }
333
+ ]
334
+ };
335
+ jest.spyOn(myNVL, 'getHits').mockImplementation((event) => Object.assign(event, { nvlTargets }));
336
+ const mouseEvent = new MouseEvent('mousemove', { clientX: 50, clientY: 50 });
337
+ hoverInteraction.handleHover(mouseEvent);
338
+ expect(callbackMock).toHaveBeenCalledWith(undefined, nvlTargets, mouseEvent);
339
+ expect(callbackMock).toHaveBeenCalledTimes(1);
340
+ });
341
+ it('should keep the cluster in the hit elements when hovering one of its nodes', () => {
342
+ const nvlTargets = {
343
+ nodes: [
344
+ {
345
+ data: { id: '0', x: 100, y: 100 },
346
+ distance: 0,
347
+ targetCoordinates: { x: 100, y: 100 },
348
+ pointerCoordinates: { x: 100, y: 100 },
349
+ distanceVector: { x: 0, y: 0 },
350
+ insideNode: true
351
+ }
352
+ ],
353
+ relationships: [],
354
+ clusters: [
355
+ {
356
+ data: { id: 'group-a', nodeIds: ['0', '1'] },
357
+ pointerCoordinates: { x: 100, y: 100 },
358
+ hull: [
359
+ { x: 0, y: 0 },
360
+ { x: 200, y: 0 },
361
+ { x: 200, y: 200 },
362
+ { x: 0, y: 200 }
363
+ ]
364
+ }
365
+ ]
366
+ };
367
+ jest.spyOn(myNVL, 'getHits').mockImplementation((event) => Object.assign(event, { nvlTargets }));
368
+ const mouseEvent = new MouseEvent('mousemove', { clientX: 100, clientY: 100 });
369
+ hoverInteraction.handleHover(mouseEvent);
370
+ expect(callbackMock).toHaveBeenCalledWith({ id: '0', x: 100, y: 100 }, nvlTargets, mouseEvent);
371
+ expect(callbackMock).toHaveBeenCalledTimes(1);
372
+ });
305
373
  });
306
374
  });
@@ -6,7 +6,6 @@ describe('ZoomInteraction', () => {
6
6
  let zoomInteraction;
7
7
  let myNVL;
8
8
  let myNvlContainer;
9
- const zoomCallbackMock = jest.fn();
10
9
  const zoomAndPanCallbackMock = jest.fn();
11
10
  beforeEach(() => {
12
11
  myNVL = new NVL(document.createElement('div'), [], [], { disableWebGL: true, initialZoom: 1, layout: 'free' });
@@ -16,7 +15,6 @@ describe('ZoomInteraction', () => {
16
15
  afterEach(() => {
17
16
  zoomInteraction.destroy();
18
17
  myNVL.destroy();
19
- zoomCallbackMock.mockReset();
20
18
  zoomAndPanCallbackMock.mockReset();
21
19
  });
22
20
  const simulateWheelEvent = async (container, deltaY, delay = 50, eventOptions = {}) => {
@@ -38,31 +36,33 @@ describe('ZoomInteraction', () => {
38
36
  };
39
37
  describe('Scrolling', () => {
40
38
  test('zooming in should increase the scale', async () => {
41
- zoomInteraction.updateCallback('onZoom', zoomCallbackMock);
39
+ zoomInteraction.updateCallback('onZoomAndPan', zoomAndPanCallbackMock);
42
40
  const initialScale = myNVL.getScale();
43
41
  const container = myNVL.getContainer();
44
42
  await simulateWheelEvent(container, -100);
45
- expect(zoomCallbackMock).toHaveBeenCalledTimes(1);
43
+ expect(zoomAndPanCallbackMock).toHaveBeenCalledTimes(1);
46
44
  const newScale = myNVL.getScale();
47
45
  expect(newScale).toBeGreaterThan(initialScale);
48
46
  expect(newScale).toBe(1.2);
49
47
  });
50
48
  test('zooming out should decrease the scale', async () => {
51
- zoomInteraction.updateCallback('onZoom', zoomCallbackMock);
49
+ zoomInteraction.updateCallback('onZoomAndPan', zoomAndPanCallbackMock);
52
50
  const initialScale = myNVL.getScale();
53
51
  await simulateWheelEvent(myNvlContainer, 100);
54
- expect(zoomCallbackMock).toHaveBeenCalledTimes(1);
52
+ expect(zoomAndPanCallbackMock).toHaveBeenCalledTimes(1);
55
53
  const newScale = myNVL.getScale();
56
54
  expect(newScale).toBeLessThan(initialScale);
57
55
  expect(newScale).toBe(0.8);
58
56
  });
59
- test('zoom callback should be called with the correct zoom level', async () => {
60
- zoomInteraction.updateCallback('onZoom', zoomCallbackMock);
57
+ test('zoom callback should be called with the correct zoom level and pan values', async () => {
58
+ zoomInteraction.updateCallback('onZoomAndPan', zoomAndPanCallbackMock);
61
59
  const wheelEvent = await simulateWheelEvent(myNvlContainer, -100);
62
- expect(zoomCallbackMock).toHaveBeenCalledTimes(1);
63
- const zoomCallbackMockArguments = zoomCallbackMock.mock.calls[0] ?? [];
64
- expect(zoomCallbackMockArguments[0]).toBe(1.2);
65
- expect(zoomCallbackMockArguments[1]).toBe(wheelEvent);
60
+ expect(zoomAndPanCallbackMock).toHaveBeenCalledTimes(1);
61
+ const zoomAndPanCallbackMockArguments = zoomAndPanCallbackMock.mock.calls[0] ?? [];
62
+ expect(zoomAndPanCallbackMockArguments[0]).toBe(1.2);
63
+ expect(zoomAndPanCallbackMockArguments[1]).toBe(25);
64
+ expect(zoomAndPanCallbackMockArguments[2]).toBe(25);
65
+ expect(zoomAndPanCallbackMockArguments[3]).toBe(wheelEvent);
66
66
  });
67
67
  test('zooming should adjust pan to mouse position', async () => {
68
68
  const initialPan = myNVL.getPan();
@@ -92,53 +92,27 @@ describe('ZoomInteraction', () => {
92
92
  expect(newScale).toBeGreaterThan(2.5);
93
93
  expect(newScale).toBeLessThan(2.7);
94
94
  });
95
- test('onZoomAndPan callback should be called with zoom level and pan values', async () => {
96
- zoomInteraction.updateCallback('onZoomAndPan', zoomAndPanCallbackMock);
97
- const wheelEvent = await simulateWheelEvent(myNvlContainer, -100);
98
- expect(zoomAndPanCallbackMock).toHaveBeenCalledTimes(1);
99
- const zoomAndPanCallbackMockArguments = zoomAndPanCallbackMock.mock.calls[0] ?? [];
100
- expect(zoomAndPanCallbackMockArguments[0]).toBe(1.2);
101
- expect(zoomAndPanCallbackMockArguments[1]).toBe(25);
102
- expect(zoomAndPanCallbackMockArguments[2]).toBe(25);
103
- expect(zoomAndPanCallbackMockArguments[3]).toBe(wheelEvent);
104
- expect(zoomCallbackMock).toHaveBeenCalledTimes(0);
105
- });
106
- test('both callbacks should be called when both are registered', async () => {
107
- zoomInteraction.updateCallback('onZoom', zoomCallbackMock);
108
- zoomInteraction.updateCallback('onZoomAndPan', zoomAndPanCallbackMock);
109
- const wheelEvent = await simulateWheelEvent(myNvlContainer, -100);
110
- expect(zoomCallbackMock).toHaveBeenCalledTimes(1);
111
- expect(zoomAndPanCallbackMock).toHaveBeenCalledTimes(1);
112
- const zoomCallbackMockArguments = zoomCallbackMock.mock.calls[0] ?? [];
113
- expect(zoomCallbackMockArguments[0]).toBe(1.2);
114
- expect(zoomCallbackMockArguments[1]).toBe(wheelEvent);
115
- const zoomAndPanCallbackMockArguments = zoomAndPanCallbackMock.mock.calls[0] ?? [];
116
- expect(zoomAndPanCallbackMockArguments[0]).toBe(1.2);
117
- expect(zoomAndPanCallbackMockArguments[1]).toBe(25);
118
- expect(zoomAndPanCallbackMockArguments[2]).toBe(25);
119
- expect(zoomAndPanCallbackMockArguments[3]).toBe(wheelEvent);
120
- });
121
95
  });
122
96
  describe('Trackpad pinch gestures', () => {
123
97
  test('trackpad pinch out should zoom in', async () => {
124
- zoomInteraction.updateCallback('onZoom', zoomCallbackMock);
98
+ zoomInteraction.updateCallback('onZoomAndPan', zoomAndPanCallbackMock);
125
99
  await simulateWheelEvent(myNvlContainer, -75, 50, { ctrlKey: true });
126
- expect(zoomCallbackMock).toHaveBeenCalledTimes(1);
100
+ expect(zoomAndPanCallbackMock).toHaveBeenCalledTimes(1);
127
101
  const newScale = myNVL.getScale();
128
102
  expect(newScale).toBe(2);
129
103
  });
130
104
  test('trackpad pinch in should zoom out', async () => {
131
- zoomInteraction.updateCallback('onZoom', zoomCallbackMock);
105
+ zoomInteraction.updateCallback('onZoomAndPan', zoomAndPanCallbackMock);
132
106
  await simulateWheelEvent(myNvlContainer, 100, 50, { ctrlKey: true });
133
- expect(zoomCallbackMock).toHaveBeenCalledTimes(1);
107
+ expect(zoomAndPanCallbackMock).toHaveBeenCalledTimes(1);
134
108
  const newScale = myNVL.getScale();
135
109
  expect(newScale).toBe(0.075);
136
110
  });
137
111
  test('trackpad pinch should work with meta key (macOS)', async () => {
138
- zoomInteraction.updateCallback('onZoom', zoomCallbackMock);
112
+ zoomInteraction.updateCallback('onZoomAndPan', zoomAndPanCallbackMock);
139
113
  const initialScale = myNVL.getScale();
140
114
  await simulateWheelEvent(myNvlContainer, -50, 50, { metaKey: true });
141
- expect(zoomCallbackMock).toHaveBeenCalledTimes(1);
115
+ expect(zoomAndPanCallbackMock).toHaveBeenCalledTimes(1);
142
116
  const newScale = myNVL.getScale();
143
117
  expect(newScale).toBeGreaterThan(initialScale);
144
118
  });
@@ -183,12 +157,12 @@ describe('ZoomInteraction', () => {
183
157
  myNVL = new NVL(document.createElement('div'), [], [], { disableWebGL: true, initialZoom: 1, layout: 'free' });
184
158
  myNvlContainer = myNVL.getContainer();
185
159
  const zoomInteractionWithUpdate = new ZoomInteraction(myNVL, { controlledZoom: false });
186
- zoomInteractionWithUpdate.updateCallback('onZoom', zoomCallbackMock);
160
+ zoomInteractionWithUpdate.updateCallback('onZoomAndPan', zoomAndPanCallbackMock);
187
161
  const initialScale = myNVL.getScale();
188
162
  await simulateWheelEvent(myNvlContainer, -100);
189
163
  expect(myNVL.getScale()).not.toBe(initialScale);
190
164
  expect(myNVL.getScale()).toBe(1.2);
191
- expect(zoomCallbackMock).toHaveBeenCalledTimes(1);
165
+ expect(zoomAndPanCallbackMock).toHaveBeenCalledTimes(1);
192
166
  zoomInteractionWithUpdate.destroy();
193
167
  });
194
168
  test('when controlledZoom is true, should provide correct callback values for multiple operations', async () => {
@@ -20,7 +20,7 @@ export type BoxSelectInteractionCallbacks = {
20
20
  */
21
21
  onBoxStarted?: ((event: MouseEvent) => void) | boolean;
22
22
  /**
23
- * Called after once the user releases the mouse after multi-selecting.
23
+ * Called once the user releases the mouse after multi-selecting.
24
24
  * @param selectionObject - The selected nodes and relationships.
25
25
  * @param event - The original mouse event.
26
26
  */
@@ -1,11 +1,12 @@
1
- import type { HitTargets, NVL, Node, Relationship } from '@neo4j-nvl/base';
1
+ import type { Cluster, HitTargets, NVL, Node, Relationship } from '@neo4j-nvl/base';
2
2
  import { BaseInteraction } from './base';
3
3
  /**
4
4
  * Options for the click interaction handler to customize its behavior.
5
5
  */
6
6
  export type ClickInteractionOptions = {
7
7
  /**
8
- * Whether to select the node or relationship on click
8
+ * Whether to select the node, relationship, or cluster members on click.
9
+ * Clicking something that is already selected clears the selection.
9
10
  * @defaultValue false
10
11
  */
11
12
  selectOnClick?: boolean;
@@ -15,66 +16,87 @@ export type ClickInteractionOptions = {
15
16
  */
16
17
  export type ClickInteractionCallbacks = {
17
18
  /**
18
- * Called when a node is clicked
19
- * @param nodes - The node that was clicked.
19
+ * Called when a node is clicked.
20
+ * @param node - The node that was clicked.
20
21
  * @param hitElements - All elements that were hit by the click.
21
22
  * @param event - The original mouse event.
22
23
  */
23
- onNodeClick?: ((nodes: Node, hitElements: HitTargets, event: MouseEvent) => void) | boolean;
24
+ onNodeClick?: ((node: Node, hitElements: HitTargets, event: MouseEvent) => void) | boolean;
24
25
  /**
25
- * Called when a relationship is clicked
26
+ * Called when a relationship is clicked.
26
27
  * @param relationship - The relationship that was clicked.
27
28
  * @param hitElements - All elements that were hit by the click.
28
29
  * @param event - The original mouse event.
29
30
  */
30
31
  onRelationshipClick?: ((relationship: Relationship, hitElements: HitTargets, event: MouseEvent) => void) | boolean;
31
32
  /**
32
- * Called when the canvas is clicked
33
+ * Called when a cluster hull is clicked (empty space inside a clusterBy hull).
34
+ * @param cluster - The cluster that was clicked.
35
+ * @param hitElements - All elements that were hit by the click.
36
+ * @param event - The original mouse event.
37
+ */
38
+ onClusterClick?: ((cluster: Cluster, hitElements: HitTargets, event: MouseEvent) => void) | boolean;
39
+ /**
40
+ * Called when the canvas is clicked.
33
41
  * @param event - The original mouse event.
34
42
  */
35
43
  onCanvasClick?: ((event: MouseEvent) => void) | boolean;
36
44
  /**
37
- * Called when a node is double clicked
45
+ * Called when the canvas is double-clicked.
38
46
  * @param event - The original mouse event.
39
47
  */
40
48
  onCanvasDoubleClick?: ((event: MouseEvent) => void) | boolean;
41
49
  /**
42
- * Called when a relationship is right-clicked
50
+ * Called when the canvas is right-clicked.
43
51
  * @param event - The original mouse event.
44
52
  */
45
53
  onCanvasRightClick?: ((event: MouseEvent) => void) | boolean;
46
54
  /**
47
- * Called when a node is double-clicked
48
- * @param nodes - The node that was double-clicked.
55
+ * Called when a node is double-clicked.
56
+ * @param node - The node that was double-clicked.
49
57
  * @param hitElements - All elements that were hit by the double-click.
50
58
  * @param event - The original mouse event.
51
59
  */
52
60
  onNodeDoubleClick?: ((node: Node, hitElements: HitTargets, event: MouseEvent) => void) | boolean;
53
61
  /**
54
- * Called when a node is right-clicked
55
- * @param nodes - The node that was right-clicked.
62
+ * Called when a node is right-clicked.
63
+ * @param node - The node that was right-clicked.
56
64
  * @param hitElements - All elements that were hit by the right-click.
57
65
  * @param event - The original mouse event.
58
66
  */
59
67
  onNodeRightClick?: ((node: Node, hitElements: HitTargets, event: MouseEvent) => void) | boolean;
60
68
  /**
61
- * Called when a relationship is double-clicked
69
+ * Called when a relationship is double-clicked.
62
70
  * @param relationship - The relationship that was double-clicked.
63
71
  * @param hitElements - All elements that were hit by the double-click.
64
72
  * @param event - The original mouse event.
65
73
  */
66
74
  onRelationshipDoubleClick?: ((relationship: Relationship, hitElements: HitTargets, event: MouseEvent) => void) | boolean;
67
75
  /**
68
- * Called when a relationship is right-clicked
76
+ * Called when a relationship is right-clicked.
69
77
  * @param relationship - The relationship that was right-clicked.
70
78
  * @param hitElements - All elements that were hit by the right-click.
71
79
  * @param event - The original mouse event.
72
80
  */
73
81
  onRelationshipRightClick?: ((relationship: Relationship, hitElements: HitTargets, event: MouseEvent) => void) | boolean;
82
+ /**
83
+ * Called when a cluster hull is double-clicked.
84
+ * @param cluster - The cluster that was double-clicked.
85
+ * @param hitElements - All elements that were hit by the double-click.
86
+ * @param event - The original mouse event.
87
+ */
88
+ onClusterDoubleClick?: ((cluster: Cluster, hitElements: HitTargets, event: MouseEvent) => void) | boolean;
89
+ /**
90
+ * Called when a cluster hull is right-clicked.
91
+ * @param cluster - The cluster that was right-clicked.
92
+ * @param hitElements - All elements that were hit by the right-click.
93
+ * @param event - The original mouse event.
94
+ */
95
+ onClusterRightClick?: ((cluster: Cluster, hitElements: HitTargets, event: MouseEvent) => void) | boolean;
74
96
  };
75
97
  /**
76
98
  * Click interaction handler that handles click, double click and right click events on
77
- * nodes, relationships, and the scene.
99
+ * nodes, relationships, clusters, and the scene.
78
100
  *
79
101
  * For examples, head to the {@link https://neo4j.com/docs/nvl/current/interaction-handlers/#_clickinteraction Click Interaction documentation page}.
80
102
  */
@@ -91,6 +113,14 @@ export declare class ClickInteraction extends BaseInteraction<ClickInteractionOp
91
113
  private handleRightClick;
92
114
  private handleDoubleClick;
93
115
  private handleClick;
116
+ /**
117
+ * Makes the given elements the entire selection, deselecting everything else.
118
+ *
119
+ * Clicking something that is already fully selected clears the selection instead, so a repeated
120
+ * click toggles it off. A cluster counts as selected once all of its members are, which keeps
121
+ * cluster clicks behaving like node and relationship clicks.
122
+ */
123
+ private setSelection;
94
124
  /**
95
125
  * Removes all related event listeners from the container.
96
126
  */
@@ -1,8 +1,18 @@
1
1
  import { BaseInteraction } from './base';
2
2
  import { isDraggingMovement } from './utils';
3
+ /**
4
+ * Marks the given ids as selected and deselects everything that is currently selected but not in that list.
5
+ */
6
+ const buildSelectionUpdates = (idsToSelect, currentlySelected) => {
7
+ const keptSelected = new Set(idsToSelect);
8
+ return [
9
+ ...idsToSelect.map((id) => ({ id, selected: true })),
10
+ ...currentlySelected.filter(({ id }) => !keptSelected.has(id)).map(({ id }) => ({ id, selected: false }))
11
+ ];
12
+ };
3
13
  /**
4
14
  * Click interaction handler that handles click, double click and right click events on
5
- * nodes, relationships, and the scene.
15
+ * nodes, relationships, clusters, and the scene.
6
16
  *
7
17
  * For examples, head to the {@link https://neo4j.com/docs/nvl/current/interaction-handlers/#_clickinteraction Click Interaction documentation page}.
8
18
  */
@@ -27,8 +37,8 @@ export class ClickInteraction extends BaseInteraction {
27
37
  handleRightClick = (event) => {
28
38
  event.preventDefault();
29
39
  const { nvlTargets } = this.nvlInstance.getHits(event);
30
- const { nodes = [], relationships = [] } = nvlTargets;
31
- if (nodes.length === 0 && relationships.length === 0) {
40
+ const { nodes = [], relationships = [], clusters = [] } = nvlTargets;
41
+ if (nodes.length === 0 && relationships.length === 0 && clusters.length === 0) {
32
42
  this.callCallbackIfRegistered('onCanvasRightClick', event);
33
43
  return;
34
44
  }
@@ -38,11 +48,14 @@ export class ClickInteraction extends BaseInteraction {
38
48
  else if (relationships.length > 0) {
39
49
  this.callCallbackIfRegistered('onRelationshipRightClick', relationships[0]?.data, nvlTargets, event);
40
50
  }
51
+ else if (clusters.length > 0) {
52
+ this.callCallbackIfRegistered('onClusterRightClick', clusters[0]?.data, nvlTargets, event);
53
+ }
41
54
  };
42
55
  handleDoubleClick = (event) => {
43
56
  const { nvlTargets } = this.nvlInstance.getHits(event);
44
- const { nodes = [], relationships = [] } = nvlTargets;
45
- if (nodes.length === 0 && relationships.length === 0) {
57
+ const { nodes = [], relationships = [], clusters = [] } = nvlTargets;
58
+ if (nodes.length === 0 && relationships.length === 0 && clusters.length === 0) {
46
59
  this.callCallbackIfRegistered('onCanvasDoubleClick', event);
47
60
  return;
48
61
  }
@@ -52,14 +65,17 @@ export class ClickInteraction extends BaseInteraction {
52
65
  else if (relationships.length > 0) {
53
66
  this.callCallbackIfRegistered('onRelationshipDoubleClick', relationships[0]?.data, nvlTargets, event);
54
67
  }
68
+ else if (clusters.length > 0) {
69
+ this.callCallbackIfRegistered('onClusterDoubleClick', clusters[0]?.data, nvlTargets, event);
70
+ }
55
71
  };
56
72
  handleClick = (event) => {
57
73
  if (isDraggingMovement(event, this.mousePosition) || event.button !== 0) {
58
74
  return;
59
75
  }
60
76
  const { nvlTargets } = this.nvlInstance.getHits(event);
61
- const { nodes = [], relationships = [] } = nvlTargets;
62
- if (nodes.length === 0 && relationships.length === 0) {
77
+ const { nodes = [], relationships = [], clusters = [] } = nvlTargets;
78
+ if (nodes.length === 0 && relationships.length === 0 && clusters.length === 0) {
63
79
  if (this.currentOptions.selectOnClick === true) {
64
80
  this.nvlInstance.deselectAll();
65
81
  }
@@ -67,41 +83,46 @@ export class ClickInteraction extends BaseInteraction {
67
83
  return;
68
84
  }
69
85
  if (nodes.length > 0) {
70
- const hitNodeData = nodes.map((node) => node.data);
86
+ const node = nodes[0]?.data;
71
87
  if (this.currentOptions.selectOnClick === true) {
72
- const selectedNodes = this.nvlInstance.getSelectedNodes();
73
- const selectedRelationships = this.nvlInstance.getSelectedRelationships();
74
- const newSelectedNodes = hitNodeData[0] ? [{ id: hitNodeData[0].id, selected: true }] : [];
75
- const nodeUpdates = [...newSelectedNodes, ...selectedNodes.map((node) => ({ id: node.id, selected: false }))];
76
- const relationshipUpdates = selectedRelationships.map((relationship) => ({
77
- ...relationship,
78
- selected: false
79
- }));
80
- this.nvlInstance.updateElementsInGraph(nodeUpdates, relationshipUpdates);
88
+ this.setSelection(node ? [node.id] : [], []);
81
89
  }
82
- this.callCallbackIfRegistered('onNodeClick', nodes[0]?.data, nvlTargets, event);
90
+ this.callCallbackIfRegistered('onNodeClick', node, nvlTargets, event);
83
91
  }
84
92
  else if (relationships.length > 0) {
85
- const hitRelationshipData = relationships.map((relationship) => relationship.data);
93
+ const relationship = relationships[0]?.data;
94
+ if (this.currentOptions.selectOnClick === true) {
95
+ this.setSelection([], relationship ? [relationship.id] : []);
96
+ }
97
+ this.callCallbackIfRegistered('onRelationshipClick', relationship, nvlTargets, event);
98
+ }
99
+ else if (clusters.length > 0) {
100
+ const cluster = clusters[0]?.data;
86
101
  if (this.currentOptions.selectOnClick === true) {
87
- const selectedNodes = this.nvlInstance.getSelectedNodes();
88
- const selectedRelationships = this.nvlInstance.getSelectedRelationships();
89
- const nodeUpdates = selectedNodes.map((node) => ({ id: node.id, selected: false }));
90
- const newSelectedRelationships = hitRelationshipData[0]
91
- ? [{ id: hitRelationshipData[0].id, selected: true }]
92
- : [];
93
- const relationshipUpdates = [
94
- ...newSelectedRelationships,
95
- ...selectedRelationships.map((relationship) => ({
96
- ...relationship,
97
- selected: false
98
- }))
99
- ];
100
- this.nvlInstance.updateElementsInGraph(nodeUpdates, relationshipUpdates);
102
+ this.setSelection(cluster ? cluster.nodeIds : [], []);
101
103
  }
102
- this.callCallbackIfRegistered('onRelationshipClick', relationships[0]?.data, nvlTargets, event);
104
+ this.callCallbackIfRegistered('onClusterClick', cluster, nvlTargets, event);
103
105
  }
104
106
  };
107
+ /**
108
+ * Makes the given elements the entire selection, deselecting everything else.
109
+ *
110
+ * Clicking something that is already fully selected clears the selection instead, so a repeated
111
+ * click toggles it off. A cluster counts as selected once all of its members are, which keeps
112
+ * cluster clicks behaving like node and relationship clicks.
113
+ */
114
+ setSelection(nodeIds, relationshipIds) {
115
+ const selectedNodes = this.nvlInstance.getSelectedNodes();
116
+ const selectedRelationships = this.nvlInstance.getSelectedRelationships();
117
+ const selectedNodeIds = new Set(selectedNodes.map((n) => n.id));
118
+ const selectedRelationshipIds = new Set(selectedRelationships.map((r) => r.id));
119
+ const isAlreadySelected = nodeIds.length + relationshipIds.length > 0 &&
120
+ nodeIds.every((id) => selectedNodeIds.has(id)) &&
121
+ relationshipIds.every((id) => selectedRelationshipIds.has(id));
122
+ const nodeIdsToSelect = isAlreadySelected ? [] : nodeIds;
123
+ const relationshipIdsToSelect = isAlreadySelected ? [] : relationshipIds;
124
+ this.nvlInstance.updateElementsInGraph(buildSelectionUpdates(nodeIdsToSelect, selectedNodes), buildSelectionUpdates(relationshipIdsToSelect, selectedRelationships));
125
+ }
105
126
  /**
106
127
  * Removes all related event listeners from the container.
107
128
  */
@@ -1,11 +1,11 @@
1
1
  import type { HitTargets, NVL, Node, Relationship } from '@neo4j-nvl/base';
2
2
  import { BaseInteraction } from './base';
3
3
  /**
4
- * Options for the click interaction handler to customize its behavior.
4
+ * Options for the hover interaction handler to customize its behavior.
5
5
  */
6
6
  export type HoverInteractionOptions = {
7
7
  /**
8
- * Whether to draw a shadow on hover
8
+ * Whether to draw a shadow on hover for nodes/relationships
9
9
  * @default false
10
10
  */
11
11
  drawShadowOnHover?: boolean;
@@ -15,9 +15,9 @@ export type HoverInteractionOptions = {
15
15
  */
16
16
  export type HoverInteractionCallbacks = {
17
17
  /**
18
- * Called when a node or relationship is hovered
19
- * @param element - The node or relationship that was hovered.
20
- * @param hitElements - All elements that were hit by the hover.
18
+ * Called when a node or relationship is hovered.
19
+ * @param element - The highest-priority node or relationship under the pointer.
20
+ * @param hitElements - All nodes, relationships, and clusters under the pointer.
21
21
  * @param event - The original mouse event.
22
22
  * This callback is called every time the mouse moves,
23
23
  * even when no element is hovered or the current element is already hovered.
@@ -21,7 +21,7 @@ export type LassoInteractionCallbacks = {
21
21
  onLassoStarted?: ((event: MouseEvent) => void) | boolean;
22
22
  /**
23
23
  * Called once when the user releases the mouse after lasso selecting.
24
- * @param selectionObject - The selected node and relationship ids.
24
+ * @param selectionObject - The selected nodes and relationships.
25
25
  * @param event - The original mouse event.
26
26
  */
27
27
  onLassoSelect?: (({ nodes, rels }: {
@@ -1,7 +1,7 @@
1
1
  import type { NVL } from '@neo4j-nvl/base';
2
2
  import { BaseInteraction } from './base';
3
3
  /**
4
- * Options for the click interaction handler to customize its behavior.
4
+ * Options for the pan interaction handler to customize its behavior.
5
5
  */
6
6
  export type PanInteractionOptions = {
7
7
  /**