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.
Files changed (174) hide show
  1. cg_code_graph-0.10.1.dist-info/METADATA +678 -0
  2. cg_code_graph-0.10.1.dist-info/RECORD +174 -0
  3. cg_code_graph-0.10.1.dist-info/WHEEL +5 -0
  4. cg_code_graph-0.10.1.dist-info/entry_points.txt +3 -0
  5. cg_code_graph-0.10.1.dist-info/licenses/LICENSE +21 -0
  6. cg_code_graph-0.10.1.dist-info/top_level.txt +1 -0
  7. codegraph/__init__.py +2 -0
  8. codegraph/aitools.py +129 -0
  9. codegraph/apps.py +76 -0
  10. codegraph/blindspots.py +428 -0
  11. codegraph/bridges.py +1701 -0
  12. codegraph/cli.py +725 -0
  13. codegraph/concepts.py +362 -0
  14. codegraph/config.py +559 -0
  15. codegraph/core/__init__.py +0 -0
  16. codegraph/core/cache.py +375 -0
  17. codegraph/core/detect.py +80 -0
  18. codegraph/core/extractors.py +187 -0
  19. codegraph/core/fsutil.py +61 -0
  20. codegraph/core/generated.py +575 -0
  21. codegraph/core/model.py +174 -0
  22. codegraph/core/paths.py +175 -0
  23. codegraph/core/plugin.py +160 -0
  24. codegraph/core/store.py +80 -0
  25. codegraph/core/syntax_errors.py +132 -0
  26. codegraph/coverage.py +928 -0
  27. codegraph/doctor.py +453 -0
  28. codegraph/external.py +613 -0
  29. codegraph/indexer.py +336 -0
  30. codegraph/link.py +434 -0
  31. codegraph/lint_async.py +524 -0
  32. codegraph/mcp_server.py +1303 -0
  33. codegraph/parity.py +473 -0
  34. codegraph/parity_structure.py +307 -0
  35. codegraph/payload.py +321 -0
  36. codegraph/plans.py +1285 -0
  37. codegraph/platform_scan.py +643 -0
  38. codegraph/platforms.py +1369 -0
  39. codegraph/plugins/__init__.py +0 -0
  40. codegraph/plugins/cfamily/__init__.py +0 -0
  41. codegraph/plugins/cfamily/plugin.py +930 -0
  42. codegraph/plugins/cfamily/syntax.py +881 -0
  43. codegraph/plugins/dart/__init__.py +0 -0
  44. codegraph/plugins/dart/bridges.py +345 -0
  45. codegraph/plugins/dart/extractor/bin/extract.dart +717 -0
  46. codegraph/plugins/dart/extractor/pubspec.lock +149 -0
  47. codegraph/plugins/dart/extractor/pubspec.yaml +7 -0
  48. codegraph/plugins/dart/http.py +904 -0
  49. codegraph/plugins/dart/models.py +308 -0
  50. codegraph/plugins/dart/plugin.py +625 -0
  51. codegraph/plugins/dart/program.py +907 -0
  52. codegraph/plugins/django/__init__.py +0 -0
  53. codegraph/plugins/django/extras.py +378 -0
  54. codegraph/plugins/django/models.py +508 -0
  55. codegraph/plugins/django/plugin.py +728 -0
  56. codegraph/plugins/django/schemas.py +339 -0
  57. codegraph/plugins/django/shapes.py +216 -0
  58. codegraph/plugins/django/urls.py +603 -0
  59. codegraph/plugins/express/__init__.py +0 -0
  60. codegraph/plugins/express/plugin.py +428 -0
  61. codegraph/plugins/flutter/__init__.py +0 -0
  62. codegraph/plugins/flutter/plugin.py +538 -0
  63. codegraph/plugins/kotlin/__init__.py +0 -0
  64. codegraph/plugins/kotlin/exact.py +457 -0
  65. codegraph/plugins/kotlin/plugin.py +1961 -0
  66. codegraph/plugins/kotlin/reparse.py +234 -0
  67. codegraph/plugins/laravel/__init__.py +0 -0
  68. codegraph/plugins/laravel/broadcast.py +351 -0
  69. codegraph/plugins/laravel/plugin.py +863 -0
  70. codegraph/plugins/laravel/tests.py +262 -0
  71. codegraph/plugins/laravel/values.py +728 -0
  72. codegraph/plugins/native/__init__.py +0 -0
  73. codegraph/plugins/native/gates.py +286 -0
  74. codegraph/plugins/native/runner.py +183 -0
  75. codegraph/plugins/native/scipread.py +194 -0
  76. codegraph/plugins/native/ts.py +54 -0
  77. codegraph/plugins/nest/__init__.py +0 -0
  78. codegraph/plugins/nest/plugin.py +654 -0
  79. codegraph/plugins/nextjs/__init__.py +0 -0
  80. codegraph/plugins/nextjs/plugin.py +336 -0
  81. codegraph/plugins/nuxt/__init__.py +0 -0
  82. codegraph/plugins/nuxt/plugin.py +308 -0
  83. codegraph/plugins/php/__init__.py +0 -0
  84. codegraph/plugins/php/extractor/composer.json +5 -0
  85. codegraph/plugins/php/extractor/composer.lock +76 -0
  86. codegraph/plugins/php/extractor/extract.php +743 -0
  87. codegraph/plugins/php/gating.py +573 -0
  88. codegraph/plugins/php/plugin.py +668 -0
  89. codegraph/plugins/php/strings.py +197 -0
  90. codegraph/plugins/python/__init__.py +0 -0
  91. codegraph/plugins/python/aitools.py +664 -0
  92. codegraph/plugins/python/external.py +245 -0
  93. codegraph/plugins/python/fields.py +107 -0
  94. codegraph/plugins/python/plugin.py +1733 -0
  95. codegraph/plugins/python/refs.py +485 -0
  96. codegraph/plugins/python/roots.py +412 -0
  97. codegraph/plugins/python/socketio.py +210 -0
  98. codegraph/plugins/python/subproc.py +864 -0
  99. codegraph/plugins/python/tests.py +1040 -0
  100. codegraph/plugins/python/values.py +179 -0
  101. codegraph/plugins/pyweb/__init__.py +0 -0
  102. codegraph/plugins/pyweb/plugin.py +1334 -0
  103. codegraph/plugins/pyweb/values.py +68 -0
  104. codegraph/plugins/rust/__init__.py +0 -0
  105. codegraph/plugins/rust/cargo.py +226 -0
  106. codegraph/plugins/rust/plugin.py +980 -0
  107. codegraph/plugins/rust/syntax.py +678 -0
  108. codegraph/plugins/scip/__init__.py +0 -0
  109. codegraph/plugins/scip/importer.py +129 -0
  110. codegraph/plugins/scip/scip.proto +962 -0
  111. codegraph/plugins/scip/scip_pb2.py +97 -0
  112. codegraph/plugins/stubs/__init__.py +0 -0
  113. codegraph/plugins/stubs/plugins.py +38 -0
  114. codegraph/plugins/swift/__init__.py +0 -0
  115. codegraph/plugins/swift/baseurl.py +109 -0
  116. codegraph/plugins/swift/exact.py +415 -0
  117. codegraph/plugins/swift/indexstore.py +209 -0
  118. codegraph/plugins/swift/packages.py +174 -0
  119. codegraph/plugins/swift/plugin.py +2890 -0
  120. codegraph/plugins/ts/__init__.py +0 -0
  121. codegraph/plugins/ts/baseurl.py +185 -0
  122. codegraph/plugins/ts/extractor/extract.mjs +2652 -0
  123. codegraph/plugins/ts/extractor/fw.mjs +685 -0
  124. codegraph/plugins/ts/extractor/package-lock.json +205 -0
  125. codegraph/plugins/ts/extractor/package.json +9 -0
  126. codegraph/plugins/ts/plugin.py +480 -0
  127. codegraph/plugins/tsweb/__init__.py +0 -0
  128. codegraph/plugins/tsweb/common.py +290 -0
  129. codegraph/plugins/tsweb/data.py +276 -0
  130. codegraph/presets/__init__.py +146 -0
  131. codegraph/presets/c_cpp.yaml +9 -0
  132. codegraph/presets/common.yaml +66 -0
  133. codegraph/presets/dart.yaml +9 -0
  134. codegraph/presets/django-ninja.yaml +15 -0
  135. codegraph/presets/django.yaml +25 -0
  136. codegraph/presets/djangorestframework.yaml +17 -0
  137. codegraph/presets/express.yaml +17 -0
  138. codegraph/presets/kotlin.yaml +11 -0
  139. codegraph/presets/laravel.yaml +40 -0
  140. codegraph/presets/nest.yaml +11 -0
  141. codegraph/presets/nextjs.yaml +15 -0
  142. codegraph/presets/nuxt.yaml +9 -0
  143. codegraph/presets/php.yaml +5 -0
  144. codegraph/presets/python.yaml +10 -0
  145. codegraph/presets/rust.yaml +5 -0
  146. codegraph/presets/swift.yaml +10 -0
  147. codegraph/presets/typescript.yaml +13 -0
  148. codegraph/process_runs.py +328 -0
  149. codegraph/protocols/__init__.py +299 -0
  150. codegraph/protocols/builtin.py +67 -0
  151. codegraph/protocols/matchers.py +144 -0
  152. codegraph/protocols/view.py +334 -0
  153. codegraph/query.py +2089 -0
  154. codegraph/realtime.py +260 -0
  155. codegraph/roundtrip.py +346 -0
  156. codegraph/routes.py +442 -0
  157. codegraph/starters.py +218 -0
  158. codegraph/tests_index.py +117 -0
  159. codegraph/viz/__init__.py +0 -0
  160. codegraph/viz/graph.py +369 -0
  161. codegraph/viz/server.py +198 -0
  162. codegraph/viz/static/app.css +148 -0
  163. codegraph/viz/static/app.js +1082 -0
  164. codegraph/viz/static/index.html +81 -0
  165. codegraph/viz/static/layered.js +237 -0
  166. codegraph/viz/static/vendor/VERSIONS.txt +4 -0
  167. codegraph/viz/static/vendor/cose-base.js +3214 -0
  168. codegraph/viz/static/vendor/cytoscape-fcose.js +1549 -0
  169. codegraph/viz/static/vendor/cytoscape.min.js +31 -0
  170. codegraph/viz/static/vendor/layout-base.js +5230 -0
  171. codegraph/viz/tools/package-lock.json +303 -0
  172. codegraph/viz/tools/package.json +7 -0
  173. codegraph/viz/tools/shoot.mjs +165 -0
  174. 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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 (`NotificationsList​DataSource`)
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
+ })()