@fusion.dev/helix-graph 0.132.0 → 0.133.1

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.
package/README.md CHANGED
@@ -133,6 +133,12 @@ light. Each catches a glint from a light fixed in the scene, so the glint moves
133
133
  as the reader turns the graph. The selected note keeps its own color and is told apart by
134
134
  a ring and a glow in that color.
135
135
 
136
+ **Data without groups can take them from its links.** `groupByCommunity(notes,
137
+ links)` gives each note without a `group` the community its links form, found
138
+ by Louvain and named after its most linked note, so the drawing colors the
139
+ clusters and a `legend` names them. On 20,000 notes in twelve clusters it finds
140
+ the twelve in about 65 milliseconds.
141
+
136
142
  **A note without a `size` is as large as it is connected**, so hubs read at a
137
143
  glance. Links are tinted by the note they leave, the selected note's and the
138
144
  hovered note's in their color, and choosing a note in the list frames it with
@@ -345,7 +351,7 @@ connectGraph(frame, threeDriver(box), {
345
351
 
346
352
  | Import | What |
347
353
  |---|---|
348
- | `@fusion.dev/helix-graph` | `connectGraph`, `diffGraph`, `fitToStencil`, `graphColorsFrom`, `linkKey`, `seedPositions`, and the types `ConnectGraphOptions`, `DrawnLink`, `DrawnNode`, `GraphChanges`, `GraphColors`, `GraphConnection`, `GraphDriver`, `GraphGroup`, `GraphLooks`, `GraphPoint`, `GraphStencil`, `GraphTone`, `LinkLook`, `NodeLook`, `SeedLink`, `SeedNode`, `ShapeField` and `StencilFrame` |
354
+ | `@fusion.dev/helix-graph` | `communitiesOf`, `connectGraph`, `diffGraph`, `fitToStencil`, `graphColorsFrom`, `groupByCommunity`, `linkKey`, `seedPositions`, and the types `ConnectGraphOptions`, `DrawnLink`, `DrawnNode`, `GraphChanges`, `GraphColors`, `GraphConnection`, `GraphDriver`, `GraphGroup`, `GraphLooks`, `GraphPoint`, `GraphReach`, `GraphStencil`, `GraphTone`, `GroupByCommunityOptions`, `LinkLook`, `NodeLook`, `SeedLink`, `SeedNode`, `ShapeField` and `StencilFrame` |
349
355
  | `@fusion.dev/helix-graph/sigma` | `sigmaDriver(sigma, options)`, and the types `SigmaDriverOptions`, `SigmaGraph`, `SigmaLayout` and `SigmaRenderer` |
350
356
  | `@fusion.dev/helix-graph/cosmos` | `cosmosDriver(graph, box, options)`, and the types `CosmosDriverOptions` and `CosmosGraph` |
351
357
  | `@fusion.dev/helix-graph/three` | `threeDriver(box, options)`, and the type `ThreeDriverOptions` |
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Groups found in the links, for a graph whose notes carry none.
3
+ *
4
+ * **Louvain**, which moves each note to the neighboring community that raises
5
+ * the graph's modularity most, then folds each community into one note and
6
+ * moves those, until nothing moves. Written here, not taken from a package,
7
+ * so it costs a page no dependency and runs for every renderer: it reads the
8
+ * notes and links a page hands the frame, and gives back notes with a `group`.
9
+ *
10
+ * **The same communities on every load.** Notes are visited in the order the
11
+ * page gives them and a tie keeps the community a note is in, so nothing
12
+ * random decides where a note lands.
13
+ */
14
+ /** A link, as the frame takes it. */
15
+ interface Link {
16
+ source: string;
17
+ target: string;
18
+ }
19
+ /** A note, as the frame takes it, as far as communities read it. */
20
+ interface Note {
21
+ group?: string | undefined;
22
+ id: string;
23
+ label?: string | undefined;
24
+ }
25
+ /**
26
+ * Each note's community, by `id`, numbered from 0 in the order the
27
+ * communities first appear among the notes. A note no link touches is a
28
+ * community of its own.
29
+ */
30
+ export declare const communitiesOf: (ids: readonly string[], links: readonly Link[]) => Map<string, number>;
31
+ /** How `groupByCommunity` names and gathers what it finds. */
32
+ export interface GroupByCommunityOptions {
33
+ /**
34
+ * The fewest notes a community needs to be a group of its own. Smaller ones
35
+ * share one group, named by `rest`, so a scatter of loose notes is one name
36
+ * in the legend and not hundreds. 3 by default.
37
+ */
38
+ fewest?: number | undefined;
39
+ /** The group the small communities share. `Other` by default. */
40
+ rest?: string | undefined;
41
+ }
42
+ /**
43
+ * The notes, each without a group given one from the communities its links
44
+ * form, **named after its community's most linked note**, so a reader sees
45
+ * "Pricing" over the cluster around Pricing. A note with a group keeps it.
46
+ * Hand the result to the frame as its `nodes`; the drawing colors the groups
47
+ * and a `legend` names them.
48
+ */
49
+ export declare const groupByCommunity: <T extends Note>(nodes: readonly T[], links: readonly Link[], options?: GroupByCommunityOptions) => (T & {
50
+ group: string;
51
+ })[];
52
+ export {};
@@ -0,0 +1,154 @@
1
+ const GAIN = 1e-12;
2
+ const PASSES = 32;
3
+ const add = (list, at, by) => {
4
+ list[at] = (list[at] ?? 0) + by;
5
+ };
6
+ const settle = (level, total, community) => {
7
+ const count = level.degree.length;
8
+ const sums = new Array(count).fill(0);
9
+ for (let node = 0; node < count; node++) {
10
+ add(sums, community[node], level.degree[node]);
11
+ }
12
+ let movedAtAll = false;
13
+ for (let pass = 0; pass < PASSES; pass++) {
14
+ let moved = false;
15
+ for (let node = 0; node < count; node++) {
16
+ const own = community[node];
17
+ const degree = level.degree[node];
18
+ const toward = new Map();
19
+ for (const [other, weight] of level.near[node]) {
20
+ const at = community[other];
21
+ toward.set(at, (toward.get(at) ?? 0) + weight);
22
+ }
23
+ add(sums, own, -degree);
24
+ let best = own;
25
+ let bestGain = (toward.get(own) ?? 0) - (sums[own] * degree) / total;
26
+ for (const [at, weight] of toward) {
27
+ const gain = weight - (sums[at] * degree) / total;
28
+ if (gain > bestGain + GAIN) {
29
+ best = at;
30
+ bestGain = gain;
31
+ }
32
+ }
33
+ add(sums, best, degree);
34
+ if (best !== own) {
35
+ community[node] = best;
36
+ moved = true;
37
+ movedAtAll = true;
38
+ }
39
+ }
40
+ if (!moved) {
41
+ break;
42
+ }
43
+ }
44
+ return movedAtAll;
45
+ };
46
+ const renumber = (community) => {
47
+ const numbers = new Map();
48
+ return community.map((at) => {
49
+ let number = numbers.get(at);
50
+ if (number === undefined) {
51
+ number = numbers.size;
52
+ numbers.set(at, number);
53
+ }
54
+ return number;
55
+ });
56
+ };
57
+ const fold = (level, community, count) => {
58
+ const near = Array.from({ length: count }, () => new Map());
59
+ const degree = new Array(count).fill(0);
60
+ level.near.forEach((neighbors, node) => {
61
+ const from = community[node];
62
+ add(degree, from, level.degree[node]);
63
+ for (const [other, weight] of neighbors) {
64
+ const to = community[other];
65
+ if (to !== from) {
66
+ const ofFrom = near[from];
67
+ ofFrom.set(to, (ofFrom.get(to) ?? 0) + weight);
68
+ }
69
+ }
70
+ });
71
+ return { degree, near };
72
+ };
73
+ export const communitiesOf = (ids, links) => {
74
+ const index = new Map(ids.map((id, at) => [id, at]));
75
+ const near = ids.map(() => new Map());
76
+ const degree = new Array(ids.length).fill(0);
77
+ for (const { source, target } of links) {
78
+ const from = index.get(source);
79
+ const to = index.get(target);
80
+ if (from === undefined || to === undefined || from === to) {
81
+ continue;
82
+ }
83
+ const ofFrom = near[from];
84
+ const ofTo = near[to];
85
+ ofFrom.set(to, (ofFrom.get(to) ?? 0) + 1);
86
+ ofTo.set(from, (ofTo.get(from) ?? 0) + 1);
87
+ add(degree, from, 1);
88
+ add(degree, to, 1);
89
+ }
90
+ const total = degree.reduce((sum, one) => sum + one, 0);
91
+ let placed = ids.map((_, at) => at);
92
+ if (total === 0) {
93
+ return new Map(ids.map((id, at) => [id, at]));
94
+ }
95
+ let level = { degree, near };
96
+ for (;;) {
97
+ const community = level.degree.map((_, at) => at);
98
+ if (!settle(level, total, community)) {
99
+ break;
100
+ }
101
+ const numbered = renumber(community);
102
+ const count = Math.max(...numbered) + 1;
103
+ placed = placed.map((at) => numbered[at]);
104
+ if (count === level.degree.length) {
105
+ break;
106
+ }
107
+ level = fold(level, numbered, count);
108
+ }
109
+ const final = renumber(placed);
110
+ return new Map(ids.map((id, at) => [id, final[at]]));
111
+ };
112
+ export const groupByCommunity = (nodes, links, options = {}) => {
113
+ const fewest = options.fewest ?? 3;
114
+ const rest = options.rest ?? 'Other';
115
+ const found = communitiesOf(nodes.map((node) => node.id), links);
116
+ const degree = new Map();
117
+ for (const { source, target } of links) {
118
+ if (source !== target) {
119
+ degree.set(source, (degree.get(source) ?? 0) + 1);
120
+ degree.set(target, (degree.get(target) ?? 0) + 1);
121
+ }
122
+ }
123
+ const members = new Map();
124
+ for (const node of nodes) {
125
+ const at = found.get(node.id);
126
+ const list = members.get(at);
127
+ if (list) {
128
+ list.push(node);
129
+ }
130
+ else {
131
+ members.set(at, [node]);
132
+ }
133
+ }
134
+ const names = new Map();
135
+ const taken = new Set(nodes.flatMap((node) => (node.group ? [node.group] : [])));
136
+ for (const [at, list] of members) {
137
+ if (list.length < fewest) {
138
+ names.set(at, rest);
139
+ continue;
140
+ }
141
+ const hub = list.reduce((best, node) => (degree.get(node.id) ?? 0) > (degree.get(best.id) ?? 0) ? node : best);
142
+ const base = hub.label || hub.id;
143
+ let name = base;
144
+ for (let number = 2; taken.has(name) || name === rest; number++) {
145
+ name = `${base} ${number}`;
146
+ }
147
+ taken.add(name);
148
+ names.set(at, name);
149
+ }
150
+ return nodes.map((node) => ({
151
+ ...node,
152
+ group: node.group || names.get(found.get(node.id)),
153
+ }));
154
+ };
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export { communitiesOf, type GroupByCommunityOptions, groupByCommunity, } from './connect-graph/communities.js';
1
2
  export { type ConnectGraphOptions, connectGraph, type DrawnLink, type DrawnNode, type GraphChanges, type GraphConnection, type GraphDriver, type GraphGroup, type GraphReach, } from './connect-graph/connect-graph.js';
2
3
  export { diffGraph, linkKey } from './connect-graph/diff.js';
3
4
  export { type GraphColors, type GraphLooks, type GraphTone, graphColorsFrom, type LinkLook, type NodeLook, } from './connect-graph/looks.js';
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ export { communitiesOf, groupByCommunity, } from './connect-graph/communities.js';
1
2
  export { connectGraph, } from './connect-graph/connect-graph.js';
2
3
  export { diffGraph, linkKey } from './connect-graph/diff.js';
3
4
  export { graphColorsFrom, } from './connect-graph/looks.js';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@fusion.dev/helix-graph",
3
3
  "description": "Connects the Helix Graph frame to a graph renderer: Sigma.js, cosmos.gl, three.js, or a driver of your own.",
4
- "version": "0.132.0",
4
+ "version": "0.133.1",
5
5
  "license": "MIT",
6
6
  "author": "Ross Sheppard <ross.sheppard@fusion.dev>",
7
7
  "repository": {
@@ -51,12 +51,12 @@
51
51
  },
52
52
  "bugs": "https://github.com/project-fusion/fusion/issues",
53
53
  "dependencies": {
54
- "@fusion.dev/helix-core": "0.132.0"
54
+ "@fusion.dev/helix-core": "0.133.1"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@cosmos.gl/graph": "^3.4.2",
58
- "@fusion.dev/helix-headless": "0.132.0",
59
- "@fusion.dev/helix-testing": "0.132.0",
58
+ "@fusion.dev/helix-headless": "0.133.1",
59
+ "@fusion.dev/helix-testing": "0.133.1",
60
60
  "@types/bun": "^1.3.0",
61
61
  "@types/three": "^0.186.0",
62
62
  "d3-force-3d": "^3.0.6",