graphlin 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +12 -0
- package/.codex-plugin/plugin.json +29 -0
- package/.mcp.json +9 -0
- package/LICENSE +21 -0
- package/README.md +71 -0
- package/adapters/README.md +32 -0
- package/adapters/claude/hooks.json +10 -0
- package/adapters/claude/profile.json +18 -0
- package/adapters/codex/hooks.json +9 -0
- package/adapters/codex/profile.json +22 -0
- package/adapters/kiro/profile.json +8 -0
- package/mcp.json +11 -0
- package/package.json +114 -0
- package/plugin.json +20 -0
- package/runtime/collector/index.mjs +23 -0
- package/runtime/core/candidates.mjs +300 -0
- package/runtime/core/common.mjs +69 -0
- package/runtime/core/evidence.mjs +150 -0
- package/runtime/core/graph.mjs +398 -0
- package/runtime/core/index.mjs +4 -0
- package/runtime/core/lexical.mjs +255 -0
- package/runtime/core/privacy.mjs +206 -0
- package/runtime/core/tool-discovery.mjs +122 -0
- package/runtime/daemon/auth.mjs +50 -0
- package/runtime/daemon/connection-info.mjs +249 -0
- package/runtime/daemon/demo.mjs +195 -0
- package/runtime/daemon/diagnostics.mjs +404 -0
- package/runtime/daemon/export.mjs +7 -0
- package/runtime/daemon/ipc.mjs +28 -0
- package/runtime/daemon/lock.mjs +137 -0
- package/runtime/daemon/manager.mjs +320 -0
- package/runtime/daemon/paths.mjs +108 -0
- package/runtime/daemon/persistence.mjs +64 -0
- package/runtime/daemon/server.mjs +292 -0
- package/runtime/daemon/settings.mjs +103 -0
- package/runtime/jev/fixture.mjs +99 -0
- package/runtime/jev/index.mjs +784 -0
- package/runtime/jev/questions.mjs +268 -0
- package/runtime/jev/wire.mjs +152 -0
- package/runtime/pipeline.mjs +1071 -0
- package/runtime/web/app.js +2596 -0
- package/runtime/web/index.html +265 -0
- package/runtime/web/layout.js +336 -0
- package/runtime/web/sidebar.js +525 -0
- package/runtime/web/sketch.js +347 -0
- package/runtime/web/style.css +593 -0
- package/schemas/bundle.schema.json +243 -0
- package/schemas/event.schema.json +108 -0
- package/schemas/graph.schema.json +449 -0
- package/schemas/patch.schema.json +111 -0
- package/scripts/arguments.mjs +37 -0
- package/scripts/build-packages.mjs +160 -0
- package/scripts/collect.sh +23 -0
- package/scripts/collector.mjs +11 -0
- package/scripts/control.mjs +80 -0
- package/scripts/daemon.mjs +28 -0
- package/scripts/graphlin.mjs +112 -0
- package/scripts/onboarding.mjs +413 -0
- package/scripts/validate-packages.mjs +118 -0
- package/skills/graphlin/SKILL.md +103 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<meta name="color-scheme" content="light">
|
|
7
|
+
<meta name="darkreader-lock">
|
|
8
|
+
<meta name="referrer" content="no-referrer">
|
|
9
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; font-src 'self'; base-uri 'none'; form-action 'none'; object-src 'none'">
|
|
10
|
+
<title>Graphlin — Live architecture</title>
|
|
11
|
+
<link rel="stylesheet" href="/style.css">
|
|
12
|
+
<script type="module" src="/app.js"></script>
|
|
13
|
+
</head>
|
|
14
|
+
<body>
|
|
15
|
+
<a class="skip-link" href="#main">Skip to architecture</a>
|
|
16
|
+
<div class="page">
|
|
17
|
+
<header class="masthead">
|
|
18
|
+
<a class="brand" href="/" aria-label="Graphlin live viewer"><span class="brand-mark" aria-hidden="true"></span>graphlin</a>
|
|
19
|
+
<div class="connection" id="connection" data-state="connecting">
|
|
20
|
+
<span class="connection-dot" aria-hidden="true"></span>
|
|
21
|
+
<span id="connection-label" role="status">Connecting to local service</span>
|
|
22
|
+
<button id="retry" class="text-button" type="button" hidden>Reconnect</button>
|
|
23
|
+
</div>
|
|
24
|
+
</header>
|
|
25
|
+
|
|
26
|
+
<main id="main">
|
|
27
|
+
<div class="intro">
|
|
28
|
+
<div>
|
|
29
|
+
<h1>Architecture, as it happens.</h1>
|
|
30
|
+
<p>Follow the work. Inspect the evidence behind each connection.</p>
|
|
31
|
+
</div>
|
|
32
|
+
<p id="project-label" class="project-label">Local project</p>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<div id="demo-banner" class="notice demo-notice" role="status" hidden>
|
|
36
|
+
<strong>Demo session</strong>
|
|
37
|
+
<span>Generated project and fixture classifier answers. This is an offline illustration, not a live Jev evaluation.</span>
|
|
38
|
+
</div>
|
|
39
|
+
<div id="error-banner" class="notice error-notice" role="alert" hidden></div>
|
|
40
|
+
<noscript><p class="notice">Enable JavaScript to receive local snapshots and inspect this architecture.</p></noscript>
|
|
41
|
+
|
|
42
|
+
<section class="workspace" aria-label="Session workspace">
|
|
43
|
+
<div class="workspace-toolbar">
|
|
44
|
+
<label class="session-picker" for="session">
|
|
45
|
+
<span>Session</span>
|
|
46
|
+
<select id="session" disabled><option value="">Waiting for a session</option></select>
|
|
47
|
+
</label>
|
|
48
|
+
<div class="session-actions">
|
|
49
|
+
<button type="button" id="classification-log" aria-haspopup="dialog" aria-controls="diagnostics-dialog">Classification log</button>
|
|
50
|
+
<button type="button" id="how-to-connect" aria-haspopup="dialog" aria-controls="connection-dialog">How to connect</button>
|
|
51
|
+
<button type="button" id="pause" aria-describedby="capture-note" disabled>Pause classification</button>
|
|
52
|
+
<button type="button" id="export" disabled>Export JSON</button>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<div class="status-strip" aria-label="Capture and classification status">
|
|
57
|
+
<div class="classification-status">
|
|
58
|
+
<span class="status-dot" id="classifier-dot" data-state="waiting" aria-hidden="true"></span>
|
|
59
|
+
<strong id="classifier-label">Waiting for local service</strong>
|
|
60
|
+
<span id="capture-note">Capture status will appear with the first snapshot.</span>
|
|
61
|
+
</div>
|
|
62
|
+
<div class="coverage-status"><span id="coverage-label">Coverage not reported</span><span id="queue-label">No snapshot yet</span></div>
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<div class="workspace-body">
|
|
66
|
+
<section class="drawing" id="drawing" data-theme="sketchbook" aria-labelledby="canvas-title">
|
|
67
|
+
<div class="drawing-toolbar">
|
|
68
|
+
<div class="drawing-heading">
|
|
69
|
+
<h2 id="canvas-title">Live architecture</h2>
|
|
70
|
+
<span id="revision">Revision —</span>
|
|
71
|
+
</div>
|
|
72
|
+
<div class="zoom-controls" role="group" aria-label="Diagram zoom">
|
|
73
|
+
<button type="button" id="zoom-out" aria-label="Zoom out" disabled>−</button>
|
|
74
|
+
<output id="zoom-level" aria-label="Zoom level">100%</output>
|
|
75
|
+
<button type="button" id="zoom-in" aria-label="Zoom in" disabled>+</button>
|
|
76
|
+
<button type="button" id="fit" disabled>Fit</button>
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
<div class="layout-toolbar" role="group" aria-label="Diagram arrangement">
|
|
80
|
+
<label for="layout">Layout
|
|
81
|
+
<select id="layout" disabled>
|
|
82
|
+
<option value="hierarchy">Hierarchy top-down</option>
|
|
83
|
+
<option value="dependency">Dependency left-right</option>
|
|
84
|
+
<option value="grouped">Group by type</option>
|
|
85
|
+
<option value="circular">Circular</option>
|
|
86
|
+
<option value="grid">Grid</option>
|
|
87
|
+
<option value="original">Original</option>
|
|
88
|
+
</select>
|
|
89
|
+
</label>
|
|
90
|
+
<button type="button" id="arrange" disabled>Arrange</button>
|
|
91
|
+
<label class="auto-arrange" for="auto-arrange"><input id="auto-arrange" type="checkbox" checked disabled> Auto-arrange</label>
|
|
92
|
+
<div class="theme-picker">
|
|
93
|
+
<label for="theme">Theme
|
|
94
|
+
<select id="theme" aria-describedby="theme-note" disabled>
|
|
95
|
+
<option value="sketchbook">Sketchbook</option>
|
|
96
|
+
<option value="ocean">Ocean</option>
|
|
97
|
+
<option value="forest">Forest</option>
|
|
98
|
+
<option value="sunset">Sunset</option>
|
|
99
|
+
<option value="berry">Berry</option>
|
|
100
|
+
<option value="sepia">Sepia</option>
|
|
101
|
+
<option value="blueprint">Blueprint dark</option>
|
|
102
|
+
<option value="midnight">Midnight dark</option>
|
|
103
|
+
</select>
|
|
104
|
+
</label>
|
|
105
|
+
<span class="theme-preview" aria-hidden="true"><i></i><i></i><i></i><i></i></span>
|
|
106
|
+
</div>
|
|
107
|
+
<span class="screen-reader" id="theme-note">Colors follow component types. Themes change appearance only.</span>
|
|
108
|
+
<span id="layout-note">Arranges when components or connections change.</span>
|
|
109
|
+
</div>
|
|
110
|
+
<div class="diagram-stage" id="diagram-stage">
|
|
111
|
+
<svg id="architecture" viewBox="0 0 920 510" role="group" tabindex="0" aria-labelledby="diagram-title diagram-desc">
|
|
112
|
+
<title id="diagram-title">Architecture diagram</title>
|
|
113
|
+
<desc id="diagram-desc">Components and relationships will appear as evidence arrives. Select a component or relationship to inspect its evidence.</desc>
|
|
114
|
+
<defs>
|
|
115
|
+
<marker id="arrow-observed" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M 0 0 L 10 5 L 0 10 Z" class="arrow-observed"></path></marker>
|
|
116
|
+
<marker id="arrow-proposed" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M 0 0 L 10 5 L 0 10 Z" class="arrow-proposed"></path></marker>
|
|
117
|
+
<marker id="arrow-stale" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M 0 0 L 10 5 L 0 10 Z" class="arrow-stale"></path></marker>
|
|
118
|
+
</defs>
|
|
119
|
+
<g id="edge-layer"></g>
|
|
120
|
+
<g id="edge-label-layer"></g>
|
|
121
|
+
<g id="node-layer"></g>
|
|
122
|
+
<g id="effects-layer" aria-hidden="true" pointer-events="none"></g>
|
|
123
|
+
</svg>
|
|
124
|
+
<div class="empty-canvas" id="empty-canvas">
|
|
125
|
+
<div class="empty-map" aria-hidden="true"><i></i><span></span><i></i></div>
|
|
126
|
+
<h3 id="empty-title">Your architecture starts here.</h3>
|
|
127
|
+
<p id="empty-description">Waiting for the local service. Safe activity and supported components will appear as your agent works.</p>
|
|
128
|
+
<div id="orientation" class="orientation">
|
|
129
|
+
<p>Ask your connected agent:</p>
|
|
130
|
+
<blockquote id="orientation-prompt" tabindex="0">Orient yourself in this project: read its main files and explain how the components connect.</blockquote>
|
|
131
|
+
<button id="orientation-copy" type="button">Copy orientation prompt</button>
|
|
132
|
+
<p id="orientation-copy-status" role="status" aria-live="polite"></p>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
<div class="drawing-footer">
|
|
137
|
+
<div class="legend" aria-label="Diagram legend">
|
|
138
|
+
<span><i class="observed" aria-hidden="true"></i>Code evidence</span>
|
|
139
|
+
<span><i class="proposed" aria-hidden="true"></i>Proposed / tentative</span>
|
|
140
|
+
<span><i class="stale" aria-hidden="true"></i>Stale / retracted</span>
|
|
141
|
+
</div>
|
|
142
|
+
<span id="graph-count">No components yet</span>
|
|
143
|
+
</div>
|
|
144
|
+
<details class="shape-key">
|
|
145
|
+
<summary>Shape key</summary>
|
|
146
|
+
<div id="shape-key-items"></div>
|
|
147
|
+
<p>Shapes describe component types. Display overrides change appearance only.</p>
|
|
148
|
+
</details>
|
|
149
|
+
<p class="diagram-note" id="diagram-note">Code classification does not establish runtime connectivity.</p>
|
|
150
|
+
</section>
|
|
151
|
+
|
|
152
|
+
<aside class="live-sidebar" id="live-sidebar" aria-label="Live activity and evidence">
|
|
153
|
+
<section class="onboarding" aria-labelledby="onboarding-title">
|
|
154
|
+
<h2 id="onboarding-title">Getting your first shape</h2>
|
|
155
|
+
<ol class="onboarding-progress" aria-label="Observed setup progress">
|
|
156
|
+
<li id="onboarding-server">Connecting to server</li>
|
|
157
|
+
<li id="onboarding-setup">Agent setup / trust: unverified</li>
|
|
158
|
+
<li id="onboarding-hooks">Waiting for a hook receipt</li>
|
|
159
|
+
<li id="onboarding-classification">Waiting for a classifier call</li>
|
|
160
|
+
<li id="onboarding-shape">Waiting for the first shape</li>
|
|
161
|
+
</ol>
|
|
162
|
+
<p class="sidebar-note">Hooks and calls are project-wide observations. A receipt confirms delivery, not full host trust; a call does not confirm success.</p>
|
|
163
|
+
<p id="onboarding-next" class="onboarding-next">Keep the Graphlin server terminal open while the viewer connects.</p>
|
|
164
|
+
<button id="onboarding-action" type="button" disabled>How to connect</button>
|
|
165
|
+
</section>
|
|
166
|
+
<details class="sidebar-hooks" id="sidebar-hooks" open>
|
|
167
|
+
<summary><span class="sidebar-title"><i class="hook-live-dot" id="sidebar-hook-dot" data-state="waiting" aria-hidden="true"></i>Hooks received</span><span class="sidebar-count" id="sidebar-hook-count">0</span></summary>
|
|
168
|
+
<p class="sidebar-note" id="sidebar-hook-coverage">Received hooks from all sessions in this project will appear here.</p>
|
|
169
|
+
<p class="sidebar-empty" id="sidebar-hook-empty">Waiting for hooks. Work in a connected agent to see each receipt here.</p>
|
|
170
|
+
<ol id="sidebar-hook-list" class="hook-receipts" aria-label="Received hooks across this project, newest first"></ol>
|
|
171
|
+
</details>
|
|
172
|
+
<section class="sidebar-history sidebar-theme" id="sidebar-history" data-theme="sketchbook" aria-labelledby="sidebar-change-heading">
|
|
173
|
+
<div class="sidebar-heading"><h2 id="sidebar-change-heading">Diagram changes</h2><span class="sidebar-count" id="sidebar-change-count">0</span></div>
|
|
174
|
+
<p class="sidebar-note" id="sidebar-change-note">Newest changes first. Select a shape to inspect it, or a revision to replay.</p>
|
|
175
|
+
<p class="sidebar-empty" id="sidebar-change-empty">Shape changes will stack here as this session discovers or changes the architecture.</p>
|
|
176
|
+
<ol class="diagram-changes" id="sidebar-change-list" aria-label="Diagram changes in this session, newest first"></ol>
|
|
177
|
+
</section>
|
|
178
|
+
<section class="inspector" aria-labelledby="inspector-heading">
|
|
179
|
+
<div class="inspector-header"><h2 id="inspector-heading">Evidence inspector</h2><button type="button" id="clear-selection" class="text-button" hidden>Clear</button></div>
|
|
180
|
+
<div id="inspector-body">
|
|
181
|
+
<div class="inspector-empty">
|
|
182
|
+
<span class="inspector-glyph" aria-hidden="true">↗</span>
|
|
183
|
+
<h3>Every connection has a reason.</h3>
|
|
184
|
+
<p>Select a component or an arrow to see its classification, confidence, and source references.</p>
|
|
185
|
+
<p class="inspector-hint">Use Tab to reach diagram items, then Enter to inspect.</p>
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
</section>
|
|
189
|
+
</aside>
|
|
190
|
+
</div>
|
|
191
|
+
|
|
192
|
+
<div class="playback">
|
|
193
|
+
<div class="mode-buttons" role="group" aria-label="Architecture view">
|
|
194
|
+
<button id="live" type="button" aria-pressed="true">Live</button>
|
|
195
|
+
<button id="replay" type="button" aria-pressed="false" disabled>Replay</button>
|
|
196
|
+
</div>
|
|
197
|
+
<label class="scrubber" for="history">
|
|
198
|
+
<span class="screen-reader">Recent architecture revision</span>
|
|
199
|
+
<input id="history" type="range" min="0" max="0" value="0" step="1" disabled aria-describedby="replay-note">
|
|
200
|
+
<output id="history-position" for="history">No history</output>
|
|
201
|
+
</label>
|
|
202
|
+
<span id="replay-note">Live follows the latest snapshot.</span>
|
|
203
|
+
</div>
|
|
204
|
+
</section>
|
|
205
|
+
|
|
206
|
+
<section class="activity-section" aria-labelledby="activity-heading">
|
|
207
|
+
<div class="activity-heading">
|
|
208
|
+
<div><h2 id="activity-heading">Activity stream</h2><p id="activity-note">Observable work, independent of classification.</p></div>
|
|
209
|
+
<span id="activity-count">0 events</span>
|
|
210
|
+
<button id="activity-toggle" type="button" aria-expanded="true" aria-controls="activity-content">Hide activity log</button>
|
|
211
|
+
</div>
|
|
212
|
+
<div id="activity-content">
|
|
213
|
+
<p class="activity-empty" id="activity-empty">No activity received. Start work in a connected agent session; its captured events will appear here.</p>
|
|
214
|
+
<ol id="activity-list" class="activity-list" aria-label="Recent activity, newest first"></ol>
|
|
215
|
+
</div>
|
|
216
|
+
</section>
|
|
217
|
+
</main>
|
|
218
|
+
<footer><p>Local viewer <span aria-hidden="true">/</span> evidence before inference</p><p id="updated-at">Waiting for a snapshot</p></footer>
|
|
219
|
+
<div id="announcement" class="screen-reader" role="status" aria-live="polite" aria-atomic="true"></div>
|
|
220
|
+
<div id="toast" class="toast" role="status" hidden></div>
|
|
221
|
+
</div>
|
|
222
|
+
<dialog id="diagnostics-dialog" class="diagnostics-dialog" aria-labelledby="diagnostics-title" aria-describedby="diagnostics-intro">
|
|
223
|
+
<div class="diagnostics-header">
|
|
224
|
+
<h2 id="diagnostics-title">Classification log</h2>
|
|
225
|
+
<button type="button" id="diagnostics-close" aria-label="Close classification log">Close</button>
|
|
226
|
+
</div>
|
|
227
|
+
<p id="diagnostics-intro">See which files were considered and why components were drawn or skipped.</p>
|
|
228
|
+
<p class="diagnostics-note">This is the live local log, including while replay is selected. Scores from before logging was enabled cannot be recovered.</p>
|
|
229
|
+
<div class="diagnostics-filters">
|
|
230
|
+
<label for="diagnostics-search">Find a record
|
|
231
|
+
<input id="diagnostics-search" type="search" maxlength="200" autocomplete="off" placeholder="File, label, event, or session">
|
|
232
|
+
</label>
|
|
233
|
+
<label for="diagnostics-session">Sessions
|
|
234
|
+
<select id="diagnostics-session"><option value="all">All sessions</option></select>
|
|
235
|
+
</label>
|
|
236
|
+
<button type="button" id="diagnostics-refresh">Refresh log</button>
|
|
237
|
+
</div>
|
|
238
|
+
<p id="diagnostics-loading" class="diagnostics-note" role="status" hidden>Loading classification records…</p>
|
|
239
|
+
<p id="diagnostics-error" class="diagnostics-error" role="alert" hidden></p>
|
|
240
|
+
<p id="diagnostics-count" class="diagnostics-note" role="status" aria-live="polite" aria-atomic="true"></p>
|
|
241
|
+
<p id="diagnostics-empty" class="diagnostics-empty" hidden></p>
|
|
242
|
+
<div id="diagnostics-records" class="diagnostics-records" role="region" aria-label="Classification records, newest first" aria-busy="false"></div>
|
|
243
|
+
<div class="diagnostics-footer">
|
|
244
|
+
<p id="diagnostics-updated" class="diagnostics-note"></p>
|
|
245
|
+
<p id="diagnostics-stats" class="diagnostics-note"></p>
|
|
246
|
+
<p id="diagnostics-log-path" class="diagnostics-log-path" hidden></p>
|
|
247
|
+
</div>
|
|
248
|
+
</dialog>
|
|
249
|
+
<dialog id="connection-dialog" class="connection-dialog" aria-labelledby="connection-dialog-title" aria-describedby="connection-dialog-intro">
|
|
250
|
+
<div class="connection-dialog-header">
|
|
251
|
+
<h2 id="connection-dialog-title">How to connect</h2>
|
|
252
|
+
<button type="button" id="connection-dialog-close" aria-label="Close connection instructions">Close</button>
|
|
253
|
+
</div>
|
|
254
|
+
<p id="connection-dialog-intro">Keep the server terminal open. Open a new terminal for the commands below.</p>
|
|
255
|
+
<p id="connection-project" class="connection-project" hidden></p>
|
|
256
|
+
<p id="connection-demo" class="notice demo-notice" hidden>Demo project. These instructions refer to the demo’s project.</p>
|
|
257
|
+
<p id="connection-loading" class="connection-loading" role="status" hidden>Loading connection instructions…</p>
|
|
258
|
+
<p id="connection-error" class="connection-error" role="alert" hidden></p>
|
|
259
|
+
<button type="button" id="connection-instructions-retry" hidden>Retry instructions</button>
|
|
260
|
+
<div id="connection-instructions" aria-busy="false"></div>
|
|
261
|
+
<ul id="connection-notes" class="connection-notes" hidden></ul>
|
|
262
|
+
<p id="connection-copy-status" class="connection-copy-status" role="status" aria-live="polite" aria-atomic="true"></p>
|
|
263
|
+
</dialog>
|
|
264
|
+
</body>
|
|
265
|
+
</html>
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
// Pure browser/Node layout; coordinates are the top-left of each node.
|
|
2
|
+
// Only positions are returned. Direction comes from actual edges, never kinds.
|
|
3
|
+
export const LAYOUT_ALGORITHMS = Object.freeze([
|
|
4
|
+
'hierarchy', 'dependency', 'grouped', 'circular', 'grid', 'original',
|
|
5
|
+
]);
|
|
6
|
+
|
|
7
|
+
const MAX_NODES = 256;
|
|
8
|
+
const MAX_EDGES = 768;
|
|
9
|
+
// Bound intake work as well as layout work. Ordinary oversized inputs select
|
|
10
|
+
// the lowest IDs/pairs deterministically; pathological arrays fail closed.
|
|
11
|
+
const MAX_INPUT_NODES = 4096;
|
|
12
|
+
const MAX_INPUT_EDGES = 12288;
|
|
13
|
+
const MAX_DIMENSION = 10000;
|
|
14
|
+
const SWEEPS = 4;
|
|
15
|
+
const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
16
|
+
const finite = value => typeof value === 'number' && Number.isFinite(value);
|
|
17
|
+
const compare = (a, b) => a < b ? -1 : a > b ? 1 : 0;
|
|
18
|
+
const validId = value => typeof value === 'string' && value.length > 0 && value.length <= 256;
|
|
19
|
+
const numeric = (value, fallback, minimum) => finite(value) && value >= minimum
|
|
20
|
+
? Math.min(value, MAX_DIMENSION) : fallback;
|
|
21
|
+
|
|
22
|
+
function compareNodes(a, b) {
|
|
23
|
+
const completeA = a.x !== null && a.y !== null;
|
|
24
|
+
const completeB = b.x !== null && b.y !== null;
|
|
25
|
+
return compare(a.id, b.id) || compare(completeB, completeA) || compare(a.kind, b.kind) ||
|
|
26
|
+
compare(a.x === null, b.x === null) || compare(a.x, b.x) ||
|
|
27
|
+
compare(a.y === null, b.y === null) || compare(a.y, b.y);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function prepare(graph) {
|
|
31
|
+
const inputNodes = Array.isArray(graph?.nodes) ? graph.nodes : [];
|
|
32
|
+
const inputEdges = Array.isArray(graph?.edges) ? graph.edges : [];
|
|
33
|
+
if (inputNodes.length > MAX_INPUT_NODES || inputEdges.length > MAX_INPUT_EDGES) {
|
|
34
|
+
return { nodes: [], edges: [] };
|
|
35
|
+
}
|
|
36
|
+
const byId = new Map();
|
|
37
|
+
for (const raw of inputNodes) {
|
|
38
|
+
if (!record(raw) || !validId(raw.id)) continue;
|
|
39
|
+
const node = {
|
|
40
|
+
id: raw.id,
|
|
41
|
+
kind: typeof raw.kind === 'string' && raw.kind.length > 0 && raw.kind.length <= 80 ? raw.kind : 'unknown',
|
|
42
|
+
x: finite(raw.x) ? raw.x : null,
|
|
43
|
+
y: finite(raw.y) ? raw.y : null,
|
|
44
|
+
};
|
|
45
|
+
const previous = byId.get(node.id);
|
|
46
|
+
if (!previous || compareNodes(node, previous) < 0) byId.set(node.id, node);
|
|
47
|
+
}
|
|
48
|
+
const nodes = [...byId.values()].sort(compareNodes).slice(0, MAX_NODES);
|
|
49
|
+
const index = new Map(nodes.map((node, i) => [node.id, i]));
|
|
50
|
+
const pairs = new Map();
|
|
51
|
+
for (const edge of inputEdges) {
|
|
52
|
+
if (!record(edge) || !index.has(edge.source) || !index.has(edge.target)) continue;
|
|
53
|
+
const source = index.get(edge.source), target = index.get(edge.target);
|
|
54
|
+
// A self-loop does not change a node's position. Parallel relations share
|
|
55
|
+
// one structural pair, so relation count does not bias placement.
|
|
56
|
+
if (source !== target) pairs.set(`${source}:${target}`, { source, target });
|
|
57
|
+
}
|
|
58
|
+
return { nodes, edges: [...pairs.values()].sort((a, b) => a.source - b.source || a.target - b.target).slice(0, MAX_EDGES) };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function grid(items, size) {
|
|
62
|
+
const columns = Math.max(1, Math.min(items.length,
|
|
63
|
+
Math.ceil(Math.sqrt(items.length * (size.h + size.gy) / (size.w + size.gx)))));
|
|
64
|
+
const positions = new Map(items.map((item, i) => [item, {
|
|
65
|
+
x: (i % columns) * (size.w + size.gx),
|
|
66
|
+
y: Math.floor(i / columns) * (size.h + size.gy),
|
|
67
|
+
}]));
|
|
68
|
+
return {
|
|
69
|
+
positions,
|
|
70
|
+
width: Math.min(columns, items.length) * (size.w + size.gx) - size.gx,
|
|
71
|
+
height: Math.ceil(items.length / columns) * (size.h + size.gy) - size.gy,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Shelf packing keeps disconnected components/kind groups disjoint without
|
|
76
|
+
// implying containment, ownership, or relationships between their members.
|
|
77
|
+
function pack(blocks, size) {
|
|
78
|
+
if (!blocks.length) return new Map();
|
|
79
|
+
const gapX = Math.max(size.gx * 2, size.w / 2, 16);
|
|
80
|
+
const gapY = Math.max(size.gy * 2, size.h / 2, 16);
|
|
81
|
+
const area = blocks.reduce((sum, block) => sum + (block.width + gapX) * (block.height + gapY), 0);
|
|
82
|
+
const targetWidth = Math.max(...blocks.map(block => block.width), Math.sqrt(area));
|
|
83
|
+
const result = new Map();
|
|
84
|
+
let x = 0, y = 0, rowHeight = 0;
|
|
85
|
+
for (const block of blocks) {
|
|
86
|
+
if (x > 0 && x + block.width > targetWidth) {
|
|
87
|
+
x = 0; y += rowHeight + gapY; rowHeight = 0;
|
|
88
|
+
}
|
|
89
|
+
for (const [id, point] of block.positions) result.set(id, { x: x + point.x, y: y + point.y });
|
|
90
|
+
x += block.width + gapX;
|
|
91
|
+
rowHeight = Math.max(rowHeight, block.height);
|
|
92
|
+
}
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function grouped(nodes, size) {
|
|
97
|
+
const kinds = new Map();
|
|
98
|
+
for (const node of nodes) {
|
|
99
|
+
if (!kinds.has(node.kind)) kinds.set(node.kind, []);
|
|
100
|
+
kinds.get(node.kind).push(node.id);
|
|
101
|
+
}
|
|
102
|
+
return pack([...kinds.keys()].sort(compare).map(kind => grid(kinds.get(kind), size)), size);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function circular(nodes, size) {
|
|
106
|
+
if (nodes.length === 1) return new Map([[nodes[0].id, { x: 0, y: 0 }]]);
|
|
107
|
+
// Adjacent centers are separated by the diagonal of an expanded node.
|
|
108
|
+
// Consequently every pair of axis-aligned node boxes is disjoint, even at
|
|
109
|
+
// the 256-node limit or with extreme aspect ratios. One pixel absorbs error.
|
|
110
|
+
const radius = (Math.hypot(size.w + size.gx, size.h + size.gy) + 1) /
|
|
111
|
+
(2 * Math.sin(Math.PI / nodes.length));
|
|
112
|
+
const points = nodes.map((node, i) => {
|
|
113
|
+
const angle = -Math.PI / 2 + i * 2 * Math.PI / nodes.length;
|
|
114
|
+
return [node.id, { x: radius * Math.cos(angle), y: radius * Math.sin(angle) }];
|
|
115
|
+
});
|
|
116
|
+
const minX = Math.min(...points.map(([, point]) => point.x));
|
|
117
|
+
const minY = Math.min(...points.map(([, point]) => point.y));
|
|
118
|
+
return new Map(points.map(([id, point]) => [id, { x: point.x - minX, y: point.y - minY }]));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function original(nodes, size) {
|
|
122
|
+
const result = new Map(), missing = [];
|
|
123
|
+
for (const node of nodes) {
|
|
124
|
+
if (node.x !== null && node.y !== null) result.set(node.id, { x: node.x, y: node.y });
|
|
125
|
+
else missing.push(node);
|
|
126
|
+
}
|
|
127
|
+
const columns = Math.max(1, Math.ceil(Math.sqrt(nodes.length)));
|
|
128
|
+
let next = 0;
|
|
129
|
+
// A fixed box can obstruct at most four cells of this grid. Five cells per
|
|
130
|
+
// node therefore suffice, including cells occupied by earlier fallbacks.
|
|
131
|
+
for (let slot = 0; next < missing.length && slot < MAX_NODES * 5; slot++) {
|
|
132
|
+
const point = { x: (slot % columns) * (size.w + size.gx),
|
|
133
|
+
y: Math.floor(slot / columns) * (size.h + size.gy) };
|
|
134
|
+
const obstructed = [...result.values()].some(other =>
|
|
135
|
+
Math.abs(point.x - other.x) < size.w + size.gx - 1e-8 &&
|
|
136
|
+
Math.abs(point.y - other.y) < size.h + size.gy - 1e-8);
|
|
137
|
+
if (!obstructed) result.set(missing[next++].id, point);
|
|
138
|
+
}
|
|
139
|
+
return result;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function stronglyConnected(outgoing) {
|
|
143
|
+
const indices = Array(outgoing.length).fill(-1), low = [], stacked = new Set(), stack = [], groups = [];
|
|
144
|
+
let counter = 0;
|
|
145
|
+
function visit(vertex) {
|
|
146
|
+
indices[vertex] = low[vertex] = counter++;
|
|
147
|
+
stack.push(vertex); stacked.add(vertex);
|
|
148
|
+
for (const target of outgoing[vertex]) {
|
|
149
|
+
if (indices[target] === -1) { visit(target); low[vertex] = Math.min(low[vertex], low[target]); }
|
|
150
|
+
else if (stacked.has(target)) low[vertex] = Math.min(low[vertex], indices[target]);
|
|
151
|
+
}
|
|
152
|
+
if (low[vertex] !== indices[vertex]) return;
|
|
153
|
+
const members = [];
|
|
154
|
+
let member;
|
|
155
|
+
do { member = stack.pop(); stacked.delete(member); members.push(member); } while (member !== vertex);
|
|
156
|
+
groups.push(members.sort((a, b) => a - b));
|
|
157
|
+
}
|
|
158
|
+
// Recursion is bounded by the admitted 256 vertices.
|
|
159
|
+
for (let i = 0; i < outgoing.length; i++) if (indices[i] === -1) visit(i);
|
|
160
|
+
return groups.sort((a, b) => a[0] - b[0]);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function condense(nodes, edges, size) {
|
|
164
|
+
const outgoing = nodes.map(() => []);
|
|
165
|
+
for (const { source, target } of edges) outgoing[source].push(target);
|
|
166
|
+
const members = stronglyConnected(outgoing);
|
|
167
|
+
const owner = new Map();
|
|
168
|
+
members.forEach((group, i) => group.forEach(vertex => owner.set(vertex, i)));
|
|
169
|
+
const blocks = members.map(group => grid(group.map(vertex => nodes[vertex].id), size));
|
|
170
|
+
const next = members.map(() => new Set()), previous = members.map(() => new Set());
|
|
171
|
+
for (const edge of edges) {
|
|
172
|
+
const source = owner.get(edge.source), target = owner.get(edge.target);
|
|
173
|
+
if (source !== target) { next[source].add(target); previous[target].add(source); }
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
blocks,
|
|
177
|
+
next: next.map(values => [...values].sort((a, b) => a - b)),
|
|
178
|
+
previous: previous.map(values => [...values].sort((a, b) => a - b)),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function weakComponents(next, previous) {
|
|
183
|
+
const seen = new Set(), result = [];
|
|
184
|
+
for (let root = 0; root < next.length; root++) {
|
|
185
|
+
if (seen.has(root)) continue;
|
|
186
|
+
const group = [], queue = [root];
|
|
187
|
+
seen.add(root);
|
|
188
|
+
for (let i = 0; i < queue.length; i++) {
|
|
189
|
+
const current = queue[i]; group.push(current);
|
|
190
|
+
for (const neighbor of [...next[current], ...previous[current]]) {
|
|
191
|
+
if (!seen.has(neighbor)) { seen.add(neighbor); queue.push(neighbor); }
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
result.push(group.sort((a, b) => a - b));
|
|
195
|
+
}
|
|
196
|
+
return result;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function rankedLayers(group, next, previous) {
|
|
200
|
+
const degrees = previous.map(values => values.length), rank = next.map(() => 0);
|
|
201
|
+
const queue = group.filter(vertex => degrees[vertex] === 0);
|
|
202
|
+
while (queue.length) {
|
|
203
|
+
queue.sort((a, b) => a - b);
|
|
204
|
+
const current = queue.shift();
|
|
205
|
+
for (const target of next[current]) {
|
|
206
|
+
rank[target] = Math.max(rank[target], rank[current] + 1);
|
|
207
|
+
if (--degrees[target] === 0) queue.push(target);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const layers = Array.from({ length: Math.max(...group.map(vertex => rank[vertex])) + 1 }, () => []);
|
|
211
|
+
for (const vertex of group) layers[rank[vertex]].push(vertex);
|
|
212
|
+
return { layers, rank };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function rowWidth(row, sizes, gap) {
|
|
216
|
+
return row.reduce((sum, vertex) => sum + sizes[vertex], 0) + Math.max(0, row.length - 1) * gap;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function crossCenters(layers, sizes, gap) {
|
|
220
|
+
const centers = new Map();
|
|
221
|
+
for (const row of layers) {
|
|
222
|
+
let offset = -rowWidth(row, sizes, gap) / 2;
|
|
223
|
+
for (const vertex of row) {
|
|
224
|
+
centers.set(vertex, offset + sizes[vertex] / 2);
|
|
225
|
+
offset += sizes[vertex] + gap;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return centers;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function reduceCrossings(initial, rank, next, previous, sizes, gap) {
|
|
232
|
+
const edges = initial.flat().flatMap(source => next[source].map(target => ({ source, target })));
|
|
233
|
+
const bands = new Map();
|
|
234
|
+
for (const edge of edges) {
|
|
235
|
+
const key = `${rank[edge.source]}:${rank[edge.target]}`;
|
|
236
|
+
if (!bands.has(key)) bands.set(key, []);
|
|
237
|
+
bands.get(key).push(edge);
|
|
238
|
+
}
|
|
239
|
+
function quality(layers) {
|
|
240
|
+
const centers = crossCenters(layers, sizes, gap);
|
|
241
|
+
let crossings = 0, span = 0;
|
|
242
|
+
for (const edge of edges) span += Math.abs(centers.get(edge.source) - centers.get(edge.target));
|
|
243
|
+
for (const band of bands.values()) for (let i = 0; i < band.length; i++) {
|
|
244
|
+
for (let j = i + 1; j < band.length; j++) {
|
|
245
|
+
const a = band[i], b = band[j];
|
|
246
|
+
if ((centers.get(a.source) - centers.get(b.source)) *
|
|
247
|
+
(centers.get(a.target) - centers.get(b.target)) < 0) crossings++;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return { crossings, span };
|
|
251
|
+
}
|
|
252
|
+
let layers = initial.map(row => [...row]), best = layers.map(row => [...row]), score = quality(best);
|
|
253
|
+
// Fixed forward/backward barycenter sweeps, retaining only the best ordering.
|
|
254
|
+
// Comparison uses actual endpoint geometry and does not introduce edges.
|
|
255
|
+
for (let sweep = 0; sweep < SWEEPS; sweep++) for (const forward of [true, false]) {
|
|
256
|
+
let centers = crossCenters(layers, sizes, gap);
|
|
257
|
+
const order = layers.map((_, i) => i);
|
|
258
|
+
if (!forward) order.reverse();
|
|
259
|
+
for (const index of order) {
|
|
260
|
+
const row = layers[index], neighbors = forward ? previous : next;
|
|
261
|
+
const scored = row.map((vertex, oldIndex) => ({
|
|
262
|
+
vertex, oldIndex,
|
|
263
|
+
value: neighbors[vertex].length
|
|
264
|
+
? neighbors[vertex].reduce((sum, neighbor) => sum + centers.get(neighbor), 0) / neighbors[vertex].length
|
|
265
|
+
: centers.get(vertex),
|
|
266
|
+
}));
|
|
267
|
+
scored.sort((a, b) => a.value - b.value || a.oldIndex - b.oldIndex || a.vertex - b.vertex);
|
|
268
|
+
layers[index] = scored.map(item => item.vertex);
|
|
269
|
+
centers = crossCenters(layers, sizes, gap);
|
|
270
|
+
}
|
|
271
|
+
const candidate = quality(layers);
|
|
272
|
+
if (candidate.crossings < score.crossings ||
|
|
273
|
+
(candidate.crossings === score.crossings && candidate.span < score.span - 1e-8)) {
|
|
274
|
+
score = candidate; best = layers.map(row => [...row]);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return best;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function directed(nodes, edges, size, horizontal) {
|
|
281
|
+
const { blocks, next, previous } = condense(nodes, edges, size);
|
|
282
|
+
const crossSizes = blocks.map(block => horizontal ? block.height : block.width);
|
|
283
|
+
const flowSizes = blocks.map(block => horizontal ? block.width : block.height);
|
|
284
|
+
const crossGap = horizontal ? size.gy : size.gx, flowGap = horizontal ? size.gx : size.gy;
|
|
285
|
+
const components = weakComponents(next, previous).map(group => {
|
|
286
|
+
const ranked = rankedLayers(group, next, previous);
|
|
287
|
+
const layers = reduceCrossings(ranked.layers, ranked.rank, next, previous, crossSizes, crossGap);
|
|
288
|
+
const breadth = Math.max(...layers.map(row => rowWidth(row, crossSizes, crossGap)));
|
|
289
|
+
const positions = new Map();
|
|
290
|
+
let flow = 0;
|
|
291
|
+
for (const row of layers) {
|
|
292
|
+
const depth = Math.max(...row.map(vertex => flowSizes[vertex]));
|
|
293
|
+
let cross = (breadth - rowWidth(row, crossSizes, crossGap)) / 2;
|
|
294
|
+
for (const vertex of row) {
|
|
295
|
+
const offset = flow + (depth - flowSizes[vertex]) / 2;
|
|
296
|
+
for (const [id, point] of blocks[vertex].positions) positions.set(id, {
|
|
297
|
+
x: point.x + (horizontal ? offset : cross),
|
|
298
|
+
y: point.y + (horizontal ? cross : offset),
|
|
299
|
+
});
|
|
300
|
+
cross += crossSizes[vertex] + crossGap;
|
|
301
|
+
}
|
|
302
|
+
flow += depth + flowGap;
|
|
303
|
+
}
|
|
304
|
+
return { positions, width: horizontal ? flow - flowGap : breadth,
|
|
305
|
+
height: horizontal ? breadth : flow - flowGap };
|
|
306
|
+
});
|
|
307
|
+
return pack(components, size);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Return a fresh Map<id, {x,y}> in ID order without modifying graph/options.
|
|
312
|
+
* Algorithms: hierarchy (default, top-down), dependency (left-right), grouped
|
|
313
|
+
* (kind only), circular, grid, original. Unknown algorithms use hierarchy.
|
|
314
|
+
* Generated layouts do not overlap; original intentionally preserves finite
|
|
315
|
+
* coordinate pairs, including existing overlaps, and fills missing pairs.
|
|
316
|
+
* Dimensions: finite [1, 10000], gaps [0, 10000]; invalid values use defaults.
|
|
317
|
+
* Layout admits 256 unique nodes and 768 distinct directed endpoint pairs.
|
|
318
|
+
* Inputs above 4096 nodes or 12288 edges return an empty Map (bounded intake).
|
|
319
|
+
*/
|
|
320
|
+
export function layoutGraph(graph, options = {}) {
|
|
321
|
+
const settings = record(options) ? options : {};
|
|
322
|
+
const size = {
|
|
323
|
+
w: numeric(settings.nodeWidth, 190, 1), h: numeric(settings.nodeHeight, 104, 1),
|
|
324
|
+
gx: numeric(settings.gapX, 80, 0), gy: numeric(settings.gapY, 80, 0),
|
|
325
|
+
};
|
|
326
|
+
const { nodes, edges } = prepare(graph);
|
|
327
|
+
if (!nodes.length) return new Map();
|
|
328
|
+
const algorithm = LAYOUT_ALGORITHMS.includes(settings.algorithm) ? settings.algorithm : 'hierarchy';
|
|
329
|
+
let positions;
|
|
330
|
+
if (algorithm === 'grid') positions = grid(nodes.map(node => node.id), size).positions;
|
|
331
|
+
else if (algorithm === 'original') positions = original(nodes, size);
|
|
332
|
+
else if (algorithm === 'grouped') positions = grouped(nodes, size);
|
|
333
|
+
else if (algorithm === 'circular') positions = circular(nodes, size);
|
|
334
|
+
else positions = directed(nodes, edges, size, algorithm === 'dependency');
|
|
335
|
+
return new Map(nodes.map(node => [node.id, positions.get(node.id)]));
|
|
336
|
+
}
|