TSUMUGI 1.0.1__py3-none-any.whl
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.
- TSUMUGI/annotator.py +103 -0
- TSUMUGI/argparser.py +599 -0
- TSUMUGI/core.py +185 -0
- TSUMUGI/data/impc_phenodigm.csv +3406 -0
- TSUMUGI/data/mp.obo +143993 -0
- TSUMUGI/filterer.py +36 -0
- TSUMUGI/formatter.py +122 -0
- TSUMUGI/genewise_annotation_builder.py +94 -0
- TSUMUGI/io_handler.py +189 -0
- TSUMUGI/main.py +300 -0
- TSUMUGI/network_constructor.py +603 -0
- TSUMUGI/ontology_handler.py +62 -0
- TSUMUGI/pairwise_similarity_builder.py +66 -0
- TSUMUGI/report_generator.py +122 -0
- TSUMUGI/similarity_calculator.py +498 -0
- TSUMUGI/subcommands/count_filterer.py +47 -0
- TSUMUGI/subcommands/genes_filterer.py +89 -0
- TSUMUGI/subcommands/graphml_builder.py +158 -0
- TSUMUGI/subcommands/life_stage_filterer.py +48 -0
- TSUMUGI/subcommands/mp_filterer.py +142 -0
- TSUMUGI/subcommands/score_filterer.py +22 -0
- TSUMUGI/subcommands/sex_filterer.py +48 -0
- TSUMUGI/subcommands/webapp_builder.py +358 -0
- TSUMUGI/subcommands/zygosity_filterer.py +48 -0
- TSUMUGI/validator.py +65 -0
- TSUMUGI/web/app/css/app.css +1129 -0
- TSUMUGI/web/app/genelist/network_genelist.html +339 -0
- TSUMUGI/web/app/genelist/network_genelist.js +421 -0
- TSUMUGI/web/app/js/data/dataLoader.js +41 -0
- TSUMUGI/web/app/js/export/graphExporter.js +214 -0
- TSUMUGI/web/app/js/graph/centrality.js +495 -0
- TSUMUGI/web/app/js/graph/components.js +30 -0
- TSUMUGI/web/app/js/graph/filters.js +158 -0
- TSUMUGI/web/app/js/graph/highlighter.js +52 -0
- TSUMUGI/web/app/js/graph/layoutController.js +454 -0
- TSUMUGI/web/app/js/graph/valueScaler.js +43 -0
- TSUMUGI/web/app/js/search/geneSearcher.js +93 -0
- TSUMUGI/web/app/js/search/phenotypeSearcher.js +292 -0
- TSUMUGI/web/app/js/ui/dynamicFontSize.js +30 -0
- TSUMUGI/web/app/js/ui/mobilePanel.js +77 -0
- TSUMUGI/web/app/js/ui/slider.js +22 -0
- TSUMUGI/web/app/js/ui/tooltips.js +514 -0
- TSUMUGI/web/app/js/viewer/pageSetup.js +217 -0
- TSUMUGI/web/app/viewer.html +515 -0
- TSUMUGI/web/app/viewer.js +1593 -0
- TSUMUGI/web/css/sanitize.css +363 -0
- TSUMUGI/web/css/top.css +391 -0
- TSUMUGI/web/image/tsumugi-favicon.ico +0 -0
- TSUMUGI/web/image/tsumugi-icon.png +0 -0
- TSUMUGI/web/image/tsumugi-logo.png +0 -0
- TSUMUGI/web/image/tsumugi-logo.svg +69 -0
- TSUMUGI/web/js/genelist_formatter.js +123 -0
- TSUMUGI/web/js/top.js +338 -0
- TSUMUGI/web/open_webapp_linux.sh +25 -0
- TSUMUGI/web/open_webapp_mac.command +25 -0
- TSUMUGI/web/open_webapp_windows.bat +37 -0
- TSUMUGI/web/serve_index.py +110 -0
- TSUMUGI/web/template/template_index.html +197 -0
- TSUMUGI/web_deployer.py +150 -0
- tsumugi-1.0.1.dist-info/METADATA +504 -0
- tsumugi-1.0.1.dist-info/RECORD +64 -0
- tsumugi-1.0.1.dist-info/WHEEL +4 -0
- tsumugi-1.0.1.dist-info/entry_points.txt +3 -0
- tsumugi-1.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1593 @@
|
|
|
1
|
+
import { exportGraphAsPNG, exportGraphAsJPG, exportGraphAsCSV, exportGraphAsGraphML, exportGraphAsSVG } from "./js/export/graphExporter.js";
|
|
2
|
+
import { scaleToOriginalRange, getColorForValue } from "./js/graph/valueScaler.js";
|
|
3
|
+
import { initInfoTooltips, removeTooltips, showSubnetworkTooltip, showTooltip } from "./js/ui/tooltips.js";
|
|
4
|
+
import { getOrderedComponents, calculateConnectedComponents } from "./js/graph/components.js";
|
|
5
|
+
import { createSlider } from "./js/ui/slider.js";
|
|
6
|
+
import { filterElementsByGenotypeAndSex } from "./js/graph/filters.js";
|
|
7
|
+
import { loadJSON } from "./js/data/dataLoader.js";
|
|
8
|
+
import {
|
|
9
|
+
applyNodeMinMax,
|
|
10
|
+
getPageConfig,
|
|
11
|
+
hidePhenotypeOnlySections,
|
|
12
|
+
isBinaryPhenotypeElements,
|
|
13
|
+
loadElementsForConfig,
|
|
14
|
+
renderEmptyState,
|
|
15
|
+
setPageTitle,
|
|
16
|
+
setVersionLabel,
|
|
17
|
+
} from "./js/viewer/pageSetup.js";
|
|
18
|
+
import { createLayoutController } from "./js/graph/layoutController.js";
|
|
19
|
+
import { setupGeneSearch } from "./js/search/geneSearcher.js";
|
|
20
|
+
import { highlightDiseaseAnnotation } from "./js/graph/highlighter.js";
|
|
21
|
+
import { setupPhenotypeSearch } from "./js/search/phenotypeSearcher.js";
|
|
22
|
+
import { initializeCentralitySystem, recalculateCentrality } from "./js/graph/centrality.js";
|
|
23
|
+
import { initDynamicFontSize } from "./js/ui/dynamicFontSize.js";
|
|
24
|
+
import { initMobilePanel } from "./js/ui/mobilePanel.js";
|
|
25
|
+
|
|
26
|
+
if (window.cytoscape && window.cytoscapeSvg && typeof window.cytoscape.use === "function") {
|
|
27
|
+
window.cytoscape.use(window.cytoscapeSvg);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const NODE_SLIDER_MIN = 1;
|
|
31
|
+
const NODE_SLIDER_MAX = 100;
|
|
32
|
+
const EDGE_SLIDER_MIN = 1;
|
|
33
|
+
const EDGE_SLIDER_MAX = 100;
|
|
34
|
+
const AUTO_ARRANGE_DELAY_MS = 150;
|
|
35
|
+
const AUTO_ARRANGE_LAYOUT_TIMEOUT_MS = 4000;
|
|
36
|
+
const AUTO_ARRANGE_REPULSION_TIMEOUT_MS = 2000;
|
|
37
|
+
const INITIAL_AUTO_ARRANGE_TIMEOUT_MS = 15000;
|
|
38
|
+
const INITIAL_ARRANGE_CLICK_DELAY_MS = 500;
|
|
39
|
+
const REPULSION_FINISH_EVENT = "tsumugi:repulsion:finish";
|
|
40
|
+
|
|
41
|
+
// Initialize UI helpers that only depend on DOM availability.
|
|
42
|
+
initInfoTooltips();
|
|
43
|
+
initDynamicFontSize();
|
|
44
|
+
initMobilePanel();
|
|
45
|
+
|
|
46
|
+
// Track which search mode is active in this viewer
|
|
47
|
+
const pageConfig = getPageConfig();
|
|
48
|
+
const isPhenotypePage = pageConfig.mode === "phenotype";
|
|
49
|
+
const isGeneSymbolPage = pageConfig.mode === "genesymbol";
|
|
50
|
+
|
|
51
|
+
let subnetworkOverlay = null;
|
|
52
|
+
|
|
53
|
+
function updateNoNodesMessage(shouldShow) {
|
|
54
|
+
const messageEl = document.getElementById("no-nodes-message");
|
|
55
|
+
if (!messageEl) return;
|
|
56
|
+
|
|
57
|
+
if (messageEl.textContent !== "No Gene Network Found") {
|
|
58
|
+
messageEl.textContent = "No Gene Network Found";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
messageEl.style.display = shouldShow ? "block" : "none";
|
|
62
|
+
|
|
63
|
+
if (!subnetworkOverlay) {
|
|
64
|
+
subnetworkOverlay = document.querySelector(".subnetwork-overlay");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (subnetworkOverlay) {
|
|
68
|
+
subnetworkOverlay.style.display = shouldShow ? "none" : "";
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
setVersionLabel();
|
|
73
|
+
|
|
74
|
+
const mapSymbolToId = loadJSON("../data/marker_symbol_accession_id.json") || {};
|
|
75
|
+
const mapPhenotypeToId = loadJSON("../data/mp_term_id_lookup.json") || {};
|
|
76
|
+
setPageTitle(pageConfig, mapSymbolToId, mapPhenotypeToId);
|
|
77
|
+
|
|
78
|
+
const elements = loadElementsForConfig(pageConfig);
|
|
79
|
+
if (!elements || elements.length === 0) {
|
|
80
|
+
if (isGeneSymbolPage) {
|
|
81
|
+
updateNoNodesMessage(true);
|
|
82
|
+
}
|
|
83
|
+
renderEmptyState("No data found. Please check your input.");
|
|
84
|
+
throw new Error("No elements available to render");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const isBinaryPhenotype = isPhenotypePage && isBinaryPhenotypeElements(elements);
|
|
88
|
+
hidePhenotypeOnlySections(isPhenotypePage && !isBinaryPhenotype);
|
|
89
|
+
|
|
90
|
+
// ############################################################################
|
|
91
|
+
// Input handler
|
|
92
|
+
// ############################################################################
|
|
93
|
+
|
|
94
|
+
const nodeColorValues = elements
|
|
95
|
+
.filter((ele) => ele.data.node_color !== undefined)
|
|
96
|
+
.map((ele) => ele.data.node_color);
|
|
97
|
+
const nodeColorMin = nodeColorValues.length ? Math.min(...nodeColorValues) : 0;
|
|
98
|
+
const nodeColorMax = nodeColorValues.length ? Math.max(...nodeColorValues) : 1;
|
|
99
|
+
|
|
100
|
+
let nodeMin = nodeColorMin;
|
|
101
|
+
let nodeMax = nodeColorMax;
|
|
102
|
+
|
|
103
|
+
if (isPhenotypePage) {
|
|
104
|
+
const adjusted = applyNodeMinMax(elements, nodeColorMin, nodeColorMax);
|
|
105
|
+
nodeMin = adjusted.nodeMin;
|
|
106
|
+
nodeMax = adjusted.nodeMax;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const edgeSizes = elements.filter((ele) => ele.data.edge_size !== undefined).map((ele) => ele.data.edge_size);
|
|
110
|
+
const edgeMin = edgeSizes.length ? Math.min(...edgeSizes) : 0;
|
|
111
|
+
const edgeMax = edgeSizes.length ? Math.max(...edgeSizes) : 1;
|
|
112
|
+
|
|
113
|
+
const baseElements = JSON.parse(JSON.stringify(elements));
|
|
114
|
+
|
|
115
|
+
function mapEdgeSizeToWidth(edgeSize) {
|
|
116
|
+
if (edgeMax === edgeMin) {
|
|
117
|
+
return 1.5;
|
|
118
|
+
}
|
|
119
|
+
const normalized = (edgeSize - edgeMin) / (edgeMax - edgeMin);
|
|
120
|
+
return 0.5 + normalized * 1.5;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ############################################################################
|
|
124
|
+
// Initialize Cytoscape
|
|
125
|
+
// ############################################################################
|
|
126
|
+
|
|
127
|
+
const defaultNodeRepulsion = 5;
|
|
128
|
+
const layoutController = createLayoutController({
|
|
129
|
+
isGeneSymbolPage,
|
|
130
|
+
defaultNodeRepulsion,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const cy = cytoscape({
|
|
134
|
+
container: document.querySelector(".cy"),
|
|
135
|
+
elements: elements,
|
|
136
|
+
style: [
|
|
137
|
+
{
|
|
138
|
+
selector: "node",
|
|
139
|
+
style: {
|
|
140
|
+
label: "data(label)",
|
|
141
|
+
"text-valign": "center",
|
|
142
|
+
"text-halign": "center",
|
|
143
|
+
"font-size": isGeneSymbolPage ? "10px" : "20px",
|
|
144
|
+
width: 15,
|
|
145
|
+
height: 15,
|
|
146
|
+
"background-color": function (ele) {
|
|
147
|
+
const originalColor = ele.data("original_node_color") || ele.data("node_color");
|
|
148
|
+
return getColorForValue(originalColor, nodeColorMin, nodeColorMax);
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
selector: "edge",
|
|
154
|
+
style: {
|
|
155
|
+
"curve-style": "bezier",
|
|
156
|
+
"text-rotation": "autorotate",
|
|
157
|
+
width: function (ele) {
|
|
158
|
+
return mapEdgeSizeToWidth(ele.data("edge_size"));
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
selector: "node.dim-node",
|
|
164
|
+
style: {
|
|
165
|
+
opacity: 0.05,
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
selector: "edge.dim-edge",
|
|
170
|
+
style: {
|
|
171
|
+
opacity: 0.05,
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
selector: "node.focus-node",
|
|
176
|
+
style: {
|
|
177
|
+
opacity: 1,
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
selector: "edge.focus-edge",
|
|
182
|
+
style: {
|
|
183
|
+
opacity: 1,
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
selector: ".disease-highlight",
|
|
188
|
+
style: {
|
|
189
|
+
"border-width": 5,
|
|
190
|
+
"border-color": "#fc4c00",
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
selector: ".gene-highlight",
|
|
195
|
+
style: {
|
|
196
|
+
"color": "#006400",
|
|
197
|
+
"font-weight": "bold",
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
selector: ".phenotype-highlight",
|
|
202
|
+
style: {
|
|
203
|
+
"border-width": 5,
|
|
204
|
+
"border-color": "#3FA7D6",
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
],
|
|
208
|
+
layout: layoutController.getLayoutOptions(),
|
|
209
|
+
userZoomingEnabled: true,
|
|
210
|
+
zoomingEnabled: true,
|
|
211
|
+
wheelSensitivity: 0.2,
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
window.cy = cy;
|
|
215
|
+
layoutController.attachCy(cy);
|
|
216
|
+
layoutController.registerInitialLayoutStop();
|
|
217
|
+
setupInitialAutoArrange();
|
|
218
|
+
cy.one("render", () => {
|
|
219
|
+
checkEmptyState();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const bodyContainer = document.querySelector(".body-container");
|
|
223
|
+
const leftPanelToggleButton = document.getElementById("toggle-left-panel");
|
|
224
|
+
const rightPanelToggleButton = document.getElementById("toggle-right-panel");
|
|
225
|
+
|
|
226
|
+
// Smooth wheel zoom on the Cytoscape canvas
|
|
227
|
+
const cyContainer = cy.container();
|
|
228
|
+
if (cyContainer) {
|
|
229
|
+
cyContainer.addEventListener(
|
|
230
|
+
"wheel",
|
|
231
|
+
(event) => {
|
|
232
|
+
event.preventDefault();
|
|
233
|
+
const zoomFactor = event.deltaY > 0 ? 0.9 : 1.1;
|
|
234
|
+
const rect = cyContainer.getBoundingClientRect();
|
|
235
|
+
const renderedPosition = {
|
|
236
|
+
x: event.clientX - rect.left,
|
|
237
|
+
y: event.clientY - rect.top,
|
|
238
|
+
};
|
|
239
|
+
const targetZoom = cy.zoom() * zoomFactor;
|
|
240
|
+
const clampedZoom = Math.min(cy.maxZoom(), Math.max(cy.minZoom(), targetZoom));
|
|
241
|
+
cy.zoom({ level: clampedZoom, renderedPosition });
|
|
242
|
+
scheduleSubnetworkFrameUpdate();
|
|
243
|
+
},
|
|
244
|
+
{ passive: false },
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function resetPanelStatesForMobile() {
|
|
249
|
+
if (!bodyContainer) return;
|
|
250
|
+
|
|
251
|
+
if (window.innerWidth <= 600) {
|
|
252
|
+
const hadHiddenPanel =
|
|
253
|
+
bodyContainer.classList.contains("left-panel-hidden") ||
|
|
254
|
+
bodyContainer.classList.contains("right-panel-hidden");
|
|
255
|
+
|
|
256
|
+
bodyContainer.classList.remove("left-panel-hidden", "right-panel-hidden");
|
|
257
|
+
|
|
258
|
+
if (leftPanelToggleButton) {
|
|
259
|
+
leftPanelToggleButton.classList.remove("collapsed");
|
|
260
|
+
leftPanelToggleButton.setAttribute("aria-label", "Hide left panel");
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (rightPanelToggleButton) {
|
|
264
|
+
rightPanelToggleButton.classList.remove("collapsed");
|
|
265
|
+
rightPanelToggleButton.setAttribute("aria-label", "Hide right panel");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (hadHiddenPanel) {
|
|
269
|
+
refreshCyViewport();
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function handleMobileResize() {
|
|
275
|
+
resetPanelStatesForMobile();
|
|
276
|
+
|
|
277
|
+
if (cy) {
|
|
278
|
+
setTimeout(() => {
|
|
279
|
+
refreshCyViewport();
|
|
280
|
+
}, 300);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
setTimeout(() => {
|
|
285
|
+
if (window.innerWidth <= 600) {
|
|
286
|
+
resetPanelStatesForMobile();
|
|
287
|
+
refreshCyViewport();
|
|
288
|
+
}
|
|
289
|
+
}, 500);
|
|
290
|
+
|
|
291
|
+
window.addEventListener("resize", handleMobileResize);
|
|
292
|
+
window.addEventListener("orientationchange", () => {
|
|
293
|
+
setTimeout(handleMobileResize, 500);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// ############################################################################
|
|
297
|
+
// Module (connected component) frames & tooltips
|
|
298
|
+
// ############################################################################
|
|
299
|
+
|
|
300
|
+
subnetworkOverlay = createSubnetworkOverlay();
|
|
301
|
+
let subnetworkMeta = [];
|
|
302
|
+
let isFrameUpdateQueued = false;
|
|
303
|
+
let subnetworkDragState = null;
|
|
304
|
+
const COMPONENT_PADDING = 16;
|
|
305
|
+
const COMPONENT_MAX_ITER = 30;
|
|
306
|
+
const COMPONENT_FIT_PADDING = 40;
|
|
307
|
+
|
|
308
|
+
function createSubnetworkOverlay() {
|
|
309
|
+
const cyContainer = document.querySelector(".cy");
|
|
310
|
+
const overlay = document.createElement("div");
|
|
311
|
+
overlay.classList.add("subnetwork-overlay");
|
|
312
|
+
cyContainer.appendChild(overlay);
|
|
313
|
+
return overlay;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function summarizeEdgePhenotypes(component) {
|
|
317
|
+
const counts = new Map();
|
|
318
|
+
component
|
|
319
|
+
.edges()
|
|
320
|
+
.filter((edge) => edge.visible())
|
|
321
|
+
.forEach((edge) => {
|
|
322
|
+
const phenotypes = Array.isArray(edge.data("phenotype"))
|
|
323
|
+
? edge.data("phenotype")
|
|
324
|
+
: edge.data("phenotype")
|
|
325
|
+
? [edge.data("phenotype")]
|
|
326
|
+
: [];
|
|
327
|
+
phenotypes.forEach((name) => {
|
|
328
|
+
counts.set(name, (counts.get(name) || 0) + 1);
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
return [...counts.entries()].sort((a, b) => {
|
|
333
|
+
if (b[1] === a[1]) {
|
|
334
|
+
return a[0].localeCompare(b[0]);
|
|
335
|
+
}
|
|
336
|
+
return b[1] - a[1];
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function updateSubnetworkFrames() {
|
|
341
|
+
if (!subnetworkOverlay) return;
|
|
342
|
+
subnetworkOverlay.innerHTML = "";
|
|
343
|
+
subnetworkMeta = [];
|
|
344
|
+
|
|
345
|
+
const visibleComponents = getOrderedComponents(cy);
|
|
346
|
+
const padding = 16;
|
|
347
|
+
const containerWidth = cy.width();
|
|
348
|
+
const containerHeight = cy.height();
|
|
349
|
+
|
|
350
|
+
visibleComponents.forEach((component, idx) => {
|
|
351
|
+
if (component.nodes().length === 0) return;
|
|
352
|
+
const bbox = component.renderedBoundingBox({ includeOverlays: false, includeLabels: true });
|
|
353
|
+
if (!bbox || !Number.isFinite(bbox.x1) || !Number.isFinite(bbox.y1)) {
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const rawLeft = bbox.x1 - padding;
|
|
358
|
+
const rawTop = bbox.y1 - padding;
|
|
359
|
+
const rawRight = bbox.x1 + bbox.w + padding;
|
|
360
|
+
const rawBottom = bbox.y1 + bbox.h + padding;
|
|
361
|
+
|
|
362
|
+
const visibleLeft = Math.max(0, rawLeft);
|
|
363
|
+
const visibleTop = Math.max(0, rawTop);
|
|
364
|
+
const visibleRight = Math.min(containerWidth, rawRight);
|
|
365
|
+
const visibleBottom = Math.min(containerHeight, rawBottom);
|
|
366
|
+
|
|
367
|
+
const width = visibleRight - visibleLeft;
|
|
368
|
+
const height = visibleBottom - visibleTop;
|
|
369
|
+
|
|
370
|
+
if (width <= 0 || height <= 0) return;
|
|
371
|
+
|
|
372
|
+
const frame = document.createElement("div");
|
|
373
|
+
frame.classList.add("subnetwork-frame");
|
|
374
|
+
frame.dataset.componentId = String(idx + 1);
|
|
375
|
+
frame.style.left = `${visibleLeft}px`;
|
|
376
|
+
frame.style.top = `${visibleTop}px`;
|
|
377
|
+
frame.style.width = `${width}px`;
|
|
378
|
+
frame.style.height = `${height}px`;
|
|
379
|
+
|
|
380
|
+
const label = document.createElement("div");
|
|
381
|
+
label.classList.add("subnetwork-frame__label");
|
|
382
|
+
label.textContent = `Module ${idx + 1}`;
|
|
383
|
+
label.dataset.componentId = String(idx + 1);
|
|
384
|
+
frame.appendChild(label);
|
|
385
|
+
|
|
386
|
+
const borders = ["top", "bottom", "left", "right"];
|
|
387
|
+
borders.forEach((side) => {
|
|
388
|
+
const border = document.createElement("div");
|
|
389
|
+
border.classList.add("subnetwork-frame__border", `subnetwork-frame__border--${side}`);
|
|
390
|
+
border.dataset.componentId = String(idx + 1);
|
|
391
|
+
frame.appendChild(border);
|
|
392
|
+
attachFrameDragHandlers(border, border);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
subnetworkOverlay.appendChild(frame);
|
|
396
|
+
attachFrameDragHandlers(frame, label);
|
|
397
|
+
|
|
398
|
+
const summary = summarizeEdgePhenotypes(component);
|
|
399
|
+
subnetworkMeta.push({
|
|
400
|
+
id: idx + 1,
|
|
401
|
+
bbox: { x1: visibleLeft, y1: visibleTop, x2: visibleLeft + width, y2: visibleTop + height },
|
|
402
|
+
phenotypes: summary,
|
|
403
|
+
nodes: component.nodes(),
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function scheduleSubnetworkFrameUpdate(options = {}) {
|
|
409
|
+
const { resolve = false, autoFit = false } = options;
|
|
410
|
+
if (isFrameUpdateQueued) return;
|
|
411
|
+
isFrameUpdateQueued = true;
|
|
412
|
+
requestAnimationFrame(() => {
|
|
413
|
+
if (resolve) {
|
|
414
|
+
resolveComponentOverlaps();
|
|
415
|
+
}
|
|
416
|
+
updateSubnetworkFrames();
|
|
417
|
+
if (autoFit) {
|
|
418
|
+
fitVisibleComponents();
|
|
419
|
+
}
|
|
420
|
+
isFrameUpdateQueued = false;
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function translateComponent(comp, dx, dy) {
|
|
425
|
+
comp.nodes().positions((node) => {
|
|
426
|
+
const pos = node.position();
|
|
427
|
+
return { x: pos.x + dx, y: pos.y + dy };
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function resolveComponentOverlaps() {
|
|
432
|
+
const components = cy.elements(":visible").components().filter((comp) => comp.nodes().length > 0);
|
|
433
|
+
if (components.length <= 1) return false;
|
|
434
|
+
|
|
435
|
+
const zoom = cy.zoom() || 1;
|
|
436
|
+
let movedAny = false;
|
|
437
|
+
|
|
438
|
+
for (let iter = 0; iter < COMPONENT_MAX_ITER; iter++) {
|
|
439
|
+
let moved = false;
|
|
440
|
+
for (let i = 0; i < components.length; i++) {
|
|
441
|
+
const bboxA = components[i].renderedBoundingBox({ includeLabels: true, includeOverlays: false });
|
|
442
|
+
for (let j = i + 1; j < components.length; j++) {
|
|
443
|
+
const bboxB = components[j].renderedBoundingBox({ includeLabels: true, includeOverlays: false });
|
|
444
|
+
|
|
445
|
+
const ax1 = bboxA.x1 - COMPONENT_PADDING;
|
|
446
|
+
const ax2 = bboxA.x2 + COMPONENT_PADDING;
|
|
447
|
+
const ay1 = bboxA.y1 - COMPONENT_PADDING;
|
|
448
|
+
const ay2 = bboxA.y2 + COMPONENT_PADDING;
|
|
449
|
+
const bx1 = bboxB.x1 - COMPONENT_PADDING;
|
|
450
|
+
const bx2 = bboxB.x2 + COMPONENT_PADDING;
|
|
451
|
+
const by1 = bboxB.y1 - COMPONENT_PADDING;
|
|
452
|
+
const by2 = bboxB.y2 + COMPONENT_PADDING;
|
|
453
|
+
|
|
454
|
+
const overlapX = Math.min(ax2, bx2) - Math.max(ax1, bx1);
|
|
455
|
+
const overlapY = Math.min(ay2, by2) - Math.max(ay1, by1);
|
|
456
|
+
|
|
457
|
+
if (overlapX <= 0 || overlapY <= 0) {
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const centerA = { x: (bboxA.x1 + bboxA.x2) / 2, y: (bboxA.y1 + bboxA.y2) / 2 };
|
|
462
|
+
const centerB = { x: (bboxB.x1 + bboxB.x2) / 2, y: (bboxB.y1 + bboxB.y2) / 2 };
|
|
463
|
+
let dx = centerB.x - centerA.x;
|
|
464
|
+
let dy = centerB.y - centerA.y;
|
|
465
|
+
if (Math.abs(dx) < 1 && Math.abs(dy) < 1) {
|
|
466
|
+
dx = 1;
|
|
467
|
+
dy = 0;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
let shiftX = 0;
|
|
471
|
+
let shiftY = 0;
|
|
472
|
+
if (overlapX < overlapY) {
|
|
473
|
+
shiftX = Math.sign(dx) * (overlapX + COMPONENT_PADDING);
|
|
474
|
+
} else {
|
|
475
|
+
shiftY = Math.sign(dy) * (overlapY + COMPONENT_PADDING);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
translateComponent(components[j], shiftX / zoom, shiftY / zoom);
|
|
479
|
+
moved = true;
|
|
480
|
+
movedAny = true;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
if (!moved) {
|
|
484
|
+
break;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
return movedAny;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function tileComponents() {
|
|
492
|
+
const components = cy.elements(":visible").components().filter((comp) => comp.nodes().length > 0);
|
|
493
|
+
if (components.length === 0) return false;
|
|
494
|
+
|
|
495
|
+
const bboxes = components.map((comp) => comp.boundingBox({ includeLabels: true, includeOverlays: false }));
|
|
496
|
+
const maxW = Math.max(...bboxes.map((b) => b.w));
|
|
497
|
+
const maxH = Math.max(...bboxes.map((b) => b.h));
|
|
498
|
+
const tilePadding = COMPONENT_PADDING;
|
|
499
|
+
const tileW = maxW + tilePadding * 2;
|
|
500
|
+
const tileH = maxH + tilePadding * 2;
|
|
501
|
+
const cols = Math.max(1, Math.ceil(Math.sqrt(components.length)));
|
|
502
|
+
|
|
503
|
+
components.forEach((comp, idx) => {
|
|
504
|
+
const col = idx % cols;
|
|
505
|
+
const row = Math.floor(idx / cols);
|
|
506
|
+
const targetCenter = {
|
|
507
|
+
x: col * tileW + tileW / 2,
|
|
508
|
+
y: row * tileH + tileH / 2,
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
const bbox = bboxes[idx];
|
|
512
|
+
const compCenter = {
|
|
513
|
+
x: (bbox.x1 + bbox.x2) / 2,
|
|
514
|
+
y: (bbox.y1 + bbox.y2) / 2,
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
translateComponent(comp, targetCenter.x - compCenter.x, targetCenter.y - compCenter.y);
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
return true;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function fitVisibleComponents() {
|
|
524
|
+
const visibles = cy.elements(":visible");
|
|
525
|
+
if (visibles && visibles.length > 0) {
|
|
526
|
+
cy.fit(visibles, COMPONENT_FIT_PADDING);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function findComponentByPosition(renderedPos) {
|
|
531
|
+
return subnetworkMeta.find(
|
|
532
|
+
(component) =>
|
|
533
|
+
renderedPos.x >= component.bbox.x1 &&
|
|
534
|
+
renderedPos.x <= component.bbox.x2 &&
|
|
535
|
+
renderedPos.y >= component.bbox.y1 &&
|
|
536
|
+
renderedPos.y <= component.bbox.y2,
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function pointerToRenderedPos(evt) {
|
|
541
|
+
const containerRect = document.querySelector(".cy").getBoundingClientRect();
|
|
542
|
+
if (evt.touches && evt.touches.length > 0) {
|
|
543
|
+
return {
|
|
544
|
+
x: evt.touches[0].clientX - containerRect.left,
|
|
545
|
+
y: evt.touches[0].clientY - containerRect.top,
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
return {
|
|
549
|
+
x: evt.clientX - containerRect.left,
|
|
550
|
+
y: evt.clientY - containerRect.top,
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function startFrameDrag(evt) {
|
|
555
|
+
const compId = Number(evt.currentTarget.dataset.componentId);
|
|
556
|
+
const component = subnetworkMeta.find((c) => c.id === compId);
|
|
557
|
+
if (!component) return;
|
|
558
|
+
|
|
559
|
+
evt.preventDefault();
|
|
560
|
+
evt.stopPropagation();
|
|
561
|
+
|
|
562
|
+
const nodes = component.nodes.filter((n) => n.visible());
|
|
563
|
+
const startRendered = pointerToRenderedPos(evt);
|
|
564
|
+
subnetworkDragState = {
|
|
565
|
+
componentId: compId,
|
|
566
|
+
startRendered,
|
|
567
|
+
zoom: cy.zoom(),
|
|
568
|
+
nodes: nodes.map((n) => ({ node: n, pos: { ...n.position() } })),
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
document.addEventListener("mousemove", onFrameDragMove);
|
|
572
|
+
document.addEventListener("touchmove", onFrameDragMove, { passive: false });
|
|
573
|
+
document.addEventListener("mouseup", endFrameDrag);
|
|
574
|
+
document.addEventListener("touchend", endFrameDrag);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function onFrameDragMove(evt) {
|
|
578
|
+
if (!subnetworkDragState) return;
|
|
579
|
+
if (evt.cancelable) {
|
|
580
|
+
evt.preventDefault();
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const currentRendered = pointerToRenderedPos(evt);
|
|
584
|
+
const dxRendered = currentRendered.x - subnetworkDragState.startRendered.x;
|
|
585
|
+
const dyRendered = currentRendered.y - subnetworkDragState.startRendered.y;
|
|
586
|
+
const zoom = subnetworkDragState.zoom || 1;
|
|
587
|
+
const dx = dxRendered / zoom;
|
|
588
|
+
const dy = dyRendered / zoom;
|
|
589
|
+
|
|
590
|
+
subnetworkDragState.nodes.forEach(({ node, pos }) => {
|
|
591
|
+
node.position({ x: pos.x + dx, y: pos.y + dy });
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
scheduleSubnetworkFrameUpdate();
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function endFrameDrag() {
|
|
598
|
+
subnetworkDragState = null;
|
|
599
|
+
document.removeEventListener("mousemove", onFrameDragMove);
|
|
600
|
+
document.removeEventListener("touchmove", onFrameDragMove);
|
|
601
|
+
document.removeEventListener("mouseup", endFrameDrag);
|
|
602
|
+
document.removeEventListener("touchend", endFrameDrag);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function attachFrameDragHandlers(frame, handleElement = frame) {
|
|
606
|
+
const handle = handleElement;
|
|
607
|
+
handle.addEventListener("mousedown", startFrameDrag);
|
|
608
|
+
handle.addEventListener("touchstart", startFrameDrag, { passive: false });
|
|
609
|
+
handle.addEventListener("click", (evt) => {
|
|
610
|
+
const compId = Number((evt.currentTarget || frame).dataset.componentId);
|
|
611
|
+
const component = subnetworkMeta.find((c) => c.id === compId);
|
|
612
|
+
if (!component) return;
|
|
613
|
+
const renderedPos = pointerToRenderedPos(evt);
|
|
614
|
+
showSubnetworkTooltip({ component, renderedPos, cyInstance: cy });
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
cy.on("layoutstop", () => scheduleSubnetworkFrameUpdate({ resolve: true, autoFit: true }));
|
|
619
|
+
cy.on("zoom pan", () => scheduleSubnetworkFrameUpdate());
|
|
620
|
+
cy.on("position", "node", () => scheduleSubnetworkFrameUpdate());
|
|
621
|
+
window.addEventListener("resize", () => scheduleSubnetworkFrameUpdate());
|
|
622
|
+
scheduleSubnetworkFrameUpdate({ resolve: true, autoFit: true });
|
|
623
|
+
|
|
624
|
+
// ############################################################################
|
|
625
|
+
// Side panel toggles
|
|
626
|
+
// ############################################################################
|
|
627
|
+
|
|
628
|
+
function refreshCyViewport() {
|
|
629
|
+
if (!cy) return;
|
|
630
|
+
if (bodyContainer) {
|
|
631
|
+
void bodyContainer.offsetWidth;
|
|
632
|
+
}
|
|
633
|
+
requestAnimationFrame(() => {
|
|
634
|
+
cy.resize();
|
|
635
|
+
cy.fit();
|
|
636
|
+
cy.center();
|
|
637
|
+
scheduleSubnetworkFrameUpdate();
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function toggleSidePanel(side) {
|
|
642
|
+
if (!bodyContainer) return;
|
|
643
|
+
|
|
644
|
+
const className = `${side}-panel-hidden`;
|
|
645
|
+
const shouldHide = !bodyContainer.classList.contains(className);
|
|
646
|
+
|
|
647
|
+
bodyContainer.classList.toggle(className, shouldHide);
|
|
648
|
+
|
|
649
|
+
const targetButton = side === "left" ? leftPanelToggleButton : rightPanelToggleButton;
|
|
650
|
+
if (targetButton) {
|
|
651
|
+
targetButton.classList.toggle("collapsed", shouldHide);
|
|
652
|
+
targetButton.setAttribute("aria-label", shouldHide ? `Show ${side} panel` : `Hide ${side} panel`);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
refreshCyViewport();
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function setupSidePanelToggles() {
|
|
659
|
+
if (!leftPanelToggleButton || !rightPanelToggleButton || !bodyContainer) {
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
leftPanelToggleButton.addEventListener("click", () => toggleSidePanel("left"));
|
|
664
|
+
rightPanelToggleButton.addEventListener("click", () => toggleSidePanel("right"));
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
setupSidePanelToggles();
|
|
668
|
+
|
|
669
|
+
// ############################################################################
|
|
670
|
+
// Control panel handler
|
|
671
|
+
// ############################################################################
|
|
672
|
+
|
|
673
|
+
// --------------------------------------------------------
|
|
674
|
+
// Network layout dropdown
|
|
675
|
+
// --------------------------------------------------------
|
|
676
|
+
document.getElementById("layout-dropdown").addEventListener("change", function () {
|
|
677
|
+
layoutController.setLayout(this.value);
|
|
678
|
+
layoutController.clearLayoutRefresh();
|
|
679
|
+
queueAutoArrange({ afterLayout: true, delayMs: AUTO_ARRANGE_DELAY_MS });
|
|
680
|
+
layoutController.runLayoutWithRepulsion();
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
// =============================================================================
|
|
684
|
+
// Slider initialization and filtering helpers
|
|
685
|
+
// =============================================================================
|
|
686
|
+
|
|
687
|
+
function clampNumber(value, min, max) {
|
|
688
|
+
if (!Number.isFinite(value)) {
|
|
689
|
+
return min;
|
|
690
|
+
}
|
|
691
|
+
return Math.min(Math.max(value, min), max);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function getSliderRoundedValues(slider) {
|
|
695
|
+
if (!slider || !slider.noUiSlider) return null;
|
|
696
|
+
return slider.noUiSlider.get().map((value) => Math.round(Number(value)));
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function getSingleSliderValue(sliderInstance) {
|
|
700
|
+
if (!sliderInstance) return null;
|
|
701
|
+
const raw = sliderInstance.get();
|
|
702
|
+
const value = Array.isArray(raw) ? raw[0] : raw;
|
|
703
|
+
const numeric = Math.round(Number(value));
|
|
704
|
+
if (!Number.isFinite(numeric)) return null;
|
|
705
|
+
return numeric;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function syncRangeInputs(minInput, maxInput, values) {
|
|
709
|
+
if (!minInput || !maxInput || !values) return;
|
|
710
|
+
const [minValue, maxValue] = values;
|
|
711
|
+
if (minInput.value !== String(minValue)) {
|
|
712
|
+
minInput.value = minValue;
|
|
713
|
+
}
|
|
714
|
+
if (maxInput.value !== String(maxValue)) {
|
|
715
|
+
maxInput.value = maxValue;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function setupRangeInputs({ minInput, maxInput, slider, rangeMin, rangeMax, step = 1 }) {
|
|
720
|
+
if (!minInput || !maxInput || !slider || !slider.noUiSlider) return;
|
|
721
|
+
|
|
722
|
+
minInput.min = rangeMin;
|
|
723
|
+
minInput.max = rangeMax;
|
|
724
|
+
minInput.step = step;
|
|
725
|
+
maxInput.min = rangeMin;
|
|
726
|
+
maxInput.max = rangeMax;
|
|
727
|
+
maxInput.step = step;
|
|
728
|
+
|
|
729
|
+
syncRangeInputs(minInput, maxInput, getSliderRoundedValues(slider));
|
|
730
|
+
|
|
731
|
+
const commit = () => {
|
|
732
|
+
const currentValues = getSliderRoundedValues(slider) || [rangeMin, rangeMax];
|
|
733
|
+
let minValue = Number(minInput.value);
|
|
734
|
+
let maxValue = Number(maxInput.value);
|
|
735
|
+
|
|
736
|
+
if (!Number.isFinite(minValue)) minValue = currentValues[0];
|
|
737
|
+
if (!Number.isFinite(maxValue)) maxValue = currentValues[1];
|
|
738
|
+
|
|
739
|
+
minValue = clampNumber(minValue, rangeMin, rangeMax);
|
|
740
|
+
maxValue = clampNumber(maxValue, rangeMin, rangeMax);
|
|
741
|
+
|
|
742
|
+
if (minValue > maxValue) {
|
|
743
|
+
[minValue, maxValue] = [maxValue, minValue];
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
minValue = Math.round(minValue);
|
|
747
|
+
maxValue = Math.round(maxValue);
|
|
748
|
+
|
|
749
|
+
syncRangeInputs(minInput, maxInput, [minValue, maxValue]);
|
|
750
|
+
slider.noUiSlider.set([minValue, maxValue]);
|
|
751
|
+
};
|
|
752
|
+
|
|
753
|
+
["change", "blur"].forEach((eventName) => {
|
|
754
|
+
minInput.addEventListener(eventName, () => commit());
|
|
755
|
+
maxInput.addEventListener(eventName, () => commit());
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
minInput.addEventListener("keydown", (event) => {
|
|
759
|
+
if (event.key === "Enter") {
|
|
760
|
+
event.preventDefault();
|
|
761
|
+
commit();
|
|
762
|
+
}
|
|
763
|
+
});
|
|
764
|
+
maxInput.addEventListener("keydown", (event) => {
|
|
765
|
+
if (event.key === "Enter") {
|
|
766
|
+
event.preventDefault();
|
|
767
|
+
commit();
|
|
768
|
+
}
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
slider.noUiSlider.on("update", (values) => {
|
|
772
|
+
const rounded = values.map((value) => Math.round(Number(value)));
|
|
773
|
+
syncRangeInputs(minInput, maxInput, rounded);
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function setupSingleInput({ input, sliderInstance, rangeMin, rangeMax, step = 1 }) {
|
|
778
|
+
if (!input || !sliderInstance) return;
|
|
779
|
+
|
|
780
|
+
input.min = rangeMin;
|
|
781
|
+
input.max = rangeMax;
|
|
782
|
+
input.step = step;
|
|
783
|
+
|
|
784
|
+
const initialValue = getSingleSliderValue(sliderInstance);
|
|
785
|
+
if (initialValue !== null && input.value !== String(initialValue)) {
|
|
786
|
+
input.value = initialValue;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
const commit = () => {
|
|
790
|
+
const currentValue = getSingleSliderValue(sliderInstance) ?? rangeMin;
|
|
791
|
+
let value = Number(input.value);
|
|
792
|
+
|
|
793
|
+
if (!Number.isFinite(value)) {
|
|
794
|
+
value = currentValue;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
value = clampNumber(value, rangeMin, rangeMax);
|
|
798
|
+
value = Math.round(value);
|
|
799
|
+
|
|
800
|
+
if (input.value !== String(value)) {
|
|
801
|
+
input.value = value;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
sliderInstance.set(value);
|
|
805
|
+
};
|
|
806
|
+
|
|
807
|
+
["change", "blur"].forEach((eventName) => {
|
|
808
|
+
input.addEventListener(eventName, () => commit());
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
input.addEventListener("keydown", (event) => {
|
|
812
|
+
if (event.key === "Enter") {
|
|
813
|
+
event.preventDefault();
|
|
814
|
+
commit();
|
|
815
|
+
}
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
sliderInstance.on("update", (value) => {
|
|
819
|
+
const nextValue = Math.round(Number(Array.isArray(value) ? value[0] : value));
|
|
820
|
+
if (Number.isFinite(nextValue) && input.value !== String(nextValue)) {
|
|
821
|
+
input.value = nextValue;
|
|
822
|
+
}
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// --------------------------------------------------------
|
|
827
|
+
// Edge size slider for Phenotypes similarity
|
|
828
|
+
// --------------------------------------------------------
|
|
829
|
+
|
|
830
|
+
const edgeSlider = document.getElementById("filter-edge-slider");
|
|
831
|
+
const edgeMinInput = document.getElementById("edge-size-min-input");
|
|
832
|
+
const edgeMaxInput = document.getElementById("edge-size-max-input");
|
|
833
|
+
let edgeSliderRangeMin = EDGE_SLIDER_MIN;
|
|
834
|
+
let edgeSliderRangeMax = EDGE_SLIDER_MAX;
|
|
835
|
+
let edgeSliderStartMin = EDGE_SLIDER_MIN;
|
|
836
|
+
let edgeSliderStartMax = EDGE_SLIDER_MAX;
|
|
837
|
+
|
|
838
|
+
if (isGeneSymbolPage) {
|
|
839
|
+
edgeSliderRangeMin = edgeMin;
|
|
840
|
+
edgeSliderRangeMax = edgeMax === edgeMin ? edgeMin + 1 : edgeMax;
|
|
841
|
+
edgeSliderStartMin = edgeSliderRangeMin;
|
|
842
|
+
edgeSliderStartMax = edgeSliderRangeMax;
|
|
843
|
+
} else {
|
|
844
|
+
edgeSliderRangeMin = EDGE_SLIDER_MIN;
|
|
845
|
+
edgeSliderRangeMax = EDGE_SLIDER_MAX;
|
|
846
|
+
edgeSliderStartMin = EDGE_SLIDER_MIN;
|
|
847
|
+
edgeSliderStartMax = EDGE_SLIDER_MAX;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
if (edgeSlider) {
|
|
851
|
+
noUiSlider.create(edgeSlider, {
|
|
852
|
+
start: [edgeSliderStartMin, edgeSliderStartMax],
|
|
853
|
+
connect: true,
|
|
854
|
+
range: { min: edgeSliderRangeMin, max: edgeSliderRangeMax },
|
|
855
|
+
step: 1,
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
setupRangeInputs({
|
|
860
|
+
minInput: edgeMinInput,
|
|
861
|
+
maxInput: edgeMaxInput,
|
|
862
|
+
slider: edgeSlider,
|
|
863
|
+
rangeMin: edgeSliderRangeMin,
|
|
864
|
+
rangeMax: edgeSliderRangeMax,
|
|
865
|
+
step: 1,
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
// --------------------------------------------------------
|
|
869
|
+
// Phenotype severity slider (Phenotype pages only)
|
|
870
|
+
// --------------------------------------------------------
|
|
871
|
+
|
|
872
|
+
const nodeSlider = document.getElementById("filter-node-slider");
|
|
873
|
+
const nodeMinInput = document.getElementById("node-color-min-input");
|
|
874
|
+
const nodeMaxInput = document.getElementById("node-color-max-input");
|
|
875
|
+
if (isPhenotypePage && nodeSlider && !isBinaryPhenotype) {
|
|
876
|
+
noUiSlider.create(nodeSlider, {
|
|
877
|
+
start: [NODE_SLIDER_MIN, NODE_SLIDER_MAX],
|
|
878
|
+
connect: true,
|
|
879
|
+
range: { min: NODE_SLIDER_MIN, max: NODE_SLIDER_MAX },
|
|
880
|
+
step: 1,
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
setupRangeInputs({
|
|
884
|
+
minInput: nodeMinInput,
|
|
885
|
+
maxInput: nodeMaxInput,
|
|
886
|
+
slider: nodeSlider,
|
|
887
|
+
rangeMin: NODE_SLIDER_MIN,
|
|
888
|
+
rangeMax: NODE_SLIDER_MAX,
|
|
889
|
+
step: 1,
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// --------------------------------------------------------
|
|
894
|
+
// Modify the filter function to handle upper and lower bounds
|
|
895
|
+
// --------------------------------------------------------
|
|
896
|
+
|
|
897
|
+
let filterByNodeColorAndEdgeSize = () => { };
|
|
898
|
+
|
|
899
|
+
if (isPhenotypePage) {
|
|
900
|
+
filterByNodeColorAndEdgeSize = function () {
|
|
901
|
+
const hasNodeSlider = nodeSlider && nodeSlider.noUiSlider;
|
|
902
|
+
const nodeSliderValues = hasNodeSlider
|
|
903
|
+
? nodeSlider.noUiSlider.get().map(Number)
|
|
904
|
+
: [NODE_SLIDER_MIN, NODE_SLIDER_MAX];
|
|
905
|
+
const edgeSliderValues = edgeSlider.noUiSlider.get().map(Number);
|
|
906
|
+
|
|
907
|
+
const nodeLowerBound = Math.min(nodeMin, nodeMax);
|
|
908
|
+
const nodeUpperBound = Math.max(nodeMin, nodeMax);
|
|
909
|
+
const rawNodeMin = Math.min(...nodeSliderValues);
|
|
910
|
+
const rawNodeMax = Math.max(...nodeSliderValues);
|
|
911
|
+
let nodeMinValue = scaleToOriginalRange(
|
|
912
|
+
rawNodeMin,
|
|
913
|
+
nodeLowerBound,
|
|
914
|
+
nodeUpperBound,
|
|
915
|
+
NODE_SLIDER_MIN,
|
|
916
|
+
NODE_SLIDER_MAX,
|
|
917
|
+
);
|
|
918
|
+
let nodeMaxValue = scaleToOriginalRange(
|
|
919
|
+
rawNodeMax,
|
|
920
|
+
nodeLowerBound,
|
|
921
|
+
nodeUpperBound,
|
|
922
|
+
NODE_SLIDER_MIN,
|
|
923
|
+
NODE_SLIDER_MAX,
|
|
924
|
+
);
|
|
925
|
+
if (nodeLowerBound === nodeUpperBound) {
|
|
926
|
+
nodeMinValue = nodeLowerBound;
|
|
927
|
+
nodeMaxValue = nodeUpperBound;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
const rawEdgeMin = Math.min(...edgeSliderValues);
|
|
931
|
+
const rawEdgeMax = Math.max(...edgeSliderValues);
|
|
932
|
+
let edgeMinValue = scaleToOriginalRange(rawEdgeMin, edgeMin, edgeMax, EDGE_SLIDER_MIN, EDGE_SLIDER_MAX);
|
|
933
|
+
let edgeMaxValue = scaleToOriginalRange(rawEdgeMax, edgeMin, edgeMax, EDGE_SLIDER_MIN, EDGE_SLIDER_MAX);
|
|
934
|
+
if (edgeMin === edgeMax) {
|
|
935
|
+
edgeMinValue = edgeMin;
|
|
936
|
+
edgeMaxValue = edgeMax;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
cy.nodes().forEach((node) => {
|
|
940
|
+
const nodeColorForFilter = node.data("node_color_for_filter") || node.data("node_color");
|
|
941
|
+
const isVisible =
|
|
942
|
+
nodeColorForFilter >= Math.min(nodeMinValue, nodeMaxValue) &&
|
|
943
|
+
nodeColorForFilter <= Math.max(nodeMinValue, nodeMaxValue);
|
|
944
|
+
node.style("display", isVisible ? "element" : "none");
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
cy.edges().forEach((edge) => {
|
|
948
|
+
const edgeSize = edge.data("edge_size");
|
|
949
|
+
const sharedPhenotypes = edge.data("phenotype") || [];
|
|
950
|
+
const sourceVisible = cy.getElementById(edge.data("source")).style("display") === "element";
|
|
951
|
+
const targetVisible = cy.getElementById(edge.data("target")).style("display") === "element";
|
|
952
|
+
|
|
953
|
+
const isVisible =
|
|
954
|
+
sourceVisible &&
|
|
955
|
+
targetVisible &&
|
|
956
|
+
edgeSize >= Math.min(edgeMinValue, edgeMaxValue) &&
|
|
957
|
+
edgeSize <= Math.max(edgeMinValue, edgeMaxValue) &&
|
|
958
|
+
sharedPhenotypes.length >= 2;
|
|
959
|
+
|
|
960
|
+
edge.style("display", isVisible ? "element" : "none");
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
cy.nodes().forEach((node) => {
|
|
964
|
+
const visibleEdges = node.connectedEdges().filter((edge) => edge.style("display") === "element");
|
|
965
|
+
if (visibleEdges.length === 0) {
|
|
966
|
+
node.style("display", "none");
|
|
967
|
+
}
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
layoutController.runLayoutWithRepulsion();
|
|
971
|
+
checkEmptyState();
|
|
972
|
+
|
|
973
|
+
if (window.refreshPhenotypeList) {
|
|
974
|
+
window.refreshPhenotypeList();
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
if (typeof window.recalculateCentrality === "function") {
|
|
978
|
+
window.recalculateCentrality();
|
|
979
|
+
}
|
|
980
|
+
};
|
|
981
|
+
} else if (isGeneSymbolPage) {
|
|
982
|
+
filterByNodeColorAndEdgeSize = function () {
|
|
983
|
+
const edgeSliderValues = edgeSlider.noUiSlider.get().map(Number);
|
|
984
|
+
|
|
985
|
+
let selectedMin = Math.min(...edgeSliderValues);
|
|
986
|
+
let selectedMax = Math.max(...edgeSliderValues);
|
|
987
|
+
|
|
988
|
+
if (edgeMin === edgeMax) {
|
|
989
|
+
selectedMin = edgeMin;
|
|
990
|
+
selectedMax = edgeMax;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
const edgeMinValue = Math.max(edgeMin, selectedMin);
|
|
994
|
+
const edgeMaxValue = Math.min(edgeMax, selectedMax);
|
|
995
|
+
|
|
996
|
+
cy.elements().forEach((ele) => ele.style("display", "none"));
|
|
997
|
+
|
|
998
|
+
cy.edges().forEach((edge) => {
|
|
999
|
+
const edgeSize = edge.data("edge_size");
|
|
1000
|
+
const isVisible =
|
|
1001
|
+
edgeSize >= Math.min(edgeMinValue, edgeMaxValue) && edgeSize <= Math.max(edgeMinValue, edgeMaxValue);
|
|
1002
|
+
edge.style("display", isVisible ? "element" : "none");
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
const visibleEdges = cy.edges().filter((edge) => edge.style("display") === "element");
|
|
1006
|
+
const candidateElements = visibleEdges.union(visibleEdges.connectedNodes());
|
|
1007
|
+
const components = candidateElements.components();
|
|
1008
|
+
|
|
1009
|
+
const targetGene = pageConfig.name;
|
|
1010
|
+
const targetNode = cy.getElementById(targetGene);
|
|
1011
|
+
|
|
1012
|
+
if (targetNode.length === 0) {
|
|
1013
|
+
updateNoNodesMessage(true);
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
targetNode.style("display", "element");
|
|
1018
|
+
|
|
1019
|
+
const directlyConnectedNodes = new Set([targetGene]);
|
|
1020
|
+
|
|
1021
|
+
cy.edges().forEach((edge) => {
|
|
1022
|
+
if (edge.style("display") === "element") {
|
|
1023
|
+
const source = edge.data("source");
|
|
1024
|
+
const target = edge.data("target");
|
|
1025
|
+
|
|
1026
|
+
if (source === targetGene) {
|
|
1027
|
+
directlyConnectedNodes.add(target);
|
|
1028
|
+
} else if (target === targetGene) {
|
|
1029
|
+
directlyConnectedNodes.add(source);
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
cy.edges().forEach((edge) => {
|
|
1035
|
+
if (edge.style("display") === "element") {
|
|
1036
|
+
const source = edge.data("source");
|
|
1037
|
+
const target = edge.data("target");
|
|
1038
|
+
|
|
1039
|
+
if (directlyConnectedNodes.has(source) && directlyConnectedNodes.has(target)) {
|
|
1040
|
+
edge.style("display", "element");
|
|
1041
|
+
} else {
|
|
1042
|
+
edge.style("display", "none");
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
cy.nodes().forEach((node) => {
|
|
1048
|
+
const nodeId = node.data("id");
|
|
1049
|
+
if (directlyConnectedNodes.has(nodeId)) {
|
|
1050
|
+
node.style("display", "element");
|
|
1051
|
+
} else {
|
|
1052
|
+
node.style("display", "none");
|
|
1053
|
+
}
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
layoutController.runLayoutWithRepulsion();
|
|
1057
|
+
checkEmptyState();
|
|
1058
|
+
|
|
1059
|
+
if (window.refreshPhenotypeList) {
|
|
1060
|
+
window.refreshPhenotypeList();
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
if (typeof window.recalculateCentrality === "function") {
|
|
1064
|
+
window.recalculateCentrality();
|
|
1065
|
+
}
|
|
1066
|
+
};
|
|
1067
|
+
} else {
|
|
1068
|
+
filterByNodeColorAndEdgeSize = function () {
|
|
1069
|
+
const edgeSliderValues = edgeSlider.noUiSlider.get().map(Number);
|
|
1070
|
+
const edgeMinValue = scaleToOriginalRange(edgeSliderValues[0], edgeMin, edgeMax, 1, 100);
|
|
1071
|
+
const edgeMaxValue = scaleToOriginalRange(edgeSliderValues[1], edgeMin, edgeMax, 1, 100);
|
|
1072
|
+
|
|
1073
|
+
cy.nodes().forEach((node) => node.style("display", "element"));
|
|
1074
|
+
|
|
1075
|
+
cy.edges().forEach((edge) => {
|
|
1076
|
+
const edgeSize = edge.data("edge_size");
|
|
1077
|
+
const sourceVisible = cy.getElementById(edge.data("source")).style("display") === "element";
|
|
1078
|
+
const targetVisible = cy.getElementById(edge.data("target")).style("display") === "element";
|
|
1079
|
+
const isVisible =
|
|
1080
|
+
sourceVisible &&
|
|
1081
|
+
targetVisible &&
|
|
1082
|
+
edgeSize >= Math.min(edgeMinValue, edgeMaxValue) &&
|
|
1083
|
+
edgeSize <= Math.max(edgeMinValue, edgeMaxValue);
|
|
1084
|
+
edge.style("display", isVisible ? "element" : "none");
|
|
1085
|
+
});
|
|
1086
|
+
|
|
1087
|
+
const components = calculateConnectedComponents(cy);
|
|
1088
|
+
const validComponents = components.filter((comp) =>
|
|
1089
|
+
Object.keys(comp).some((label) => {
|
|
1090
|
+
const node = cy.$(`node[label="${label}"]`);
|
|
1091
|
+
return node.data("node_color") === 1;
|
|
1092
|
+
}),
|
|
1093
|
+
);
|
|
1094
|
+
|
|
1095
|
+
validComponents.forEach((comp) => {
|
|
1096
|
+
Object.keys(comp).forEach((label) => {
|
|
1097
|
+
const node = cy.$(`node[label="${label}"]`);
|
|
1098
|
+
node.style("display", "element");
|
|
1099
|
+
node.connectedEdges().forEach((edge) => {
|
|
1100
|
+
const edgeSize = edge.data("edge_size");
|
|
1101
|
+
if (
|
|
1102
|
+
edgeSize >= Math.min(edgeMinValue, edgeMaxValue) &&
|
|
1103
|
+
edgeSize <= Math.max(edgeMinValue, edgeMaxValue)
|
|
1104
|
+
) {
|
|
1105
|
+
edge.style("display", "element");
|
|
1106
|
+
}
|
|
1107
|
+
});
|
|
1108
|
+
});
|
|
1109
|
+
});
|
|
1110
|
+
|
|
1111
|
+
cy.nodes().forEach((node) => {
|
|
1112
|
+
const visibleEdges = node.connectedEdges().filter((edge) => edge.style("display") === "element");
|
|
1113
|
+
if (visibleEdges.length === 0) {
|
|
1114
|
+
node.style("display", "none");
|
|
1115
|
+
}
|
|
1116
|
+
});
|
|
1117
|
+
|
|
1118
|
+
layoutController.runLayoutWithRepulsion();
|
|
1119
|
+
checkEmptyState();
|
|
1120
|
+
|
|
1121
|
+
if (window.refreshPhenotypeList) {
|
|
1122
|
+
window.refreshPhenotypeList();
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
if (typeof window.recalculateCentrality === "function") {
|
|
1126
|
+
window.recalculateCentrality();
|
|
1127
|
+
}
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
if (edgeSlider && edgeSlider.noUiSlider) {
|
|
1132
|
+
edgeSlider.noUiSlider.on("update", function (values) {
|
|
1133
|
+
filterByNodeColorAndEdgeSize();
|
|
1134
|
+
});
|
|
1135
|
+
edgeSlider.noUiSlider.on("set", function () {
|
|
1136
|
+
queueAutoArrange({ afterLayout: true, delayMs: AUTO_ARRANGE_DELAY_MS });
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
if (isPhenotypePage && nodeSlider && nodeSlider.noUiSlider) {
|
|
1141
|
+
nodeSlider.noUiSlider.on("update", function (values) {
|
|
1142
|
+
filterByNodeColorAndEdgeSize();
|
|
1143
|
+
});
|
|
1144
|
+
nodeSlider.noUiSlider.on("set", function () {
|
|
1145
|
+
queueAutoArrange({ afterLayout: true, delayMs: AUTO_ARRANGE_DELAY_MS });
|
|
1146
|
+
});
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
// =============================================================================
|
|
1150
|
+
// Genotype, sex, and life-stage specific filtering
|
|
1151
|
+
// =============================================================================
|
|
1152
|
+
|
|
1153
|
+
let targetPhenotype = isPhenotypePage ? pageConfig.displayName : "";
|
|
1154
|
+
|
|
1155
|
+
function isGenotypeAllSelected() {
|
|
1156
|
+
const allCheckbox = document.querySelector('#genotype-filter-form input[value="All"]');
|
|
1157
|
+
return allCheckbox ? allCheckbox.checked : true;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
function applyFiltering() {
|
|
1161
|
+
queueAutoArrange({ afterLayout: true, delayMs: AUTO_ARRANGE_DELAY_MS });
|
|
1162
|
+
const sourceElements = isGenotypeAllSelected() ? baseElements : elements;
|
|
1163
|
+
filterElementsByGenotypeAndSex(sourceElements, cy, targetPhenotype, filterByNodeColorAndEdgeSize);
|
|
1164
|
+
if (typeof window.recalculateCentrality === "function") {
|
|
1165
|
+
window.recalculateCentrality();
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
function setupAllToggle(formId) {
|
|
1170
|
+
const form = document.getElementById(formId);
|
|
1171
|
+
if (!form) return;
|
|
1172
|
+
|
|
1173
|
+
const checkboxes = Array.from(form.querySelectorAll('input[type="checkbox"]'));
|
|
1174
|
+
const allCheckbox = checkboxes.find((checkbox) => checkbox.value === "All");
|
|
1175
|
+
const optionCheckboxes = checkboxes.filter((checkbox) => checkbox !== allCheckbox);
|
|
1176
|
+
|
|
1177
|
+
const ensureAllSelected = () => {
|
|
1178
|
+
if (allCheckbox) {
|
|
1179
|
+
allCheckbox.checked = true;
|
|
1180
|
+
optionCheckboxes.forEach((checkbox) => {
|
|
1181
|
+
checkbox.checked = false;
|
|
1182
|
+
});
|
|
1183
|
+
}
|
|
1184
|
+
};
|
|
1185
|
+
|
|
1186
|
+
if (allCheckbox) {
|
|
1187
|
+
allCheckbox.addEventListener("change", () => {
|
|
1188
|
+
if (allCheckbox.checked) {
|
|
1189
|
+
optionCheckboxes.forEach((checkbox) => {
|
|
1190
|
+
checkbox.checked = false;
|
|
1191
|
+
});
|
|
1192
|
+
} else if (!optionCheckboxes.some((checkbox) => checkbox.checked)) {
|
|
1193
|
+
ensureAllSelected();
|
|
1194
|
+
}
|
|
1195
|
+
applyFiltering();
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
optionCheckboxes.forEach((checkbox) => {
|
|
1200
|
+
checkbox.addEventListener("change", () => {
|
|
1201
|
+
if (checkbox.checked) {
|
|
1202
|
+
if (allCheckbox) {
|
|
1203
|
+
allCheckbox.checked = false;
|
|
1204
|
+
}
|
|
1205
|
+
if (optionCheckboxes.every((option) => option.checked)) {
|
|
1206
|
+
ensureAllSelected();
|
|
1207
|
+
applyFiltering();
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
1210
|
+
} else if (!optionCheckboxes.some((option) => option.checked)) {
|
|
1211
|
+
ensureAllSelected();
|
|
1212
|
+
applyFiltering();
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1215
|
+
applyFiltering();
|
|
1216
|
+
});
|
|
1217
|
+
});
|
|
1218
|
+
|
|
1219
|
+
if (!optionCheckboxes.some((checkbox) => checkbox.checked)) {
|
|
1220
|
+
ensureAllSelected();
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
["genotype-filter-form", "sex-filter-form", "lifestage-filter-form"].forEach((formId) => setupAllToggle(formId));
|
|
1225
|
+
|
|
1226
|
+
// =============================================================================
|
|
1227
|
+
// Highlight human disease annotations
|
|
1228
|
+
// =============================================================================
|
|
1229
|
+
highlightDiseaseAnnotation({ cy });
|
|
1230
|
+
|
|
1231
|
+
// ############################################################################
|
|
1232
|
+
// Cytoscape's visualization setting
|
|
1233
|
+
// ############################################################################
|
|
1234
|
+
|
|
1235
|
+
setupGeneSearch({ cy });
|
|
1236
|
+
|
|
1237
|
+
setupPhenotypeSearch({ cy, elements });
|
|
1238
|
+
|
|
1239
|
+
const fontSizeInput = document.getElementById("font-size-input");
|
|
1240
|
+
const fontSizeSliderInstance = createSlider("font-size-slider", isGeneSymbolPage ? 10 : 20, 1, 50, 1, (intValues) => {
|
|
1241
|
+
if (fontSizeInput) {
|
|
1242
|
+
fontSizeInput.value = intValues;
|
|
1243
|
+
}
|
|
1244
|
+
cy.style()
|
|
1245
|
+
.selector("node")
|
|
1246
|
+
.style("font-size", intValues + "px")
|
|
1247
|
+
.update();
|
|
1248
|
+
});
|
|
1249
|
+
|
|
1250
|
+
const edgeWidthInput = document.getElementById("edge-width-input");
|
|
1251
|
+
const edgeWidthSliderInstance = createSlider("edge-width-slider", 5, 1, 10, 1, (intValues) => {
|
|
1252
|
+
if (edgeWidthInput) {
|
|
1253
|
+
edgeWidthInput.value = intValues;
|
|
1254
|
+
}
|
|
1255
|
+
cy.style()
|
|
1256
|
+
.selector("edge")
|
|
1257
|
+
.style("width", function (ele) {
|
|
1258
|
+
const baseWidth = mapEdgeSizeToWidth(ele.data("edge_size"));
|
|
1259
|
+
return baseWidth * (intValues * 0.4);
|
|
1260
|
+
})
|
|
1261
|
+
.update();
|
|
1262
|
+
});
|
|
1263
|
+
|
|
1264
|
+
const layoutDropdown = document.getElementById("layout-dropdown");
|
|
1265
|
+
const nodeRepulsionContainer = document.getElementById("node-repulsion-container");
|
|
1266
|
+
const nodeRepulsionBox = document.getElementById("node-repulsion-box");
|
|
1267
|
+
|
|
1268
|
+
function updateNodeRepulsionVisibility() {
|
|
1269
|
+
const displayValue = "block";
|
|
1270
|
+
|
|
1271
|
+
if (nodeRepulsionContainer) {
|
|
1272
|
+
nodeRepulsionContainer.style.display = displayValue;
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
if (nodeRepulsionBox) {
|
|
1276
|
+
nodeRepulsionBox.style.display = displayValue;
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
updateNodeRepulsionVisibility();
|
|
1281
|
+
layoutDropdown.addEventListener("change", updateNodeRepulsionVisibility);
|
|
1282
|
+
|
|
1283
|
+
const nodeRepulsionInput = document.getElementById("node-repulsion-input");
|
|
1284
|
+
const nodeRepulsionSliderInstance = createSlider("nodeRepulsion-slider", defaultNodeRepulsion, 1, 10, 1, (intValues) => {
|
|
1285
|
+
if (nodeRepulsionInput) {
|
|
1286
|
+
nodeRepulsionInput.value = intValues;
|
|
1287
|
+
}
|
|
1288
|
+
layoutController.updateRepulsionScale(intValues);
|
|
1289
|
+
layoutController.scheduleNodeRepulsion();
|
|
1290
|
+
if (layoutController.getLayout() !== "random") {
|
|
1291
|
+
layoutController.queueLayoutRefresh(150);
|
|
1292
|
+
}
|
|
1293
|
+
});
|
|
1294
|
+
|
|
1295
|
+
setupSingleInput({
|
|
1296
|
+
input: fontSizeInput,
|
|
1297
|
+
sliderInstance: fontSizeSliderInstance,
|
|
1298
|
+
rangeMin: 1,
|
|
1299
|
+
rangeMax: 50,
|
|
1300
|
+
step: 1,
|
|
1301
|
+
});
|
|
1302
|
+
|
|
1303
|
+
setupSingleInput({
|
|
1304
|
+
input: edgeWidthInput,
|
|
1305
|
+
sliderInstance: edgeWidthSliderInstance,
|
|
1306
|
+
rangeMin: 1,
|
|
1307
|
+
rangeMax: 10,
|
|
1308
|
+
step: 1,
|
|
1309
|
+
});
|
|
1310
|
+
|
|
1311
|
+
setupSingleInput({
|
|
1312
|
+
input: nodeRepulsionInput,
|
|
1313
|
+
sliderInstance: nodeRepulsionSliderInstance,
|
|
1314
|
+
rangeMin: 1,
|
|
1315
|
+
rangeMax: 10,
|
|
1316
|
+
step: 1,
|
|
1317
|
+
});
|
|
1318
|
+
const nodeRepulsionSlider = document.getElementById("nodeRepulsion-slider");
|
|
1319
|
+
if (nodeRepulsionSlider && nodeRepulsionSlider.noUiSlider) {
|
|
1320
|
+
nodeRepulsionSlider.noUiSlider.on("set", () => {
|
|
1321
|
+
const needsLayoutStop = layoutController.getLayout() !== "random";
|
|
1322
|
+
queueAutoArrange({ afterLayout: needsLayoutStop, delayMs: AUTO_ARRANGE_DELAY_MS });
|
|
1323
|
+
});
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
// ############################################################################
|
|
1327
|
+
// Initialize centrality system
|
|
1328
|
+
// ############################################################################
|
|
1329
|
+
|
|
1330
|
+
initializeCentralitySystem(cy, createSlider);
|
|
1331
|
+
window.recalculateCentrality = recalculateCentrality;
|
|
1332
|
+
|
|
1333
|
+
// ############################################################################
|
|
1334
|
+
// Tooltip handling
|
|
1335
|
+
// ############################################################################
|
|
1336
|
+
|
|
1337
|
+
const DIM_NODE_CLASS = "dim-node";
|
|
1338
|
+
const DIM_EDGE_CLASS = "dim-edge";
|
|
1339
|
+
const FOCUS_NODE_CLASS = "focus-node";
|
|
1340
|
+
const FOCUS_EDGE_CLASS = "focus-edge";
|
|
1341
|
+
|
|
1342
|
+
function clearNeighborHighlights() {
|
|
1343
|
+
cy.nodes().removeClass(DIM_NODE_CLASS);
|
|
1344
|
+
cy.edges().removeClass(DIM_EDGE_CLASS);
|
|
1345
|
+
cy.nodes().removeClass(FOCUS_NODE_CLASS);
|
|
1346
|
+
cy.edges().removeClass(FOCUS_EDGE_CLASS);
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
function highlightNeighbors(target) {
|
|
1350
|
+
if (!target) {
|
|
1351
|
+
return;
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
clearNeighborHighlights();
|
|
1355
|
+
|
|
1356
|
+
let highlightElements;
|
|
1357
|
+
|
|
1358
|
+
if (target.isNode()) {
|
|
1359
|
+
const nodeId = target.id();
|
|
1360
|
+
const neighborIds = new Set([nodeId]);
|
|
1361
|
+
|
|
1362
|
+
target.connectedEdges().forEach((edge) => {
|
|
1363
|
+
if (!edge.visible()) return;
|
|
1364
|
+
const srcId = edge.source().id();
|
|
1365
|
+
const tgtId = edge.target().id();
|
|
1366
|
+
if (srcId === nodeId) {
|
|
1367
|
+
neighborIds.add(tgtId);
|
|
1368
|
+
}
|
|
1369
|
+
if (tgtId === nodeId) {
|
|
1370
|
+
neighborIds.add(srcId);
|
|
1371
|
+
}
|
|
1372
|
+
});
|
|
1373
|
+
|
|
1374
|
+
const highlightNodes = cy.nodes().filter((n) => n.visible() && neighborIds.has(n.id()));
|
|
1375
|
+
const highlightEdges = cy
|
|
1376
|
+
.edges()
|
|
1377
|
+
.filter((e) => e.visible() && (e.source().id() === nodeId || e.target().id() === nodeId));
|
|
1378
|
+
|
|
1379
|
+
highlightElements = highlightNodes.union(highlightEdges);
|
|
1380
|
+
} else if (target.isEdge()) {
|
|
1381
|
+
highlightElements = target.union(target.connectedNodes()).filter((ele) => ele.visible());
|
|
1382
|
+
} else {
|
|
1383
|
+
return;
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
// Dim all visible elements first, then un-dim the highlight set to ensure neighbors stay emphasized
|
|
1387
|
+
const visibleElements = cy.elements().filter((ele) => ele.visible());
|
|
1388
|
+
visibleElements.nodes().addClass(DIM_NODE_CLASS);
|
|
1389
|
+
visibleElements.edges().addClass(DIM_EDGE_CLASS);
|
|
1390
|
+
|
|
1391
|
+
// Remove dimming from the intended highlight set
|
|
1392
|
+
highlightElements.nodes().removeClass(DIM_NODE_CLASS);
|
|
1393
|
+
highlightElements.edges().removeClass(DIM_EDGE_CLASS);
|
|
1394
|
+
highlightElements.nodes().addClass(FOCUS_NODE_CLASS);
|
|
1395
|
+
highlightElements.edges().addClass(FOCUS_EDGE_CLASS);
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
cy.on("tap", "node, edge", function (event) {
|
|
1399
|
+
highlightNeighbors(event.target);
|
|
1400
|
+
});
|
|
1401
|
+
|
|
1402
|
+
cy.on("tap", "node, edge", function (event) {
|
|
1403
|
+
showTooltip(event, cy, mapSymbolToId, targetPhenotype, { nodeColorValues });
|
|
1404
|
+
});
|
|
1405
|
+
|
|
1406
|
+
cy.on("tap", function (event) {
|
|
1407
|
+
if (event.target !== cy) {
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
const renderedPos = event.renderedPosition || event.position || { x: 0, y: 0 };
|
|
1412
|
+
const component = findComponentByPosition(renderedPos);
|
|
1413
|
+
if (component) {
|
|
1414
|
+
showSubnetworkTooltip({ component, renderedPos, cyInstance: cy });
|
|
1415
|
+
} else {
|
|
1416
|
+
removeTooltips();
|
|
1417
|
+
clearNeighborHighlights();
|
|
1418
|
+
}
|
|
1419
|
+
});
|
|
1420
|
+
|
|
1421
|
+
// ############################################################################
|
|
1422
|
+
// Exporter
|
|
1423
|
+
// ############################################################################
|
|
1424
|
+
|
|
1425
|
+
const fileName = `TSUMUGI_${pageConfig.name || "network"}`;
|
|
1426
|
+
|
|
1427
|
+
function attachExportHandler(elementId, handler) {
|
|
1428
|
+
const button = document.getElementById(elementId);
|
|
1429
|
+
if (!button) return;
|
|
1430
|
+
button.addEventListener("click", handler);
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
attachExportHandler("export-png", () => exportGraphAsPNG(cy, fileName));
|
|
1434
|
+
attachExportHandler("export-jpg", () => exportGraphAsJPG(cy, fileName));
|
|
1435
|
+
attachExportHandler("export-svg", () => exportGraphAsSVG(cy, fileName));
|
|
1436
|
+
attachExportHandler("export-csv", () => exportGraphAsCSV(cy, fileName));
|
|
1437
|
+
attachExportHandler("export-graphml", () => exportGraphAsGraphML(cy, fileName));
|
|
1438
|
+
|
|
1439
|
+
attachExportHandler("export-png-mobile", () => exportGraphAsPNG(cy, fileName));
|
|
1440
|
+
attachExportHandler("export-jpg-mobile", () => exportGraphAsJPG(cy, fileName));
|
|
1441
|
+
attachExportHandler("export-svg-mobile", () => exportGraphAsSVG(cy, fileName));
|
|
1442
|
+
attachExportHandler("export-csv-mobile", () => exportGraphAsCSV(cy, fileName));
|
|
1443
|
+
attachExportHandler("export-graphml-mobile", () => exportGraphAsGraphML(cy, fileName));
|
|
1444
|
+
|
|
1445
|
+
// ############################################################################
|
|
1446
|
+
// UI Helpers
|
|
1447
|
+
// ############################################################################
|
|
1448
|
+
|
|
1449
|
+
function checkEmptyState() {
|
|
1450
|
+
const visibleNodes = cy.nodes(":visible").length;
|
|
1451
|
+
const visibleEdges = cy.edges(":visible").length;
|
|
1452
|
+
let shouldShow = visibleNodes === 0;
|
|
1453
|
+
|
|
1454
|
+
if (isGeneSymbolPage) {
|
|
1455
|
+
const targetNode = cy.getElementById(pageConfig.name);
|
|
1456
|
+
const targetVisible = targetNode.length > 0 && targetNode.style("display") !== "none";
|
|
1457
|
+
if (!targetVisible || visibleEdges === 0) {
|
|
1458
|
+
shouldShow = true;
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
updateNoNodesMessage(shouldShow);
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
const recenterBtn = document.getElementById("recenter-button");
|
|
1466
|
+
if (recenterBtn) {
|
|
1467
|
+
recenterBtn.addEventListener("click", () => {
|
|
1468
|
+
if (cy) {
|
|
1469
|
+
cy.fit();
|
|
1470
|
+
cy.center();
|
|
1471
|
+
scheduleSubnetworkFrameUpdate();
|
|
1472
|
+
}
|
|
1473
|
+
});
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
function autoArrangeModules() {
|
|
1477
|
+
if (!cy) return;
|
|
1478
|
+
cy.startBatch();
|
|
1479
|
+
tileComponents();
|
|
1480
|
+
resolveComponentOverlaps();
|
|
1481
|
+
cy.endBatch();
|
|
1482
|
+
fitVisibleComponents();
|
|
1483
|
+
scheduleSubnetworkFrameUpdate();
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
function setupInitialAutoArrange() {
|
|
1487
|
+
let handled = false;
|
|
1488
|
+
let scheduled = false;
|
|
1489
|
+
let hasRendered = false;
|
|
1490
|
+
|
|
1491
|
+
cy.one("render", () => {
|
|
1492
|
+
hasRendered = true;
|
|
1493
|
+
});
|
|
1494
|
+
|
|
1495
|
+
const triggerInitialArrange = (reason) => {
|
|
1496
|
+
if (handled) return;
|
|
1497
|
+
handled = true;
|
|
1498
|
+
const arrangeButton = document.getElementById("arrange-modules-button");
|
|
1499
|
+
if (arrangeButton) {
|
|
1500
|
+
arrangeButton.click();
|
|
1501
|
+
return;
|
|
1502
|
+
}
|
|
1503
|
+
autoArrangeModules();
|
|
1504
|
+
};
|
|
1505
|
+
|
|
1506
|
+
const scheduleInitialArrange = (reason) => {
|
|
1507
|
+
if (scheduled) return;
|
|
1508
|
+
scheduled = true;
|
|
1509
|
+
setTimeout(() => {
|
|
1510
|
+
triggerInitialArrange(reason);
|
|
1511
|
+
}, INITIAL_ARRANGE_CLICK_DELAY_MS);
|
|
1512
|
+
};
|
|
1513
|
+
|
|
1514
|
+
const triggerAfterRender = (reason) => {
|
|
1515
|
+
const runAfterPaint = () => {
|
|
1516
|
+
requestAnimationFrame(() => {
|
|
1517
|
+
requestAnimationFrame(() => {
|
|
1518
|
+
scheduleInitialArrange(reason);
|
|
1519
|
+
});
|
|
1520
|
+
});
|
|
1521
|
+
};
|
|
1522
|
+
|
|
1523
|
+
if (hasRendered) {
|
|
1524
|
+
runAfterPaint();
|
|
1525
|
+
return;
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
cy.one("render", runAfterPaint);
|
|
1529
|
+
};
|
|
1530
|
+
|
|
1531
|
+
const timeoutId = setTimeout(() => {
|
|
1532
|
+
triggerAfterRender("timeout");
|
|
1533
|
+
}, INITIAL_AUTO_ARRANGE_TIMEOUT_MS);
|
|
1534
|
+
|
|
1535
|
+
cy.one("layoutstop", () => {
|
|
1536
|
+
clearTimeout(timeoutId);
|
|
1537
|
+
triggerAfterRender("layoutstop");
|
|
1538
|
+
});
|
|
1539
|
+
|
|
1540
|
+
window.addEventListener(
|
|
1541
|
+
REPULSION_FINISH_EVENT,
|
|
1542
|
+
() => {
|
|
1543
|
+
clearTimeout(timeoutId);
|
|
1544
|
+
triggerAfterRender("repulsion");
|
|
1545
|
+
},
|
|
1546
|
+
{ once: true },
|
|
1547
|
+
);
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
function queueAutoArrange({ afterLayout = false, delayMs = AUTO_ARRANGE_DELAY_MS } = {}) {
|
|
1551
|
+
if (!cy) return;
|
|
1552
|
+
let arranged = false;
|
|
1553
|
+
const runAutoArrange = () => {
|
|
1554
|
+
if (arranged) return;
|
|
1555
|
+
arranged = true;
|
|
1556
|
+
autoArrangeModules();
|
|
1557
|
+
};
|
|
1558
|
+
const scheduleRun = () => {
|
|
1559
|
+
setTimeout(runAutoArrange, delayMs);
|
|
1560
|
+
};
|
|
1561
|
+
if (!afterLayout) {
|
|
1562
|
+
scheduleRun();
|
|
1563
|
+
return;
|
|
1564
|
+
}
|
|
1565
|
+
let repulsionFallbackId = null;
|
|
1566
|
+
const onRepulsionFinish = (event) => {
|
|
1567
|
+
if (repulsionFallbackId) {
|
|
1568
|
+
clearTimeout(repulsionFallbackId);
|
|
1569
|
+
repulsionFallbackId = null;
|
|
1570
|
+
}
|
|
1571
|
+
scheduleRun();
|
|
1572
|
+
};
|
|
1573
|
+
const scheduleAfterRepulsion = () => {
|
|
1574
|
+
window.addEventListener(REPULSION_FINISH_EVENT, onRepulsionFinish, { once: true });
|
|
1575
|
+
repulsionFallbackId = setTimeout(() => {
|
|
1576
|
+
window.removeEventListener(REPULSION_FINISH_EVENT, onRepulsionFinish);
|
|
1577
|
+
scheduleRun();
|
|
1578
|
+
}, AUTO_ARRANGE_REPULSION_TIMEOUT_MS);
|
|
1579
|
+
};
|
|
1580
|
+
const layoutFallbackId = setTimeout(() => {
|
|
1581
|
+
scheduleRun();
|
|
1582
|
+
}, AUTO_ARRANGE_LAYOUT_TIMEOUT_MS);
|
|
1583
|
+
cy.one("layoutstop", () => {
|
|
1584
|
+
if (arranged) return;
|
|
1585
|
+
clearTimeout(layoutFallbackId);
|
|
1586
|
+
scheduleAfterRepulsion();
|
|
1587
|
+
});
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
const arrangeModulesButton = document.getElementById("arrange-modules-button");
|
|
1591
|
+
if (arrangeModulesButton) {
|
|
1592
|
+
arrangeModulesButton.addEventListener("click", autoArrangeModules);
|
|
1593
|
+
}
|