Spaces:
Paused
Paused
File size: 17,566 Bytes
614205a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 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 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 | <!doctype html>
<!-- Knowledge graph view (ECharts, radial layout). Filled in by visualize_kg.render_graph_html. -->
<html><head><meta charset="utf-8">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&display=swap">
<script src="https://cdnjs.cloudflare.com/ajax/libs/echarts/5.5.0/echarts.min.js"></script>
<style>
:root { --border: #E4E2DC; --muted: #4A4E5C; --text: #1A1C24; }
body { margin: 0; font-family: 'Manrope', sans-serif; color: var(--text); background: #fff; }
.frame { position: relative; border: 1px solid var(--border); border-radius: 16px; overflow: hidden; }
#chart { height: 760px; }
/* Edge legend: centred under the node legend (an ECharts legend at the top of the canvas).
The strip itself lets mouse events through so the canvas can still be panned underneath it. */
.edges { position: absolute; top: 40px; left: 0; right: 0; display: flex; flex-wrap: wrap; justify-content: center;
gap: 6px 16px; padding: 0 16px; font-size: 12px; color: var(--muted); align-items: center; pointer-events: none; }
.chip { display: inline-flex; align-items: center; gap: 6px; }
button.chip { font: inherit; color: inherit; background: none; border: 0; padding: 2px 0; cursor: pointer;
pointer-events: auto; }
button.chip:hover { color: var(--text); }
button.chip.off { opacity: 0.4; text-decoration: line-through; }
.line { width: 18px; height: 3px; border-radius: 2px; }
.hint { position: absolute; bottom: 10px; left: 0; right: 0; text-align: center; color: #A3A6AF; font-size: 11px;
pointer-events: none; }
</style></head>
<body>
<div class="frame">
<div id="chart"></div>
<div class="edges" id="edges"></div>
<div class="hint">Hover for details · drag to move · scroll to zoom · click a legend item to hide it</div>
</div>
<script>
const elements = __ELEMENTS__, KINDS = __KINDS__, QUERY = __QUERY__;
const PREDICTED = __PREDICTED__; // GO categories predicted in this run, e.g. ["Molecular Function"]
const nodesIn = elements.filter(e => !e.data.source).map(e => e.data);
const edgesIn = elements.filter(e => e.data.source).map(e => e.data);
const typeById = Object.fromEntries(nodesIn.map(n => [n.id, n.type]));
const esc = s => String(s).replace(/[&<>"]/g, c => ({'&': '&', '<': '<', '>': '>', '"': '"'}[c]));
// Categories double as the interactive legend: clicking one hides that node type.
// The query protein has its own category so hiding "Associated proteins" never removes it.
const QUERY_BORDER = '#1F5F5D'; // dark teal ring around the query protein
const catOf = n => n.role === 'query' ? 'Query protein' : n.type === 'Protein' ? 'Associated proteins' : n.type;
const cats = [['Query protein', nodesIn.find(n => n.role === 'query').color],
...new Map(nodesIn.filter(n => n.role !== 'query').map(n => [catOf(n), n.color])).entries()];
const catIndex = Object.fromEntries(cats.map(([t], i) => [t, i]));
const degree = {};
edgesIn.forEach(e => { degree[e.source] = (degree[e.source] || 0) + 1; degree[e.target] = (degree[e.target] || 0) + 1; });
// Word-wrap text to lines of at most `max` characters (ECharts does not wrap inside rich text);
// keeps at most 3 lines and ends with an ellipsis if longer.
function wrap(text, max) {
const words = String(text).replace(/[{}|]/g, '').split(/\s+/), lines = [];
let cur = '';
words.forEach(w => {
if (cur && (cur + ' ' + w).length > max) { lines.push(cur); cur = w; } else { cur = cur ? cur + ' ' + w : w; }
});
if (cur) lines.push(cur);
if (lines.length > 3) { lines.length = 3; lines[2] = lines[2].replace(/.{0,1}$/, '…'); }
return lines;
}
const nodes = nodesIn.map(n => ({
id: n.id, name: n.id, value: n.name, category: catIndex[catOf(n)], raw: n,
symbolSize: n.role === 'query' ? 40 : 12 + Math.min(degree[n.id] || 0, 6) * 1.5,
itemStyle: n.role === 'query' ? { borderColor: QUERY_BORDER, borderWidth: 4 }
: { borderColor: '#fff', borderWidth: 1.5 },
// Name on top (wrapped), ID below in brackets, on a white pill
label: { show: true, backgroundColor: 'rgba(255,255,255,0.8)', padding: [2, 4], borderRadius: 6,
formatter: wrap(n.name, n.role === 'query' ? 26 : 20).map(l => `{n|${l}}`).join('\n') + `\n{i|(${n.id})}`,
rich: { n: { fontSize: n.role === 'query' ? 12 : 10, fontWeight: n.role === 'query' ? 700 : 600,
color: '#1A1C24', align: 'center', lineHeight: n.role === 'query' ? 15 : 12 },
i: { fontSize: n.role === 'query' ? 11 : 9, color: '#8A8D98', align: 'center', lineHeight: 12 } } },
}));
const strong = k => k === 'confirmed' || k === 'novel';
const links = edgesIn.map(e => ({
source: e.source, target: e.target, raw: e,
lineStyle: { color: e.color, width: strong(e.kind) ? 2.4 : e.kind === 'annotation' ? 1.8 : 1.1,
opacity: strong(e.kind) ? 0.9 : 0.55, curveness: 0.08 },
// Shown only on hover of the edge or of a node it touches (see syncEdgeLabels).
// Sits on the line itself; the white pill (80% opaque) mostly masks the line behind the text.
label: { show: true, position: 'middle', distance: 0, verticalAlign: 'middle',
formatter: wrap(e.relation, 12).join('\n'), fontSize: 9, lineHeight: 11, align: 'center',
color: strong(e.kind) || e.kind === 'annotation' ? e.color : '#6B6E78',
backgroundColor: 'rgba(255,255,255,0.8)', padding: [2, 4], borderRadius: 6,
opacity: 1 }, // otherwise ECharts gives the label the line's opacity and the line shows through
}));
// Radial layout: query at the centre, first-degree neighbours on an inner ring grouped into sectors
// (confirmed, novel, annotations, then other types), second-degree neighbours on an outer ring next to the
// first-degree node(s) they hang off. Labels point outwards.
(function placeRadial() {
const R1 = 230, R2 = 470;
const adj = {};
links.forEach(l => { (adj[l.source] = adj[l.source] || new Set()).add(l.target);
(adj[l.target] = adj[l.target] || new Set()).add(l.source); });
const kindFromQuery = {};
links.forEach(l => { if (l.source === QUERY) kindFromQuery[l.target] = l.raw.kind;
else if (l.target === QUERY) kindFromQuery[l.source] = l.raw.kind; });
const rank = n => ({ confirmed: 0, novel: 1, annotation: 2 })[kindFromQuery[n.id]] ?? 3;
const first = nodes.filter(n => n.id in kindFromQuery)
.sort((a, b) => rank(a) - rank(b) || a.raw.type.localeCompare(b.raw.type) || a.id.localeCompare(b.id));
const firstIds = new Set(first.map(n => n.id));
// Inner ring: one slot per node, a fraction of a slot as gap between sectors
let slot = 0, prevGroup = null;
first.forEach(n => { const g = rank(n) + '|' + n.raw.type; if (prevGroup !== null && g !== prevGroup) slot += 0.6;
n._slot = slot++; prevGroup = g; });
const angle = {};
first.forEach(n => { angle[n.id] = -Math.PI / 2 + 2 * Math.PI * n._slot / Math.max(slot, 1); });
// Outer ring: circular mean of the parents' angles ...
const second = nodes.filter(n => n.id !== QUERY && !firstIds.has(n.id)).map(n => {
let sx = 0, sy = 0;
(adj[n.id] ? [...adj[n.id]] : []).filter(p => firstIds.has(p)).forEach(p => { sx += Math.cos(angle[p]); sy += Math.sin(angle[p]); });
angle[n.id] = Math.atan2(sy, sx);
return n;
}).sort((a, b) => angle[a.id] - angle[b.id]);
// ... then relax neighbouring pairs (wrapping around the circle) until each pair is at least minSep apart
const minSep = Math.min(2 * Math.PI / Math.max(second.length, 1), 0.28);
for (let iter = 0; iter < 200; iter++) {
let moved = false;
for (let i = 0; i < second.length; i++) {
const a = second[i], b = second[(i + 1) % second.length];
let gap = angle[b.id] - angle[a.id];
if (i === second.length - 1) gap += 2 * Math.PI;
if (second.length > 1 && gap < minSep) {
const push = (minSep - gap) / 2;
angle[a.id] -= push; angle[b.id] += push; moved = true;
}
}
if (!moved) break;
}
nodes.forEach(n => {
if (n.id === QUERY) { n.x = 0; n.y = 0; n.label.position = 'bottom'; return; }
const inner = firstIds.has(n.id), r = inner ? R1 : R2, a = angle[n.id], c = Math.cos(a), s = Math.sin(a);
n.x = r * c; n.y = r * s;
// Outer ring labels always go sideways (crowded top/bottom labels would collide otherwise)
n.label.position = !inner ? (c >= 0 ? 'right' : 'left')
: c > 0.35 ? 'right' : c < -0.35 ? 'left' : s > 0 ? 'bottom' : 'top';
n.label.align = n.label.position === 'right' ? 'left' : n.label.position === 'left' ? 'right' : 'center';
});
})();
const chart = echarts.init(document.getElementById('chart'), null, { renderer: 'canvas' });
chart.setOption({
textStyle: { fontFamily: 'Manrope, sans-serif' },
tooltip: {
enterable: true, // so the "Open entry" link in the card can be clicked
backgroundColor: '#fff', borderColor: '#E4E2DC', borderWidth: 1, padding: [10, 12], extraCssText:
'border-radius:12px;box-shadow:0 6px 24px rgba(26,28,36,.10);max-width:280px;white-space:normal;',
textStyle: { color: '#1A1C24', fontSize: 13 },
formatter: p => {
const d = p.data.raw;
if (p.dataType === 'node') {
const role = d.role === 'query' ? ' · query protein' : '';
return `<b>${esc(d.name)}</b><div style="color:#4A4E5C;font-size:12px">${esc(d.id)} · ${esc(d.type)}${role}</div>` +
(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>` : '');
}
// Blue edges of other proteins carry no note: predictions are only made for the query protein.
// For the query, say whether the term's GO category was part of this run at all.
const category = typeById[d.target];
const score = d.prob !== null ? `score ${d.prob.toFixed(2)}`
: d.kind !== 'annotation' || d.source !== QUERY ? ''
: PREDICTED.includes(category) ? 'not predicted in this run'
: `no ${category} predictions were made in this run`;
return `<b style="color:${d.color}">${esc(KINDS[d.kind])}</b><div style="color:#4A4E5C;font-size:12px">` +
`${esc(d.source)} ${esc(d.relation)} ${esc(d.target)}${score ? ' · ' + score : ''}</div>`;
},
},
legend: { data: cats.map(([t]) => t), top: 14, left: 'center', icon: 'circle', itemWidth: 10, itemHeight: 10,
textStyle: { color: '#4A4E5C', fontSize: 12 } },
toolbox: { right: 14, top: 10, itemSize: 14, itemGap: 12, iconStyle: { borderColor: '#4A4E5C' },
feature: { restore: { title: 'Reset' },
saveAsImage: { title: 'PNG', pixelRatio: 3, name: QUERY + '_knowledge_graph' },
// Custom features must be named my*; icon is a document outline with "{ }"
myEdges: { title: 'Edges (JSON)', onclick: () => downloadShownEdges(),
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' } } },
animationDuration: 600,
series: [{
type: 'graph', layout: 'none', roam: true, draggable: true,
// Only top/bottom + centred, so ECharts keeps the aspect ratio (all four sides would stretch the circle)
top: 120, bottom: 60, left: 'center',
data: nodes, links, categories: cats.map(([t, c]) => ({ name: t, itemStyle:
t === 'Query protein' ? { color: c, borderColor: QUERY_BORDER, borderWidth: 2 } : { color: c } })),
edgeSymbol: ['none', 'arrow'], edgeSymbolSize: 6,
label: { color: '#4A4E5C', distance: 3 },
emphasis: { focus: 'adjacency', lineStyle: { width: 3, opacity: 1 } },
blur: { itemStyle: { opacity: 0.12 }, lineStyle: { opacity: 0.05 }, label: { opacity: 0.2 } },
}],
});
// Edge labels are shown only for the hovered edge, or for all edges of the hovered node.
// ECharts has no option for this (its adjacency focus does not put neighbouring edges into the emphasis
// state), so the label elements are toggled directly. Hover states restore the labels' normal visibility,
// so the sync also runs after every render; it only redraws when something actually changes.
let hoveredNode = null, hoveredEdge = null;
function syncEdgeLabels() {
const series = chart.getModel().getSeriesByIndex(0), edgeData = series.getEdgeData();
const node = hoveredNode === null ? null : series.getGraph().getNodeByIndex(hoveredNode);
const show = new Set(node ? node.edges.map(e => e.dataIndex) : []);
if (hoveredEdge !== null) show.add(hoveredEdge);
edgeData.each(i => {
const el = edgeData.getItemGraphicEl(i), text = el && el.getTextContent();
if (text && text.ignore === show.has(i)) { text.ignore = !show.has(i); text.markRedraw(); }
});
}
chart.on('rendered', syncEdgeLabels);
chart.on('mouseover', p => {
if (p.dataType === 'node') hoveredNode = p.dataIndex; else if (p.dataType === 'edge') hoveredEdge = p.dataIndex;
setTimeout(syncEdgeLabels, 30);
});
chart.on('mouseout', p => {
if (p.dataType === 'node') hoveredNode = null; else if (p.dataType === 'edge') hoveredEdge = null;
setTimeout(syncEdgeLabels, 30);
});
// Keep edge labels upright. ECharts rotates 'middle' edge labels along the edge and has no option to turn
// that off, so wrap the edge element's beforeUpdate (once, on its prototype) and reset the rotation right
// after ECharts sets it. Relies on ECharts 5 internals (_lineDraw, Line#beforeUpdate): recheck when
// upgrading the pinned ECharts version.
let uprightPatched = false;
chart.on('rendered', () => {
if (uprightPatched) return;
const view = chart.getViewOfSeriesModel(chart.getModel().getSeriesByIndex(0));
const first = view && view._lineDraw && view._lineDraw.group.childAt(0);
if (!first) return;
const proto = Object.getPrototypeOf(first), original = proto.beforeUpdate;
proto.beforeUpdate = function () {
original.apply(this, arguments);
const text = this.getTextContent();
if (text) text.rotation = 0;
};
uprightPatched = true;
});
// Pan and zoom from anywhere on the canvas. ECharts only accepts them inside the bounding box of the
// nodes, and re-sets that check on every render, so the controller is made to always use this one.
// The top row (node legend and toolbox, drawn on the canvas) stays excluded so it remains clickable.
// Relies on ECharts 5 internals (GraphView#_controller): recheck when upgrading the pinned version.
const panAnywhere = (e, x, y) => y > 40;
let panPatched = false;
chart.on('rendered', () => {
if (panPatched) return;
const controller = chart.getViewOfSeriesModel(chart.getModel().getSeriesByIndex(0))._controller;
if (!controller) return;
const original = controller.setPointerChecker;
controller.setPointerChecker = function () { original.call(this, panAnywhere); };
controller.setPointerChecker();
panPatched = true;
});
// Edge-kind filter: hide the edges of a kind and any node left without visible edges.
// Current positions (including dragged nodes) are carried over.
const hiddenKinds = new Set();
function applyEdgeFilter() {
const pos = {};
chart.getModel().getSeriesByIndex(0).getGraph().eachNode(n => { pos[n.id] = n.getLayout(); });
const visibleLinks = links.filter(l => !hiddenKinds.has(l.raw.kind));
const keep = new Set([QUERY]);
visibleLinks.forEach(l => { keep.add(l.source); keep.add(l.target); });
const visibleNodes = nodes.filter(n => keep.has(n.id)).map(n => {
const p = pos[n.id];
return p ? { ...n, x: p[0], y: p[1] } : n;
});
chart.setOption({ series: [{ data: visibleNodes, links: visibleLinks }] });
}
// Download the edges currently shown (respecting hidden edge kinds and hidden node types), grouped by
// edge type: {"Protein_protein_function_GO_term_F": [{source, target, probability, kind}, ...], ...}
function downloadShownEdges() {
const selected = chart.getOption().legend[0].selected || {};
const nodeShown = Object.fromEntries(nodes.map(n => [n.id, selected[cats[n.category][0]] !== false]));
const out = {};
links.filter(l => !hiddenKinds.has(l.raw.kind) && nodeShown[l.source] && nodeShown[l.target]).forEach(l => {
(out[l.raw.type] = out[l.raw.type] || []).push(
{ source: l.source, target: l.target, probability: l.raw.prob, kind: l.raw.kind });
});
const url = URL.createObjectURL(new Blob([JSON.stringify(out, null, 2)], { type: 'application/json' }));
const a = Object.assign(document.createElement('a'), { href: url, download: QUERY + '_edges.json' });
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
window.addEventListener('resize', () => chart.resize());
const present = new Set(edgesIn.map(e => e.kind)), kindColor = {};
edgesIn.forEach(e => { kindColor[e.kind] = e.color; });
const edgeLegend = document.getElementById('edges');
edgeLegend.innerHTML = Object.keys(KINDS).filter(k => present.has(k))
.map(k => `<button class="chip" data-kind="${k}"><span class="line" style="background:${kindColor[k]}"></span>${esc(KINDS[k])}</button>`).join('');
edgeLegend.querySelectorAll('button.chip').forEach(b => b.addEventListener('click', () => {
const k = b.dataset.kind;
hiddenKinds.has(k) ? hiddenKinds.delete(k) : hiddenKinds.add(k);
b.classList.toggle('off', hiddenKinds.has(k));
applyEdgeFilter();
}));
</script>
</body></html>
|