@neo4j-nvl/layout-workers 1.2.0-d671dd1f → 1.2.0-dbc13ec2

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.
@@ -4,8 +4,8 @@ import { layout } from './dagre-layout-impl.js';
4
4
  class HierarchicalLayoutFallbackWorker {
5
5
  start() { }
6
6
  postMessage(data) {
7
- const { nodes, nodeIds, idToPosition, rels, direction, packing, pixelRatio, forcedDelay = 0 } = data;
8
- const layoutData = layout(nodes, nodeIds, idToPosition, rels, direction, packing, pixelRatio);
7
+ const { nodes, nodeIds, idToPosition, rels, direction, packing, pixelRatio, forcedDelay = 0, clusterAssignments } = data;
8
+ const layoutData = layout(nodes, nodeIds, idToPosition, rels, direction, packing, pixelRatio, clusterAssignments);
9
9
  if (forcedDelay) {
10
10
  setTimeout(() => {
11
11
  this.onmessage({ data: layoutData });
@@ -2,8 +2,8 @@ import { layout } from './dagre-layout-impl.js';
2
2
  onconnect = ({ ports }) => {
3
3
  const port = ports[0];
4
4
  port.onmessage = ({ data }) => {
5
- const { nodes, nodeIds, idToPosition, rels, direction, packing, pixelRatio, forcedDelay = 0 } = data;
6
- const layoutData = layout(nodes, nodeIds, idToPosition, rels, direction, packing, pixelRatio);
5
+ const { nodes, nodeIds, idToPosition, rels, direction, packing, pixelRatio, forcedDelay = 0, clusterAssignments } = data;
6
+ const layoutData = layout(nodes, nodeIds, idToPosition, rels, direction, packing, pixelRatio, clusterAssignments);
7
7
  if (forcedDelay) {
8
8
  setTimeout(() => {
9
9
  port.postMessage(layoutData);
@@ -1,4 +1,4 @@
1
- export function layout(nodes: any, nodeIds: any, idToPosition: any, rels: any, direction: any, packing: any, pixelRatio?: number): {
1
+ export function layout(nodes: any, nodeIds: any, idToPosition: any, rels: any, direction: any, packing: any, pixelRatio: any, clusterAssignments: any): {
2
2
  positions: {};
3
3
  parents: {};
4
4
  waypoints: {};
@@ -56,9 +56,11 @@ const getGraphDimensions = (g) => {
56
56
  yOffset: minCenterY - minY
57
57
  };
58
58
  };
59
- const createGraph = (pixelRatio) => {
60
- // Create a new directed graph
61
- const g = new dagre.graphlib.Graph();
59
+ const SYNTHETIC_PARENT_PREFIX = 'syntheticParent:';
60
+ const toSyntheticParentNodeId = (clusterId) => `${SYNTHETIC_PARENT_PREFIX}${clusterId}`;
61
+ const createGraph = (pixelRatio, { compound = false } = {}) => {
62
+ // Create a new directed graph (compound when clusterBy assignments are provided)
63
+ const g = new dagre.graphlib.Graph(compound ? { compound: true } : undefined);
62
64
  // Set an object for the graph label
63
65
  g.setGraph({});
64
66
  // Default to assigning a new object as a label for each new edge.
@@ -70,6 +72,24 @@ const createGraph = (pixelRatio) => {
70
72
  g.graph().ranksep = 75 * pixelRatio;
71
73
  return g;
72
74
  };
75
+ const applySyntheticParents = (g, clusterAssignments) => {
76
+ if (!clusterAssignments) {
77
+ return new Set();
78
+ }
79
+ const createdSyntheticParents = new Set();
80
+ for (const [nodeId, clusterId] of Object.entries(clusterAssignments)) {
81
+ if (!g.hasNode(nodeId) || clusterId === undefined || clusterId === null) {
82
+ continue;
83
+ }
84
+ const syntheticParentNodeId = toSyntheticParentNodeId(clusterId);
85
+ if (!createdSyntheticParents.has(syntheticParentNodeId)) {
86
+ g.setNode(syntheticParentNodeId, { width: 0, height: 0 });
87
+ createdSyntheticParents.add(syntheticParentNodeId);
88
+ }
89
+ g.setParent(nodeId, syntheticParentNodeId);
90
+ }
91
+ return createdSyntheticParents;
92
+ };
73
93
  const findParentForEdges = (id, connectedNodes, layoutGraph) => {
74
94
  const { rank: currentRank } = layoutGraph.node(id);
75
95
  let pRank = null;
@@ -123,10 +143,14 @@ const getConnectedSubGraphs = (g, pixelRatio) => {
123
143
  }
124
144
  return subGraphs;
125
145
  };
126
- const layoutGraph = (g, direction, parents) => {
146
+ const layoutGraph = (g, direction, parents, syntheticParentNodeIds = new Set()) => {
127
147
  g.graph().ranker = Ranker;
128
148
  g.graph().rankdir = Directions[direction];
129
149
  const dagreLayoutGraph = dagre.layout(g);
150
+ syntheticParentNodeIds.forEach((id) => {
151
+ dagreLayoutGraph.removeNode(id);
152
+ g.removeNode(id);
153
+ });
130
154
  for (const id of dagreLayoutGraph.nodes()) {
131
155
  const pId = findParent(id, dagreLayoutGraph);
132
156
  if (pId !== null) {
@@ -158,8 +182,10 @@ const mergeStraightPoints = (points) => {
158
182
  mergedPoints.push(points[points.length - 1]);
159
183
  return mergedPoints;
160
184
  };
161
- export const layout = (nodes, nodeIds, idToPosition, rels, direction, packing, pixelRatio = 1) => {
162
- const g = createGraph(pixelRatio);
185
+ export const layout = (nodes, nodeIds, idToPosition, rels, direction, packing, pixelRatio, clusterAssignments) => {
186
+ const resolvedPixelRatio = pixelRatio ?? 1;
187
+ const useClusters = Boolean(clusterAssignments && Object.keys(clusterAssignments).length > 0);
188
+ const g = createGraph(resolvedPixelRatio, { compound: useClusters });
163
189
  const parents = {};
164
190
  const positionSum = { x: 0, y: 0 };
165
191
  const numNodes = nodes.length;
@@ -169,9 +195,10 @@ export const layout = (nodes, nodeIds, idToPosition, rels, direction, packing, p
169
195
  const position = idToPosition[n.id];
170
196
  positionSum.x += position?.x || 0;
171
197
  positionSum.y += position?.y || 0;
172
- const size = (n.size || DefaultNodeSize) * GlAdjust * pixelRatio;
198
+ const size = (n.size || DefaultNodeSize) * GlAdjust * resolvedPixelRatio;
173
199
  g.setNode(n.id, { width: size, height: size });
174
200
  }
201
+ const syntheticParentNodeIds = useClusters ? applySyntheticParents(g, clusterAssignments) : new Set();
175
202
  const prevNodeCenterPoint = numNodes ? [positionSum.x / numNodes, positionSum.y / numNodes] : [0, 0];
176
203
  // Add edges to the graph.
177
204
  const addedRel = {};
@@ -184,7 +211,9 @@ export const layout = (nodes, nodeIds, idToPosition, rels, direction, packing, p
184
211
  }
185
212
  }
186
213
  }
187
- const subGraphs = getConnectedSubGraphs(g, pixelRatio);
214
+ // With clusterBy, lay out one compound graph so same-cluster nodes stay together
215
+ // even across disconnected components. Otherwise keep component packing.
216
+ const subGraphs = useClusters ? [g] : getConnectedSubGraphs(g, resolvedPixelRatio);
188
217
  if (subGraphs.length > 1) {
189
218
  subGraphs.forEach((subGraph) => layoutGraph(subGraph, direction, parents));
190
219
  const isVertical = isDirectionVertical(direction);
@@ -316,7 +345,7 @@ export const layout = (nodes, nodeIds, idToPosition, rels, direction, packing, p
316
345
  }
317
346
  }
318
347
  else {
319
- layoutGraph(g, direction, parents);
348
+ layoutGraph(g, direction, parents, syntheticParentNodeIds);
320
349
  }
321
350
  positionSum.x = 0;
322
351
  positionSum.y = 0;
@@ -33,4 +33,94 @@ describe('Dagre-layout-impl', () => {
33
33
  expect(xs[i]).toBeGreaterThan(xs[i - 1]);
34
34
  }
35
35
  });
36
+ test('synthetic parent nodes are omitted from positions and animation parents', () => {
37
+ const nodes = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
38
+ const nodeIds = { a: { id: 'a' }, b: { id: 'b' }, c: { id: 'c' } };
39
+ const idToPosition = {
40
+ a: { id: 'a', x: 0, y: 0 },
41
+ b: { id: 'b', x: 0, y: 0 },
42
+ c: { id: 'c', x: 0, y: 0 }
43
+ };
44
+ const rels = [
45
+ { id: 'ab', from: 'a', to: 'b' },
46
+ { id: 'bc', from: 'b', to: 'c' }
47
+ ];
48
+ const clusterAssignments = { a: 'group-1', b: 'group-1', c: 'group-2' };
49
+ const { positions, parents } = layout(nodes, nodeIds, idToPosition, rels, 'down', 'bin', 1, clusterAssignments);
50
+ expect(Object.keys(positions).sort()).toEqual(nodes.map(({ id }) => id).sort());
51
+ expect(Object.values(parents).every((id) => id in nodeIds)).toBe(true);
52
+ });
53
+ test('clusterAssignments group same-cluster nodes', () => {
54
+ const categories = ['Person', 'Company', 'Product'];
55
+ const nodes = [];
56
+ const nodeIds = {};
57
+ const idToPosition = {};
58
+ const rels = [];
59
+ const clusterAssignments = {};
60
+ categories.forEach((category) => {
61
+ for (let i = 0; i < 4; i++) {
62
+ const id = `${category}-${i}`;
63
+ nodes.push({ id });
64
+ nodeIds[id] = { id };
65
+ idToPosition[id] = { id, x: 0, y: 0 };
66
+ clusterAssignments[id] = category;
67
+ if (i > 0) {
68
+ rels.push({ id: `${id}-prev`, from: `${category}-${i - 1}`, to: id });
69
+ }
70
+ }
71
+ });
72
+ rels.push({ id: 'cross-1', from: 'Person-0', to: 'Company-0' });
73
+ rels.push({ id: 'cross-2', from: 'Company-3', to: 'Product-0' });
74
+ const { positions } = layout(nodes, nodeIds, idToPosition, rels, 'down', 'bin', 1, clusterAssignments);
75
+ expect(Object.keys(positions)).toHaveLength(nodes.length);
76
+ const centroid = (ids) => {
77
+ let x = 0;
78
+ let y = 0;
79
+ ids.forEach((id) => {
80
+ x += positions[id].x;
81
+ y += positions[id].y;
82
+ });
83
+ return { x: x / ids.length, y: y / ids.length };
84
+ };
85
+ const clusterIds = categories.map((category) => nodes.filter((node) => node.id.startsWith(`${category}-`)).map((node) => node.id));
86
+ const centroids = clusterIds.map(centroid);
87
+ // Same-cluster members should be closer to their own centroid than to other centroids on average
88
+ clusterIds.forEach((ids, clusterIndex) => {
89
+ const own = centroids[clusterIndex];
90
+ const avgOwn = ids.reduce((sum, id) => {
91
+ const dx = positions[id].x - own.x;
92
+ const dy = positions[id].y - own.y;
93
+ return sum + Math.hypot(dx, dy);
94
+ }, 0) / ids.length;
95
+ const otherDistances = centroids
96
+ .filter((_, index) => index !== clusterIndex)
97
+ .map((other) => Math.hypot(own.x - other.x, own.y - other.y));
98
+ const minOtherCentroid = Math.min(...otherDistances);
99
+ expect(avgOwn).toBeLessThan(minOtherCentroid);
100
+ });
101
+ });
102
+ test('omitting clusterAssignments keeps the non-compound packing path', () => {
103
+ const nodes = [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }];
104
+ const nodeIds = {
105
+ a: { id: 'a' },
106
+ b: { id: 'b' },
107
+ c: { id: 'c' },
108
+ d: { id: 'd' }
109
+ };
110
+ const idToPosition = {
111
+ a: { id: 'a', x: 0, y: 0 },
112
+ b: { id: 'b', x: 0, y: 0 },
113
+ c: { id: 'c', x: 0, y: 0 },
114
+ d: { id: 'd', x: 0, y: 0 }
115
+ };
116
+ // Two disconnected components
117
+ const rels = [
118
+ { id: 'ab', from: 'a', to: 'b' },
119
+ { id: 'cd', from: 'c', to: 'd' }
120
+ ];
121
+ const { positions } = layout(nodes, nodeIds, idToPosition, rels, 'down', 'bin', 1);
122
+ expect(Object.keys(positions)).toHaveLength(4);
123
+ expect(positions.a).toBeDefined();
124
+ expect(positions.d).toBeDefined();
125
+ });
36
126
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neo4j-nvl/layout-workers",
3
- "version": "1.2.0-d671dd1f",
3
+ "version": "1.2.0-dbc13ec2",
4
4
  "license": "SEE LICENSE IN 'LICENSE.txt'",
5
5
  "description": "Layout workers for the Neo4j Visualization Library",
6
6
  "keywords": [
@@ -22,7 +22,8 @@
22
22
  "watch": "yarn global:tsc -w",
23
23
  "prepack": "cp ../../LICENSE.txt ./",
24
24
  "postpack": "rm LICENSE.txt",
25
- "eslint": "yarn global:eslint ./src/ --ext .js,.mjs"
25
+ "eslint": "yarn global:eslint ./src/ --ext .js,.mjs",
26
+ "test": "NODE_OPTIONS=--experimental-vm-modules yarn global:jest"
26
27
  },
27
28
  "typedoc": {
28
29
  "entryPoint": "./src/index.js",