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 => ({'&': '&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>