codesynapt 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/LICENSE +686 -0
  3. package/LICENSES.md +141 -0
  4. package/README.md +331 -0
  5. package/electron/main.cjs +2849 -0
  6. package/electron/plugin-loader.cjs +184 -0
  7. package/electron/preload.cjs +108 -0
  8. package/package.json +216 -0
  9. package/packages/core/bin/codesynapt-mcp.cjs +611 -0
  10. package/packages/core/bin/codesynapt.cjs +1933 -0
  11. package/packages/core/legacy.js +300 -0
  12. package/packages/core/lib/control-server.cjs +1539 -0
  13. package/packages/core/lib/embedding.cjs +89 -0
  14. package/packages/core/lib/logger.cjs +63 -0
  15. package/packages/core/lib/search-cache.cjs +140 -0
  16. package/packages/core/lib/search-worker.cjs +255 -0
  17. package/packages/core/lib/search.cjs +211 -0
  18. package/packages/core/lib/symbol-graph.cjs +402 -0
  19. package/packages/core/lib/symbol-parser-js.cjs +542 -0
  20. package/packages/core/lib/symbol-parser-misc.cjs +394 -0
  21. package/packages/core/lib/symbol-parser-py.cjs +215 -0
  22. package/packages/core/lib/symbol-parser-treesitter.cjs +658 -0
  23. package/packages/core/lib/symbol-parser-tsc.cjs +332 -0
  24. package/packages/core/monorepo.js +310 -0
  25. package/packages/core/parser.js +2234 -0
  26. package/packages/core/scanner.js +623 -0
  27. package/plugin-api/LICENSE +21 -0
  28. package/plugin-api/README.md +114 -0
  29. package/plugin-api/docs/01-getting-started.md +197 -0
  30. package/plugin-api/docs/02-concepts.md +269 -0
  31. package/plugin-api/docs/api-reference.md +463 -0
  32. package/plugin-api/docs/troubleshooting.md +332 -0
  33. package/plugin-api/docs/types/exporter.md +377 -0
  34. package/plugin-api/docs/types/theme.md +312 -0
  35. package/plugin-api/examples/hello-world-plugin/README.md +70 -0
  36. package/plugin-api/examples/hello-world-plugin/main.js +36 -0
  37. package/plugin-api/examples/hello-world-plugin/manifest.json +12 -0
  38. package/plugin-api/examples/mermaid-exporter/README.md +125 -0
  39. package/plugin-api/examples/mermaid-exporter/main.js +58 -0
  40. package/plugin-api/examples/mermaid-exporter/manifest.json +12 -0
  41. package/plugin-api/examples/rust-parser/README.md +71 -0
  42. package/plugin-api/examples/rust-parser/main.js +123 -0
  43. package/plugin-api/examples/rust-parser/manifest.json +12 -0
  44. package/plugin-api/examples/sunset-theme/README.md +95 -0
  45. package/plugin-api/examples/sunset-theme/manifest.json +12 -0
  46. package/plugin-api/examples/sunset-theme/theme.css +31 -0
  47. package/plugin-api/package.json +20 -0
  48. package/plugin-api/types.d.ts +395 -0
  49. package/public/app.js +6837 -0
  50. package/public/backend.js +285 -0
  51. package/public/index.html +647 -0
  52. package/public/plugin-host.js +321 -0
  53. package/public/style.css +4359 -0
  54. package/public/vendor/three.module.js +53044 -0
  55. package/scripts/competitor-watch.mjs +144 -0
  56. package/scripts/copy-vendor.js +21 -0
  57. package/scripts/download-bundled-node.cjs +53 -0
  58. package/scripts/fuses-after-pack.cjs +34 -0
  59. package/scripts/license-check.js +119 -0
  60. package/scripts/perf-test.js +200 -0
  61. package/server.js +132 -0
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env node
2
+ // Competitor monitoring — fetch the latest releases and issues from
3
+ // every repo listed in docs/COMPETITOR-WATCH.md and write a digest
4
+ // to docs/competitor-log.md. Run weekly.
5
+ //
6
+ // Uses the public GitHub API (no token required for low-rate usage).
7
+ // If GITHUB_TOKEN is set, it's used to lift the 60/h anon rate limit.
8
+ //
9
+ // Usage:
10
+ // node scripts/competitor-watch.mjs # default cadence
11
+ // node scripts/competitor-watch.mjs --since 14d # last 14 days only
12
+ // node scripts/competitor-watch.mjs --repo codegraph # just one repo
13
+
14
+ import fs from 'node:fs/promises'
15
+ import path from 'node:path'
16
+
17
+ const REPOS = [
18
+ { owner: 'colbymchenry', name: 'codegraph', cadence: 'weekly', why: 'direct competitor' },
19
+ { owner: 'sourcegraph', name: 'sourcegraph', cadence: 'biweekly', why: 'SCIP/LSIF gold standard' },
20
+ { owner: 'sourcegraph', name: 'scip', cadence: 'monthly', why: 'symbol-index format' },
21
+ { owner: 'continuedev', name: 'continue', cadence: 'monthly', why: 'open-source AI agent' },
22
+ { owner: 'Aider-AI', name: 'aider', cadence: 'monthly', why: 'repo-map analogue' },
23
+ { owner: 'modelcontextprotocol', name: 'servers', cadence: 'monthly', why: 'MCP spec drift' },
24
+ { owner: 'anthropics', name: 'claude-code', cadence: 'weekly', why: 'primary client' },
25
+ { owner: 'tree-sitter', name: 'tree-sitter', cadence: 'biweekly', why: 'parser ABI' },
26
+ ]
27
+
28
+ const args = process.argv.slice(2)
29
+ const getArg = (k) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : null }
30
+ const sinceArg = getArg('--since') || '7d'
31
+ const repoFilter = getArg('--repo')
32
+
33
+ const sinceMs = (() => {
34
+ const m = sinceArg.match(/^(\d+)([dhw])$/)
35
+ if (!m) return 7 * 86400_000
36
+ const n = parseInt(m[1], 10)
37
+ return { d: 86400_000, h: 3600_000, w: 7 * 86400_000 }[m[2]] * n
38
+ })()
39
+ const sinceDate = new Date(Date.now() - sinceMs)
40
+ const sinceIso = sinceDate.toISOString()
41
+
42
+ const TOKEN = process.env.GITHUB_TOKEN
43
+ const HEADERS = {
44
+ 'Accept': 'application/vnd.github+json',
45
+ 'User-Agent': 'codesynapt-competitor-watch',
46
+ ...(TOKEN ? { 'Authorization': `Bearer ${TOKEN}` } : {}),
47
+ }
48
+
49
+ async function gh(pathPart) {
50
+ const url = `https://api.github.com${pathPart}`
51
+ const res = await fetch(url, { headers: HEADERS })
52
+ if (res.status === 403) {
53
+ const remaining = res.headers.get('x-ratelimit-remaining')
54
+ if (remaining === '0') {
55
+ console.error(`[rate-limit] hit; set GITHUB_TOKEN to lift to 5000/h`)
56
+ return null
57
+ }
58
+ }
59
+ if (!res.ok) {
60
+ console.error(`[gh] ${pathPart} → ${res.status}`)
61
+ return null
62
+ }
63
+ return res.json()
64
+ }
65
+
66
+ async function fetchRepo(repo) {
67
+ const base = `/repos/${repo.owner}/${repo.name}`
68
+ const [releases, issues, commits] = await Promise.all([
69
+ gh(`${base}/releases?per_page=5`),
70
+ gh(`${base}/issues?state=all&sort=updated&since=${sinceIso}&per_page=10`),
71
+ gh(`${base}/commits?since=${sinceIso}&per_page=10`),
72
+ ])
73
+ return {
74
+ repo,
75
+ releases: (releases || []).filter((r) => new Date(r.published_at) > sinceDate),
76
+ issues: (issues || []).filter((i) => !i.pull_request),
77
+ prs: (issues || []).filter((i) => i.pull_request),
78
+ commits: (commits || []).slice(0, 5),
79
+ }
80
+ }
81
+
82
+ function fmtDate(s) { return s ? s.split('T')[0] : '' }
83
+
84
+ function repoSection(r) {
85
+ const lines = []
86
+ lines.push(`### [${r.repo.owner}/${r.repo.name}](https://github.com/${r.repo.owner}/${r.repo.name}) — _${r.repo.why}_`)
87
+ if (r.releases.length) {
88
+ lines.push(`\n**Releases (${r.releases.length})**`)
89
+ for (const rel of r.releases) {
90
+ lines.push(`- [\`${rel.tag_name}\`](${rel.html_url}) — ${fmtDate(rel.published_at)} · ${(rel.name || '').slice(0, 80)}`)
91
+ }
92
+ }
93
+ if (r.prs.length) {
94
+ lines.push(`\n**PRs (${r.prs.length} updated since ${sinceArg})**`)
95
+ for (const pr of r.prs.slice(0, 5)) {
96
+ lines.push(`- [#${pr.number}](${pr.html_url}) ${pr.state} · ${pr.title.slice(0, 100)}`)
97
+ }
98
+ }
99
+ if (r.issues.length) {
100
+ lines.push(`\n**Issues (${r.issues.length} updated since ${sinceArg})**`)
101
+ for (const iss of r.issues.slice(0, 5)) {
102
+ lines.push(`- [#${iss.number}](${iss.html_url}) ${iss.state} · ${iss.title.slice(0, 100)}`)
103
+ }
104
+ }
105
+ if (!r.releases.length && !r.issues.length && !r.prs.length) {
106
+ lines.push(`\n_no activity since ${sinceArg}_`)
107
+ }
108
+ return lines.join('\n')
109
+ }
110
+
111
+ async function main() {
112
+ const targets = repoFilter
113
+ ? REPOS.filter((r) => r.name === repoFilter)
114
+ : REPOS
115
+ if (!targets.length) {
116
+ console.error(`No repo matches "${repoFilter}". Available: ${REPOS.map((r) => r.name).join(', ')}`)
117
+ process.exit(1)
118
+ }
119
+ console.log(`[watch] fetching ${targets.length} repos, since ${sinceIso}`)
120
+ const results = []
121
+ for (const r of targets) {
122
+ process.stdout.write(` ${r.owner}/${r.name}… `)
123
+ const out = await fetchRepo(r)
124
+ results.push(out)
125
+ console.log(`${out.releases.length}r ${out.prs.length}p ${out.issues.length}i`)
126
+ }
127
+ const today = new Date().toISOString().split('T')[0]
128
+ const md = [
129
+ `## ${today} — last ${sinceArg}`,
130
+ '',
131
+ ...results.map(repoSection).map((s) => s + '\n'),
132
+ '\n---\n',
133
+ ].join('\n')
134
+
135
+ // Prepend to docs/competitor-log.md so the newest entry is on top.
136
+ const logPath = path.resolve('docs', 'competitor-log.md')
137
+ let existing = ''
138
+ try { existing = await fs.readFile(logPath, 'utf8') } catch {}
139
+ const header = existing.startsWith('# ') ? '' : '# Competitor activity log\n\n'
140
+ await fs.writeFile(logPath, header + md + existing.replace(/^# Competitor activity log\n\n/, ''))
141
+ console.log(`\nWrote ${logPath}`)
142
+ }
143
+
144
+ main().catch((e) => { console.error(e); process.exit(1) })
@@ -0,0 +1,21 @@
1
+ // Copy three.module.js from node_modules into public/vendor/
2
+ // so it can be loaded via a relative path in both browser and Electron modes.
3
+ import fs from 'fs'
4
+ import path from 'path'
5
+ import { fileURLToPath } from 'url'
6
+
7
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
8
+ const ROOT = path.resolve(__dirname, '..')
9
+
10
+ const src = path.join(ROOT, 'node_modules/three/build/three.module.js')
11
+ const dst = path.join(ROOT, 'public/vendor/three.module.js')
12
+
13
+ if (!fs.existsSync(src)) {
14
+ console.warn('[copy-vendor] three not installed yet — skipping')
15
+ process.exit(0)
16
+ }
17
+
18
+ fs.mkdirSync(path.dirname(dst), { recursive: true })
19
+ fs.copyFileSync(src, dst)
20
+ const size = (fs.statSync(dst).size / 1024).toFixed(0)
21
+ console.log(`[copy-vendor] three.module.js → public/vendor/ (${size} KB)`)
@@ -0,0 +1,53 @@
1
+ // Downloads Node 22 LTS Windows x64 binary into build/bundled-node/
2
+ // so electron-builder can bundle it into the NSIS installer.
3
+ //
4
+ // Run before `npm run dist:win` if you want the offline installer
5
+ // option to include a bundled Node. Skipped otherwise — the installer
6
+ // just relies on the user's system Node.
7
+ //
8
+ // Usage: node scripts/download-bundled-node.cjs
9
+ const fs = require('fs')
10
+ const path = require('path')
11
+ const https = require('https')
12
+
13
+ const NODE_VERSION = 'v22.11.0'
14
+ const URL = `https://nodejs.org/dist/${NODE_VERSION}/win-x64/node.exe`
15
+ const OUT_DIR = path.resolve(__dirname, '..', 'build', 'bundled-node')
16
+ const OUT_FILE = path.join(OUT_DIR, 'node.exe')
17
+
18
+ if (fs.existsSync(OUT_FILE)) {
19
+ const size = fs.statSync(OUT_FILE).size
20
+ if (size > 50 * 1024 * 1024) {
21
+ console.log(`[bundled-node] already present (${(size/1024/1024).toFixed(1)} MB) — skipping`)
22
+ process.exit(0)
23
+ }
24
+ }
25
+
26
+ fs.mkdirSync(OUT_DIR, { recursive: true })
27
+
28
+ console.log(`[bundled-node] downloading ${URL}`)
29
+ const file = fs.createWriteStream(OUT_FILE)
30
+ function fetch(url) {
31
+ https.get(url, (res) => {
32
+ if (res.statusCode === 301 || res.statusCode === 302) {
33
+ file.close()
34
+ fs.unlinkSync(OUT_FILE)
35
+ return fetch(res.headers.location)
36
+ }
37
+ if (res.statusCode !== 200) {
38
+ console.error(`[bundled-node] HTTP ${res.statusCode}`)
39
+ process.exit(1)
40
+ }
41
+ res.pipe(file)
42
+ file.on('finish', () => {
43
+ file.close()
44
+ const size = fs.statSync(OUT_FILE).size
45
+ console.log(`[bundled-node] downloaded ${(size/1024/1024).toFixed(1)} MB → ${OUT_FILE}`)
46
+ })
47
+ }).on('error', (e) => {
48
+ console.error(`[bundled-node] error: ${e.message}`)
49
+ fs.unlinkSync(OUT_FILE)
50
+ process.exit(1)
51
+ })
52
+ }
53
+ fetch(URL)
@@ -0,0 +1,34 @@
1
+ // Electron Fuses — flip security-relevant flags off in the packaged binary.
2
+ // Called via package.json `build.afterPack` hook by electron-builder.
3
+ //
4
+ // Why: a signed Electron binary by default lets `ELECTRON_RUN_AS_NODE=1`
5
+ // run arbitrary Node code. Closing that fuse removes that RCE vector.
6
+ //
7
+ // docs: https://www.electronjs.org/docs/latest/tutorial/fuses
8
+ const path = require('path')
9
+ const fs = require('fs')
10
+
11
+ exports.default = async function fusesAfterPack(context) {
12
+ // @electron/fuses is ESM-only, so dynamic import from this CJS hook.
13
+ const { flipFuses, FuseVersion, FuseV1Options } = await import('@electron/fuses')
14
+ const exeName = context.packager.platform.nodeName === 'darwin'
15
+ ? `${context.packager.appInfo.productFilename}.app`
16
+ : `${context.packager.appInfo.productFilename}.exe`
17
+ const exePath = path.join(context.appOutDir, exeName)
18
+ if (!fs.existsSync(exePath)) {
19
+ console.warn(`[fuses] target not found: ${exePath} — skipping`)
20
+ return
21
+ }
22
+ await flipFuses(exePath, {
23
+ version: FuseVersion.V1,
24
+ [FuseV1Options.RunAsNode]: false,
25
+ [FuseV1Options.EnableCookieEncryption]: true,
26
+ [FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
27
+ [FuseV1Options.EnableNodeCliInspectArguments]: false,
28
+ [FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: false,
29
+ [FuseV1Options.OnlyLoadAppFromAsar]: false,
30
+ [FuseV1Options.LoadBrowserProcessSpecificV8Snapshot]: false,
31
+ [FuseV1Options.GrantFileProtocolExtraPrivileges]: false,
32
+ })
33
+ console.log(`[fuses] applied to ${exePath}`)
34
+ }
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env node
2
+ // License compliance check.
3
+ // Walks node_modules, reads each package's license field, and fails
4
+ // if any dependency uses a license outside the allowed list.
5
+ //
6
+ // Run via `npm run license-check`.
7
+ //
8
+ // Allowed licenses are the standard permissive set. GPL, AGPL, LGPL,
9
+ // CC-BY-NC, and other copyleft / restrictive licenses are blocked
10
+ // to avoid surprises in a project intended for MIT distribution.
11
+
12
+ import fs from 'fs'
13
+ import path from 'path'
14
+ import { fileURLToPath } from 'url'
15
+
16
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
17
+ const ROOT = path.resolve(__dirname, '..')
18
+ const MODULES = path.join(ROOT, 'node_modules')
19
+
20
+ const ALLOWED = new Set([
21
+ 'MIT',
22
+ 'Apache-2.0',
23
+ 'BSD',
24
+ 'BSD-2-Clause',
25
+ 'BSD-3-Clause',
26
+ 'BSD-3-Clause-Clear',
27
+ 'ISC',
28
+ 'CC0-1.0',
29
+ '0BSD',
30
+ 'Unlicense',
31
+ 'WTFPL',
32
+ 'Python-2.0', // some Python-related tools
33
+ 'BlueOak-1.0.0',
34
+ 'Zlib',
35
+ ])
36
+
37
+ // Some packages bundle both — we accept if any constituent is allowed.
38
+ function isAllowedLicense(raw) {
39
+ if (!raw) return false
40
+ if (typeof raw === 'object') raw = raw.type || raw.license || ''
41
+ if (Array.isArray(raw)) return raw.some(isAllowedLicense)
42
+ const norm = String(raw).trim()
43
+ if (ALLOWED.has(norm)) return true
44
+ // Handle SPDX expressions like "(MIT OR Apache-2.0)"
45
+ const stripped = norm.replace(/^\(|\)$/g, '')
46
+ if (stripped.includes(' OR ')) {
47
+ return stripped.split(' OR ').some((p) => ALLOWED.has(p.trim()))
48
+ }
49
+ if (stripped.includes(' AND ')) {
50
+ return stripped.split(' AND ').every((p) => ALLOWED.has(p.trim()))
51
+ }
52
+ return false
53
+ }
54
+
55
+ function walkModules(dir, results = []) {
56
+ if (!fs.existsSync(dir)) return results
57
+ for (const name of fs.readdirSync(dir)) {
58
+ if (name === '.bin' || name === '.cache') continue
59
+ const full = path.join(dir, name)
60
+ let stat
61
+ try { stat = fs.statSync(full) } catch { continue }
62
+ if (!stat.isDirectory()) continue
63
+ if (name.startsWith('@')) {
64
+ // scoped: descend one level
65
+ walkModules(full, results)
66
+ } else {
67
+ const pkgPath = path.join(full, 'package.json')
68
+ if (fs.existsSync(pkgPath)) {
69
+ try {
70
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
71
+ results.push({
72
+ name: pkg.name,
73
+ version: pkg.version,
74
+ license: pkg.license || pkg.licenses || null,
75
+ path: full,
76
+ })
77
+ } catch { /* skip malformed */ }
78
+ }
79
+ // Recurse into nested node_modules
80
+ const nested = path.join(full, 'node_modules')
81
+ if (fs.existsSync(nested)) walkModules(nested, results)
82
+ }
83
+ }
84
+ return results
85
+ }
86
+
87
+ const all = walkModules(MODULES)
88
+ if (all.length === 0) {
89
+ console.log('No node_modules found. Run `npm install` first.')
90
+ process.exit(0)
91
+ }
92
+
93
+ const seen = new Set()
94
+ const violations = []
95
+ const unknown = []
96
+ for (const m of all) {
97
+ const key = `${m.name}@${m.version}`
98
+ if (seen.has(key)) continue
99
+ seen.add(key)
100
+ if (!m.license) { unknown.push(m); continue }
101
+ if (!isAllowedLicense(m.license)) {
102
+ violations.push(m)
103
+ }
104
+ }
105
+
106
+ console.log(`Checked ${seen.size} unique packages.`)
107
+ if (unknown.length > 0) {
108
+ console.log(`\n⚠️ ${unknown.length} packages have no license declared:`)
109
+ for (const m of unknown) console.log(` ${m.name}@${m.version}`)
110
+ }
111
+ if (violations.length > 0) {
112
+ console.log(`\n❌ ${violations.length} packages with disallowed licenses:`)
113
+ for (const m of violations) {
114
+ const lic = typeof m.license === 'string' ? m.license : JSON.stringify(m.license)
115
+ console.log(` ${m.name}@${m.version} → ${lic}`)
116
+ }
117
+ process.exit(1)
118
+ }
119
+ console.log('\n✅ All dependencies use permissive licenses.')
@@ -0,0 +1,200 @@
1
+ // Performance test for the linked-list grid + RR springs version.
2
+ // Mirrors public/app.js step() exactly.
3
+
4
+ const MAX_NODES = 300_000
5
+ const SIZES = [1_000, 10_000, 30_000, 100_000, 300_000]
6
+ const FRAMES = 30
7
+
8
+ function makeSyntheticGraph(n) {
9
+ const nodes = []
10
+ const spread = Math.max(20, Math.sqrt(n) * 1.6)
11
+ for (let i = 0; i < n; i++) {
12
+ const r = 4 + Math.random() * spread
13
+ const theta = Math.random() * Math.PI * 2
14
+ nodes.push({
15
+ id: 'f' + i, idx: i,
16
+ p: { x: r * Math.cos(theta), y: (Math.random() - 0.5) * 8, z: r * Math.sin(theta) },
17
+ v: { x: 0, y: 0, z: 0 },
18
+ mass: 1 + Math.sqrt(Math.floor(Math.random() * 12)) * 1.6,
19
+ })
20
+ }
21
+ const edges = []
22
+ for (let i = 0; i < n; i++) {
23
+ for (let j = 0; j < 3; j++) {
24
+ const t = Math.floor(Math.random() * n)
25
+ if (t !== i) edges.push({ s: nodes[i].id, t: nodes[t].id, k: 'import' })
26
+ }
27
+ }
28
+ return { nodes, edges }
29
+ }
30
+
31
+ const GRID_CELL = 11
32
+ const GRID_INV = 1 / GRID_CELL
33
+ const GRID_SIZE_BITS = 6
34
+ const GRID_SIZE = 1 << GRID_SIZE_BITS
35
+ const GRID_MASK = GRID_SIZE - 1
36
+ const GRID_TOTAL = GRID_SIZE ** 3
37
+
38
+ const cellHead = new Int32Array(GRID_TOTAL)
39
+ const nextInCell = new Int32Array(MAX_NODES)
40
+
41
+ function buildGrid(arr) {
42
+ cellHead.fill(-1)
43
+ const n = arr.length
44
+ for (let i = 0; i < n; i++) {
45
+ const p = arr[i].p
46
+ const cx = ((p.x * GRID_INV) | 0) & GRID_MASK
47
+ const cy = ((p.y * GRID_INV) | 0) & GRID_MASK
48
+ const cz = ((p.z * GRID_INV) | 0) & GRID_MASK
49
+ const cell = (cx << (GRID_SIZE_BITS * 2)) | (cy << GRID_SIZE_BITS) | cz
50
+ nextInCell[i] = cellHead[cell]
51
+ cellHead[cell] = i
52
+ }
53
+ }
54
+
55
+ let rrCursor = 0, springCursor = 0
56
+
57
+ function step(arr, edges, nodeMap, alpha = 0.3, dt = 0.016) {
58
+ if (alpha === 0) return
59
+ const n = arr.length
60
+ const REPEL = 240, SPRING = 0.05, REST = 9
61
+ const CENTER = 0.0018, DISK = 0.012
62
+ const CUT2 = GRID_CELL * GRID_CELL * 2.25
63
+ const VEL_KEEP = 0.62
64
+
65
+ buildGrid(arr)
66
+
67
+ const RR_CHUNK = Math.min(n, Math.max(4000, Math.floor(120_000 / Math.max(1, Math.log2(n)))))
68
+ const PAIR_CAP_LOCAL = 32
69
+
70
+ for (let k = 0; k < RR_CHUNK; k++) {
71
+ const i = (rrCursor + k) % n
72
+ const ni = arr[i]
73
+ const aix = ni.p.x, aiy = ni.p.y, aiz = ni.p.z
74
+ const aim = ni.mass, invMi = 1 / aim
75
+ const cx = ((aix * GRID_INV) | 0) & GRID_MASK
76
+ const cy = ((aiy * GRID_INV) | 0) & GRID_MASK
77
+ const cz = ((aiz * GRID_INV) | 0) & GRID_MASK
78
+ let count = 0
79
+
80
+ for (let dx = -1; dx <= 1; dx++) {
81
+ const ncx = (cx + dx) & GRID_MASK
82
+ for (let dy = -1; dy <= 1; dy++) {
83
+ const ncy = (cy + dy) & GRID_MASK
84
+ for (let dz = -1; dz <= 1; dz++) {
85
+ const ncz = (cz + dz) & GRID_MASK
86
+ const cell = (ncx << (GRID_SIZE_BITS * 2)) | (ncy << GRID_SIZE_BITS) | ncz
87
+ let j = cellHead[cell]
88
+ while (j !== -1) {
89
+ if (j === i) { j = nextInCell[j]; continue }
90
+ const nj = arr[j]
91
+ const ex = aix - nj.p.x, ey = aiy - nj.p.y, ez = aiz - nj.p.z
92
+ const d2 = ex*ex + ey*ey + ez*ez
93
+ if (d2 > CUT2 || d2 < 0.0001) { j = nextInCell[j]; continue }
94
+ const safeD2 = d2 < 0.5 ? 0.5 : d2
95
+ const invD = 1 / Math.sqrt(d2)
96
+ const f = REPEL * aim * nj.mass / safeD2 * alpha * 0.5
97
+ const fx = ex * invD * f, fy = ey * invD * f, fz = ez * invD * f
98
+ ni.v.x += fx * invMi; ni.v.y += fy * invMi; ni.v.z += fz * invMi
99
+ const invMj = 1 / nj.mass
100
+ nj.v.x -= fx * invMj; nj.v.y -= fy * invMj; nj.v.z -= fz * invMj
101
+ if (++count > PAIR_CAP_LOCAL) { dx = 2; dy = 2; dz = 2; break }
102
+ j = nextInCell[j]
103
+ }
104
+ }
105
+ }
106
+ }
107
+ }
108
+ rrCursor = (rrCursor + RR_CHUNK) % n
109
+
110
+ const eLen = edges.length
111
+ if (eLen > 0) {
112
+ const SPRING_CHUNK = Math.min(eLen, Math.max(8000, Math.floor(200_000 / Math.max(1, Math.log2(eLen)))))
113
+ const springAlpha = alpha * Math.min(1, eLen / SPRING_CHUNK)
114
+ for (let k = 0; k < SPRING_CHUNK; k++) {
115
+ const idx = (springCursor + k) % eLen
116
+ const e = edges[idx]
117
+ const na = nodeMap.get(e.s), nb = nodeMap.get(e.t)
118
+ if (!na || !nb) continue
119
+ const ex = nb.p.x - na.p.x, ey = nb.p.y - na.p.y, ez = nb.p.z - na.p.z
120
+ const d2 = ex*ex + ey*ey + ez*ez
121
+ if (d2 < 1e-6) continue
122
+ const d = Math.sqrt(d2)
123
+ const f = SPRING * (d - REST) * springAlpha / d
124
+ const fx = ex * f, fy = ey * f, fz = ez * f
125
+ const inv1 = 1 / na.mass, inv2 = 1 / nb.mass
126
+ na.v.x += fx * inv1; na.v.y += fy * inv1; na.v.z += fz * inv1
127
+ nb.v.x -= fx * inv2; nb.v.y -= fy * inv2; nb.v.z -= fz * inv2
128
+ }
129
+ springCursor = (springCursor + SPRING_CHUNK) % eLen
130
+ }
131
+
132
+ const CA = CENTER * alpha, DA = DISK * alpha, DT6 = dt * 6
133
+ for (let i = 0; i < n; i++) {
134
+ const node = arr[i]
135
+ const p = node.p, v = node.v
136
+ v.x += -p.x * CA
137
+ v.y += -p.y * CA - p.y * DA
138
+ v.z += -p.z * CA
139
+ v.x *= VEL_KEEP; v.y *= VEL_KEEP; v.z *= VEL_KEEP
140
+ p.x += v.x * DT6; p.y += v.y * DT6; p.z += v.z * DT6
141
+ }
142
+ }
143
+
144
+ function writeBuffers(arr) {
145
+ const n = arr.length
146
+ const positions = new Float32Array(n * 3)
147
+ for (let i = 0; i < n; i++) {
148
+ positions[i*3] = arr[i].p.x
149
+ positions[i*3+1] = arr[i].p.y
150
+ positions[i*3+2] = arr[i].p.z
151
+ }
152
+ }
153
+
154
+ console.log('Performance test — flat grid + RR repulsion + RR springs + inlined math')
155
+ console.log('Targets: <16ms=60fps, <33ms=30fps, <100ms=10fps\n')
156
+ console.log(' SIMULATING (during first ~5s of layout) SETTLED (steady state)')
157
+ console.log(' ───────────────────────────────────── ─────────────────────')
158
+
159
+ function stepSettled(arr, dt = 0.016) {
160
+ // Mirror app.js settled-state fast path (alpha == 0)
161
+ const n = arr.length
162
+ for (let i = 0; i < n; i++) {
163
+ const v = arr[i].v
164
+ if (v.x * v.x + v.y * v.y + v.z * v.z < 0.0002) { v.x = 0; v.y = 0; v.z = 0; continue }
165
+ v.x *= 0.62; v.y *= 0.62; v.z *= 0.62
166
+ const p = arr[i].p
167
+ p.x += v.x * dt * 6; p.y += v.y * dt * 6; p.z += v.z * dt * 6
168
+ }
169
+ }
170
+
171
+ for (const N of SIZES) {
172
+ const { nodes, edges } = makeSyntheticGraph(N)
173
+ const nodeMap = new Map(nodes.map((n) => [n.id, n]))
174
+ rrCursor = 0; springCursor = 0
175
+
176
+ // Warm-up
177
+ step(nodes, edges, nodeMap, 0.3)
178
+ step(nodes, edges, nodeMap, 0.3)
179
+
180
+ // Active simulation
181
+ const t0 = performance.now()
182
+ for (let f = 0; f < FRAMES; f++) {
183
+ step(nodes, edges, nodeMap, 0.3)
184
+ writeBuffers(nodes)
185
+ }
186
+ const msSim = (performance.now() - t0) / FRAMES
187
+
188
+ // Settled — zero velocity, just buffer write
189
+ for (const node of nodes) { node.v.x = 0; node.v.y = 0; node.v.z = 0 }
190
+ const t1 = performance.now()
191
+ for (let f = 0; f < FRAMES; f++) {
192
+ stepSettled(nodes)
193
+ writeBuffers(nodes)
194
+ }
195
+ const msSettled = (performance.now() - t1) / FRAMES
196
+
197
+ const v1 = msSim < 18 ? '🟢' : msSim < 34 ? '🟡' : msSim < 105 ? '🟠' : '🔴'
198
+ const v2 = msSettled < 18 ? '🟢' : msSettled < 34 ? '🟡' : msSettled < 105 ? '🟠' : '🔴'
199
+ console.log(`${N.toString().padStart(7)} nodes ${msSim.toFixed(1).padStart(6)} ms (${(1000/msSim).toFixed(0).padStart(3)} fps) ${v1} ${msSettled.toFixed(1).padStart(6)} ms (${(1000/msSettled).toFixed(0).padStart(3)} fps) ${v2}`)
200
+ }