@neat.is/web 0.2.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,604 @@
1
+ /* NEAT — graph view runtime
2
+ Renders the sample Bloomberg topology with Cytoscape.js.
3
+ View-only: pan + zoom enabled; node/edge editing disabled.
4
+ */
5
+ ;(function () {
6
+ const data = window.NEAT_GRAPH
7
+ if (!data) {
8
+ console.error('NEAT_GRAPH not loaded')
9
+ return
10
+ }
11
+
12
+ // ---------- node-shape / color helpers --------------------------------
13
+ // Map node types to color CSS variables resolved at runtime.
14
+ const cssVar = (name) => getComputedStyle(document.documentElement).getPropertyValue(name).trim()
15
+
16
+ const TYPE_STYLE = {
17
+ service: { color: cssVar('--n-service'), shape: 'round-rectangle', size: 32 },
18
+ db: { color: cssVar('--n-db'), shape: 'barrel', size: 34 },
19
+ cache: { color: cssVar('--n-cache'), shape: 'barrel', size: 28 },
20
+ stream: { color: cssVar('--n-stream'), shape: 'cut-rectangle', size: 32 },
21
+ queue: { color: cssVar('--n-queue'), shape: 'cut-rectangle', size: 28 },
22
+ lambda: { color: cssVar('--n-lambda'), shape: 'diamond', size: 30 },
23
+ cron: { color: cssVar('--n-cron'), shape: 'tag', size: 26 },
24
+ api: { color: cssVar('--n-api'), shape: 'round-rectangle', size: 22 },
25
+ apigw: { color: cssVar('--n-apigw'), shape: 'round-rectangle', size: 36 },
26
+ waf: { color: '#a89898', shape: 'hexagon', size: 28 },
27
+ cdn: { color: '#a8a8b8', shape: 'hexagon', size: 28 },
28
+ lb: { color: '#a8b8b0', shape: 'hexagon', size: 28 },
29
+ compute: { color: cssVar('--n-compute'), shape: 'round-rectangle', size: 32 },
30
+ storage: { color: cssVar('--n-storage'), shape: 'round-tag', size: 28 },
31
+ external: { color: cssVar('--n-external'), shape: 'round-octagon', size: 30 },
32
+ search: { color: cssVar('--n-search'), shape: 'barrel', size: 28 },
33
+ cluster: { color: cssVar('--n-cluster'), shape: 'round-rectangle' },
34
+ namespace: { color: cssVar('--n-namespace'), shape: 'round-rectangle' },
35
+ vpc: { color: cssVar('--n-vpc'), shape: 'round-rectangle' },
36
+ env: { color: cssVar('--n-env'), shape: 'round-rectangle' },
37
+ cloud: { color: '#1d1d22', shape: 'round-rectangle' },
38
+ }
39
+
40
+ const provColor = {
41
+ STATIC: cssVar('--prov-static'),
42
+ OBSERVED: cssVar('--prov-observed'),
43
+ INFERRED: cssVar('--prov-inferred'),
44
+ }
45
+
46
+ // ---------- build elements --------------------------------------------
47
+ const elements = []
48
+ data.nodes.forEach((n) => {
49
+ const t = n.data.type
50
+ const ts = TYPE_STYLE[t] || { color: '#888', shape: 'ellipse', size: 24 }
51
+ elements.push({
52
+ data: {
53
+ ...n.data,
54
+ _color: ts.color,
55
+ _shape: ts.shape,
56
+ _size: ts.size || 28,
57
+ _isCompound: ['cloud', 'env', 'vpc', 'cluster', 'namespace'].includes(t),
58
+ },
59
+ classes: `t-${t} ${['cloud', 'env', 'vpc', 'cluster', 'namespace'].includes(t) ? 'compound' : 'leaf'}`,
60
+ })
61
+ })
62
+ data.edges.forEach((e) => {
63
+ elements.push({
64
+ data: {
65
+ ...e.data,
66
+ _color: provColor[e.data.provenance] || '#888',
67
+ _width: e.data.provenance === 'INFERRED' ? 1 : e.data.provenance === 'OBSERVED' ? 1.4 : 1.2,
68
+ _style:
69
+ e.data.provenance === 'INFERRED'
70
+ ? 'dotted'
71
+ : e.data.provenance === 'OBSERVED'
72
+ ? 'dashed'
73
+ : 'solid',
74
+ _opacity:
75
+ e.data.provenance === 'INFERRED' ? 0.55 : e.data.provenance === 'OBSERVED' ? 0.85 : 0.75,
76
+ },
77
+ })
78
+ })
79
+
80
+ // ---------- Cytoscape -------------------------------------------------
81
+ const cy = cytoscape({
82
+ container: document.getElementById('cy'),
83
+ elements,
84
+
85
+ minZoom: 0.001,
86
+ maxZoom: 50,
87
+ wheelSensitivity: 0.25,
88
+
89
+ // VIEW-ONLY: pan + zoom allowed; nodes locked
90
+ autoungrabify: true, // can't drag nodes
91
+ autounselectify: false, // selection IS allowed (read-only inspector)
92
+ boxSelectionEnabled: false,
93
+
94
+ style: [
95
+ // ---------- compound containers ----------
96
+ {
97
+ selector: 'node.compound',
98
+ style: {
99
+ 'background-color': 'data(_color)',
100
+ 'background-opacity': 0.35,
101
+ 'border-width': 1,
102
+ 'border-color': '#2a2a30',
103
+ 'border-style': 'solid',
104
+ shape: 'round-rectangle',
105
+ 'corner-radius': '4',
106
+ label: 'data(label)',
107
+ 'text-valign': 'top',
108
+ 'text-halign': 'left',
109
+ 'text-margin-x': 8,
110
+ 'text-margin-y': 4,
111
+ 'font-family': 'JetBrains Mono, monospace',
112
+ 'font-size': 10.5,
113
+ color: '#9b968c',
114
+ padding: '24px',
115
+ 'text-transform': 'lowercase',
116
+ },
117
+ },
118
+ {
119
+ selector: 'node.t-cloud',
120
+ style: { 'background-opacity': 0.18, padding: '32px', 'font-size': 11.5, color: '#d8d3c9' },
121
+ },
122
+ {
123
+ selector: 'node.t-env',
124
+ style: { 'background-opacity': 0.3, padding: '28px', 'font-size': 11, color: '#d8d3c9' },
125
+ },
126
+ {
127
+ selector: 'node.t-vpc',
128
+ style: { 'background-opacity': 0.4, padding: '22px', 'font-size': 10.5 },
129
+ },
130
+ { selector: 'node.t-cluster', style: { 'background-opacity': 0.55, padding: '20px' } },
131
+ { selector: 'node.t-namespace', style: { 'background-opacity': 0.65, padding: '16px' } },
132
+
133
+ // ---------- leaf nodes ----------
134
+ {
135
+ selector: 'node.leaf',
136
+ style: {
137
+ 'background-color': 'data(_color)',
138
+ 'background-opacity': 0.92,
139
+ shape: 'data(_shape)',
140
+ width: 'data(_size)',
141
+ height: 'data(_size)',
142
+ label: 'data(label)',
143
+ 'text-valign': 'bottom',
144
+ 'text-halign': 'center',
145
+ 'text-margin-y': 5,
146
+ 'font-family': 'JetBrains Mono, monospace',
147
+ 'font-size': 9.5,
148
+ color: '#d8d3c9',
149
+ 'text-outline-width': 2,
150
+ 'text-outline-color': '#0a0a0b',
151
+ 'border-width': 1,
152
+ 'border-color': '#0a0a0b',
153
+ 'min-zoomed-font-size': 7,
154
+ },
155
+ },
156
+ // type-specific tweaks
157
+ {
158
+ selector: 'node.t-api',
159
+ style: { width: 8, height: 8, 'font-size': 8.5, color: '#9b968c' },
160
+ },
161
+ { selector: 'node.t-cron', style: { width: 18, height: 14 } },
162
+ {
163
+ selector: 'node.t-external',
164
+ style: {
165
+ 'background-opacity': 0.7,
166
+ 'border-color': '#46443f',
167
+ 'border-width': 1,
168
+ 'border-style': 'dashed',
169
+ color: '#9b968c',
170
+ },
171
+ },
172
+ {
173
+ selector: 'node.t-lambda',
174
+ style: { 'background-color': cssVar('--n-lambda'), width: 22, height: 22 },
175
+ },
176
+ { selector: 'node.t-storage', style: { 'background-color': cssVar('--n-storage') } },
177
+ { selector: 'node.t-stream', style: { 'background-color': cssVar('--n-stream') } },
178
+ {
179
+ selector: 'node.t-queue',
180
+ style: { 'background-color': cssVar('--n-queue'), width: 20, height: 20 },
181
+ },
182
+
183
+ // selected
184
+ {
185
+ selector: 'node:selected',
186
+ style: {
187
+ 'border-color': cssVar('--accent'),
188
+ 'border-width': 2,
189
+ 'background-opacity': 1,
190
+ color: '#f4efe6',
191
+ 'font-weight': 600,
192
+ 'z-index': 999,
193
+ },
194
+ },
195
+ // dim non-neighbors when something is selected
196
+ {
197
+ selector: '.dim',
198
+ style: {
199
+ opacity: 0.18,
200
+ },
201
+ },
202
+ {
203
+ selector: 'edge.dim',
204
+ style: { opacity: 0.08 },
205
+ },
206
+ {
207
+ selector: 'node.hl, edge.hl',
208
+ style: { opacity: 1 },
209
+ },
210
+ {
211
+ selector: 'edge.hl',
212
+ style: {
213
+ width: 'mapData(_width, 0, 2, 1.6, 2.4)',
214
+ opacity: 1,
215
+ },
216
+ },
217
+
218
+ // ---------- edges ----------
219
+ {
220
+ selector: 'edge',
221
+ style: {
222
+ 'curve-style': 'bezier',
223
+ 'control-point-step-size': 30,
224
+ 'line-color': 'data(_color)',
225
+ 'line-style': 'data(_style)',
226
+ width: 'data(_width)',
227
+ opacity: 'data(_opacity)',
228
+ 'target-arrow-shape': 'triangle-backcurve',
229
+ 'target-arrow-color': 'data(_color)',
230
+ 'arrow-scale': 0.85,
231
+ 'font-family': 'JetBrains Mono, monospace',
232
+ 'font-size': 8,
233
+ color: '#6a675f',
234
+ 'text-rotation': 'autorotate',
235
+ 'text-background-color': '#0a0a0b',
236
+ 'text-background-opacity': 1,
237
+ 'text-background-padding': 2,
238
+ },
239
+ },
240
+ {
241
+ // show edge verb only on closer zoom
242
+ selector: 'edge[type]',
243
+ style: { label: 'data(type)', 'min-zoomed-font-size': 11 },
244
+ },
245
+
246
+ // ---------- node-type extras: special apigw/cdn/lb badges ----------
247
+ {
248
+ selector: 'node.t-apigw',
249
+ style: {
250
+ 'background-color': cssVar('--n-apigw'),
251
+ shape: 'round-rectangle',
252
+ width: 36,
253
+ height: 22,
254
+ 'font-size': 9,
255
+ },
256
+ },
257
+ ],
258
+
259
+ layout: {
260
+ name: 'cose',
261
+ animate: false,
262
+ randomize: true,
263
+ idealEdgeLength: 90,
264
+ nodeRepulsion: 9000,
265
+ edgeElasticity: 80,
266
+ gravity: 0.4,
267
+ numIter: 2200,
268
+ nestingFactor: 1.4,
269
+ componentSpacing: 100,
270
+ padding: 30,
271
+ fit: true,
272
+ },
273
+ })
274
+
275
+ // status bar counts + legend counts
276
+ document.getElementById('st-nodes').textContent = data.nodes.length
277
+ document.getElementById('st-edges').textContent = data.edges.length
278
+ const counts = { STATIC: 0, OBSERVED: 0, INFERRED: 0 }
279
+ data.edges.forEach((e) => {
280
+ counts[e.data.provenance] = (counts[e.data.provenance] || 0) + 1
281
+ })
282
+ document.getElementById('ct-static').textContent = counts.STATIC
283
+ document.getElementById('ct-observed').textContent = counts.OBSERVED
284
+ document.getElementById('ct-inferred').textContent = counts.INFERRED
285
+ // also update canvas tag
286
+ document.querySelector('.canvas-tag .meta').textContent =
287
+ `live · ${data.nodes.length} nodes · ${data.edges.length} edges · cose layout`
288
+ // edges tab count
289
+ // (will be set on selection)
290
+
291
+ // ---------- selection / inspector -------------------------------------
292
+ const inspectBody = document.getElementById('inspect-body')
293
+
294
+ function renderInspector(nodeId) {
295
+ const n = cy.getElementById(nodeId)
296
+ if (!n || n.length === 0 || !n.isNode()) return
297
+ const d = n.data()
298
+ // gather outgoing/incoming
299
+ const out = n.outgoers('edge').map((e) => ({
300
+ verb: e.data('type'),
301
+ target: e.target().data('label') || e.target().id(),
302
+ prov: e.data('provenance'),
303
+ conf: e.data('confidence'),
304
+ }))
305
+ const inc = n.incomers('edge').map((e) => ({
306
+ verb: e.data('type'),
307
+ target: e.source().data('label') || e.source().id(),
308
+ prov: e.data('provenance'),
309
+ conf: e.data('confidence'),
310
+ }))
311
+
312
+ // metadata kvs (only the ones we have)
313
+ const meta = []
314
+ if (d.lang) meta.push(['language', d.lang])
315
+ if (d.replicas) meta.push(['replicas', d.replicas])
316
+ if (d.image) meta.push(['image', d.image])
317
+ if (d.engine) meta.push(['engine', d.engine])
318
+ if (d.version) meta.push(['version', d.version])
319
+ if (d.size) meta.push(['instance', d.size])
320
+ if (d.protocol) meta.push(['protocol', d.protocol])
321
+ if (d.runtime) meta.push(['runtime', d.runtime])
322
+ if (d.mem) meta.push(['memory', d.mem])
323
+ if (d.schedule) meta.push(['schedule', d.schedule])
324
+ if (d.tz) meta.push(['tz', d.tz])
325
+ if (d.kind) meta.push(['kind', d.kind])
326
+ if (d.region) meta.push(['region', d.region])
327
+ if (d.stage) meta.push(['stage', d.stage])
328
+ if (d.k8s_version) meta.push(['k8s', d.k8s_version])
329
+
330
+ const parent = d.parent ? cy.getElementById(d.parent).data('label') : '—'
331
+
332
+ // synthetic metrics so the panel feels alive
333
+ const metricRPS = d.replicas
334
+ ? Math.round(d.replicas * 480 + Math.random() * 220)
335
+ : Math.round(40 + Math.random() * 80)
336
+ const metricP99 = (38 + Math.random() * 64).toFixed(1)
337
+ const metricErr = (Math.random() * 0.7).toFixed(2)
338
+
339
+ // type-aware title decoration
340
+ const labelParts = (d.label || d.id).split('/')
341
+ const stem = labelParts.length > 1 ? labelParts[0] + '/' : ''
342
+ const rest = labelParts.length > 1 ? labelParts.slice(1).join('/') : d.label || d.id
343
+
344
+ inspectBody.innerHTML = `
345
+ <section class="insp-section">
346
+ <div class="insp-eyebrow">${escapeHtml(d.type || '').toUpperCase()} · ${escapeHtml(parent)}</div>
347
+ <div class="insp-title"><span class="stem">${escapeHtml(stem)}</span>${escapeHtml(rest)}</div>
348
+ <div class="insp-sub">${escapeHtml(d.id)}</div>
349
+ <div class="insp-tags">
350
+ ${d.replicas ? `<span class="tag alive">${d.replicas} replicas</span>` : ''}
351
+ ${d.engine ? `<span class="tag">${escapeHtml(d.engine)}</span>` : ''}
352
+ ${d.lang ? `<span class="tag">${escapeHtml(d.lang)}</span>` : ''}
353
+ ${d.kind ? `<span class="tag">${escapeHtml(d.kind)}</span>` : ''}
354
+ ${meta.length === 0 ? `<span class="tag">${escapeHtml(d.type)}</span>` : ''}
355
+ </div>
356
+ </section>
357
+
358
+ ${metricsBlock(d.type, metricRPS, metricP99, metricErr)}
359
+
360
+ ${
361
+ meta.length
362
+ ? `
363
+ <section class="insp-section">
364
+ <div class="insp-h">Properties <span class="ct">${meta.length}</span></div>
365
+ <dl class="kv">
366
+ ${meta.map(([k, v]) => `<dt>${escapeHtml(k)}</dt><dd>${escapeHtml(String(v))}</dd>`).join('')}
367
+ </dl>
368
+ </section>`
369
+ : ''
370
+ }
371
+
372
+ <section class="insp-section">
373
+ <div class="insp-h">Outgoing <span class="ct">${out.length}</span></div>
374
+ <ul class="edge-list">
375
+ ${out.length ? out.map(edgeRow).join('') : `<li><span class="verb">—</span><span class="target" style="color:var(--paper-3)">no outgoing edges</span></li>`}
376
+ </ul>
377
+ </section>
378
+
379
+ <section class="insp-section">
380
+ <div class="insp-h">Incoming <span class="ct">${inc.length}</span></div>
381
+ <ul class="edge-list">
382
+ ${inc.length ? inc.map(edgeRow).join('') : `<li><span class="verb">—</span><span class="target" style="color:var(--paper-3)">no incoming edges</span></li>`}
383
+ </ul>
384
+ </section>
385
+
386
+ <section class="insp-section">
387
+ <div class="insp-h">Provenance summary <span class="ct">${out.length + inc.length}</span></div>
388
+ ${provBars(out, inc)}
389
+ </section>
390
+ `
391
+
392
+ // tab edge count
393
+ document.querySelectorAll('.inspect-tab').forEach((t, i) => {
394
+ if (i === 1) {
395
+ const ct = t.querySelector('.ct')
396
+ if (ct) ct.textContent = out.length + inc.length
397
+ }
398
+ })
399
+ }
400
+
401
+ function edgeRow(e) {
402
+ return `<li>
403
+ <span class="pdot ${e.prov}"></span>
404
+ <span class="verb">${escapeHtml(e.verb || '').toLowerCase()}</span>
405
+ <span class="target">${escapeHtml(e.target)}</span>
406
+ <span class="conf">${typeof e.conf === 'number' ? e.conf.toFixed(2) : '—'}</span>
407
+ </li>`
408
+ }
409
+
410
+ function metricsBlock(type, rps, p99, err) {
411
+ if (!type || ['env', 'vpc', 'cluster', 'namespace', 'cloud', 'external', 'api'].includes(type))
412
+ return ''
413
+ return `<section class="insp-section">
414
+ <div class="metrics">
415
+ <div class="metric"><div class="lbl">req/s</div><div class="val">${rps.toLocaleString()}</div><div class="delta">+${(Math.random() * 4).toFixed(1)}%</div></div>
416
+ <div class="metric"><div class="lbl">p99 ms</div><div class="val">${p99}</div><div class="delta ${parseFloat(p99) > 80 ? 'bad' : ''}">${parseFloat(p99) > 80 ? '+' : '−'}${(Math.random() * 8).toFixed(1)}%</div></div>
417
+ <div class="metric"><div class="lbl">err %</div><div class="val">${err}</div><div class="delta ${parseFloat(err) > 0.4 ? 'bad' : ''}">${parseFloat(err) > 0.4 ? '+' : '−'}${(Math.random() * 0.3).toFixed(2)}</div></div>
418
+ </div>
419
+ </section>`
420
+ }
421
+
422
+ function provBars(out, inc) {
423
+ const all = [...out, ...inc]
424
+ const total = all.length || 1
425
+ const c = { STATIC: 0, OBSERVED: 0, INFERRED: 0 }
426
+ all.forEach((e) => {
427
+ c[e.prov] = (c[e.prov] || 0) + 1
428
+ })
429
+ const row = (k) => {
430
+ const pct = (c[k] / total) * 100
431
+ return `<div style="display:flex;align-items:center;gap:8px;font-size:11.5px;margin:5px 0">
432
+ <span class="pdot ${k}" style="width:7px;height:7px;border-radius:50%;background:var(--prov-${k.toLowerCase()})"></span>
433
+ <span class="serif" style="font-style:italic;width:70px;color:var(--paper-2)">${k.toLowerCase()}</span>
434
+ <div style="flex:1;height:4px;background:var(--ink-3);border-radius:2px;overflow:hidden">
435
+ <div style="width:${pct}%;height:100%;background:var(--prov-${k.toLowerCase()})"></div>
436
+ </div>
437
+ <span class="mono" style="font-size:10.5px;color:var(--paper-3);width:34px;text-align:right">${c[k]}</span>
438
+ </div>`
439
+ }
440
+ return row('STATIC') + row('OBSERVED') + row('INFERRED')
441
+ }
442
+
443
+ function escapeHtml(s) {
444
+ return String(s).replace(
445
+ /[&<>"']/g,
446
+ (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c],
447
+ )
448
+ }
449
+
450
+ // selection: highlight neighbors, dim others, render inspector
451
+ function focusNode(id) {
452
+ cy.elements().removeClass('hl dim')
453
+ const n = cy.getElementById(id)
454
+ if (!n || n.length === 0) return
455
+ const neigh = n.neighborhood().add(n)
456
+ cy.elements().not(neigh).addClass('dim')
457
+ neigh.addClass('hl')
458
+ renderInspector(id)
459
+ }
460
+
461
+ cy.on('tap', 'node', (evt) => {
462
+ const id = evt.target.id()
463
+ cy.$(':selected').unselect()
464
+ evt.target.select()
465
+ focusNode(id)
466
+ })
467
+ cy.on('tap', (evt) => {
468
+ if (evt.target === cy) {
469
+ cy.elements().removeClass('hl dim')
470
+ cy.$(':selected').unselect()
471
+ // re-render the default
472
+ renderInspector('svc-oms')
473
+ }
474
+ })
475
+
476
+ // initial selection: order management — central, interesting fan-out
477
+ cy.ready(() => {
478
+ setTimeout(() => {
479
+ cy.$id('svc-oms').select()
480
+ focusNode('svc-oms')
481
+ cy.fit(undefined, 40)
482
+ // if fit clamped to minZoom (huge graph), zoom in a touch toward center
483
+ if (cy.zoom() < 0.25) {
484
+ cy.zoom({ level: 0.45, renderedPosition: { x: cy.width() / 2, y: cy.height() / 2 } })
485
+ cy.center(cy.$id('svc-oms'))
486
+ }
487
+ drawMinimap()
488
+ }, 80)
489
+ })
490
+
491
+ // ---------- trackpad two-finger pan -----------------------------------
492
+ // Cytoscape binds its own wheel listener on its inner canvases. We attach
493
+ // ours in the CAPTURE phase on the wrapper so we see the event first and
494
+ // can stop it from reaching Cytoscape's zoom-on-wheel handler.
495
+ const cyEl = document.getElementById('cy')
496
+ const wheelHandler = (e) => {
497
+ if (e.ctrlKey) {
498
+ // pinch-zoom (browser synthesizes ctrlKey for trackpad pinch)
499
+ e.preventDefault()
500
+ e.stopPropagation()
501
+ const factor = Math.exp(-e.deltaY * 0.015)
502
+ const newZoom = Math.max(cy.minZoom(), Math.min(cy.maxZoom(), cy.zoom() * factor))
503
+ const rect = cyEl.getBoundingClientRect()
504
+ cy.zoom({
505
+ level: newZoom,
506
+ renderedPosition: { x: e.clientX - rect.left, y: e.clientY - rect.top },
507
+ })
508
+ } else {
509
+ // two-finger drag → pan
510
+ e.preventDefault()
511
+ e.stopPropagation()
512
+ cy.panBy({ x: -e.deltaX, y: -e.deltaY })
513
+ }
514
+ }
515
+ cyEl.addEventListener('wheel', wheelHandler, { passive: false, capture: true })
516
+ // also catch on the canvas-wrap in case cy mounts canvases that intercept
517
+ document
518
+ .querySelector('.canvas-wrap')
519
+ .addEventListener('wheel', wheelHandler, { passive: false, capture: true })
520
+
521
+ // ---------- zoom controls ---------------------------------------------
522
+ document.getElementById('z-in').onclick = () =>
523
+ cy.zoom({ level: cy.zoom() * 1.2, renderedPosition: { x: cy.width() / 2, y: cy.height() / 2 } })
524
+ document.getElementById('z-out').onclick = () =>
525
+ cy.zoom({ level: cy.zoom() / 1.2, renderedPosition: { x: cy.width() / 2, y: cy.height() / 2 } })
526
+ document.getElementById('z-fit').onclick = () => cy.fit(undefined, 60)
527
+
528
+ // ---------- minimap ---------------------------------------------------
529
+ const mmCanvas = document.getElementById('minimap-canvas')
530
+ const mmFrame = document.getElementById('minimap-frame')
531
+ function drawMinimap() {
532
+ const dpr = window.devicePixelRatio || 1
533
+ const rect = mmCanvas.getBoundingClientRect()
534
+ mmCanvas.width = rect.width * dpr
535
+ mmCanvas.height = rect.height * dpr
536
+ const ctx = mmCanvas.getContext('2d')
537
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
538
+ ctx.clearRect(0, 0, rect.width, rect.height)
539
+
540
+ const bb = cy.elements().boundingBox()
541
+ if (!isFinite(bb.x1)) return
542
+ const pad = 8
543
+ const sx = (rect.width - pad * 2) / (bb.w || 1)
544
+ const sy = (rect.height - pad * 2) / (bb.h || 1)
545
+ const s = Math.min(sx, sy)
546
+ const ox = pad - bb.x1 * s + (rect.width - pad * 2 - bb.w * s) / 2
547
+ const oy = pad - bb.y1 * s + (rect.height - pad * 2 - bb.h * s) / 2
548
+
549
+ // edges (very faint)
550
+ ctx.lineWidth = 0.5
551
+ cy.edges().forEach((e) => {
552
+ const a = e.source().position(),
553
+ b = e.target().position()
554
+ ctx.strokeStyle = e.data('_color') + '55'
555
+ ctx.beginPath()
556
+ ctx.moveTo(a.x * s + ox, a.y * s + oy)
557
+ ctx.lineTo(b.x * s + ox, b.y * s + oy)
558
+ ctx.stroke()
559
+ })
560
+ // nodes
561
+ cy.nodes().forEach((n) => {
562
+ if (n.data('_isCompound')) return
563
+ const p = n.position()
564
+ ctx.fillStyle = n.data('_color') || '#888'
565
+ ctx.beginPath()
566
+ ctx.arc(p.x * s + ox, p.y * s + oy, 1.4, 0, Math.PI * 2)
567
+ ctx.fill()
568
+ })
569
+
570
+ // viewport frame
571
+ const ext = cy.extent() // graph-coord viewport
572
+ const fx = ext.x1 * s + ox
573
+ const fy = ext.y1 * s + oy
574
+ const fw = (ext.x2 - ext.x1) * s
575
+ const fh = (ext.y2 - ext.y1) * s
576
+ mmFrame.style.left = Math.max(0, fx) + 'px'
577
+ mmFrame.style.top = Math.max(0, fy) + 'px'
578
+ mmFrame.style.width = Math.min(rect.width - Math.max(0, fx), fw) + 'px'
579
+ mmFrame.style.height = Math.min(rect.height - Math.max(0, fy), fh) + 'px'
580
+ }
581
+ cy.on('viewport zoom pan render', () => requestAnimationFrame(drawMinimap))
582
+ window.addEventListener('resize', () => requestAnimationFrame(drawMinimap))
583
+
584
+ // ---------- legend filtering ------------------------------------------
585
+ const provFilter = new Set() // empty = show all; otherwise = hidden set
586
+ document.querySelectorAll('.legend-row[data-prov]').forEach((row) => {
587
+ row.addEventListener('click', () => {
588
+ const p = row.dataset.prov
589
+ if (provFilter.has(p)) {
590
+ provFilter.delete(p)
591
+ row.style.opacity = '1'
592
+ } else {
593
+ provFilter.add(p)
594
+ row.style.opacity = '0.4'
595
+ }
596
+ cy.edges().forEach((e) => {
597
+ e.style('display', provFilter.has(e.data('provenance')) ? 'none' : 'element')
598
+ })
599
+ })
600
+ })
601
+
602
+ // expose for debugging
603
+ window.__cy = cy
604
+ })()