Skip to content

Commit f84ec0f

Browse files
committed
implemented metric-based color/value scaling in style editor
1 parent 7a7e3bf commit f84ec0f

5 files changed

Lines changed: 246 additions & 43 deletions

File tree

src/graph/filter.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,8 +110,11 @@ class GraphFilterManager {
110110
await this.cache.graph.hideElement(hideElementsDiff);
111111
this.cache.visibleElementsChanged = true;
112112
}
113+
if (this.cache.visibleElementsChanged) {
114+
this.cache.metrics.invalidateMetricValues();
115+
}
113116

114117
}
115118
}
116119

117-
export {GraphFilterManager};
120+
export {GraphFilterManager};

src/managers/metrics.js

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import {Popup} from "../utilities/popup.js";
22

33
const NODE_CONNECTIVITY_METRICS_PRECISION = 5;
4+
const METRIC_VALUE_LABELS = {
5+
centrality: "Centrality",
6+
betweenness: "Score",
7+
closeness: "Score",
8+
eigenvector: "Score",
9+
pagerank: "Score",
10+
};
411

512
const metrics = {
613
centrality: {
@@ -35,6 +42,7 @@ class NetworkMetrics {
3542
this.m = metrics;
3643
this.collapsed = false;
3744
this.cache = cache;
45+
this.metricValueCache = new Map();
3846

3947
this.selectBtns = {
4048
'Add to Selection': async () => this.updateSelectedNodes(true),
@@ -78,6 +86,7 @@ class NetworkMetrics {
7886
this.resetNodeToolTipMetricTexts();
7987

8088
const metricResult = await this.m[this.selected]?.calculate(this.cache);
89+
this.storeMetricValues(this.selected, metricResult);
8190

8291
/* multiselect */
8392
const selectedValues = Array.from(this.multiselect.selectedOptions, opt => opt.value);
@@ -112,6 +121,55 @@ class NetworkMetrics {
112121
await new Promise(resolve => requestAnimationFrame(resolve));
113122
}
114123

124+
storeMetricValues(metricId, metricResult) {
125+
if (!metricResult?.nodeValues) return;
126+
this.metricValueCache.set(metricId, {
127+
label: this.m[metricId]?.label || metricId,
128+
valueLabel: METRIC_VALUE_LABELS[metricId] || "Value",
129+
values: metricResult.nodeValues,
130+
});
131+
}
132+
133+
invalidateMetricValues() {
134+
this.metricValueCache.clear();
135+
}
136+
137+
async ensureMetricValues(metricId) {
138+
const existing = this.metricValueCache.get(metricId);
139+
if (existing?.values?.size) return existing;
140+
141+
const metric = this.m[metricId];
142+
if (!metric) return null;
143+
144+
const metricName = metric.label || metricId;
145+
await this.cache.ui.showLoading("Calculating", `Network Metric: ${metricName}`);
146+
await new Promise(resolve => requestAnimationFrame(resolve));
147+
148+
const metricResult = await metric.calculate(this.cache);
149+
this.storeMetricValues(metricId, metricResult);
150+
151+
await this.cache.ui.hideLoading();
152+
await new Promise(resolve => requestAnimationFrame(resolve));
153+
return this.metricValueCache.get(metricId) || null;
154+
}
155+
156+
getMetricScaleOptions() {
157+
const options = Object.values(this.m).map(metric => {
158+
const cached = this.metricValueCache.get(metric.id);
159+
return {
160+
id: metric.id,
161+
label: metric.label,
162+
valueLabel: METRIC_VALUE_LABELS[metric.id] || "Value",
163+
cached: !!cached?.values?.size,
164+
};
165+
});
166+
return options.sort((a, b) => a.label.localeCompare(b.label));
167+
}
168+
169+
getMetricScaleValues(metricId) {
170+
return this.metricValueCache.get(metricId) || null;
171+
}
172+
115173
resetNodeToolTipMetricTexts() {
116174
for (const nodeID of this.cache.toolTips.keys()) {
117175
this.updateNodeToolTipMetricText(nodeID, undefined, undefined, true);
@@ -284,11 +342,13 @@ async function calculateDegreeCentrality(cache) {
284342
((n - 1) * (n - 2))
285343
: 0;
286344

345+
const nodeValues = new Map(scores.map(s => [s.id, s.centrality]));
287346
return {
288347
scores: scores.map(s => ({
289348
id: s.id,
290349
text: `Degree ${s.degree} | Centrality ${s.centrality.toFixed(NODE_CONNECTIVITY_METRICS_PRECISION)} (${Math.round((s.centrality / max) * 100)} %)`
291350
})),
351+
nodeValues,
292352
graphLevelMetrics: {
293353
"Maximum Degree Centrality": max * (n - 1),
294354
"Minimum Degree Centrality": min * (n - 1),
@@ -434,11 +494,13 @@ async function calculateBetweennessCentrality(cache) {
434494
const min = Math.min(...centralityValues);
435495
const centralization = scores.reduce((acc, s) => acc + (max - s.score), 0) / ((n - 1) * (n - 2) / 2);
436496

497+
const nodeValues = new Map(scores.map(s => [s.id, s.score]));
437498
return {
438499
scores: scores.map(s => ({
439500
id: s.id,
440501
text: `Score: ${s.score.toFixed(NODE_CONNECTIVITY_METRICS_PRECISION)} (${Math.round((s.score / max) * 100)}%)`
441502
})),
503+
nodeValues,
442504
graphLevelMetrics: {
443505
"Maximum Betweenness Centrality": +max.toFixed(NODE_CONNECTIVITY_METRICS_PRECISION),
444506
"Minimum Betweenness Centrality": +min.toFixed(NODE_CONNECTIVITY_METRICS_PRECISION),
@@ -547,11 +609,13 @@ async function calculateClosenessCentrality(cache) {
547609
// Avoid division by zero in percentage calculations
548610
const maxForPercentage = max || 1;
549611

612+
const nodeValues = new Map(scores.map(s => [s.id, s.closeness]));
550613
return {
551614
scores: scores.map(s => ({
552615
id: s.id,
553616
text: `Score: ${s.closeness.toFixed(NODE_CONNECTIVITY_METRICS_PRECISION)} (${Math.round((s.closeness / maxForPercentage) * 100)}%)`
554617
})),
618+
nodeValues,
555619
graphLevelMetrics: {
556620
"Maximum Closeness Centrality": +max.toFixed(NODE_CONNECTIVITY_METRICS_PRECISION),
557621
"Minimum Closeness Centrality": +min.toFixed(NODE_CONNECTIVITY_METRICS_PRECISION),
@@ -654,11 +718,13 @@ async function calculateEigenvectorCentrality(cache) {
654718
const mean = eigenVector.reduce((a, b) => a + b) / n;
655719
const variance = eigenVector.reduce((acc, val) => acc + Math.pow(val - mean, 2), 0) / n;
656720

721+
const nodeValues = new Map(scores.map(s => [s.id, s.centrality]));
657722
return {
658723
scores: scores.map(s => ({
659724
id: s.id,
660725
text: `Score: ${s.centrality.toFixed(NODE_CONNECTIVITY_METRICS_PRECISION)} (${Math.round((s.centrality / max) * 100)}%)`
661726
})),
727+
nodeValues,
662728
graphLevelMetrics: {
663729
"Maximum Eigenvector Centrality": +max.toFixed(NODE_CONNECTIVITY_METRICS_PRECISION),
664730
"Minimum Eigenvector Centrality": +min.toFixed(NODE_CONNECTIVITY_METRICS_PRECISION),
@@ -780,11 +846,13 @@ async function calculatePageRank(cache) {
780846
const maxDegree = Math.max(...degrees);
781847
const avgDegree = degrees.reduce((a, b) => a + b) / n;
782848

849+
const nodeValues = new Map(sortedScores.map(s => [s.id, s.score]));
783850
return {
784851
scores: sortedScores.map(s => ({
785852
id: s.id,
786853
text: `Score: ${s.score.toFixed(NODE_CONNECTIVITY_METRICS_PRECISION)} (${Math.round((s.score / maxScore) * 100)}%)`
787854
})),
855+
nodeValues,
788856
graphLevelMetrics: {
789857
"Maximum PageRank Score": +maxScore.toFixed(NODE_CONNECTIVITY_METRICS_PRECISION),
790858
"Minimum PageRank Score": +minScore.toFixed(NODE_CONNECTIVITY_METRICS_PRECISION),
@@ -886,4 +954,4 @@ A node is important if it receives many links from other important nodes.
886954
};
887955
}
888956

889-
export { NetworkMetrics };
957+
export { NetworkMetrics };

src/managers/ui.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,12 +75,12 @@ class UIManager {
7575
}
7676

7777
toggleStyleElementsThatRequireAtLeastOneVisibleNode(enable) {
78-
this.toggleDisabledElements(["selectByNodeIDsInput", "Node ID(s)", "selectByNodeIDsSwitch",
78+
this.toggleDisabledElements(["selectByNodeIDsInput", "Node IDs", "selectByNodeIDsSwitch",
7979
"selectByNodeIDsSwitchLabel", "selectByNodeIDsButton"], enable);
8080
}
8181

8282
toggleStyleElementsThatRequireAtLeastOneVisibleEdge(enable) {
83-
this.toggleDisabledElements(["selectByEdgeIDsInput", "Edge ID(s)", "selectByEdgeIDsSwitch",
83+
this.toggleDisabledElements(["selectByEdgeIDsInput", "Edge IDs", "selectByEdgeIDsSwitch",
8484
"selectByEdgeIDsSwitchLabel", "selectByEdgeIDsButton"], enable);
8585
}
8686

src/utilities/color_scale_picker.js

Lines changed: 98 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ class ColorScalePicker {
1515
this.elementType = "nodes";
1616
this.currentProperty = null;
1717
this.dom = {};
18+
this.metricValuePrefix = "__metric__:";
19+
this.activeMetricSource = null;
1820

1921
this.cache = cache;
2022
}
@@ -146,32 +148,95 @@ class ColorScalePicker {
146148
});
147149
});
148150

151+
const metricOptions = this.elementType === 'nodes'
152+
? this.cache.metrics.getMetricScaleOptions()
153+
: [];
154+
149155
dropdown.innerHTML = '<option value="">Select property</option>';
150-
Array.from(available).sort().forEach(prop => {
151-
const opt = document.createElement('option');
152-
opt.value = prop;
153-
opt.textContent = prop;
154-
dropdown.appendChild(opt);
155-
});
156+
const propertyOptions = Array.from(available).sort();
157+
if (propertyOptions.length > 0) {
158+
const dataGroup = document.createElement('optgroup');
159+
dataGroup.label = 'Data Properties';
160+
propertyOptions.forEach(prop => {
161+
const opt = document.createElement('option');
162+
opt.value = prop;
163+
opt.textContent = prop;
164+
dataGroup.appendChild(opt);
165+
});
166+
dropdown.appendChild(dataGroup);
167+
}
168+
169+
if (metricOptions.length > 0) {
170+
const metricGroup = document.createElement('optgroup');
171+
metricGroup.label = 'Network Metrics';
172+
metricOptions.forEach(metric => {
173+
const opt = document.createElement('option');
174+
opt.value = `${this.metricValuePrefix}${metric.id}`;
175+
opt.textContent = `${metric.label} (${metric.valueLabel})${metric.cached ? '' : ' (calculate)'}`;
176+
metricGroup.appendChild(opt);
177+
});
178+
dropdown.appendChild(metricGroup);
179+
}
156180

157-
dropdown.onchange = () => this.selectProperty(dropdown.value, filters.get(dropdown.value));
181+
dropdown.onchange = async () => {
182+
const value = dropdown.value;
183+
const metricSource = this.getMetricSource(value);
184+
if (metricSource) {
185+
await this.selectProperty(value, {isCategory: false}, metricSource);
186+
} else {
187+
await this.selectProperty(value, filters.get(value));
188+
}
189+
};
158190
}
159191

160-
selectProperty(property, filterObj) {
192+
async selectProperty(property, filterObj, metricSource = null) {
161193
if (!property) return;
162194

163195
const selectedElements = this.elementType === 'nodes' ? this.cache.selectedNodes : this.cache.selectedEdges;
164196
const elementRef = this.elementType === 'nodes' ? this.cache.nodeRef : this.cache.edgeRef;
197+
if (property.startsWith(this.metricValuePrefix) && !metricSource) {
198+
const metricId = property.slice(this.metricValuePrefix.length);
199+
metricSource = await this.cache.metrics.ensureMetricValues(metricId);
200+
filterObj = {isCategory: false};
201+
}
202+
if (property.startsWith(this.metricValuePrefix) && !metricSource) {
203+
this.cache.ui.warning('Metric values not available yet. Calculate the metric first.');
204+
return;
205+
}
206+
if (!filterObj) {
207+
this.cache.ui.warning('No values found for selected property');
208+
return;
209+
}
210+
this.activeMetricSource = metricSource;
165211

212+
const values = [];
166213
const elementsWithProperty = Array.from(selectedElements)
167214
.filter(id => {
215+
if (metricSource) {
216+
const value = metricSource.values.get(id);
217+
if (value !== undefined) {
218+
values.push(value);
219+
return true;
220+
}
221+
return false;
222+
}
168223
const element = elementRef.get(id);
169-
return element?.featureValues.has(property);
224+
const value = element?.featureValues.get(property);
225+
if (value !== undefined) {
226+
values.push(value);
227+
return true;
228+
}
229+
return false;
170230
});
171231

172232
const totalElements = selectedElements.length;
173233
const elementsWithPropertyCount = elementsWithProperty.length;
174234

235+
if (!filterObj.isCategory && values.length === 0) {
236+
this.cache.ui.warning('No numeric values found for selected property');
237+
return;
238+
}
239+
175240
const existingCounter = this.element.querySelector('.picker-property-counter');
176241
if (existingCounter) {
177242
existingCounter.remove();
@@ -191,9 +256,9 @@ class ColorScalePicker {
191256
const elementTypeLabel = this.elementType === 'nodes' ? 'nodes' : 'edges';
192257

193258
// Extract property label after last "::"
194-
const propertyDisplayName = property.includes('::')
195-
? property.split('::').pop()
196-
: property;
259+
const propertyDisplayName = metricSource
260+
? `${metricSource.label} (${metricSource.valueLabel})`
261+
: (property.includes('::') ? property.split('::').pop() : property);
197262

198263
// Get the current property being styled (like "Node Fill Color")
199264
const targetProperty = this.currentProperty || 'color';
@@ -205,10 +270,6 @@ class ColorScalePicker {
205270

206271
// Add property range for continuous (non-category) properties
207272
if (!filterObj.isCategory) {
208-
const values = Array.from(elementsWithProperty)
209-
.map(id => elementRef.get(id)?.featureValues.get(property))
210-
.filter(v => v !== undefined);
211-
212273
const minVal = Math.min(...values);
213274
const maxVal = Math.max(...values);
214275

@@ -282,10 +343,13 @@ class ColorScalePicker {
282343
initializeGradient(property) {
283344
const selectedElements = this.elementType === 'nodes' ? this.cache.selectedNodes : this.cache.selectedEdges;
284345
const elementRef = this.elementType === 'nodes' ? this.cache.nodeRef : this.cache.edgeRef;
285-
286-
const values = Array.from(selectedElements)
287-
.map(id => elementRef.get(id)?.featureValues.get(property))
288-
.filter(v => v !== undefined);
346+
const values = this.activeMetricSource
347+
? Array.from(selectedElements)
348+
.map(id => this.activeMetricSource.values.get(id))
349+
.filter(v => v !== undefined)
350+
: Array.from(selectedElements)
351+
.map(id => elementRef.get(id)?.featureValues.get(property))
352+
.filter(v => v !== undefined);
289353

290354
this.minValue = Math.min(...values);
291355
this.maxValue = Math.max(...values);
@@ -434,7 +498,10 @@ class ColorScalePicker {
434498
const selectedElements = this.elementType === 'nodes' ? this.cache.selectedNodes : this.cache.selectedEdges;
435499
const elementRef = this.elementType === 'nodes' ? this.cache.nodeRef : this.cache.edgeRef;
436500

437-
const filterObj = this.cache.data.layouts[this.cache.data.selectedLayout].filters.get(dropdown.value);
501+
const metricSource = this.getMetricSource(dropdown.value);
502+
const filterObj = metricSource
503+
? {isCategory: false}
504+
: this.cache.data.layouts[this.cache.data.selectedLayout].filters.get(dropdown.value);
438505
const isCategory = filterObj?.isCategory;
439506

440507
if (isCategory) {
@@ -455,7 +522,9 @@ class ColorScalePicker {
455522
} else {
456523
Array.from(selectedElements).forEach(elementId => {
457524
const element = elementRef.get(elementId);
458-
const value = element?.featureValues.get(dropdown.value);
525+
const value = metricSource
526+
? metricSource.values.get(elementId)
527+
: element?.featureValues.get(dropdown.value);
459528

460529
if (value !== undefined) {
461530
const normalizedValue = ((value - this.minValue) / (this.maxValue - this.minValue)) * 100;
@@ -531,6 +600,12 @@ class ColorScalePicker {
531600
this.element?.remove();
532601
this.element = null;
533602
}
603+
604+
getMetricSource(property) {
605+
if (!property || !property.startsWith(this.metricValuePrefix)) return null;
606+
const metricId = property.slice(this.metricValuePrefix.length);
607+
return this.cache.metrics.getMetricScaleValues(metricId);
608+
}
534609
}
535610

536611
function replaceColorScale(obj, elemID, colorMap) {
@@ -557,4 +632,4 @@ function replaceColorScale(obj, elemID, colorMap) {
557632
return obj;
558633
}
559634

560-
export {ColorScalePicker, replaceColorScale};
635+
export {ColorScalePicker, replaceColorScale};

0 commit comments

Comments
 (0)