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,52 @@
|
|
|
1
|
+
export function highlightDiseaseAnnotation({ cy, checkboxId = "disease" }) {
|
|
2
|
+
const checkbox = document.getElementById(checkboxId);
|
|
3
|
+
|
|
4
|
+
checkbox.addEventListener("change", () => {
|
|
5
|
+
if (checkbox.checked) {
|
|
6
|
+
// When checked, highlight nodes that contain "**Associated Human Diseases**"
|
|
7
|
+
highlightDiseaseNodes(cy);
|
|
8
|
+
} else {
|
|
9
|
+
// When unchecked, remove the highlight
|
|
10
|
+
resetDiseaseHighlight(cy);
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function highlightDiseaseNodes(cy) {
|
|
16
|
+
// Find disease-related nodes and highlight them
|
|
17
|
+
const diseaseNodes = cy.nodes().filter((node) => {
|
|
18
|
+
const nodeData = node.data();
|
|
19
|
+
return checkNodeForDiseaseInfo(nodeData);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
if (diseaseNodes.length > 0) {
|
|
23
|
+
diseaseNodes.addClass("disease-highlight");
|
|
24
|
+
// diseaseNodes.style("border-width", 3);
|
|
25
|
+
// diseaseNodes.style("border-color", "#fc4c00");
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function resetDiseaseHighlight(cy) {
|
|
30
|
+
// Find disease-related nodes and remove highlighting
|
|
31
|
+
const diseaseNodes = cy.nodes().filter((node) => {
|
|
32
|
+
const nodeData = node.data();
|
|
33
|
+
return checkNodeForDiseaseInfo(nodeData);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
if (diseaseNodes.length > 0) {
|
|
37
|
+
diseaseNodes.removeClass("disease-highlight");
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function checkNodeForDiseaseInfo(nodeData) {
|
|
42
|
+
// Reuse the tooltip logic for checking the disease field
|
|
43
|
+
const diseases = Array.isArray(nodeData.disease) ? nodeData.disease : [nodeData.disease];
|
|
44
|
+
|
|
45
|
+
// Treat nodes as disease-related if the diseases array contains a non-empty value
|
|
46
|
+
// Tooltips display "Associated Human Diseases" when diseases[0] !== ""
|
|
47
|
+
if (diseases && diseases.length > 0 && diseases[0] !== "" && diseases[0] !== undefined && diseases[0] !== null) {
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
import { scaleToOriginalRange } from "./valueScaler.js";
|
|
2
|
+
|
|
3
|
+
const NODE_REPULSION_MIN = 1;
|
|
4
|
+
const NODE_REPULSION_MAX = 10000;
|
|
5
|
+
const COMPONENT_SPACING_MIN = 1;
|
|
6
|
+
const COMPONENT_SPACING_MAX = 200;
|
|
7
|
+
|
|
8
|
+
const REPULSION_SPACING_MIN = 20;
|
|
9
|
+
const REPULSION_SPACING_FACTOR_MIN = 0.75;
|
|
10
|
+
const REPULSION_SPACING_FACTOR_MAX = 2.6;
|
|
11
|
+
const REPULSION_STRENGTH_MIN = 0.25;
|
|
12
|
+
const REPULSION_STRENGTH_MAX = 1.1;
|
|
13
|
+
const REPULSION_RADIAL_MIN = 0.02;
|
|
14
|
+
const REPULSION_RADIAL_MAX = 0.12;
|
|
15
|
+
|
|
16
|
+
export function createLayoutController({ isGeneSymbolPage, defaultNodeRepulsion }) {
|
|
17
|
+
let cy = null;
|
|
18
|
+
let currentLayout = "cose";
|
|
19
|
+
let nodeRepulsionScale = defaultNodeRepulsion;
|
|
20
|
+
let nodeRepulsionValue = scaleToOriginalRange(defaultNodeRepulsion, NODE_REPULSION_MIN, NODE_REPULSION_MAX);
|
|
21
|
+
let componentSpacingValue = scaleToOriginalRange(defaultNodeRepulsion, COMPONENT_SPACING_MIN, COMPONENT_SPACING_MAX);
|
|
22
|
+
|
|
23
|
+
let repulsionRunId = 0;
|
|
24
|
+
let repulsionAnimationId = null;
|
|
25
|
+
let layoutRunToken = 0;
|
|
26
|
+
let layoutRefreshTimeout = null;
|
|
27
|
+
|
|
28
|
+
function getEffectiveRepulsionScale() {
|
|
29
|
+
return currentLayout === "cose" ? nodeRepulsionScale : nodeRepulsionScale * 0.2;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function getLayoutOptions() {
|
|
33
|
+
const effectiveScale = getEffectiveRepulsionScale();
|
|
34
|
+
const spacingFactor = scaleToOriginalRange(effectiveScale, 0.65, 1.45);
|
|
35
|
+
const overlapPadding = scaleToOriginalRange(effectiveScale, 2, 18);
|
|
36
|
+
const minNodeSpacing = scaleToOriginalRange(effectiveScale, 10, 45);
|
|
37
|
+
const coseIdealEdgeLength = scaleToOriginalRange(effectiveScale, 70, 140);
|
|
38
|
+
const coseNodeOverlap = scaleToOriginalRange(effectiveScale, 10, 30);
|
|
39
|
+
|
|
40
|
+
if (currentLayout === "cose") {
|
|
41
|
+
const baseOptions = {
|
|
42
|
+
name: currentLayout,
|
|
43
|
+
nodeRepulsion: nodeRepulsionValue,
|
|
44
|
+
componentSpacing: componentSpacingValue,
|
|
45
|
+
idealEdgeLength: coseIdealEdgeLength,
|
|
46
|
+
nodeOverlap: coseNodeOverlap,
|
|
47
|
+
padding: 30,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
if (isGeneSymbolPage) {
|
|
51
|
+
return {
|
|
52
|
+
...baseOptions,
|
|
53
|
+
animate: true,
|
|
54
|
+
animationDuration: 500,
|
|
55
|
+
gravity: -1.2,
|
|
56
|
+
numIter: 1500,
|
|
57
|
+
initialTemp: 200,
|
|
58
|
+
coolingFactor: 0.95,
|
|
59
|
+
minTemp: 1.0,
|
|
60
|
+
edgeElasticity: 100,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return baseOptions;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (currentLayout === "grid") {
|
|
68
|
+
return {
|
|
69
|
+
name: currentLayout,
|
|
70
|
+
avoidOverlap: true,
|
|
71
|
+
avoidOverlapPadding: overlapPadding,
|
|
72
|
+
spacingFactor: spacingFactor,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (currentLayout === "concentric") {
|
|
77
|
+
return {
|
|
78
|
+
name: currentLayout,
|
|
79
|
+
minNodeSpacing: minNodeSpacing,
|
|
80
|
+
avoidOverlap: true,
|
|
81
|
+
spacingFactor: spacingFactor,
|
|
82
|
+
padding: 30,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (currentLayout === "breadthfirst") {
|
|
87
|
+
return {
|
|
88
|
+
name: currentLayout,
|
|
89
|
+
spacingFactor: spacingFactor,
|
|
90
|
+
avoidOverlap: true,
|
|
91
|
+
padding: 30,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { name: currentLayout };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function getVisibleComponentsForRepulsion() {
|
|
99
|
+
const visibleElements = cy.elements().filter((ele) => ele.style("display") === "element");
|
|
100
|
+
return visibleElements.components().filter((comp) => comp.nodes().length > 1);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function getGlobalRepulsionSpacingBase() {
|
|
104
|
+
const visibleNodes = cy.nodes().filter((node) => node.style("display") === "element");
|
|
105
|
+
const totalNodes = Math.max(1, visibleNodes.length);
|
|
106
|
+
const zoom = cy.zoom() || 1;
|
|
107
|
+
const width = Math.max(1, cy.width() / zoom);
|
|
108
|
+
const height = Math.max(1, cy.height() / zoom);
|
|
109
|
+
const area = Math.max(1, width * height);
|
|
110
|
+
const baseSpacing = Math.sqrt(area / totalNodes);
|
|
111
|
+
return {
|
|
112
|
+
baseSpacing: Math.max(REPULSION_SPACING_MIN, baseSpacing),
|
|
113
|
+
totalNodes,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function buildRepulsionState(nodes, baseSpacing, repulsionScale) {
|
|
118
|
+
if (!nodes || nodes.length < 2) return null;
|
|
119
|
+
|
|
120
|
+
const nodeArray = nodes.toArray();
|
|
121
|
+
const nodeCount = nodeArray.length;
|
|
122
|
+
const positions = new Array(nodeCount);
|
|
123
|
+
const movable = new Array(nodeCount);
|
|
124
|
+
const degreesById = new Map();
|
|
125
|
+
const nodeIdSet = new Set();
|
|
126
|
+
|
|
127
|
+
nodeArray.forEach((node, idx) => {
|
|
128
|
+
const nodeId = node.id();
|
|
129
|
+
degreesById.set(nodeId, 0);
|
|
130
|
+
nodeIdSet.add(nodeId);
|
|
131
|
+
const pos = node.position();
|
|
132
|
+
positions[idx] = { x: pos.x, y: pos.y };
|
|
133
|
+
const grabbed = typeof node.grabbed === "function" && node.grabbed();
|
|
134
|
+
movable[idx] = !node.locked() && !grabbed;
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
nodes.connectedEdges().forEach((edge) => {
|
|
138
|
+
if (edge.style("display") !== "element") {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const source = edge.data("source");
|
|
142
|
+
const target = edge.data("target");
|
|
143
|
+
if (!nodeIdSet.has(source) || !nodeIdSet.has(target)) return;
|
|
144
|
+
degreesById.set(source, (degreesById.get(source) || 0) + 1);
|
|
145
|
+
degreesById.set(target, (degreesById.get(target) || 0) + 1);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const degrees = new Float32Array(nodeCount);
|
|
149
|
+
let minDegree = Infinity;
|
|
150
|
+
let maxDegree = -Infinity;
|
|
151
|
+
|
|
152
|
+
nodeArray.forEach((node, idx) => {
|
|
153
|
+
const degree = degreesById.get(node.id()) || 0;
|
|
154
|
+
degrees[idx] = degree;
|
|
155
|
+
minDegree = Math.min(minDegree, degree);
|
|
156
|
+
maxDegree = Math.max(maxDegree, degree);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
if (!Number.isFinite(minDegree) || !Number.isFinite(maxDegree)) {
|
|
160
|
+
minDegree = 0;
|
|
161
|
+
maxDegree = 0;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const bbox = nodes.boundingBox({ includeLabels: false, includeOverlays: false });
|
|
165
|
+
let centerX = (bbox.x1 + bbox.x2) / 2;
|
|
166
|
+
let centerY = (bbox.y1 + bbox.y2) / 2;
|
|
167
|
+
|
|
168
|
+
if (!Number.isFinite(centerX) || !Number.isFinite(centerY)) {
|
|
169
|
+
const sum = positions.reduce(
|
|
170
|
+
(acc, pos) => {
|
|
171
|
+
acc.x += pos.x;
|
|
172
|
+
acc.y += pos.y;
|
|
173
|
+
return acc;
|
|
174
|
+
},
|
|
175
|
+
{ x: 0, y: 0 },
|
|
176
|
+
);
|
|
177
|
+
centerX = sum.x / positions.length;
|
|
178
|
+
centerY = sum.y / positions.length;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const spacingFactor = scaleToOriginalRange(
|
|
182
|
+
repulsionScale,
|
|
183
|
+
REPULSION_SPACING_FACTOR_MIN,
|
|
184
|
+
REPULSION_SPACING_FACTOR_MAX,
|
|
185
|
+
);
|
|
186
|
+
const spacing = baseSpacing * spacingFactor;
|
|
187
|
+
const strength = scaleToOriginalRange(repulsionScale, REPULSION_STRENGTH_MIN, REPULSION_STRENGTH_MAX);
|
|
188
|
+
const radialStrength = scaleToOriginalRange(repulsionScale, REPULSION_RADIAL_MIN, REPULSION_RADIAL_MAX);
|
|
189
|
+
|
|
190
|
+
const radialBase = Math.max(1, spacing * Math.sqrt(nodeCount));
|
|
191
|
+
const minRadius = Math.max(spacing * 0.8, radialBase * 0.35);
|
|
192
|
+
const maxRadius = Math.max(minRadius + spacing * 2, radialBase * 0.95 + spacing * 2);
|
|
193
|
+
// Low-degree nodes drift toward the periphery while hubs stay closer to center.
|
|
194
|
+
const targetRadii = new Float32Array(nodeCount);
|
|
195
|
+
|
|
196
|
+
nodeArray.forEach((node, idx) => {
|
|
197
|
+
const degree = degrees[idx];
|
|
198
|
+
const normalized = maxDegree === minDegree ? 0.5 : (degree - minDegree) / (maxDegree - minDegree);
|
|
199
|
+
targetRadii[idx] = minRadius + (1 - normalized) * (maxRadius - minRadius);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
let iterations = 4;
|
|
203
|
+
if (nodeCount > 2000) {
|
|
204
|
+
iterations = 1;
|
|
205
|
+
} else if (nodeCount > 1200) {
|
|
206
|
+
iterations = 2;
|
|
207
|
+
} else if (nodeCount > 800) {
|
|
208
|
+
iterations = 3;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const step = nodeCount > 1200 ? 0.55 : 0.65;
|
|
212
|
+
const maxShift = spacing * 0.4;
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
nodes: nodeArray,
|
|
216
|
+
positions,
|
|
217
|
+
movable,
|
|
218
|
+
targetRadii,
|
|
219
|
+
center: { x: centerX, y: centerY },
|
|
220
|
+
config: {
|
|
221
|
+
spacing,
|
|
222
|
+
strength,
|
|
223
|
+
radialStrength,
|
|
224
|
+
step,
|
|
225
|
+
iterations,
|
|
226
|
+
maxShift,
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function applyRepulsionIteration(state) {
|
|
232
|
+
const { nodes, positions, movable, targetRadii, center, config } = state;
|
|
233
|
+
const count = nodes.length;
|
|
234
|
+
const spacing = config.spacing;
|
|
235
|
+
const spacingSq = spacing * spacing;
|
|
236
|
+
const cellSize = Math.max(1, spacing);
|
|
237
|
+
// Spatial hashing keeps neighbor checks fast for large graphs.
|
|
238
|
+
const grid = new Map();
|
|
239
|
+
const cellX = new Int32Array(count);
|
|
240
|
+
const cellY = new Int32Array(count);
|
|
241
|
+
const dispX = new Float32Array(count);
|
|
242
|
+
const dispY = new Float32Array(count);
|
|
243
|
+
|
|
244
|
+
for (let i = 0; i < count; i += 1) {
|
|
245
|
+
const pos = positions[i];
|
|
246
|
+
const cx = Math.floor(pos.x / cellSize);
|
|
247
|
+
const cyCell = Math.floor(pos.y / cellSize);
|
|
248
|
+
cellX[i] = cx;
|
|
249
|
+
cellY[i] = cyCell;
|
|
250
|
+
const key = `${cx},${cyCell}`;
|
|
251
|
+
if (!grid.has(key)) {
|
|
252
|
+
grid.set(key, []);
|
|
253
|
+
}
|
|
254
|
+
grid.get(key).push(i);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
for (let i = 0; i < count; i += 1) {
|
|
258
|
+
const cx = cellX[i];
|
|
259
|
+
const cyCell = cellY[i];
|
|
260
|
+
for (let gx = -1; gx <= 1; gx += 1) {
|
|
261
|
+
for (let gy = -1; gy <= 1; gy += 1) {
|
|
262
|
+
const key = `${cx + gx},${cyCell + gy}`;
|
|
263
|
+
const bucket = grid.get(key);
|
|
264
|
+
if (!bucket) continue;
|
|
265
|
+
for (let b = 0; b < bucket.length; b += 1) {
|
|
266
|
+
const j = bucket[b];
|
|
267
|
+
if (j <= i) continue;
|
|
268
|
+
let dx = positions[i].x - positions[j].x;
|
|
269
|
+
let dy = positions[i].y - positions[j].y;
|
|
270
|
+
let distSq = dx * dx + dy * dy;
|
|
271
|
+
if (distSq < 0.01) {
|
|
272
|
+
dx = (i % 2 === 0 ? 1 : -1) * 0.01;
|
|
273
|
+
dy = (j % 2 === 0 ? 1 : -1) * 0.01;
|
|
274
|
+
distSq = dx * dx + dy * dy;
|
|
275
|
+
}
|
|
276
|
+
if (distSq >= spacingSq) continue;
|
|
277
|
+
const dist = Math.sqrt(distSq);
|
|
278
|
+
const force = ((spacing - dist) / spacing) * config.strength;
|
|
279
|
+
const ux = dx / dist;
|
|
280
|
+
const uy = dy / dist;
|
|
281
|
+
const fx = ux * force;
|
|
282
|
+
const fy = uy * force;
|
|
283
|
+
dispX[i] += fx;
|
|
284
|
+
dispY[i] += fy;
|
|
285
|
+
dispX[j] -= fx;
|
|
286
|
+
dispY[j] -= fy;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (config.radialStrength > 0) {
|
|
293
|
+
for (let i = 0; i < count; i += 1) {
|
|
294
|
+
if (!movable[i]) continue;
|
|
295
|
+
let dx = positions[i].x - center.x;
|
|
296
|
+
let dy = positions[i].y - center.y;
|
|
297
|
+
let dist = Math.sqrt(dx * dx + dy * dy);
|
|
298
|
+
if (dist < 0.01) {
|
|
299
|
+
const angle = (i / count) * Math.PI * 2;
|
|
300
|
+
dx = Math.cos(angle);
|
|
301
|
+
dy = Math.sin(angle);
|
|
302
|
+
dist = 1;
|
|
303
|
+
}
|
|
304
|
+
const delta = (targetRadii[i] - dist) * config.radialStrength;
|
|
305
|
+
dispX[i] += (dx / dist) * delta;
|
|
306
|
+
dispY[i] += (dy / dist) * delta;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
let moved = false;
|
|
311
|
+
cy.batch(() => {
|
|
312
|
+
for (let i = 0; i < count; i += 1) {
|
|
313
|
+
if (!movable[i]) continue;
|
|
314
|
+
let dx = dispX[i] * config.step;
|
|
315
|
+
let dy = dispY[i] * config.step;
|
|
316
|
+
const shift = Math.hypot(dx, dy);
|
|
317
|
+
if (shift > config.maxShift) {
|
|
318
|
+
const scale = config.maxShift / shift;
|
|
319
|
+
dx *= scale;
|
|
320
|
+
dy *= scale;
|
|
321
|
+
}
|
|
322
|
+
if (Math.abs(dx) > 0.001 || Math.abs(dy) > 0.001) {
|
|
323
|
+
moved = true;
|
|
324
|
+
}
|
|
325
|
+
positions[i].x += dx;
|
|
326
|
+
positions[i].y += dy;
|
|
327
|
+
nodes[i].position({ x: positions[i].x, y: positions[i].y });
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
return moved;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function scheduleNodeRepulsion() {
|
|
335
|
+
if (!cy) return;
|
|
336
|
+
if (repulsionAnimationId) {
|
|
337
|
+
cancelAnimationFrame(repulsionAnimationId);
|
|
338
|
+
}
|
|
339
|
+
const components = getVisibleComponentsForRepulsion();
|
|
340
|
+
if (!components.length) return;
|
|
341
|
+
const { baseSpacing } = getGlobalRepulsionSpacingBase();
|
|
342
|
+
const effectiveScale = getEffectiveRepulsionScale();
|
|
343
|
+
const states = components
|
|
344
|
+
.map((comp) => buildRepulsionState(comp.nodes(), baseSpacing, effectiveScale))
|
|
345
|
+
.filter(Boolean);
|
|
346
|
+
if (!states.length) return;
|
|
347
|
+
const runId = ++repulsionRunId;
|
|
348
|
+
if (typeof window !== "undefined") {
|
|
349
|
+
window.dispatchEvent(new CustomEvent("tsumugi:repulsion:start", { detail: { runId } }));
|
|
350
|
+
}
|
|
351
|
+
const stateIterations = states.map(() => 0);
|
|
352
|
+
|
|
353
|
+
const tick = () => {
|
|
354
|
+
if (runId !== repulsionRunId) return;
|
|
355
|
+
let anyActive = false;
|
|
356
|
+
|
|
357
|
+
states.forEach((state, idx) => {
|
|
358
|
+
if (stateIterations[idx] >= state.config.iterations) {
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
const moved = applyRepulsionIteration(state);
|
|
362
|
+
stateIterations[idx] += 1;
|
|
363
|
+
if (!moved) {
|
|
364
|
+
stateIterations[idx] = state.config.iterations;
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
if (stateIterations[idx] < state.config.iterations) {
|
|
368
|
+
anyActive = true;
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
if (anyActive) {
|
|
373
|
+
repulsionAnimationId = requestAnimationFrame(tick);
|
|
374
|
+
} else {
|
|
375
|
+
repulsionAnimationId = null;
|
|
376
|
+
if (typeof window !== "undefined") {
|
|
377
|
+
window.dispatchEvent(new CustomEvent("tsumugi:repulsion:finish", { detail: { runId } }));
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
repulsionAnimationId = requestAnimationFrame(tick);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function runLayoutWithRepulsion() {
|
|
386
|
+
if (!cy) return;
|
|
387
|
+
if (repulsionAnimationId) {
|
|
388
|
+
cancelAnimationFrame(repulsionAnimationId);
|
|
389
|
+
repulsionAnimationId = null;
|
|
390
|
+
}
|
|
391
|
+
repulsionRunId += 1;
|
|
392
|
+
const layout = cy.layout(getLayoutOptions());
|
|
393
|
+
const token = ++layoutRunToken;
|
|
394
|
+
cy.one("layoutstop", () => {
|
|
395
|
+
if (token !== layoutRunToken) return;
|
|
396
|
+
scheduleNodeRepulsion();
|
|
397
|
+
});
|
|
398
|
+
layout.run();
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function updateRepulsionScale(scale) {
|
|
402
|
+
nodeRepulsionScale = scale;
|
|
403
|
+
nodeRepulsionValue = scaleToOriginalRange(scale, NODE_REPULSION_MIN, NODE_REPULSION_MAX);
|
|
404
|
+
componentSpacingValue = scaleToOriginalRange(scale, COMPONENT_SPACING_MIN, COMPONENT_SPACING_MAX);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function setLayout(layoutName) {
|
|
408
|
+
currentLayout = layoutName;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function getLayout() {
|
|
412
|
+
return currentLayout;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function attachCy(instance) {
|
|
416
|
+
cy = instance;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function queueLayoutRefresh(delayMs = 150) {
|
|
420
|
+
if (!cy) return;
|
|
421
|
+
if (layoutRefreshTimeout) {
|
|
422
|
+
clearTimeout(layoutRefreshTimeout);
|
|
423
|
+
}
|
|
424
|
+
layoutRefreshTimeout = setTimeout(() => {
|
|
425
|
+
runLayoutWithRepulsion();
|
|
426
|
+
layoutRefreshTimeout = null;
|
|
427
|
+
}, delayMs);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function clearLayoutRefresh() {
|
|
431
|
+
if (layoutRefreshTimeout) {
|
|
432
|
+
clearTimeout(layoutRefreshTimeout);
|
|
433
|
+
layoutRefreshTimeout = null;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function registerInitialLayoutStop() {
|
|
438
|
+
if (!cy) return;
|
|
439
|
+
cy.one("layoutstop", scheduleNodeRepulsion);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return {
|
|
443
|
+
attachCy,
|
|
444
|
+
clearLayoutRefresh,
|
|
445
|
+
getLayout,
|
|
446
|
+
getLayoutOptions,
|
|
447
|
+
queueLayoutRefresh,
|
|
448
|
+
registerInitialLayoutStop,
|
|
449
|
+
runLayoutWithRepulsion,
|
|
450
|
+
scheduleNodeRepulsion,
|
|
451
|
+
setLayout,
|
|
452
|
+
updateRepulsionScale,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export function scaleToOriginalRange(value, minValue, maxValue, scaleMin = 1, scaleMax = 10) {
|
|
2
|
+
// Scales a value from the range [scaleMin, scaleMax] to a new range [minValue, maxValue].
|
|
3
|
+
if (maxValue === minValue) {
|
|
4
|
+
return minValue;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const denominator = scaleMax - scaleMin;
|
|
8
|
+
if (denominator === 0) {
|
|
9
|
+
return minValue;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const clampedValue = Math.min(Math.max(value, scaleMin), scaleMax);
|
|
13
|
+
return minValue + ((clampedValue - scaleMin) * (maxValue - minValue)) / denominator;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function scaleValue(value, minValue, maxValue, minScale, maxScale) {
|
|
17
|
+
// Map the value into the [minScale, maxScale] interval
|
|
18
|
+
if (minValue == maxValue) {
|
|
19
|
+
return (maxScale + minScale) / 2;
|
|
20
|
+
}
|
|
21
|
+
return minScale + ((value - minValue) * (maxScale - minScale)) / (maxValue - minValue);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function getColorForValue(value, scaleMin = 1, scaleMax = 10) {
|
|
25
|
+
// Normalize the value from [scaleMin, scaleMax] into [0, 1]
|
|
26
|
+
const denominator = scaleMax - scaleMin;
|
|
27
|
+
const clampedValue = Math.min(Math.max(value, scaleMin), scaleMax);
|
|
28
|
+
const ratio = denominator === 0 ? 0 : (clampedValue - scaleMin) / denominator;
|
|
29
|
+
|
|
30
|
+
// Interpolate from light yellow to orange
|
|
31
|
+
const r1 = 248,
|
|
32
|
+
g1 = 229,
|
|
33
|
+
b1 = 140; // Light Yellow
|
|
34
|
+
const r2 = 255,
|
|
35
|
+
g2 = 140,
|
|
36
|
+
b2 = 0; // Orange
|
|
37
|
+
|
|
38
|
+
const r = Math.round(r1 + (r2 - r1) * ratio);
|
|
39
|
+
const g = Math.round(g1 + (g2 - g1) * ratio);
|
|
40
|
+
const b = Math.round(b1 + (b2 - b1) * ratio);
|
|
41
|
+
|
|
42
|
+
return `rgb(${r}, ${g}, ${b})`;
|
|
43
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
export function setupGeneSearch({
|
|
2
|
+
cy,
|
|
3
|
+
inputId = "gene-search",
|
|
4
|
+
listId = "suggestions",
|
|
5
|
+
// buttonId = "search-button",
|
|
6
|
+
}) {
|
|
7
|
+
const input = document.getElementById(inputId);
|
|
8
|
+
const suggestionsList = document.getElementById(listId);
|
|
9
|
+
|
|
10
|
+
// Shared helper that performs the gene search
|
|
11
|
+
function performSearch(query) {
|
|
12
|
+
const normalized = query.trim().toLowerCase();
|
|
13
|
+
const matchedNode = cy.nodes().filter((node) => node.data("label").toLowerCase() === normalized);
|
|
14
|
+
|
|
15
|
+
if (matchedNode.length > 0) {
|
|
16
|
+
matchedNode.addClass("gene-highlight");
|
|
17
|
+
cy.center(matchedNode);
|
|
18
|
+
cy.animate({
|
|
19
|
+
center: { eles: matchedNode },
|
|
20
|
+
zoom: 5,
|
|
21
|
+
duration: 500,
|
|
22
|
+
});
|
|
23
|
+
} else {
|
|
24
|
+
alert("Gene not found in the network.");
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Shared helper that renders suggestion items
|
|
29
|
+
function showSuggestions(query = "") {
|
|
30
|
+
const normalizedQuery = query.trim().toLowerCase();
|
|
31
|
+
suggestionsList.innerHTML = "";
|
|
32
|
+
|
|
33
|
+
const visibleLabels = cy
|
|
34
|
+
.nodes()
|
|
35
|
+
.filter((n) => n.style("display") !== "none")
|
|
36
|
+
.map((n) => n.data("label"));
|
|
37
|
+
|
|
38
|
+
const matched = visibleLabels
|
|
39
|
+
.filter((label) => (normalizedQuery ? label.toLowerCase().includes(normalizedQuery) : true))
|
|
40
|
+
.sort()
|
|
41
|
+
.slice(0, 10);
|
|
42
|
+
|
|
43
|
+
if (matched.length === 0) {
|
|
44
|
+
suggestionsList.hidden = true;
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
matched.forEach((label) => {
|
|
49
|
+
const li = document.createElement("li");
|
|
50
|
+
li.textContent = label;
|
|
51
|
+
|
|
52
|
+
// Trigger a search when the suggestion is clicked
|
|
53
|
+
li.addEventListener("mousedown", () => {
|
|
54
|
+
input.value = label;
|
|
55
|
+
suggestionsList.hidden = true;
|
|
56
|
+
performSearch(label); // Fire the search immediately
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
suggestionsList.appendChild(li);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
suggestionsList.hidden = false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
input.addEventListener("input", () => {
|
|
66
|
+
const query = input.value.trim().toLowerCase();
|
|
67
|
+
|
|
68
|
+
if (!query) {
|
|
69
|
+
suggestionsList.hidden = true;
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
showSuggestions(query);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Show suggestions on click
|
|
77
|
+
input.addEventListener("click", () => {
|
|
78
|
+
const query = input.value.trim().toLowerCase();
|
|
79
|
+
showSuggestions(query);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Show suggestions when the field gains focus
|
|
83
|
+
input.addEventListener("focus", () => {
|
|
84
|
+
const query = input.value.trim().toLowerCase();
|
|
85
|
+
showSuggestions(query);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
input.addEventListener("blur", () => {
|
|
89
|
+
setTimeout(() => {
|
|
90
|
+
suggestionsList.hidden = true;
|
|
91
|
+
}, 100);
|
|
92
|
+
});
|
|
93
|
+
}
|