@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.
- package/CHANGELOG.md +145 -0
- package/LICENSE +21 -0
- package/README.md +178 -0
- package/bin/astrolabe.mjs +136 -0
- package/package.json +78 -0
- package/src/adapters/nuxt/index.mjs +541 -0
- package/src/adapters/nuxt/project.mjs +174 -0
- package/src/adapters/registry.mjs +23 -0
- package/src/adapters/shared.mjs +191 -0
- package/src/adapters/vue/index.mjs +211 -0
- package/src/adapters/vue/project.mjs +116 -0
- package/src/core/client/app.js +662 -0
- package/src/core/client/overview.js +255 -0
- package/src/core/client/styles.css +669 -0
- package/src/core/diff.mjs +0 -0
- package/src/core/findings.mjs +147 -0
- package/src/core/graph.mjs +148 -0
- package/src/core/layout.mjs +141 -0
- package/src/core/render.mjs +92 -0
- package/src/core/schema.mjs +67 -0
- package/src/core/sfc.mjs +134 -0
- package/src/core/svg.mjs +102 -0
- package/src/core/syntax.mjs +59 -0
- package/src/core/views.mjs +304 -0
- package/src/index.d.ts +155 -0
- package/src/index.mjs +29 -0
|
@@ -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(' · ')
|