Spaces:
Paused
Paused
Erva Ulusoy commited on
Commit ·
614205a
1
Parent(s): a9a53d0
replace pyvis with eccharts kg view
Browse files- kg_graph_template.html +298 -0
- requirements.txt +1 -2
- 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 => ({'&': '&', '<': '<', '>': '>', '"': '"'}[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,
|
|
|
|
| 135 |
"""
|
| 136 |
-
|
| 137 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
Args:
|
| 139 |
-
protein_id: ID of the
|
| 140 |
-
protein_edges:
|
| 141 |
prediction_df: DataFrame containing predictions
|
| 142 |
-
limit: Maximum number of edges
|
| 143 |
-
is_second_degree: Whether these are second-degree edges
|
| 144 |
-
second_degree_limit: Maximum number of edges
|
|
|
|
| 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 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 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 |
-
|
| 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,
|
| 237 |
-
|
| 238 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 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 |
-
|
| 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(
|
| 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('</', '<\\/')
|