Erva Ulusoy commited on
Commit
614205a
·
1 Parent(s): a9a53d0

replace pyvis with eccharts kg view

Browse files
Files changed (3) hide show
  1. kg_graph_template.html +298 -0
  2. requirements.txt +1 -2
  3. visualize_kg.py +139 -442
kg_graph_template.html ADDED
@@ -0,0 +1,298 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <!-- Knowledge graph view (ECharts, radial layout). Filled in by visualize_kg.render_graph_html. -->
3
+ <html><head><meta charset="utf-8">
4
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&display=swap">
5
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/echarts/5.5.0/echarts.min.js"></script>
6
+ <style>
7
+ :root { --border: #E4E2DC; --muted: #4A4E5C; --text: #1A1C24; }
8
+ body { margin: 0; font-family: 'Manrope', sans-serif; color: var(--text); background: #fff; }
9
+ .frame { position: relative; border: 1px solid var(--border); border-radius: 16px; overflow: hidden; }
10
+ #chart { height: 760px; }
11
+ /* Edge legend: centred under the node legend (an ECharts legend at the top of the canvas).
12
+ The strip itself lets mouse events through so the canvas can still be panned underneath it. */
13
+ .edges { position: absolute; top: 40px; left: 0; right: 0; display: flex; flex-wrap: wrap; justify-content: center;
14
+ gap: 6px 16px; padding: 0 16px; font-size: 12px; color: var(--muted); align-items: center; pointer-events: none; }
15
+ .chip { display: inline-flex; align-items: center; gap: 6px; }
16
+ button.chip { font: inherit; color: inherit; background: none; border: 0; padding: 2px 0; cursor: pointer;
17
+ pointer-events: auto; }
18
+ button.chip:hover { color: var(--text); }
19
+ button.chip.off { opacity: 0.4; text-decoration: line-through; }
20
+ .line { width: 18px; height: 3px; border-radius: 2px; }
21
+ .hint { position: absolute; bottom: 10px; left: 0; right: 0; text-align: center; color: #A3A6AF; font-size: 11px;
22
+ pointer-events: none; }
23
+ </style></head>
24
+ <body>
25
+ <div class="frame">
26
+ <div id="chart"></div>
27
+ <div class="edges" id="edges"></div>
28
+ <div class="hint">Hover for details · drag to move · scroll to zoom · click a legend item to hide it</div>
29
+ </div>
30
+ <script>
31
+ const elements = __ELEMENTS__, KINDS = __KINDS__, QUERY = __QUERY__;
32
+ const PREDICTED = __PREDICTED__; // GO categories predicted in this run, e.g. ["Molecular Function"]
33
+ const nodesIn = elements.filter(e => !e.data.source).map(e => e.data);
34
+ const edgesIn = elements.filter(e => e.data.source).map(e => e.data);
35
+ const typeById = Object.fromEntries(nodesIn.map(n => [n.id, n.type]));
36
+ const esc = s => String(s).replace(/[&<>"]/g, c => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;'}[c]));
37
+
38
+ // Categories double as the interactive legend: clicking one hides that node type.
39
+ // The query protein has its own category so hiding "Associated proteins" never removes it.
40
+ const QUERY_BORDER = '#1F5F5D'; // dark teal ring around the query protein
41
+ const catOf = n => n.role === 'query' ? 'Query protein' : n.type === 'Protein' ? 'Associated proteins' : n.type;
42
+ const cats = [['Query protein', nodesIn.find(n => n.role === 'query').color],
43
+ ...new Map(nodesIn.filter(n => n.role !== 'query').map(n => [catOf(n), n.color])).entries()];
44
+ const catIndex = Object.fromEntries(cats.map(([t], i) => [t, i]));
45
+ const degree = {};
46
+ edgesIn.forEach(e => { degree[e.source] = (degree[e.source] || 0) + 1; degree[e.target] = (degree[e.target] || 0) + 1; });
47
+
48
+ // Word-wrap text to lines of at most `max` characters (ECharts does not wrap inside rich text);
49
+ // keeps at most 3 lines and ends with an ellipsis if longer.
50
+ function wrap(text, max) {
51
+ const words = String(text).replace(/[{}|]/g, '').split(/\s+/), lines = [];
52
+ let cur = '';
53
+ words.forEach(w => {
54
+ if (cur && (cur + ' ' + w).length > max) { lines.push(cur); cur = w; } else { cur = cur ? cur + ' ' + w : w; }
55
+ });
56
+ if (cur) lines.push(cur);
57
+ if (lines.length > 3) { lines.length = 3; lines[2] = lines[2].replace(/.{0,1}$/, '…'); }
58
+ return lines;
59
+ }
60
+
61
+ const nodes = nodesIn.map(n => ({
62
+ id: n.id, name: n.id, value: n.name, category: catIndex[catOf(n)], raw: n,
63
+ symbolSize: n.role === 'query' ? 40 : 12 + Math.min(degree[n.id] || 0, 6) * 1.5,
64
+ itemStyle: n.role === 'query' ? { borderColor: QUERY_BORDER, borderWidth: 4 }
65
+ : { borderColor: '#fff', borderWidth: 1.5 },
66
+ // Name on top (wrapped), ID below in brackets, on a white pill
67
+ label: { show: true, backgroundColor: 'rgba(255,255,255,0.8)', padding: [2, 4], borderRadius: 6,
68
+ formatter: wrap(n.name, n.role === 'query' ? 26 : 20).map(l => `{n|${l}}`).join('\n') + `\n{i|(${n.id})}`,
69
+ rich: { n: { fontSize: n.role === 'query' ? 12 : 10, fontWeight: n.role === 'query' ? 700 : 600,
70
+ color: '#1A1C24', align: 'center', lineHeight: n.role === 'query' ? 15 : 12 },
71
+ i: { fontSize: n.role === 'query' ? 11 : 9, color: '#8A8D98', align: 'center', lineHeight: 12 } } },
72
+ }));
73
+ const strong = k => k === 'confirmed' || k === 'novel';
74
+ const links = edgesIn.map(e => ({
75
+ source: e.source, target: e.target, raw: e,
76
+ lineStyle: { color: e.color, width: strong(e.kind) ? 2.4 : e.kind === 'annotation' ? 1.8 : 1.1,
77
+ opacity: strong(e.kind) ? 0.9 : 0.55, curveness: 0.08 },
78
+ // Shown only on hover of the edge or of a node it touches (see syncEdgeLabels).
79
+ // Sits on the line itself; the white pill (80% opaque) mostly masks the line behind the text.
80
+ label: { show: true, position: 'middle', distance: 0, verticalAlign: 'middle',
81
+ formatter: wrap(e.relation, 12).join('\n'), fontSize: 9, lineHeight: 11, align: 'center',
82
+ color: strong(e.kind) || e.kind === 'annotation' ? e.color : '#6B6E78',
83
+ backgroundColor: 'rgba(255,255,255,0.8)', padding: [2, 4], borderRadius: 6,
84
+ opacity: 1 }, // otherwise ECharts gives the label the line's opacity and the line shows through
85
+ }));
86
+
87
+ // Radial layout: query at the centre, first-degree neighbours on an inner ring grouped into sectors
88
+ // (confirmed, novel, annotations, then other types), second-degree neighbours on an outer ring next to the
89
+ // first-degree node(s) they hang off. Labels point outwards.
90
+ (function placeRadial() {
91
+ const R1 = 230, R2 = 470;
92
+ const adj = {};
93
+ links.forEach(l => { (adj[l.source] = adj[l.source] || new Set()).add(l.target);
94
+ (adj[l.target] = adj[l.target] || new Set()).add(l.source); });
95
+ const kindFromQuery = {};
96
+ links.forEach(l => { if (l.source === QUERY) kindFromQuery[l.target] = l.raw.kind;
97
+ else if (l.target === QUERY) kindFromQuery[l.source] = l.raw.kind; });
98
+ const rank = n => ({ confirmed: 0, novel: 1, annotation: 2 })[kindFromQuery[n.id]] ?? 3;
99
+ const first = nodes.filter(n => n.id in kindFromQuery)
100
+ .sort((a, b) => rank(a) - rank(b) || a.raw.type.localeCompare(b.raw.type) || a.id.localeCompare(b.id));
101
+ const firstIds = new Set(first.map(n => n.id));
102
+
103
+ // Inner ring: one slot per node, a fraction of a slot as gap between sectors
104
+ let slot = 0, prevGroup = null;
105
+ first.forEach(n => { const g = rank(n) + '|' + n.raw.type; if (prevGroup !== null && g !== prevGroup) slot += 0.6;
106
+ n._slot = slot++; prevGroup = g; });
107
+ const angle = {};
108
+ first.forEach(n => { angle[n.id] = -Math.PI / 2 + 2 * Math.PI * n._slot / Math.max(slot, 1); });
109
+
110
+ // Outer ring: circular mean of the parents' angles ...
111
+ const second = nodes.filter(n => n.id !== QUERY && !firstIds.has(n.id)).map(n => {
112
+ let sx = 0, sy = 0;
113
+ (adj[n.id] ? [...adj[n.id]] : []).filter(p => firstIds.has(p)).forEach(p => { sx += Math.cos(angle[p]); sy += Math.sin(angle[p]); });
114
+ angle[n.id] = Math.atan2(sy, sx);
115
+ return n;
116
+ }).sort((a, b) => angle[a.id] - angle[b.id]);
117
+ // ... then relax neighbouring pairs (wrapping around the circle) until each pair is at least minSep apart
118
+ const minSep = Math.min(2 * Math.PI / Math.max(second.length, 1), 0.28);
119
+ for (let iter = 0; iter < 200; iter++) {
120
+ let moved = false;
121
+ for (let i = 0; i < second.length; i++) {
122
+ const a = second[i], b = second[(i + 1) % second.length];
123
+ let gap = angle[b.id] - angle[a.id];
124
+ if (i === second.length - 1) gap += 2 * Math.PI;
125
+ if (second.length > 1 && gap < minSep) {
126
+ const push = (minSep - gap) / 2;
127
+ angle[a.id] -= push; angle[b.id] += push; moved = true;
128
+ }
129
+ }
130
+ if (!moved) break;
131
+ }
132
+
133
+ nodes.forEach(n => {
134
+ if (n.id === QUERY) { n.x = 0; n.y = 0; n.label.position = 'bottom'; return; }
135
+ const inner = firstIds.has(n.id), r = inner ? R1 : R2, a = angle[n.id], c = Math.cos(a), s = Math.sin(a);
136
+ n.x = r * c; n.y = r * s;
137
+ // Outer ring labels always go sideways (crowded top/bottom labels would collide otherwise)
138
+ n.label.position = !inner ? (c >= 0 ? 'right' : 'left')
139
+ : c > 0.35 ? 'right' : c < -0.35 ? 'left' : s > 0 ? 'bottom' : 'top';
140
+ n.label.align = n.label.position === 'right' ? 'left' : n.label.position === 'left' ? 'right' : 'center';
141
+ });
142
+ })();
143
+
144
+ const chart = echarts.init(document.getElementById('chart'), null, { renderer: 'canvas' });
145
+ chart.setOption({
146
+ textStyle: { fontFamily: 'Manrope, sans-serif' },
147
+ tooltip: {
148
+ enterable: true, // so the "Open entry" link in the card can be clicked
149
+ backgroundColor: '#fff', borderColor: '#E4E2DC', borderWidth: 1, padding: [10, 12], extraCssText:
150
+ 'border-radius:12px;box-shadow:0 6px 24px rgba(26,28,36,.10);max-width:280px;white-space:normal;',
151
+ textStyle: { color: '#1A1C24', fontSize: 13 },
152
+ formatter: p => {
153
+ const d = p.data.raw;
154
+ if (p.dataType === 'node') {
155
+ const role = d.role === 'query' ? ' · query protein' : '';
156
+ return `<b>${esc(d.name)}</b><div style="color:#4A4E5C;font-size:12px">${esc(d.id)} · ${esc(d.type)}${role}</div>` +
157
+ (d.url ? `<a href="${esc(d.url)}" target="_blank" style="color:#3B4BD8;font-size:12px;text-decoration:none;display:inline-block;margin-top:4px">Open entry ↗</a>` : '');
158
+ }
159
+ // Blue edges of other proteins carry no note: predictions are only made for the query protein.
160
+ // For the query, say whether the term's GO category was part of this run at all.
161
+ const category = typeById[d.target];
162
+ const score = d.prob !== null ? `score ${d.prob.toFixed(2)}`
163
+ : d.kind !== 'annotation' || d.source !== QUERY ? ''
164
+ : PREDICTED.includes(category) ? 'not predicted in this run'
165
+ : `no ${category} predictions were made in this run`;
166
+ return `<b style="color:${d.color}">${esc(KINDS[d.kind])}</b><div style="color:#4A4E5C;font-size:12px">` +
167
+ `${esc(d.source)} ${esc(d.relation)} ${esc(d.target)}${score ? ' · ' + score : ''}</div>`;
168
+ },
169
+ },
170
+ legend: { data: cats.map(([t]) => t), top: 14, left: 'center', icon: 'circle', itemWidth: 10, itemHeight: 10,
171
+ textStyle: { color: '#4A4E5C', fontSize: 12 } },
172
+ toolbox: { right: 14, top: 10, itemSize: 14, itemGap: 12, iconStyle: { borderColor: '#4A4E5C' },
173
+ feature: { restore: { title: 'Reset' },
174
+ saveAsImage: { title: 'PNG', pixelRatio: 3, name: QUERY + '_knowledge_graph' },
175
+ // Custom features must be named my*; icon is a document outline with "{ }"
176
+ myEdges: { title: 'Edges (JSON)', onclick: () => downloadShownEdges(),
177
+ icon: 'path://M5,2h9l5,5v15H5z M14,2v5h5 M10.5,10.5c-1.5,0-1.5,1-1.5,2s0,1.5-1,1.5c1,0,1,0.5,1,1.5s0,2,1.5,2 M13.5,10.5c1.5,0,1.5,1,1.5,2s0,1.5,1,1.5c-1,0-1,0.5-1,1.5s0,2-1.5,2' } } },
178
+ animationDuration: 600,
179
+ series: [{
180
+ type: 'graph', layout: 'none', roam: true, draggable: true,
181
+ // Only top/bottom + centred, so ECharts keeps the aspect ratio (all four sides would stretch the circle)
182
+ top: 120, bottom: 60, left: 'center',
183
+ data: nodes, links, categories: cats.map(([t, c]) => ({ name: t, itemStyle:
184
+ t === 'Query protein' ? { color: c, borderColor: QUERY_BORDER, borderWidth: 2 } : { color: c } })),
185
+ edgeSymbol: ['none', 'arrow'], edgeSymbolSize: 6,
186
+ label: { color: '#4A4E5C', distance: 3 },
187
+ emphasis: { focus: 'adjacency', lineStyle: { width: 3, opacity: 1 } },
188
+ blur: { itemStyle: { opacity: 0.12 }, lineStyle: { opacity: 0.05 }, label: { opacity: 0.2 } },
189
+ }],
190
+ });
191
+
192
+ // Edge labels are shown only for the hovered edge, or for all edges of the hovered node.
193
+ // ECharts has no option for this (its adjacency focus does not put neighbouring edges into the emphasis
194
+ // state), so the label elements are toggled directly. Hover states restore the labels' normal visibility,
195
+ // so the sync also runs after every render; it only redraws when something actually changes.
196
+ let hoveredNode = null, hoveredEdge = null;
197
+ function syncEdgeLabels() {
198
+ const series = chart.getModel().getSeriesByIndex(0), edgeData = series.getEdgeData();
199
+ const node = hoveredNode === null ? null : series.getGraph().getNodeByIndex(hoveredNode);
200
+ const show = new Set(node ? node.edges.map(e => e.dataIndex) : []);
201
+ if (hoveredEdge !== null) show.add(hoveredEdge);
202
+ edgeData.each(i => {
203
+ const el = edgeData.getItemGraphicEl(i), text = el && el.getTextContent();
204
+ if (text && text.ignore === show.has(i)) { text.ignore = !show.has(i); text.markRedraw(); }
205
+ });
206
+ }
207
+ chart.on('rendered', syncEdgeLabels);
208
+ chart.on('mouseover', p => {
209
+ if (p.dataType === 'node') hoveredNode = p.dataIndex; else if (p.dataType === 'edge') hoveredEdge = p.dataIndex;
210
+ setTimeout(syncEdgeLabels, 30);
211
+ });
212
+ chart.on('mouseout', p => {
213
+ if (p.dataType === 'node') hoveredNode = null; else if (p.dataType === 'edge') hoveredEdge = null;
214
+ setTimeout(syncEdgeLabels, 30);
215
+ });
216
+
217
+ // Keep edge labels upright. ECharts rotates 'middle' edge labels along the edge and has no option to turn
218
+ // that off, so wrap the edge element's beforeUpdate (once, on its prototype) and reset the rotation right
219
+ // after ECharts sets it. Relies on ECharts 5 internals (_lineDraw, Line#beforeUpdate): recheck when
220
+ // upgrading the pinned ECharts version.
221
+ let uprightPatched = false;
222
+ chart.on('rendered', () => {
223
+ if (uprightPatched) return;
224
+ const view = chart.getViewOfSeriesModel(chart.getModel().getSeriesByIndex(0));
225
+ const first = view && view._lineDraw && view._lineDraw.group.childAt(0);
226
+ if (!first) return;
227
+ const proto = Object.getPrototypeOf(first), original = proto.beforeUpdate;
228
+ proto.beforeUpdate = function () {
229
+ original.apply(this, arguments);
230
+ const text = this.getTextContent();
231
+ if (text) text.rotation = 0;
232
+ };
233
+ uprightPatched = true;
234
+ });
235
+
236
+ // Pan and zoom from anywhere on the canvas. ECharts only accepts them inside the bounding box of the
237
+ // nodes, and re-sets that check on every render, so the controller is made to always use this one.
238
+ // The top row (node legend and toolbox, drawn on the canvas) stays excluded so it remains clickable.
239
+ // Relies on ECharts 5 internals (GraphView#_controller): recheck when upgrading the pinned version.
240
+ const panAnywhere = (e, x, y) => y > 40;
241
+ let panPatched = false;
242
+ chart.on('rendered', () => {
243
+ if (panPatched) return;
244
+ const controller = chart.getViewOfSeriesModel(chart.getModel().getSeriesByIndex(0))._controller;
245
+ if (!controller) return;
246
+ const original = controller.setPointerChecker;
247
+ controller.setPointerChecker = function () { original.call(this, panAnywhere); };
248
+ controller.setPointerChecker();
249
+ panPatched = true;
250
+ });
251
+
252
+ // Edge-kind filter: hide the edges of a kind and any node left without visible edges.
253
+ // Current positions (including dragged nodes) are carried over.
254
+ const hiddenKinds = new Set();
255
+ function applyEdgeFilter() {
256
+ const pos = {};
257
+ chart.getModel().getSeriesByIndex(0).getGraph().eachNode(n => { pos[n.id] = n.getLayout(); });
258
+ const visibleLinks = links.filter(l => !hiddenKinds.has(l.raw.kind));
259
+ const keep = new Set([QUERY]);
260
+ visibleLinks.forEach(l => { keep.add(l.source); keep.add(l.target); });
261
+ const visibleNodes = nodes.filter(n => keep.has(n.id)).map(n => {
262
+ const p = pos[n.id];
263
+ return p ? { ...n, x: p[0], y: p[1] } : n;
264
+ });
265
+ chart.setOption({ series: [{ data: visibleNodes, links: visibleLinks }] });
266
+ }
267
+
268
+ // Download the edges currently shown (respecting hidden edge kinds and hidden node types), grouped by
269
+ // edge type: {"Protein_protein_function_GO_term_F": [{source, target, probability, kind}, ...], ...}
270
+ function downloadShownEdges() {
271
+ const selected = chart.getOption().legend[0].selected || {};
272
+ const nodeShown = Object.fromEntries(nodes.map(n => [n.id, selected[cats[n.category][0]] !== false]));
273
+ const out = {};
274
+ links.filter(l => !hiddenKinds.has(l.raw.kind) && nodeShown[l.source] && nodeShown[l.target]).forEach(l => {
275
+ (out[l.raw.type] = out[l.raw.type] || []).push(
276
+ { source: l.source, target: l.target, probability: l.raw.prob, kind: l.raw.kind });
277
+ });
278
+ const url = URL.createObjectURL(new Blob([JSON.stringify(out, null, 2)], { type: 'application/json' }));
279
+ const a = Object.assign(document.createElement('a'), { href: url, download: QUERY + '_edges.json' });
280
+ document.body.appendChild(a); a.click(); a.remove();
281
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
282
+ }
283
+
284
+ window.addEventListener('resize', () => chart.resize());
285
+
286
+ const present = new Set(edgesIn.map(e => e.kind)), kindColor = {};
287
+ edgesIn.forEach(e => { kindColor[e.kind] = e.color; });
288
+ const edgeLegend = document.getElementById('edges');
289
+ edgeLegend.innerHTML = Object.keys(KINDS).filter(k => present.has(k))
290
+ .map(k => `<button class="chip" data-kind="${k}"><span class="line" style="background:${kindColor[k]}"></span>${esc(KINDS[k])}</button>`).join('');
291
+ edgeLegend.querySelectorAll('button.chip').forEach(b => b.addEventListener('click', () => {
292
+ const k = b.dataset.kind;
293
+ hiddenKinds.has(k) ? hiddenKinds.delete(k) : hiddenKinds.add(k);
294
+ b.classList.toggle('off', hiddenKinds.has(k));
295
+ applyEdgeFilter();
296
+ }));
297
+ </script>
298
+ </body></html>
requirements.txt CHANGED
@@ -7,5 +7,4 @@ torch_sparse==0.6.15
7
  torch_scatter==2.1.0
8
  torch_geometric==2.2.0
9
  gdown
10
- rapidfuzz
11
- pyvis
 
7
  torch_scatter==2.1.0
8
  torch_geometric==2.2.0
9
  gdown
10
+ rapidfuzz
 
visualize_kg.py CHANGED
@@ -1,7 +1,11 @@
1
- from pyvis.network import Network
2
  import os
3
  import json
4
  import gzip
 
 
 
 
 
5
 
6
  NODE_TYPE_COLORS = {
7
  'Disease': '#079dbb',
@@ -18,6 +22,20 @@ NODE_TYPE_COLORS = {
18
  'Protein': '#3aa6a4'
19
  }
20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  EDGE_LABEL_TRANSLATION = {
22
  'Orthology': 'is ortholog to',
23
  'Pathway': 'takes part in',
@@ -131,61 +149,65 @@ def _gather_protein_edges(data, protein_id):
131
 
132
  return protein_edges
133
 
134
- def _filter_edges(protein_id, protein_edges, prediction_df, limit=10, is_second_degree=False, second_degree_limit=3):
 
135
  """
136
- Filter edges based on type and limit
137
-
 
 
 
 
 
 
 
138
  Args:
139
- protein_id: ID of the protein
140
- protein_edges: Dictionary of edges to filter
141
  prediction_df: DataFrame containing predictions
142
- limit: Maximum number of edges to keep for first-degree connections
143
- is_second_degree: Whether these are second-degree edges
144
- second_degree_limit: Maximum number of edges to keep for second-degree connections
 
145
  """
146
  filtered_edges = {}
147
-
148
  # Use appropriate limit based on edge degree
149
  current_limit = second_degree_limit if is_second_degree else limit
150
-
151
- prediction_categories = prediction_df['GO_category'].unique()
152
- prediction_categories = [GO_CATEGORY_MAPPING[category] for category in prediction_categories]
153
- go_category_reverse_mapping = {v:k for k, v in GO_CATEGORY_MAPPING.items()}
154
-
155
- for edge_type, edges in protein_edges.items():
156
- # Skip if edges is empty
157
- if edges is None or len(edges) == 0:
158
- continue
159
-
160
- if edge_type[2].startswith('GO_term'): # Check if it's any GO term edge
161
- if edge_type[2] in prediction_categories:
162
- # Handle edges for GO terms that are in prediction_df
163
- category_mask = (prediction_df['GO_category'] == go_category_reverse_mapping[edge_type[2]]) & (prediction_df['UniProt_ID'] == protein_id)
164
- category_predictions = prediction_df[category_mask]
165
-
166
- if len(category_predictions) > 0:
167
- category_predictions = category_predictions.sort_values(by='Probability', ascending=False)
168
- edges_set = set(edges) # Convert to set for O(1) lookup
169
-
170
- valid_edges = []
171
- for _, row in category_predictions.iterrows():
172
- term = row['GO_ID']
173
- prob = row['Probability']
174
- edge = (protein_id, term)
175
- is_ground_truth = edge in edges_set
176
- valid_edges.append((edge, prob, is_ground_truth))
177
- if len(valid_edges) >= current_limit:
178
- break
179
- filtered_edges[edge_type] = valid_edges
180
- else:
181
- # If no predictions but it's a GO category in prediction_df
182
- filtered_edges[edge_type] = [(edge, 'no_pred', True) for edge in list(edges)[:current_limit]]
183
- else:
184
- # For GO terms not in prediction_df, mark them as ground truth with blue color
185
- filtered_edges[edge_type] = [(edge, 'no_pred', True) for edge in list(edges)[:current_limit]]
186
  else:
187
- # For non-GO edges, include all edges up to limit
188
- filtered_edges[edge_type] = [(edge, None, True) for edge in list(edges)[:current_limit]]
189
 
190
  return filtered_edges
191
 
@@ -233,14 +255,24 @@ def _gather_neighbor_edges(data, node_id, node_type, exclude_node_id):
233
 
234
  return neighbor_edges
235
 
236
- def visualize_protein_subgraph(data, protein_id, prediction_df, limit=10, second_degree_limit=3, include_second_degree=False):
237
- with gzip.open('data/name_info.json.gz', 'rt', encoding='utf-8') as file:
238
- name_info = json.load(file)
 
 
 
 
 
 
 
 
 
239
 
240
  # Get the first-degree edges and filter them
241
  protein_edges = _gather_protein_edges(data, protein_id)
242
  first_degree_edges = _filter_edges(protein_id, protein_edges, prediction_df,
243
- limit=limit, is_second_degree=False)
 
244
 
245
  # Initialize all_edges with first degree edges
246
  all_edges = first_degree_edges.copy()
@@ -260,7 +292,7 @@ def visualize_protein_subgraph(data, protein_id, prediction_df, limit=10, second
260
 
261
  # Gather and filter second-degree edges with the smaller limit
262
  second_degree_edges = {}
263
- for neighbor_id, neighbor_type in neighbor_nodes:
264
  neighbor_edges = _gather_neighbor_edges(data, neighbor_id, neighbor_type, protein_id)
265
  filtered_neighbor_edges = _filter_edges(neighbor_id, neighbor_edges, prediction_df,
266
  limit=limit,
@@ -285,397 +317,62 @@ def visualize_protein_subgraph(data, protein_id, prediction_df, limit=10, second
285
 
286
  print(f'Edges to be visualized: {visualized_edges}')
287
 
288
- net = Network(height="600px", width="100%", directed=True, notebook=False)
289
-
290
- # Create groups configuration from NODE_TYPE_COLORS
291
- groups_config = {}
292
- for node_type, color in NODE_TYPE_COLORS.items():
293
- groups_config[node_type] = {
294
- "color": {"background": color, "border": color}
295
- }
296
-
297
- # Convert groups_config to a JSON-compatible string
298
- groups_json = json.dumps(groups_config)
299
-
300
- # Configure physics options with settings for better clustering
301
- net.set_options("""{
302
- "physics": {
303
- "enabled": true,
304
- "barnesHut": {
305
- "gravitationalConstant": -1000,
306
- "springLength": 250,
307
- "springConstant": 0.001,
308
- "damping": 0.09,
309
- "avoidOverlap": 0
310
- },
311
- "forceAtlas2Based": {
312
- "gravitationalConstant": -50,
313
- "centralGravity": 0.01,
314
- "springLength": 100,
315
- "springConstant": 0.08,
316
- "damping": 0.4,
317
- "avoidOverlap": 0
318
- },
319
- "solver": "barnesHut",
320
- "stabilization": {
321
- "enabled": true,
322
- "iterations": 1000,
323
- "updateInterval": 25
324
- }
325
- },
326
- "layout": {
327
- "improvedLayout": true,
328
- "hierarchical": {
329
- "enabled": false
330
- }
331
- },
332
- "interaction": {
333
- "hover": true,
334
- "navigationButtons": true,
335
- "multiselect": true
336
- },
337
- "configure": {
338
- "enabled": false,
339
- "filter": ["physics", "layout", "manipulation"],
340
- "showButton": true
341
- },
342
- "groups": """ + groups_json + "}")
343
-
344
- # Add the main protein node
345
- query_node_url = get_node_url('Protein', protein_id)
346
- node_name = name_info['Protein'][protein_id]
347
- query_node_title = f"{node_name} (Query Protein)"
348
- if query_node_url:
349
- query_node_title = f'<a href="{query_node_url}" target="_blank">{query_node_title}</a>'
350
-
351
- net.add_node(protein_id,
352
- label=protein_id,
353
- title=query_node_title,
354
- color={'background': 'white', 'border': '#c1121f'},
355
- borderWidth=4,
356
- shape="dot",
357
- font={'color': '#000000', 'size': 15},
358
- group='Protein',
359
- size=30,
360
- mass=2.5)
361
-
362
- # Track added nodes to avoid duplication
363
- added_nodes = {protein_id}
364
-
365
- # Add edges and target nodes
366
- for edge_type, edges in visualized_edges.items():
367
- source_type, relation_type, target_type = edge_type
368
-
369
- if relation_type in ['protein_function', 'domain_function']:
370
- relation_type = EDGE_LABEL_TRANSLATION[(relation_type, target_type)]
371
- else:
372
- relation_type = EDGE_LABEL_TRANSLATION[relation_type]
373
-
374
- for edge_info in edges:
375
- edge, probability, is_ground_truth = edge_info
376
- source, target = edge[0], edge[1]
377
- source_str = str(source)
378
- target_str = str(target)
379
-
380
- # Add source node if not present
381
- if source_str not in added_nodes:
382
- if not source_type.startswith('GO_term'):
383
- node_name = name_info[source_type][source_str]
384
- else:
385
- node_name = name_info['GO_term'][source_str]
386
- url = get_node_url(source_type, source_str)
387
- node_color = NODE_TYPE_COLORS[source_type]
388
- node_type_label = NODE_LABEL_TRANSLATION[source_type] if source_type in NODE_LABEL_TRANSLATION else source_type
389
- if url:
390
- title = f"<div style='color: {node_color}'><a href='{url}' target='_blank'>{node_name} ({node_type_label})</a></div>"
391
- else:
392
- title = f"<div style='color: {node_color}'>{node_name} ({node_type_label})</div>"
393
- net.add_node(source_str,
394
- label=source_str,
395
- shape="dot",
396
- font={'color': '#000000', 'size': 12},
397
- title=title,
398
- group=source_type,
399
- size=15,
400
- mass=1.5)
401
- added_nodes.add(source_str)
402
-
403
- # Add target node if not present
404
- if target_str not in added_nodes:
405
- if not target_type.startswith('GO_term'):
406
- node_name = name_info[target_type][target_str]
407
- else:
408
- node_name = name_info['GO_term'][target_str]
409
- url = get_node_url(target_type, target_str)
410
- node_color = NODE_TYPE_COLORS[target_type]
411
- node_type_label = NODE_LABEL_TRANSLATION[target_type] if target_type in NODE_LABEL_TRANSLATION else target_type
412
- if url:
413
- title = f"<div style='color: {node_color}'><a href='{url}' target='_blank'>{node_name} ({node_type_label})</a></div>"
414
- else:
415
- title = f"<div style='color: {node_color}'>{node_name} ({node_type_label})</div>"
416
- net.add_node(target_str,
417
- label=target_str,
418
- shape="dot",
419
- font={'color': '#000000', 'size': 12},
420
- title=title,
421
- group=target_type,
422
- size=15,
423
- mass=1.5)
424
- added_nodes.add(target_str)
425
-
426
- # Add edge with relationship type and probability as label
427
- edge_label = f"{relation_type}"
428
- if probability is not None:
429
- if probability == 'no_pred':
430
- edge_color = '#219ebc'
431
- title_text = f"{relation_type} (P=Not generated)"
432
- else:
433
- title_text = f"{relation_type} (P={probability:.2f})"
434
- edge_color = '#8338ec' if is_ground_truth else '#c1121f'
435
- # Add color to edge title
436
- title_text = f"<div style='color: {edge_color}'>{title_text}</div>"
437
- net.add_edge(source_str, target_str,
438
- label='', # Empty label
439
- font={'size': 0},
440
- color=edge_color,
441
- title=title_text,
442
- length=200,
443
- smooth={'type': 'curvedCW', 'roundness': 0.1})
444
- else:
445
- edge_color = '#666666'
446
- title_text = f"<div style='color: {edge_color}'>{edge_label}</div>"
447
- net.add_edge(source_str, target_str,
448
- label='', # Empty label
449
- font={'size': 0},
450
- color=edge_color,
451
- title=title_text,
452
- length=200,
453
- smooth={'type': 'curvedCW', 'roundness': 0.1})
454
-
455
- # LEGEND
456
- legend_html = """
457
- <style>
458
- .kg-legend {
459
- margin-top: 20px;
460
- padding: 20px;
461
- border: 1px solid #ddd;
462
- border-radius: 5px;
463
- font-family: Arial, sans-serif;
464
- display: flex;
465
- gap: 20px;
466
- }
467
- .legend-section-nodes {
468
- flex: 2; /* Takes up 2/3 of the space */
469
- }
470
- .legend-section-edges {
471
- flex: 1; /* Takes up 1/3 of the space */
472
- }
473
- .legend-title {
474
- margin-bottom: 15px;
475
- color: #333;
476
- font-size: 16px;
477
- font-weight: bold;
478
- }
479
- .nodes-grid {
480
- display: grid;
481
- grid-template-columns: repeat(2, 1fr);
482
- gap: 12px;
483
- }
484
- .edges-grid {
485
- display: grid;
486
- grid-template-columns: 1fr;
487
- gap: 12px;
488
- }
489
- .legend-item {
490
- display: flex;
491
- align-items: center;
492
- padding: 4px;
493
- }
494
- .node-indicator {
495
- width: 15px;
496
- height: 15px;
497
- border-radius: 50%;
498
- margin-right: 10px;
499
- flex-shrink: 0;
500
- }
501
- .edge-indicator {
502
- width: 40px;
503
- height: 3px;
504
- margin-right: 10px;
505
- flex-shrink: 0;
506
- }
507
- .legend-label {
508
- font-size: 14px;
509
- }
510
- </style>
511
- <div class="kg-legend">
512
- <div class="legend-section-nodes">
513
- <div class="legend-title">Node Types</div>
514
- <div class="nodes-grid">"""
515
-
516
- # Node types in 2 columns
517
- for node_type, color in NODE_TYPE_COLORS.items():
518
- if node_type == 'kegg_Pathway':
519
- continue
520
- if node_type in NODE_LABEL_TRANSLATION:
521
- node_label = NODE_LABEL_TRANSLATION[node_type]
522
- else:
523
- node_label = node_type
524
- legend_html += f"""
525
- <div class="legend-item">
526
- <div class="node-indicator" style="background-color: {color};"></div>
527
- <span class="legend-label">{node_label}</span>
528
- </div>"""
529
-
530
- # Edge types in 1 column
531
- legend_html += """
532
- </div>
533
- </div>
534
- <div class="legend-section-edges">
535
- <div class="legend-title">Edge Colors</div>
536
- <div class="edges-grid">
537
- <div class="legend-item">
538
- <div class="edge-indicator" style="background-color: #8338ec;"></div>
539
- <span class="legend-label">Confirmed Prediction (Found in Ground Truth)</span>
540
- </div>
541
- <div class="legend-item">
542
- <div class="edge-indicator" style="background-color: #c1121f;"></div>
543
- <span class="legend-label">Novel Prediction (Not in Ground Truth)</span>
544
- </div>
545
- <div class="legend-item">
546
- <div class="edge-indicator" style="background-color: #219ebc;"></div>
547
- <span class="legend-label">Existing GO Term Annotation</span>
548
- </div>
549
- <div class="legend-item">
550
- <div class="edge-indicator" style="background-color: #666666;"></div>
551
- <span class="legend-label">Other Relationships</span>
552
- </div>
553
- </div>
554
- </div>
555
- """
556
 
557
  # Save graph to a protein-specific file in a temporary directory
558
  os.makedirs('temp_viz', exist_ok=True)
559
  suffix = "_with_2nd_degree" if include_second_degree else "_1st_degree"
560
- file_path = os.path.join('temp_viz', f'{protein_id}_graph{suffix}.html')
561
-
562
- net.save_graph(file_path)
563
-
564
- with open(file_path, 'r', encoding='utf-8') as f:
565
- content = f.read()
566
-
567
- # Add the custom popup JavaScript code before the return network statement
568
- custom_popup_code = """
569
- // make a custom popup
570
- var popup = document.createElement("div");
571
- popup.className = 'popup';
572
- popupTimeout = null;
573
- popup.addEventListener('mouseover', function () {
574
- if (popupTimeout !== null) {
575
- clearTimeout(popupTimeout);
576
- popupTimeout = null;
577
- }
578
- });
579
- popup.addEventListener('mouseout', function () {
580
- if (popupTimeout === null) {
581
- hidePopup();
582
- }
583
- });
584
- container.appendChild(popup);
585
-
586
- // use the popup event to show
587
- network.on("showPopup", function (params) {
588
- showPopup(params);
589
- });
590
-
591
- // use the hide event to hide it
592
- network.on("hidePopup", function (params) {
593
- hidePopup();
594
- });
595
-
596
- // hiding the popup through css
597
- function hidePopup() {
598
- popupTimeout = setTimeout(function () { popup.style.display = 'none'; }, 500);
599
- }
600
-
601
- // showing the popup
602
- function showPopup(nodeId) {
603
- // get the data from the vis.DataSet
604
- var nodeData = nodes.get(nodeId);
605
- // get the position of the node
606
- var posCanvas = network.getPositions([nodeId])[nodeId];
607
-
608
- if (!nodeData) {
609
- var edgeData = edges.get(nodeId);
610
- var poses = network.getPositions([edgeData.from, edgeData.to]);
611
- var middle_x = (poses[edgeData.to].x - poses[edgeData.from].x) * 0.5;
612
- var middle_y = (poses[edgeData.to].y - poses[edgeData.from].y) * 0.5;
613
- posCanvas = poses[edgeData.from];
614
- posCanvas.x = posCanvas.x + middle_x;
615
- posCanvas.y = posCanvas.y + middle_y;
616
-
617
- popup.innerHTML = edgeData.title;
618
- } else {
619
- popup.innerHTML = nodeData.title;
620
- // get the bounding box of the node
621
- var boundingBox = network.getBoundingBox(nodeId);
622
- posCanvas.x = posCanvas.x + 0.5 * (boundingBox.right - boundingBox.left);
623
- posCanvas.y = posCanvas.y + 0.5 * (boundingBox.top - boundingBox.bottom);
624
- };
625
-
626
- //position tooltip:
627
- // convert coordinates to the DOM space
628
- var posDOM = network.canvasToDOM(posCanvas);
629
-
630
- // Give it an offset
631
- posDOM.x += 10;
632
- posDOM.y -= 20;
633
-
634
- // show and place the tooltip.
635
- popup.style.display = 'block';
636
- popup.style.top = posDOM.y + 'px';
637
- popup.style.left = posDOM.x + 'px';
638
- }
639
- """
640
-
641
- # Add the custom popup CSS
642
- custom_popup_css = """
643
- /* position absolute is important and the container has to be relative or absolute as well. */
644
- div.popup {
645
- position: absolute;
646
- top: 0px;
647
- left: 0px;
648
- display: none;
649
- background-color: white;
650
- border-radius: 3px;
651
- border: 1px solid #ddd;
652
- box-shadow: 3px 3px 10px rgba(0, 0, 0, 0.2);
653
- padding: 5px;
654
- z-index: 1000;
655
- }
656
- div.popup a {
657
- color: inherit;
658
- text-decoration: underline;
659
- }
660
- """
661
-
662
- # Insert the custom CSS in the head
663
- content = content.replace('</style>', f'{custom_popup_css}</style>')
664
-
665
- # Insert the custom popup code before the "return network;" statement
666
- content = content.replace('return network;', f'{custom_popup_code}\nreturn network;')
667
-
668
- # Remove the original tooltip-hiding CSS if it exists
669
- content = content.replace("""
670
- /* hide the original tooltip */
671
- .vis-network-tooltip {
672
- display:none;
673
- }""", "")
674
-
675
- # Insert the legend before the closing body tag
676
- content = content.replace('</body>', f'{legend_html}</body>')
677
-
678
  with open(file_path, 'w', encoding='utf-8') as f:
679
- f.write(content)
680
-
681
- return file_path, visualized_edges
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import os
2
  import json
3
  import gzip
4
+ import uuid
5
+
6
+ # ECharts page the graph is rendered into (placeholders __ELEMENTS__, __KINDS__, __QUERY__, __PREDICTED__)
7
+ with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'kg_graph_template.html'), encoding='utf-8') as _f:
8
+ _GRAPH_TEMPLATE = _f.read()
9
 
10
  NODE_TYPE_COLORS = {
11
  'Disease': '#079dbb',
 
22
  'Protein': '#3aa6a4'
23
  }
24
 
25
+ EDGE_KIND_COLORS = {
26
+ 'confirmed': '#8338ec', # predicted and annotated in the KG
27
+ 'novel': '#c1121f', # predicted, not annotated in the KG
28
+ 'annotation': '#219ebc', # annotated in the KG, not among the shown predictions
29
+ 'other': '#666666',
30
+ }
31
+
32
+ EDGE_KIND_LABELS = {
33
+ 'confirmed': 'Confirmed prediction (in KG)',
34
+ 'novel': 'Novel prediction',
35
+ 'annotation': 'Known GO annotation',
36
+ 'other': 'Other relationship',
37
+ }
38
+
39
  EDGE_LABEL_TRANSLATION = {
40
  'Orthology': 'is ortholog to',
41
  'Pathway': 'takes part in',
 
149
 
150
  return protein_edges
151
 
152
+ def _filter_edges(protein_id, protein_edges, prediction_df, limit=10, is_second_degree=False,
153
+ second_degree_limit=3, prediction_limit=5):
154
  """
155
+ Select the edges to draw for one node.
156
+
157
+ Returns {edge_type: [((source, target), probability, kind), ...]}, where kind is
158
+ 'confirmed' - predicted GO term that is also annotated in the KG (purple); includes annotated terms
159
+ predicted below the top `prediction_limit` terms
160
+ 'novel' - one of the top `prediction_limit` predicted GO terms, not annotated in the KG (red)
161
+ 'annotation' - protein-GO annotation in the KG that was not predicted (blue); probability is None
162
+ 'other' - any other relationship, incl. domain-GO and GO-GO edges (gray)
163
+
164
  Args:
165
+ protein_id: ID of the node whose edges are filtered
166
+ protein_edges: {edge_type: set of (source, target)} from the KG
167
  prediction_df: DataFrame containing predictions
168
+ limit: Maximum number of KG edges per edge type for first-degree connections
169
+ is_second_degree: Whether these are second-degree edges (KG only, no predictions)
170
+ second_degree_limit: Maximum number of edges per edge type for second-degree connections
171
+ prediction_limit: Maximum number of predicted GO terms per predicted category
172
  """
173
  filtered_edges = {}
174
+
175
  # Use appropriate limit based on edge degree
176
  current_limit = second_degree_limit if is_second_degree else limit
177
+
178
+ # Predictions are drawn for the query protein only; second-degree edges show the KG as is.
179
+ predicted = {}
180
+ if not is_second_degree:
181
+ rows = prediction_df[prediction_df['UniProt_ID'] == protein_id]
182
+ for category, group in rows.groupby('GO_category'):
183
+ predicted[GO_CATEGORY_MAPPING[category]] = group.sort_values(
184
+ by=['Probability', 'GO_ID'], ascending=[False, True])
185
+
186
+ # Include predicted categories even when the protein has no KG annotation in them
187
+ edge_types = {edge_type for edge_type, edges in protein_edges.items() if edges}
188
+ edge_types |= {('Protein', 'protein_function', category) for category in predicted}
189
+
190
+ for edge_type in sorted(edge_types):
191
+ # Sorted so the same subset is shown on every run (set order varies between processes)
192
+ edges = sorted(protein_edges.get(edge_type) or [])
193
+
194
+ if edge_type[1] == 'protein_function' and edge_type[2] in predicted:
195
+ kg_edges = set(edges)
196
+ scores = dict(zip(predicted[edge_type[2]]['GO_ID'], predicted[edge_type[2]]['Probability']))
197
+ top_terms = list(scores)[:prediction_limit]
198
+ shown = [((protein_id, term), scores[term],
199
+ 'confirmed' if (protein_id, term) in kg_edges else 'novel') for term in top_terms]
200
+ # KG annotations outside the top predictions, under the regular neighbour limit: purple if the model
201
+ # also predicted them (any rank above the threshold, highest scores first), blue if it did not
202
+ rest = [edge for edge in edges if edge[1] not in top_terms]
203
+ rest.sort(key=lambda edge: -scores.get(edge[1], -1))
204
+ shown += [(edge, scores[edge[1]], 'confirmed') if edge[1] in scores else (edge, None, 'annotation')
205
+ for edge in rest[:current_limit]]
206
+ filtered_edges[edge_type] = shown
207
+ elif edge_type[1] == 'protein_function':
208
+ filtered_edges[edge_type] = [(edge, None, 'annotation') for edge in edges[:current_limit]]
 
 
 
 
209
  else:
210
+ filtered_edges[edge_type] = [(edge, None, 'other') for edge in edges[:current_limit]]
 
211
 
212
  return filtered_edges
213
 
 
255
 
256
  return neighbor_edges
257
 
258
+ def visualize_protein_subgraph(data, protein_id, prediction_df, limit=10, second_degree_limit=3,
259
+ include_second_degree=False, prediction_limit=5, predicted_categories=None):
260
+ """
261
+ Build the knowledge-graph view around one protein and save it as an HTML file.
262
+
263
+ predicted_categories: GO categories the run predicted (e.g. ['GO_term_F']); used to tell apart, in the
264
+ tooltips, annotations that were not predicted from those whose category was not run at all. Defaults to
265
+ the categories present in prediction_df.
266
+ Returns (file_path, visualized_edges).
267
+ """
268
+ from run_prothgt_app import load_name_info # cached loader; avoids re-reading 24 MB per call
269
+ name_info = load_name_info()
270
 
271
  # Get the first-degree edges and filter them
272
  protein_edges = _gather_protein_edges(data, protein_id)
273
  first_degree_edges = _filter_edges(protein_id, protein_edges, prediction_df,
274
+ limit=limit, is_second_degree=False,
275
+ prediction_limit=prediction_limit)
276
 
277
  # Initialize all_edges with first degree edges
278
  all_edges = first_degree_edges.copy()
 
292
 
293
  # Gather and filter second-degree edges with the smaller limit
294
  second_degree_edges = {}
295
+ for neighbor_id, neighbor_type in sorted(neighbor_nodes):
296
  neighbor_edges = _gather_neighbor_edges(data, neighbor_id, neighbor_type, protein_id)
297
  filtered_neighbor_edges = _filter_edges(neighbor_id, neighbor_edges, prediction_df,
298
  limit=limit,
 
317
 
318
  print(f'Edges to be visualized: {visualized_edges}')
319
 
320
+ # Render with the ECharts template (radial layout, see kg_graph_template.html)
321
+ elements = _build_graph_elements(protein_id, visualized_edges, name_info)
322
+ html = _GRAPH_TEMPLATE \
323
+ .replace('__ELEMENTS__', _json_for_script(elements)) \
324
+ .replace('__KINDS__', _json_for_script(EDGE_KIND_LABELS)) \
325
+ .replace('__QUERY__', _json_for_script(protein_id)) \
326
+ .replace('__PREDICTED__', _json_for_script(
327
+ sorted(NODE_LABEL_TRANSLATION[c] for c in predicted_categories) if predicted_categories is not None
328
+ else sorted(prediction_df['GO_category'].unique().tolist())))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
329
 
330
  # Save graph to a protein-specific file in a temporary directory
331
  os.makedirs('temp_viz', exist_ok=True)
332
  suffix = "_with_2nd_degree" if include_second_degree else "_1st_degree"
333
+ # Unique per call: the app caches one file per setting combination and the Space is shared
334
+ file_path = os.path.join('temp_viz', f'{protein_id}_graph{suffix}_{uuid.uuid4().hex[:8]}.html')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
335
  with open(file_path, 'w', encoding='utf-8') as f:
336
+ f.write(html)
337
+
338
+ return file_path, visualized_edges
339
+
340
+
341
+ def _build_graph_elements(protein_id, visualized_edges, name_info):
342
+ """Nodes and edges for the graph template, as [{'data': {...}}, ...] (nodes first)."""
343
+ nodes, edges = {}, []
344
+
345
+ def add_node(node_id, node_type):
346
+ if node_id in nodes:
347
+ return
348
+ names = name_info['GO_term' if node_type.startswith('GO_term') else node_type]
349
+ nodes[node_id] = {'data': {
350
+ 'id': node_id,
351
+ 'name': names.get(node_id, node_id),
352
+ 'type': NODE_LABEL_TRANSLATION.get(node_type, node_type),
353
+ 'color': NODE_TYPE_COLORS[node_type],
354
+ 'url': get_node_url(node_type, node_id) or '',
355
+ 'role': 'query' if node_id == protein_id else '',
356
+ }}
357
+
358
+ add_node(protein_id, 'Protein')
359
+ for (source_type, relation, target_type), items in visualized_edges.items():
360
+ key = (relation, target_type) if relation in ('protein_function', 'domain_function') else relation
361
+ for (source, target), probability, kind in items:
362
+ source, target = str(source), str(target)
363
+ add_node(source, source_type)
364
+ add_node(target, target_type)
365
+ edges.append({'data': {
366
+ 'source': source, 'target': target,
367
+ 'type': f'{source_type}_{relation}_{target_type}', # key used in the edges JSON download
368
+ 'relation': EDGE_LABEL_TRANSLATION[key],
369
+ 'kind': kind,
370
+ 'color': EDGE_KIND_COLORS[kind],
371
+ 'prob': None if probability is None else float(probability),
372
+ }})
373
+ return list(nodes.values()) + edges
374
+
375
+
376
+ def _json_for_script(value):
377
+ """JSON that is safe to inline in a <script> block."""
378
+ return json.dumps(value).replace('</', '<\\/')