Skip to content

Commit c32fc1a

Browse files
committed
add pagerank
1 parent 058d6ea commit c32fc1a

8 files changed

Lines changed: 525 additions & 171 deletions

File tree

src/lib/computedData.ts

Lines changed: 57 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import _, { max, min, sortBy } from "lodash";
33
import { Dimensions } from "sigma/types";
44

55
import { findRanges } from "../utils/number";
6+
import { calculatePageRank } from "../utils/graphAlgorithms";
67
import {
78
DEFAULT_NODE_COLOR,
89
DEFAULT_NODE_SIZE_RATIO,
@@ -129,20 +130,62 @@ export function getNodeSizes(
129130

130131
if (typeof nodeSizeField === "string") {
131132
nodeSizes = {};
132-
const field = fieldsIndex[nodeSizeField];
133-
134-
if (field.type === "quanti") {
135-
getSize = (value: any) => {
136-
const size =
137-
typeof value === "number"
138-
? ((NODE_SIZE_MAX - NODE_SIZE_MIN) * (value - field.min)) / (field.max - field.min) + NODE_SIZE_MIN
139-
: NODE_DEFAULT_SIZE;
140-
return size * ratio * screenSizeRatio * graphSizeRatio;
141-
};
142-
graph.forEachNode((node, nodeData) => {
143-
nodeSizes![node] = getSize!(getValue(nodeData, field));
144-
});
145-
nodeSizeExtents = [field.min, field.max];
133+
134+
// Check if it's the PageRank option
135+
if (nodeSizeField === "pagerank") {
136+
try {
137+
// Calculate PageRank scores
138+
const pageRankScores = calculatePageRank(graph);
139+
const scores = Object.values(pageRankScores);
140+
const minScore = min(scores) as number;
141+
const maxScore = max(scores) as number;
142+
143+
getSize = (value: any) => {
144+
const size =
145+
typeof value === "number"
146+
? ((NODE_SIZE_MAX - NODE_SIZE_MIN) * (value - minScore)) / (maxScore - minScore) + NODE_SIZE_MIN
147+
: NODE_DEFAULT_SIZE;
148+
return size * ratio * screenSizeRatio * graphSizeRatio;
149+
};
150+
151+
graph.forEachNode((node) => {
152+
const score = pageRankScores[node] || 0;
153+
nodeSizes![node] = getSize!(score);
154+
});
155+
156+
nodeSizeExtents = [minScore, maxScore];
157+
} catch (error) {
158+
// Fallback to default sizing
159+
nodeSizes = {};
160+
const values = graph.mapNodes((_node, attributes) => attributes.rawSize);
161+
nodeSizeExtents = [min(values) as number, max(values) as number];
162+
graph.forEachNode((node, { rawSize }) => {
163+
nodeSizes[node] =
164+
(((NODE_SIZE_MAX - NODE_SIZE_MIN) * (rawSize - nodeSizeExtents[0])) /
165+
(nodeSizeExtents[1] - nodeSizeExtents[0]) +
166+
NODE_SIZE_MIN) *
167+
ratio *
168+
screenSizeRatio *
169+
graphSizeRatio;
170+
});
171+
}
172+
} else {
173+
// Handle regular field-based sizing
174+
const field = fieldsIndex[nodeSizeField];
175+
176+
if (field.type === "quanti") {
177+
getSize = (value: any) => {
178+
const size =
179+
typeof value === "number"
180+
? ((NODE_SIZE_MAX - NODE_SIZE_MIN) * (value - field.min)) / (field.max - field.min) + NODE_SIZE_MIN
181+
: NODE_DEFAULT_SIZE;
182+
return size * ratio * screenSizeRatio * graphSizeRatio;
183+
};
184+
graph.forEachNode((node, nodeData) => {
185+
nodeSizes![node] = getSize!(getValue(nodeData, field));
186+
});
187+
nodeSizeExtents = [field.min, field.max];
188+
}
146189
}
147190
} else {
148191
nodeSizes = {};

src/lib/graph.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,16 @@ export function applyNodeLabelSizes(
4747
const extentDelta = nodeSizeExtents[1] - nodeSizeExtents[0];
4848
const factor = (maxSize - minSize) / (extentDelta || 1);
4949
graph.forEachNode((node, nodeData) => {
50-
const nodeSize = nodeSizeField ? getValue(nodeData, fieldsIndex[nodeSizeField]) : nodeData.rawSize;
50+
let nodeSize: number;
51+
if (nodeSizeField === "pagerank") {
52+
// For PageRank, we need to get the size from the node's size attribute
53+
// since PageRank values are already applied as sizes
54+
nodeSize = graph.getNodeAttribute(node, "size") || nodeData.rawSize;
55+
} else if (nodeSizeField && fieldsIndex[nodeSizeField]) {
56+
nodeSize = getValue(nodeData, fieldsIndex[nodeSizeField]);
57+
} else {
58+
nodeSize = nodeData.rawSize;
59+
}
5160
graph.setNodeAttribute(node, "labelSize", minSize + (nodeSize - nodeSizeExtents[0]) * factor);
5261
});
5362
}
@@ -59,10 +68,10 @@ export function applyNodeSubtitles({ graph, fieldsIndex }: Data, { subtitleField
5968
"subtitles",
6069
subtitleFields
6170
? subtitleFields.flatMap((f) => {
62-
const field = fieldsIndex[f];
63-
const val = getValue(nodeData, field);
64-
return isNil(val) ? [] : [`${field.label}: ${typeof val === "number" ? val.toLocaleString() : val}`];
65-
})
71+
const field = fieldsIndex[f];
72+
const val = getValue(nodeData, field);
73+
return isNil(val) ? [] : [`${field.label}: ${typeof val === "number" ? val.toLocaleString() : val}`];
74+
})
6675
: [],
6776
),
6877
);

src/lib/navState.ts

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,16 @@ export interface NavState {
9191
edgeColoring?: EdgeColoring | undefined;
9292
edgeDirection?: EdgeDirection | undefined;
9393

94+
// Edge creation conditions:
95+
edgeCreationTopicThreshold?: number | undefined;
96+
edgeCreationContributorThreshold?: number | undefined;
97+
edgeCreationStargazerThreshold?: number | undefined;
98+
edgeCreationEnableTopicLinking?: boolean | undefined;
99+
edgeCreationEnableContributorOverlap?: boolean | undefined;
100+
edgeCreationEnableSharedOrganization?: boolean | undefined;
101+
edgeCreationEnableCommonStargazers?: boolean | undefined;
102+
edgeCreationEnableDependencies?: boolean | undefined;
103+
94104
// Only for some specific transitions:
95105
preventBlocker?: boolean;
96106
}
@@ -131,6 +141,15 @@ export function cleanNavState(state: NavState, data: Data): NavState {
131141
labelThresholdRatio,
132142
disableDefaultSize,
133143
disableDefaultColor,
144+
// Edge creation conditions
145+
edgeCreationTopicThreshold,
146+
edgeCreationContributorThreshold,
147+
edgeCreationStargazerThreshold,
148+
edgeCreationEnableTopicLinking,
149+
edgeCreationEnableContributorOverlap,
150+
edgeCreationEnableSharedOrganization,
151+
edgeCreationEnableCommonStargazers,
152+
edgeCreationEnableDependencies,
134153
} = state;
135154

136155
const cleanedSubtitleFields = uniq((subtitleFields || []).filter((f) => fieldsIndex[f]));
@@ -171,7 +190,7 @@ export function cleanNavState(state: NavState, data: Data): NavState {
171190
subtitleFields: cleanedSubtitleFields.length ? cleanedSubtitleFields : undefined,
172191
// Viewer state:
173192
nodeSizeField:
174-
(nodeSizeField && fieldsIndex[nodeSizeField] && cleanedSizeableIndex[nodeSizeField]
193+
(nodeSizeField && (nodeSizeField === "pagerank" || (fieldsIndex[nodeSizeField] && cleanedSizeableIndex[nodeSizeField]))
175194
? nodeSizeField
176195
: undefined) || (cleanedDisableDefaultSize ? cleanedSizeable[0] : undefined),
177196
nodeColorField:
@@ -205,9 +224,27 @@ export function cleanNavState(state: NavState, data: Data): NavState {
205224
maxLabelSize: cleanedMaxLabelSize !== DEFAULT_LABEL_SIZE ? cleanedMaxLabelSize : undefined,
206225
disableDefaultSize: cleanedDisableDefaultSize || undefined,
207226
disableDefaultColor: cleanedDisableDefaultColor || undefined,
227+
// Edge creation conditions - preserve as-is since they don't need cleaning
228+
edgeCreationTopicThreshold,
229+
edgeCreationContributorThreshold,
230+
edgeCreationStargazerThreshold,
231+
edgeCreationEnableTopicLinking,
232+
edgeCreationEnableContributorOverlap,
233+
edgeCreationEnableSharedOrganization,
234+
edgeCreationEnableCommonStargazers,
235+
edgeCreationEnableDependencies,
208236
};
209237

210-
return omitBy(cleanedState, isNil) as NavState;
238+
// Don't filter out edge creation conditions even if they're undefined initially
239+
// This allows them to be set later and persist
240+
const finalState = omitBy(cleanedState, (value, key) => {
241+
// Don't filter out edge creation conditions
242+
if (key.startsWith('edgeCreation')) return false;
243+
// Filter out other undefined values
244+
return isNil(value);
245+
});
246+
247+
return finalState as NavState;
211248
}
212249

213250
export function navStateToQueryURL(state: NavState): string {
@@ -247,11 +284,21 @@ export function navStateToQueryURL(state: NavState): string {
247284
if (state.minLabelSize) params.append("ls", state.minLabelSize + "");
248285
if (state.maxLabelSize) params.append("le", state.maxLabelSize + "");
249286

287+
// Edge creation conditions
288+
if (state.edgeCreationTopicThreshold) params.append("ett", state.edgeCreationTopicThreshold + "");
289+
if (state.edgeCreationContributorThreshold) params.append("ect", state.edgeCreationContributorThreshold + "");
290+
if (state.edgeCreationStargazerThreshold) params.append("est", state.edgeCreationStargazerThreshold + "");
291+
if (state.edgeCreationEnableTopicLinking) params.append("etl", "1");
292+
if (state.edgeCreationEnableContributorOverlap) params.append("eco", "1");
293+
if (state.edgeCreationEnableSharedOrganization) params.append("eso", "1");
294+
if (state.edgeCreationEnableCommonStargazers) params.append("ecs", "1");
295+
if (state.edgeCreationEnableDependencies) params.append("edp", "1");
296+
250297
return urlSearchParamsToString(params);
251298
}
252299

253300
export function queryURLToNavState(queryURL: string): NavState {
254-
const { url, l, r, s, c, n, fa, ca, sa, le, ls, nr, er, ec, ed, gm, lt, ds, dc, st, ...query } =
301+
const { url, l, r, s, c, n, fa, ca, sa, le, ls, nr, er, ec, ed, gm, lt, ds, dc, st, ett, ect, est, etl, eco, eso, ecs, edp, ...query } =
255302
queryStringToRecord(queryURL);
256303
const navState: NavState = {};
257304

@@ -276,6 +323,16 @@ export function queryURLToNavState(queryURL: string): NavState {
276323
if (fa) navState.filterable = arrayify(fa);
277324
if (st) navState.subtitleFields = arrayify(st);
278325

326+
// Edge creation conditions
327+
if (typeof ett === "string") navState.edgeCreationTopicThreshold = +ett;
328+
if (typeof ect === "string") navState.edgeCreationContributorThreshold = +ect;
329+
if (typeof est === "string") navState.edgeCreationStargazerThreshold = +est;
330+
if (typeof etl === "string") navState.edgeCreationEnableTopicLinking = etl === "1";
331+
if (typeof eco === "string") navState.edgeCreationEnableContributorOverlap = eco === "1";
332+
if (typeof eso === "string") navState.edgeCreationEnableSharedOrganization = eso === "1";
333+
if (typeof ecs === "string") navState.edgeCreationEnableCommonStargazers = ecs === "1";
334+
if (typeof edp === "string") navState.edgeCreationEnableDependencies = edp === "1";
335+
279336
const fields = groupBy(Object.keys(query), (key) => key.replace(/\.(v|t|min|max)$/, ""));
280337
const filters = map(fields, ([q0, q1], field): Filter => {
281338
// Terms case:

src/utils/graphAlgorithms.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { calculatePageRank } from './graphAlgorithms';
3+
4+
// Mock graph object for testing
5+
const createMockGraph = () => {
6+
const nodes = ['A', 'B', 'C', 'D'];
7+
const edges = [
8+
{ source: 'A', target: 'B' },
9+
{ source: 'A', target: 'C' },
10+
{ source: 'B', target: 'C' },
11+
{ source: 'C', target: 'D' },
12+
{ source: 'D', target: 'A' },
13+
];
14+
15+
return {
16+
nodes: () => nodes,
17+
outNeighbors: (node: string) => {
18+
return edges
19+
.filter(edge => edge.source === node)
20+
.map(edge => edge.target);
21+
}
22+
};
23+
};
24+
25+
describe('calculatePageRank', () => {
26+
it('should calculate PageRank scores for a simple graph', () => {
27+
const graph = createMockGraph();
28+
const scores = calculatePageRank(graph);
29+
30+
expect(scores).toBeDefined();
31+
expect(Object.keys(scores)).toHaveLength(4);
32+
expect(scores['A']).toBeGreaterThan(0);
33+
expect(scores['B']).toBeGreaterThan(0);
34+
expect(scores['C']).toBeGreaterThan(0);
35+
expect(scores['D']).toBeGreaterThan(0);
36+
37+
// All scores should sum to approximately 1
38+
const totalScore = Object.values(scores).reduce((sum, score) => sum + score, 0);
39+
expect(totalScore).toBeCloseTo(1, 2);
40+
});
41+
42+
it('should handle empty graph', () => {
43+
const emptyGraph = {
44+
nodes: () => [],
45+
outNeighbors: () => []
46+
};
47+
48+
const scores = calculatePageRank(emptyGraph);
49+
expect(scores).toEqual({});
50+
});
51+
52+
it('should handle single node graph', () => {
53+
const singleNodeGraph = {
54+
nodes: () => ['A'],
55+
outNeighbors: () => []
56+
};
57+
58+
const scores = calculatePageRank(singleNodeGraph);
59+
expect(scores).toEqual({ 'A': 1 });
60+
});
61+
62+
it('should converge within reasonable iterations', () => {
63+
const graph = createMockGraph();
64+
const scores = calculatePageRank(graph, 0.85, 50, 1e-6);
65+
66+
// Should converge and produce valid scores
67+
expect(Object.values(scores).every(score => score > 0 && score < 1)).toBe(true);
68+
});
69+
});

0 commit comments

Comments
 (0)