@amulet-laboratories/astrolabe 0.4.0

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,662 @@
1
+ const G = JSON.parse(document.getElementById('data').textContent)
2
+ const NS = 'http://www.w3.org/2000/svg'
3
+ const RAW_BY_ID = new Map(G.nodes.map((n) => [n.id, n]))
4
+
5
+ const state = {
6
+ view: 'layers',
7
+ group: true,
8
+ types: new Set(Object.keys(TYPES)),
9
+ focus: null,
10
+ heat: false,
11
+ }
12
+
13
+ let current = { proj: null, layout: null, byId: new Map() }
14
+ let selected = null
15
+ let hovered = null
16
+ let hits = new Set()
17
+
18
+ const svg = document.getElementById('svg')
19
+ const viewport = document.getElementById('viewport')
20
+ const gLinks = document.getElementById('links')
21
+ const gNodes = document.getElementById('nodes')
22
+ const gCols = document.getElementById('cols')
23
+
24
+ const colorOf = (n) => `var(--${TYPES[n.type].color})`
25
+ const truncate = (s, max) => (s.length > max ? `${s.slice(0, max - 1)}…` : s)
26
+ const shortPkg = (p) => p.replace(/^@[^/]+\//, '')
27
+
28
+ function el(name, attrs = {}) {
29
+ const node = document.createElementNS(NS, name)
30
+ for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, v)
31
+ return node
32
+ }
33
+
34
+ // --- drawing ----------------------------------------------------------------
35
+
36
+ function draw({ refit = false } = {}) {
37
+ const view = VIEWS[state.view]
38
+ const proj = applyFocus(view.project(G, state), state.focus)
39
+ const grouping = state.group && view.grouping !== false
40
+ const layout = computeLayout(proj, { ...state, group: grouping })
41
+ current = { proj, layout, byId: new Map(proj.nodes.map((n) => [n.id, n])) }
42
+
43
+ const toggle = document.getElementById('group')
44
+ toggle.disabled = view.grouping === false
45
+ document.getElementById('group-label').classList.toggle('disabled', toggle.disabled)
46
+
47
+ gLinks.replaceChildren()
48
+ gNodes.replaceChildren()
49
+ gCols.replaceChildren()
50
+
51
+ for (const c of layout.active) {
52
+ const title = el('text', { x: layout.xs.get(c), y: 16, class: 'col-title' })
53
+ const name = proj.title(c) ?? `Column ${c}`
54
+ title.textContent = `${name} (${layout.cols.get(c).length})`
55
+ gCols.append(
56
+ title,
57
+ el('line', {
58
+ x1: layout.xs.get(c),
59
+ y1: 26,
60
+ x2: layout.xs.get(c) + NODE_W,
61
+ y2: 26,
62
+ class: 'col-rule',
63
+ }),
64
+ )
65
+ }
66
+ for (const h of layout.headers) {
67
+ const t = el('text', { x: h.x + 2, y: h.y, class: 'group-title' })
68
+ t.textContent = h.text
69
+ gCols.append(t)
70
+ }
71
+
72
+ for (const e of proj.edges) {
73
+ const path = el('path', {
74
+ d: edgePath(e, layout),
75
+ class: 'link',
76
+ 'data-from': e.from,
77
+ 'data-to': e.to,
78
+ })
79
+ path.style.setProperty('--lc', colorOf(current.byId.get(e.to)))
80
+ if (e.weight > 1)
81
+ path.setAttribute('stroke-width', Math.min(6, 1 + Math.log2(e.weight)).toFixed(2))
82
+ gLinks.append(path)
83
+ }
84
+
85
+ // Heat overlay: warmth ∝ how often a file changes. sqrt spreads the long
86
+ // tail so a handful of very-hot files don't flatten everything else.
87
+ const maxChurn = state.heat ? Math.max(1, ...proj.nodes.map((n) => n.meta.churn ?? 0)) : 1
88
+
89
+ for (const n of proj.nodes) {
90
+ const { x, y } = layout.pos.get(n.id)
91
+ const g = el('g', { class: 'node', transform: `translate(${x},${y})`, 'data-id': n.id })
92
+ g.style.setProperty('--nc', colorOf(n))
93
+ if (state.heat)
94
+ g.style.setProperty('--heat', Math.sqrt((n.meta.churn ?? 0) / maxChurn).toFixed(3))
95
+ g.append(el('rect', { width: NODE_W, height: NODE_H }))
96
+ g.append(el('rect', { width: 3, height: NODE_H, class: 'tick', rx: 1.5 }))
97
+
98
+ const label = el('text', { x: 10, y: NODE_H / 2 })
99
+ label.textContent = truncate(n.label, 24)
100
+ g.append(label)
101
+
102
+ const badge = badgeFor(n)
103
+ if (badge) {
104
+ const b = el('text', { x: NODE_W - 8, y: NODE_H / 2, class: 'badge' })
105
+ b.textContent = badge
106
+ g.append(b)
107
+ }
108
+ const title = el('title')
109
+ title.textContent = n.label + (n.file ? ` — ${n.file}` : '')
110
+ g.append(title)
111
+ gNodes.append(g)
112
+ }
113
+
114
+ svg.dataset.w = layout.width
115
+ svg.dataset.h = layout.height
116
+ document.getElementById('hint').textContent = VIEWS[state.view].hint
117
+ paint()
118
+ if (refit) fit()
119
+ }
120
+
121
+ function badgeFor(n) {
122
+ if (n.meta.module) return `${n.meta.members}`
123
+ if (n.type === 'server' && n.meta.method && n.meta.method !== 'ALL') return n.meta.method
124
+ if (n.type === 'collection' && n.meta.files) return `${n.meta.files} files`
125
+ if (n.meta.pkg) return shortPkg(n.meta.pkg)
126
+ return ''
127
+ }
128
+
129
+ function neighboursOf(id) {
130
+ const near = new Set([id])
131
+ for (const e of current.proj.edges) {
132
+ if (e.from === id) near.add(e.to)
133
+ if (e.to === id) near.add(e.from)
134
+ }
135
+ return near
136
+ }
137
+
138
+ function paint() {
139
+ // Hover previews the same highlight a click would give, but only when
140
+ // nothing is pinned — otherwise moving the mouse would fight the selection.
141
+ const active = selected ?? (current.byId.has(hovered) ? hovered : null)
142
+ const near = active ? neighboursOf(active) : null
143
+ for (const g of gNodes.children) {
144
+ const id = g.dataset.id
145
+ g.classList.toggle('dim', !!near && !near.has(id))
146
+ g.classList.toggle('sel', id === selected)
147
+ g.classList.toggle('hover', id === active && id !== selected)
148
+ g.classList.toggle('hit', hits.has(id))
149
+ }
150
+ for (const p of gLinks.children) {
151
+ const on = !!near && (p.dataset.from === active || p.dataset.to === active)
152
+ p.classList.toggle('on', on)
153
+ p.classList.toggle('dim', !!near && !on)
154
+ }
155
+ }
156
+
157
+ /** Toggling select, for clicks on the canvas. */
158
+ function select(id) {
159
+ selected = selected === id ? null : id
160
+ paint()
161
+ renderPanel()
162
+ syncHash()
163
+ }
164
+
165
+ /**
166
+ * Jump to a node by id — from a finding, a ranking, or a relationship link.
167
+ * Every path that selects something goes through here, so none of them can
168
+ * forget to sync the URL or move the camera.
169
+ */
170
+ function selectNode(id) {
171
+ selected = id
172
+ paint()
173
+ renderPanel()
174
+ syncHash()
175
+ if (current.layout.pos.has(id)) focusNode(id)
176
+ }
177
+
178
+ // --- detail panel -----------------------------------------------------------
179
+
180
+ function renderPanel() {
181
+ const body = document.getElementById('panel-body')
182
+ const node = selected && (current.byId.get(selected) ?? RAW_BY_ID.get(selected))
183
+ if (!node) return renderOverview(body)
184
+ body.className = ''
185
+ body.replaceChildren()
186
+
187
+ const h = document.createElement('h2')
188
+ h.textContent = node.label
189
+ const kind = document.createElement('div')
190
+ kind.className = 'kind'
191
+ kind.textContent = node.meta.module
192
+ ? 'Module'
193
+ : TYPES[node.type].label + (node.meta.pkg ? ` · ${node.meta.pkg}` : '')
194
+ body.append(h, kind)
195
+
196
+ if (node.file) {
197
+ const f = document.createElement('div')
198
+ f.className = 'file'
199
+ f.textContent = node.file
200
+ body.append(f)
201
+ }
202
+
203
+ const isolate = document.createElement('button')
204
+ isolate.className = 'action'
205
+ isolate.type = 'button'
206
+ isolate.textContent = state.focus === node.id ? 'Show everything' : 'Isolate this'
207
+ isolate.addEventListener('click', () => setFocus(state.focus === node.id ? null : node.id))
208
+ body.append(isolate)
209
+
210
+ if (node.meta.module) {
211
+ body.append(section('Contains', [tagList(node.meta.kinds)]))
212
+ body.append(section(`Members (${node.meta.members})`, [tagList(node.meta.names)]))
213
+ }
214
+
215
+ const facts = []
216
+ if (node.meta.loc) facts.push(['Lines', String(node.meta.loc)])
217
+ if (node.meta.churn) facts.push(['Churn', `${node.meta.churn} commits`])
218
+ if (node.meta.layout) facts.push(['Layout', node.meta.layout])
219
+ if (node.meta.method) facts.push(['Method', node.meta.method])
220
+ if (node.meta.type) facts.push(['Type', node.meta.type])
221
+ if (node.meta.source) facts.push(['Source', node.meta.source])
222
+ if (node.meta.files) facts.push(['Files', String(node.meta.files)])
223
+ if (node.meta.group && !node.meta.module) facts.push(['Group', node.meta.group])
224
+ if (facts.length) {
225
+ const ul = document.createElement('ul')
226
+ for (const [k, v] of facts) {
227
+ const li = document.createElement('li')
228
+ const label = document.createElement('span')
229
+ label.className = 'rel'
230
+ label.textContent = `${k} `
231
+ li.append(label, document.createTextNode(v))
232
+ ul.append(li)
233
+ }
234
+ body.append(section('Details', [ul]))
235
+ }
236
+ if (node.meta.props?.length) body.append(section('Props', [tagList(node.meta.props)]))
237
+ if (node.meta.fields?.length) body.append(section('Schema', [tagList(node.meta.fields)]))
238
+
239
+ // Relationships come from the raw graph, not the current projection, so the
240
+ // panel always tells the whole truth even when a view hides some edges.
241
+ const edges = node.meta.module ? current.proj.edges : G.edges
242
+ const lookup = node.meta.module ? current.byId : RAW_BY_ID
243
+ const outs = edges.filter((e) => e.from === node.id)
244
+ const ins = edges.filter((e) => e.to === node.id)
245
+ if (outs.length)
246
+ body.append(section(`Depends on (${outs.length})`, [refList(outs, 'to', lookup)]))
247
+ if (ins.length) body.append(section(`Used by (${ins.length})`, [refList(ins, 'from', lookup)]))
248
+
249
+ // Whatever the findings pass concluded about this node, said here too — so
250
+ // the node view and the overview can never disagree.
251
+ for (const f of G.findings.filter((f) => f.nodes.includes(node.id))) {
252
+ const w = document.createElement('div')
253
+ w.className = `warn sev-${f.severity}`
254
+ w.textContent = f.detail
255
+ body.append(w)
256
+ }
257
+ }
258
+
259
+ function section(title, children) {
260
+ const frag = document.createDocumentFragment()
261
+ const h = document.createElement('h3')
262
+ h.textContent = title
263
+ frag.append(h, ...children)
264
+ return frag
265
+ }
266
+
267
+ function tagList(items) {
268
+ const d = document.createElement('div')
269
+ d.className = 'tags'
270
+ for (const it of items) {
271
+ const s = document.createElement('span')
272
+ s.className = 'tag'
273
+ s.textContent = it
274
+ d.append(s)
275
+ }
276
+ return d
277
+ }
278
+
279
+ function refList(edges, side, lookup) {
280
+ const ul = document.createElement('ul')
281
+ for (const e of edges) {
282
+ const other = lookup.get(e[side]) ?? RAW_BY_ID.get(e[side])
283
+ if (!other) continue
284
+ const li = document.createElement('li')
285
+ const rel = document.createElement('span')
286
+ rel.className = 'rel'
287
+ rel.textContent = (EDGE_VERB[e.kind] ?? e.kind) + (e.weight > 1 ? ` ×${e.weight} ` : ' ')
288
+ const b = document.createElement('button')
289
+ b.className = 'ref'
290
+ b.type = 'button'
291
+ b.textContent = other.label
292
+ b.style.color = colorOf(other)
293
+ b.addEventListener('click', () => selectNode(other.id))
294
+ li.append(rel, b)
295
+ ul.append(li)
296
+ }
297
+ return ul
298
+ }
299
+
300
+ // --- controls ---------------------------------------------------------------
301
+
302
+ function setFocus(id) {
303
+ state.focus = id
304
+ const note = document.getElementById('focus-note')
305
+ note.hidden = !id
306
+ if (id) note.querySelector('span').textContent = (RAW_BY_ID.get(id) ?? { label: id }).label
307
+ draw({ refit: true })
308
+ renderPanel()
309
+ syncHash()
310
+ }
311
+
312
+ function setView(id) {
313
+ state.view = id
314
+ for (const b of document.querySelectorAll('#views button')) {
315
+ b.setAttribute('aria-selected', String(b.dataset.view === id))
316
+ }
317
+ draw({ refit: true })
318
+ renderPanel()
319
+ syncHash()
320
+ }
321
+
322
+ function buildViews() {
323
+ const host = document.getElementById('views')
324
+ for (const [id, view] of Object.entries(VIEWS)) {
325
+ const b = document.createElement('button')
326
+ b.type = 'button'
327
+ b.role = 'tab'
328
+ b.dataset.view = id
329
+ b.textContent = view.name
330
+ b.setAttribute('aria-selected', String(id === state.view))
331
+ b.addEventListener('click', () => setView(id))
332
+ host.append(b)
333
+ }
334
+ }
335
+
336
+ function buildChips() {
337
+ const chips = document.getElementById('chips')
338
+ const counts = new Map()
339
+ for (const n of G.nodes) counts.set(n.type, (counts.get(n.type) ?? 0) + 1)
340
+ for (const type of Object.keys(TYPES)) {
341
+ if (!counts.get(type)) continue
342
+ const b = document.createElement('button')
343
+ b.className = 'chip'
344
+ b.type = 'button'
345
+ b.setAttribute('aria-pressed', 'true')
346
+ b.style.setProperty('--dot', `var(--${TYPES[type].color})`)
347
+ b.innerHTML = '<span class="dot"></span>'
348
+ const n = document.createElement('span')
349
+ n.className = 'n'
350
+ n.textContent = counts.get(type)
351
+ b.append(document.createTextNode(TYPES[type].label), n)
352
+ b.addEventListener('click', () => {
353
+ state.types.has(type) ? state.types.delete(type) : state.types.add(type)
354
+ b.setAttribute('aria-pressed', String(state.types.has(type)))
355
+ draw()
356
+ })
357
+ chips.append(b)
358
+ }
359
+ }
360
+
361
+ document.getElementById('group').addEventListener('change', (ev) => {
362
+ state.group = ev.target.checked
363
+ draw()
364
+ })
365
+
366
+ document.getElementById('search').addEventListener('input', (ev) => {
367
+ const q = ev.target.value.trim().toLowerCase()
368
+ hits = new Set(
369
+ q
370
+ ? G.nodes
371
+ .filter((n) => `${n.label} ${n.file ?? ''}`.toLowerCase().includes(q))
372
+ .map((n) => n.id)
373
+ : [],
374
+ )
375
+ paint()
376
+ const first = [...hits].find((id) => current.layout.pos.has(id))
377
+ if (q && first) focusNode(first)
378
+ })
379
+
380
+ document.getElementById('reset').addEventListener('click', () => {
381
+ selected = null
382
+ setFocus(null)
383
+ renderPanel()
384
+ paint()
385
+ fit()
386
+ })
387
+
388
+ const VIEW_KEYS = Object.keys(VIEWS)
389
+
390
+ document.addEventListener('keydown', (ev) => {
391
+ const typing = /^(INPUT|TEXTAREA)$/.test(ev.target.tagName)
392
+ if (ev.key === 'Escape') {
393
+ if (typing) return ev.target.blur()
394
+ if (state.focus) setFocus(null)
395
+ else select(null)
396
+ return
397
+ }
398
+ if (typing || ev.metaKey || ev.ctrlKey || ev.altKey) return
399
+
400
+ if (ev.key === '/') {
401
+ ev.preventDefault()
402
+ document.getElementById('search').focus()
403
+ } else if (ev.key >= '1' && ev.key <= String(VIEW_KEYS.length)) {
404
+ setView(VIEW_KEYS[Number(ev.key) - 1])
405
+ } else if (ev.key === 'g') {
406
+ const box = document.getElementById('group')
407
+ if (box.disabled) return
408
+ box.checked = !box.checked
409
+ state.group = box.checked
410
+ draw()
411
+ } else if (ev.key === 'f' && selected) {
412
+ setFocus(state.focus === selected ? null : selected)
413
+ }
414
+ })
415
+
416
+ // --- shareable state --------------------------------------------------------
417
+ // The view, selection and isolation live in the URL, so a particular reading of
418
+ // the graph can be sent to someone else as a link.
419
+
420
+ function syncHash() {
421
+ const parts = [`v=${state.view}`]
422
+ if (selected) parts.push(`n=${encodeURIComponent(selected)}`)
423
+ if (state.focus) parts.push(`f=${encodeURIComponent(state.focus)}`)
424
+ history.replaceState(null, '', `#${parts.join('&')}`)
425
+ }
426
+
427
+ function readHash() {
428
+ const params = new URLSearchParams(location.hash.slice(1))
429
+ const view = params.get('v')
430
+ if (view && VIEWS[view]) state.view = view
431
+ const focus = params.get('f')
432
+ if (focus && RAW_BY_ID.has(focus)) state.focus = focus
433
+ const node = params.get('n')
434
+ if (node && RAW_BY_ID.has(node)) selected = node
435
+ }
436
+
437
+ // --- pan / zoom -------------------------------------------------------------
438
+
439
+ const view = { x: 0, y: 0, k: 1 }
440
+ const apply = () =>
441
+ viewport.setAttribute('transform', `translate(${view.x},${view.y}) scale(${view.k})`)
442
+
443
+ svg.addEventListener(
444
+ 'wheel',
445
+ (ev) => {
446
+ ev.preventDefault()
447
+ const r = svg.getBoundingClientRect()
448
+ const px = ev.clientX - r.left
449
+ const py = ev.clientY - r.top
450
+ const k = Math.min(2.5, Math.max(0.08, view.k * Math.exp(-ev.deltaY * 0.0015)))
451
+ view.x = px - (px - view.x) * (k / view.k)
452
+ view.y = py - (py - view.y) * (k / view.k)
453
+ view.k = k
454
+ apply()
455
+ },
456
+ { passive: false },
457
+ )
458
+
459
+ // A press is a click until it travels far enough to be a pan. Pointer capture
460
+ // is taken only once panning starts: capturing on pointerdown would retarget
461
+ // the click to the <svg> and swallow every node selection.
462
+ const DRAG_SLOP = 4
463
+ let drag = null
464
+
465
+ svg.addEventListener('pointerdown', (ev) => {
466
+ if (ev.button !== 0) return
467
+ drag = { sx: ev.clientX, sy: ev.clientY, x0: view.x, y0: view.y, moved: false, id: ev.pointerId }
468
+ })
469
+
470
+ svg.addEventListener('pointermove', (ev) => {
471
+ if (!drag) return
472
+ const dx = ev.clientX - drag.sx
473
+ const dy = ev.clientY - drag.sy
474
+ if (!drag.moved) {
475
+ if (Math.hypot(dx, dy) < DRAG_SLOP) return
476
+ drag.moved = true
477
+ svg.classList.add('grabbing')
478
+ svg.setPointerCapture(drag.id)
479
+ }
480
+ view.x = drag.x0 + dx
481
+ view.y = drag.y0 + dy
482
+ apply()
483
+ })
484
+
485
+ svg.addEventListener('pointerup', (ev) => {
486
+ if (!drag) return
487
+ const wasDrag = drag.moved
488
+ if (wasDrag && svg.hasPointerCapture(drag.id)) svg.releasePointerCapture(drag.id)
489
+ drag = null
490
+ svg.classList.remove('grabbing')
491
+ if (wasDrag) return
492
+ const hit = ev.target.closest?.('.node')
493
+ select(hit ? hit.dataset.id : null)
494
+ })
495
+
496
+ svg.addEventListener('pointercancel', () => {
497
+ drag = null
498
+ svg.classList.remove('grabbing')
499
+ })
500
+
501
+ svg.addEventListener('pointerover', (ev) => {
502
+ if (selected || drag?.moved) return
503
+ const id = ev.target.closest?.('.node')?.dataset.id ?? null
504
+ if (id === hovered) return
505
+ hovered = id
506
+ paint()
507
+ })
508
+
509
+ svg.addEventListener('pointerleave', () => {
510
+ if (!hovered) return
511
+ hovered = null
512
+ paint()
513
+ })
514
+
515
+ function fit() {
516
+ const r = svg.getBoundingClientRect()
517
+ const w = Number(svg.dataset.w) || 1
518
+ const h = Number(svg.dataset.h) || 1
519
+ view.k = Math.min((r.width - 40) / w, (r.height - 40) / h, 1)
520
+ view.x = (r.width - w * view.k) / 2
521
+ view.y = 20
522
+ apply()
523
+ }
524
+
525
+ function focusNode(id) {
526
+ const p = current.layout.pos.get(id)
527
+ if (!p) return
528
+ const r = svg.getBoundingClientRect()
529
+ view.x = r.width / 2 - (p.x + NODE_W / 2) * view.k
530
+ view.y = r.height / 2 - (p.y + NODE_H / 2) * view.k
531
+ apply()
532
+ }
533
+
534
+ document.getElementById('focus-clear').addEventListener('click', () => setFocus(null))
535
+ window.addEventListener('resize', () => fit())
536
+
537
+ // --- heat toggle ------------------------------------------------------------
538
+ // Only offered when the app is a git repo (churn data is present). Shades every
539
+ // file-backed node by how often it changes — where the risk and the reading is.
540
+ if (G.nodes.some((n) => n.meta.churn)) {
541
+ const heatBtn = document.createElement('button')
542
+ heatBtn.id = 'heat'
543
+ heatBtn.type = 'button'
544
+ heatBtn.textContent = 'Heat'
545
+ heatBtn.title = 'Shade nodes by how often they change (git churn)'
546
+ heatBtn.setAttribute('aria-pressed', 'false')
547
+ document.getElementById('reset').before(heatBtn)
548
+ heatBtn.addEventListener('click', () => {
549
+ state.heat = !state.heat
550
+ heatBtn.setAttribute('aria-pressed', String(state.heat))
551
+ document.getElementById('canvas').classList.toggle('heat', state.heat)
552
+ draw()
553
+ })
554
+ }
555
+
556
+ // --- command palette --------------------------------------------------------
557
+ // ⌘K / Ctrl-K opens a fuzzy jump-to-node search over the whole graph — the
558
+ // fastest way to reach a node by name in a large app. Selecting one routes
559
+ // through selectNode, so it moves the camera, panel and URL like any other pick.
560
+
561
+ const palette = document.createElement('div')
562
+ palette.id = 'palette'
563
+ palette.hidden = true
564
+ palette.innerHTML =
565
+ '<div class="palette-box"><input type="search" placeholder="Jump to any node…" autocomplete="off" spellcheck="false" aria-label="Jump to node"><ul role="listbox"></ul></div>'
566
+ document.body.append(palette)
567
+ const palInput = palette.querySelector('input')
568
+ const palList = palette.querySelector('ul')
569
+ let palItems = []
570
+ let palIndex = 0
571
+
572
+ function openPalette() {
573
+ palette.hidden = false
574
+ palInput.value = ''
575
+ renderPalette('')
576
+ palInput.focus()
577
+ }
578
+
579
+ function renderPalette(q) {
580
+ const query = q.trim().toLowerCase()
581
+ palItems = G.nodes
582
+ .filter(
583
+ (n) =>
584
+ !query ||
585
+ `${n.label} ${n.file ?? ''} ${TYPES[n.type]?.label ?? ''}`.toLowerCase().includes(query),
586
+ )
587
+ .slice(0, 40)
588
+ palIndex = 0
589
+ palList.replaceChildren()
590
+ palItems.forEach((n, i) => {
591
+ const li = document.createElement('li')
592
+ li.setAttribute('role', 'option')
593
+ if (i === 0) li.className = 'active'
594
+ const dot = document.createElement('span')
595
+ dot.className = 'pdot'
596
+ dot.style.background = colorOf(n)
597
+ const label = document.createElement('span')
598
+ label.className = 'plabel'
599
+ label.textContent = n.label
600
+ const kind = document.createElement('span')
601
+ kind.className = 'pkind'
602
+ kind.textContent = TYPES[n.type]?.label ?? n.type
603
+ li.append(dot, label, kind)
604
+ li.addEventListener('mousedown', (ev) => {
605
+ ev.preventDefault()
606
+ choosePalette(i)
607
+ })
608
+ palList.append(li)
609
+ })
610
+ }
611
+
612
+ function movePalette(delta) {
613
+ if (!palItems.length) return
614
+ palIndex = (palIndex + delta + palItems.length) % palItems.length
615
+ for (const [i, li] of [...palList.children].entries())
616
+ li.classList.toggle('active', i === palIndex)
617
+ palList.children[palIndex]?.scrollIntoView({ block: 'nearest' })
618
+ }
619
+
620
+ function choosePalette(i) {
621
+ const n = palItems[i]
622
+ if (!n) return
623
+ palette.hidden = true
624
+ selectNode(n.id)
625
+ }
626
+
627
+ palInput.addEventListener('input', (ev) => renderPalette(ev.target.value))
628
+ palInput.addEventListener('keydown', (ev) => {
629
+ if (ev.key === 'ArrowDown') (ev.preventDefault(), movePalette(1))
630
+ else if (ev.key === 'ArrowUp') (ev.preventDefault(), movePalette(-1))
631
+ else if (ev.key === 'Enter') (ev.preventDefault(), choosePalette(palIndex))
632
+ else if (ev.key === 'Escape') (ev.preventDefault(), (palette.hidden = true))
633
+ })
634
+ palette.addEventListener('mousedown', (ev) => {
635
+ if (ev.target === palette) palette.hidden = true
636
+ })
637
+ document.addEventListener('keydown', (ev) => {
638
+ if ((ev.metaKey || ev.ctrlKey) && ev.key === 'k') {
639
+ ev.preventDefault()
640
+ palette.hidden ? openPalette() : (palette.hidden = true)
641
+ }
642
+ })
643
+
644
+ // A visible, clickable affordance so the palette is discoverable, not just a
645
+ // shortcut you have to already know.
646
+ const palHint = document.createElement('button')
647
+ palHint.id = 'pal-hint'
648
+ palHint.type = 'button'
649
+ palHint.textContent = '⌘K'
650
+ palHint.title = 'Jump to any node'
651
+ palHint.addEventListener('click', openPalette)
652
+ document.getElementById('search').before(palHint)
653
+
654
+ readHash()
655
+ buildViews()
656
+ buildChips()
657
+ if (state.focus) setFocus(state.focus)
658
+ else draw()
659
+ fit()
660
+ renderPanel()
661
+ if (selected) focusNode(selected)
662
+ if (G.warnings.length) document.getElementById('warnings').textContent = G.warnings.join(' · ')