cg-code-graph 0.10.1__py3-none-any.whl
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.
- cg_code_graph-0.10.1.dist-info/METADATA +678 -0
- cg_code_graph-0.10.1.dist-info/RECORD +174 -0
- cg_code_graph-0.10.1.dist-info/WHEEL +5 -0
- cg_code_graph-0.10.1.dist-info/entry_points.txt +3 -0
- cg_code_graph-0.10.1.dist-info/licenses/LICENSE +21 -0
- cg_code_graph-0.10.1.dist-info/top_level.txt +1 -0
- codegraph/__init__.py +2 -0
- codegraph/aitools.py +129 -0
- codegraph/apps.py +76 -0
- codegraph/blindspots.py +428 -0
- codegraph/bridges.py +1701 -0
- codegraph/cli.py +725 -0
- codegraph/concepts.py +362 -0
- codegraph/config.py +559 -0
- codegraph/core/__init__.py +0 -0
- codegraph/core/cache.py +375 -0
- codegraph/core/detect.py +80 -0
- codegraph/core/extractors.py +187 -0
- codegraph/core/fsutil.py +61 -0
- codegraph/core/generated.py +575 -0
- codegraph/core/model.py +174 -0
- codegraph/core/paths.py +175 -0
- codegraph/core/plugin.py +160 -0
- codegraph/core/store.py +80 -0
- codegraph/core/syntax_errors.py +132 -0
- codegraph/coverage.py +928 -0
- codegraph/doctor.py +453 -0
- codegraph/external.py +613 -0
- codegraph/indexer.py +336 -0
- codegraph/link.py +434 -0
- codegraph/lint_async.py +524 -0
- codegraph/mcp_server.py +1303 -0
- codegraph/parity.py +473 -0
- codegraph/parity_structure.py +307 -0
- codegraph/payload.py +321 -0
- codegraph/plans.py +1285 -0
- codegraph/platform_scan.py +643 -0
- codegraph/platforms.py +1369 -0
- codegraph/plugins/__init__.py +0 -0
- codegraph/plugins/cfamily/__init__.py +0 -0
- codegraph/plugins/cfamily/plugin.py +930 -0
- codegraph/plugins/cfamily/syntax.py +881 -0
- codegraph/plugins/dart/__init__.py +0 -0
- codegraph/plugins/dart/bridges.py +345 -0
- codegraph/plugins/dart/extractor/bin/extract.dart +717 -0
- codegraph/plugins/dart/extractor/pubspec.lock +149 -0
- codegraph/plugins/dart/extractor/pubspec.yaml +7 -0
- codegraph/plugins/dart/http.py +904 -0
- codegraph/plugins/dart/models.py +308 -0
- codegraph/plugins/dart/plugin.py +625 -0
- codegraph/plugins/dart/program.py +907 -0
- codegraph/plugins/django/__init__.py +0 -0
- codegraph/plugins/django/extras.py +378 -0
- codegraph/plugins/django/models.py +508 -0
- codegraph/plugins/django/plugin.py +728 -0
- codegraph/plugins/django/schemas.py +339 -0
- codegraph/plugins/django/shapes.py +216 -0
- codegraph/plugins/django/urls.py +603 -0
- codegraph/plugins/express/__init__.py +0 -0
- codegraph/plugins/express/plugin.py +428 -0
- codegraph/plugins/flutter/__init__.py +0 -0
- codegraph/plugins/flutter/plugin.py +538 -0
- codegraph/plugins/kotlin/__init__.py +0 -0
- codegraph/plugins/kotlin/exact.py +457 -0
- codegraph/plugins/kotlin/plugin.py +1961 -0
- codegraph/plugins/kotlin/reparse.py +234 -0
- codegraph/plugins/laravel/__init__.py +0 -0
- codegraph/plugins/laravel/broadcast.py +351 -0
- codegraph/plugins/laravel/plugin.py +863 -0
- codegraph/plugins/laravel/tests.py +262 -0
- codegraph/plugins/laravel/values.py +728 -0
- codegraph/plugins/native/__init__.py +0 -0
- codegraph/plugins/native/gates.py +286 -0
- codegraph/plugins/native/runner.py +183 -0
- codegraph/plugins/native/scipread.py +194 -0
- codegraph/plugins/native/ts.py +54 -0
- codegraph/plugins/nest/__init__.py +0 -0
- codegraph/plugins/nest/plugin.py +654 -0
- codegraph/plugins/nextjs/__init__.py +0 -0
- codegraph/plugins/nextjs/plugin.py +336 -0
- codegraph/plugins/nuxt/__init__.py +0 -0
- codegraph/plugins/nuxt/plugin.py +308 -0
- codegraph/plugins/php/__init__.py +0 -0
- codegraph/plugins/php/extractor/composer.json +5 -0
- codegraph/plugins/php/extractor/composer.lock +76 -0
- codegraph/plugins/php/extractor/extract.php +743 -0
- codegraph/plugins/php/gating.py +573 -0
- codegraph/plugins/php/plugin.py +668 -0
- codegraph/plugins/php/strings.py +197 -0
- codegraph/plugins/python/__init__.py +0 -0
- codegraph/plugins/python/aitools.py +664 -0
- codegraph/plugins/python/external.py +245 -0
- codegraph/plugins/python/fields.py +107 -0
- codegraph/plugins/python/plugin.py +1733 -0
- codegraph/plugins/python/refs.py +485 -0
- codegraph/plugins/python/roots.py +412 -0
- codegraph/plugins/python/socketio.py +210 -0
- codegraph/plugins/python/subproc.py +864 -0
- codegraph/plugins/python/tests.py +1040 -0
- codegraph/plugins/python/values.py +179 -0
- codegraph/plugins/pyweb/__init__.py +0 -0
- codegraph/plugins/pyweb/plugin.py +1334 -0
- codegraph/plugins/pyweb/values.py +68 -0
- codegraph/plugins/rust/__init__.py +0 -0
- codegraph/plugins/rust/cargo.py +226 -0
- codegraph/plugins/rust/plugin.py +980 -0
- codegraph/plugins/rust/syntax.py +678 -0
- codegraph/plugins/scip/__init__.py +0 -0
- codegraph/plugins/scip/importer.py +129 -0
- codegraph/plugins/scip/scip.proto +962 -0
- codegraph/plugins/scip/scip_pb2.py +97 -0
- codegraph/plugins/stubs/__init__.py +0 -0
- codegraph/plugins/stubs/plugins.py +38 -0
- codegraph/plugins/swift/__init__.py +0 -0
- codegraph/plugins/swift/baseurl.py +109 -0
- codegraph/plugins/swift/exact.py +415 -0
- codegraph/plugins/swift/indexstore.py +209 -0
- codegraph/plugins/swift/packages.py +174 -0
- codegraph/plugins/swift/plugin.py +2890 -0
- codegraph/plugins/ts/__init__.py +0 -0
- codegraph/plugins/ts/baseurl.py +185 -0
- codegraph/plugins/ts/extractor/extract.mjs +2652 -0
- codegraph/plugins/ts/extractor/fw.mjs +685 -0
- codegraph/plugins/ts/extractor/package-lock.json +205 -0
- codegraph/plugins/ts/extractor/package.json +9 -0
- codegraph/plugins/ts/plugin.py +480 -0
- codegraph/plugins/tsweb/__init__.py +0 -0
- codegraph/plugins/tsweb/common.py +290 -0
- codegraph/plugins/tsweb/data.py +276 -0
- codegraph/presets/__init__.py +146 -0
- codegraph/presets/c_cpp.yaml +9 -0
- codegraph/presets/common.yaml +66 -0
- codegraph/presets/dart.yaml +9 -0
- codegraph/presets/django-ninja.yaml +15 -0
- codegraph/presets/django.yaml +25 -0
- codegraph/presets/djangorestframework.yaml +17 -0
- codegraph/presets/express.yaml +17 -0
- codegraph/presets/kotlin.yaml +11 -0
- codegraph/presets/laravel.yaml +40 -0
- codegraph/presets/nest.yaml +11 -0
- codegraph/presets/nextjs.yaml +15 -0
- codegraph/presets/nuxt.yaml +9 -0
- codegraph/presets/php.yaml +5 -0
- codegraph/presets/python.yaml +10 -0
- codegraph/presets/rust.yaml +5 -0
- codegraph/presets/swift.yaml +10 -0
- codegraph/presets/typescript.yaml +13 -0
- codegraph/process_runs.py +328 -0
- codegraph/protocols/__init__.py +299 -0
- codegraph/protocols/builtin.py +67 -0
- codegraph/protocols/matchers.py +144 -0
- codegraph/protocols/view.py +334 -0
- codegraph/query.py +2089 -0
- codegraph/realtime.py +260 -0
- codegraph/roundtrip.py +346 -0
- codegraph/routes.py +442 -0
- codegraph/starters.py +218 -0
- codegraph/tests_index.py +117 -0
- codegraph/viz/__init__.py +0 -0
- codegraph/viz/graph.py +369 -0
- codegraph/viz/server.py +198 -0
- codegraph/viz/static/app.css +148 -0
- codegraph/viz/static/app.js +1082 -0
- codegraph/viz/static/index.html +81 -0
- codegraph/viz/static/layered.js +237 -0
- codegraph/viz/static/vendor/VERSIONS.txt +4 -0
- codegraph/viz/static/vendor/cose-base.js +3214 -0
- codegraph/viz/static/vendor/cytoscape-fcose.js +1549 -0
- codegraph/viz/static/vendor/cytoscape.min.js +31 -0
- codegraph/viz/static/vendor/layout-base.js +5230 -0
- codegraph/viz/tools/package-lock.json +303 -0
- codegraph/viz/tools/package.json +7 -0
- codegraph/viz/tools/shoot.mjs +165 -0
- codegraph/xcode.py +251 -0
|
@@ -0,0 +1,1082 @@
|
|
|
1
|
+
/* code-graph view: Cytoscape.js over the deterministic subgraph returned by /api/graph (or window.__STATIC__). */
|
|
2
|
+
(function () {
|
|
3
|
+
'use strict'
|
|
4
|
+
const STATIC = window.__STATIC__ || null
|
|
5
|
+
const $ = (id) => document.getElementById(id)
|
|
6
|
+
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]))
|
|
7
|
+
// node colour = family (#82 item 14): Okabe-Ito hues, darkened to >= 3:1 on the light and the dark canvas; inside a
|
|
8
|
+
// family the shape tells kinds apart (KIND_SHAPE)
|
|
9
|
+
const FAMILY = { code: '#0072b2', ui: '#00866a', entry: '#d55e00', data: '#a06a00', config: '#6b7280', jobs: '#b5638f',
|
|
10
|
+
external: '#2b7fb8', findings: '#8a7f00' }
|
|
11
|
+
const KIND_FAMILY = {
|
|
12
|
+
method: 'code', function: 'code', script: 'code', class: 'code', type: 'code', property: 'code', field: 'code', file: 'code',
|
|
13
|
+
composable: 'ui', store: 'ui', component: 'ui', module: 'ui', page: 'ui', layout: 'ui', app: 'ui',
|
|
14
|
+
route: 'entry', command: 'jobs', schedule: 'jobs', job: 'jobs', listener: 'jobs', observer: 'jobs',
|
|
15
|
+
table: 'data', column: 'data', config: 'config', env: 'config', setting: 'config', constant: 'config', enum_case: 'config',
|
|
16
|
+
http: 'external', connection: 'external', client: 'external', request_key: 'external',
|
|
17
|
+
resolution: 'findings', issue: 'findings', admin: 'jobs'
|
|
18
|
+
}
|
|
19
|
+
const KIND_COLOR = Object.fromEntries(Object.entries(KIND_FAMILY).map(([k, f]) => [k, FAMILY[f]]))
|
|
20
|
+
const KIND_SHAPE = { method: 'ellipse', function: 'ellipse', script: 'ellipse', class: 'round-rectangle', type: 'round-tag', property: 'diamond',
|
|
21
|
+
field: 'diamond', file: 'rectangle', page: 'round-rectangle', layout: 'round-rectangle', app: 'round-rectangle', component: 'ellipse',
|
|
22
|
+
composable: 'triangle', store: 'barrel', module: 'rectangle', table: 'barrel', column: 'rectangle', route: 'round-triangle',
|
|
23
|
+
http: 'round-hexagon', connection: 'round-hexagon', client: 'round-octagon', request_key: 'tag', command: 'round-pentagon',
|
|
24
|
+
schedule: 'round-pentagon', job: 'round-pentagon', listener: 'vee', observer: 'vee', config: 'round-rectangle', env: 'rectangle',
|
|
25
|
+
setting: 'tag', constant: 'rectangle', enum_case: 'tag', resolution: 'star', issue: 'round-diamond', admin: 'round-heptagon' }
|
|
26
|
+
// a function is a small circle with a border, a method a plain circle (same family)
|
|
27
|
+
const KIND_BORDER = { function: 2, script: 2 }
|
|
28
|
+
|
|
29
|
+
// themes (#82 item 13): the style table is written in light colours; the dark theme maps each one (DARK)
|
|
30
|
+
const DARK = {
|
|
31
|
+
'#ffffff': '#12161d', '#fff': '#12161d', '#1d2330': '#e6e9ef', '#000': '#ffffff', '#384156': '#c3cad6', '#eef1f6': '#1c222c',
|
|
32
|
+
'#eaf6ec': '#16261d', '#7d889c': '#8792a6', '#4f9a66': '#5fb07a', '#f5f7fa': '#191e27',
|
|
33
|
+
'#4a5368': '#d0d6e0', '#767e90': '#a3acbc', '#848c9e': '#7c8597', '#d64545': '#ff6b6b', '#b26b00': '#e09a2c', '#1d4ed8': '#7aa7ff',
|
|
34
|
+
'#2e9d57': '#3fbf6f', '#1b7a3d': '#3fbf6f', '#c77c00': '#f0a030', '#d6006b': '#ff4f9a', '#b06ab3': '#c98bcc', '#d62828': '#ff5c5c',
|
|
35
|
+
'#ff2e88': '#ff5ca3', '#8e6bbf': '#a88ad6', '#145c2e': '#7ddc9c', '#8a5300': '#f0b060', '#a3004f': '#ff7ab5', '#b01e1e': '#ff8080'
|
|
36
|
+
}
|
|
37
|
+
let themePref = 'auto'; try { themePref = localStorage.getItem('cg.theme') || 'auto' } catch (e) { themePref = 'auto' }
|
|
38
|
+
const darkMq = window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)') : null
|
|
39
|
+
function themeName () { return themePref === 'auto' ? (darkMq && darkMq.matches ? 'dark' : 'light') : themePref }
|
|
40
|
+
function c (hex) { return themeName() === 'dark' ? (DARK[hex] || hex) : hex }
|
|
41
|
+
function applyTheme () {
|
|
42
|
+
document.documentElement.dataset.theme = themeName()
|
|
43
|
+
const b = document.getElementById('theme'); if (b) b.textContent = 'theme: ' + themePref
|
|
44
|
+
if (cy) { cy.style(makeStyle()); if (data && data.meta.mode !== 'plan') legend() }
|
|
45
|
+
}
|
|
46
|
+
// plan overlay: role of a node in the planned change (codegraph/plans.py check result)
|
|
47
|
+
const PLAN_MARK = { added: '+ ', modified: '~ ', missing: '! ', forbidden: '✕ ' }
|
|
48
|
+
const PLAN_ROLE_LABEL = { added: 'planned (new)', modified: 'planned modification', missing: 'MISSING FROM PLAN', review: 'review (related)',
|
|
49
|
+
covered: 'covered by plan', forbidden: 'forbidden target', context: 'context' }
|
|
50
|
+
const PLAN_PRI = { forbidden: 4, added: 3, gap: 2, touches: 1 }
|
|
51
|
+
const CONF_RANK = { heuristic: 1, resolved: 2, exact: 3 }
|
|
52
|
+
// confidence by dash pattern and width as well as colour (#82 item 7); every colour >= 3:1 on the canvas
|
|
53
|
+
// (tests/test_viz.py reads these tables)
|
|
54
|
+
const CANVAS = '#ffffff'; const MODULE_FILL = '#f5f7fa' // a module box's fill (#eef1f6 at 0.6) on the canvas
|
|
55
|
+
const CONF_EDGE = { exact: { color: '#4a5368', width: 2, style: 'solid' }, resolved: { color: '#767e90', width: 1.5, style: 'dashed' },
|
|
56
|
+
heuristic: { color: '#848c9e', width: 1.5, style: 'dotted' } }
|
|
57
|
+
const GATED = '#d64545'; const PARTGATED = '#b26b00'
|
|
58
|
+
const hiddenConf = new Set() // confidences hidden by the legend chips (client-side, no refetch)
|
|
59
|
+
const ENTRY_LABEL = { http_route: 'HTTP route', websocket: 'websocket', artisan_command: 'artisan', management_command: 'manage.py', scheduled: 'schedule', queue_job: 'queue job',
|
|
60
|
+
listener: 'listener', admin_panel: 'admin', observer: 'observer', ui_page: 'UI page', ui_layout: 'UI layout', ui_app: 'UI app',
|
|
61
|
+
message_handler: 'message handler', cli_command: 'CLI command', main: 'main', ffi_export: 'FFI export', public_api: 'public API',
|
|
62
|
+
test: 'test', bench: 'bench', example: 'example', build_script: 'build script' }
|
|
63
|
+
let data = null; let byId = {}; let collapsed = new Set(); let cy = null; let selected = null
|
|
64
|
+
// layered view (#82): open caller clusters (id -> members shown), `flat` = expand all, last build, module label prefix
|
|
65
|
+
const L = window.CGLayered
|
|
66
|
+
let open = new Map(); let flat = false; let lay = null; let modPrefix = ''; let hover = null; let lastCluster = null
|
|
67
|
+
let presetsP = null
|
|
68
|
+
const LEAF_FS = 12; const MIN_PX = 11; const MAX_FIT_ZOOM = 1.6
|
|
69
|
+
const DEFAULT_MODE = { table: 'reaches', column: 'reaches', config: 'reaches', env: 'reaches', connection: 'reaches', setting: 'reaches',
|
|
70
|
+
request_key: 'reaches', resolution: 'reaches', page: 'downstream', route: 'downstream', component: 'downstream', layout: 'downstream',
|
|
71
|
+
app: 'downstream', http: 'downstream' }
|
|
72
|
+
|
|
73
|
+
function shortName (n) {
|
|
74
|
+
const k = n.kind; const nm = n.name || n.id
|
|
75
|
+
if (k === 'method') return (n.fqn || nm).replace(/^.*\\/, '')
|
|
76
|
+
if (k === 'column') return (n.id.split(':')[1] || nm).split('.').slice(1).join('.')
|
|
77
|
+
if (k === 'table') return 'TABLE ' + nm
|
|
78
|
+
if (k === 'component' || k === 'module') return nm.replace(/^.*\//, '')
|
|
79
|
+
if (k === 'page') return 'page ' + nm
|
|
80
|
+
if (k === 'function' || k === 'composable' || k === 'store') return (n.id.split('#')[1] || nm)
|
|
81
|
+
if (k === 'resolution') return 'resolution ' + nm.replace(/^.*\\/, '')
|
|
82
|
+
if (k === 'route' || k === 'http') return nm
|
|
83
|
+
if (k === 'client') return nm.replace(/^[^/]+\//, '')
|
|
84
|
+
if (k === 'issue') return 'issue ' + nm
|
|
85
|
+
if (k === 'file') return nm.replace(/^.*\//, '')
|
|
86
|
+
if (k === 'property') return (n.fqn || nm).replace(/^.*\\/, '')
|
|
87
|
+
return nm
|
|
88
|
+
}
|
|
89
|
+
function gated (n) { return (n.gate_status && n.gate_status !== 'live') || n.live === false }
|
|
90
|
+
function layoutKind () {
|
|
91
|
+
const v = $('layout').value
|
|
92
|
+
if (v === 'layered' || v === 'fcose') return v
|
|
93
|
+
return data && ['impact', 'downstream', 'path'].includes(data.meta.mode) ? 'layered' : 'fcose'
|
|
94
|
+
}
|
|
95
|
+
function commonPrefix (labels) {
|
|
96
|
+
// `Packages · X`, `Packages · Y`: drop the shared leading part (the full name stays in the tooltip and panel)
|
|
97
|
+
const toks = labels.map((l) => String(l).split(' · '))
|
|
98
|
+
if (toks.length < 2 || toks.some((t) => t.length < 2)) return ''
|
|
99
|
+
let i = 0
|
|
100
|
+
while (toks.every((t) => t.length > i + 1 && t[i] === toks[0][i])) i++
|
|
101
|
+
return i ? toks[0].slice(0, i).join(' · ') + ' · ' : ''
|
|
102
|
+
}
|
|
103
|
+
function stripModule (l) {
|
|
104
|
+
if (modPrefix && l.startsWith(modPrefix)) return l.slice(modPrefix.length)
|
|
105
|
+
const t = l.split(' · ') // a generic leading folder (`Packages · `, `Sources · `, `app · `) says nothing
|
|
106
|
+
return t.length > 1 && L.GENERIC.has(t[0].toLowerCase()) ? t.slice(1).join(' · ') : l
|
|
107
|
+
}
|
|
108
|
+
const groupsById = () => { const o = {}; for (const g of data.groups) o[g.id] = g; return o }
|
|
109
|
+
function modLabel (gid) {
|
|
110
|
+
const g = groupsById()[gid]
|
|
111
|
+
return stripModule(g ? g.label : String(gid))
|
|
112
|
+
}
|
|
113
|
+
// long identifiers may wrap after `.`, `/`, `\`, `::` and `_` (a zero-width space is a wrap point for Cytoscape)
|
|
114
|
+
// and, inside a word longer than 16 characters, at camel-case humps (`NotificationsListDataSource`)
|
|
115
|
+
const breakable = (s) => String(s).replace(/(::|[./\\_])(?=[^\s\u200b:])/g, '$1\u200b')
|
|
116
|
+
.replace(/[^\s\u200b]{17,}/g, (w) => w.replace(/([a-z0-9])(?=[A-Z])/g, '$1\u200b'))
|
|
117
|
+
function leafLabel (n, withModule) {
|
|
118
|
+
const ek = n.entry_kind ? `\n[${ENTRY_LABEL[n.entry_kind] || n.entry_kind}]` : ''
|
|
119
|
+
return breakable((PLAN_MARK[n.plan_role] || '') + shortName(n)) + ek + (withModule ? '\n' + breakable(modLabel(n.group)) : '')
|
|
120
|
+
}
|
|
121
|
+
function leafClasses (n) {
|
|
122
|
+
let cls = 'n'
|
|
123
|
+
if (n.entry_kind) cls += ' entry'
|
|
124
|
+
if (n.is_target) cls += ' target'
|
|
125
|
+
if (gated(n)) cls += ' gatedn'
|
|
126
|
+
if (n.plan_role) cls += ' p-' + n.plan_role
|
|
127
|
+
if (n.plan_guard) cls += ' p-guard'
|
|
128
|
+
if (n.kind === 'issue') cls += n.plan_linked ? ' p-linked' : ' p-unlinked'
|
|
129
|
+
return cls
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function defaultCollapse () {
|
|
133
|
+
collapsed = new Set()
|
|
134
|
+
const total = data.nodes.length
|
|
135
|
+
if (total <= 45) return
|
|
136
|
+
const lim = total > 250 ? 3 : 6
|
|
137
|
+
for (const g of data.groups) {
|
|
138
|
+
if (g.targets || g.plan) continue
|
|
139
|
+
if (data.meta.mode === 'plan') { if (g.count > 4) collapsed.add(g.id); continue }
|
|
140
|
+
if (g.count > lim || (g.id.startsWith('table:') && g.count > 1)) collapsed.add(g.id) // columns fold into their table
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function elements () { return layoutKind() === 'layered' ? elementsLayered() : elementsClustered() }
|
|
145
|
+
|
|
146
|
+
// rows: node + wrapped label lines (greedy fill at ~6.8 px per character, wrap points as in breakable())
|
|
147
|
+
function labelLines (text, maxW, cw) {
|
|
148
|
+
let n = 0
|
|
149
|
+
for (const part of String(text).split('\n')) {
|
|
150
|
+
let line = 0; n++
|
|
151
|
+
for (const w of part.split(/[\s\u200b]+/)) {
|
|
152
|
+
const len = w.length * cw
|
|
153
|
+
if (line && line + cw + len > maxW) { n++; line = len } else line += (line ? cw : 0) + len
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return n
|
|
157
|
+
}
|
|
158
|
+
function rowHeight (u) {
|
|
159
|
+
if (u.type === 'node') return 16 + 3 + 15 * labelLines(leafLabel(u.node, !u.lane), 110, 6.8) + 14
|
|
160
|
+
const w = 30 + Math.min(46, Math.sqrt(u.count) * 7)
|
|
161
|
+
return Math.max(20, w * 0.5) + 4 + 16 * labelLines(clusterLabel(u), 130, 7.4) + 16
|
|
162
|
+
}
|
|
163
|
+
function clusterLabel (u) {
|
|
164
|
+
const word = data.meta.mode === 'impact' ? 'caller' : 'node'
|
|
165
|
+
const n = u.count; const s = n > 1 ? 's' : ''
|
|
166
|
+
let label = u.how === 'module' ? `${modLabel(u.key)} · ${n} ${word}${s}`
|
|
167
|
+
: u.how === 'folder' ? `${u.key} · ${n} ${word}${s}` + (u.groups > 1 ? ` in ${u.groups} modules` : '')
|
|
168
|
+
: `+${n} ${word}${s} in ${u.groups} module${u.groups > 1 ? 's' : ''}`
|
|
169
|
+
if (u.entries) label += ` (${u.entries} entry)`
|
|
170
|
+
if (u.gated) label += ` · ${u.gated} gated`
|
|
171
|
+
if (u.open) label = `▾ ${u.how === 'rest' ? `+${n} ${word}s` : (u.how === 'module' ? modLabel(u.key) : u.key)} · ${u.shown} of ${n} shown`
|
|
172
|
+
return breakable(label)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function elementsLayered () {
|
|
176
|
+
lay = L.build(data, { open, flat, heightOf: rowHeight, fitWidth: Math.max(0, ($('cy').clientWidth || 0) - 60) })
|
|
177
|
+
const els = []
|
|
178
|
+
for (const u of lay.units) {
|
|
179
|
+
const p = lay.pos.get(u.id)
|
|
180
|
+
if (u.type === 'node') {
|
|
181
|
+
const n = u.node
|
|
182
|
+
els.push({ group: 'nodes', data: { id: n.id, label: leafLabel(n, !u.lane), color: KIND_COLOR[n.kind] || '#6b7280', kind: n.kind, shape: KIND_SHAPE[n.kind] || 'ellipse', bw: KIND_BORDER[n.kind] || 0 },
|
|
183
|
+
position: { x: p.x, y: p.y }, classes: leafClasses(n) + (u.lane ? ' lane' : '') })
|
|
184
|
+
continue
|
|
185
|
+
}
|
|
186
|
+
const top = Object.entries(u.kinds).sort((a, b) => b[1] - a[1])
|
|
187
|
+
const n = u.count; const label = clusterLabel(u)
|
|
188
|
+
const w = 30 + Math.min(46, Math.sqrt(n) * 7)
|
|
189
|
+
els.push({ group: 'nodes', data: { id: u.id, cid: u.id, label, color: KIND_COLOR[top[0][0]] || '#6b7280', w, h: Math.max(20, w * 0.5), kinds: u.kinds },
|
|
190
|
+
position: { x: p.x, y: p.y }, classes: 'cluster' + (u.open ? ' open' : '') + (u.gated === n ? ' allgated' : '') })
|
|
191
|
+
}
|
|
192
|
+
return els.concat(edgeEls((id) => lay.rep.get(id), ' lay'))
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function elementsClustered () {
|
|
196
|
+
lay = null
|
|
197
|
+
const els = []; const rep = {}
|
|
198
|
+
for (const g of data.groups) {
|
|
199
|
+
const gn = data.nodes.filter((n) => n.group === g.id)
|
|
200
|
+
const ng = gn.filter(gated).length; const ne = gn.filter((n) => n.entry_kind).length
|
|
201
|
+
const top = Object.entries(g.kinds).sort((a, b) => b[1] - a[1])
|
|
202
|
+
if (gn.length === 1 && !collapsed.has(g.id)) { // no box around a single node: the module is its second label line
|
|
203
|
+
const n = gn[0]; rep[n.id] = n.id
|
|
204
|
+
els.push({ group: 'nodes', data: { id: n.id, label: leafLabel(n, true), color: KIND_COLOR[n.kind] || '#6b7280', kind: n.kind, shape: KIND_SHAPE[n.kind] || 'ellipse', bw: KIND_BORDER[n.kind] || 0 }, classes: leafClasses(n) })
|
|
205
|
+
continue
|
|
206
|
+
}
|
|
207
|
+
if (collapsed.has(g.id)) {
|
|
208
|
+
els.push({ group: 'nodes', data: { id: 'G:' + g.id, gid: g.id, label: `${modLabel(g.id)}\n${g.count} node${g.count > 1 ? 's' : ''}` +
|
|
209
|
+
(ne ? ` · ${ne} entry` : '') + (ng ? ` · ${ng} gated` : ''), color: KIND_COLOR[top[0][0]] || '#6b7280', size: 26 + Math.min(40, Math.sqrt(g.count) * 7),
|
|
210
|
+
repo: g.repo, gatedFrac: ng / g.count, kinds: g.kinds }, classes: 'collapsed' + (ng === g.count ? ' allgated' : '') })
|
|
211
|
+
for (const n of gn) rep[n.id] = 'G:' + g.id
|
|
212
|
+
} else {
|
|
213
|
+
els.push({ group: 'nodes', data: { id: 'G:' + g.id, gid: g.id, label: modLabel(g.id) + ` (${g.count})`, repo: g.repo }, classes: 'module ' + (g.side === 'fe' ? 'fe' : 'be') })
|
|
214
|
+
for (const n of gn) {
|
|
215
|
+
rep[n.id] = n.id
|
|
216
|
+
els.push({ group: 'nodes', data: { id: n.id, parent: 'G:' + g.id, label: leafLabel(n, false), color: KIND_COLOR[n.kind] || '#6b7280', kind: n.kind, shape: KIND_SHAPE[n.kind] || 'ellipse', bw: KIND_BORDER[n.kind] || 0 }, classes: leafClasses(n) })
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return els.concat(edgeEls((id) => rep[id], ''))
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function edgeEls (repOf, extra) {
|
|
224
|
+
const els = []
|
|
225
|
+
const agg = {}
|
|
226
|
+
for (const e of data.edges) {
|
|
227
|
+
const s = repOf(e.src); const d = repOf(e.dst)
|
|
228
|
+
if (!s || !d || s === d) continue
|
|
229
|
+
const k = s + '→' + d
|
|
230
|
+
const a = agg[k] || (agg[k] = { s, d, n: 0, best: 0, gated: 0, kinds: {}, raw: [], plan: null })
|
|
231
|
+
a.n++; a.best = Math.max(a.best, CONF_RANK[e.confidence] || 1); if (e.gated) a.gated++
|
|
232
|
+
if (e.plan && (PLAN_PRI[e.plan] || 0) > (PLAN_PRI[a.plan] || 0)) a.plan = e.plan
|
|
233
|
+
a.kinds[e.kind] = (a.kinds[e.kind] || 0) + 1; a.raw.push(e)
|
|
234
|
+
}
|
|
235
|
+
for (const [k, a] of Object.entries(agg)) {
|
|
236
|
+
const conf = ['', 'heuristic', 'resolved', 'exact'][a.best]
|
|
237
|
+
let cls = 'c-' + conf + extra
|
|
238
|
+
if (a.gated === a.n) cls += ' gated'; else if (a.gated) cls += ' partgated'
|
|
239
|
+
if (a.n > 1) cls += ' multi'
|
|
240
|
+
let label = a.n > 1 ? String(a.n) : ''
|
|
241
|
+
if (a.plan) {
|
|
242
|
+
cls += ' pe-' + a.plan
|
|
243
|
+
if (a.plan === 'forbidden') label = '✕ forbidden'
|
|
244
|
+
else if (a.plan === 'added') label = '+ ' + Object.keys(a.kinds)[0]
|
|
245
|
+
else if (a.plan === 'gap') label = Object.keys(a.kinds)[0].replace(/^PLAN_/, '').toLowerCase()
|
|
246
|
+
}
|
|
247
|
+
els.push({ group: 'edges', data: { id: 'E:' + k, source: a.s, target: a.d, n: a.n, label, w: CONF_EDGE[conf].width + Math.min(5, Math.log2(a.n)), conf, agg: a }, classes: cls })
|
|
248
|
+
}
|
|
249
|
+
return els
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const makeStyle = () => [
|
|
253
|
+
// labels below 11 rendered px are not drawn (level of detail, #82); the target, entry points, the hovered and the
|
|
254
|
+
// selected node and their neighbours, clusters and modules get a larger font instead (lod())
|
|
255
|
+
{ selector: 'node.n', style: { 'background-color': 'data(color)', shape: 'data(shape)', 'border-width': 'data(bw)', 'border-color': c('#1d2330'), label: 'data(label)', 'font-size': LEAF_FS, 'min-zoomed-font-size': MIN_PX, 'text-wrap': 'wrap',
|
|
256
|
+
'text-max-width': 110, 'text-valign': 'bottom', 'text-margin-y': 3, width: 16, height: 16, color: c('#1d2330'), 'text-background-color': c('#fff'),
|
|
257
|
+
'text-background-opacity': 0.75, 'text-background-padding': 1 } },
|
|
258
|
+
{ selector: 'node.entry', style: { shape: 'round-diamond', width: 24, height: 24, 'border-width': 2, 'border-color': c('#1d2330'), 'font-weight': 'bold' } },
|
|
259
|
+
{ selector: 'node.target', style: { shape: 'star', width: 30, height: 30, 'border-width': 3, 'border-color': c('#000'), 'font-size': 14, 'font-weight': 'bold' } },
|
|
260
|
+
{ selector: 'node.gatedn', style: { 'border-width': 3, 'border-style': 'dashed', 'border-color': c('#d64545'), 'background-opacity': 0.55 } },
|
|
261
|
+
{ selector: 'node.module', style: { label: 'data(label)', 'text-valign': 'top', 'text-halign': 'center', 'font-size': 13, 'font-weight': 'bold', color: c('#384156'),
|
|
262
|
+
'background-color': c('#eef1f6'), 'background-opacity': 0.6, 'border-width': 1, 'border-color': c('#7d889c'), shape: 'round-rectangle', padding: 10 } },
|
|
263
|
+
{ selector: 'node.module.fe', style: { 'background-color': c('#eaf6ec'), 'border-color': c('#4f9a66') } },
|
|
264
|
+
{ selector: 'node.collapsed', style: { shape: 'round-rectangle', 'background-color': 'data(color)', 'background-opacity': 0.85, label: 'data(label)', 'text-wrap': 'wrap',
|
|
265
|
+
'text-max-width': 190, 'font-size': 13, 'font-weight': 'bold', 'text-valign': 'bottom', 'text-margin-y': 4, width: 'data(size)', height: 'data(size)',
|
|
266
|
+
'border-width': 2, 'border-color': c('#384156'), color: c('#1d2330'), 'text-background-color': c('#fff'), 'text-background-opacity': 0.8, 'text-background-padding': 2 } },
|
|
267
|
+
{ selector: 'node.collapsed.allgated', style: { 'border-color': c('#d64545'), 'border-style': 'dashed', 'border-width': 3 } },
|
|
268
|
+
{ selector: 'node.cluster', style: { shape: 'round-rectangle', 'background-color': 'data(color)', 'background-opacity': 0.35, width: 'data(w)', height: 'data(h)',
|
|
269
|
+
'border-width': 2, 'border-color': c('#384156'), label: 'data(label)', 'text-wrap': 'wrap', 'text-max-width': 130, 'font-size': 13, 'font-weight': 'bold',
|
|
270
|
+
'text-valign': 'bottom', 'text-margin-y': 4, color: c('#1d2330'), 'text-background-color': c('#fff'), 'text-background-opacity': 0.85, 'text-background-padding': 2 } },
|
|
271
|
+
{ selector: 'node.cluster.open', style: { 'border-style': 'dashed', 'background-opacity': 0.12 } },
|
|
272
|
+
{ selector: 'node.cluster.allgated', style: { 'border-color': c('#d64545'), 'border-style': 'dashed', 'border-width': 3 } },
|
|
273
|
+
{ selector: 'edge', style: { 'curve-style': 'bezier', 'target-arrow-shape': 'triangle', 'arrow-scale': 0.8, width: 'data(w)', 'line-color': c('#7d889c'), 'target-arrow-color': c('#7d889c'),
|
|
274
|
+
label: 'data(label)', 'font-size': 12, 'min-zoomed-font-size': MIN_PX, 'font-weight': 'bold', color: c('#384156'), 'text-background-color': c('#fff'),
|
|
275
|
+
'text-background-opacity': 0.9, 'text-background-padding': 1 } },
|
|
276
|
+
{ selector: 'edge.lay', style: { 'curve-style': 'taxi', 'taxi-direction': 'rightward', 'taxi-turn': '60%', 'taxi-turn-min-distance': 12 } },
|
|
277
|
+
{ selector: 'edge.c-exact', style: { 'line-style': 'solid', 'line-color': c(CONF_EDGE.exact.color), 'target-arrow-color': c(CONF_EDGE.exact.color) } },
|
|
278
|
+
{ selector: 'edge.c-resolved', style: { 'line-style': 'dashed', 'line-dash-pattern': [7, 3], 'line-color': c(CONF_EDGE.resolved.color),
|
|
279
|
+
'target-arrow-color': c(CONF_EDGE.resolved.color) } },
|
|
280
|
+
{ selector: 'edge.c-heuristic', style: { 'line-style': 'dotted', 'line-dash-pattern': [1.5, 3], 'line-color': c(CONF_EDGE.heuristic.color),
|
|
281
|
+
'target-arrow-color': c(CONF_EDGE.heuristic.color) } },
|
|
282
|
+
{ selector: 'edge.gated', style: { 'line-color': c(GATED), 'target-arrow-color': c(GATED), 'line-style': 'dashed', 'line-dash-pattern': [3, 3] } },
|
|
283
|
+
{ selector: 'edge.partgated', style: { 'line-color': c(PARTGATED), 'target-arrow-color': c(PARTGATED) } },
|
|
284
|
+
{ selector: 'edge.confhide', style: { display: 'none' } },
|
|
285
|
+
// ---- plan overlay
|
|
286
|
+
{ selector: 'node.p-added', style: { 'background-color': c('#2e9d57'), 'border-width': 3, 'border-style': 'dashed', 'border-color': c('#1b7a3d'), shape: 'round-rectangle',
|
|
287
|
+
width: 26, height: 18, color: c('#145c2e'), 'font-weight': 'bold' } },
|
|
288
|
+
{ selector: 'node.p-modified', style: { 'border-width': 5, 'border-color': c('#c77c00'), 'border-style': 'solid', width: 22, height: 22, 'font-weight': 'bold', color: c('#8a5300') } },
|
|
289
|
+
{ selector: 'node.p-guard', style: { shape: 'hexagon', width: 28, height: 28 } },
|
|
290
|
+
{ selector: 'node.p-missing', style: { 'underlay-color': c('#ff2e88'), 'underlay-opacity': 0.32, 'underlay-padding': 9, 'underlay-shape': 'ellipse',
|
|
291
|
+
'border-width': 3, 'border-color': c('#d6006b'), color: c('#a3004f'), 'font-weight': 'bold' } },
|
|
292
|
+
{ selector: 'node.p-review', style: { 'border-width': 2, 'border-style': 'dotted', 'border-color': c('#b06ab3'), 'background-opacity': 0.7 } },
|
|
293
|
+
{ selector: 'node.p-covered', style: { 'border-width': 2, 'border-color': c('#2e9d57') } },
|
|
294
|
+
{ selector: 'node.p-forbidden', style: { 'border-width': 4, 'border-color': c('#d62828'), 'border-style': 'double', color: c('#b01e1e'), 'font-weight': 'bold' } },
|
|
295
|
+
{ selector: 'node.p-unlinked', style: { 'border-width': 3, 'border-color': c('#d62828') } },
|
|
296
|
+
{ selector: 'edge.pe-added', style: { 'line-color': c('#2e9d57'), 'target-arrow-color': c('#2e9d57'), 'line-style': 'dashed', 'line-dash-pattern': [8, 4], width: 3.5,
|
|
297
|
+
color: c('#145c2e') } },
|
|
298
|
+
{ selector: 'edge.pe-forbidden', style: { 'line-color': c('#d62828'), 'target-arrow-color': c('#d62828'), 'line-style': 'solid', width: 4, 'mid-target-arrow-shape': 'tee',
|
|
299
|
+
'mid-target-arrow-color': c('#d62828'), 'arrow-scale': 1.3, color: c('#b01e1e'), 'font-size': 12 } },
|
|
300
|
+
{ selector: 'edge.pe-gap', style: { 'line-color': c('#ff2e88'), 'target-arrow-color': c('#ff2e88'), 'line-style': 'dotted', width: 2.5, color: c('#a3004f'), 'font-size': 9.5,
|
|
301
|
+
'font-weight': 'normal' } },
|
|
302
|
+
{ selector: 'edge.pe-touches', style: { 'line-color': c('#8e6bbf'), 'target-arrow-color': c('#8e6bbf'), 'line-style': 'dashed', 'line-dash-pattern': [2, 4], width: 1.2, opacity: 0.55 } },
|
|
303
|
+
{ selector: '.dim', style: { opacity: 0.18 } },
|
|
304
|
+
{ selector: 'edge.hl', style: { 'line-color': c('#1d4ed8'), 'target-arrow-color': c('#1d4ed8'), width: 4, opacity: 1, 'z-index': 99 } },
|
|
305
|
+
{ selector: 'edge.hl.gated', style: { 'line-color': c('#d64545'), 'target-arrow-color': c('#d64545') } },
|
|
306
|
+
{ selector: 'node.hl', style: { opacity: 1, 'border-width': 3, 'border-color': c('#1d4ed8') } },
|
|
307
|
+
{ selector: 'node:selected', style: { 'overlay-color': c('#1d4ed8'), 'overlay-opacity': 0.15 } }
|
|
308
|
+
]
|
|
309
|
+
|
|
310
|
+
// performance (#82 item 15): fcose 'default' quality; after an expand / collapse the nodes keep their positions and
|
|
311
|
+
// the layout only refines them (randomize off, fewer iterations), so the picture does not move
|
|
312
|
+
function layoutOpts (incremental) {
|
|
313
|
+
if (layoutKind() === 'layered') return { name: 'preset', fit: false, animate: false, padding: 30 }
|
|
314
|
+
return { name: 'fcose', quality: 'default', animate: false, randomize: !incremental, nodeDimensionsIncludeLabels: true, packComponents: true,
|
|
315
|
+
nodeRepulsion: 9000, idealEdgeLength: 90, edgeElasticity: 0.3, nestingFactor: 0.15, gravity: 0.3, gravityCompound: 1.2,
|
|
316
|
+
gravityRangeCompound: 1.4, numIter: incremental ? 800 : 2500, tile: true, tilingPaddingVertical: 18, tilingPaddingHorizontal: 18, padding: 30 }
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
let incremental = false; let anchorId = null; let pinned = null
|
|
320
|
+
function render (keepView) {
|
|
321
|
+
if (!data) return
|
|
322
|
+
const els = elements()
|
|
323
|
+
if (!cy) {
|
|
324
|
+
cy = cytoscape({ container: $('cy'), elements: els, style: makeStyle(), layout: { name: 'preset' }, minZoom: 0.05, maxZoom: 4,
|
|
325
|
+
pixelRatio: Math.min(2, window.devicePixelRatio || 1) })
|
|
326
|
+
cy.on('tap', 'node', (ev) => onTap(ev.target))
|
|
327
|
+
cy.on('cxttap', 'node', (ev) => showMenu(ev.target, ev))
|
|
328
|
+
cy.on('tap', hideMenu)
|
|
329
|
+
cy.on('dbltap', 'node.module', (ev) => { collapsed.add(ev.target.data('gid')); render() })
|
|
330
|
+
cy.on('tap', (ev) => { if (ev.target === cy) { clearHl(); lastCluster = null; if (!STATIC) setHash((h) => h.delete('select')) } })
|
|
331
|
+
let pending = false
|
|
332
|
+
cy.on('zoom', () => { if (!pending) { pending = true; requestAnimationFrame(() => { pending = false; lod() }) } })
|
|
333
|
+
cy.on('mouseover', 'node', (ev) => { hover = ev.target.id(); showTip(ev); lod() })
|
|
334
|
+
cy.on('mousemove', 'node', (ev) => moveTip(ev))
|
|
335
|
+
cy.on('mouseout', 'node', () => { hover = null; $('tip').hidden = true; lod() })
|
|
336
|
+
$('cy').tabIndex = 0
|
|
337
|
+
window.__cgCy = cy // read by tools/shoot.mjs (label sizes, overlap, item counts)
|
|
338
|
+
} else {
|
|
339
|
+
const prev = keepView ? new Map(cy.nodes().map((n) => [n.id(), { ...n.position() }])) : null
|
|
340
|
+
// opening / folding a cluster can change the layer gap: the cluster the user clicked stays where it was on screen
|
|
341
|
+
const a = keepView && anchorId && cy.getElementById(anchorId)
|
|
342
|
+
pinned = a && a.length ? { id: anchorId, at: { ...a.renderedPosition() } } : null
|
|
343
|
+
cy.elements().remove(); cy.add(els)
|
|
344
|
+
if (prev && layoutKind() !== 'layered') cy.nodes().forEach((n) => { const p = prev.get(n.id()); if (p) n.position(p) })
|
|
345
|
+
incremental = !!prev && layoutKind() !== 'layered'
|
|
346
|
+
}
|
|
347
|
+
const t0 = performance.now()
|
|
348
|
+
const l = cy.layout(layoutOpts(incremental)); incremental = false
|
|
349
|
+
l.one('layoutstop', () => {
|
|
350
|
+
window.__cgLayoutMs = performance.now() - t0
|
|
351
|
+
if (!keepView) initialFit()
|
|
352
|
+
else if (pinned) { const n = cy.getElementById(pinned.id); if (n.length) { const r = n.renderedPosition(); cy.panBy({ x: pinned.at.x - r.x, y: pinned.at.y - r.y }) } }
|
|
353
|
+
pinned = null; lod(); document.body.dataset.ready = '1'
|
|
354
|
+
})
|
|
355
|
+
document.body.dataset.ready = '0'
|
|
356
|
+
l.run()
|
|
357
|
+
applyConf()
|
|
358
|
+
if (data.meta.mode !== 'plan') legend()
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// legend chips: hide / show the edges whose strongest evidence is resolved or heuristic, without a refetch
|
|
362
|
+
function applyConf () {
|
|
363
|
+
if (cy) cy.edges().forEach((e) => { e.toggleClass('confhide', hiddenConf.has(e.data('conf'))) })
|
|
364
|
+
status()
|
|
365
|
+
for (const b of document.querySelectorAll('#legend .chip')) b.setAttribute('aria-pressed', String(!hiddenConf.has(b.dataset.conf)))
|
|
366
|
+
}
|
|
367
|
+
function toggleConf (c) { if (hiddenConf.has(c)) hiddenConf.delete(c); else hiddenConf.add(c); applyConf() }
|
|
368
|
+
|
|
369
|
+
function initialFit () {
|
|
370
|
+
// the first view always fits every node (#82: no column cut off at the canvas edge). When that leaves leaf labels
|
|
371
|
+
// under 11 px they hide (the target, entries and the hovered neighbourhood keep theirs) and a "readable zoom"
|
|
372
|
+
// button zooms to the target and the layer next to it instead
|
|
373
|
+
const btn = $('fitall'); btn.hidden = true
|
|
374
|
+
cy.fit(undefined, 30)
|
|
375
|
+
if (cy.zoom() > MAX_FIT_ZOOM) { cy.zoom(MAX_FIT_ZOOM); cy.center() } // a handful of nodes is not blown up to poster size
|
|
376
|
+
if (cy.zoom() >= (MIN_PX + 0.5) / LEAF_FS || cy.nodes().length <= 1) return
|
|
377
|
+
btn.textContent = 'readable zoom'; btn.title = 'zoom to the target and its neighbours at legible label size (f fits all again)'
|
|
378
|
+
btn.hidden = false
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function focusCore () {
|
|
382
|
+
// the target and the layer next to it at >= 11 px; a target at the edge of the drawing (impact: right,
|
|
383
|
+
// downstream: left) stays at that edge of the canvas
|
|
384
|
+
const need = (MIN_PX + 0.5) / LEAF_FS
|
|
385
|
+
const tg = cy.nodes('.target')
|
|
386
|
+
const core = tg.length ? tg.closedNeighborhood().nodes() : cy.nodes('.entry')
|
|
387
|
+
if (!core.length) return
|
|
388
|
+
cy.fit(core, 40)
|
|
389
|
+
cy.zoom(Math.min(1.0, Math.max(need, cy.zoom()))); cy.center(core)
|
|
390
|
+
const all = cy.nodes().boundingBox(); const bb = core.boundingBox(); const z = cy.zoom(); const W = cy.width(); const pan = cy.pan()
|
|
391
|
+
const tx = tg.length ? tg.boundingBox() : bb
|
|
392
|
+
if (tx.x2 >= all.x2 - 1) cy.pan({ x: W - 40 - bb.x2 * z, y: pan.y })
|
|
393
|
+
else if (tx.x1 <= all.x1 + 1) cy.pan({ x: 40 - bb.x1 * z, y: pan.y })
|
|
394
|
+
lod()
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function lod () {
|
|
398
|
+
// level of detail: leaves below 11 rendered px hide (min-zoomed-font-size); these keep a readable label
|
|
399
|
+
if (!cy) return
|
|
400
|
+
const z = cy.zoom()
|
|
401
|
+
// entry-point labels stay readable while zooming out until they would grow into their neighbours (z < 0.8)
|
|
402
|
+
const keep = cy.nodes(z >= 0.8 ? '.target, .entry, :selected' : '.target, :selected')
|
|
403
|
+
let k = keep
|
|
404
|
+
if (hover) { const h = cy.getElementById(hover); k = k.union(h).union(h.neighborhood().nodes()) }
|
|
405
|
+
const sel = cy.nodes(':selected'); if (sel.length) k = k.union(sel.neighborhood().nodes())
|
|
406
|
+
cy.batch(() => {
|
|
407
|
+
cy.nodes('.n').forEach((n) => {
|
|
408
|
+
if (k.has(n)) { const base = n.hasClass('target') ? 14 : LEAF_FS; n.style('font-size', Math.min(40, Math.max(base, (MIN_PX + 0.5) / z))) } else n.removeStyle('font-size')
|
|
409
|
+
})
|
|
410
|
+
// cluster / module labels grow to stay readable while zooming out, until they would outgrow their 130 px wrap
|
|
411
|
+
// and run into the next column (z < 0.8); below that they hide like leaf labels
|
|
412
|
+
cy.nodes('.cluster, .module, .collapsed').forEach((n) => n.style(z >= 0.8 ? { 'font-size': Math.min(60, Math.max(13, 12.5 / z)), 'min-zoomed-font-size': 0 } : { 'font-size': 13, 'min-zoomed-font-size': MIN_PX }))
|
|
413
|
+
})
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function tipHtml (el) {
|
|
417
|
+
if (el.hasClass('cluster')) {
|
|
418
|
+
const u = lay && lay.units.find((x) => x.id === el.id())
|
|
419
|
+
return `<b>${esc(el.data('label').split('\n')[0].replace(/\u200b/g, ''))}</b><div>${u ? u.count : ''} nodes · click to ${u && u.open ? (u.shown < u.count ? 'show 20 more' : 'fold') : 'open'}</div>`
|
|
420
|
+
}
|
|
421
|
+
if (el.hasClass('module') || el.hasClass('collapsed')) {
|
|
422
|
+
const g = groupsById()[el.data('gid')]; return `<b>${esc(g ? g.label : el.id())}</b><div>${g ? g.count : ''} nodes</div>`
|
|
423
|
+
}
|
|
424
|
+
const n = byId[el.id()] || { id: el.id(), kind: el.data('kind') }
|
|
425
|
+
return `<b>${esc(n.fqn || n.name || n.id)}</b><div>${esc(n.kind)}${n.entry_kind ? ' · entry: ' + esc(ENTRY_LABEL[n.entry_kind] || n.entry_kind) : ''}` +
|
|
426
|
+
`${n.depth != null ? ' · depth ' + n.depth : ''}${n.path_confidence ? ' · path ' + esc(n.path_confidence) : ''}</div>` +
|
|
427
|
+
(n.file ? `<div class="loc">${esc(n.file)}${n.line ? ':' + n.line : ''}</div>` : '') + (n.group ? `<div>${esc((groupsById()[n.group] || {}).label || '')}</div>` : '')
|
|
428
|
+
}
|
|
429
|
+
function showTip (ev) { const t = $('tip'); t.innerHTML = tipHtml(ev.target); t.hidden = false; moveTip(ev) }
|
|
430
|
+
function moveTip (ev) {
|
|
431
|
+
const t = $('tip'); const oe = ev.originalEvent; if (!oe || t.hidden) return
|
|
432
|
+
const x = Math.min(window.innerWidth - t.offsetWidth - 8, oe.clientX + 14); const y = Math.min(window.innerHeight - t.offsetHeight - 8, oe.clientY + 14)
|
|
433
|
+
t.style.left = x + 'px'; t.style.top = y + 'px'
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function status () {
|
|
437
|
+
const m = data.meta; const nodes = data.nodes
|
|
438
|
+
$('cy').setAttribute('aria-label', `${m.mode || 'graph'} ${(m.specs || []).join(', ')}: ${nodes.length} nodes, ${data.edges.length} evidence edges, ` +
|
|
439
|
+
`${nodes.filter((n) => n.entry_kind).length} entry points. Press L for a list view, ? for keyboard shortcuts.`)
|
|
440
|
+
$('truncbanner').hidden = !m.truncated
|
|
441
|
+
if ($('listview') && !$('listview').hidden) renderList()
|
|
442
|
+
const ent = nodes.filter((n) => n.entry_kind).length; const g = nodes.filter(gated).length
|
|
443
|
+
const byk = {}; for (const n of nodes) if (n.entry_kind) byk[n.entry_kind] = (byk[n.entry_kind] || 0) + 1
|
|
444
|
+
if (m.mode === 'plan') {
|
|
445
|
+
const s = m.summary || {}
|
|
446
|
+
$('status').innerHTML = `<b>plan</b> ${esc(m.plan)}${m.verify ? ' <b>[verify]</b>' : ''} · ${esc(m.title || '')}<br>` +
|
|
447
|
+
`<span style="color:#a3004f"><b>${s.missing_from_plan}</b> missing from plan</span> · ${s.review} review · ${s.covered} covered · ` +
|
|
448
|
+
`<span style="color:#b01e1e">${s.forbidden_paths_present} forbidden path(s) present</span> · ${s.open_findings_touching} open findings (${s.unlinked_open_findings} not linked) · ` +
|
|
449
|
+
`refs ${s.references - s.unresolved}/${s.references} resolve` + (s.verify ? ` · verify ${esc(JSON.stringify(s.verify))}` : '')
|
|
450
|
+
return
|
|
451
|
+
}
|
|
452
|
+
$('status').innerHTML = `<b>${esc(m.mode)}</b> ${esc((m.specs || []).join(', '))}` + (m.sinks ? ` · sinks ${esc(m.sinks.join(','))}` : '') +
|
|
453
|
+
` · ${nodes.length} nodes, ${evidenceCount()}, ` + (lay ? `${lay.units.filter((u) => !u.lane).length} items (${lay.units.filter((u) => u.type === 'cluster').length} clusters, ${open.size} open)`
|
|
454
|
+
: `${data.groups.length} modules (${collapsed.size} folded)`) +
|
|
455
|
+
` · entry points ${ent}` + (ent ? ' (' + Object.entries(byk).map(([k, v]) => `${ENTRY_LABEL[k] || k} ${v}`).join(', ') + ')' : '') +
|
|
456
|
+
(m.gate ? ` · gate <b>${esc(m.gate)}</b>: ${g} gated` : '') + (m.truncated ? ' · <b>truncated</b>' : '') +
|
|
457
|
+
(data.title ? ` · <i>${esc(data.title)}</i>` : '')
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function evidenceCount () {
|
|
461
|
+
const n = data.edges.length
|
|
462
|
+
if (!hiddenConf.size) return `${n} evidence edges`
|
|
463
|
+
const shown = data.edges.filter((e) => !hiddenConf.has(e.confidence)).length
|
|
464
|
+
return `${shown} of ${n} evidence edges shown (${[...hiddenConf].join(', ')} hidden)`
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function legend () {
|
|
468
|
+
if (data.meta.mode === 'plan') {
|
|
469
|
+
$('legend').innerHTML = '<span><span class="sw" style="background:#2e9d57;border:2px dashed #1b7a3d"></span>+ planned new</span>' +
|
|
470
|
+
'<span><span class="sw" style="border:3px solid #c77c00;background:#fff"></span>~ planned modification (hexagon = guard)</span>' +
|
|
471
|
+
'<span><span class="sw" style="background:#ff2e88;opacity:.6;border-radius:6px"></span>! uncovered: missing from plan</span>' +
|
|
472
|
+
'<span><span class="sw" style="border:2px dotted #b06ab3;background:#fff"></span>review</span>' +
|
|
473
|
+
'<span><span class="sw" style="border:2px solid #2e9d57;background:#fff"></span>covered</span>' +
|
|
474
|
+
'<span><span class="sw" style="border:3px double #d62828;background:#fff"></span>✕ forbidden target</span>' +
|
|
475
|
+
'<span><span class="ln" style="border-top:3px dashed #2e9d57"></span>planned edge</span>' +
|
|
476
|
+
'<span><span class="ln" style="border-top:4px solid #d62828"></span>⊣ forbidden path (still in code)</span>' +
|
|
477
|
+
'<span><span class="ln" style="border-top:3px dotted #ff2e88"></span>check relation (not a code edge)</span>' +
|
|
478
|
+
'<span><span class="ln" style="border-top:2px dashed #8e6bbf"></span>filed issue touches (red border = not linked in plan)</span>' +
|
|
479
|
+
'<span>grey edges = real indexed evidence (file:line)</span>'
|
|
480
|
+
return
|
|
481
|
+
}
|
|
482
|
+
// only the encodings present in this view, kinds with counts (#82 item 8); a kind chip dims everything else
|
|
483
|
+
const used = {}; for (const n of data.nodes) used[n.kind] = (used[n.kind] || 0) + 1
|
|
484
|
+
let h = Object.entries(used).sort((a, b) => b[1] - a[1]).map(([k, cnt0]) =>
|
|
485
|
+
`<button type="button" class="chip kchip" data-kind="${esc(k)}" aria-pressed="${kindHl === k}" title="highlight ${esc(k)} nodes">` +
|
|
486
|
+
`<span class="sw" style="background:${KIND_COLOR[k] || '#6b7280'}"></span>${esc(k)} ${cnt0}</button>`).join('')
|
|
487
|
+
const has = (sel) => cy && cy.elements(sel).length > 0
|
|
488
|
+
if (data.nodes.some((n) => n.entry_kind)) h += '<span>◆ entry point</span>'
|
|
489
|
+
if (data.nodes.some((n) => n.is_target)) h += '<span>★ target</span>'
|
|
490
|
+
if (data.nodes.some(gated)) h += `<span><span class="sw" style="border:2px dashed ${c(GATED)};background:${c('#fff')}"></span>gated node</span>`
|
|
491
|
+
const cnt = {}; for (const e of data.edges) cnt[e.confidence] = (cnt[e.confidence] || 0) + 1
|
|
492
|
+
h += Object.entries(CONF_EDGE).filter(([cf]) => cnt[cf]).map(([cf, v]) => {
|
|
493
|
+
const sample = `<span class="ln" style="border-top:${v.width + 0.5}px ${v.style} ${c(v.color)}"></span>${cf} ${cnt[cf] || 0}`
|
|
494
|
+
return cf === 'exact' ? `<span>${sample}</span>`
|
|
495
|
+
: `<button type="button" class="chip" data-conf="${cf}" aria-pressed="${!hiddenConf.has(cf)}" title="show / hide ${cf} edges">${sample}</button>`
|
|
496
|
+
}).join('')
|
|
497
|
+
if (has('edge.gated')) h += `<span><span class="ln" style="border-top:2px dashed ${c(GATED)}"></span>gated edge</span>`
|
|
498
|
+
if (has('edge.partgated')) h += `<span><span class="ln" style="border-top:2px solid ${c(PARTGATED)}"></span>partly gated</span>`
|
|
499
|
+
const boxes = []
|
|
500
|
+
if (has('node.cluster')) boxes.push('rounded box = caller cluster with its count (click to open, again for 20 more, Esc folds); a node\'s second line is its module')
|
|
501
|
+
if (has('node.module')) boxes.push('framed box = module (double-click to fold)')
|
|
502
|
+
if (has('node.collapsed')) boxes.push('filled box = folded module (click to open)')
|
|
503
|
+
if (has('edge.multi')) boxes.push('numbers on edges = folded evidence edges')
|
|
504
|
+
if (boxes.length) h += '<span>' + boxes.join('; ') + '</span>'
|
|
505
|
+
$('legend').innerHTML = h
|
|
506
|
+
for (const b of document.querySelectorAll('#legend .chip[data-conf]')) b.addEventListener('click', () => toggleConf(b.dataset.conf))
|
|
507
|
+
for (const b of document.querySelectorAll('#legend .kchip')) b.addEventListener('click', () => highlightKind(b.dataset.kind))
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// a kind chip: that kind's nodes (and the clusters / folded modules holding one) stay, the rest dims
|
|
511
|
+
let kindHl = null
|
|
512
|
+
function highlightKind (k) {
|
|
513
|
+
const again = kindHl === k
|
|
514
|
+
clearHl()
|
|
515
|
+
if (!again && cy) {
|
|
516
|
+
kindHl = k
|
|
517
|
+
const keep = cy.nodes().filter((n) => n.data('kind') === k || (n.data('kinds') && n.data('kinds')[k]))
|
|
518
|
+
cy.elements().not(keep).not(keep.ancestors()).addClass('dim')
|
|
519
|
+
keep.addClass('hl')
|
|
520
|
+
}
|
|
521
|
+
for (const b of document.querySelectorAll('#legend .kchip')) b.setAttribute('aria-pressed', String(b.dataset.kind === kindHl))
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function clearHl () {
|
|
525
|
+
kindHl = null
|
|
526
|
+
for (const b of document.querySelectorAll('#legend .kchip')) b.setAttribute('aria-pressed', 'false')
|
|
527
|
+
if (cy) cy.elements().removeClass('dim hl')
|
|
528
|
+
}
|
|
529
|
+
function highlightFrom (id) {
|
|
530
|
+
clearHl()
|
|
531
|
+
// evidence paths go from dependents towards targets/sinks: follow outgoing edges
|
|
532
|
+
const start = cy.getElementById(id); if (!start.length) return
|
|
533
|
+
const seen = start.union(start.successors()).union(start.predecessors())
|
|
534
|
+
cy.elements().not(seen).not(seen.ancestors()).addClass('dim')
|
|
535
|
+
seen.addClass('hl')
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// shareable URLs (#82 item 11): the hash carries the query plus select / expand / layout, written on interaction;
|
|
539
|
+
// a new query is a history entry (Back returns to the previous one), view changes replace the current entry
|
|
540
|
+
function setHash (mut, push) {
|
|
541
|
+
const h = new URLSearchParams(location.hash.slice(1)); mut(h)
|
|
542
|
+
const s = '#' + h.toString(); if (s === location.hash) return
|
|
543
|
+
if (push) history.pushState(null, '', s); else history.replaceState(null, '', s)
|
|
544
|
+
}
|
|
545
|
+
function sameQuery (a, b) {
|
|
546
|
+
return ['mode', 'sinks', 'min_conf'].every((k) => a.get(k) === b.get(k)) && a.getAll('spec').join('\n') === b.getAll('spec').join('\n')
|
|
547
|
+
}
|
|
548
|
+
function copyLink () {
|
|
549
|
+
const url = location.href; const btn = $('copylink')
|
|
550
|
+
const done = (ok) => { btn.textContent = ok ? 'copied' : 'copy failed'; setTimeout(() => { btn.textContent = 'copy link' }, 1500) }
|
|
551
|
+
if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(url).then(() => done(true), () => done(false)); return }
|
|
552
|
+
const t = document.createElement('textarea'); t.value = url; document.body.appendChild(t); t.select()
|
|
553
|
+
let ok = false; try { ok = document.execCommand('copy') } catch (e) { ok = false }
|
|
554
|
+
t.remove(); done(ok)
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function saveExpand () {
|
|
558
|
+
const h = new URLSearchParams(location.hash.slice(1))
|
|
559
|
+
const v = lay ? [...open].map(([id, n]) => (n === 20 ? id : id + '~' + n)) : []
|
|
560
|
+
if (v.length) h.set('expand', v.join('|')); else h.delete('expand')
|
|
561
|
+
history.replaceState(null, '', '#' + h.toString())
|
|
562
|
+
}
|
|
563
|
+
function applyExpand (list) {
|
|
564
|
+
for (const x of list) {
|
|
565
|
+
if (x.startsWith('C:')) { const [id, n] = x.split('~'); open.set(id, Number(n) || 20); continue }
|
|
566
|
+
collapsed.delete(x) // a module id (clustered layout); in the layered layout: the clusters holding its nodes
|
|
567
|
+
if (layoutKind() === 'layered') {
|
|
568
|
+
const b = L.build(data, { open, flat })
|
|
569
|
+
for (const u of b.units) if (u.type === 'cluster' && u.members.some((m) => (byId[m] || {}).group === x)) open.set(u.id, Math.max(open.get(u.id) || 0, Math.min(u.count, 20)))
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
// keyboard (#82 item 10): / search, f fit, + - zoom, 0 reset, arrows move the selection along the edges
|
|
574
|
+
// (← caller, → callee; ↑ ↓ within the column), Enter / Space opens a cluster or shows a node, ? help
|
|
575
|
+
// list view (#82 item 14): the same subgraph as a tree by depth / layer, for screen readers and keyboard use
|
|
576
|
+
function renderList () {
|
|
577
|
+
const lv = $('listview'); if (!data) return
|
|
578
|
+
const lyr = L.layers(data); const by = new Map()
|
|
579
|
+
for (const n of data.nodes) { const k = n.depth != null ? n.depth : lyr.get(n.id); if (!by.has(k)) by.set(k, []); by.get(k).push(n) }
|
|
580
|
+
const word = data.meta.mode === 'impact' ? 'depth' : 'layer'
|
|
581
|
+
lv.innerHTML = `<b>${esc(data.meta.mode)} ${esc((data.meta.specs || []).join(', '))}</b> · ${data.nodes.length} nodes` +
|
|
582
|
+
[...by.keys()].sort((a, b) => a - b).map((k) => `<h3>${word} ${k} (${by.get(k).length})</h3><ul role="group">` +
|
|
583
|
+
by.get(k).sort((a, b) => shortName(a).localeCompare(shortName(b))).map((n) => `<li role="treeitem"><a tabindex="0" data-id="${esc(n.id)}">${esc(shortName(n))}</a>` +
|
|
584
|
+
`<span class="k">${esc(n.kind)}${n.entry_kind ? ' · ' + esc(ENTRY_LABEL[n.entry_kind] || n.entry_kind) : ''}${n.is_target ? ' · target' : ''} · ${esc(n.file || '')}</span></li>`).join('') + '</ul>').join('')
|
|
585
|
+
for (const a of lv.querySelectorAll('a[data-id]')) {
|
|
586
|
+
a.onclick = () => showNode(a.dataset.id)
|
|
587
|
+
a.onkeydown = (e) => { if (e.key === 'Enter') { showNode(a.dataset.id); e.stopPropagation() } }
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
function toggleList () { const lv = $('listview'); lv.hidden = !lv.hidden; $('listbtn').setAttribute('aria-pressed', String(!lv.hidden)); if (!lv.hidden) { renderList(); const a = lv.querySelector('a'); if (a) a.focus() } }
|
|
591
|
+
|
|
592
|
+
function keyNav (e) {
|
|
593
|
+
const k = e.key
|
|
594
|
+
if ((k === 'Enter' || k === ' ') && /^(BUTTON|A|SUMMARY)$/.test((e.target && e.target.tagName) || '')) return
|
|
595
|
+
if (k === '/') { const ls = $('lsearch'); const t = ls && ls.offsetParent ? ls : $('spec'); t.focus(); t.select(); e.preventDefault(); return }
|
|
596
|
+
if (k === '?') { $('help').hidden = !$('help').hidden; e.preventDefault(); return }
|
|
597
|
+
if (k === 'l' || k === 'L') { if (data) toggleList(); e.preventDefault(); return }
|
|
598
|
+
if (!cy || !data) return
|
|
599
|
+
const centre = { x: cy.width() / 2, y: cy.height() / 2 }
|
|
600
|
+
if (k === 'f') { initialFit(); lod() }
|
|
601
|
+
else if (k === '+' || k === '=') cy.zoom({ level: Math.min(cy.maxZoom(), cy.zoom() * 1.25), renderedPosition: centre })
|
|
602
|
+
else if (k === '-' || k === '_') cy.zoom({ level: Math.max(cy.minZoom(), cy.zoom() / 1.25), renderedPosition: centre })
|
|
603
|
+
else if (k === '0') initialFit()
|
|
604
|
+
else if (k.startsWith('Arrow')) moveSel(k.slice(5).toLowerCase())
|
|
605
|
+
else if (k === 'Enter' || k === ' ') {
|
|
606
|
+
const cur = cy.$('node:selected')[0]; if (!cur) return
|
|
607
|
+
if (cur.hasClass('cluster') || cur.hasClass('collapsed')) {
|
|
608
|
+
const cid = cur.id(); onTap(cur); const el = cy.getElementById(cid); if (el.length) el.select() // keep the place
|
|
609
|
+
} else { highlightFrom(cur.id()); showNode(cur.id()) }
|
|
610
|
+
} else return
|
|
611
|
+
e.preventDefault()
|
|
612
|
+
}
|
|
613
|
+
function selectEl (el) {
|
|
614
|
+
cy.$(':selected').unselect(); el.select()
|
|
615
|
+
const bb = el.renderedBoundingBox(); const ext = { w: cy.width(), h: cy.height() }
|
|
616
|
+
if (bb.x1 < 0 || bb.y1 < 0 || bb.x2 > ext.w || bb.y2 > ext.h) cy.center(el)
|
|
617
|
+
if (el.hasClass('n')) { highlightFrom(el.id()); showNode(el.id()) } else { clearHl(); el.addClass('hl') }
|
|
618
|
+
$('cy').focus()
|
|
619
|
+
}
|
|
620
|
+
function moveSel (dir) {
|
|
621
|
+
const nodes = cy.nodes().filter((n) => !n.isParent() && n.visible())
|
|
622
|
+
let cur = cy.$('node:selected')[0]
|
|
623
|
+
if (!cur) { cur = nodes.filter('.target')[0] || nodes[0]; if (cur) selectEl(cur); return }
|
|
624
|
+
const p = cur.position(); let cand
|
|
625
|
+
const lane = lay && cur.hasClass('cluster') && cur.hasClass('open') && dir === 'right'
|
|
626
|
+
? nodes.filter((n) => lay.units.some((u) => u.id === n.id() && u.lane === cur.id())) : null
|
|
627
|
+
if (lane && lane.length) cand = lane.sort((a, b) => a.position('y') - b.position('y'))
|
|
628
|
+
else if (dir === 'left' && lay && cur.hasClass('lane')) { // from a member back to its cluster
|
|
629
|
+
const u = lay.units.find((x) => x.id === cur.id()); cand = u ? cy.getElementById(u.lane) : cy.collection()
|
|
630
|
+
} else if (dir === 'left' || dir === 'right') {
|
|
631
|
+
cand = (dir === 'left' ? cur.incomers('node') : cur.outgoers('node')).filter((n) => n.visible())
|
|
632
|
+
if (!cand.length) cand = nodes.filter((n) => (dir === 'left' ? n.position('x') < p.x - 20 : n.position('x') > p.x + 20))
|
|
633
|
+
cand = cand.sort((a, b) => Math.abs(a.position('y') - p.y) + Math.abs(a.position('x') - p.x) / 4 - Math.abs(b.position('y') - p.y) - Math.abs(b.position('x') - p.x) / 4)
|
|
634
|
+
} else {
|
|
635
|
+
cand = nodes.filter((n) => Math.abs(n.position('x') - p.x) < 20 && (dir === 'up' ? n.position('y') < p.y - 1 : n.position('y') > p.y + 1))
|
|
636
|
+
.sort((a, b) => Math.abs(a.position('y') - p.y) - Math.abs(b.position('y') - p.y))
|
|
637
|
+
}
|
|
638
|
+
if (cand.length) selectEl(cand[0])
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// export (#82 item 12): the whole drawing at 2x as PNG (on the current canvas colour), or the visible subgraph as JSON
|
|
642
|
+
function exportName (ext) {
|
|
643
|
+
const m = (data && data.meta) || {}
|
|
644
|
+
return ['cg', m.mode || 'view', ...(m.specs || [])].join('-').replace(/[^\w.-]+/g, '_').slice(0, 120) + '.' + ext
|
|
645
|
+
}
|
|
646
|
+
function download (blob, name) {
|
|
647
|
+
const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = name
|
|
648
|
+
document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(a.href); a.remove() }, 500)
|
|
649
|
+
}
|
|
650
|
+
function exportPng () {
|
|
651
|
+
if (!cy) return
|
|
652
|
+
download(cy.png({ full: true, scale: 2, bg: getComputedStyle($('cy')).backgroundColor || '#fff', output: 'blob', maxWidth: 16384, maxHeight: 16384 }), exportName('png'))
|
|
653
|
+
}
|
|
654
|
+
function exportJson () {
|
|
655
|
+
if (!cy) return
|
|
656
|
+
const vis = new Set(cy.nodes(':visible').map((n) => n.id()))
|
|
657
|
+
const nodes = data.nodes.filter((n) => vis.has(n.id) || (lay && vis.has(lay.rep.get(n.id))))
|
|
658
|
+
const ids = new Set(nodes.map((n) => n.id))
|
|
659
|
+
const out = { meta: data.meta, url: location.href, nodes, edges: data.edges.filter((e) => ids.has(e.src) && ids.has(e.dst) && !hiddenConf.has(e.confidence)) }
|
|
660
|
+
download(new Blob([JSON.stringify(out, null, 1)], { type: 'application/json' }), exportName('json'))
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function toggleCluster (id) {
|
|
664
|
+
const u = lay && lay.units.find((x) => x.id === id); if (!u) return
|
|
665
|
+
if (!u.open) open.set(id, 20); else if (u.shown < u.count) open.set(id, u.shown + 20); else open.delete(id)
|
|
666
|
+
lastCluster = open.has(id) ? id : null
|
|
667
|
+
anchorId = id; saveExpand(); render(true); anchorId = null
|
|
668
|
+
const v = lay.units.find((x) => x.id === id); if (v) showCluster(v)
|
|
669
|
+
}
|
|
670
|
+
function foldCluster (id) { if (open.delete(id)) { anchorId = id; saveExpand(); render(true); anchorId = null } lastCluster = null }
|
|
671
|
+
|
|
672
|
+
function showCluster (u) {
|
|
673
|
+
const ns = u.members.map((m) => byId[m]).filter(Boolean)
|
|
674
|
+
$('panelbody').innerHTML = `<h2>${esc(cy.getElementById(u.id).data('label').split('\n')[0].replace(/^▾ /, '').replace(/\u200b/g, ''))}</h2><div>${u.count} nodes · ${Object.entries(u.kinds).map(([k, v]) => `${k} ${v}`).join(', ')}` +
|
|
675
|
+
`${u.open ? ` · showing ${u.shown}` : ' · folded'}</div><h3>members</h3><table>` + ns.map((n) =>
|
|
676
|
+
`<tr><td class="k">${esc(n.kind)}</td><td><a data-id="${esc(n.id)}">${esc(shortName(n))}</a>${n.entry_kind ? ` <span class="badge">${esc(ENTRY_LABEL[n.entry_kind] || n.entry_kind)}</span>` : ''}` +
|
|
677
|
+
`${gated(n) ? ' <span class="badge gated">gated</span>' : ''}<div class="loc">${esc(n.file || '')}${n.line ? ':' + n.line : ''}</div></td></tr>`).join('') + '</table>'
|
|
678
|
+
wireLinks()
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function onTap (el) {
|
|
682
|
+
if (el.hasClass('cluster')) { toggleCluster(el.id()); return }
|
|
683
|
+
if (el.hasClass('collapsed')) { collapsed.delete(el.data('gid')); render(true); showGroup(el.data('gid')); return }
|
|
684
|
+
if (el.hasClass('module')) { showGroup(el.data('gid')); return }
|
|
685
|
+
highlightFrom(el.id()); showNode(el.id())
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function showGroup (gid) {
|
|
689
|
+
const g = data.groups.find((x) => x.id === gid); const ns = data.nodes.filter((n) => n.group === gid)
|
|
690
|
+
$('panelbody').innerHTML = `<h2>${esc(g.label)}</h2><div>${g.count} nodes · ${Object.entries(g.kinds).map(([k, v]) => `${k} ${v}`).join(', ')}</div>` +
|
|
691
|
+
'<h3>members</h3><table>' + ns.sort((a, b) => (a.depth || 0) - (b.depth || 0)).map((n) =>
|
|
692
|
+
`<tr><td class="k">${esc(n.kind)}</td><td><a data-id="${esc(n.id)}">${esc(shortName(n))}</a>${n.entry_kind ? ` <span class="badge">${esc(ENTRY_LABEL[n.entry_kind] || n.entry_kind)}</span>` : ''}` +
|
|
693
|
+
`${gated(n) ? ' <span class="badge gated">gated</span>' : ''}<div class="loc">${esc(n.file || '')}${n.line ? ':' + n.line : ''}</div></td></tr>`).join('') + '</table>'
|
|
694
|
+
wireLinks()
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const DB_HINT = 'DB'
|
|
698
|
+
let wrapCode = false
|
|
699
|
+
function truncMiddle (s, n) { s = String(s || ''); if (s.length <= n) return s; const k = Math.floor((n - 1) / 2); return s.slice(0, k) + '…' + s.slice(s.length - (n - 1 - k)) }
|
|
700
|
+
function nodeActions (id) {
|
|
701
|
+
const tgt = data.nodes.filter((n) => n.is_target && n.id !== id).map((n) => n.id)
|
|
702
|
+
const out = [['impact of this', 'impact', [id]], ['downstream of this', 'downstream', [id]]]
|
|
703
|
+
if (tgt.length) out.push(['path from here to the target', 'path', [id, tgt[0]]])
|
|
704
|
+
return out
|
|
705
|
+
}
|
|
706
|
+
function runAction (mode, spec) {
|
|
707
|
+
$('mode').value = mode; $('spec').value = spec
|
|
708
|
+
$('mode').dispatchEvent(new Event('change')); hideMenu(); go()
|
|
709
|
+
}
|
|
710
|
+
function copyText (txt, btn) {
|
|
711
|
+
const done = (ok) => { if (!btn) return; const t = btn.textContent; btn.textContent = ok ? 'copied' : 'copy failed'; setTimeout(() => { btn.textContent = t }, 1200) }
|
|
712
|
+
if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(txt).then(() => done(true), () => done(false)); return }
|
|
713
|
+
const t = document.createElement('textarea'); t.value = txt; document.body.appendChild(t); t.select()
|
|
714
|
+
let ok = false; try { ok = document.execCommand('copy') } catch (e) { ok = false }
|
|
715
|
+
t.remove(); done(ok)
|
|
716
|
+
}
|
|
717
|
+
function wirePanel () {
|
|
718
|
+
for (const b of $('panelbody').querySelectorAll('[data-copy]')) b.onclick = () => copyText(b.dataset.copy, b)
|
|
719
|
+
for (const b of $('panelbody').querySelectorAll('.act')) b.onclick = () => runAction(b.dataset.mode, b.dataset.spec)
|
|
720
|
+
const w = $('panelbody').querySelector('.wrapbtn')
|
|
721
|
+
if (w) w.onclick = () => { wrapCode = !wrapCode; w.setAttribute('aria-pressed', String(wrapCode)); $('panelbody').querySelector('pre.code').classList.toggle('wrap', wrapCode) }
|
|
722
|
+
}
|
|
723
|
+
// what the view shows: callers per depth, entry points by kind, gated counts (top of the target's panel)
|
|
724
|
+
function viewSummary () {
|
|
725
|
+
const m = data.meta || {}; const ns = data.nodes
|
|
726
|
+
const byD = {}; for (const n of ns) if (n.depth != null && !n.is_target) byD[n.depth] = (byD[n.depth] || 0) + 1
|
|
727
|
+
const ek = {}; for (const n of ns) if (n.entry_kind) ek[n.entry_kind] = (ek[n.entry_kind] || 0) + 1
|
|
728
|
+
const g = ns.filter(gated).length
|
|
729
|
+
let h = `<div class="summary"><h3>view: ${esc(m.mode || '')} · ${ns.length} nodes · ${data.edges.length} evidence edges</h3>`
|
|
730
|
+
if (Object.keys(byD).length) {
|
|
731
|
+
const word = m.mode === 'impact' ? 'callers' : 'nodes'
|
|
732
|
+
h += `<table class="bydepth"><tr><td class="k">depth</td>${Object.keys(byD).sort((a, b) => a - b).map((d) => `<td>${d}</td>`).join('')}</tr>` +
|
|
733
|
+
`<tr><td class="k">${word}</td>${Object.keys(byD).sort((a, b) => a - b).map((d) => `<td>${byD[d]}</td>`).join('')}</tr></table>`
|
|
734
|
+
}
|
|
735
|
+
h += '<div>entry points: ' + (Object.keys(ek).length ? Object.entries(ek).map(([k, v]) => `<span class="badge">${esc(ENTRY_LABEL[k] || k)} × ${v}</span>`).join('') : 'none') + '</div>'
|
|
736
|
+
if (m.gate || g) h += `<div>gated: ${g}${m.gate ? ` (gate ${esc(m.gate)})` : ''}</div>`
|
|
737
|
+
return h + '</div>'
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// right-click menu with the node actions
|
|
741
|
+
function hideMenu () { $('ctxmenu').hidden = true }
|
|
742
|
+
function showMenu (el, ev) {
|
|
743
|
+
if (STATIC || !el.hasClass('n')) return
|
|
744
|
+
const m = $('ctxmenu'); const p = ev.renderedPosition || { x: 0, y: 0 }; const r = $('cy').getBoundingClientRect()
|
|
745
|
+
m.innerHTML = nodeActions(el.id()).map(([lab, mode, specs]) => `<button type="button" role="menuitem" data-mode="${mode}" data-spec="${esc(specs.join(', '))}">${lab}</button>`).join('') +
|
|
746
|
+
`<button type="button" role="menuitem" data-show="1">details</button>`
|
|
747
|
+
m.style.left = (r.left + p.x) + 'px'; m.style.top = (r.top + p.y) + 'px'; m.hidden = false
|
|
748
|
+
for (const b of m.querySelectorAll('button')) b.onclick = () => { if (b.dataset.show) { hideMenu(); highlightFrom(el.id()); showNode(el.id()) } else runAction(b.dataset.mode, b.dataset.spec) }
|
|
749
|
+
m.querySelector('button').focus()
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// resizable / collapsible panel (#82 item 9): drag the left edge (or ← → on it), collapse to a rail; kept in localStorage
|
|
753
|
+
function setPanel (w, collapsed) {
|
|
754
|
+
const max = Math.round(window.innerWidth * 0.5)
|
|
755
|
+
w = Math.max(260, Math.min(max, w))
|
|
756
|
+
document.documentElement.style.setProperty('--panel', (collapsed ? 36 : w) + 'px')
|
|
757
|
+
document.body.classList.toggle('pcollapsed', !!collapsed)
|
|
758
|
+
$('phandle').setAttribute('aria-valuenow', String(collapsed ? 36 : w))
|
|
759
|
+
$('pcollapse').textContent = collapsed ? '⟨' : '⟩'; $('pcollapse').setAttribute('aria-expanded', String(!collapsed))
|
|
760
|
+
try { localStorage.setItem('cg.panel', JSON.stringify({ w, collapsed: !!collapsed })) } catch (e) { /* private mode */ }
|
|
761
|
+
if (cy) cy.resize()
|
|
762
|
+
}
|
|
763
|
+
function panelState () {
|
|
764
|
+
let st = null; try { st = JSON.parse(localStorage.getItem('cg.panel') || 'null') } catch (e) { st = null }
|
|
765
|
+
return st || { w: parseInt(getComputedStyle($('panel')).width, 10) || 360, collapsed: false }
|
|
766
|
+
}
|
|
767
|
+
function initPanel () {
|
|
768
|
+
const st0 = panelState(); if (localStorage.getItem('cg.panel')) setPanel(st0.w, st0.collapsed)
|
|
769
|
+
$('pcollapse').onclick = () => { const st = panelState(); setPanel(st.w, !st.collapsed) }
|
|
770
|
+
const h = $('phandle')
|
|
771
|
+
h.onpointerdown = (e) => {
|
|
772
|
+
h.setPointerCapture(e.pointerId)
|
|
773
|
+
const move = (ev) => setPanel(window.innerWidth - ev.clientX, false)
|
|
774
|
+
h.onpointermove = move; h.onpointerup = () => { h.onpointermove = null; h.onpointerup = null }
|
|
775
|
+
}
|
|
776
|
+
h.onkeydown = (e) => {
|
|
777
|
+
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
|
|
778
|
+
const st = panelState(); setPanel(st.w + (e.key === 'ArrowLeft' ? 30 : -30), false); e.preventDefault(); e.stopPropagation()
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function wireLinks () {
|
|
783
|
+
for (const a of $('panelbody').querySelectorAll('a[data-id]')) a.onclick = () => {
|
|
784
|
+
const id = a.dataset.id; const n = byId[id]
|
|
785
|
+
if (n && !lay && collapsed.has(n.group)) { collapsed.delete(n.group); render(true) }
|
|
786
|
+
if (n && lay && lay.rep.get(id) !== id) { // folded in a cluster: open it far enough to show the node
|
|
787
|
+
const u = lay.units.find((x) => x.id === lay.rep.get(id))
|
|
788
|
+
if (u) { open.set(u.id, Math.ceil((u.members.indexOf(id) + 1) / 20) * 20); saveExpand(); render(true) }
|
|
789
|
+
}
|
|
790
|
+
if (cy.getElementById(id).length) { cy.$(':selected').unselect(); cy.getElementById(id).select(); highlightFrom(id) }
|
|
791
|
+
showNode(id)
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
async function getDetail (id) {
|
|
796
|
+
if (STATIC) return STATIC.details[id] || null
|
|
797
|
+
const r = await fetch('/api/node?id=' + encodeURIComponent(id)); return r.ok ? r.json() : null
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
async function showNode (id) {
|
|
801
|
+
selected = id
|
|
802
|
+
if (!STATIC) setHash((h) => h.set('select', id))
|
|
803
|
+
const n = byId[id] || { id }
|
|
804
|
+
const d = await getDetail(id)
|
|
805
|
+
if (selected !== id) return
|
|
806
|
+
const k = (d && d.kind) || n.kind
|
|
807
|
+
const fqn = (d && d.fqn) || n.fqn || id
|
|
808
|
+
const file = (d && d.file) || n.file; const line = (d && d.line) || n.line
|
|
809
|
+
const spec = n.kind === 'method' || n.kind === 'function' ? fqn : id
|
|
810
|
+
const cli = `cg impact '${spec.replace(/'/g, "'\\''")}' --db ${DB_HINT}`
|
|
811
|
+
// sticky header (#82 item 9): name, kind, file:line, copy buttons, editor link, node actions
|
|
812
|
+
let h = `<div class="phead"><h2>${esc(shortName(d || n))}</h2><span class="kind" style="background:${KIND_COLOR[k] || '#6b7280'}">${esc(k)}</span>`
|
|
813
|
+
if (d && d.entry_kind) h += `<span class="badge">entry: ${esc(ENTRY_LABEL[d.entry_kind] || d.entry_kind)}</span>`
|
|
814
|
+
if (n.is_target) h += '<span class="badge">query target</span>'
|
|
815
|
+
if (file) h += `<div class="loc" title="${esc(file)}${line ? ':' + line : ''}">${esc(truncMiddle(file, 52))}${line ? ':' + line : ''}</div>`
|
|
816
|
+
h += '<div class="pacts">' + [['id', id], ['FQN', fqn], ['CLI', cli]].map(([lab, v]) =>
|
|
817
|
+
`<button type="button" class="pbtn" data-copy="${esc(v)}" title="copy ${esc(v)}">copy ${lab}</button>`).join('')
|
|
818
|
+
const abs = d && d.snippet && d.snippet.abs
|
|
819
|
+
if (abs && !STATIC) h += `<a class="pbtn" href="vscode://file/${encodeURI(abs)}:${d.snippet.line}" title="open ${esc(abs)} in VS Code">open in editor</a>`
|
|
820
|
+
if (!STATIC) {
|
|
821
|
+
h += '<br>' + nodeActions(id).map(([lab, mode, specs]) =>
|
|
822
|
+
`<button type="button" class="pbtn act" data-mode="${mode}" data-spec="${esc(specs.join(', '))}">${lab}</button>`).join('')
|
|
823
|
+
}
|
|
824
|
+
h += '</div></div>'
|
|
825
|
+
if (n.is_target) h += viewSummary()
|
|
826
|
+
if (n.plan_role) {
|
|
827
|
+
h += `<div class="planbox p-${esc(n.plan_role)}"><b>${esc(PLAN_ROLE_LABEL[n.plan_role] || n.plan_role)}</b>${n.plan_check ? ` · check <code>${esc(n.plan_check)}</code>` : ''}` +
|
|
828
|
+
(n.plan_why ? `<div>${esc(n.plan_why)}</div>` : '') + (n.plan_evidence ? `<div class="loc">${n.plan_evidence.map(esc).join('<br>')}</div>` : '') +
|
|
829
|
+
(n.plan_items && n.plan_items.length > 1 ? `<div>all checks:<br>${n.plan_items.map(esc).join('<br>')}</div>` : '') +
|
|
830
|
+
(n.plan_attrs ? `<pre>${esc(JSON.stringify(n.plan_attrs, null, 1))}</pre>` : '') + (n.plan_url ? `<div class="loc">${esc(n.plan_url)}</div>` : '') + '</div>'
|
|
831
|
+
}
|
|
832
|
+
if (n.gate_status) h += `<span class="badge ${n.gate_status === 'live' ? 'live' : 'gated'}">${esc(n.gate_status)}</span>`
|
|
833
|
+
if (n.live === false) h += '<span class="badge gated">reached only via gated edges</span>'
|
|
834
|
+
h += `<div class="loc">${esc(fqn)}</div>`
|
|
835
|
+
if (n.depth != null) h += `<div>depth ${n.depth}${n.path_confidence ? ` · path confidence <b>${esc(n.path_confidence)}</b>` : ''}${n.class ? ` · class ${esc(n.class)}` : ''}</div>`
|
|
836
|
+
const ek = (d && d.entry_kinds) || n.entry_kinds
|
|
837
|
+
if (ek && Object.keys(ek).length) {
|
|
838
|
+
h += '<h3>reached from entry kinds</h3>' + Object.entries(ek).map(([k2, v]) => `<span class="badge">${esc(ENTRY_LABEL[k2] || k2)} × ${v}</span>`).join('')
|
|
839
|
+
const lek = (d && d.live_entry_kinds) || n.live_entry_kinds
|
|
840
|
+
if (lek && d && d.gate) h += `<div>live under gate <b>${esc(d.gate)}</b>: ` + (Object.keys(lek).length ? Object.entries(lek).map(([k2, v]) => `<span class="badge live">${esc(ENTRY_LABEL[k2] || k2)} × ${v}</span>`).join('') : '<span class="badge gated">none</span>') + '</div>'
|
|
841
|
+
}
|
|
842
|
+
if (n.gate_evidence) {
|
|
843
|
+
const g = n.gate_evidence
|
|
844
|
+
h += `<h3>gate evidence</h3><pre>${esc(g.kind)} @ ${esc(g.at)}\n${esc(g.from)} -> ${esc(g.to)}\nguard: ${esc(JSON.stringify(g.guard))}</pre>`
|
|
845
|
+
}
|
|
846
|
+
if (d && d.doc) h += `<h3>docblock</h3><pre>${esc(d.doc)}</pre>`
|
|
847
|
+
if (d && d.snippet) {
|
|
848
|
+
const s = d.snippet
|
|
849
|
+
h += `<h3 class="src" title="${esc(s.file)}:${s.line}">Source <span class="loc">${esc(truncMiddle(s.file, 40))}:${s.line}</span>` +
|
|
850
|
+
`<button type="button" class="pbtn wrapbtn" aria-pressed="${wrapCode}">wrap</button></h3><pre class="code${wrapCode ? ' wrap' : ''}" tabindex="0" aria-label="source">` + s.lines.map((l, i) => {
|
|
851
|
+
const no = s.start + i; return `<span class="${no === s.line ? 'hl' : ''}">${String(no).padStart(5)} ${esc(l)}</span>`
|
|
852
|
+
}).join('\n') + '</pre>'
|
|
853
|
+
}
|
|
854
|
+
const sub = data.edges.filter((e) => e.src === id || e.dst === id)
|
|
855
|
+
if (sub.length) {
|
|
856
|
+
// grouped by direction, then confidence (strongest first), sorted by depth and name; collapsible
|
|
857
|
+
h += `<h3>evidence edges in this view: ${sub.length}</h3>`
|
|
858
|
+
for (const [lab, side] of [['callers / sources', 'in'], ['callees / targets', 'out']]) {
|
|
859
|
+
const es = sub.filter((e) => (side === 'out' ? e.src === id : e.dst === id))
|
|
860
|
+
if (!es.length) continue
|
|
861
|
+
h += `<details class="egrp" open><summary>${side === 'out' ? '→' : '←'} ${lab}: ${es.length}</summary>`
|
|
862
|
+
for (const conf of ['exact', 'resolved', 'heuristic']) {
|
|
863
|
+
const ec = es.filter((e) => (e.confidence || 'heuristic') === conf)
|
|
864
|
+
if (!ec.length) continue
|
|
865
|
+
const other = (e) => (e.src === id ? e.dst : e.src)
|
|
866
|
+
ec.sort((a, b) => ((byId[other(a)] || {}).depth ?? 99) - ((byId[other(b)] || {}).depth ?? 99) ||
|
|
867
|
+
shortName(byId[other(a)] || { id: other(a) }).localeCompare(shortName(byId[other(b)] || { id: other(b) })))
|
|
868
|
+
h += `<details class="egrp" ${ec.length <= 25 ? 'open' : ''}><summary>${conf}: ${ec.length}</summary><table>` + ec.map((e) => {
|
|
869
|
+
const o = other(e); const on = byId[o] || { id: o, kind: o.split(':')[0], name: o }
|
|
870
|
+
return `<tr><td class="k">${esc(e.kind)}${e.plan ? ` <span class="badge">${esc(e.plan)}</span>` : ''}${on.depth != null ? `<br>depth ${on.depth}` : ''}${e.gated ? ` <span class="badge gated">gated: ${esc(e.gated)}</span>` : ''}</td>` +
|
|
871
|
+
`<td><a data-id="${esc(o)}">${esc(shortName(on))}</a><div class="loc">${esc(e.at || '')}</div>${e.guard ? `<div>guard ${esc(JSON.stringify(e.guard))}</div>` : ''}</td></tr>`
|
|
872
|
+
}).join('') + '</table></details>'
|
|
873
|
+
}
|
|
874
|
+
h += '</details>'
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
if (d) {
|
|
878
|
+
for (const [lab, list, cnt] of [['outgoing (DB)', d.out_edges, d.out_count], ['incoming (DB)', d.in_edges, d.in_count]]) {
|
|
879
|
+
if (!list || !list.length) continue
|
|
880
|
+
h += `<h3>${lab}: ${cnt}${cnt > list.length ? ` (first ${list.length})` : ''}</h3><table>` + list.map((e) =>
|
|
881
|
+
`<tr><td class="k">${esc(e.kind)}<br>${esc(e.confidence)}${e.gated ? ' <span class="badge gated">gated</span>' : ''}</td><td>${esc(e.other)}<div class="loc">${esc(e.at)}</div>` +
|
|
882
|
+
(Object.keys(e.attrs || {}).length ? `<div>${esc(JSON.stringify(e.attrs)).slice(0, 300)}</div>` : '') + '</td></tr>').join('') + '</table>'
|
|
883
|
+
}
|
|
884
|
+
const a = d.attrs || {}
|
|
885
|
+
const keep = Object.fromEntries(Object.entries(a).filter(([k2]) => !['repo', 'parent'].includes(k2)))
|
|
886
|
+
if (Object.keys(keep).length) h += `<h3>attributes</h3><pre>${esc(JSON.stringify(keep, null, 1)).slice(0, 4000)}</pre>`
|
|
887
|
+
}
|
|
888
|
+
$('panelbody').innerHTML = h
|
|
889
|
+
wireLinks(); wirePanel()
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function setData (g, title) {
|
|
893
|
+
data = g; if (title) data.title = title
|
|
894
|
+
byId = {}; for (const n of data.nodes) byId[n.id] = n
|
|
895
|
+
modPrefix = commonPrefix(data.groups.map((g) => g.label))
|
|
896
|
+
open = new Map(); flat = false; lastCluster = null
|
|
897
|
+
defaultCollapse(); render(); legend()
|
|
898
|
+
if (data.report) $('panelbody').innerHTML = '<h2>plan check report</h2><div class="hint">click a node for its role, evidence and source</div><pre class="report">' + esc(data.report) + '</pre>'
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
function readHash () {
|
|
902
|
+
const p = new URLSearchParams(location.hash.slice(1))
|
|
903
|
+
if (p.get('mode')) $('mode').value = p.get('mode')
|
|
904
|
+
if (p.getAll('spec').length) $('spec').value = p.getAll('spec').join(', ')
|
|
905
|
+
if (p.has('sinks')) $('sinks').value = p.get('sinks')
|
|
906
|
+
if (p.get('min_conf')) $('minconf').value = p.get('min_conf')
|
|
907
|
+
if (p.get('layout')) $('layout').value = p.get('layout') === 'breadthfirst' ? 'layered' : p.get('layout')
|
|
908
|
+
return p
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
async function go (pushHash) {
|
|
912
|
+
const specs = $('spec').value.split(',').map((s) => s.trim()).filter(Boolean)
|
|
913
|
+
if (!specs.length) return
|
|
914
|
+
const q = new URLSearchParams(); q.set('mode', $('mode').value); for (const s of specs) q.append('spec', s)
|
|
915
|
+
if ($('mode').value === 'downstream') q.set('sinks', $('sinks').value)
|
|
916
|
+
const hp = new URLSearchParams(location.hash.slice(1)); if ($('mode').value === 'plan' && hp.get('verify') === '1') q.set('verify', '1')
|
|
917
|
+
q.set('min_conf', $('minconf').value)
|
|
918
|
+
if (pushHash !== false) {
|
|
919
|
+
const h = new URLSearchParams(q); if ($('layout').value !== 'auto') h.set('layout', $('layout').value)
|
|
920
|
+
const old = new URLSearchParams(location.hash.slice(1))
|
|
921
|
+
if (old.getAll('spec').length && !sameQuery(old, h)) history.pushState(null, '', '#' + h.toString())
|
|
922
|
+
else history.replaceState(null, '', '#' + h.toString())
|
|
923
|
+
}
|
|
924
|
+
hideLanding()
|
|
925
|
+
$('status').textContent = 'loading…'
|
|
926
|
+
const r = await fetch('/api/graph?' + q.toString()); const g = await r.json()
|
|
927
|
+
if (!r.ok) { $('status').textContent = 'error: ' + (g.error || r.status); document.body.dataset.ready = 'error'; return }
|
|
928
|
+
if (!g.nodes.length) { $('status').textContent = 'nothing matched; try search suggestions'; document.body.dataset.ready = 'empty'; return }
|
|
929
|
+
setData(g)
|
|
930
|
+
const p = new URLSearchParams(location.hash.slice(1))
|
|
931
|
+
if (p.get('expand')) { applyExpand(p.get('expand').split('|')); render() }
|
|
932
|
+
if (p.get('focus')) { // zoom to a node and its direct neighbours (e.g. the uncovered siblings around a table)
|
|
933
|
+
const f = cy.getElementById(p.get('focus'))
|
|
934
|
+
if (f.length) cy.fit(f.closedNeighborhood().union(f.closedNeighborhood().connectedNodes()), Number(p.get('pad') || 40))
|
|
935
|
+
}
|
|
936
|
+
const tgt = data.nodes.find((n) => n.is_target)
|
|
937
|
+
if (!p.get('select') && tgt && data.meta.mode !== 'plan' && !data.report) { // fill the panel with the target summary
|
|
938
|
+
if (cy.getElementById(tgt.id).length) cy.getElementById(tgt.id).select()
|
|
939
|
+
showNode(tgt.id)
|
|
940
|
+
}
|
|
941
|
+
if (p.get('select')) {
|
|
942
|
+
setTimeout(() => {
|
|
943
|
+
const id = p.get('select')
|
|
944
|
+
if (cy.getElementById(id).length && p.get('hl') !== '0') { cy.getElementById(id).select(); highlightFrom(id) }
|
|
945
|
+
showNode(id)
|
|
946
|
+
}, 50)
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
let sugT = null
|
|
951
|
+
function suggest () {
|
|
952
|
+
clearTimeout(sugT)
|
|
953
|
+
sugT = setTimeout(async () => {
|
|
954
|
+
const last = $('spec').value.split(',').pop().trim(); if (last.length < 3 || STATIC) return
|
|
955
|
+
const r = await fetch('/api/search?limit=25&q=' + encodeURIComponent(last)); const rows = await r.json()
|
|
956
|
+
$('sugg').innerHTML = rows.map((x) => `<option value="${esc(x.kind === 'method' ? x.fqn : (x.kind === 'page' ? 'page:' + x.name : x.id))}">${esc(x.kind)} ${esc(x.file || '')}</option>`).join('')
|
|
957
|
+
}, 200)
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// ---------------------------------------------------------------- landing page: overview, search, starter queries
|
|
961
|
+
const getJSON = async (u) => { const r = await fetch(u); return r.ok ? r.json() : null }
|
|
962
|
+
function hideLanding () { $('landing').hidden = true; $('legendbox').hidden = false }
|
|
963
|
+
function runPreset (p) {
|
|
964
|
+
$('mode').value = p.mode; $('spec').value = p.specs.join(', '); if (p.sinks) $('sinks').value = p.sinks.join(',')
|
|
965
|
+
$('mode').dispatchEvent(new Event('change')); go()
|
|
966
|
+
}
|
|
967
|
+
function runHit (x) {
|
|
968
|
+
$('mode').value = DEFAULT_MODE[x.kind] || 'impact'; $('spec').value = x.id
|
|
969
|
+
$('mode').dispatchEvent(new Event('change')); go()
|
|
970
|
+
}
|
|
971
|
+
async function landing () {
|
|
972
|
+
const box = $('landing'); box.hidden = false; $('legendbox').hidden = true; $('fitall').hidden = true
|
|
973
|
+
document.body.dataset.ready = '0'
|
|
974
|
+
$('status').textContent = ''
|
|
975
|
+
const [meta, st, presets] = await Promise.all([getJSON('/api/meta'), getJSON('/api/stats'), presetsP])
|
|
976
|
+
const fmt = (n) => Number(n || 0).toLocaleString('en-US')
|
|
977
|
+
const top = (o, k) => Object.entries(o || {}).slice(0, k).map(([a, b]) => `${esc(ENTRY_LABEL[a] || a)} ${fmt(b)}`).join(' · ')
|
|
978
|
+
const conf = (st && st.confidence) || {}; const tot = (conf.exact || 0) + (conf.resolved || 0) + (conf.heuristic || 0) || 1
|
|
979
|
+
const pc = (k) => Math.round(100 * (conf[k] || 0) / tot) + '%'
|
|
980
|
+
const kinds = Object.keys((st && st.node_kinds) || {})
|
|
981
|
+
const chipKinds = ['method', 'function', 'class', 'page', 'route', 'component', 'table', 'column', 'config', 'env', 'enum_case', 'constant'].filter((k) => kinds.includes(k))
|
|
982
|
+
box.innerHTML = `<h1>${esc((meta && meta.project) || 'code-graph')}</h1><div class="sub">indexed ${esc((meta && meta.indexed_at) || '?')}` +
|
|
983
|
+
`${meta && meta.db ? ' · ' + esc(String(meta.db).replace(/^.*\//, '')) : ''}</div>` +
|
|
984
|
+
(st ? `<div class="stats"><div class="stat"><div class="n">${fmt(st.nodes)}</div><div class="l">nodes</div><div class="kinds">${top(st.node_kinds, 6)}</div></div>` +
|
|
985
|
+
`<div class="stat"><div class="n">${fmt(st.edges)}</div><div class="l">edges</div><div class="kinds">exact ${pc('exact')} · resolved ${pc('resolved')} · heuristic ${pc('heuristic')}</div></div>` +
|
|
986
|
+
`<div class="stat"><div class="n">${fmt(st.entry_points)}</div><div class="l">entry points</div><div class="kinds">${top(st.entry_kinds, 5) || 'none'}</div></div></div>` : '') +
|
|
987
|
+
'<div class="search"><input id="lsearch" type="search" placeholder="Search a symbol, table, route… (↑ ↓ Enter)" autocomplete="off" aria-label="search the graph" aria-controls="lhits"></div>' +
|
|
988
|
+
`<div class="chips" role="group" aria-label="kind">${['all'].concat(chipKinds).map((k) => `<button class="chip" data-kind="${k === 'all' ? '' : k}" aria-pressed="${k === 'all'}">${k}</button>`).join('')}</div>` +
|
|
989
|
+
'<ul class="hits" id="lhits" role="listbox"></ul>' +
|
|
990
|
+
'<h2>Starter queries</h2>' + ((presets || []).length ? '<div class="cards">' + presets.map((p, i) =>
|
|
991
|
+
`<button class="card" data-i="${i}"><div class="t">${esc(p.label)}</div>${p.why ? `<div class="w">${esc(p.why)}</div>` : ''}` +
|
|
992
|
+
`<code>${esc(p.cli || (p.mode + ' ' + p.specs.join(', ')))}</code></button>`).join('') + '</div>' : '<div class="sub">no starter queries for this graph; search a node above</div>')
|
|
993
|
+
for (const c of box.querySelectorAll('.card')) c.onclick = () => runPreset(presets[Number(c.dataset.i)])
|
|
994
|
+
let kind = ''; let hits = []; let cur = -1; let t = null
|
|
995
|
+
const inp = $('lsearch')
|
|
996
|
+
const paint = () => {
|
|
997
|
+
$('lhits').innerHTML = hits.map((x, i) => `<li role="option" data-i="${i}" aria-selected="${i === cur}"><span class="k">${esc(x.kind)}</span>` +
|
|
998
|
+
`<span class="nm">${esc(shortName(x))}</span><span class="f">${esc(x.file || '')}${x.line ? ':' + x.line : ''}</span>` +
|
|
999
|
+
`<span class="q">↵ ${esc(DEFAULT_MODE[x.kind] || 'impact')}</span></li>`).join('')
|
|
1000
|
+
for (const li of $('lhits').querySelectorAll('li')) li.onclick = () => runHit(hits[Number(li.dataset.i)])
|
|
1001
|
+
}
|
|
1002
|
+
const search = () => {
|
|
1003
|
+
clearTimeout(t)
|
|
1004
|
+
t = setTimeout(async () => {
|
|
1005
|
+
const q = inp.value.trim(); if (q.length < 2) { hits = []; cur = -1; paint(); return }
|
|
1006
|
+
hits = (await getJSON('/api/search?fuzzy=1&limit=12&q=' + encodeURIComponent(q) + (kind ? '&kind=' + kind : ''))) || []; cur = hits.length ? 0 : -1; paint()
|
|
1007
|
+
}, 150)
|
|
1008
|
+
}
|
|
1009
|
+
inp.oninput = search
|
|
1010
|
+
inp.onkeydown = (e) => {
|
|
1011
|
+
if (e.key === 'ArrowDown' && hits.length) { cur = (cur + 1) % hits.length; paint(); e.preventDefault() }
|
|
1012
|
+
if (e.key === 'ArrowUp' && hits.length) { cur = (cur - 1 + hits.length) % hits.length; paint(); e.preventDefault() }
|
|
1013
|
+
if (e.key === 'Enter' && hits[cur]) runHit(hits[cur])
|
|
1014
|
+
}
|
|
1015
|
+
for (const c of box.querySelectorAll('.chip')) {
|
|
1016
|
+
c.onclick = () => {
|
|
1017
|
+
kind = c.dataset.kind
|
|
1018
|
+
for (const o of box.querySelectorAll('.chip')) o.setAttribute('aria-pressed', String(o === c))
|
|
1019
|
+
search(); inp.focus()
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
inp.focus()
|
|
1023
|
+
document.body.dataset.ready = 'landing'
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
async function init () {
|
|
1027
|
+
$('collapse').onclick = () => {
|
|
1028
|
+
if (lay) { open.clear(); flat = false; saveExpand() } else for (const g of data.groups) if (!g.targets) collapsed.add(g.id)
|
|
1029
|
+
render()
|
|
1030
|
+
}
|
|
1031
|
+
$('expand').onclick = () => { if (layoutKind() === 'layered') { open.clear(); flat = true } else collapsed.clear(); render() }
|
|
1032
|
+
$('fit').onclick = () => { if (cy) { initialFit(); lod() } }
|
|
1033
|
+
$('fitall').onclick = () => { if (cy) focusCore() }
|
|
1034
|
+
$('layout').onchange = () => {
|
|
1035
|
+
if (!STATIC) setHash((h) => { if ($('layout').value === 'auto') h.delete('layout'); else h.set('layout', $('layout').value); h.delete('expand') })
|
|
1036
|
+
if (data) { open.clear(); flat = false; render() }
|
|
1037
|
+
}
|
|
1038
|
+
$('copylink').onclick = copyLink
|
|
1039
|
+
$('listbtn').onclick = toggleList
|
|
1040
|
+
$('theme').onclick = () => {
|
|
1041
|
+
themePref = { auto: 'light', light: 'dark', dark: 'auto' }[themePref] || 'auto'
|
|
1042
|
+
try { localStorage.setItem('cg.theme', themePref) } catch (e) { /* private mode */ }
|
|
1043
|
+
applyTheme()
|
|
1044
|
+
}
|
|
1045
|
+
if (darkMq && darkMq.addEventListener) darkMq.addEventListener('change', () => { if (themePref === 'auto') applyTheme() })
|
|
1046
|
+
applyTheme()
|
|
1047
|
+
initPanel()
|
|
1048
|
+
$('exportpng').onclick = exportPng; $('exportjson').onclick = exportJson
|
|
1049
|
+
$('helpclose').onclick = () => { $('help').hidden = true }
|
|
1050
|
+
$('more').onclick = (e) => { const g = $('viewgrp'); const o = g.classList.toggle('open'); $('more').setAttribute('aria-expanded', String(o)); e.stopPropagation() }
|
|
1051
|
+
document.addEventListener('click', (e) => { if (!$('viewgrp').contains(e.target)) { $('viewgrp').classList.remove('open'); $('more').setAttribute('aria-expanded', 'false') } })
|
|
1052
|
+
document.addEventListener('keydown', (e) => {
|
|
1053
|
+
const typing = /^(INPUT|SELECT|TEXTAREA)$/.test((e.target && e.target.tagName) || '')
|
|
1054
|
+
if (e.key === 'Escape') {
|
|
1055
|
+
$('viewgrp').classList.remove('open')
|
|
1056
|
+
if (typing) return
|
|
1057
|
+
if (lastCluster) foldCluster(lastCluster); else clearHl()
|
|
1058
|
+
if (!$('help').hidden) $('help').hidden = true
|
|
1059
|
+
hideMenu()
|
|
1060
|
+
} else if (e.key === 'Backspace' && !typing && lastCluster) { foldCluster(lastCluster); e.preventDefault() }
|
|
1061
|
+
else if (!typing && !e.ctrlKey && !e.metaKey && !e.altKey) keyNav(e)
|
|
1062
|
+
})
|
|
1063
|
+
const sinkVis = () => { $('sinks').style.display = $('mode').value === 'downstream' ? '' : 'none' }
|
|
1064
|
+
$('mode').addEventListener('change', sinkVis); sinkVis()
|
|
1065
|
+
if (STATIC) {
|
|
1066
|
+
for (const id of ['mode', 'spec', 'sinks', 'minconf', 'go']) $(id).disabled = true
|
|
1067
|
+
$('home').removeAttribute('href')
|
|
1068
|
+
$('spec').value = STATIC.title
|
|
1069
|
+
if ([...$('mode').options].some((o) => o.value === STATIC.graph.meta.mode)) $('mode').value = STATIC.graph.meta.mode
|
|
1070
|
+
setData(STATIC.graph, STATIC.title)
|
|
1071
|
+
return
|
|
1072
|
+
}
|
|
1073
|
+
$('home').onclick = (e) => { e.preventDefault(); history.pushState(null, '', location.pathname + location.search); landing() }
|
|
1074
|
+
window.addEventListener('popstate', () => { const h = readHash(); sinkVis(); if (h.getAll('spec').length) go(false); else landing() })
|
|
1075
|
+
$('go').onclick = () => go(); $('spec').oninput = suggest
|
|
1076
|
+
$('spec').onkeydown = (e) => { if (e.key === 'Enter') go() }
|
|
1077
|
+
presetsP = getJSON('/api/presets').then((x) => x || [])
|
|
1078
|
+
const h = readHash(); sinkVis()
|
|
1079
|
+
if (h.getAll('spec').length) go(false); else landing()
|
|
1080
|
+
}
|
|
1081
|
+
init()
|
|
1082
|
+
})()
|