Spaces:
Paused
Paused
Download kg_graph_template.html from HUBioDataLab/ProtHGT: direct link, hf CLI and curl.
- Browser
- Download file 17.6 kB
-
https://huggingface.co/spaces/HUBioDataLab/ProtHGT/resolve/main/kg_graph_template.html
- Command line
-
hf download hf://spaces/HUBioDataLab/ProtHGT/kg_graph_template.html
-
curl -L -o kg_graph_template.html https://huggingface.co/spaces/HUBioDataLab/ProtHGT/resolve/main/kg_graph_template.html
17.6 kB
| <!-- 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> | |