ProtHGT / kg_graph_template.html
Erva Ulusoy
replace pyvis with eccharts kg view
614205a
Raw History Blame Contribute Delete
17.6 kB
<!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 => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;'}[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>