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,2596 @@
|
|
|
1
|
+
import { layoutGraph, LAYOUT_ALGORITHMS } from './layout.js';
|
|
2
|
+
import { sketchOutline, sketchDetails, sketchConnection } from './sketch.js';
|
|
3
|
+
import { createLiveSidebar } from './sidebar.js';
|
|
4
|
+
|
|
5
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
6
|
+
const MAX_JSON_BYTES = 8 * 1024 * 1024;
|
|
7
|
+
const LIMITS = Object.freeze({ nodes: 500, edges: 1500, activity: 200, hookEvents: 200, history: 100, refs: 32, sessions: 100 });
|
|
8
|
+
const ROLES = ['client', 'service', 'datastore', 'queue', 'external', 'module', 'function', 'class', 'interface', 'event', 'configuration', 'package'];
|
|
9
|
+
export const SHAPE_NAMES = Object.freeze({
|
|
10
|
+
rounded_rect: 'Rounded rectangle', rect: 'Rectangle', cylinder: 'Cylinder', cloud: 'Cloud',
|
|
11
|
+
diamond: 'Diamond', group: 'Group', browser: 'Browser', component: 'Component',
|
|
12
|
+
queue: 'Queue', hexagon: 'Hexagon', class_box: 'Class box', interface_box: 'Interface box',
|
|
13
|
+
document: 'Document', parallelogram: 'Parallelogram', folder: 'Folder',
|
|
14
|
+
});
|
|
15
|
+
const SHAPES = Object.keys(SHAPE_NAMES);
|
|
16
|
+
export const THEME_NAMES = Object.freeze({
|
|
17
|
+
sketchbook: 'Sketchbook', ocean: 'Ocean', forest: 'Forest', sunset: 'Sunset',
|
|
18
|
+
berry: 'Berry', sepia: 'Sepia', blueprint: 'Blueprint dark', midnight: 'Midnight dark',
|
|
19
|
+
});
|
|
20
|
+
const THEMES = Object.keys(THEME_NAMES);
|
|
21
|
+
const RELATIONS = ['calls', 'reads', 'writes', 'publishes', 'consumes', 'depends_on'];
|
|
22
|
+
const CLASSIFICATIONS = ['pending', 'accepted', 'tentative', 'abstained', 'stale'];
|
|
23
|
+
const EVIDENCE = ['proposed', 'observed', 'verified', 'removed'];
|
|
24
|
+
const VALIDITY = ['current', 'stale', 'retracted'];
|
|
25
|
+
const ACTIVITY = ['idle', 'pending', 'running', 'failed', 'interrupted', 'unknown'];
|
|
26
|
+
const EVENT_STATES = ['pending', 'succeeded', 'failed', 'interrupted', 'unresolved', 'observed'];
|
|
27
|
+
const CLASSIFIERS = ['ready', 'metadata_only', 'missing_key', 'paused', 'unavailable', 'timeout', 'demo'];
|
|
28
|
+
const ROLE_SHAPES = {
|
|
29
|
+
client: 'browser', service: 'component', datastore: 'cylinder', queue: 'queue', external: 'cloud',
|
|
30
|
+
module: 'rect', function: 'hexagon', class: 'class_box', interface: 'interface_box',
|
|
31
|
+
event: 'document', configuration: 'parallelogram', package: 'folder',
|
|
32
|
+
};
|
|
33
|
+
const PROBABILITIES = ['supportProbability', 'roleProbability', 'roleConfidence', 'missingContextProbability'];
|
|
34
|
+
const NODE_WIDTH = 190;
|
|
35
|
+
const NODE_HEIGHT = 104;
|
|
36
|
+
const EDGE_LANE_GAP = 36;
|
|
37
|
+
const EDGE_RELATION_ORDER = ['calls', 'writes', 'depends_on', 'reads', 'publishes', 'consumes'];
|
|
38
|
+
const LAYOUT_NAMES = {
|
|
39
|
+
hierarchy: 'Hierarchy top-down', dependency: 'Dependency left-right', grouped: 'Group by type',
|
|
40
|
+
circular: 'Circular', grid: 'Grid', original: 'Original', force: 'Force-directed',
|
|
41
|
+
};
|
|
42
|
+
const MAX_VIEWS = 32;
|
|
43
|
+
const MAX_EFFECTS = 16;
|
|
44
|
+
const MAX_SKETCHES = 512;
|
|
45
|
+
const MIN_ZOOM = .000001;
|
|
46
|
+
const MAX_ZOOM = 4;
|
|
47
|
+
const own = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
|
|
48
|
+
const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
49
|
+
const array = value => Array.isArray(value) ? value : [];
|
|
50
|
+
const token = (value, allowed, fallback) => allowed.includes(value) ? value : fallback;
|
|
51
|
+
const count = value => Number.isSafeInteger(value) && value >= 0 ? Math.min(value, 1e9) : 0;
|
|
52
|
+
const probability = value => typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1;
|
|
53
|
+
const coordinate = value => typeof value === 'number' && Number.isFinite(value) && Math.abs(value) <= 1e6;
|
|
54
|
+
|
|
55
|
+
export function safeText(value, max = 180) {
|
|
56
|
+
return typeof value === 'string' ? value.replace(/[\u0000-\u0008\u000b-\u001f\u007f\u202a-\u202e\u2066-\u2069]/g, '').slice(0, max) : '';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function identifier(value) { return safeText(value, 180); }
|
|
60
|
+
function readable(value) { return value.replaceAll('_', ' '); }
|
|
61
|
+
function upperFirst(value) { return value ? value[0].toUpperCase() + value.slice(1) : ''; }
|
|
62
|
+
function validTime(value) {
|
|
63
|
+
if (typeof value !== 'string' && typeof value !== 'number') return null;
|
|
64
|
+
const timestamp = typeof value === 'number' ? value : Date.parse(value);
|
|
65
|
+
return Number.isFinite(timestamp) && Math.abs(timestamp) <= 8.64e15 ? timestamp : null;
|
|
66
|
+
}
|
|
67
|
+
function shortId(value) { return value.length > 19 ? `${value.slice(0, 9)}…${value.slice(-6)}` : value; }
|
|
68
|
+
function clip(value, size) { return value.length > size ? `${value.slice(0, size - 1)}…` : value; }
|
|
69
|
+
function hashId(id) {
|
|
70
|
+
let hash = 2166136261;
|
|
71
|
+
for (let i = 0; i < id.length; i += 1) hash = Math.imul(hash ^ id.charCodeAt(i), 16777619);
|
|
72
|
+
return hash >>> 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function createSketchCache(draw = sketchOutline) {
|
|
76
|
+
const entries = new Map();
|
|
77
|
+
return {
|
|
78
|
+
paths(shape, id) {
|
|
79
|
+
const key = JSON.stringify([shape, id]);
|
|
80
|
+
let paths = entries.get(key);
|
|
81
|
+
if (paths) entries.delete(key);
|
|
82
|
+
else paths = Object.freeze(draw(shape, id).slice(0, 2));
|
|
83
|
+
entries.set(key, paths);
|
|
84
|
+
while (entries.size > MAX_SKETCHES) entries.delete(entries.keys().next().value);
|
|
85
|
+
return paths;
|
|
86
|
+
},
|
|
87
|
+
clear() { entries.clear(); },
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function createPresentation() {
|
|
92
|
+
return { algorithm: 'hierarchy', auto: true, theme: 'sketchbook', positions: new Map(), shapes: new Map(), signature: '', camera: null };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function presentationKey(snapshot, replayFrame = null) {
|
|
96
|
+
return JSON.stringify([snapshot.projectId, snapshot.sessionId, replayFrame ? `revision:${replayFrame.revision}` : 'live']);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function layoutSignature(graph, algorithm) {
|
|
100
|
+
// Parallel relation types, labels, confidence and activity do not change the
|
|
101
|
+
// structural layout. Legacy diamonds are the only larger shape envelope.
|
|
102
|
+
const nodes = graph.nodes.map(node => [node.id, node.kind, node.shape === 'diamond',
|
|
103
|
+
...(algorithm === 'original' ? [node.x, node.y] : [])]).sort((a, b) => a[0].localeCompare(b[0]));
|
|
104
|
+
const pairs = [...new Set(graph.edges.map(edge => JSON.stringify([edge.source, edge.target])))].sort();
|
|
105
|
+
return JSON.stringify([algorithm, nodes, pairs]);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function stagedPositions(nodes, supplied) {
|
|
109
|
+
const positions = new Map();
|
|
110
|
+
for (const node of nodes) {
|
|
111
|
+
const point = supplied.get(node.id);
|
|
112
|
+
// These positions are generated locally. Staging can extend beyond the
|
|
113
|
+
// canonical coordinate intake bound and must not discard that position on
|
|
114
|
+
// the next metadata-only snapshot.
|
|
115
|
+
if (point && Number.isFinite(point.x) && Number.isFinite(point.y)) positions.set(node.id, { x: point.x, y: point.y });
|
|
116
|
+
}
|
|
117
|
+
const missing = nodes.filter(node => !positions.has(node.id)).sort((a, b) => a.id.localeCompare(b.id));
|
|
118
|
+
// A disjoint shelf beyond all placed nodes also handles bounded layout
|
|
119
|
+
// engines returning fewer nodes than the viewer admits.
|
|
120
|
+
const startX = positions.size ? Math.max(...[...positions.values()].map(point => point.x)) + NODE_WIDTH + 80 : 0;
|
|
121
|
+
const startY = positions.size ? Math.min(...[...positions.values()].map(point => point.y)) : 0;
|
|
122
|
+
const columns = Math.max(1, Math.ceil(Math.sqrt(missing.length)));
|
|
123
|
+
missing.forEach((node, index) => positions.set(node.id, {
|
|
124
|
+
x: startX + index % columns * (NODE_WIDTH + 80),
|
|
125
|
+
y: startY + Math.floor(index / columns) * (NODE_HEIGHT + 80),
|
|
126
|
+
}));
|
|
127
|
+
return positions;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function displayShape(node, overrides) {
|
|
131
|
+
const override = overrides.get(node.id);
|
|
132
|
+
return override === 'automatic' ? ROLE_SHAPES[node.kind] : token(override, SHAPES, node.shape);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function projectPresentation(graph, view, { arrange = false, layout = layoutGraph } = {}) {
|
|
136
|
+
const signature = layoutSignature(graph, view.algorithm);
|
|
137
|
+
if (view.algorithm === 'original') {
|
|
138
|
+
view.positions = new Map(graph.nodes.map(node => [node.id, { x: node.x, y: node.y }]));
|
|
139
|
+
} else if (arrange || (view.auto && signature !== view.signature)) {
|
|
140
|
+
const positions = layout({
|
|
141
|
+
nodes: graph.nodes.map(({ id, kind, x, y }) => ({ id, kind, x, y })),
|
|
142
|
+
edges: graph.edges.map(({ id, source, target }) => ({ id, source, target })),
|
|
143
|
+
}, { algorithm: view.algorithm, nodeWidth: NODE_WIDTH, nodeHeight: NODE_HEIGHT, gapX: 80, gapY: 80 });
|
|
144
|
+
view.positions = stagedPositions(graph.nodes, positions instanceof Map ? positions : new Map());
|
|
145
|
+
} else view.positions = stagedPositions(graph.nodes, view.positions);
|
|
146
|
+
view.signature = signature;
|
|
147
|
+
return { ...graph, nodes: graph.nodes.map(node => ({ ...node, ...view.positions.get(node.id), shape: displayShape(node, view.shapes) })) };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function liveNodeChanges(previous, next, eligible) {
|
|
151
|
+
if (!eligible || !previous || !next) return { added: [], removed: [] };
|
|
152
|
+
const before = new Set(previous.nodes.map(node => node.id));
|
|
153
|
+
const after = new Set(next.nodes.map(node => node.id));
|
|
154
|
+
return {
|
|
155
|
+
added: next.nodes.filter(node => !before.has(node.id)).map(node => node.id),
|
|
156
|
+
removed: previous.nodes.filter(node => !after.has(node.id)).map(node => node.id),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function nodeTitleWidth(shapeName) {
|
|
161
|
+
return { queue: 132, component: 142, parallelogram: 144, diamond: 140 }[shapeName] || 158;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function nodeTitleLines(label, shapeName) {
|
|
165
|
+
const width = nodeTitleWidth(shapeName);
|
|
166
|
+
// Conservative advances for a 14px title, independent of font availability.
|
|
167
|
+
// The title's SVG viewport also clips any wider fallback glyphs.
|
|
168
|
+
const measure = text => [...text].reduce((sum, char) => sum +
|
|
169
|
+
(/[^\x20-\x7e]|[MWmw@%&]/.test(char) ? 16 : /[A-Z]/.test(char) ? 11 : /[il.,' :;]/.test(char) ? 5 : 9), 0);
|
|
170
|
+
const words = safeText(label).replace(/([a-z0-9])([A-Z])/g, '$1 $2').trim().split(/\s+/);
|
|
171
|
+
const lines = [''];
|
|
172
|
+
for (const word of words) {
|
|
173
|
+
const last = lines.length - 1;
|
|
174
|
+
const candidate = lines[last] ? `${lines[last]} ${word}` : word;
|
|
175
|
+
if (measure(candidate) <= width) { lines[last] = candidate; continue; }
|
|
176
|
+
if (lines[last]) lines.push('');
|
|
177
|
+
for (const char of word) {
|
|
178
|
+
const index = lines.length - 1;
|
|
179
|
+
if (measure(lines[index] + char) > width) lines.push(char);
|
|
180
|
+
else lines[index] += char;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (lines.length > 2) {
|
|
184
|
+
const characters = [...lines[1]];
|
|
185
|
+
while (measure(characters.join('') + '…') > width) characters.pop();
|
|
186
|
+
lines[1] = characters.join('') + '…';
|
|
187
|
+
}
|
|
188
|
+
return lines.slice(0, 2);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function normalizeConfidence(value) {
|
|
192
|
+
if (probability(value)) return { reportedConfidence: value };
|
|
193
|
+
if (!record(value)) return {};
|
|
194
|
+
const result = {};
|
|
195
|
+
for (const key of PROBABILITIES) if (probability(value[key])) result[key] = value[key];
|
|
196
|
+
if (probability(value.reportedConfidence)) result.reportedConfidence = value.reportedConfidence;
|
|
197
|
+
if (record(value.roleProbabilities)) {
|
|
198
|
+
const roles = {};
|
|
199
|
+
for (const role of [...ROLES, 'unknown']) if (probability(value.roleProbabilities[role])) roles[role] = value.roleProbabilities[role];
|
|
200
|
+
if (Object.keys(roles).length) result.roleProbabilities = roles;
|
|
201
|
+
}
|
|
202
|
+
return result;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function normalizeRefs(value, includeExcerpts = true) {
|
|
206
|
+
return array(value).slice(0, LIMITS.refs).filter(record).map(ref => {
|
|
207
|
+
const result = {
|
|
208
|
+
artifactId: identifier(ref.artifactId),
|
|
209
|
+
hash: safeText(ref.hash, 180),
|
|
210
|
+
generation: count(ref.generation),
|
|
211
|
+
eventId: identifier(ref.eventId),
|
|
212
|
+
startLine: count(ref.startLine),
|
|
213
|
+
endLine: count(ref.endLine),
|
|
214
|
+
sourceClass: token(ref.sourceClass, ['source', 'public_intent'], 'unknown'),
|
|
215
|
+
basis: ref.basis === 'jev_interpretation' ? 'jev_interpretation' : 'unknown',
|
|
216
|
+
};
|
|
217
|
+
if (record(ref.sourceRef) && ref.sourceRef.type === 'artifact') {
|
|
218
|
+
result.sourceRef = {
|
|
219
|
+
type: 'artifact', artifactId: identifier(ref.sourceRef.artifactId),
|
|
220
|
+
hash: safeText(ref.sourceRef.hash, 180), generation: count(ref.sourceRef.generation),
|
|
221
|
+
};
|
|
222
|
+
} else if (record(ref.sourceRef) && ref.sourceRef.type === 'message') {
|
|
223
|
+
result.sourceRef = {
|
|
224
|
+
type: 'message', messageId: identifier(ref.sourceRef.messageId),
|
|
225
|
+
hash: safeText(ref.sourceRef.hash, 180), contentVersion: count(ref.sourceRef.contentVersion),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
if (includeExcerpts && typeof ref.excerpt === 'string') result.excerpt = safeText(ref.excerpt, 6000);
|
|
229
|
+
return result;
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function claimFields(value, includeExcerpts) {
|
|
234
|
+
const result = {
|
|
235
|
+
evidenceState: token(value.evidenceState, EVIDENCE, 'proposed'),
|
|
236
|
+
classification: token(value.classification, CLASSIFICATIONS, 'tentative'),
|
|
237
|
+
validity: token(value.validity, VALIDITY, 'stale'),
|
|
238
|
+
sourceRefs: normalizeRefs(value.sourceRefs, includeExcerpts),
|
|
239
|
+
};
|
|
240
|
+
if (probability(value.confidence)) result.confidence = value.confidence;
|
|
241
|
+
else if (record(value.confidence)) {
|
|
242
|
+
const confidence = normalizeConfidence(value.confidence);
|
|
243
|
+
if (Object.keys(confidence).length) result.confidence = confidence;
|
|
244
|
+
}
|
|
245
|
+
return result;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function normalizeGraph(value, { includeExcerpts = true } = {}) {
|
|
249
|
+
if (!record(value) || value.schemaVersion !== 1 || !Array.isArray(value.nodes) || !Array.isArray(value.edges)) {
|
|
250
|
+
throw new Error('invalid_snapshot');
|
|
251
|
+
}
|
|
252
|
+
const nodes = [];
|
|
253
|
+
const edges = [];
|
|
254
|
+
const nodeIds = new Set();
|
|
255
|
+
const edgeIds = new Set();
|
|
256
|
+
for (const raw of value.nodes.slice(0, LIMITS.nodes)) {
|
|
257
|
+
if (!record(raw)) continue;
|
|
258
|
+
const id = identifier(raw.id);
|
|
259
|
+
if (!id || nodeIds.has(id)) continue;
|
|
260
|
+
const kind = token(raw.kind, ROLES, 'module');
|
|
261
|
+
// Normal coordinates are compiler-owned. Corrupt coordinates receive a
|
|
262
|
+
// deterministic, finite fallback without changing any valid node position.
|
|
263
|
+
const fallback = hashId(id);
|
|
264
|
+
nodes.push({
|
|
265
|
+
id, label: safeText(raw.label) || 'Unnamed component', kind,
|
|
266
|
+
shape: token(raw.shape, SHAPES, ROLE_SHAPES[kind]),
|
|
267
|
+
x: coordinate(raw.x) ? raw.x : 40 + (fallback % 4) * 270,
|
|
268
|
+
y: coordinate(raw.y) ? raw.y : 40 + (Math.floor(fallback / 4) % 8) * 170,
|
|
269
|
+
activityState: token(raw.activityState, ACTIVITY, 'unknown'),
|
|
270
|
+
...claimFields(raw, includeExcerpts),
|
|
271
|
+
});
|
|
272
|
+
nodeIds.add(id);
|
|
273
|
+
}
|
|
274
|
+
for (const raw of value.edges.slice(0, LIMITS.edges)) {
|
|
275
|
+
if (!record(raw)) continue;
|
|
276
|
+
const id = identifier(raw.id);
|
|
277
|
+
const source = identifier(raw.source);
|
|
278
|
+
const target = identifier(raw.target);
|
|
279
|
+
if (!id || edgeIds.has(id) || !nodeIds.has(source) || !nodeIds.has(target) || !RELATIONS.includes(raw.relation)) continue;
|
|
280
|
+
edges.push({
|
|
281
|
+
id, source, target, relation: raw.relation,
|
|
282
|
+
label: safeText(raw.label, 100) || readable(raw.relation),
|
|
283
|
+
...claimFields(raw, includeExcerpts),
|
|
284
|
+
});
|
|
285
|
+
edgeIds.add(id);
|
|
286
|
+
}
|
|
287
|
+
return { schemaVersion: 1, revision: count(value.revision), nodes, edges };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function normalizeCoverage(value) {
|
|
291
|
+
if (typeof value === 'string') return safeText(value, 320);
|
|
292
|
+
if (typeof value === 'number') return count(value);
|
|
293
|
+
if (!record(value)) return null;
|
|
294
|
+
const result = {};
|
|
295
|
+
for (const key of ['gaps', 'unsupported', 'incomplete', 'captured', 'total', 'supported', 'omitted', 'dropped']) {
|
|
296
|
+
if (Number.isSafeInteger(value[key]) && value[key] >= 0) result[key] = count(value[key]);
|
|
297
|
+
}
|
|
298
|
+
for (const key of ['tools', 'publicIntent', 'manualOnly']) {
|
|
299
|
+
if (typeof value[key] === 'boolean') result[key] = value[key];
|
|
300
|
+
}
|
|
301
|
+
for (const key of ['mode', 'level']) if (typeof value[key] === 'string') result[key] = safeText(value[key], 80);
|
|
302
|
+
return result;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function normalizeActivity(raw) {
|
|
306
|
+
return {
|
|
307
|
+
schemaVersion: 1,
|
|
308
|
+
id: identifier(raw.id),
|
|
309
|
+
projectId: identifier(raw.projectId),
|
|
310
|
+
sessionId: identifier(raw.sessionId),
|
|
311
|
+
agentId: identifier(raw.agentId),
|
|
312
|
+
toolCallId: raw.toolCallId === null ? null : identifier(raw.toolCallId),
|
|
313
|
+
kind: safeText(raw.kind, 60),
|
|
314
|
+
toolCategory: safeText(raw.toolCategory, 50),
|
|
315
|
+
outcome: safeText(raw.outcome, 40),
|
|
316
|
+
at: validTime(raw.at),
|
|
317
|
+
sequence: count(raw.sequence),
|
|
318
|
+
incomplete: raw.incomplete === true,
|
|
319
|
+
label: safeText(raw.label) || 'Observed event',
|
|
320
|
+
state: token(raw.state, EVENT_STATES, 'unresolved'),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export function normalizeSnapshot(value, { includeExcerpts = true } = {}) {
|
|
325
|
+
if (!record(value) || value.schemaVersion !== 1 || !record(value.status)) throw new Error('invalid_snapshot');
|
|
326
|
+
const graph = normalizeGraph(value.graph, { includeExcerpts });
|
|
327
|
+
const history = [];
|
|
328
|
+
for (const frame of array(value.history).slice(-LIMITS.history)) {
|
|
329
|
+
if (!record(frame) || !record(frame.graph)) continue;
|
|
330
|
+
try {
|
|
331
|
+
const historicalGraph = normalizeGraph(frame.graph, { includeExcerpts });
|
|
332
|
+
history.push({ revision: historicalGraph.revision, at: validTime(frame.at), graph: historicalGraph });
|
|
333
|
+
} catch { /* An invalid historical frame cannot replace the current graph. */ }
|
|
334
|
+
}
|
|
335
|
+
const sessions = [];
|
|
336
|
+
const sessionIds = new Set();
|
|
337
|
+
for (const session of array(value.sessions).slice(0, LIMITS.sessions)) {
|
|
338
|
+
if (!record(session)) continue;
|
|
339
|
+
const id = identifier(session.id);
|
|
340
|
+
if (!id || sessionIds.has(id)) continue;
|
|
341
|
+
sessions.push({ id, label: safeText(session.label, 120) || `Session ${shortId(id)}` });
|
|
342
|
+
sessionIds.add(id);
|
|
343
|
+
}
|
|
344
|
+
const sessionId = value.sessionId === null ? null : identifier(value.sessionId);
|
|
345
|
+
if (sessionId && !sessionIds.has(sessionId)) sessions.push({ id: sessionId, label: `Session ${shortId(sessionId)}` });
|
|
346
|
+
return {
|
|
347
|
+
schemaVersion: 1,
|
|
348
|
+
projectId: identifier(value.projectId),
|
|
349
|
+
sessionId,
|
|
350
|
+
mode: token(value.mode, ['live', 'demo', 'replay'], 'live'),
|
|
351
|
+
paused: value.paused === true,
|
|
352
|
+
sessions,
|
|
353
|
+
graph,
|
|
354
|
+
activity: array(value.activity).slice(-LIMITS.activity).filter(record).map(normalizeActivity),
|
|
355
|
+
...(Array.isArray(value.hookEvents) ? {
|
|
356
|
+
hookEvents: value.hookEvents.slice(-LIMITS.hookEvents).filter(record).map(event => ({
|
|
357
|
+
...normalizeActivity(event), receipt: count(event.receipt),
|
|
358
|
+
})),
|
|
359
|
+
} : {}),
|
|
360
|
+
history,
|
|
361
|
+
status: {
|
|
362
|
+
connection: safeText(value.status.connection, 80),
|
|
363
|
+
classifier: token(value.status.classifier, CLASSIFIERS, 'unavailable'),
|
|
364
|
+
coverage: normalizeCoverage(value.status.coverage),
|
|
365
|
+
dropped: count(value.status.dropped),
|
|
366
|
+
pending: count(value.status.pending),
|
|
367
|
+
calls: count(value.status.calls),
|
|
368
|
+
},
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export function sanitizedExport(value) {
|
|
373
|
+
// Reproject the export endpoint, including historical graphs. Display
|
|
374
|
+
// permission does not imply that source excerpts should leave the viewer.
|
|
375
|
+
const result = normalizeSnapshot(value, { includeExcerpts: false });
|
|
376
|
+
for (const event of result.activity) if (event.at !== null) event.at = new Date(event.at).toISOString();
|
|
377
|
+
for (const event of array(result.hookEvents)) if (event.at !== null) event.at = new Date(event.at).toISOString();
|
|
378
|
+
for (const frame of result.history) if (frame.at !== null) frame.at = new Date(frame.at).toISOString();
|
|
379
|
+
return result;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export function coverageSummary(coverage, dropped = 0) {
|
|
383
|
+
let label = 'Coverage not reported';
|
|
384
|
+
if (typeof coverage === 'string' && coverage) label = `Coverage: ${readable(coverage)}`;
|
|
385
|
+
else if (typeof coverage === 'number') label = coverage ? `${coverage} coverage gaps` : 'No reported coverage gaps';
|
|
386
|
+
else if (record(coverage)) {
|
|
387
|
+
if (coverage.manualOnly) label = 'Manual control only';
|
|
388
|
+
else if (coverage.tools && coverage.publicIntent) label = 'Tools + public intent';
|
|
389
|
+
else if (coverage.tools) label = 'Tools only';
|
|
390
|
+
else if (coverage.mode || coverage.level) label = `Coverage: ${readable(coverage.mode || coverage.level)}`;
|
|
391
|
+
const gaps = count(coverage.gaps ?? coverage.unsupported) + count(coverage.incomplete) + count(coverage.omitted);
|
|
392
|
+
if (gaps) label += ` · ${gaps} reported gaps`;
|
|
393
|
+
else if (own(coverage, 'gaps')) label += ' · no reported gaps';
|
|
394
|
+
}
|
|
395
|
+
return dropped ? `${label} · ${dropped} dropped` : label;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export function claimSummary(claim) {
|
|
399
|
+
const refs = claim.sourceRefs || [];
|
|
400
|
+
if (claim.validity === 'retracted' || claim.evidenceState === 'removed') {
|
|
401
|
+
return { tone: 'stale', label: 'Support retracted', explanation: 'The recorded support was retracted. This claim is retained here for context or replay.' };
|
|
402
|
+
}
|
|
403
|
+
if (claim.validity === 'stale' || claim.classification === 'stale') {
|
|
404
|
+
return { tone: 'stale', label: 'Evidence stale', explanation: 'The backing evidence is no longer current. This interpretation needs reconciliation with the current artifact version.' };
|
|
405
|
+
}
|
|
406
|
+
if (claim.evidenceState === 'proposed' || (refs.length && refs.every(ref => ref.sourceClass === 'public_intent'))) {
|
|
407
|
+
return { tone: 'proposed', label: 'Proposed', explanation: 'This is a proposal or stated intent. It does not establish that a component exists or that a change completed.' };
|
|
408
|
+
}
|
|
409
|
+
if (claim.classification !== 'accepted') {
|
|
410
|
+
return { tone: 'proposed', label: upperFirst(claim.classification), explanation: 'The code interpretation is uncertain or incomplete. Inspect the evidence before relying on this claim.' };
|
|
411
|
+
}
|
|
412
|
+
if (!refs.length || refs.some(ref => ref.basis !== 'jev_interpretation' || ref.sourceClass === 'unknown')) {
|
|
413
|
+
return { tone: 'proposed', label: 'Provenance incomplete', explanation: 'This snapshot does not provide enough provenance to establish the basis of this claim.' };
|
|
414
|
+
}
|
|
415
|
+
return { tone: 'observed', label: 'Code evidence', explanation: 'Jev interpreted approved source evidence as supporting this claim. Code or configuration can describe a dependency without proving that it runs or connects successfully.' };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export function edgeLanes(edges) {
|
|
419
|
+
const groups = new Map();
|
|
420
|
+
const pairCounts = new Map();
|
|
421
|
+
for (const edge of edges) {
|
|
422
|
+
const pair = JSON.stringify([edge.source, edge.target].sort());
|
|
423
|
+
pairCounts.set(pair, (pairCounts.get(pair) || 0) + 1);
|
|
424
|
+
const key = JSON.stringify([edge.source, edge.target, edge.relation]);
|
|
425
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
426
|
+
groups.get(key).push(edge);
|
|
427
|
+
}
|
|
428
|
+
const lanes = new Map();
|
|
429
|
+
for (const siblings of groups.values()) {
|
|
430
|
+
siblings.sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
431
|
+
siblings.forEach((edge, index) => {
|
|
432
|
+
const pair = JSON.stringify([edge.source, edge.target].sort());
|
|
433
|
+
if (pairCounts.get(pair) === 1) {
|
|
434
|
+
lanes.set(edge.id, 0);
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
// Parallel relations retain fixed slots; reverse directions occupy the
|
|
438
|
+
// opposite side. An isolated relation uses a direct connection.
|
|
439
|
+
const direction = edge.source <= edge.target ? 1 : -1;
|
|
440
|
+
const relation = Math.max(0, EDGE_RELATION_ORDER.indexOf(edge.relation));
|
|
441
|
+
lanes.set(edge.id, direction * (.5 + relation + index * EDGE_RELATION_ORDER.length));
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
return lanes;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export function graphEdgeRoutes(graph) {
|
|
448
|
+
const nodes = new Map(graph.nodes.map(node => [node.id, node]));
|
|
449
|
+
const lanes = edgeLanes(graph.edges);
|
|
450
|
+
const routes = new Map();
|
|
451
|
+
const pairWidths = new Map();
|
|
452
|
+
for (const edge of graph.edges) {
|
|
453
|
+
const pair = JSON.stringify([edge.source, edge.target].sort());
|
|
454
|
+
pairWidths.set(pair, Math.max(pairWidths.get(pair) || 0, edgeLabelWidth(edge.label)));
|
|
455
|
+
}
|
|
456
|
+
for (const edge of graph.edges) {
|
|
457
|
+
const source = nodes.get(edge.source);
|
|
458
|
+
const target = nodes.get(edge.target);
|
|
459
|
+
if (!source || !target) continue;
|
|
460
|
+
const lane = lanes.get(edge.id);
|
|
461
|
+
const route = routeEdge(source, target, lane);
|
|
462
|
+
route.labelWidth = edgeLabelWidth(edge.label);
|
|
463
|
+
route.labelHeight = 26;
|
|
464
|
+
const pair = JSON.stringify([edge.source, edge.target].sort());
|
|
465
|
+
const gap = Math.hypot(route.end.x - route.start.x, route.end.y - route.start.y);
|
|
466
|
+
if (source.id !== target.id && gap < pairWidths.get(pair) + 26) {
|
|
467
|
+
// A short gap cannot fit a readable label between node boundaries.
|
|
468
|
+
// Put labels outside the pair's silhouette on distinct, compact rails;
|
|
469
|
+
// the arrow remains straight when this is the pair's only relationship.
|
|
470
|
+
const dx = target.x - source.x;
|
|
471
|
+
const dy = target.y - source.y;
|
|
472
|
+
const length = Math.hypot(dx, dy) || 1;
|
|
473
|
+
const direction = source.id <= target.id ? 1 : -1;
|
|
474
|
+
const normal = { x: -dy / length * direction, y: dx / length * direction };
|
|
475
|
+
const angle = route.angle * Math.PI / 180;
|
|
476
|
+
const labelExtent = route.labelWidth / 2 * Math.abs(Math.cos(angle) * normal.x + Math.sin(angle) * normal.y)
|
|
477
|
+
+ route.labelHeight / 2 * Math.abs(-Math.sin(angle) * normal.x + Math.cos(angle) * normal.y);
|
|
478
|
+
const nodeExtent = Math.abs(normal.x) * NODE_WIDTH / 2 + Math.abs(normal.y) * NODE_HEIGHT / 2;
|
|
479
|
+
const offset = (lane > 0 ? 1 : -1) * (nodeExtent + labelExtent + 8 + Math.max(0, Math.abs(lane) - .5) * EDGE_LANE_GAP);
|
|
480
|
+
route.x = (source.x + target.x + NODE_WIDTH) / 2 + normal.x * offset;
|
|
481
|
+
route.y = (source.y + target.y + NODE_HEIGHT) / 2 + normal.y * offset;
|
|
482
|
+
const leaderX = route.midpoint.x - route.x;
|
|
483
|
+
const leaderY = route.midpoint.y - route.y;
|
|
484
|
+
const localX = Math.cos(angle) * leaderX + Math.sin(angle) * leaderY;
|
|
485
|
+
const localY = -Math.sin(angle) * leaderX + Math.cos(angle) * leaderY;
|
|
486
|
+
const fraction = Math.min(
|
|
487
|
+
localX === 0 ? Infinity : route.labelWidth / 2 / Math.abs(localX),
|
|
488
|
+
localY === 0 ? Infinity : route.labelHeight / 2 / Math.abs(localY),
|
|
489
|
+
);
|
|
490
|
+
if (Number.isFinite(fraction)) {
|
|
491
|
+
route.leader = `M ${route.midpoint.x} ${route.midpoint.y} L ${route.x + leaderX * fraction} ${route.y + leaderY * fraction}`;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
routes.set(edge.id, route);
|
|
495
|
+
}
|
|
496
|
+
return routes;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function edgeLabelWidth(label) { return Math.max(42, clip(label, 28).length * 6.5 + 16); }
|
|
500
|
+
|
|
501
|
+
export function graphBounds(graph, routes = graphEdgeRoutes(graph)) {
|
|
502
|
+
if (!graph.nodes.length) return { x: 0, y: 0, width: 920, height: 510 };
|
|
503
|
+
let minX = Math.min(...graph.nodes.map(node => node.x));
|
|
504
|
+
let minY = Math.min(...graph.nodes.map(node => node.y));
|
|
505
|
+
let maxX = Math.max(...graph.nodes.map(node => node.x + NODE_WIDTH));
|
|
506
|
+
let maxY = Math.max(...graph.nodes.map(node => node.y + NODE_HEIGHT));
|
|
507
|
+
for (const edge of graph.edges) {
|
|
508
|
+
const route = routes.get(edge.id);
|
|
509
|
+
if (!route) continue;
|
|
510
|
+
const angle = route.angle * Math.PI / 180;
|
|
511
|
+
const halfWidth = route.labelWidth / 2;
|
|
512
|
+
const halfHeight = route.labelHeight / 2;
|
|
513
|
+
const labelWidth = Math.abs(Math.cos(angle)) * halfWidth + Math.abs(Math.sin(angle)) * halfHeight;
|
|
514
|
+
const labelHeight = Math.abs(Math.sin(angle)) * halfWidth + Math.abs(Math.cos(angle)) * halfHeight;
|
|
515
|
+
minX = Math.min(minX, route.bounds.minX, route.x - labelWidth);
|
|
516
|
+
minY = Math.min(minY, route.bounds.minY, route.y - labelHeight);
|
|
517
|
+
maxX = Math.max(maxX, route.bounds.maxX, route.x + labelWidth);
|
|
518
|
+
maxY = Math.max(maxY, route.bounds.maxY, route.y + labelHeight);
|
|
519
|
+
}
|
|
520
|
+
return {
|
|
521
|
+
x: minX - 64, y: minY - 64,
|
|
522
|
+
width: Math.max(400, maxX - minX + 128),
|
|
523
|
+
height: Math.max(280, maxY - minY + 128),
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
export function fitViewport(bounds, size = { width: 920, height: 510 }) {
|
|
528
|
+
const width = Number.isFinite(size?.width) && size.width > 0 ? size.width : 920;
|
|
529
|
+
const height = Number.isFinite(size?.height) && size.height > 0 ? size.height : 510;
|
|
530
|
+
const zoom = Math.min(MAX_ZOOM, width / bounds.width, height / bounds.height);
|
|
531
|
+
const viewport = { width: width / zoom, height: height / zoom };
|
|
532
|
+
viewport.x = bounds.x + (bounds.width - viewport.width) / 2;
|
|
533
|
+
viewport.y = bounds.y + (bounds.height - viewport.height) / 2;
|
|
534
|
+
return { viewport, zoom };
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function cameraGraphSignature(graph, algorithm) {
|
|
538
|
+
// Tool progress is independent of architecture. Repeated snapshots, live
|
|
539
|
+
// activity, evidence excerpts and confidence updates must not move the camera.
|
|
540
|
+
const nodes = graph.nodes.map(node => [
|
|
541
|
+
node.id, node.label, node.kind, node.shape, node.x, node.y,
|
|
542
|
+
node.evidenceState, node.classification, node.validity,
|
|
543
|
+
]).sort((a, b) => a[0].localeCompare(b[0]));
|
|
544
|
+
const edges = graph.edges.map(edge => [
|
|
545
|
+
edge.id, edge.source, edge.target, edge.label, edge.relation,
|
|
546
|
+
edge.evidenceState, edge.classification, edge.validity,
|
|
547
|
+
]).sort((a, b) => a[0].localeCompare(b[0]));
|
|
548
|
+
return JSON.stringify([algorithm, nodes, edges]);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function combinedBounds(first, second) {
|
|
552
|
+
const x = Math.min(first.x, second.x), y = Math.min(first.y, second.y);
|
|
553
|
+
return {
|
|
554
|
+
x, y,
|
|
555
|
+
width: Math.max(first.x + first.width, second.x + second.width) - x,
|
|
556
|
+
height: Math.max(first.y + first.height, second.y + second.height) - y,
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function curveRoute(points, normal) {
|
|
561
|
+
const [start, first, second, end] = points;
|
|
562
|
+
const round = value => Number(value.toFixed(3));
|
|
563
|
+
const midpoint = {
|
|
564
|
+
x: round((start.x + 3 * first.x + 3 * second.x + end.x) / 8),
|
|
565
|
+
y: round((start.y + 3 * first.y + 3 * second.y + end.y) / 8),
|
|
566
|
+
};
|
|
567
|
+
const x = round(midpoint.x - normal.x * 9);
|
|
568
|
+
const y = round(midpoint.y - normal.y * 9);
|
|
569
|
+
const tangentX = first.x - start.x + 2 * (second.x - first.x) + end.x - second.x;
|
|
570
|
+
const tangentY = first.y - start.y + 2 * (second.y - first.y) + end.y - second.y;
|
|
571
|
+
let angle = Math.atan2(tangentY, tangentX) * 180 / Math.PI;
|
|
572
|
+
if (angle >= 90) angle -= 180;
|
|
573
|
+
if (angle < -90) angle += 180;
|
|
574
|
+
const cubic = points.map(point => [round(point.x), round(point.y)]);
|
|
575
|
+
const coordinates = cubic.map(point => point.join(' '));
|
|
576
|
+
return {
|
|
577
|
+
d: `M ${coordinates[0]} C ${coordinates.slice(1).join(' ')}`,
|
|
578
|
+
points: cubic,
|
|
579
|
+
x, y, angle: round(angle), start, end, midpoint,
|
|
580
|
+
// The control hull contains the complete cubic, including its outer arcs.
|
|
581
|
+
bounds: {
|
|
582
|
+
minX: Math.min(...points.map(point => point.x)),
|
|
583
|
+
minY: Math.min(...points.map(point => point.y)),
|
|
584
|
+
maxX: Math.max(...points.map(point => point.x)),
|
|
585
|
+
maxY: Math.max(...points.map(point => point.y)),
|
|
586
|
+
},
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function nodePort(center, direction, normal, offset, shapeName) {
|
|
591
|
+
const reach = Math.min(
|
|
592
|
+
direction.x === 0 ? Infinity : NODE_WIDTH / 2 / Math.abs(direction.x),
|
|
593
|
+
direction.y === 0 ? Infinity : NODE_HEIGHT / 2 / Math.abs(direction.y),
|
|
594
|
+
);
|
|
595
|
+
const x = direction.x * reach + normal.x * offset;
|
|
596
|
+
const y = direction.y * reach + normal.y * offset;
|
|
597
|
+
const scale = Math.min(
|
|
598
|
+
x === 0 ? Infinity : NODE_WIDTH / 2 / Math.abs(x),
|
|
599
|
+
y === 0 ? Infinity : NODE_HEIGHT / 2 / Math.abs(y),
|
|
600
|
+
);
|
|
601
|
+
const polygons = {
|
|
602
|
+
diamond: [[95, -12], [204, 52], [95, 116], [-14, 52]],
|
|
603
|
+
component: [[10, 0], [190, 0], [190, 104], [10, 104], [10, 85], [0, 85],
|
|
604
|
+
[0, 68], [10, 68], [10, 35], [0, 35], [0, 18], [10, 18]],
|
|
605
|
+
hexagon: [[23, 0], [167, 0], [190, 52], [167, 104], [23, 104], [0, 52]],
|
|
606
|
+
parallelogram: [[22, 0], [190, 0], [168, 104], [0, 104]],
|
|
607
|
+
document: [[0, 0], [167, 0], [190, 23], [190, 104], [0, 104]],
|
|
608
|
+
folder: [[0, 10], [66, 10], [78, 0], [190, 0], [190, 104], [0, 104]],
|
|
609
|
+
};
|
|
610
|
+
const polygon = polygons[shapeName];
|
|
611
|
+
let reachScale = scale;
|
|
612
|
+
if (polygon) {
|
|
613
|
+
let outerExit = 0;
|
|
614
|
+
const cross = (ax, ay, bx, by) => ax * by - ay * bx;
|
|
615
|
+
for (let index = 0; index < polygon.length; index++) {
|
|
616
|
+
const a = polygon[index], b = polygon[(index + 1) % polygon.length];
|
|
617
|
+
const ax = a[0] - NODE_WIDTH / 2, ay = a[1] - NODE_HEIGHT / 2;
|
|
618
|
+
const ex = b[0] - a[0], ey = b[1] - a[1];
|
|
619
|
+
const divisor = cross(x, y, ex, ey);
|
|
620
|
+
if (Math.abs(divisor) < 1e-9) continue;
|
|
621
|
+
const t = cross(ax, ay, ex, ey) / divisor;
|
|
622
|
+
const u = cross(ax, ay, x, y) / divisor;
|
|
623
|
+
if (t >= 0 && u >= 0 && u <= 1) outerExit = Math.max(outerExit, t);
|
|
624
|
+
}
|
|
625
|
+
// Diamonds extend beyond the nominal box. For a concave component outline,
|
|
626
|
+
// use the outermost exit so a tab cannot cover the arrow after a notch.
|
|
627
|
+
if (outerExit > 0) reachScale = outerExit;
|
|
628
|
+
}
|
|
629
|
+
return { x: center.x + x * reachScale, y: center.y + y * reachScale };
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
export function routeEdge(source, target, lane = 0) {
|
|
633
|
+
const limit = LIMITS.edges * EDGE_RELATION_ORDER.length;
|
|
634
|
+
const slot = Number.isFinite(lane) ? Math.max(-limit, Math.min(limit, lane)) : 0;
|
|
635
|
+
if (source.id === target.id) {
|
|
636
|
+
const side = slot < 0 ? -1 : 1;
|
|
637
|
+
const x = source.x + NODE_WIDTH;
|
|
638
|
+
const y = source.y + (side > 0 ? 24 : NODE_HEIGHT - 24);
|
|
639
|
+
const extent = 80 + Math.abs(slot) * EDGE_LANE_GAP;
|
|
640
|
+
const center = { x: source.x + NODE_WIDTH / 2, y: source.y + NODE_HEIGHT / 2 };
|
|
641
|
+
const boundary = point => nodePort(center, { x: point.x - center.x, y: point.y - center.y }, { x: 0, y: 0 }, 0, source.shape);
|
|
642
|
+
return curveRoute([
|
|
643
|
+
boundary({ x, y }),
|
|
644
|
+
{ x: x + extent, y: y - side * extent },
|
|
645
|
+
{ x: x - 90, y: y - side * extent },
|
|
646
|
+
boundary({ x: x - 80, y: source.y + (side > 0 ? 0 : NODE_HEIGHT) }),
|
|
647
|
+
], { x: 0, y: side });
|
|
648
|
+
}
|
|
649
|
+
const a = { x: source.x + NODE_WIDTH / 2, y: source.y + NODE_HEIGHT / 2 };
|
|
650
|
+
const b = { x: target.x + NODE_WIDTH / 2, y: target.y + NODE_HEIGHT / 2 };
|
|
651
|
+
const distance = Math.hypot(b.x - a.x, b.y - a.y);
|
|
652
|
+
const direction = distance ? { x: (b.x - a.x) / distance, y: (b.y - a.y) / distance } : { x: 1, y: 0 };
|
|
653
|
+
const canonicalDirection = source.id <= target.id ? 1 : -1;
|
|
654
|
+
const normal = { x: -direction.y * canonicalDirection, y: direction.x * canonicalDirection };
|
|
655
|
+
const portOffset = Math.max(-36, Math.min(36, slot * 6));
|
|
656
|
+
const start = nodePort(a, direction, normal, portOffset, source.shape);
|
|
657
|
+
const end = nodePort(b, { x: -direction.x, y: -direction.y }, normal, portOffset, target.shape);
|
|
658
|
+
const arc = slot * EDGE_LANE_GAP * 4 / 3;
|
|
659
|
+
const control = fraction => ({
|
|
660
|
+
x: start.x + (end.x - start.x) * fraction + normal.x * arc,
|
|
661
|
+
y: start.y + (end.y - start.y) * fraction + normal.y * arc,
|
|
662
|
+
});
|
|
663
|
+
return curveRoute([start, control(1 / 3), control(2 / 3), end], normal);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
export function historyFrames(snapshot) {
|
|
667
|
+
const revisions = new Map();
|
|
668
|
+
for (const frame of snapshot.history) revisions.set(frame.revision, frame);
|
|
669
|
+
// Current evidence may be reprojected without a new semantic revision.
|
|
670
|
+
revisions.set(snapshot.graph.revision, {
|
|
671
|
+
revision: snapshot.graph.revision,
|
|
672
|
+
at: revisions.get(snapshot.graph.revision)?.at ?? null,
|
|
673
|
+
graph: snapshot.graph,
|
|
674
|
+
});
|
|
675
|
+
return [...revisions.values()].sort((a, b) => a.revision - b.revision);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
export function reconcileReplayFrame(frames, pinned) {
|
|
679
|
+
if (!pinned) return null;
|
|
680
|
+
const currentProjection = frames.find(frame => frame.revision === pinned.revision);
|
|
681
|
+
if (currentProjection) return currentProjection;
|
|
682
|
+
// Keep the historical position after retention eviction, but retain no
|
|
683
|
+
// excerpts that the service can no longer reproject under its display policy.
|
|
684
|
+
return { ...pinned, graph: normalizeGraph(pinned.graph, { includeExcerpts: false }) };
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
export function parseLaunchToken(hash) {
|
|
688
|
+
if (!hash || hash === '#' || hash === '#main') return null;
|
|
689
|
+
if (hash.length > 2048) throw new Error('invalid_launch');
|
|
690
|
+
const fragment = hash.replace(/^#/, '');
|
|
691
|
+
const params = new URLSearchParams(fragment);
|
|
692
|
+
const value = params.has('token') ? params.get('token') : fragment;
|
|
693
|
+
if (params.getAll('token').length > 1 || !/^[A-Za-z0-9_-]{16,512}$/.test(value)) throw new Error('invalid_launch');
|
|
694
|
+
return value;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
export async function exchangeLaunchToken({ location, history, request }) {
|
|
698
|
+
const hash = location.hash;
|
|
699
|
+
if (!hash || hash === '#' || hash === '#main') return;
|
|
700
|
+
try {
|
|
701
|
+
const launchToken = parseLaunchToken(hash);
|
|
702
|
+
if (launchToken) await request('/api/auth', { method: 'POST', body: JSON.stringify({ token: launchToken }) });
|
|
703
|
+
} finally {
|
|
704
|
+
// Never put the launch token in storage, query strings, logs or later calls.
|
|
705
|
+
history.replaceState(history.state, '', location.pathname + location.search);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function html(tag, text, className) {
|
|
710
|
+
const element = document.createElement(tag);
|
|
711
|
+
if (text !== undefined) element.textContent = text;
|
|
712
|
+
if (className) element.className = className;
|
|
713
|
+
return element;
|
|
714
|
+
}
|
|
715
|
+
function svgElement(tag, attributes = {}, text) {
|
|
716
|
+
const element = document.createElementNS(SVG_NS, tag);
|
|
717
|
+
for (const [key, value] of Object.entries(attributes)) element.setAttribute(key, String(value));
|
|
718
|
+
if (text !== undefined) element.textContent = text;
|
|
719
|
+
return element;
|
|
720
|
+
}
|
|
721
|
+
function formatTime(value, withDate = false) {
|
|
722
|
+
if (value === null) return 'Time unknown';
|
|
723
|
+
return new Intl.DateTimeFormat(undefined, withDate
|
|
724
|
+
? { dateStyle: 'medium', timeStyle: 'medium' }
|
|
725
|
+
: { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).format(value);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
async function readResponse(response) {
|
|
729
|
+
if (Number(response.headers.get('content-length')) > MAX_JSON_BYTES) throw new Error('response_too_large');
|
|
730
|
+
if (!response.body?.getReader) {
|
|
731
|
+
const text = await response.text();
|
|
732
|
+
if (text.length > MAX_JSON_BYTES) throw new Error('response_too_large');
|
|
733
|
+
return text;
|
|
734
|
+
}
|
|
735
|
+
const reader = response.body.getReader();
|
|
736
|
+
const decoder = new TextDecoder();
|
|
737
|
+
let bytes = 0;
|
|
738
|
+
let text = '';
|
|
739
|
+
try {
|
|
740
|
+
while (true) {
|
|
741
|
+
const { value, done } = await reader.read();
|
|
742
|
+
if (done) break;
|
|
743
|
+
bytes += value.byteLength;
|
|
744
|
+
if (bytes > MAX_JSON_BYTES) {
|
|
745
|
+
await reader.cancel();
|
|
746
|
+
throw new Error('response_too_large');
|
|
747
|
+
}
|
|
748
|
+
text += decoder.decode(value, { stream: true });
|
|
749
|
+
}
|
|
750
|
+
return text + decoder.decode();
|
|
751
|
+
} finally { reader.releaseLock(); }
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
async function request(path, options = {}) {
|
|
755
|
+
const controller = new AbortController();
|
|
756
|
+
const timeout = setTimeout(() => controller.abort(), 8000);
|
|
757
|
+
const onAbort = () => controller.abort(options.signal?.reason);
|
|
758
|
+
if (options.signal?.aborted) onAbort();
|
|
759
|
+
else options.signal?.addEventListener('abort', onAbort, { once: true });
|
|
760
|
+
try {
|
|
761
|
+
const response = await fetch(path, {
|
|
762
|
+
method: options.method || 'GET',
|
|
763
|
+
credentials: 'same-origin', cache: 'no-store', redirect: 'error',
|
|
764
|
+
headers: options.body ? { 'Content-Type': 'application/json' } : { Accept: 'application/json' },
|
|
765
|
+
body: options.body, signal: controller.signal,
|
|
766
|
+
});
|
|
767
|
+
if (!response.ok) {
|
|
768
|
+
await response.body?.cancel();
|
|
769
|
+
const error = new Error(response.status === 401 || response.status === 403 ? 'auth_required' : 'request_failed');
|
|
770
|
+
error.status = response.status;
|
|
771
|
+
throw error;
|
|
772
|
+
}
|
|
773
|
+
const text = await readResponse(response);
|
|
774
|
+
if (!text.trim()) return null;
|
|
775
|
+
return JSON.parse(text);
|
|
776
|
+
} finally {
|
|
777
|
+
clearTimeout(timeout);
|
|
778
|
+
options.signal?.removeEventListener('abort', onAbort);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
export function normalizeConnectionInfo(value) {
|
|
783
|
+
if (!record(value) || !Array.isArray(value.instructions) || typeof value.projectRoot !== 'string') {
|
|
784
|
+
throw new Error('invalid_connection_info');
|
|
785
|
+
}
|
|
786
|
+
const instructions = value.instructions.slice(0, 8).map((instruction, index) => {
|
|
787
|
+
if (!record(instruction) || !Array.isArray(instruction.steps)) throw new Error('invalid_connection_info');
|
|
788
|
+
return {
|
|
789
|
+
id: identifier(instruction.id) || `instruction-${index}`,
|
|
790
|
+
title: safeText(instruction.title, 120) || 'Connection instructions',
|
|
791
|
+
description: safeText(instruction.description, 1200),
|
|
792
|
+
steps: instruction.steps.slice(0, 12).map(step => {
|
|
793
|
+
// Commands must remain complete and exact. Do not clip, construct, run,
|
|
794
|
+
// shell-expand, or silently remove characters from executable text.
|
|
795
|
+
if (!record(step) || typeof step.command !== 'string' || !step.command.trim() ||
|
|
796
|
+
step.command.length > 8192 || /[\u0000-\u0008\u000b-\u001f\u007f\u202a-\u202e\u2066-\u2069]/.test(step.command)) {
|
|
797
|
+
throw new Error('invalid_connection_info');
|
|
798
|
+
}
|
|
799
|
+
return {
|
|
800
|
+
label: safeText(step.label, 160) || 'Command',
|
|
801
|
+
command: step.command,
|
|
802
|
+
description: safeText(step.description, 1200),
|
|
803
|
+
};
|
|
804
|
+
}),
|
|
805
|
+
};
|
|
806
|
+
});
|
|
807
|
+
return {
|
|
808
|
+
projectRoot: safeText(value.projectRoot, 4096),
|
|
809
|
+
mode: token(value.mode, ['live', 'demo', 'replay'], 'live'),
|
|
810
|
+
instructions,
|
|
811
|
+
notes: array(value.notes).slice(0, 12).map(note => safeText(note, 1600)).filter(Boolean),
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
export function friendlyProjectName(projectRoot) {
|
|
816
|
+
return safeText(projectRoot, 4096).replace(/\/+$/, '').split('/').at(-1)?.slice(0, 120) || 'Local project';
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
export const ORIENTATION_PROMPT = 'Orient yourself in this project: read its main files and explain how the components connect.';
|
|
820
|
+
|
|
821
|
+
// Observations are not an installation or trust audit. Activity (including
|
|
822
|
+
// pre-tool intent) is not a substitute for the server's hook receipt feed.
|
|
823
|
+
export function onboardingProgress(snapshot, connection = 'connecting') {
|
|
824
|
+
const demo = snapshot?.mode === 'demo' || snapshot?.status.classifier === 'demo';
|
|
825
|
+
const hooks = array(snapshot?.hookEvents).some(event => event.receipt > 0);
|
|
826
|
+
const calls = (snapshot?.status.calls || 0) > 0;
|
|
827
|
+
const shape = Boolean(snapshot?.graph.nodes.length || array(snapshot?.history).some(frame => frame.graph.nodes.length));
|
|
828
|
+
const classifier = snapshot?.paused ? 'paused' : snapshot?.status.classifier;
|
|
829
|
+
const steps = [
|
|
830
|
+
{ id: 'server', state: connection === 'connected' ? 'observed' : 'waiting',
|
|
831
|
+
label: connection === 'connected' ? 'Server connected' : connection === 'connecting' ? 'Connecting to server' : 'Server connection needs attention' },
|
|
832
|
+
{ id: 'setup', state: 'unverified', label: 'Agent setup / trust: unverified' },
|
|
833
|
+
{ id: 'hooks', state: demo ? 'demo' : hooks ? 'observed' : 'waiting',
|
|
834
|
+
label: demo ? 'Demo hook receipts' : hooks ? 'Hook delivery observed' : 'Waiting for a hook receipt' },
|
|
835
|
+
{ id: 'classification', state: demo ? 'demo' : calls ? 'observed' : 'waiting',
|
|
836
|
+
label: demo ? 'Fixture classification' : calls ? 'Classification call observed' : 'Waiting for a classifier call' },
|
|
837
|
+
{ id: 'shape', state: demo ? 'demo' : shape ? 'observed' : 'waiting',
|
|
838
|
+
label: demo ? 'Demo shapes' : shape ? 'First shape observed in this session' : 'Waiting for the first shape' },
|
|
839
|
+
];
|
|
840
|
+
let next;
|
|
841
|
+
if (connection === 'auth') next = ['Open a fresh viewer link from the Graphlin server terminal.', 'Reconnect', 'reconnect'];
|
|
842
|
+
else if (connection !== 'connected') next = [connection === 'connecting'
|
|
843
|
+
? 'Keep the Graphlin server terminal open while the viewer connects.'
|
|
844
|
+
: 'Check that the Graphlin server is running for this project, then reconnect.', 'Reconnect', 'reconnect'];
|
|
845
|
+
else if (demo) next = ['This is an offline demo. Start a live viewer for your project to connect an agent.', 'How to connect', 'connect'];
|
|
846
|
+
else if (classifier === 'missing_key') next = ['Run graphlin init in this project’s terminal to save a TypeSafe API key at the masked prompt. Then stop and restart the Graphlin server.', 'How to connect', 'connect'];
|
|
847
|
+
else if (classifier === 'metadata_only') next = ['Run graphlin init and choose source mode if you consent to sending locally filtered source and public messages to TypeSafe. Then stop and restart the Graphlin server.', 'How to connect', 'connect'];
|
|
848
|
+
else if (classifier === 'paused') next = ['Resume classification, then ask your agent to read the main project files.', 'Resume classification', 'resume'];
|
|
849
|
+
else if (['unavailable', 'timeout'].includes(classifier)) next = ['Open the classification log for the reported failure. Check the server’s classifier connection, then let your agent read a file again.', 'View classification log', 'diagnostics'];
|
|
850
|
+
else if (!hooks) next = [Array.isArray(snapshot?.hookEvents)
|
|
851
|
+
? 'Connect your agent to this project and approve its setup prompts. In Codex, review Graphlin in /hooks. Then send the orientation prompt.'
|
|
852
|
+
: 'Restart the updated Graphlin server to see hook receipts. Session activity alone cannot verify hook delivery.', 'How to connect', 'connect'];
|
|
853
|
+
else if (snapshot.status.pending > 0) next = ['Evidence is queued for classification. Follow its progress and any skipped or failed reasons in the log.', 'View classification log', 'diagnostics'];
|
|
854
|
+
else if (!shape) next = ['Send the orientation prompt to your connected agent. If no shape appears, the classification log explains skipped or failed work.', 'View classification log', 'diagnostics'];
|
|
855
|
+
else next = ['Select a shape or arrow to inspect its evidence. Hook delivery does not verify full host trust; classification does not prove runtime success.', 'View classification log', 'diagnostics'];
|
|
856
|
+
return { steps, next: { text: next[0], label: next[1], action: next[2] } };
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
export function startConnectionDialog({ load = () => request('/api/connection-info'), onInfo = () => {} } = {}) {
|
|
860
|
+
const $ = id => document.getElementById(id);
|
|
861
|
+
const dialog = $('connection-dialog');
|
|
862
|
+
const trigger = $('how-to-connect');
|
|
863
|
+
const closeButton = $('connection-dialog-close');
|
|
864
|
+
const retry = $('connection-instructions-retry');
|
|
865
|
+
let opened = false;
|
|
866
|
+
let epoch = 0;
|
|
867
|
+
let returnFocus = null;
|
|
868
|
+
let contentControls = [];
|
|
869
|
+
let disposed = false;
|
|
870
|
+
|
|
871
|
+
function clearContent() {
|
|
872
|
+
contentControls = [];
|
|
873
|
+
$('connection-instructions').replaceChildren();
|
|
874
|
+
$('connection-notes').replaceChildren();
|
|
875
|
+
$('connection-notes').hidden = true;
|
|
876
|
+
$('connection-project').hidden = true;
|
|
877
|
+
$('connection-project').textContent = '';
|
|
878
|
+
$('connection-demo').hidden = true;
|
|
879
|
+
$('connection-copy-status').textContent = '';
|
|
880
|
+
}
|
|
881
|
+
function renderInfo(info, ticket) {
|
|
882
|
+
const sections = [];
|
|
883
|
+
for (const instruction of info.instructions) {
|
|
884
|
+
const section = html('section', undefined, 'connection-instruction');
|
|
885
|
+
section.append(html('h3', instruction.title));
|
|
886
|
+
if (instruction.description) section.append(html('p', instruction.description));
|
|
887
|
+
const steps = html('ol', undefined, 'connection-steps');
|
|
888
|
+
for (const step of instruction.steps) {
|
|
889
|
+
const item = html('li');
|
|
890
|
+
const heading = html('div', undefined, 'connection-step-heading');
|
|
891
|
+
const copy = html('button', 'Copy');
|
|
892
|
+
copy.type = 'button';
|
|
893
|
+
copy.setAttribute('aria-label', `Copy ${step.label}`);
|
|
894
|
+
const pre = html('pre');
|
|
895
|
+
pre.setAttribute('tabindex', '0');
|
|
896
|
+
pre.setAttribute('aria-label', `${step.label} command`);
|
|
897
|
+
pre.append(html('code', step.command));
|
|
898
|
+
heading.append(html('h4', step.label), copy);
|
|
899
|
+
item.append(heading);
|
|
900
|
+
if (step.description) item.append(html('p', step.description));
|
|
901
|
+
item.append(pre);
|
|
902
|
+
let copying = false;
|
|
903
|
+
copy.addEventListener('click', async () => {
|
|
904
|
+
if (copying || !opened || ticket !== epoch) return;
|
|
905
|
+
copying = true;
|
|
906
|
+
$('connection-copy-status').textContent = '';
|
|
907
|
+
copy.setAttribute('aria-busy', 'true');
|
|
908
|
+
try {
|
|
909
|
+
if (!window.navigator?.clipboard?.writeText) throw new Error('clipboard_unavailable');
|
|
910
|
+
await window.navigator.clipboard.writeText(step.command);
|
|
911
|
+
if (!opened || ticket !== epoch) return;
|
|
912
|
+
copy.textContent = 'Copied';
|
|
913
|
+
$('connection-copy-status').textContent = `${step.label} copied.`;
|
|
914
|
+
} catch {
|
|
915
|
+
if (!opened || ticket !== epoch) return;
|
|
916
|
+
$('connection-copy-status').textContent = 'Copy is unavailable. Select the command text and copy it manually.';
|
|
917
|
+
pre.focus();
|
|
918
|
+
} finally {
|
|
919
|
+
copying = false;
|
|
920
|
+
copy.setAttribute('aria-busy', 'false');
|
|
921
|
+
}
|
|
922
|
+
});
|
|
923
|
+
contentControls.push(copy, pre);
|
|
924
|
+
steps.append(item);
|
|
925
|
+
}
|
|
926
|
+
section.append(steps);
|
|
927
|
+
sections.push(section);
|
|
928
|
+
}
|
|
929
|
+
if (!sections.length) sections.push(html('p', 'No connection instructions are available for this project yet.', 'connection-empty'));
|
|
930
|
+
$('connection-instructions').replaceChildren(...sections);
|
|
931
|
+
$('connection-project').textContent = `Project: ${info.projectRoot}`;
|
|
932
|
+
$('connection-project').hidden = !info.projectRoot;
|
|
933
|
+
$('connection-demo').hidden = info.mode !== 'demo';
|
|
934
|
+
$('connection-notes').replaceChildren(...info.notes.map(note => html('li', note)));
|
|
935
|
+
$('connection-notes').hidden = !info.notes.length;
|
|
936
|
+
}
|
|
937
|
+
async function loadInstructions() {
|
|
938
|
+
if (!opened || disposed) return;
|
|
939
|
+
const ticket = ++epoch;
|
|
940
|
+
clearContent();
|
|
941
|
+
$('connection-loading').hidden = false;
|
|
942
|
+
$('connection-error').hidden = true;
|
|
943
|
+
$('connection-error').textContent = '';
|
|
944
|
+
retry.hidden = true;
|
|
945
|
+
$('connection-instructions').setAttribute('aria-busy', 'true');
|
|
946
|
+
if (document.activeElement === retry) closeButton.focus();
|
|
947
|
+
try {
|
|
948
|
+
const info = normalizeConnectionInfo(await load());
|
|
949
|
+
if (!opened || disposed || ticket !== epoch) return;
|
|
950
|
+
renderInfo(info, ticket);
|
|
951
|
+
onInfo(info);
|
|
952
|
+
} catch {
|
|
953
|
+
if (!opened || disposed || ticket !== epoch) return;
|
|
954
|
+
$('connection-error').textContent = 'Connection instructions could not be loaded. Check that the local service is running, then retry.';
|
|
955
|
+
$('connection-error').hidden = false;
|
|
956
|
+
retry.hidden = false;
|
|
957
|
+
} finally {
|
|
958
|
+
if (opened && ticket === epoch) {
|
|
959
|
+
$('connection-loading').hidden = true;
|
|
960
|
+
$('connection-instructions').setAttribute('aria-busy', 'false');
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
function finishClose() {
|
|
965
|
+
if (!opened) return;
|
|
966
|
+
opened = false;
|
|
967
|
+
epoch++;
|
|
968
|
+
clearContent();
|
|
969
|
+
$('connection-loading').hidden = true;
|
|
970
|
+
$('connection-error').hidden = true;
|
|
971
|
+
retry.hidden = true;
|
|
972
|
+
$('connection-instructions').setAttribute('aria-busy', 'false');
|
|
973
|
+
const destination = returnFocus && document.body.contains(returnFocus) && !returnFocus.disabled ? returnFocus : trigger;
|
|
974
|
+
returnFocus = null;
|
|
975
|
+
if (!disposed) destination.focus({ preventScroll: true });
|
|
976
|
+
}
|
|
977
|
+
function closeDialog() {
|
|
978
|
+
if (!opened) return;
|
|
979
|
+
dialog.close();
|
|
980
|
+
finishClose();
|
|
981
|
+
}
|
|
982
|
+
function openDialog() {
|
|
983
|
+
if (disposed || opened) return;
|
|
984
|
+
returnFocus = document.activeElement || trigger;
|
|
985
|
+
opened = true;
|
|
986
|
+
dialog.showModal();
|
|
987
|
+
closeButton.focus();
|
|
988
|
+
return loadInstructions();
|
|
989
|
+
}
|
|
990
|
+
function onKeyDown(event) {
|
|
991
|
+
if (!opened) return;
|
|
992
|
+
if (event.key === 'Escape') {
|
|
993
|
+
event.preventDefault();
|
|
994
|
+
event.stopPropagation();
|
|
995
|
+
closeDialog();
|
|
996
|
+
} else if (event.key === 'Tab') {
|
|
997
|
+
const controls = [closeButton, retry, ...contentControls].filter(control => !control.disabled && !control.hidden);
|
|
998
|
+
const index = controls.indexOf(document.activeElement);
|
|
999
|
+
if (index < 0 || (!event.shiftKey && index === controls.length - 1) || (event.shiftKey && index === 0)) {
|
|
1000
|
+
event.preventDefault();
|
|
1001
|
+
controls[event.shiftKey ? controls.length - 1 : 0].focus();
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
function onCancel(event) { event.preventDefault(); closeDialog(); }
|
|
1006
|
+
function onNativeClose() { if (!dialog.open) finishClose(); }
|
|
1007
|
+
trigger.addEventListener('click', openDialog);
|
|
1008
|
+
closeButton.addEventListener('click', closeDialog);
|
|
1009
|
+
retry.addEventListener('click', loadInstructions);
|
|
1010
|
+
dialog.addEventListener('keydown', onKeyDown);
|
|
1011
|
+
dialog.addEventListener('cancel', onCancel);
|
|
1012
|
+
dialog.addEventListener('close', onNativeClose);
|
|
1013
|
+
return {
|
|
1014
|
+
open: openDialog,
|
|
1015
|
+
close: closeDialog,
|
|
1016
|
+
dispose() {
|
|
1017
|
+
disposed = true;
|
|
1018
|
+
closeDialog();
|
|
1019
|
+
epoch++;
|
|
1020
|
+
trigger.removeEventListener('click', openDialog);
|
|
1021
|
+
closeButton.removeEventListener('click', closeDialog);
|
|
1022
|
+
retry.removeEventListener('click', loadInstructions);
|
|
1023
|
+
dialog.removeEventListener('keydown', onKeyDown);
|
|
1024
|
+
dialog.removeEventListener('cancel', onCancel);
|
|
1025
|
+
dialog.removeEventListener('close', onNativeClose);
|
|
1026
|
+
},
|
|
1027
|
+
};
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
const LOG_STAGES = Object.freeze({
|
|
1031
|
+
capture: 'Capture', candidates: 'Candidate discovery', classification: 'Classification',
|
|
1032
|
+
apply: 'Graph update', skip: 'Skipped',
|
|
1033
|
+
});
|
|
1034
|
+
const LOG_ACTIVITIES = ['inspect', 'propose', 'implement', 'verify', 'repair', 'explain', 'other'];
|
|
1035
|
+
const LOG_REASONS = Object.freeze({
|
|
1036
|
+
no_candidates: 'No candidates found', no_approved_candidates: 'No candidates passed intake',
|
|
1037
|
+
no_accepted_classification: 'No classification met acceptance requirements',
|
|
1038
|
+
insufficient_relevance: 'Relevance was below the required score',
|
|
1039
|
+
metadata_only: 'Source interpretation is off', classification_paused: 'Classification is paused',
|
|
1040
|
+
below_drawing_floor: 'Support was too low to draw', stale_result: 'Evidence changed before the result arrived',
|
|
1041
|
+
no_graph_changes: 'No graph changes', unchanged: 'Evidence is unchanged',
|
|
1042
|
+
deadline_exceeded: 'Classification deadline elapsed', cache_hit: 'A cached result was used',
|
|
1043
|
+
no_graph_change: 'No graph changes', patch_applied: 'Graph changes applied',
|
|
1044
|
+
paused_deferred: 'Classification is paused; work is waiting',
|
|
1045
|
+
source_changed_during_classification: 'The source changed before classification finished',
|
|
1046
|
+
classification_not_drawable: 'The classification produced no drawable result',
|
|
1047
|
+
candidates_ready: 'Candidates are ready for classification', classification_started: 'Classification started',
|
|
1048
|
+
snippet_limit: 'Source window limit reached',
|
|
1049
|
+
});
|
|
1050
|
+
const LOG_LIMITS = Object.freeze({ records: 300, rows: 100, artifacts: 24, candidates: 48, edges: 72, requests: 12 });
|
|
1051
|
+
const fixedCode = value => typeof value === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,119}$/.test(value) ? value : '';
|
|
1052
|
+
const logNumber = value => typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1e12 ? value : null;
|
|
1053
|
+
const logProbability = value => probability(value) ? value : null;
|
|
1054
|
+
const logBoolean = value => typeof value === 'boolean' ? value : null;
|
|
1055
|
+
const logLabel = value => safeText(value, 120).replace(/([a-z])([A-Z])/g, '$1 $2').replaceAll('_', ' ');
|
|
1056
|
+
const logReason = value => LOG_REASONS[value] || upperFirst(logLabel(value)) || 'No reason reported';
|
|
1057
|
+
|
|
1058
|
+
function diagnosticNextAction(entry) {
|
|
1059
|
+
const reason = entry.reason || entry.diagnostics.code;
|
|
1060
|
+
if (reason === 'metadata_only') return 'To interpret source, review source consent in the Graphlin server setup.';
|
|
1061
|
+
if (['paused_deferred', 'classification_paused'].includes(reason)) return 'Resume classification in the viewer to process waiting evidence.';
|
|
1062
|
+
if (['missing_key', 'missing_api_key', 'classifier_unavailable'].includes(reason)) return 'Check the TypeSafe key and classifier configuration in the Graphlin server terminal.';
|
|
1063
|
+
if (['deadline_exceeded', 'classifier_exception'].includes(reason)) return 'Check the server’s classifier connection, then let your agent read the file again.';
|
|
1064
|
+
if (['stale_result', 'source_changed_during_classification', 'queued_source_superseded'].includes(reason)) return 'Let your agent read the latest file version; this result cannot support the current source.';
|
|
1065
|
+
if (['no_candidates', 'no_approved_candidates', 'no_accepted_classification', 'insufficient_relevance', 'classification_not_drawable'].includes(reason)) return 'Ask your agent to read the main implementation files and their dependencies. A captured event may produce no shape.';
|
|
1066
|
+
if (reason === 'classification_queue_full' || reason === 'capture_queue_full') return 'Let queued work finish, then ask your agent to read the file again.';
|
|
1067
|
+
return '';
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
function logNumbers(value) {
|
|
1071
|
+
if (!record(value)) return {};
|
|
1072
|
+
return Object.fromEntries(Object.entries(value).slice(0, 32)
|
|
1073
|
+
.filter(([key, value]) => fixedCode(key) && logNumber(value) !== null));
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
export function normalizeDiagnostics(value) {
|
|
1077
|
+
if (!record(value) || value.schemaVersion !== 1 || !Array.isArray(value.records)) throw new Error('invalid_diagnostics');
|
|
1078
|
+
const selected = value.records.slice(-LOG_LIMITS.records);
|
|
1079
|
+
const records = selected.filter(record).map((entry, index) => {
|
|
1080
|
+
const diagnostics = record(entry.diagnostics) ? entry.diagnostics : {};
|
|
1081
|
+
const trace = record(diagnostics.trace) ? diagnostics.trace : {};
|
|
1082
|
+
const patch = record(entry.patch) ? entry.patch : {};
|
|
1083
|
+
const reasons = values => array(values).slice(0, 12).map(fixedCode).filter(Boolean);
|
|
1084
|
+
const candidates = array(entry.candidates).slice(0, LOG_LIMITS.candidates).filter(record).map(candidate => ({
|
|
1085
|
+
candidateId: identifier(candidate.candidateId), artifactId: identifier(candidate.artifactId),
|
|
1086
|
+
label: safeText(candidate.label, 240), sourceClass: fixedCode(candidate.sourceClass),
|
|
1087
|
+
startLine: logNumber(candidate.startLine), endLine: logNumber(candidate.endLine), complete: logBoolean(candidate.complete),
|
|
1088
|
+
}));
|
|
1089
|
+
const sequence = logNumber(entry.sequence ?? entry.seq);
|
|
1090
|
+
const at = validTime(entry.at);
|
|
1091
|
+
const id = identifier(entry.id);
|
|
1092
|
+
return {
|
|
1093
|
+
id, sequence, at: at === null ? null : new Date(at).toISOString(),
|
|
1094
|
+
stage: token(entry.stage, Object.keys(LOG_STAGES), 'unknown'),
|
|
1095
|
+
eventId: identifier(entry.eventId), sourceEventId: identifier(entry.sourceEventId),
|
|
1096
|
+
sessionId: identifier(entry.sessionId), eventKind: fixedCode(entry.eventKind),
|
|
1097
|
+
toolCategory: fixedCode(entry.toolCategory), status: fixedCode(entry.status), reason: fixedCode(entry.reason),
|
|
1098
|
+
artifacts: array(entry.artifacts).slice(0, LOG_LIMITS.artifacts).filter(record).map(artifact => ({
|
|
1099
|
+
artifactId: identifier(artifact.artifactId), path: safeText(artifact.path, 1024),
|
|
1100
|
+
status: fixedCode(artifact.status), complete: logBoolean(artifact.complete), candidateCount: logNumber(artifact.candidateCount),
|
|
1101
|
+
availableCandidates: logNumber(artifact.availableCandidates), reason: fixedCode(artifact.reason),
|
|
1102
|
+
})),
|
|
1103
|
+
candidates,
|
|
1104
|
+
diagnostics: {
|
|
1105
|
+
code: fixedCode(diagnostics.code), durationMs: logNumber(diagnostics.durationMs), calls: logNumber(diagnostics.calls),
|
|
1106
|
+
candidatesOmitted: logNumber(diagnostics.candidatesOmitted), proposalsOmitted: logNumber(diagnostics.proposalsOmitted),
|
|
1107
|
+
questionCounts: logNumbers(diagnostics.questionCounts), stageDurationMs: logNumbers(diagnostics.stageDurationMs),
|
|
1108
|
+
extraction: array(diagnostics.extraction).slice(0, 33).filter(record).map(item => ({
|
|
1109
|
+
artifactId: identifier(item.artifactId), available: logNumber(item.available), selected: logNumber(item.selected), reason: fixedCode(item.reason),
|
|
1110
|
+
truncated: item.truncated === true,
|
|
1111
|
+
})),
|
|
1112
|
+
admission: array(diagnostics.admission).slice(0, 32).filter(record).map(item => ({
|
|
1113
|
+
candidateId: identifier(item.candidateId), proposalId: identifier(item.proposalId), status: fixedCode(item.status), reason: fixedCode(item.reason),
|
|
1114
|
+
})),
|
|
1115
|
+
trace: {
|
|
1116
|
+
activity: record(trace.activity) && LOG_ACTIVITIES.includes(trace.activity.choice) ? {
|
|
1117
|
+
choice: trace.activity.choice,
|
|
1118
|
+
confidence: logProbability(trace.activity.confidence),
|
|
1119
|
+
probabilities: Object.fromEntries(LOG_ACTIVITIES.filter(activity => probability(trace.activity.probabilities?.[activity]))
|
|
1120
|
+
.map(activity => [activity, trace.activity.probabilities[activity]])),
|
|
1121
|
+
} : null,
|
|
1122
|
+
thresholds: {
|
|
1123
|
+
...logNumbers(trace.thresholds),
|
|
1124
|
+
...Object.fromEntries(['intake', 'admission', 'intakePolicy', 'admissionPolicy']
|
|
1125
|
+
.filter(key => record(trace.thresholds?.[key])).map(key => [key, logNumbers(trace.thresholds[key])])),
|
|
1126
|
+
},
|
|
1127
|
+
relevance: logProbability(trace.relevance),
|
|
1128
|
+
intake: array(trace.intake).slice(0, LOG_LIMITS.candidates).filter(record).map(item => ({
|
|
1129
|
+
candidateId: identifier(item.candidateId), relevant: logProbability(item.relevant),
|
|
1130
|
+
sensitive: logProbability(item.sensitive), approved: logBoolean(item.approved), reason: fixedCode(item.reason),
|
|
1131
|
+
materialized: logBoolean(item.materialized),
|
|
1132
|
+
})),
|
|
1133
|
+
nodes: array(trace.nodes).slice(0, LOG_LIMITS.candidates).filter(record).map(item => ({
|
|
1134
|
+
candidateId: identifier(item.candidateId), role: token(item.role, [...ROLES, 'unknown'], 'unknown'),
|
|
1135
|
+
supportProbability: logProbability(item.supportProbability), roleProbability: logProbability(item.roleProbability),
|
|
1136
|
+
roleConfidence: logProbability(item.roleConfidence), classification: fixedCode(item.classification), reasons: reasons(item.reasons),
|
|
1137
|
+
roleProbabilities: Object.fromEntries([...ROLES, 'unknown'].filter(role => probability(item.roleProbabilities?.[role]))
|
|
1138
|
+
.map(role => [role, item.roleProbabilities[role]])),
|
|
1139
|
+
})),
|
|
1140
|
+
edges: array(trace.edges).slice(0, LOG_LIMITS.edges).filter(record).map(item => ({
|
|
1141
|
+
proposalId: identifier(item.proposalId), sourceCandidateId: identifier(item.sourceCandidateId),
|
|
1142
|
+
targetCandidateId: identifier(item.targetCandidateId), relation: token(item.relation, RELATIONS, 'unknown'),
|
|
1143
|
+
evidenceCandidateIds: array(item.evidenceCandidateIds).slice(0, 16).map(identifier).filter(Boolean),
|
|
1144
|
+
supportProbability: logProbability(item.supportProbability), missingContextProbability: logProbability(item.missingContextProbability),
|
|
1145
|
+
classification: fixedCode(item.classification), reasons: reasons(item.reasons),
|
|
1146
|
+
})),
|
|
1147
|
+
requests: array(trace.requests).slice(0, LOG_LIMITS.requests).filter(record).map(item => ({
|
|
1148
|
+
stage: fixedCode(item.stage), model: safeText(item.model, 120), rubricVersion: safeText(item.rubricVersion, 120),
|
|
1149
|
+
status: fixedCode(item.status), code: fixedCode(item.code), durationMs: logNumber(item.durationMs),
|
|
1150
|
+
questionCount: logNumber(item.questionCount), requestBytes: logNumber(item.requestBytes),
|
|
1151
|
+
dispatched: logBoolean(item.dispatched),
|
|
1152
|
+
httpStatus: Number.isInteger(item.httpStatus) && item.httpStatus >= 100 && item.httpStatus <= 599 ? item.httpStatus : null,
|
|
1153
|
+
})),
|
|
1154
|
+
},
|
|
1155
|
+
},
|
|
1156
|
+
patch: Object.fromEntries(['revisionBefore', 'revisionAfter', 'nodesAdded', 'nodesUpdated', 'nodesRemoved',
|
|
1157
|
+
'edgesAdded', 'edgesUpdated', 'edgesRemoved'].map(key => [key, logNumber(patch[key])])),
|
|
1158
|
+
// Keys are presentation-only; neither raw records nor canonical graph data are edited.
|
|
1159
|
+
key: JSON.stringify([id, sequence, at, identifier(entry.eventId), fixedCode(entry.stage), id || sequence !== null ? null : index]),
|
|
1160
|
+
truncated: entry.truncated === true,
|
|
1161
|
+
trimmed: entry.truncated === true || [
|
|
1162
|
+
[entry.artifacts, LOG_LIMITS.artifacts], [entry.candidates, LOG_LIMITS.candidates],
|
|
1163
|
+
[trace.intake, LOG_LIMITS.candidates], [trace.nodes, LOG_LIMITS.candidates],
|
|
1164
|
+
[trace.edges, LOG_LIMITS.edges], [trace.requests, LOG_LIMITS.requests],
|
|
1165
|
+
[diagnostics.extraction, 33], [diagnostics.admission, 32],
|
|
1166
|
+
].some(([items, limit]) => Array.isArray(items) && items.length > limit),
|
|
1167
|
+
};
|
|
1168
|
+
});
|
|
1169
|
+
return {
|
|
1170
|
+
schemaVersion: 1, records, stats: logNumbers(value.stats), logPath: safeText(value.logPath, 1024),
|
|
1171
|
+
omitted: Math.max(0, value.records.length - records.length),
|
|
1172
|
+
};
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
export function filterDiagnostics(records, { query = '', sessionId = '' } = {}) {
|
|
1176
|
+
const terms = safeText(query, 200).toLocaleLowerCase().trim().split(/\s+/).filter(Boolean);
|
|
1177
|
+
return records.filter(entry => {
|
|
1178
|
+
if (sessionId && entry.sessionId !== sessionId) return false;
|
|
1179
|
+
const searchable = [entry.eventId, entry.sourceEventId, entry.sessionId, entry.eventKind, entry.toolCategory,
|
|
1180
|
+
entry.stage, entry.status, entry.reason, logReason(entry.reason), entry.diagnostics.code, entry.diagnostics.trace.activity?.choice,
|
|
1181
|
+
...entry.artifacts.flatMap(artifact => [artifact.artifactId, artifact.path]),
|
|
1182
|
+
...entry.candidates.flatMap(candidate => [candidate.candidateId, candidate.artifactId, candidate.label]),
|
|
1183
|
+
].join(' ').toLocaleLowerCase();
|
|
1184
|
+
return terms.every(term => searchable.includes(term));
|
|
1185
|
+
}).reverse();
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
export function startDiagnosticsDialog({ load = options => request('/api/diagnostics', options), currentSession = () => '' } = {}) {
|
|
1189
|
+
const $ = id => document.getElementById(id);
|
|
1190
|
+
const dialog = $('diagnostics-dialog'), trigger = $('classification-log'), closeButton = $('diagnostics-close');
|
|
1191
|
+
const search = $('diagnostics-search'), scope = $('diagnostics-session'), refresh = $('diagnostics-refresh');
|
|
1192
|
+
let opened = false, disposed = false, epoch = 0, returnFocus = null, selectedSession = '';
|
|
1193
|
+
let info = null, rows = [], loadController = null;
|
|
1194
|
+
|
|
1195
|
+
function abortLoad() {
|
|
1196
|
+
const pending = loadController;
|
|
1197
|
+
loadController = null;
|
|
1198
|
+
pending?.abort();
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
function percent(value) { return value === null ? 'Not reported' : `${Math.round(value * 1000) / 10}%`; }
|
|
1202
|
+
function fact(list, label, value) {
|
|
1203
|
+
if (value === '' || value === null || value === undefined) return;
|
|
1204
|
+
const item = html('div');
|
|
1205
|
+
item.append(html('dt', label), html('dd', String(value)));
|
|
1206
|
+
list.append(item);
|
|
1207
|
+
}
|
|
1208
|
+
function table(title, headings, values) {
|
|
1209
|
+
const section = html('section', undefined, 'diagnostics-detail-section');
|
|
1210
|
+
section.append(html('h3', title));
|
|
1211
|
+
const wrap = html('div', undefined, 'diagnostics-table-wrap');
|
|
1212
|
+
wrap.setAttribute('tabindex', '0'); wrap.setAttribute('role', 'region'); wrap.setAttribute('aria-label', `${title}, scrollable table`);
|
|
1213
|
+
const table = html('table'), head = html('thead'), header = html('tr'), body = html('tbody');
|
|
1214
|
+
table.setAttribute('aria-label', title);
|
|
1215
|
+
headings.forEach(label => { const cell = html('th', label); cell.setAttribute('scope', 'col'); header.append(cell); });
|
|
1216
|
+
head.append(header);
|
|
1217
|
+
values.slice(0, 20).forEach(values => { const row = html('tr'); values.forEach(value => row.append(html('td', String(value)))); body.append(row); });
|
|
1218
|
+
table.append(head, body); wrap.append(table); section.append(wrap);
|
|
1219
|
+
section.scrollControl = wrap;
|
|
1220
|
+
if (values.length > 20) section.append(html('p', 'First 20 shown. More entries appear in the diagnostic JSON below.', 'diagnostics-note'));
|
|
1221
|
+
return section;
|
|
1222
|
+
}
|
|
1223
|
+
function detail(entry) {
|
|
1224
|
+
const body = html('div', undefined, 'diagnostics-detail');
|
|
1225
|
+
const facts = html('dl', undefined, 'diagnostics-facts');
|
|
1226
|
+
const trace = entry.diagnostics.trace;
|
|
1227
|
+
fact(facts, 'Reason code', entry.reason || entry.diagnostics.code || 'Not reported');
|
|
1228
|
+
fact(facts, 'Recorded at', entry.at);
|
|
1229
|
+
fact(facts, 'Event', entry.eventId); fact(facts, 'Source event', entry.sourceEventId);
|
|
1230
|
+
fact(facts, 'Session', entry.sessionId || 'Not assigned');
|
|
1231
|
+
fact(facts, 'Captured event', [entry.eventKind, entry.toolCategory].filter(Boolean).join(' / '));
|
|
1232
|
+
fact(facts, 'Activity classification', trace.activity ? upperFirst(trace.activity.choice) : 'Not recorded at this stage');
|
|
1233
|
+
if (trace.activity) fact(facts, 'Activity confidence', percent(trace.activity.confidence));
|
|
1234
|
+
fact(facts, 'Duration', entry.diagnostics.durationMs === null ? null : `${entry.diagnostics.durationMs} ms`);
|
|
1235
|
+
fact(facts, 'Classifier calls', entry.diagnostics.calls);
|
|
1236
|
+
fact(facts, 'Candidates omitted', entry.diagnostics.candidatesOmitted);
|
|
1237
|
+
fact(facts, 'Relation proposals omitted', entry.diagnostics.proposalsOmitted);
|
|
1238
|
+
if (entry.patch.revisionBefore !== null || entry.patch.revisionAfter !== null) {
|
|
1239
|
+
fact(facts, 'Graph revision', `${entry.patch.revisionBefore ?? 'Unknown'} → ${entry.patch.revisionAfter ?? 'Unknown'}`);
|
|
1240
|
+
fact(facts, 'Components', ['Added', 'Updated', 'Removed'].map(action => `${action.toLowerCase()}: ${entry.patch[`nodes${action}`] ?? 'not reported'}`).join(', '));
|
|
1241
|
+
fact(facts, 'Relationships', ['Added', 'Updated', 'Removed'].map(action => `${action.toLowerCase()}: ${entry.patch[`edges${action}`] ?? 'not reported'}`).join(', '));
|
|
1242
|
+
}
|
|
1243
|
+
body.append(facts);
|
|
1244
|
+
if (entry.artifacts.length) body.append(table('Files considered', ['File or artifact', 'Status', 'Complete capture', 'Candidates'],
|
|
1245
|
+
entry.artifacts.map(item => [item.path || item.artifactId, logLabel(item.status) || 'Not reported',
|
|
1246
|
+
item.complete === null ? 'Not reported' : item.complete ? 'Yes' : 'No', item.candidateCount ?? 'Not reported'])));
|
|
1247
|
+
const labels = new Map(entry.candidates.map(item => [item.candidateId, item.label || item.candidateId]));
|
|
1248
|
+
const label = id => labels.get(id) || id || 'Not reported';
|
|
1249
|
+
const files = new Map(entry.artifacts.map(item => [item.artifactId, item.path || item.artifactId]));
|
|
1250
|
+
if (entry.diagnostics.extraction.length) body.append(table('Candidate discovery', ['File or artifact', 'Available', 'Selected', 'Reason'],
|
|
1251
|
+
entry.diagnostics.extraction.map(item => [files.get(item.artifactId) || item.artifactId || 'Not reported',
|
|
1252
|
+
item.available === null ? 'Not reported' : `${item.available}${item.truncated ? '+' : ''}`,
|
|
1253
|
+
item.selected ?? 'Not reported', logReason(item.reason)])));
|
|
1254
|
+
if (entry.diagnostics.admission.length) body.append(table('Drawing decisions', ['Candidate or proposal', 'Outcome', 'Reason'],
|
|
1255
|
+
entry.diagnostics.admission.map(item => [item.candidateId ? label(item.candidateId) : item.proposalId || 'Not reported',
|
|
1256
|
+
logLabel(item.status) || 'Not reported', logReason(item.reason)])));
|
|
1257
|
+
if (trace.requests.length) body.append(table('Classifier requests', ['Stage / model / rubric', 'Duration', 'Outcome / code', 'Questions'],
|
|
1258
|
+
trace.requests.slice(0, 2).map(item => [
|
|
1259
|
+
[item.stage || 'Stage not reported', item.model || 'Model not reported', item.rubricVersion || 'Rubric not reported'].join(' / '),
|
|
1260
|
+
item.durationMs === null ? 'Not reported' : `${item.durationMs} ms`,
|
|
1261
|
+
`${upperFirst(logLabel(item.status)) || 'Not reported'} / ${item.code || 'No code reported'}`,
|
|
1262
|
+
item.questionCount ?? 'Not reported',
|
|
1263
|
+
])));
|
|
1264
|
+
const thresholdNames = {
|
|
1265
|
+
relevantMin: 'Minimum relevance', sensitiveMax: 'Maximum sensitivity', relevanceMin: 'Minimum relevance',
|
|
1266
|
+
nodeSupportMin: 'Minimum component support', roleProbabilityMin: 'Minimum role probability',
|
|
1267
|
+
roleConfidenceMin: 'Minimum role confidence', edgeSupportMin: 'Minimum relationship support',
|
|
1268
|
+
missingContextMax: 'Maximum missing context',
|
|
1269
|
+
};
|
|
1270
|
+
const thresholds = ['intake', 'admission'].flatMap(stage =>
|
|
1271
|
+
Object.entries(trace.thresholds[stage] || trace.thresholds[`${stage}Policy`] || {})
|
|
1272
|
+
.filter(([, value]) => probability(value))
|
|
1273
|
+
.map(([name, value]) => [upperFirst(stage), thresholdNames[name] || upperFirst(logLabel(name)), percent(value)]));
|
|
1274
|
+
if (thresholds.length) body.append(table('Decision thresholds', ['Stage', 'Threshold', 'Value'], thresholds.slice(0, 9)));
|
|
1275
|
+
if (trace.intake.length) body.append(table('Intake checks', ['Candidate', 'Relevant', 'Sensitive', 'Passes intake', 'Sent to architecture', 'Reason'],
|
|
1276
|
+
trace.intake.map(item => [label(item.candidateId), percent(item.relevant), percent(item.sensitive),
|
|
1277
|
+
item.approved === null ? 'Not reported' : item.approved ? 'Yes' : 'No',
|
|
1278
|
+
item.materialized === null ? 'Not reported' : item.materialized ? 'Yes' : 'No', logReason(item.reason)])));
|
|
1279
|
+
if (trace.relevance !== null) body.append(html('p', `Overall relevance: ${percent(trace.relevance)}`, 'diagnostics-note'));
|
|
1280
|
+
if (trace.nodes.length) body.append(table('Component scores', ['Candidate / role', 'Support', 'Role probability', 'Role confidence', 'Outcome / reason'],
|
|
1281
|
+
trace.nodes.map(item => [`${label(item.candidateId)} / ${upperFirst(item.role)}`, percent(item.supportProbability),
|
|
1282
|
+
percent(item.roleProbability), percent(item.roleConfidence),
|
|
1283
|
+
[logLabel(item.classification), ...item.reasons.map(logReason)].filter(Boolean).join('; ') || 'Not reported'])));
|
|
1284
|
+
if (trace.edges.length) body.append(table('Relationship scores', ['Connection', 'Support', 'Missing context', 'Outcome / reason'],
|
|
1285
|
+
trace.edges.map(item => [`${label(item.sourceCandidateId)} ${readable(item.relation)} ${label(item.targetCandidateId)}`,
|
|
1286
|
+
percent(item.supportProbability), percent(item.missingContextProbability),
|
|
1287
|
+
[logLabel(item.classification), ...item.reasons.map(logReason)].filter(Boolean).join('; ') || 'Not reported'])));
|
|
1288
|
+
if (!trace.nodes.length && !trace.edges.length && !trace.intake.length) {
|
|
1289
|
+
body.append(html('p', 'No component or relationship scores were recorded at this stage.', 'diagnostics-note'));
|
|
1290
|
+
}
|
|
1291
|
+
const { key, trimmed, ...safe } = entry;
|
|
1292
|
+
const json = JSON.stringify(safe, null, 2);
|
|
1293
|
+
body.append(html('h3', 'Diagnostic JSON'));
|
|
1294
|
+
if (entry.truncated) body.append(html('p', 'The server abbreviated this record; some diagnostic details are unavailable.', 'diagnostics-note'));
|
|
1295
|
+
else if (trimmed || json.length > 48000) body.append(html('p', 'Details are abbreviated to keep this viewer responsive.', 'diagnostics-note'));
|
|
1296
|
+
const pre = html('pre', json.slice(0, 48000) + (json.length > 48000 ? '\n… (display limit reached)' : ''));
|
|
1297
|
+
pre.setAttribute('tabindex', '0'); pre.setAttribute('aria-label', 'Diagnostic JSON');
|
|
1298
|
+
body.append(pre);
|
|
1299
|
+
return { body, pre, controls: [...Array.from(body.children).filter(child => child.scrollControl).map(child => child.scrollControl), pre] };
|
|
1300
|
+
}
|
|
1301
|
+
function render() {
|
|
1302
|
+
if (!info) return;
|
|
1303
|
+
const expanded = new Set(rows.filter(row => row.element.open).map(row => row.key));
|
|
1304
|
+
const focused = rows.find(row => row.summary === document.activeElement)?.key;
|
|
1305
|
+
const matches = filterDiagnostics(info.records, { query: search.value, sessionId: scope.value === 'current' ? selectedSession : '' });
|
|
1306
|
+
rows = matches.slice(0, LOG_LIMITS.rows).map(entry => {
|
|
1307
|
+
const element = html('details', undefined, 'diagnostics-record'), summary = html('summary');
|
|
1308
|
+
const heading = html('span', undefined, 'diagnostics-record-heading');
|
|
1309
|
+
const stage = html('span', LOG_STAGES[entry.stage] || 'Other stage', 'diagnostics-stage');
|
|
1310
|
+
stage.dataset.stage = entry.stage;
|
|
1311
|
+
const time = html('time', entry.at ? formatTime(validTime(entry.at)) : 'Time not reported');
|
|
1312
|
+
if (entry.at) { time.setAttribute('datetime', entry.at); time.setAttribute('title', entry.at); }
|
|
1313
|
+
heading.append(stage, html('strong', logReason(entry.reason || entry.diagnostics.code)), time);
|
|
1314
|
+
const path = entry.artifacts.map(item => item.path || item.artifactId).filter(Boolean);
|
|
1315
|
+
const labels = entry.candidates.map(item => item.label).filter(Boolean);
|
|
1316
|
+
summary.append(heading, html('span', [...path.slice(0, 2), ...labels.slice(0, 2)].join(' · ') || entry.eventKind || 'No file or candidate label recorded', 'diagnostics-record-path'));
|
|
1317
|
+
summary.append(html('span', [upperFirst(logLabel(entry.status)) || 'Status not reported',
|
|
1318
|
+
entry.candidates.length ? `${entry.candidates.length} candidates` : '', entry.sessionId ? `Session ${shortId(entry.sessionId)}` : 'Session not assigned',
|
|
1319
|
+
].filter(Boolean).join(' · '), 'diagnostics-record-meta'));
|
|
1320
|
+
const next = diagnosticNextAction(entry);
|
|
1321
|
+
if (next) summary.append(html('span', next, 'diagnostics-record-meta'));
|
|
1322
|
+
element.append(summary);
|
|
1323
|
+
const row = { key: entry.key, element, summary, pre: null, controls: [] };
|
|
1324
|
+
const expand = () => {
|
|
1325
|
+
if (!element.open || row.pre) return;
|
|
1326
|
+
const content = detail(entry);
|
|
1327
|
+
row.pre = content.pre;
|
|
1328
|
+
row.controls = content.controls;
|
|
1329
|
+
element.append(content.body);
|
|
1330
|
+
};
|
|
1331
|
+
element.addEventListener('toggle', expand);
|
|
1332
|
+
if (expanded.has(entry.key)) { element.open = true; expand(); }
|
|
1333
|
+
return row;
|
|
1334
|
+
});
|
|
1335
|
+
$('diagnostics-records').replaceChildren(...rows.map(row => row.element));
|
|
1336
|
+
$('diagnostics-count').textContent = `Showing ${rows.length} of ${matches.length} matching records, newest first.${info.omitted ? ` ${info.omitted} older or unsupported records are outside this view.` : ''}`;
|
|
1337
|
+
$('diagnostics-empty').hidden = rows.length > 0;
|
|
1338
|
+
$('diagnostics-empty').textContent = info.records.length
|
|
1339
|
+
? 'No records match these filters. Try All sessions or a different file, label, or event.'
|
|
1340
|
+
: 'No classification records yet. After restarting the updated server, let your agent read or change a file, then refresh.';
|
|
1341
|
+
$('diagnostics-log-path').hidden = !info.logPath;
|
|
1342
|
+
$('diagnostics-log-path').textContent = info.logPath ? `Local log: ${info.logPath}` : '';
|
|
1343
|
+
$('diagnostics-stats').textContent = Object.entries(info.stats).slice(0, 8).map(([key, value]) => `${upperFirst(logLabel(key))}: ${value}`).join(' · ');
|
|
1344
|
+
if (focused) (rows.find(row => row.key === focused)?.summary || refresh).focus();
|
|
1345
|
+
}
|
|
1346
|
+
async function reload() {
|
|
1347
|
+
if (!opened || disposed) return;
|
|
1348
|
+
const ticket = ++epoch;
|
|
1349
|
+
abortLoad();
|
|
1350
|
+
const controller = new AbortController();
|
|
1351
|
+
loadController = controller;
|
|
1352
|
+
refresh.disabled = true;
|
|
1353
|
+
$('diagnostics-loading').hidden = false;
|
|
1354
|
+
$('diagnostics-error').hidden = true;
|
|
1355
|
+
$('diagnostics-error').textContent = '';
|
|
1356
|
+
$('diagnostics-records').setAttribute('aria-busy', 'true');
|
|
1357
|
+
try {
|
|
1358
|
+
const next = normalizeDiagnostics(await load({ signal: controller.signal }));
|
|
1359
|
+
if (!opened || disposed || ticket !== epoch) return;
|
|
1360
|
+
info = next;
|
|
1361
|
+
render();
|
|
1362
|
+
$('diagnostics-updated').textContent = `Refreshed ${formatTime(Date.now())}. Refresh to see newer records.`;
|
|
1363
|
+
} catch (cause) {
|
|
1364
|
+
if (!opened || disposed || ticket !== epoch) return;
|
|
1365
|
+
$('diagnostics-error').textContent = cause.status === 404 || cause.message === 'invalid_diagnostics'
|
|
1366
|
+
? 'This server does not provide the classification log yet. Restart the updated local server, then refresh. Earlier scores cannot be recovered.'
|
|
1367
|
+
: cause.message === 'auth_required'
|
|
1368
|
+
? 'Viewer authorization is required. Reopen a fresh viewer link from the local server.'
|
|
1369
|
+
: `The classification log could not be loaded. Check the local server and refresh. If it was started before this update, restart it.${info ? ' Showing the last loaded records.' : ''}`;
|
|
1370
|
+
$('diagnostics-error').hidden = false;
|
|
1371
|
+
} finally {
|
|
1372
|
+
if (loadController === controller) loadController = null;
|
|
1373
|
+
if (opened && ticket === epoch) {
|
|
1374
|
+
refresh.disabled = false;
|
|
1375
|
+
$('diagnostics-loading').hidden = true;
|
|
1376
|
+
$('diagnostics-records').setAttribute('aria-busy', 'false');
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
function finishClose() {
|
|
1381
|
+
if (!opened) return;
|
|
1382
|
+
opened = false; epoch++;
|
|
1383
|
+
abortLoad();
|
|
1384
|
+
info = null; rows = [];
|
|
1385
|
+
$('diagnostics-records').replaceChildren();
|
|
1386
|
+
for (const id of ['diagnostics-log-path', 'diagnostics-stats', 'diagnostics-count', 'diagnostics-updated', 'diagnostics-error']) $(id).textContent = '';
|
|
1387
|
+
$('diagnostics-log-path').hidden = true; $('diagnostics-loading').hidden = true; $('diagnostics-error').hidden = true;
|
|
1388
|
+
$('diagnostics-records').setAttribute('aria-busy', 'false');
|
|
1389
|
+
refresh.disabled = false;
|
|
1390
|
+
const destination = returnFocus && document.body.contains(returnFocus) && !returnFocus.disabled ? returnFocus : trigger;
|
|
1391
|
+
returnFocus = null;
|
|
1392
|
+
if (!disposed) destination.focus({ preventScroll: true });
|
|
1393
|
+
}
|
|
1394
|
+
function closeDialog() { if (opened) { dialog.close(); finishClose(); } }
|
|
1395
|
+
function openDialog() {
|
|
1396
|
+
if (disposed || opened) return;
|
|
1397
|
+
selectedSession = identifier(currentSession());
|
|
1398
|
+
search.value = ''; scope.value = selectedSession ? 'current' : 'all';
|
|
1399
|
+
const current = html('option', selectedSession ? `Current session (${shortId(selectedSession)})` : 'Current session unavailable');
|
|
1400
|
+
current.value = 'current'; current.disabled = !selectedSession;
|
|
1401
|
+
const all = html('option', 'All sessions'); all.value = 'all';
|
|
1402
|
+
scope.replaceChildren(current, all);
|
|
1403
|
+
scope.value = selectedSession ? 'current' : 'all';
|
|
1404
|
+
returnFocus = document.activeElement || trigger;
|
|
1405
|
+
opened = true;
|
|
1406
|
+
$('diagnostics-empty').hidden = true;
|
|
1407
|
+
dialog.showModal(); closeButton.focus();
|
|
1408
|
+
return reload();
|
|
1409
|
+
}
|
|
1410
|
+
function onKeyDown(event) {
|
|
1411
|
+
if (!opened) return;
|
|
1412
|
+
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); closeDialog(); }
|
|
1413
|
+
else if (event.key === 'Tab') {
|
|
1414
|
+
const controls = [closeButton, search, scope, refresh, ...rows.flatMap(row => [row.summary, ...(row.element.open ? row.controls : [])])]
|
|
1415
|
+
.filter(control => !control.disabled && !control.hidden);
|
|
1416
|
+
const index = controls.indexOf(document.activeElement);
|
|
1417
|
+
if (index < 0 || (!event.shiftKey && index === controls.length - 1) || (event.shiftKey && index === 0)) {
|
|
1418
|
+
event.preventDefault(); controls[event.shiftKey ? controls.length - 1 : 0].focus();
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
const onFilter = () => { if (opened) render(); };
|
|
1423
|
+
const onCancel = event => { event.preventDefault(); closeDialog(); };
|
|
1424
|
+
const onNativeClose = () => { if (!dialog.open) finishClose(); };
|
|
1425
|
+
trigger.addEventListener('click', openDialog); closeButton.addEventListener('click', closeDialog);
|
|
1426
|
+
refresh.addEventListener('click', reload); search.addEventListener('input', onFilter); scope.addEventListener('change', onFilter);
|
|
1427
|
+
dialog.addEventListener('keydown', onKeyDown); dialog.addEventListener('cancel', onCancel); dialog.addEventListener('close', onNativeClose);
|
|
1428
|
+
return {
|
|
1429
|
+
open: openDialog,
|
|
1430
|
+
close: closeDialog,
|
|
1431
|
+
dispose() {
|
|
1432
|
+
disposed = true; closeDialog(); epoch++;
|
|
1433
|
+
abortLoad();
|
|
1434
|
+
trigger.removeEventListener('click', openDialog); closeButton.removeEventListener('click', closeDialog);
|
|
1435
|
+
refresh.removeEventListener('click', reload); search.removeEventListener('input', onFilter); scope.removeEventListener('change', onFilter);
|
|
1436
|
+
dialog.removeEventListener('keydown', onKeyDown); dialog.removeEventListener('cancel', onCancel); dialog.removeEventListener('close', onNativeClose);
|
|
1437
|
+
},
|
|
1438
|
+
};
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
export function startViewer() {
|
|
1442
|
+
const $ = id => document.getElementById(id);
|
|
1443
|
+
const state = {
|
|
1444
|
+
snapshot: null, selection: null, replayFrame: null, frames: [], stream: null,
|
|
1445
|
+
connection: 'connecting', busy: false, exporting: false, epoch: 0, connectEpoch: 0,
|
|
1446
|
+
viewport: null, fitBounds: null, zoom: 1, followFit: true, lastGraphSignature: '', inspectorSignature: '',
|
|
1447
|
+
nodeElements: new Map(), edgeElements: new Map(), activityElements: new Map(),
|
|
1448
|
+
views: new Map(), viewKey: null, view: null, displayGraph: null,
|
|
1449
|
+
effects: new Map(), motionReady: false, movement: null, closed: false, projectName: '',
|
|
1450
|
+
};
|
|
1451
|
+
const motionPreference = window.matchMedia?.('(prefers-reduced-motion: reduce)');
|
|
1452
|
+
const sketches = createSketchCache();
|
|
1453
|
+
const detailSketches = createSketchCache(sketchDetails);
|
|
1454
|
+
const connectionDialog = startConnectionDialog({ onInfo: info => {
|
|
1455
|
+
state.projectName = friendlyProjectName(info.projectRoot);
|
|
1456
|
+
renderStatus();
|
|
1457
|
+
} });
|
|
1458
|
+
const diagnosticsDialog = startDiagnosticsDialog({ currentSession: () => state.snapshot?.sessionId });
|
|
1459
|
+
const sidebar = createLiveSidebar({
|
|
1460
|
+
onInspect(selection, id) {
|
|
1461
|
+
const target = typeof selection === 'string' ? { type: selection, id } : selection;
|
|
1462
|
+
if (state.closed || !record(target) || !['node', 'edge'].includes(target.type)) return;
|
|
1463
|
+
if (state.replayFrame) {
|
|
1464
|
+
resetMotionBaseline();
|
|
1465
|
+
state.replayFrame = null;
|
|
1466
|
+
render();
|
|
1467
|
+
}
|
|
1468
|
+
select(target.type, identifier(target.id));
|
|
1469
|
+
},
|
|
1470
|
+
onReplay(revision) {
|
|
1471
|
+
if (state.closed) return;
|
|
1472
|
+
const index = state.frames.findIndex(frame => frame.revision === revision);
|
|
1473
|
+
if (index >= 0) replayAt(index);
|
|
1474
|
+
},
|
|
1475
|
+
});
|
|
1476
|
+
let toastTimer;
|
|
1477
|
+
let announcementTimer;
|
|
1478
|
+
let pointer = null;
|
|
1479
|
+
let canvasSize = '';
|
|
1480
|
+
let projectController = null;
|
|
1481
|
+
|
|
1482
|
+
function announce(message) {
|
|
1483
|
+
clearTimeout(announcementTimer);
|
|
1484
|
+
announcementTimer = setTimeout(() => { $('announcement').textContent = message; }, 250);
|
|
1485
|
+
}
|
|
1486
|
+
function toast(message) {
|
|
1487
|
+
clearTimeout(toastTimer);
|
|
1488
|
+
$('toast').textContent = message;
|
|
1489
|
+
$('toast').hidden = false;
|
|
1490
|
+
toastTimer = setTimeout(() => { $('toast').hidden = true; }, 4500);
|
|
1491
|
+
}
|
|
1492
|
+
function error(message = '') {
|
|
1493
|
+
$('error-banner').textContent = message;
|
|
1494
|
+
$('error-banner').hidden = !message;
|
|
1495
|
+
}
|
|
1496
|
+
function connection(value) {
|
|
1497
|
+
state.connection = value;
|
|
1498
|
+
$('connection').dataset.state = value;
|
|
1499
|
+
$('connection-label').textContent = {
|
|
1500
|
+
connecting: 'Connecting to local service',
|
|
1501
|
+
connected: 'Connected to local service',
|
|
1502
|
+
reconnecting: 'Disconnected · reconnecting',
|
|
1503
|
+
error: 'Local service unavailable',
|
|
1504
|
+
auth: 'Viewer authorization required',
|
|
1505
|
+
}[value] || 'Connection unknown';
|
|
1506
|
+
$('retry').hidden = value === 'connected' || value === 'connecting';
|
|
1507
|
+
renderOnboarding();
|
|
1508
|
+
updateControls();
|
|
1509
|
+
}
|
|
1510
|
+
function renderOnboarding() {
|
|
1511
|
+
const progress = onboardingProgress(state.snapshot, state.connection);
|
|
1512
|
+
for (const step of progress.steps) {
|
|
1513
|
+
const element = $(`onboarding-${step.id}`);
|
|
1514
|
+
element.textContent = step.label;
|
|
1515
|
+
element.dataset.state = step.state;
|
|
1516
|
+
}
|
|
1517
|
+
$('onboarding-next').textContent = progress.next.text;
|
|
1518
|
+
$('onboarding-action').textContent = progress.next.label;
|
|
1519
|
+
$('onboarding-action').dataset.action = progress.next.action;
|
|
1520
|
+
// Preserve a manual text selection while live snapshots arrive.
|
|
1521
|
+
if ($('orientation-prompt').textContent !== ORIENTATION_PROMPT) $('orientation-prompt').textContent = ORIENTATION_PROMPT;
|
|
1522
|
+
$('orientation').hidden = Boolean(state.replayFrame || state.snapshot?.mode === 'demo' || state.snapshot?.mode === 'replay');
|
|
1523
|
+
}
|
|
1524
|
+
function currentGraph() { return state.replayFrame?.graph || state.snapshot?.graph; }
|
|
1525
|
+
function applyTheme() {
|
|
1526
|
+
const theme = token(state.view?.theme, THEMES, 'sketchbook');
|
|
1527
|
+
if ($('drawing').dataset.theme !== theme) $('drawing').dataset.theme = theme;
|
|
1528
|
+
$('theme').value = theme;
|
|
1529
|
+
}
|
|
1530
|
+
function presentation() {
|
|
1531
|
+
const key = presentationKey(state.snapshot, state.replayFrame);
|
|
1532
|
+
if (state.viewKey !== key) {
|
|
1533
|
+
finishPan();
|
|
1534
|
+
clearMotion();
|
|
1535
|
+
state.motionReady = false;
|
|
1536
|
+
let view = state.views.get(key);
|
|
1537
|
+
if (!view) view = createPresentation();
|
|
1538
|
+
state.views.delete(key);
|
|
1539
|
+
state.views.set(key, view);
|
|
1540
|
+
while (state.views.size > MAX_VIEWS) state.views.delete(state.views.keys().next().value);
|
|
1541
|
+
state.view = view;
|
|
1542
|
+
state.viewKey = key;
|
|
1543
|
+
state.viewport = view.camera ? { ...view.camera.viewport } : null;
|
|
1544
|
+
state.fitBounds = view.camera ? { ...view.camera.fitBounds } : null;
|
|
1545
|
+
state.zoom = view.camera?.zoom || 1;
|
|
1546
|
+
state.followFit = view.camera?.followFit ?? true;
|
|
1547
|
+
state.lastGraphSignature = '';
|
|
1548
|
+
}
|
|
1549
|
+
return state.view;
|
|
1550
|
+
}
|
|
1551
|
+
function motionAllowed() { return !state.closed && !document.hidden && motionPreference?.matches !== true; }
|
|
1552
|
+
function removalBounds() {
|
|
1553
|
+
return [...state.effects.values()].flatMap(effect => effect.bounds ? [effect.bounds] : []);
|
|
1554
|
+
}
|
|
1555
|
+
function finishEffect(id) {
|
|
1556
|
+
const effect = state.effects.get(id);
|
|
1557
|
+
if (!effect) return;
|
|
1558
|
+
clearTimeout(effect.timer);
|
|
1559
|
+
effect.visual?.classList.remove('is-appearing');
|
|
1560
|
+
effect.element?.remove();
|
|
1561
|
+
effect.target?.removeEventListener?.('animationend', effect.finish);
|
|
1562
|
+
state.effects.delete(id);
|
|
1563
|
+
if (effect.bounds && !removalBounds().length) {
|
|
1564
|
+
$('empty-canvas').hidden = Boolean(state.displayGraph?.nodes.length);
|
|
1565
|
+
// The final pop releases its extra canvas space. A manual camera chosen
|
|
1566
|
+
// during the animation remains authoritative until another graph change.
|
|
1567
|
+
if (!state.closed && state.followFit && state.displayGraph) {
|
|
1568
|
+
fitCamera(state.movement?.fitDuring || graphBounds(state.displayGraph));
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
function rememberEffect(id, effect, target, duration) {
|
|
1573
|
+
const finish = () => { if (state.effects.get(id) === effect) finishEffect(id); };
|
|
1574
|
+
Object.assign(effect, { finish, target, timer: setTimeout(finish, duration + 80) });
|
|
1575
|
+
state.effects.set(id, effect);
|
|
1576
|
+
target.addEventListener('animationend', finish);
|
|
1577
|
+
}
|
|
1578
|
+
function cancelMovement() {
|
|
1579
|
+
const movement = state.movement;
|
|
1580
|
+
if (!movement) return;
|
|
1581
|
+
state.movement = null;
|
|
1582
|
+
window.cancelAnimationFrame?.(movement.frame);
|
|
1583
|
+
clearTimeout(movement.timer);
|
|
1584
|
+
if (state.displayGraph) paintGeometry(state.displayGraph);
|
|
1585
|
+
if (movement.fitAfter) fitCamera(movement.fitAfter);
|
|
1586
|
+
}
|
|
1587
|
+
function clearMotion() {
|
|
1588
|
+
cancelMovement();
|
|
1589
|
+
for (const id of state.effects.keys()) finishEffect(id);
|
|
1590
|
+
}
|
|
1591
|
+
function resetMotionBaseline() {
|
|
1592
|
+
finishPan();
|
|
1593
|
+
state.motionReady = false;
|
|
1594
|
+
clearMotion();
|
|
1595
|
+
}
|
|
1596
|
+
function animateChanges(changes, before) {
|
|
1597
|
+
// Re-addition always cancels a removal, even when motion is suppressed.
|
|
1598
|
+
for (const node of currentGraph().nodes) if (state.effects.get(node.id)?.element) finishEffect(node.id);
|
|
1599
|
+
if (!motionAllowed()) return;
|
|
1600
|
+
const removals = [];
|
|
1601
|
+
for (const id of changes.removed) finishEffect(id);
|
|
1602
|
+
for (const id of changes.removed) {
|
|
1603
|
+
if (state.effects.size >= MAX_EFFECTS) break;
|
|
1604
|
+
const node = before?.nodes.find(item => item.id === id);
|
|
1605
|
+
if (!node) continue;
|
|
1606
|
+
const decoration = svgElement('g', { class: 'node-burst', 'aria-hidden': 'true', 'pointer-events': 'none', transform: `translate(${node.x + NODE_WIDTH / 2} ${node.y + NODE_HEIGHT / 2})` });
|
|
1607
|
+
decoration.dataset.kind = node.kind;
|
|
1608
|
+
const outline = svgElement('g', { class: 'burst-outline' });
|
|
1609
|
+
const content = svgElement('g', { transform: `translate(${-NODE_WIDTH / 2} ${-NODE_HEIGHT / 2})` });
|
|
1610
|
+
content.append(...shape(node), sketch(node));
|
|
1611
|
+
outline.append(content);
|
|
1612
|
+
decoration.append(outline);
|
|
1613
|
+
for (let index = 0; index < 8; index++) {
|
|
1614
|
+
const direction = svgElement('g', { transform: `rotate(${index * 45 + hashId(id) % 20})` });
|
|
1615
|
+
direction.append(svgElement('circle', { class: 'burst-particle', cx: 0, cy: 0, r: 3 }));
|
|
1616
|
+
decoration.append(direction);
|
|
1617
|
+
}
|
|
1618
|
+
// Covers the largest outline expansion and the 105px particle travel in
|
|
1619
|
+
// the existing pop animation, including the wider diamond silhouette.
|
|
1620
|
+
const bounds = { x: node.x - 64, y: node.y - 64, width: NODE_WIDTH + 128, height: NODE_HEIGHT + 128 };
|
|
1621
|
+
rememberEffect(id, { element: decoration, bounds }, decoration, 300);
|
|
1622
|
+
removals.push(decoration);
|
|
1623
|
+
}
|
|
1624
|
+
if (removals.length) {
|
|
1625
|
+
fitCamera(state.movement?.fitDuring || graphBounds(state.displayGraph));
|
|
1626
|
+
$('empty-canvas').hidden = true;
|
|
1627
|
+
$('effects-layer').append(...removals);
|
|
1628
|
+
}
|
|
1629
|
+
for (const id of changes.added) {
|
|
1630
|
+
finishEffect(id);
|
|
1631
|
+
if (state.effects.size >= MAX_EFFECTS) break;
|
|
1632
|
+
const visual = state.nodeElements.get(id)?.visual;
|
|
1633
|
+
if (!visual) continue;
|
|
1634
|
+
visual.classList.add('is-appearing');
|
|
1635
|
+
rememberEffect(id, { visual }, visual, 400);
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
function updateControls() {
|
|
1639
|
+
const online = state.connection === 'connected' && Boolean(state.snapshot);
|
|
1640
|
+
$('onboarding-action').disabled = state.connection === 'connecting' || state.busy;
|
|
1641
|
+
$('session').disabled = !online || state.busy || !state.snapshot?.sessions.length;
|
|
1642
|
+
$('pause').disabled = !online || state.busy;
|
|
1643
|
+
$('export').disabled = !online || state.exporting;
|
|
1644
|
+
$('pause').textContent = state.busy ? 'Applying…' : state.snapshot?.paused ? 'Resume classification' : 'Pause classification';
|
|
1645
|
+
$('pause').setAttribute('aria-pressed', String(Boolean(state.snapshot?.paused)));
|
|
1646
|
+
$('replay').disabled = !state.snapshot || state.frames.length < 2;
|
|
1647
|
+
$('history').disabled = !state.snapshot || state.frames.length < 2;
|
|
1648
|
+
const nodes = currentGraph()?.nodes.length || 0;
|
|
1649
|
+
$('fit').disabled = !nodes;
|
|
1650
|
+
$('zoom-in').disabled = !nodes || state.zoom >= MAX_ZOOM;
|
|
1651
|
+
$('zoom-out').disabled = !nodes || state.zoom <= MIN_ZOOM;
|
|
1652
|
+
$('layout').disabled = !state.snapshot;
|
|
1653
|
+
$('theme').disabled = !state.snapshot;
|
|
1654
|
+
$('arrange').disabled = !nodes;
|
|
1655
|
+
$('auto-arrange').disabled = !state.snapshot;
|
|
1656
|
+
}
|
|
1657
|
+
function resetView() {
|
|
1658
|
+
resetMotionBaseline();
|
|
1659
|
+
state.selection = null;
|
|
1660
|
+
state.replayFrame = null;
|
|
1661
|
+
state.viewport = null;
|
|
1662
|
+
state.fitBounds = null;
|
|
1663
|
+
state.followFit = true;
|
|
1664
|
+
state.zoom = 1;
|
|
1665
|
+
state.lastGraphSignature = '';
|
|
1666
|
+
state.inspectorSignature = '';
|
|
1667
|
+
}
|
|
1668
|
+
function acceptSnapshot(raw, streamed = false) {
|
|
1669
|
+
const snapshot = normalizeSnapshot(raw);
|
|
1670
|
+
if (!streamed) resetMotionBaseline();
|
|
1671
|
+
const switched = state.snapshot && (snapshot.sessionId !== state.snapshot.sessionId || snapshot.projectId !== state.snapshot.projectId);
|
|
1672
|
+
const eligible = streamed && state.motionReady && !switched && !state.replayFrame &&
|
|
1673
|
+
snapshot.mode !== 'replay' && state.snapshot?.mode !== 'replay' && motionAllowed();
|
|
1674
|
+
const changes = liveNodeChanges(state.snapshot?.graph, snapshot.graph, eligible);
|
|
1675
|
+
const before = state.displayGraph;
|
|
1676
|
+
cancelMovement();
|
|
1677
|
+
if (switched) resetView();
|
|
1678
|
+
state.snapshot = snapshot;
|
|
1679
|
+
state.epoch += 1;
|
|
1680
|
+
state.frames = historyFrames(snapshot);
|
|
1681
|
+
state.replayFrame = reconcileReplayFrame(state.frames, state.replayFrame);
|
|
1682
|
+
render();
|
|
1683
|
+
animateChanges(changes, before);
|
|
1684
|
+
state.motionReady = streamed && !state.replayFrame && snapshot.mode !== 'replay' && motionAllowed();
|
|
1685
|
+
$('updated-at').textContent = `Snapshot received ${formatTime(Date.now())}`;
|
|
1686
|
+
}
|
|
1687
|
+
function renderStatus() {
|
|
1688
|
+
const snapshot = state.snapshot;
|
|
1689
|
+
if (!snapshot) return;
|
|
1690
|
+
const classifier = snapshot.paused ? 'paused' : snapshot.status.classifier;
|
|
1691
|
+
const labels = {
|
|
1692
|
+
ready: ['Classifier ready', 'Capture continues independently; readiness does not confirm a classification.'],
|
|
1693
|
+
metadata_only: ['Metadata only', 'Source interpretation is off; safe activity remains visible.'],
|
|
1694
|
+
missing_key: ['Classifier not configured', 'Configure credentials in the local service; capture continues.'],
|
|
1695
|
+
paused: ['Classification paused', 'Capture and evidence invalidation continue.'],
|
|
1696
|
+
unavailable: ['Classifier unavailable', 'Capture continues; the last accepted map is retained.'],
|
|
1697
|
+
timeout: ['Classification delayed', 'The decision deadline elapsed; capture continues.'],
|
|
1698
|
+
demo: ['Fixture classifier', 'Offline demo; no live Jev evaluation.'],
|
|
1699
|
+
};
|
|
1700
|
+
$('classifier-label').textContent = labels[classifier][0];
|
|
1701
|
+
$('capture-note').textContent = labels[classifier][1];
|
|
1702
|
+
$('classifier-dot').dataset.state = classifier === 'ready' ? 'ready' : ['unavailable', 'timeout'].includes(classifier) ? 'error' : 'waiting';
|
|
1703
|
+
$('coverage-label').textContent = coverageSummary(snapshot.status.coverage, snapshot.status.dropped);
|
|
1704
|
+
$('coverage-label').title = $('coverage-label').textContent;
|
|
1705
|
+
$('queue-label').textContent = `${snapshot.status.pending} pending · ${snapshot.status.calls} classifier calls`;
|
|
1706
|
+
$('demo-banner').hidden = snapshot.mode !== 'demo' && snapshot.status.classifier !== 'demo';
|
|
1707
|
+
$('project-label').textContent = state.projectName || (snapshot.projectId ? `Project ${shortId(snapshot.projectId)}` : 'Local project');
|
|
1708
|
+
$('project-label').title = snapshot.projectId;
|
|
1709
|
+
const signature = JSON.stringify(snapshot.sessions);
|
|
1710
|
+
if ($('session').dataset.signature !== signature) {
|
|
1711
|
+
const options = snapshot.sessions.map(session => {
|
|
1712
|
+
const option = html('option', session.label);
|
|
1713
|
+
option.value = session.id;
|
|
1714
|
+
return option;
|
|
1715
|
+
});
|
|
1716
|
+
if (!options.length) options.push(html('option', 'Waiting for a session'));
|
|
1717
|
+
$('session').replaceChildren(...options);
|
|
1718
|
+
$('session').dataset.signature = signature;
|
|
1719
|
+
}
|
|
1720
|
+
$('session').value = snapshot.sessionId || '';
|
|
1721
|
+
}
|
|
1722
|
+
function shape(node) {
|
|
1723
|
+
const details = detailSketches.paths(node.shape, node.id);
|
|
1724
|
+
return [
|
|
1725
|
+
...canonicalShape(node, details.length === 0),
|
|
1726
|
+
...(details.length ? [sketchInk(details, 'node-sketch node-sketch-details')] : []),
|
|
1727
|
+
];
|
|
1728
|
+
}
|
|
1729
|
+
function canonicalShape(node, includeDetails) {
|
|
1730
|
+
const w = NODE_WIDTH;
|
|
1731
|
+
const h = NODE_HEIGHT;
|
|
1732
|
+
const detail = d => includeDetails ? [svgElement('path', { class: 'node-detail', d })] : [];
|
|
1733
|
+
if (node.shape === 'cylinder') return [
|
|
1734
|
+
svgElement('path', { class: 'node-shape', d: `M 0 17 C 0 -2 ${w} -2 ${w} 17 L ${w} ${h - 17} C ${w} ${h + 5} 0 ${h + 5} 0 ${h - 17} Z` }),
|
|
1735
|
+
...detail(`M 0 17 C 0 37 ${w} 37 ${w} 17`),
|
|
1736
|
+
];
|
|
1737
|
+
if (node.shape === 'cloud') return [svgElement('path', { class: 'node-shape', d: `M 18 99 C -8 99 -9 53 13 45 C -2 16 34 1 58 13 C 76 -7 132 -5 145 16 C 182 7 202 36 183 57 C 207 73 191 103 169 99 Z` })];
|
|
1738
|
+
if (node.shape === 'diamond') return [svgElement('path', { class: 'node-shape', d: `M ${w / 2} -12 L ${w + 14} ${h / 2} L ${w / 2} ${h + 12} L -14 ${h / 2} Z` })];
|
|
1739
|
+
const path = d => svgElement('path', { class: 'node-shape', d });
|
|
1740
|
+
const box = () => svgElement('rect', { class: 'node-shape', width: w, height: h, rx: 3 });
|
|
1741
|
+
if (node.shape === 'browser') return [box(), ...detail(`M 0 23 H ${w}`),
|
|
1742
|
+
...[12, 22, 32].map(cx => svgElement('circle', { class: 'node-detail', cx, cy: 12, r: 2 }))];
|
|
1743
|
+
if (node.shape === 'component') return [
|
|
1744
|
+
path(`M 10 0 H ${w} V ${h} H 10 Z`),
|
|
1745
|
+
svgElement('rect', { class: 'node-shape', x: 0, y: 18, width: 21, height: 17, rx: 1 }),
|
|
1746
|
+
svgElement('rect', { class: 'node-shape', x: 0, y: 68, width: 21, height: 17, rx: 1 }),
|
|
1747
|
+
];
|
|
1748
|
+
if (node.shape === 'queue') return [box(), ...detail(`M 17 0 V ${h} M ${w - 17} 0 V ${h}`),
|
|
1749
|
+
...detail('M 40 16 H 150 M 140 11 L 150 16 L 140 21')];
|
|
1750
|
+
if (node.shape === 'hexagon') return [path(`M 23 0 H ${w - 23} L ${w} ${h / 2} L ${w - 23} ${h} H 23 L 0 ${h / 2} Z`)];
|
|
1751
|
+
if (node.shape === 'class_box') return [box(), ...detail(`M 0 25 H ${w} M 0 72 H ${w}`),
|
|
1752
|
+
svgElement('text', { class: 'shape-symbol', x: w / 2, y: 17, 'text-anchor': 'middle' }, 'C')];
|
|
1753
|
+
if (node.shape === 'interface_box') return [box(), ...detail(`M 0 25 H ${w}`),
|
|
1754
|
+
svgElement('text', { class: 'shape-symbol', x: w / 2, y: 17, 'text-anchor': 'middle' }, '«interface»')];
|
|
1755
|
+
if (node.shape === 'document') return [path(`M 0 0 H ${w - 23} L ${w} 23 V ${h} H 0 Z`),
|
|
1756
|
+
...detail(`M ${w - 23} 0 V 23 H ${w}`)];
|
|
1757
|
+
if (node.shape === 'parallelogram') return [path(`M 22 0 H ${w} L ${w - 22} ${h} H 0 Z`)];
|
|
1758
|
+
if (node.shape === 'folder') return [path(`M 0 10 H 66 L 78 0 H ${w} V ${h} H 0 Z`), ...detail(`M 0 24 H ${w}`)];
|
|
1759
|
+
return [svgElement('rect', { class: 'node-shape', width: w, height: h, rx: node.shape === 'rounded_rect' ? 10 : node.shape === 'group' ? 2 : 5 })];
|
|
1760
|
+
}
|
|
1761
|
+
function inkPath(className, d = '') {
|
|
1762
|
+
return svgElement('path', { class: className, d, fill: 'none', 'aria-hidden': 'true', 'pointer-events': 'none' });
|
|
1763
|
+
}
|
|
1764
|
+
function sketchInk(paths, className) {
|
|
1765
|
+
const group = svgElement('g', { class: className, fill: 'none', 'aria-hidden': 'true', 'pointer-events': 'none' });
|
|
1766
|
+
paths.forEach((d, index) => {
|
|
1767
|
+
group.append(inkPath(index ? 'sketch-secondary' : 'sketch-primary', d));
|
|
1768
|
+
});
|
|
1769
|
+
return group;
|
|
1770
|
+
}
|
|
1771
|
+
function sketch(node) {
|
|
1772
|
+
// The helper owns only seeded decoration. Canonical fills, ports and hit
|
|
1773
|
+
// geometry stay in the existing shape renderer. Separate bounded caches
|
|
1774
|
+
// reuse outlines and details in live nodes, replay and removal decorations.
|
|
1775
|
+
return sketchInk(sketches.paths(node.shape, node.id), 'node-sketch');
|
|
1776
|
+
}
|
|
1777
|
+
function renderShapeKey() {
|
|
1778
|
+
const items = [];
|
|
1779
|
+
for (const [kind, name] of Object.entries(ROLE_SHAPES)) {
|
|
1780
|
+
const item = html('span', undefined, 'shape-key-item');
|
|
1781
|
+
item.dataset.kind = kind;
|
|
1782
|
+
const preview = svgElement('svg', { viewBox: '-16 -16 222 136', 'aria-hidden': 'true', focusable: 'false' });
|
|
1783
|
+
const node = { shape: name, id: `shape-key-${kind}` };
|
|
1784
|
+
preview.append(...shape(node), sketch(node));
|
|
1785
|
+
item.append(preview, html('span', upperFirst(kind)));
|
|
1786
|
+
items.push(item);
|
|
1787
|
+
}
|
|
1788
|
+
$('shape-key-items').replaceChildren(...items);
|
|
1789
|
+
}
|
|
1790
|
+
function interactiveGroup(type, id) {
|
|
1791
|
+
const group = svgElement('g', { class: type === 'node' ? 'diagram-node' : 'edge-control', role: 'button', tabindex: 0 });
|
|
1792
|
+
group.addEventListener('click', () => select(type, id));
|
|
1793
|
+
group.addEventListener('keydown', event => {
|
|
1794
|
+
if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); select(type, id); }
|
|
1795
|
+
});
|
|
1796
|
+
return group;
|
|
1797
|
+
}
|
|
1798
|
+
function select(type, id) {
|
|
1799
|
+
state.selection = { type, id };
|
|
1800
|
+
updateSelection();
|
|
1801
|
+
renderInspector();
|
|
1802
|
+
renderActivity();
|
|
1803
|
+
const item = type === 'node' ? currentGraph()?.nodes.find(node => node.id === id) : currentGraph()?.edges.find(edge => edge.id === id);
|
|
1804
|
+
if (item) {
|
|
1805
|
+
revealInspector();
|
|
1806
|
+
announce(`Evidence for ${item.label} shown in the inspector.`);
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
function revealInspector() {
|
|
1810
|
+
const container = $('live-sidebar');
|
|
1811
|
+
const panel = $('inspector-body')?.parentElement;
|
|
1812
|
+
if (!container?.scrollTo || !container.getBoundingClientRect || !panel?.getBoundingClientRect ||
|
|
1813
|
+
!container.contains(panel)) return;
|
|
1814
|
+
const region = container.getBoundingClientRect();
|
|
1815
|
+
const evidence = panel.getBoundingClientRect();
|
|
1816
|
+
if (!Number.isFinite(region.top) || !Number.isFinite(evidence.top) || !(region.height > 0)) return;
|
|
1817
|
+
const offset = evidence.top - region.top - (container.clientTop || 0);
|
|
1818
|
+
if (Math.abs(offset) < 1) return;
|
|
1819
|
+
container.scrollTo({
|
|
1820
|
+
top: Math.max(0, (container.scrollTop || 0) + offset),
|
|
1821
|
+
behavior: motionAllowed() ? 'smooth' : 'auto',
|
|
1822
|
+
});
|
|
1823
|
+
}
|
|
1824
|
+
function updateSelection() {
|
|
1825
|
+
for (const [id, group] of state.nodeElements) group.setAttribute('aria-pressed', String(state.selection?.type === 'node' && state.selection.id === id));
|
|
1826
|
+
for (const [id, group] of state.edgeElements) {
|
|
1827
|
+
const selected = state.selection?.type === 'edge' && state.selection.id === id;
|
|
1828
|
+
group.dataset.selected = String(selected);
|
|
1829
|
+
group.control.setAttribute('aria-pressed', String(selected));
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
function setViewBox() {
|
|
1833
|
+
if (!state.viewport) return;
|
|
1834
|
+
const box = state.viewport;
|
|
1835
|
+
$('architecture').setAttribute('viewBox', `${box.x} ${box.y} ${box.width} ${box.height}`);
|
|
1836
|
+
const percent = state.zoom * 100;
|
|
1837
|
+
$('zoom-level').textContent = `${percent >= 10 ? Math.round(percent) : Number(percent.toPrecision(2))}%`;
|
|
1838
|
+
$('architecture').classList.toggle('is-pannable', Boolean(currentGraph()?.nodes.length));
|
|
1839
|
+
if (state.view) state.view.camera = {
|
|
1840
|
+
viewport: { ...state.viewport }, fitBounds: { ...state.fitBounds }, zoom: state.zoom, followFit: state.followFit,
|
|
1841
|
+
};
|
|
1842
|
+
updateControls();
|
|
1843
|
+
}
|
|
1844
|
+
function fitCamera(bounds) {
|
|
1845
|
+
finishPan();
|
|
1846
|
+
const bursts = removalBounds();
|
|
1847
|
+
// With no remaining components, frame the pops themselves instead of
|
|
1848
|
+
// adding the empty diagram's arbitrary origin to their bounds.
|
|
1849
|
+
if (bursts.length && !state.displayGraph?.nodes.length) bounds = bursts[0];
|
|
1850
|
+
for (const burst of bursts) bounds = combinedBounds(bounds, burst);
|
|
1851
|
+
const canvas = $('architecture');
|
|
1852
|
+
const size = canvas.getBoundingClientRect?.() || $('diagram-stage').getBoundingClientRect?.();
|
|
1853
|
+
const fitted = fitViewport(bounds, size);
|
|
1854
|
+
if (size?.width > 0 && size?.height > 0) canvasSize = `${size.width}:${size.height}`;
|
|
1855
|
+
state.fitBounds = bounds;
|
|
1856
|
+
state.viewport = fitted.viewport;
|
|
1857
|
+
state.zoom = fitted.zoom;
|
|
1858
|
+
state.followFit = true;
|
|
1859
|
+
setViewBox();
|
|
1860
|
+
}
|
|
1861
|
+
function fitGraph() {
|
|
1862
|
+
const graph = state.displayGraph;
|
|
1863
|
+
if (!graph) return;
|
|
1864
|
+
cancelMovement();
|
|
1865
|
+
fitCamera(graphBounds(graph));
|
|
1866
|
+
}
|
|
1867
|
+
function zoom(factor) {
|
|
1868
|
+
if (!state.viewport || !state.fitBounds) return;
|
|
1869
|
+
cancelMovement();
|
|
1870
|
+
finishPan();
|
|
1871
|
+
const next = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, state.zoom * factor));
|
|
1872
|
+
const scale = state.zoom / next;
|
|
1873
|
+
const old = state.viewport;
|
|
1874
|
+
state.viewport = {
|
|
1875
|
+
x: old.x + old.width * (1 - scale) / 2,
|
|
1876
|
+
y: old.y + old.height * (1 - scale) / 2,
|
|
1877
|
+
width: old.width * scale, height: old.height * scale,
|
|
1878
|
+
};
|
|
1879
|
+
state.zoom = next;
|
|
1880
|
+
state.followFit = false;
|
|
1881
|
+
setViewBox();
|
|
1882
|
+
}
|
|
1883
|
+
function renderGraph({ forceFit = false } = {}) {
|
|
1884
|
+
const canonical = currentGraph();
|
|
1885
|
+
if (!canonical) return;
|
|
1886
|
+
const view = presentation();
|
|
1887
|
+
applyTheme();
|
|
1888
|
+
const graph = projectPresentation(canonical, view);
|
|
1889
|
+
state.displayGraph = graph;
|
|
1890
|
+
const routes = graphEdgeRoutes(graph);
|
|
1891
|
+
const bounds = graphBounds(graph, routes);
|
|
1892
|
+
const signature = cameraGraphSignature(graph, view.algorithm);
|
|
1893
|
+
// Set the physical viewport before inserting newcomers or starting motion.
|
|
1894
|
+
// A manual camera survives status updates, but the next diagram change fits
|
|
1895
|
+
// the complete map, even with automatic arrangement disabled.
|
|
1896
|
+
if (forceFit || !state.viewport || signature !== state.lastGraphSignature) fitCamera(bounds);
|
|
1897
|
+
state.lastGraphSignature = signature;
|
|
1898
|
+
$('layout').value = view.algorithm;
|
|
1899
|
+
$('auto-arrange').checked = view.auto;
|
|
1900
|
+
$('layout-note').textContent = view.algorithm === 'original' ? 'Original positions from this revision.'
|
|
1901
|
+
: view.auto ? 'Arranges when components or connections change.' : 'Positions held. Arrange to move them.';
|
|
1902
|
+
const nodes = new Map(graph.nodes.map(node => [node.id, node]));
|
|
1903
|
+
const wantedEdges = new Set(graph.edges.map(edge => edge.id));
|
|
1904
|
+
for (const [id, group] of state.nodeElements) if (!nodes.has(id)) { group.remove(); state.nodeElements.delete(id); }
|
|
1905
|
+
for (const [id, group] of state.edgeElements) if (!wantedEdges.has(id)) {
|
|
1906
|
+
group.control.remove();
|
|
1907
|
+
group.remove();
|
|
1908
|
+
state.edgeElements.delete(id);
|
|
1909
|
+
}
|
|
1910
|
+
for (const edge of graph.edges) {
|
|
1911
|
+
const source = nodes.get(edge.source);
|
|
1912
|
+
const target = nodes.get(edge.target);
|
|
1913
|
+
const route = routes.get(edge.id);
|
|
1914
|
+
const summary = claimSummary(edge);
|
|
1915
|
+
let group = state.edgeElements.get(edge.id);
|
|
1916
|
+
if (!group) {
|
|
1917
|
+
group = svgElement('g', { class: 'diagram-edge' });
|
|
1918
|
+
group.titleElement = svgElement('title');
|
|
1919
|
+
group.hit = svgElement('path', { class: 'edge-hit', 'pointer-events': 'stroke' });
|
|
1920
|
+
group.line = inkPath('edge-line');
|
|
1921
|
+
group.secondaryLine = inkPath('edge-line-secondary');
|
|
1922
|
+
group.heads = [inkPath('edge-head'), inkPath('edge-head edge-head-secondary')];
|
|
1923
|
+
group.leader = svgElement('path', { class: 'edge-label-leader' });
|
|
1924
|
+
// The semantic button bounds contain only this small label, not the
|
|
1925
|
+
// whole curve. Its center is painted and clickable at every angle.
|
|
1926
|
+
group.control = interactiveGroup('edge', edge.id);
|
|
1927
|
+
group.background = svgElement('rect', { class: 'edge-label-background', rx: 4, 'pointer-events': 'all' });
|
|
1928
|
+
group.text = svgElement('text', { class: 'edge-label', x: 0, y: 4, 'text-anchor': 'middle' });
|
|
1929
|
+
group.control.append(group.background, group.text);
|
|
1930
|
+
group.append(group.titleElement, group.hit, group.line, group.secondaryLine, ...group.heads, group.leader);
|
|
1931
|
+
group.addEventListener('click', () => select('edge', edge.id));
|
|
1932
|
+
group.control.addEventListener('focus', () => group.classList.add('is-focused'));
|
|
1933
|
+
group.control.addEventListener('blur', () => group.classList.remove('is-focused'));
|
|
1934
|
+
state.edgeElements.set(edge.id, group);
|
|
1935
|
+
$('edge-layer').append(group);
|
|
1936
|
+
// Paint small label controls above every curve/leader, but below nodes.
|
|
1937
|
+
$('edge-label-layer').append(group.control);
|
|
1938
|
+
}
|
|
1939
|
+
group.dataset.tone = summary.tone;
|
|
1940
|
+
group.control.setAttribute('aria-label', `${source.label} ${readable(edge.relation)} ${target.label}. ${summary.label}. Inspect evidence.`);
|
|
1941
|
+
const edgeSignature = JSON.stringify([edge.label, source.label, target.label, summary.tone, route]);
|
|
1942
|
+
if (group.renderSignature !== edgeSignature) {
|
|
1943
|
+
group.titleElement.textContent = `${source.label} → ${target.label}: ${edge.label}`;
|
|
1944
|
+
paintEdgeGeometry(group, route, edge.id);
|
|
1945
|
+
group.text.textContent = clip(edge.label, 28);
|
|
1946
|
+
group.renderSignature = edgeSignature;
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
for (const node of graph.nodes) {
|
|
1950
|
+
let group = state.nodeElements.get(node.id);
|
|
1951
|
+
if (!group) {
|
|
1952
|
+
group = interactiveGroup('node', node.id);
|
|
1953
|
+
const center = svgElement('g', { transform: `translate(${NODE_WIDTH / 2} ${NODE_HEIGHT / 2})` });
|
|
1954
|
+
group.visual = svgElement('g', { class: 'node-visual' });
|
|
1955
|
+
group.content = svgElement('g', { transform: `translate(${-NODE_WIDTH / 2} ${-NODE_HEIGHT / 2})` });
|
|
1956
|
+
group.visual.append(group.content);
|
|
1957
|
+
center.append(group.visual);
|
|
1958
|
+
group.append(
|
|
1959
|
+
svgElement('title', {}, node.label),
|
|
1960
|
+
svgElement('rect', { class: 'node-hit', width: NODE_WIDTH, height: NODE_HEIGHT, 'pointer-events': 'all' }),
|
|
1961
|
+
center,
|
|
1962
|
+
svgElement('rect', { class: 'selection-ring', x: -7, y: -7, width: NODE_WIDTH + 14, height: NODE_HEIGHT + 14, rx: 14 }),
|
|
1963
|
+
);
|
|
1964
|
+
state.nodeElements.set(node.id, group);
|
|
1965
|
+
$('node-layer').append(group);
|
|
1966
|
+
}
|
|
1967
|
+
const summary = claimSummary(node);
|
|
1968
|
+
group.dataset.tone = summary.tone;
|
|
1969
|
+
group.setAttribute('transform', `translate(${node.x} ${node.y})`);
|
|
1970
|
+
group.dataset.shape = node.shape;
|
|
1971
|
+
group.dataset.kind = node.kind;
|
|
1972
|
+
group.setAttribute('aria-label', `${node.label}. ${upperFirst(node.kind)}. ${summary.label}. Activity ${node.activityState}. Inspect evidence.`);
|
|
1973
|
+
const nodeSignature = JSON.stringify([node.label, node.shape, node.kind, node.activityState, summary]);
|
|
1974
|
+
if (group.renderSignature === nodeSignature) continue;
|
|
1975
|
+
const titleLines = nodeTitleLines(node.label, node.shape);
|
|
1976
|
+
const titleWidth = nodeTitleWidth(node.shape);
|
|
1977
|
+
const titleViewport = svgElement('svg', {
|
|
1978
|
+
class: 'node-title-viewport', x: (NODE_WIDTH - titleWidth) / 2, y: 38,
|
|
1979
|
+
width: titleWidth, height: 34, viewBox: `0 0 ${titleWidth} 34`,
|
|
1980
|
+
overflow: 'hidden', 'pointer-events': 'none', 'aria-hidden': 'true',
|
|
1981
|
+
});
|
|
1982
|
+
const title = svgElement('text', { class: 'node-title', 'text-anchor': 'middle' });
|
|
1983
|
+
titleLines.forEach((line, index) => title.append(svgElement('tspan', {
|
|
1984
|
+
x: titleWidth / 2, y: titleLines.length === 1 ? 23 : 13 + index * 16,
|
|
1985
|
+
}, line)));
|
|
1986
|
+
titleViewport.append(title);
|
|
1987
|
+
const children = [
|
|
1988
|
+
...shape(node),
|
|
1989
|
+
sketch(node),
|
|
1990
|
+
svgElement('text', { class: 'node-role', x: NODE_WIDTH / 2, y: node.shape === 'cylinder' ? 20 : 36, 'text-anchor': 'middle' }, upperFirst(node.kind)),
|
|
1991
|
+
titleViewport,
|
|
1992
|
+
svgElement('text', { class: 'node-state', x: NODE_WIDTH / 2, y: titleLines.length === 1 ? 85 : 91, 'text-anchor': 'middle' }, clip(summary.label, 26)),
|
|
1993
|
+
];
|
|
1994
|
+
if (['pending', 'running', 'failed', 'interrupted'].includes(node.activityState)) {
|
|
1995
|
+
children.push(svgElement('circle', { class: 'node-activity', 'data-state': node.activityState, cx: NODE_WIDTH - 12, cy: 12, r: 4 }));
|
|
1996
|
+
}
|
|
1997
|
+
group.querySelector('title').textContent = node.label;
|
|
1998
|
+
group.content.replaceChildren(...children);
|
|
1999
|
+
group.renderSignature = nodeSignature;
|
|
2000
|
+
}
|
|
2001
|
+
updateSelection();
|
|
2002
|
+
$('revision').textContent = `Revision ${graph.revision}`;
|
|
2003
|
+
$('canvas-title').textContent = state.replayFrame ? 'Architecture replay' : 'Live architecture';
|
|
2004
|
+
$('diagram-title').textContent = `${state.replayFrame ? 'Historical' : 'Live'} architecture, revision ${graph.revision}`;
|
|
2005
|
+
$('diagram-desc').textContent = `${graph.nodes.length} components and ${graph.edges.length} relationships. Code interpretation does not establish runtime connectivity. Use Tab and Enter to inspect a component or relationship. With the diagram focused, use plus and minus to zoom, arrow keys to pan, and 0 to fit.`;
|
|
2006
|
+
$('graph-count').textContent = `${graph.nodes.length} components · ${graph.edges.length} relationships`;
|
|
2007
|
+
$('empty-canvas').hidden = graph.nodes.length > 0 || removalBounds().length > 0;
|
|
2008
|
+
const classifier = state.snapshot.paused ? 'paused' : state.snapshot.status.classifier;
|
|
2009
|
+
const emptyMessages = {
|
|
2010
|
+
metadata_only: ['Activity is live. The map is waiting.', 'This session captures metadata only. Enable source interpretation in the local service to build an evidence-backed architecture.'],
|
|
2011
|
+
missing_key: ['Ready for a classifier.', 'Captured activity appears below. Configure the classifier in the local service to interpret permitted source evidence.'],
|
|
2012
|
+
paused: ['Classification is paused.', 'Capture and evidence invalidation continue. Resume classification to interpret new evidence.'],
|
|
2013
|
+
unavailable: ['Waiting for classification.', 'The classifier is unavailable. Safe activity continues below; supported architecture will appear when classification recovers.'],
|
|
2014
|
+
timeout: ['Evidence needs another moment.', 'Classification exceeded its deadline. Activity still appears below, and no unsupported components are added.'],
|
|
2015
|
+
};
|
|
2016
|
+
const message = state.replayFrame
|
|
2017
|
+
? ['No components in this revision.', 'Move through the recent revisions or return to Live to follow the current map.']
|
|
2018
|
+
: emptyMessages[classifier] || ['Your architecture starts here.', 'Work in a connected agent session. Components appear when approved evidence supports them; activity can arrive first.'];
|
|
2019
|
+
$('empty-title').textContent = message[0];
|
|
2020
|
+
$('empty-description').textContent = message[1];
|
|
2021
|
+
}
|
|
2022
|
+
function paintEdgeGeometry(group, route, id) {
|
|
2023
|
+
const ink = sketchConnection(route.points, id);
|
|
2024
|
+
group.hit.setAttribute('d', route.d);
|
|
2025
|
+
[group.line, group.secondaryLine].forEach((path, index) => path.setAttribute('d', ink.lines[index] || ''));
|
|
2026
|
+
group.heads.forEach((path, index) => path.setAttribute('d', ink.heads[index] || ''));
|
|
2027
|
+
group.leader.setAttribute('d', route.leader || '');
|
|
2028
|
+
group.control.setAttribute('transform', `translate(${route.x} ${route.y}) rotate(${route.angle})`);
|
|
2029
|
+
// A near pair can cross the threshold for moving its label off the curve.
|
|
2030
|
+
// Keep the same compact semantic control throughout the interpolation.
|
|
2031
|
+
group.background.setAttribute('x', -route.labelWidth / 2);
|
|
2032
|
+
group.background.setAttribute('y', -route.labelHeight / 2);
|
|
2033
|
+
group.background.setAttribute('width', route.labelWidth);
|
|
2034
|
+
group.background.setAttribute('height', route.labelHeight);
|
|
2035
|
+
}
|
|
2036
|
+
function paintGeometry(graph) {
|
|
2037
|
+
for (const node of graph.nodes) {
|
|
2038
|
+
state.nodeElements.get(node.id)?.setAttribute('transform', `translate(${node.x} ${node.y})`);
|
|
2039
|
+
}
|
|
2040
|
+
for (const [id, route] of graphEdgeRoutes(graph)) {
|
|
2041
|
+
const group = state.edgeElements.get(id);
|
|
2042
|
+
if (!group) continue;
|
|
2043
|
+
paintEdgeGeometry(group, route, id);
|
|
2044
|
+
}
|
|
2045
|
+
}
|
|
2046
|
+
function moveLayout(before, after) {
|
|
2047
|
+
if (!before || !motionAllowed() || !window.requestAnimationFrame || after.nodes.length > 80 || after.edges.length > 160) return;
|
|
2048
|
+
const from = new Map(before.nodes.map(node => [node.id, node]));
|
|
2049
|
+
if (!after.nodes.some(node => from.has(node.id) && (from.get(node.id).x !== node.x || from.get(node.id).y !== node.y))) return;
|
|
2050
|
+
// Very distant original coordinates should settle immediately. Nearby
|
|
2051
|
+
// layouts keep both endpoints visible throughout their interpolation.
|
|
2052
|
+
const viewport = state.viewport;
|
|
2053
|
+
const visible = before.nodes.some(node => node.x < viewport.x + viewport.width &&
|
|
2054
|
+
node.x + NODE_WIDTH > viewport.x && node.y < viewport.y + viewport.height &&
|
|
2055
|
+
node.y + NODE_HEIGHT > viewport.y);
|
|
2056
|
+
if (!visible) return;
|
|
2057
|
+
const fitAfter = graphBounds(after);
|
|
2058
|
+
const fitDuring = combinedBounds(graphBounds(before), fitAfter);
|
|
2059
|
+
fitCamera(fitDuring);
|
|
2060
|
+
const movement = { start: null, frame: null, timer: null, fitAfter, fitDuring };
|
|
2061
|
+
state.movement = movement;
|
|
2062
|
+
const paint = progress => paintGeometry({ ...after, nodes: after.nodes.map(node => {
|
|
2063
|
+
const old = from.get(node.id) || node;
|
|
2064
|
+
return { ...node, x: old.x + (node.x - old.x) * progress, y: old.y + (node.y - old.y) * progress };
|
|
2065
|
+
}) });
|
|
2066
|
+
paint(0);
|
|
2067
|
+
const tick = now => {
|
|
2068
|
+
if (state.movement !== movement) return;
|
|
2069
|
+
if (!motionAllowed()) { cancelMovement(); return; }
|
|
2070
|
+
if (movement.start === null) movement.start = now;
|
|
2071
|
+
const progress = Math.max(0, Math.min(1, (now - movement.start) / 250));
|
|
2072
|
+
paint(1 - (1 - progress) ** 3);
|
|
2073
|
+
if (progress < 1) movement.frame = window.requestAnimationFrame(tick);
|
|
2074
|
+
else cancelMovement();
|
|
2075
|
+
};
|
|
2076
|
+
movement.frame = window.requestAnimationFrame(tick);
|
|
2077
|
+
movement.timer = setTimeout(() => { if (state.movement === movement) cancelMovement(); }, 350);
|
|
2078
|
+
}
|
|
2079
|
+
function arrange() {
|
|
2080
|
+
const graph = currentGraph();
|
|
2081
|
+
if (!graph) return;
|
|
2082
|
+
finishPan();
|
|
2083
|
+
cancelMovement();
|
|
2084
|
+
const before = state.displayGraph;
|
|
2085
|
+
projectPresentation(graph, presentation(), { arrange: true });
|
|
2086
|
+
renderGraph({ forceFit: true });
|
|
2087
|
+
moveLayout(before, state.displayGraph);
|
|
2088
|
+
announce(`Arranged using ${LAYOUT_NAMES[state.view.algorithm]}. Evidence and selection are unchanged.`);
|
|
2089
|
+
}
|
|
2090
|
+
function fact(list, label, value) {
|
|
2091
|
+
const row = html('div');
|
|
2092
|
+
row.append(html('dt', label), html('dd', value));
|
|
2093
|
+
list.append(row);
|
|
2094
|
+
}
|
|
2095
|
+
function renderInspector() {
|
|
2096
|
+
const body = $('inspector-body');
|
|
2097
|
+
const graph = currentGraph();
|
|
2098
|
+
const selected = state.selection;
|
|
2099
|
+
const claim = selected && graph ? (selected.type === 'node' ? graph.nodes : graph.edges).find(item => item.id === selected.id) : null;
|
|
2100
|
+
const linkedIds = new Set(claim?.sourceRefs.map(ref => ref.eventId) || []);
|
|
2101
|
+
const related = state.snapshot?.activity.filter(event => linkedIds.has(event.id)).slice(-5).reverse() || [];
|
|
2102
|
+
const override = selected?.type === 'node' ? state.view?.shapes.get(selected.id) : undefined;
|
|
2103
|
+
const signature = JSON.stringify([selected, claim, Boolean(state.replayFrame), state.replayFrame?.revision, related, override]);
|
|
2104
|
+
if (state.inspectorSignature === signature) return;
|
|
2105
|
+
state.inspectorSignature = signature;
|
|
2106
|
+
$('clear-selection').hidden = !selected;
|
|
2107
|
+
if (!selected || !graph) {
|
|
2108
|
+
const empty = html('div', undefined, 'inspector-empty');
|
|
2109
|
+
empty.append(html('span', '↗', 'inspector-glyph'), html('h3', 'Every connection has a reason.'), html('p', 'Select a component or an arrow to see its classification, confidence, and source references.'), html('p', 'Use Tab to reach diagram items, then Enter to inspect.', 'inspector-hint'));
|
|
2110
|
+
body.replaceChildren(empty);
|
|
2111
|
+
return;
|
|
2112
|
+
}
|
|
2113
|
+
if (!claim) {
|
|
2114
|
+
body.replaceChildren(html('h3', 'Selection left this revision.'), html('p', 'Its supporting evidence may have changed or been retracted. Use replay to inspect earlier revisions, or select another component.'));
|
|
2115
|
+
return;
|
|
2116
|
+
}
|
|
2117
|
+
const summary = claimSummary(claim);
|
|
2118
|
+
const type = html('div', selected.type === 'node' ? `${upperFirst(claim.kind)} component` : `${upperFirst(readable(claim.relation))} relationship`, 'claim-type');
|
|
2119
|
+
const badges = html('div', undefined, 'claim-badges');
|
|
2120
|
+
const badge = html('span', summary.label, 'badge');
|
|
2121
|
+
badge.dataset.tone = summary.tone;
|
|
2122
|
+
badges.append(badge);
|
|
2123
|
+
if (state.replayFrame) badges.append(html('span', `Replay · revision ${graph.revision}`, 'badge'));
|
|
2124
|
+
const facts = html('dl', undefined, 'evidence-facts');
|
|
2125
|
+
fact(facts, 'Classification', upperFirst(claim.classification));
|
|
2126
|
+
fact(facts, 'Validity', upperFirst(claim.validity));
|
|
2127
|
+
fact(facts, 'Evidence state', claim.evidenceState === 'verified' ? 'Verification reported; scope unavailable' : upperFirst(claim.evidenceState));
|
|
2128
|
+
fact(facts, 'Basis', claim.sourceRefs.length && claim.sourceRefs.every(ref => ref.basis === 'jev_interpretation') ? 'Jev code interpretation' : 'Provenance incomplete');
|
|
2129
|
+
fact(facts, 'Runtime', 'Not established by this snapshot');
|
|
2130
|
+
if (selected.type === 'node') fact(facts, 'Activity', upperFirst(claim.activityState));
|
|
2131
|
+
else {
|
|
2132
|
+
fact(facts, 'From', graph.nodes.find(node => node.id === claim.source)?.label || 'Unknown component');
|
|
2133
|
+
fact(facts, 'To', graph.nodes.find(node => node.id === claim.target)?.label || 'Unknown component');
|
|
2134
|
+
}
|
|
2135
|
+
body.replaceChildren(type, html('h3', claim.label), badges, html('p', summary.explanation), facts);
|
|
2136
|
+
if (selected.type === 'node') {
|
|
2137
|
+
const label = html('label', 'Shape · visual only', 'shape-picker');
|
|
2138
|
+
label.setAttribute('for', 'display-shape');
|
|
2139
|
+
const selectShape = html('select');
|
|
2140
|
+
selectShape.setAttribute('id', 'display-shape');
|
|
2141
|
+
selectShape.setAttribute('aria-describedby', 'shape-note');
|
|
2142
|
+
for (const [value, name] of [['automatic', 'Automatic'], ...Object.entries(SHAPE_NAMES)]) {
|
|
2143
|
+
const option = html('option', name);
|
|
2144
|
+
option.value = value;
|
|
2145
|
+
selectShape.append(option);
|
|
2146
|
+
}
|
|
2147
|
+
// Old records retain their valid shape. Explicit Automatic opts into the
|
|
2148
|
+
// current role mapping; no stored claim is rewritten.
|
|
2149
|
+
selectShape.value = override || (claim.shape === ROLE_SHAPES[claim.kind] ? 'automatic' : claim.shape);
|
|
2150
|
+
label.append(selectShape);
|
|
2151
|
+
const note = html('p', `Appearance only. Automatic uses ${SHAPE_NAMES[ROLE_SHAPES[claim.kind]].toLowerCase()} for ${claim.kind}.`, 'fine-print');
|
|
2152
|
+
note.setAttribute('id', 'shape-note');
|
|
2153
|
+
body.append(label, note);
|
|
2154
|
+
const scopeKey = state.viewKey;
|
|
2155
|
+
selectShape.addEventListener('change', () => {
|
|
2156
|
+
if (state.viewKey !== scopeKey || ![...SHAPES, 'automatic'].includes(selectShape.value)) return;
|
|
2157
|
+
finishPan();
|
|
2158
|
+
state.view.shapes.delete(claim.id);
|
|
2159
|
+
state.view.shapes.set(claim.id, selectShape.value);
|
|
2160
|
+
while (state.view.shapes.size > LIMITS.nodes) state.view.shapes.delete(state.view.shapes.keys().next().value);
|
|
2161
|
+
cancelMovement();
|
|
2162
|
+
finishEffect(claim.id);
|
|
2163
|
+
renderGraph();
|
|
2164
|
+
// Preserve the focused select and open evidence disclosures.
|
|
2165
|
+
state.inspectorSignature = JSON.stringify([selected, claim, Boolean(state.replayFrame), state.replayFrame?.revision, related, selectShape.value]);
|
|
2166
|
+
announce(`Display shape changed. ${claim.label} remains classified as ${claim.kind}.`);
|
|
2167
|
+
});
|
|
2168
|
+
}
|
|
2169
|
+
const confidence = normalizeConfidence(claim.confidence);
|
|
2170
|
+
body.append(html('h4', 'Classifier confidence'));
|
|
2171
|
+
const confidenceList = html('ul', undefined, 'confidence-list');
|
|
2172
|
+
const confidenceLabels = {
|
|
2173
|
+
supportProbability: 'Evidence support probability',
|
|
2174
|
+
roleProbability: 'Selected role probability',
|
|
2175
|
+
roleConfidence: 'Role distribution confidence',
|
|
2176
|
+
missingContextProbability: 'Missing-context probability',
|
|
2177
|
+
reportedConfidence: 'Reported classifier confidence',
|
|
2178
|
+
};
|
|
2179
|
+
for (const key of Object.keys(confidenceLabels)) {
|
|
2180
|
+
if (!probability(confidence[key])) continue;
|
|
2181
|
+
const item = html('li');
|
|
2182
|
+
item.append(html('span', confidenceLabels[key]), html('strong', `${(confidence[key] * 100).toFixed(1)}%`));
|
|
2183
|
+
confidenceList.append(item);
|
|
2184
|
+
}
|
|
2185
|
+
if (confidenceList.childElementCount) body.append(confidenceList);
|
|
2186
|
+
else body.append(html('p', 'Confidence was not supplied for this claim.', 'fine-print'));
|
|
2187
|
+
body.append(html('p', 'These values describe the classifier’s interpretation. They are not measured accuracy or the probability that a runtime connection succeeds.', 'fine-print'));
|
|
2188
|
+
if (record(confidence.roleProbabilities)) {
|
|
2189
|
+
const details = html('details');
|
|
2190
|
+
details.append(html('summary', 'Role probabilities'));
|
|
2191
|
+
const list = html('ul', undefined, 'confidence-list');
|
|
2192
|
+
for (const [role, value] of Object.entries(confidence.roleProbabilities)) {
|
|
2193
|
+
const row = html('li');
|
|
2194
|
+
row.append(html('span', upperFirst(role)), html('strong', `${(value * 100).toFixed(1)}%`));
|
|
2195
|
+
list.append(row);
|
|
2196
|
+
}
|
|
2197
|
+
details.append(list);
|
|
2198
|
+
body.append(details);
|
|
2199
|
+
}
|
|
2200
|
+
body.append(html('h4', `Source references (${claim.sourceRefs.length})`));
|
|
2201
|
+
if (!claim.sourceRefs.length) body.append(html('p', 'No source references were supplied. This claim’s provenance cannot be inspected.', 'fine-print'));
|
|
2202
|
+
const refs = html('ol', undefined, 'source-list');
|
|
2203
|
+
for (const ref of claim.sourceRefs) {
|
|
2204
|
+
const item = html('li', undefined, 'source-reference');
|
|
2205
|
+
item.append(
|
|
2206
|
+
html('strong', ref.sourceClass === 'public_intent' ? 'Public intent' : ref.sourceClass === 'source' ? 'Source artifact' : 'Unknown source class'),
|
|
2207
|
+
html('span', ref.basis === 'jev_interpretation' ? 'Basis: Jev interpretation' : 'Basis not supplied'),
|
|
2208
|
+
html('span', `${ref.sourceClass === 'public_intent' ? 'Message' : 'Artifact'}: ${ref.sourceRef?.messageId || ref.artifactId || 'not supplied'}`),
|
|
2209
|
+
html('span', `Version: ${ref.hash || 'not supplied'} · ${ref.sourceClass === 'public_intent' ? 'content version' : 'generation'} ${ref.sourceRef?.contentVersion ?? ref.generation}`),
|
|
2210
|
+
);
|
|
2211
|
+
if (ref.startLine > 0) item.append(html('span', ref.endLine >= ref.startLine ? `Lines ${ref.startLine}–${ref.endLine}` : `Line ${ref.startLine}`));
|
|
2212
|
+
if (ref.eventId) item.append(html('span', `Event: ${ref.eventId}`));
|
|
2213
|
+
if (ref.excerpt) {
|
|
2214
|
+
const details = html('details');
|
|
2215
|
+
details.append(html('summary', 'Approved excerpt'), html('pre', ref.excerpt));
|
|
2216
|
+
item.append(details);
|
|
2217
|
+
} else item.append(html('span', 'Excerpt unavailable or withheld by display policy.'));
|
|
2218
|
+
refs.append(item);
|
|
2219
|
+
}
|
|
2220
|
+
body.append(refs);
|
|
2221
|
+
if (related.length) {
|
|
2222
|
+
body.append(html('h4', 'Related captured activity'));
|
|
2223
|
+
for (const event of related) {
|
|
2224
|
+
const button = html('button', `${event.label} · ${upperFirst(event.state)}`, 'related-event');
|
|
2225
|
+
button.type = 'button';
|
|
2226
|
+
button.addEventListener('click', () => {
|
|
2227
|
+
const row = state.activityElements.get(event.id);
|
|
2228
|
+
if (row) {
|
|
2229
|
+
row.querySelector('button').focus();
|
|
2230
|
+
row.scrollIntoView({ block: 'nearest' });
|
|
2231
|
+
}
|
|
2232
|
+
});
|
|
2233
|
+
body.append(button);
|
|
2234
|
+
}
|
|
2235
|
+
body.append(html('p', 'A successful tool outcome does not verify this architecture.', 'fine-print'));
|
|
2236
|
+
}
|
|
2237
|
+
}
|
|
2238
|
+
function renderHistory() {
|
|
2239
|
+
const replay = Boolean(state.replayFrame);
|
|
2240
|
+
const frameIndex = replay ? state.frames.findIndex(frame => frame.revision === state.replayFrame.revision) : state.frames.length - 1;
|
|
2241
|
+
$('live').setAttribute('aria-pressed', String(!replay));
|
|
2242
|
+
$('replay').setAttribute('aria-pressed', String(replay));
|
|
2243
|
+
$('history').max = String(Math.max(0, state.frames.length - 1));
|
|
2244
|
+
$('history').value = String(Math.max(0, frameIndex));
|
|
2245
|
+
const revision = currentGraph()?.revision ?? 0;
|
|
2246
|
+
$('history').setAttribute('aria-valuetext', `Revision ${revision}${replay ? ', replay' : ', live'}`);
|
|
2247
|
+
$('history-position').textContent = state.snapshot ? `Rev. ${revision}` : 'No history';
|
|
2248
|
+
$('replay-note').textContent = replay
|
|
2249
|
+
? frameIndex < 0 ? 'Pinned revision aged out of recent history. Activity stays live.' : `Historical graph${state.replayFrame.at === null ? '' : ` at ${formatTime(state.replayFrame.at)}`}. Activity stays live.`
|
|
2250
|
+
: `${state.frames.length} recent ${state.frames.length === 1 ? 'revision' : 'revisions'}. Live follows the latest snapshot.`;
|
|
2251
|
+
$('activity-note').textContent = replay ? 'Live activity continues while you inspect a historical graph.' : 'Observable work, independent of classification.';
|
|
2252
|
+
}
|
|
2253
|
+
function renderActivity() {
|
|
2254
|
+
const events = [...state.snapshot.activity].sort((a, b) => b.sequence - a.sequence || (b.at || 0) - (a.at || 0));
|
|
2255
|
+
const ids = new Set(events.map(event => event.id));
|
|
2256
|
+
for (const [id, row] of state.activityElements) if (!ids.has(id)) { row.remove(); state.activityElements.delete(id); }
|
|
2257
|
+
const selectedGraph = currentGraph();
|
|
2258
|
+
const selected = state.selection;
|
|
2259
|
+
const selectedClaim = selected ? (selected.type === 'node' ? selectedGraph.nodes : selectedGraph.edges).find(item => item.id === selected.id) : null;
|
|
2260
|
+
const relatedIds = new Set(selectedClaim?.sourceRefs.map(ref => ref.eventId) || []);
|
|
2261
|
+
for (const [index, event] of events.entries()) {
|
|
2262
|
+
let row = state.activityElements.get(event.id);
|
|
2263
|
+
if (!row) {
|
|
2264
|
+
row = html('li', undefined, 'activity-item');
|
|
2265
|
+
state.activityElements.set(event.id, row);
|
|
2266
|
+
}
|
|
2267
|
+
row.classList.toggle('is-focused', relatedIds.has(event.id));
|
|
2268
|
+
const expectedPosition = $('activity-list').children[index];
|
|
2269
|
+
if (expectedPosition !== row) $('activity-list').insertBefore(row, expectedPosition || null);
|
|
2270
|
+
const eventSignature = JSON.stringify(event);
|
|
2271
|
+
if (row.renderSignature === eventSignature) continue;
|
|
2272
|
+
const button = html('button', undefined, 'activity-row');
|
|
2273
|
+
button.type = 'button';
|
|
2274
|
+
const time = html('time', event.at === null ? '—' : formatTime(event.at));
|
|
2275
|
+
if (event.at !== null) time.dateTime = new Date(event.at).toISOString();
|
|
2276
|
+
time.title = formatTime(event.at, true);
|
|
2277
|
+
const dot = html('span', undefined, 'event-dot');
|
|
2278
|
+
dot.dataset.state = event.state;
|
|
2279
|
+
dot.setAttribute('aria-hidden', 'true');
|
|
2280
|
+
button.append(time, dot, html('span', event.label, 'event-title'), html('span', event.incomplete ? 'Incomplete capture' : event.toolCategory ? readable(event.toolCategory) : 'Captured event', 'event-detail'), html('span', upperFirst(event.state), 'event-state'));
|
|
2281
|
+
button.setAttribute('aria-label', `${event.label}. ${event.state}${event.incomplete ? '. Incomplete capture' : ''}. Inspect related evidence.`);
|
|
2282
|
+
button.addEventListener('click', () => {
|
|
2283
|
+
const graph = currentGraph();
|
|
2284
|
+
const node = graph.nodes.find(item => item.sourceRefs.some(ref => ref.eventId === event.id));
|
|
2285
|
+
const edge = graph.edges.find(item => item.sourceRefs.some(ref => ref.eventId === event.id));
|
|
2286
|
+
if (node || edge) {
|
|
2287
|
+
state.selection = { type: node ? 'node' : 'edge', id: (node || edge).id };
|
|
2288
|
+
updateSelection();
|
|
2289
|
+
renderInspector();
|
|
2290
|
+
renderActivity();
|
|
2291
|
+
announce(`Related evidence for ${(node || edge).label} is shown in the inspector.`);
|
|
2292
|
+
} else toast(state.replayFrame ? 'No evidence link for this event in the displayed revision. Return to Live to inspect current links.' : 'This captured event has no architecture evidence link. Tool activity alone does not establish an architectural claim.');
|
|
2293
|
+
});
|
|
2294
|
+
const focused = row.contains(document.activeElement);
|
|
2295
|
+
row.replaceChildren(button);
|
|
2296
|
+
row.renderSignature = eventSignature;
|
|
2297
|
+
if (focused) button.focus({ preventScroll: true });
|
|
2298
|
+
}
|
|
2299
|
+
$('activity-count').textContent = `${events.length} recent ${events.length === 1 ? 'event' : 'events'}`;
|
|
2300
|
+
$('activity-empty').hidden = events.length > 0;
|
|
2301
|
+
$('activity-list').hidden = events.length === 0;
|
|
2302
|
+
}
|
|
2303
|
+
function render() {
|
|
2304
|
+
renderStatus();
|
|
2305
|
+
renderOnboarding();
|
|
2306
|
+
renderGraph();
|
|
2307
|
+
renderInspector();
|
|
2308
|
+
renderHistory();
|
|
2309
|
+
renderActivity();
|
|
2310
|
+
renderSidebar();
|
|
2311
|
+
updateControls();
|
|
2312
|
+
}
|
|
2313
|
+
function renderSidebar() {
|
|
2314
|
+
if (state.snapshot) sidebar.update(state.snapshot, {
|
|
2315
|
+
replay: Boolean(state.replayFrame), theme: token(state.view?.theme, THEMES, 'sketchbook'),
|
|
2316
|
+
});
|
|
2317
|
+
}
|
|
2318
|
+
function replayAt(index) {
|
|
2319
|
+
const frame = state.frames[index];
|
|
2320
|
+
if (!frame) return;
|
|
2321
|
+
resetMotionBaseline();
|
|
2322
|
+
state.replayFrame = frame;
|
|
2323
|
+
render();
|
|
2324
|
+
announce(`Showing architecture revision ${frame.revision}. Activity remains live.`);
|
|
2325
|
+
}
|
|
2326
|
+
async function refresh() {
|
|
2327
|
+
const epoch = state.epoch;
|
|
2328
|
+
const raw = await request('/api/state');
|
|
2329
|
+
if (epoch === state.epoch) acceptSnapshot(raw);
|
|
2330
|
+
}
|
|
2331
|
+
async function control(action, sessionId) {
|
|
2332
|
+
if (state.busy) return;
|
|
2333
|
+
state.busy = true;
|
|
2334
|
+
if (action === 'session') resetMotionBaseline();
|
|
2335
|
+
error();
|
|
2336
|
+
updateControls();
|
|
2337
|
+
try {
|
|
2338
|
+
await request('/api/control', { method: 'POST', body: JSON.stringify(sessionId ? { action, sessionId } : { action }) });
|
|
2339
|
+
await refresh();
|
|
2340
|
+
announce(action === 'pause' ? 'Classification paused. Capture and evidence invalidation continue.' : action === 'resume' ? 'Classification resumed.' : 'Session selected.');
|
|
2341
|
+
} catch (cause) {
|
|
2342
|
+
if (cause.message === 'auth_required') connection('auth');
|
|
2343
|
+
error('The control was not confirmed. Reconnect and check the current state before trying again.');
|
|
2344
|
+
if (state.snapshot) $('session').value = state.snapshot.sessionId;
|
|
2345
|
+
} finally { state.busy = false; updateControls(); }
|
|
2346
|
+
}
|
|
2347
|
+
async function connect() {
|
|
2348
|
+
resetMotionBaseline();
|
|
2349
|
+
const attempt = ++state.connectEpoch;
|
|
2350
|
+
projectController?.abort();
|
|
2351
|
+
projectController = null;
|
|
2352
|
+
state.stream?.close();
|
|
2353
|
+
state.stream = null;
|
|
2354
|
+
connection('connecting');
|
|
2355
|
+
error();
|
|
2356
|
+
try {
|
|
2357
|
+
await exchangeLaunchToken({ location: window.location, history: window.history, request });
|
|
2358
|
+
const raw = await request('/api/state');
|
|
2359
|
+
if (attempt !== state.connectEpoch) return;
|
|
2360
|
+
acceptSnapshot(raw);
|
|
2361
|
+
const stream = new EventSource('/api/events', { withCredentials: true });
|
|
2362
|
+
state.stream = stream;
|
|
2363
|
+
stream.addEventListener('open', () => { if (state.stream === stream) connection('connected'); });
|
|
2364
|
+
stream.addEventListener('snapshot', event => {
|
|
2365
|
+
if (state.stream !== stream) return;
|
|
2366
|
+
try {
|
|
2367
|
+
if (event.data.length > MAX_JSON_BYTES) throw new Error('response_too_large');
|
|
2368
|
+
acceptSnapshot(JSON.parse(event.data), true);
|
|
2369
|
+
connection('connected');
|
|
2370
|
+
error();
|
|
2371
|
+
} catch {
|
|
2372
|
+
resetMotionBaseline();
|
|
2373
|
+
error('An invalid snapshot was ignored. The last accepted view is retained; a complete snapshot will restore the live view.');
|
|
2374
|
+
}
|
|
2375
|
+
});
|
|
2376
|
+
stream.addEventListener('error', () => {
|
|
2377
|
+
if (state.stream !== stream) return;
|
|
2378
|
+
resetMotionBaseline();
|
|
2379
|
+
connection('reconnecting');
|
|
2380
|
+
error('The live connection was lost. Displaying the last received snapshot while the viewer reconnects.');
|
|
2381
|
+
});
|
|
2382
|
+
// Optional authenticated metadata must not hold up the event stream or
|
|
2383
|
+
// turn an older server's missing endpoint into a connection failure.
|
|
2384
|
+
const controller = new AbortController();
|
|
2385
|
+
projectController = controller;
|
|
2386
|
+
try {
|
|
2387
|
+
const info = normalizeConnectionInfo(await request('/api/connection-info', { signal: controller.signal }));
|
|
2388
|
+
if (!state.closed && attempt === state.connectEpoch) {
|
|
2389
|
+
state.projectName = friendlyProjectName(info.projectRoot);
|
|
2390
|
+
renderStatus();
|
|
2391
|
+
}
|
|
2392
|
+
} catch { /* The project ID remains a usable fallback. */ }
|
|
2393
|
+
finally { if (projectController === controller) projectController = null; }
|
|
2394
|
+
} catch (cause) {
|
|
2395
|
+
if (attempt !== state.connectEpoch) return;
|
|
2396
|
+
const auth = cause.message === 'auth_required' || cause.message === 'invalid_launch';
|
|
2397
|
+
connection(auth ? 'auth' : 'error');
|
|
2398
|
+
error(auth
|
|
2399
|
+
? 'This launch link is invalid, expired, or already used. Open a fresh viewer link from the local Graphlin service.'
|
|
2400
|
+
: 'The local service could not provide a snapshot. Check that Graphlin is running, then reconnect.');
|
|
2401
|
+
}
|
|
2402
|
+
}
|
|
2403
|
+
async function exportJSON() {
|
|
2404
|
+
if (state.exporting) return;
|
|
2405
|
+
state.exporting = true;
|
|
2406
|
+
updateControls();
|
|
2407
|
+
try {
|
|
2408
|
+
const exported = sanitizedExport(await request('/api/export'));
|
|
2409
|
+
const blob = new Blob([JSON.stringify(exported, null, 2) + '\n'], { type: 'application/json' });
|
|
2410
|
+
const url = URL.createObjectURL(blob);
|
|
2411
|
+
const link = html('a');
|
|
2412
|
+
link.href = url;
|
|
2413
|
+
link.download = 'graphlin-current-snapshot.json';
|
|
2414
|
+
document.body.append(link);
|
|
2415
|
+
link.click();
|
|
2416
|
+
link.remove();
|
|
2417
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
2418
|
+
toast('Exported the current server snapshot. Source excerpts are omitted, including from history.');
|
|
2419
|
+
} catch {
|
|
2420
|
+
error('The snapshot could not be exported. Check the connection and try again.');
|
|
2421
|
+
} finally { state.exporting = false; updateControls(); }
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
$('retry').addEventListener('click', connect);
|
|
2425
|
+
const onOnboardingAction = () => {
|
|
2426
|
+
if (state.closed) return;
|
|
2427
|
+
const action = $('onboarding-action').dataset.action;
|
|
2428
|
+
if (action === 'reconnect') return connect();
|
|
2429
|
+
if (action === 'resume') return control('resume');
|
|
2430
|
+
if (action === 'diagnostics') return diagnosticsDialog.open();
|
|
2431
|
+
return connectionDialog.open();
|
|
2432
|
+
};
|
|
2433
|
+
const onCopyOrientation = async () => {
|
|
2434
|
+
try {
|
|
2435
|
+
if (!window.navigator?.clipboard?.writeText) throw new Error('clipboard_unavailable');
|
|
2436
|
+
await window.navigator.clipboard.writeText(ORIENTATION_PROMPT);
|
|
2437
|
+
if (!state.closed) $('orientation-copy-status').textContent = 'Copied. Paste this prompt into your connected agent.';
|
|
2438
|
+
} catch {
|
|
2439
|
+
if (state.closed) return;
|
|
2440
|
+
$('orientation-copy-status').textContent = 'Select the prompt text and copy it manually.';
|
|
2441
|
+
$('orientation-prompt').focus();
|
|
2442
|
+
}
|
|
2443
|
+
};
|
|
2444
|
+
const onToggleActivity = () => {
|
|
2445
|
+
const hidden = !$('activity-content').hidden;
|
|
2446
|
+
$('activity-content').hidden = hidden;
|
|
2447
|
+
$('activity-toggle').textContent = hidden ? 'Show activity log' : 'Hide activity log';
|
|
2448
|
+
$('activity-toggle').setAttribute('aria-expanded', String(!hidden));
|
|
2449
|
+
};
|
|
2450
|
+
$('onboarding-action').addEventListener('click', onOnboardingAction);
|
|
2451
|
+
$('orientation-copy').addEventListener('click', onCopyOrientation);
|
|
2452
|
+
$('activity-toggle').addEventListener('click', onToggleActivity);
|
|
2453
|
+
$('pause').addEventListener('click', () => control(state.snapshot?.paused ? 'resume' : 'pause'));
|
|
2454
|
+
$('session').addEventListener('change', () => control('session', $('session').value));
|
|
2455
|
+
$('export').addEventListener('click', exportJSON);
|
|
2456
|
+
$('live').addEventListener('click', () => {
|
|
2457
|
+
resetMotionBaseline();
|
|
2458
|
+
state.replayFrame = null;
|
|
2459
|
+
if (state.snapshot) render();
|
|
2460
|
+
announce('Following the live architecture.');
|
|
2461
|
+
});
|
|
2462
|
+
$('replay').addEventListener('click', () => replayAt(Math.max(0, state.frames.length - 2)));
|
|
2463
|
+
$('history').addEventListener('input', () => replayAt(Number($('history').value)));
|
|
2464
|
+
$('clear-selection').addEventListener('click', () => {
|
|
2465
|
+
state.selection = null;
|
|
2466
|
+
updateSelection();
|
|
2467
|
+
renderInspector();
|
|
2468
|
+
renderActivity();
|
|
2469
|
+
});
|
|
2470
|
+
$('fit').addEventListener('click', fitGraph);
|
|
2471
|
+
$('arrange').addEventListener('click', arrange);
|
|
2472
|
+
$('layout').addEventListener('change', () => {
|
|
2473
|
+
if (!state.view || !LAYOUT_ALGORITHMS.includes($('layout').value)) return;
|
|
2474
|
+
finishPan();
|
|
2475
|
+
state.view.algorithm = $('layout').value;
|
|
2476
|
+
arrange();
|
|
2477
|
+
});
|
|
2478
|
+
$('theme').addEventListener('change', () => {
|
|
2479
|
+
if (!state.view || !THEMES.includes($('theme').value)) { applyTheme(); return; }
|
|
2480
|
+
state.view.theme = $('theme').value;
|
|
2481
|
+
// Recolor through inherited CSS only. Selection, evidence, motion timers,
|
|
2482
|
+
// layout interpolation, camera and graph DOM remain undisturbed.
|
|
2483
|
+
applyTheme();
|
|
2484
|
+
renderSidebar();
|
|
2485
|
+
announce(`${THEME_NAMES[state.view.theme]} theme applied.`);
|
|
2486
|
+
});
|
|
2487
|
+
$('auto-arrange').addEventListener('change', () => {
|
|
2488
|
+
if (!state.view) return;
|
|
2489
|
+
finishPan();
|
|
2490
|
+
state.view.auto = $('auto-arrange').checked;
|
|
2491
|
+
if (state.view.auto) arrange();
|
|
2492
|
+
else renderGraph();
|
|
2493
|
+
});
|
|
2494
|
+
$('zoom-in').addEventListener('click', () => zoom(1.25));
|
|
2495
|
+
$('zoom-out').addEventListener('click', () => zoom(.8));
|
|
2496
|
+
$('architecture').addEventListener('keydown', event => {
|
|
2497
|
+
if (event.target !== $('architecture')) return;
|
|
2498
|
+
if (event.key === '+' || event.key === '=') { event.preventDefault(); zoom(1.25); }
|
|
2499
|
+
else if (event.key === '-') { event.preventDefault(); zoom(.8); }
|
|
2500
|
+
else if (event.key === '0') { event.preventDefault(); fitGraph(); }
|
|
2501
|
+
else if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key) && state.viewport) {
|
|
2502
|
+
event.preventDefault();
|
|
2503
|
+
cancelMovement();
|
|
2504
|
+
finishPan();
|
|
2505
|
+
const move = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[event.key];
|
|
2506
|
+
state.viewport.x += move[0] * state.viewport.width * .1;
|
|
2507
|
+
state.viewport.y += move[1] * state.viewport.height * .1;
|
|
2508
|
+
state.followFit = false;
|
|
2509
|
+
setViewBox();
|
|
2510
|
+
}
|
|
2511
|
+
});
|
|
2512
|
+
$('architecture').addEventListener('pointerdown', event => {
|
|
2513
|
+
if (state.closed || !state.viewport || event.button !== 0 || !currentGraph()?.nodes.length ||
|
|
2514
|
+
event.target.closest('[role="button"]') || event.target.closest('.diagram-edge')) return;
|
|
2515
|
+
cancelMovement();
|
|
2516
|
+
finishPan();
|
|
2517
|
+
pointer = { x: event.clientX, y: event.clientY, view: { ...state.viewport }, id: event.pointerId };
|
|
2518
|
+
$('architecture').setPointerCapture(event.pointerId);
|
|
2519
|
+
$('architecture').classList.add('is-panning');
|
|
2520
|
+
});
|
|
2521
|
+
$('architecture').addEventListener('pointermove', event => {
|
|
2522
|
+
if (!pointer || event.pointerId !== pointer.id) return;
|
|
2523
|
+
const rect = $('architecture').getBoundingClientRect();
|
|
2524
|
+
const scale = Math.max(pointer.view.width / Math.max(1, rect.width), pointer.view.height / Math.max(1, rect.height));
|
|
2525
|
+
state.viewport.x = pointer.view.x - (event.clientX - pointer.x) * scale;
|
|
2526
|
+
state.viewport.y = pointer.view.y - (event.clientY - pointer.y) * scale;
|
|
2527
|
+
state.followFit = false;
|
|
2528
|
+
setViewBox();
|
|
2529
|
+
});
|
|
2530
|
+
function finishPan() {
|
|
2531
|
+
const active = pointer;
|
|
2532
|
+
pointer = null;
|
|
2533
|
+
const canvas = $('architecture');
|
|
2534
|
+
canvas.classList.remove('is-panning');
|
|
2535
|
+
if (active && canvas.hasPointerCapture?.(active.id)) canvas.releasePointerCapture(active.id);
|
|
2536
|
+
}
|
|
2537
|
+
$('architecture').addEventListener('pointerup', finishPan);
|
|
2538
|
+
$('architecture').addEventListener('pointercancel', finishPan);
|
|
2539
|
+
$('architecture').addEventListener('lostpointercapture', finishPan);
|
|
2540
|
+
const onPageHide = () => { connectionDialog.close(); diagnosticsDialog.close(); resetMotionBaseline(); state.stream?.close(); state.stream = null; };
|
|
2541
|
+
const onPageShow = event => { if (!state.closed && event.persisted) connect(); };
|
|
2542
|
+
const onOnline = () => { if (!state.closed && state.connection !== 'connected') connect(); };
|
|
2543
|
+
const onVisibility = () => resetMotionBaseline();
|
|
2544
|
+
const onMotionPreference = () => { if (motionPreference.matches) resetMotionBaseline(); };
|
|
2545
|
+
const onResize = () => {
|
|
2546
|
+
if (state.closed || !state.displayGraph) return;
|
|
2547
|
+
const rect = $('architecture').getBoundingClientRect?.();
|
|
2548
|
+
if (!rect || rect.width <= 0 || rect.height <= 0) return;
|
|
2549
|
+
const nextSize = `${rect.width}:${rect.height}`;
|
|
2550
|
+
if (nextSize === canvasSize) return;
|
|
2551
|
+
canvasSize = nextSize;
|
|
2552
|
+
fitGraph();
|
|
2553
|
+
};
|
|
2554
|
+
const resizeObserver = window.ResizeObserver ? new window.ResizeObserver(onResize) : null;
|
|
2555
|
+
resizeObserver?.observe($('diagram-stage'));
|
|
2556
|
+
window.addEventListener('pagehide', onPageHide);
|
|
2557
|
+
window.addEventListener('pageshow', onPageShow);
|
|
2558
|
+
window.addEventListener('online', onOnline);
|
|
2559
|
+
window.addEventListener('resize', onResize);
|
|
2560
|
+
document.addEventListener?.('visibilitychange', onVisibility);
|
|
2561
|
+
motionPreference?.addEventListener?.('change', onMotionPreference);
|
|
2562
|
+
for (const option of $('layout').children) option.disabled = !LAYOUT_ALGORITHMS.includes(option.value);
|
|
2563
|
+
renderShapeKey();
|
|
2564
|
+
applyTheme();
|
|
2565
|
+
return {
|
|
2566
|
+
ready: connect(),
|
|
2567
|
+
close() {
|
|
2568
|
+
state.closed = true;
|
|
2569
|
+
projectController?.abort();
|
|
2570
|
+
projectController = null;
|
|
2571
|
+
$('onboarding-action').removeEventListener('click', onOnboardingAction);
|
|
2572
|
+
$('orientation-copy').removeEventListener('click', onCopyOrientation);
|
|
2573
|
+
$('activity-toggle').removeEventListener('click', onToggleActivity);
|
|
2574
|
+
connectionDialog.dispose();
|
|
2575
|
+
diagnosticsDialog.dispose();
|
|
2576
|
+
sidebar.destroy();
|
|
2577
|
+
resizeObserver?.disconnect();
|
|
2578
|
+
resetMotionBaseline();
|
|
2579
|
+
sketches.clear();
|
|
2580
|
+
detailSketches.clear();
|
|
2581
|
+
state.connectEpoch += 1;
|
|
2582
|
+
state.stream?.close();
|
|
2583
|
+
state.stream = null;
|
|
2584
|
+
clearTimeout(toastTimer);
|
|
2585
|
+
clearTimeout(announcementTimer);
|
|
2586
|
+
document.removeEventListener?.('visibilitychange', onVisibility);
|
|
2587
|
+
motionPreference?.removeEventListener?.('change', onMotionPreference);
|
|
2588
|
+
window.removeEventListener?.('pagehide', onPageHide);
|
|
2589
|
+
window.removeEventListener?.('pageshow', onPageShow);
|
|
2590
|
+
window.removeEventListener?.('online', onOnline);
|
|
2591
|
+
window.removeEventListener?.('resize', onResize);
|
|
2592
|
+
},
|
|
2593
|
+
};
|
|
2594
|
+
}
|
|
2595
|
+
|
|
2596
|
+
if (typeof document !== 'undefined' && document.getElementById('architecture')) startViewer();
|