laminark 2.21.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/.claude-plugin/marketplace.json +15 -0
  2. package/README.md +182 -0
  3. package/package.json +63 -0
  4. package/plugin/.claude-plugin/plugin.json +13 -0
  5. package/plugin/.mcp.json +12 -0
  6. package/plugin/dist/analysis/worker.d.ts +1 -0
  7. package/plugin/dist/analysis/worker.js +233 -0
  8. package/plugin/dist/analysis/worker.js.map +1 -0
  9. package/plugin/dist/config-t8LZeB-u.mjs +90 -0
  10. package/plugin/dist/config-t8LZeB-u.mjs.map +1 -0
  11. package/plugin/dist/hooks/handler.d.ts +284 -0
  12. package/plugin/dist/hooks/handler.d.ts.map +1 -0
  13. package/plugin/dist/hooks/handler.js +2125 -0
  14. package/plugin/dist/hooks/handler.js.map +1 -0
  15. package/plugin/dist/index.d.ts +445 -0
  16. package/plugin/dist/index.d.ts.map +1 -0
  17. package/plugin/dist/index.js +5831 -0
  18. package/plugin/dist/index.js.map +1 -0
  19. package/plugin/dist/observations-Ch0nc47i.d.mts +170 -0
  20. package/plugin/dist/observations-Ch0nc47i.d.mts.map +1 -0
  21. package/plugin/dist/tool-registry-CZ3mJ4iR.mjs +2655 -0
  22. package/plugin/dist/tool-registry-CZ3mJ4iR.mjs.map +1 -0
  23. package/plugin/hooks/hooks.json +78 -0
  24. package/plugin/scripts/README.md +47 -0
  25. package/plugin/scripts/bump-version.sh +44 -0
  26. package/plugin/scripts/ensure-deps.sh +12 -0
  27. package/plugin/scripts/install.sh +63 -0
  28. package/plugin/scripts/local-install.sh +103 -0
  29. package/plugin/scripts/setup-tmpdir.sh +65 -0
  30. package/plugin/scripts/uninstall.sh +95 -0
  31. package/plugin/scripts/update.sh +88 -0
  32. package/plugin/scripts/verify-install.sh +43 -0
  33. package/plugin/ui/activity.js +185 -0
  34. package/plugin/ui/app.js +1642 -0
  35. package/plugin/ui/graph.js +2333 -0
  36. package/plugin/ui/help.js +228 -0
  37. package/plugin/ui/index.html +492 -0
  38. package/plugin/ui/settings.js +650 -0
  39. package/plugin/ui/styles.css +2910 -0
  40. package/plugin/ui/timeline.js +652 -0
@@ -0,0 +1,1642 @@
1
+ /**
2
+ * Laminark client-side application.
3
+ *
4
+ * Handles tab navigation, SSE connection with auto-reconnect,
5
+ * REST API data fetching, and initial state loading.
6
+ */
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Global state
10
+ // ---------------------------------------------------------------------------
11
+
12
+ window.laminarkState = {
13
+ graph: null,
14
+ timeline: null,
15
+ graphInitialized: false,
16
+ timelineInitialized: false,
17
+ currentProject: null,
18
+ };
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // REST API helpers
22
+ // ---------------------------------------------------------------------------
23
+
24
+ /**
25
+ * Fetches graph data (nodes + edges) from the REST API.
26
+ * @param {Object} [filters] - Optional filters
27
+ * @param {string} [filters.type] - Comma-separated entity types
28
+ * @param {string} [filters.since] - ISO8601 timestamp
29
+ * @returns {Promise<{nodes: Array, edges: Array}>}
30
+ */
31
+ async function fetchGraphData(filters) {
32
+ const params = new URLSearchParams();
33
+ if (filters?.type) params.set('type', filters.type);
34
+ if (filters?.since) params.set('since', filters.since);
35
+ if (filters?.until) params.set('until', filters.until);
36
+ if (window.laminarkState.currentProject) params.set('project', window.laminarkState.currentProject);
37
+
38
+ const url = '/api/graph' + (params.toString() ? '?' + params.toString() : '');
39
+
40
+ try {
41
+ const res = await fetch(url);
42
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
43
+ return await res.json();
44
+ } catch (err) {
45
+ console.error('[laminark] Failed to fetch graph data:', err);
46
+ return { nodes: [], edges: [] };
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Fetches timeline data (sessions, observations, topic shifts).
52
+ * @param {Object} [range] - Optional time range
53
+ * @param {string} [range.from] - ISO8601 start
54
+ * @param {string} [range.to] - ISO8601 end
55
+ * @param {number} [range.limit] - Max observations
56
+ * @returns {Promise<{sessions: Array, observations: Array, topicShifts: Array}>}
57
+ */
58
+ async function fetchTimelineData(range) {
59
+ const params = new URLSearchParams();
60
+ if (range?.from) params.set('from', range.from);
61
+ if (range?.to) params.set('to', range.to);
62
+ if (range?.limit) params.set('limit', String(range.limit));
63
+ if (window.laminarkState.currentProject) params.set('project', window.laminarkState.currentProject);
64
+
65
+ const url = '/api/timeline' + (params.toString() ? '?' + params.toString() : '');
66
+
67
+ try {
68
+ const res = await fetch(url);
69
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
70
+ return await res.json();
71
+ } catch (err) {
72
+ console.error('[laminark] Failed to fetch timeline data:', err);
73
+ return { sessions: [], observations: [], topicShifts: [] };
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Fetches detailed info for a single graph node.
79
+ * @param {string} id - Node ID
80
+ * @returns {Promise<{entity: Object, observations: Array, relationships: Array}|null>}
81
+ */
82
+ async function fetchNodeDetails(id) {
83
+ try {
84
+ const res = await fetch(`/api/node/${encodeURIComponent(id)}`);
85
+ if (!res.ok) return null;
86
+ return await res.json();
87
+ } catch (err) {
88
+ console.error('[laminark] Failed to fetch node details:', err);
89
+ return null;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Fetches available projects from the REST API.
95
+ * @returns {Promise<{projects: Array, defaultProject: string|null}>}
96
+ */
97
+ async function fetchProjects() {
98
+ try {
99
+ const res = await fetch('/api/projects');
100
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
101
+ return await res.json();
102
+ } catch (err) {
103
+ console.error('[laminark] Failed to fetch projects:', err);
104
+ return { projects: [], defaultProject: null };
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Fetches recent debug paths from the REST API.
110
+ * @returns {Promise<{paths: Array}>}
111
+ */
112
+ async function fetchPaths() {
113
+ try {
114
+ const params = new URLSearchParams();
115
+ if (window.laminarkState.currentProject) params.set('project', window.laminarkState.currentProject);
116
+ const res = await fetch('/api/paths' + (params.toString() ? '?' + params.toString() : ''));
117
+ if (!res.ok) throw new Error('HTTP ' + res.status);
118
+ return await res.json();
119
+ } catch (err) {
120
+ console.error('[laminark] Failed to fetch paths:', err);
121
+ return { paths: [] };
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Fetches detail for a single debug path including waypoints.
127
+ * @param {string} pathId - Path ID
128
+ * @returns {Promise<{path: Object, waypoints: Array}|null>}
129
+ */
130
+ async function fetchPathDetail(pathId) {
131
+ try {
132
+ const res = await fetch('/api/paths/' + encodeURIComponent(pathId));
133
+ if (!res.ok) return null;
134
+ return await res.json();
135
+ } catch (err) {
136
+ console.error('[laminark] Failed to fetch path detail:', err);
137
+ return null;
138
+ }
139
+ }
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // SSE connection with auto-reconnect and heartbeat watchdog
143
+ // ---------------------------------------------------------------------------
144
+
145
+ let eventSource = null;
146
+ let reconnectDelay = 3000;
147
+ const MAX_RECONNECT_DELAY = 30000;
148
+ const HEARTBEAT_TIMEOUT_MS = 60000; // 60s with no heartbeat triggers reconnect
149
+ let lastEventTime = 0;
150
+ let heartbeatWatchdog = null;
151
+
152
+ function updateSSEStatus(status) {
153
+ var indicator = document.getElementById('sse-status');
154
+ if (indicator) {
155
+ indicator.className = 'status-indicator ' + status;
156
+ updateSSETooltip(indicator, status);
157
+ }
158
+ }
159
+
160
+ function updateSSETooltip(indicator, status) {
161
+ var base = 'SSE: ' + status;
162
+ if (lastEventTime > 0) {
163
+ var ago = Math.round((Date.now() - lastEventTime) / 1000);
164
+ var agoText = ago < 60 ? ago + 's ago' : Math.round(ago / 60) + 'min ago';
165
+ indicator.title = base + ' (last event: ' + agoText + ')';
166
+ } else {
167
+ indicator.title = base;
168
+ }
169
+ }
170
+
171
+ function recordEventReceived() {
172
+ lastEventTime = Date.now();
173
+ resetHeartbeatWatchdog();
174
+ }
175
+
176
+ function resetHeartbeatWatchdog() {
177
+ if (heartbeatWatchdog) clearTimeout(heartbeatWatchdog);
178
+ heartbeatWatchdog = setTimeout(function () {
179
+ console.warn('[laminark] No heartbeat for ' + (HEARTBEAT_TIMEOUT_MS / 1000) + 's, forcing SSE reconnect');
180
+ if (eventSource) {
181
+ eventSource.close();
182
+ eventSource = null;
183
+ }
184
+ updateSSEStatus('disconnected');
185
+ reconnectWithCatchup();
186
+ }, HEARTBEAT_TIMEOUT_MS);
187
+ }
188
+
189
+ /**
190
+ * Reconnect SSE and fetch fresh data from REST API to catch up on missed events.
191
+ */
192
+ function reconnectWithCatchup() {
193
+ setTimeout(function () {
194
+ console.log('[laminark] Reconnecting SSE with data catch-up');
195
+ connectSSE();
196
+
197
+ // Belt-and-suspenders: fetch fresh data from REST API to catch up
198
+ if (window.laminarkGraph && window.laminarkState.graphInitialized) {
199
+ var filters = getActiveFilters();
200
+ window.laminarkGraph.loadGraphData(filters ? { type: filters.join(',') } : undefined);
201
+ }
202
+ if (window.laminarkTimeline && window.laminarkState.timelineInitialized) {
203
+ window.laminarkTimeline.loadTimelineData();
204
+ }
205
+
206
+ reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
207
+ }, reconnectDelay);
208
+ }
209
+
210
+ function connectSSE() {
211
+ updateSSEStatus('connecting');
212
+
213
+ eventSource = new EventSource('/api/sse');
214
+
215
+ eventSource.addEventListener('connected', function (e) {
216
+ console.log('[laminark] SSE connected:', JSON.parse(e.data));
217
+ updateSSEStatus('connected');
218
+ reconnectDelay = 3000; // Reset backoff on successful connection
219
+ recordEventReceived();
220
+ });
221
+
222
+ eventSource.addEventListener('heartbeat', function (_e) {
223
+ // Heartbeat received -- connection is alive
224
+ updateSSEStatus('connected');
225
+ recordEventReceived();
226
+ });
227
+
228
+ eventSource.addEventListener('new_observation', function (e) {
229
+ var data = JSON.parse(e.data);
230
+ recordEventReceived();
231
+ document.dispatchEvent(new CustomEvent('laminark:new_observation', { detail: data }));
232
+ });
233
+
234
+ eventSource.addEventListener('entity_updated', function (e) {
235
+ var data = JSON.parse(e.data);
236
+ recordEventReceived();
237
+ document.dispatchEvent(new CustomEvent('laminark:entity_updated', { detail: data }));
238
+ });
239
+
240
+ eventSource.addEventListener('topic_shift', function (e) {
241
+ var data = JSON.parse(e.data);
242
+ recordEventReceived();
243
+ document.dispatchEvent(new CustomEvent('laminark:topic_shift', { detail: data }));
244
+ });
245
+
246
+ eventSource.addEventListener('session_start', function (e) {
247
+ var data = JSON.parse(e.data);
248
+ recordEventReceived();
249
+ document.dispatchEvent(new CustomEvent('laminark:session_start', { detail: data }));
250
+ });
251
+
252
+ eventSource.addEventListener('session_end', function (e) {
253
+ var data = JSON.parse(e.data);
254
+ recordEventReceived();
255
+ document.dispatchEvent(new CustomEvent('laminark:session_end', { detail: data }));
256
+ });
257
+
258
+ eventSource.addEventListener('path_started', function (e) {
259
+ var data = JSON.parse(e.data);
260
+ recordEventReceived();
261
+ document.dispatchEvent(new CustomEvent('laminark:path_started', { detail: data }));
262
+ });
263
+
264
+ eventSource.addEventListener('path_waypoint', function (e) {
265
+ var data = JSON.parse(e.data);
266
+ recordEventReceived();
267
+ document.dispatchEvent(new CustomEvent('laminark:path_waypoint', { detail: data }));
268
+ });
269
+
270
+ eventSource.addEventListener('path_resolved', function (e) {
271
+ var data = JSON.parse(e.data);
272
+ recordEventReceived();
273
+ document.dispatchEvent(new CustomEvent('laminark:path_resolved', { detail: data }));
274
+ });
275
+
276
+ eventSource.onerror = function () {
277
+ console.warn('[laminark] SSE connection error, reconnecting in', reconnectDelay, 'ms');
278
+ updateSSEStatus('disconnected');
279
+ eventSource.close();
280
+ eventSource = null;
281
+
282
+ if (heartbeatWatchdog) clearTimeout(heartbeatWatchdog);
283
+ reconnectWithCatchup();
284
+ };
285
+ }
286
+
287
+ // ---------------------------------------------------------------------------
288
+ // Project selector
289
+ // ---------------------------------------------------------------------------
290
+
291
+ async function initProjectSelector() {
292
+ var select = document.getElementById('project-selector');
293
+ if (!select) return;
294
+
295
+ var data = await fetchProjects();
296
+ var projects = data.projects || [];
297
+ var defaultProject = data.defaultProject;
298
+
299
+ select.innerHTML = '';
300
+
301
+ if (projects.length === 0) {
302
+ var opt = document.createElement('option');
303
+ opt.value = '';
304
+ opt.textContent = 'No projects';
305
+ select.appendChild(opt);
306
+ return;
307
+ }
308
+
309
+ projects.forEach(function (p) {
310
+ var opt = document.createElement('option');
311
+ opt.value = p.hash;
312
+ opt.textContent = p.displayName;
313
+ if (p.hash === defaultProject) opt.selected = true;
314
+ select.appendChild(opt);
315
+ });
316
+
317
+ // Set initial current project
318
+ window.laminarkState.currentProject = select.value || defaultProject;
319
+
320
+ select.addEventListener('change', function () {
321
+ window.laminarkState.currentProject = select.value;
322
+ // Reload all data for the new project
323
+ if (window.laminarkGraph && window.laminarkState.graphInitialized) {
324
+ window.laminarkGraph.loadGraphData();
325
+ }
326
+ if (window.laminarkTimeline && window.laminarkState.timelineInitialized) {
327
+ window.laminarkTimeline.loadTimelineData();
328
+ }
329
+ });
330
+ }
331
+
332
+ // ---------------------------------------------------------------------------
333
+ // Tab navigation
334
+ // ---------------------------------------------------------------------------
335
+
336
+ function initNavigation() {
337
+ const tabs = document.querySelectorAll('.nav-tab');
338
+ const views = document.querySelectorAll('.view-container');
339
+ const filterBar = document.getElementById('filter-bar');
340
+
341
+ tabs.forEach(function (tab) {
342
+ tab.addEventListener('click', function () {
343
+ const targetView = tab.getAttribute('data-view');
344
+
345
+ // Update active tab
346
+ tabs.forEach(function (t) { t.classList.remove('active'); });
347
+ tab.classList.add('active');
348
+
349
+ // Show target view, hide others
350
+ views.forEach(function (v) {
351
+ v.classList.toggle('active', v.id === targetView);
352
+ });
353
+
354
+ // Show/hide filter bar and time range bar (only for graph view)
355
+ var isGraph = targetView === 'graph-view';
356
+ var mainContent = document.getElementById('main-content');
357
+ if (filterBar) {
358
+ filterBar.style.display = isGraph ? '' : 'none';
359
+ }
360
+ var timeRangeBar = document.getElementById('time-range-bar');
361
+ if (timeRangeBar) {
362
+ timeRangeBar.style.display = isGraph ? '' : 'none';
363
+ }
364
+ if (mainContent) {
365
+ mainContent.classList.toggle('no-bars', !isGraph);
366
+ }
367
+
368
+ // Refresh stats when switching to settings tab
369
+ if (targetView === 'settings-view' && window.laminarkSettings) {
370
+ window.laminarkSettings.refreshStats();
371
+ }
372
+
373
+ // Lazy initialization: only init each view when first activated
374
+ if (targetView === 'timeline-view' && !window.laminarkState.timelineInitialized) {
375
+ if (window.laminarkTimeline) {
376
+ window.laminarkTimeline.initTimeline('timeline-view');
377
+ window.laminarkTimeline.loadTimelineData();
378
+ window.laminarkState.timelineInitialized = true;
379
+ }
380
+ } else if (targetView === 'graph-view' && !window.laminarkState.graphInitialized) {
381
+ if (window.laminarkGraph) {
382
+ window.laminarkGraph.initGraph('cy');
383
+ window.laminarkGraph.loadGraphData();
384
+ window.laminarkState.graphInitialized = true;
385
+ }
386
+ }
387
+ });
388
+ });
389
+ }
390
+
391
+ // ---------------------------------------------------------------------------
392
+ // Filter bar
393
+ // ---------------------------------------------------------------------------
394
+
395
+ function initFilters() {
396
+ const pills = document.querySelectorAll('.filter-pill');
397
+
398
+ pills.forEach(function (pill) {
399
+ pill.addEventListener('click', function () {
400
+ const type = pill.getAttribute('data-type');
401
+
402
+ if (type === 'all') {
403
+ var typePills = document.querySelectorAll('.filter-pill:not([data-type="all"])');
404
+ var allCurrentlyActive = Array.from(typePills).every(function (p) { return p.classList.contains('active'); });
405
+
406
+ if (allCurrentlyActive) {
407
+ // Toggle off -- deactivate all pills
408
+ pills.forEach(function (p) {
409
+ p.classList.remove('active');
410
+ });
411
+ if (window.laminarkGraph && window.laminarkGraph.setActiveTypes) {
412
+ window.laminarkGraph.setActiveTypes([]);
413
+ }
414
+ } else {
415
+ // Toggle on -- activate all type pills
416
+ pills.forEach(function (p) {
417
+ p.classList.add('active');
418
+ });
419
+ if (window.laminarkGraph && window.laminarkGraph.resetFilters) {
420
+ window.laminarkGraph.resetFilters();
421
+ }
422
+ }
423
+ } else {
424
+ // Toggle this filter pill
425
+ pill.classList.toggle('active');
426
+
427
+ // Check if all type pills are now active
428
+ const typePills = document.querySelectorAll('.filter-pill:not([data-type="all"])');
429
+ const allActive = Array.from(typePills).every(function (p) { return p.classList.contains('active'); });
430
+ const noneActive = !Array.from(typePills).some(function (p) { return p.classList.contains('active'); });
431
+ const allPill = document.querySelector('.filter-pill[data-type="all"]');
432
+
433
+ if (allActive || noneActive) {
434
+ // All selected or none selected -- reset to "All"
435
+ pills.forEach(function (p) { p.classList.add('active'); });
436
+ if (window.laminarkGraph && window.laminarkGraph.resetFilters) {
437
+ window.laminarkGraph.resetFilters();
438
+ }
439
+ return;
440
+ }
441
+
442
+ // Update "All" pill state
443
+ if (allPill) allPill.classList.remove('active');
444
+
445
+ // Use graph.js filterByType for toggle behavior
446
+ if (window.laminarkGraph && window.laminarkGraph.filterByType) {
447
+ window.laminarkGraph.filterByType(type);
448
+ }
449
+ }
450
+
451
+ // Dispatch filter change event for any other listeners
452
+ const activeTypes = getActiveFilters();
453
+ document.dispatchEvent(new CustomEvent('laminark:filter_change', { detail: { types: activeTypes } }));
454
+ });
455
+ });
456
+ }
457
+
458
+ function getActiveFilters() {
459
+ const allPill = document.querySelector('.filter-pill[data-type="all"].active');
460
+ if (allPill) return null; // No filter -- show all
461
+
462
+ const active = document.querySelectorAll('.filter-pill.active');
463
+ return Array.from(active).map(function (p) { return p.getAttribute('data-type'); });
464
+ }
465
+
466
+ // ---------------------------------------------------------------------------
467
+ // Time range controls
468
+ // ---------------------------------------------------------------------------
469
+
470
+ function initTimeRange() {
471
+ var presetBtns = document.querySelectorAll('.time-preset');
472
+ var timeFromInput = document.getElementById('time-from');
473
+ var timeToInput = document.getElementById('time-to');
474
+ var applyBtn = document.getElementById('time-apply');
475
+
476
+ presetBtns.forEach(function (btn) {
477
+ btn.addEventListener('click', function () {
478
+ var preset = btn.getAttribute('data-preset');
479
+
480
+ // Update active state
481
+ presetBtns.forEach(function (b) { b.classList.remove('active'); });
482
+ btn.classList.add('active');
483
+
484
+ // Calculate from/to dates based on preset
485
+ var now = new Date();
486
+ var from = null;
487
+ var to = null;
488
+
489
+ if (preset === 'hour') {
490
+ from = new Date(now.getTime() - 60 * 60 * 1000);
491
+ to = now;
492
+ } else if (preset === 'today') {
493
+ from = new Date(now.getFullYear(), now.getMonth(), now.getDate());
494
+ to = now;
495
+ } else if (preset === 'week') {
496
+ // Start of this week (Monday)
497
+ var day = now.getDay();
498
+ var diff = day === 0 ? 6 : day - 1; // Monday = 0 offset
499
+ from = new Date(now.getFullYear(), now.getMonth(), now.getDate() - diff);
500
+ to = now;
501
+ } else if (preset === 'month') {
502
+ from = new Date(now.getFullYear(), now.getMonth(), 1);
503
+ to = now;
504
+ }
505
+ // preset === 'all' => from = null, to = null
506
+
507
+ // Update the date inputs to reflect the preset
508
+ if (timeFromInput && timeToInput) {
509
+ timeFromInput.value = from ? toDatetimeLocalString(from) : '';
510
+ timeToInput.value = to ? toDatetimeLocalString(to) : '';
511
+ }
512
+
513
+ // Apply client-side time range filter (instant for presets)
514
+ if (window.laminarkGraph && window.laminarkGraph.filterByTimeRange) {
515
+ window.laminarkGraph.filterByTimeRange(
516
+ from ? from.toISOString() : null,
517
+ to ? to.toISOString() : null
518
+ );
519
+ }
520
+ });
521
+ });
522
+
523
+ // Custom date range -- apply button
524
+ if (applyBtn) {
525
+ applyBtn.addEventListener('click', function () {
526
+ // Deactivate preset buttons
527
+ presetBtns.forEach(function (b) { b.classList.remove('active'); });
528
+
529
+ var fromVal = timeFromInput ? timeFromInput.value : '';
530
+ var toVal = timeToInput ? timeToInput.value : '';
531
+
532
+ var from = fromVal ? new Date(fromVal).toISOString() : null;
533
+ var to = toVal ? new Date(toVal).toISOString() : null;
534
+
535
+ // For custom date ranges, re-fetch from API for server-side filtering
536
+ if (window.laminarkGraph) {
537
+ var filters = {};
538
+ if (from) filters.since = from;
539
+ if (to) filters.until = to;
540
+
541
+ // Re-fetch graph data with server-side filtering
542
+ window.laminarkGraph.loadGraphData(filters).then(function () {
543
+ // Then also apply client-side time range for combined filtering
544
+ if (window.laminarkGraph.filterByTimeRange) {
545
+ window.laminarkGraph.filterByTimeRange(from, to);
546
+ }
547
+ });
548
+ }
549
+ });
550
+ }
551
+ }
552
+
553
+ /**
554
+ * Converts a Date to a datetime-local input value string.
555
+ * @param {Date} date
556
+ * @returns {string}
557
+ */
558
+ function toDatetimeLocalString(date) {
559
+ var pad = function (n) { return n < 10 ? '0' + n : '' + n; };
560
+ return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate()) +
561
+ 'T' + pad(date.getHours()) + ':' + pad(date.getMinutes());
562
+ }
563
+
564
+ // ---------------------------------------------------------------------------
565
+ // Detail panel
566
+ // ---------------------------------------------------------------------------
567
+
568
+ function initDetailPanel() {
569
+ const closeBtn = document.getElementById('detail-close');
570
+ const focusBtn = document.getElementById('detail-focus');
571
+
572
+ if (closeBtn) {
573
+ closeBtn.addEventListener('click', function () {
574
+ if (window.laminarkGraph && window.laminarkGraph.hideDetailPanel) {
575
+ window.laminarkGraph.hideDetailPanel();
576
+ } else {
577
+ var panel = document.getElementById('detail-panel');
578
+ if (panel) panel.classList.add('hidden');
579
+ }
580
+ });
581
+ }
582
+
583
+ if (focusBtn) {
584
+ focusBtn.addEventListener('click', function () {
585
+ var nodeId = focusBtn.getAttribute('data-node-id');
586
+ var nodeLabel = focusBtn.getAttribute('data-node-label');
587
+ if (nodeId && window.laminarkGraph && window.laminarkGraph.enterFocusMode) {
588
+ window.laminarkGraph.enterFocusMode(nodeId, nodeLabel || nodeId);
589
+ }
590
+ });
591
+ }
592
+ }
593
+
594
+ function showNodeDetails(nodeData) {
595
+ const panel = document.getElementById('detail-panel');
596
+ const title = document.getElementById('detail-title');
597
+ const body = document.getElementById('detail-body');
598
+
599
+ if (!panel || !title || !body) return;
600
+
601
+ title.textContent = nodeData.entity.label;
602
+
603
+ // Update focus button with current node info
604
+ var focusBtn = document.getElementById('detail-focus');
605
+ if (focusBtn) {
606
+ focusBtn.setAttribute('data-node-id', nodeData.entity.id);
607
+ focusBtn.setAttribute('data-node-label', nodeData.entity.label);
608
+ }
609
+
610
+ // Build detail panel content using DOM elements for safety
611
+ body.innerHTML = '';
612
+
613
+ // Entity info section
614
+ var entitySection = document.createElement('div');
615
+ entitySection.className = 'detail-section';
616
+
617
+ var entityTitle = document.createElement('div');
618
+ entityTitle.className = 'detail-section-title';
619
+ entityTitle.textContent = 'Entity';
620
+ entitySection.appendChild(entityTitle);
621
+
622
+ // Type field with colored badge
623
+ var typeField = document.createElement('div');
624
+ typeField.className = 'detail-field';
625
+ var typeLabel = document.createElement('span');
626
+ typeLabel.className = 'field-label';
627
+ typeLabel.textContent = 'Type: ';
628
+ var typeBadge = document.createElement('span');
629
+ typeBadge.className = 'type-badge';
630
+ typeBadge.setAttribute('data-type', nodeData.entity.type);
631
+ typeBadge.textContent = nodeData.entity.type;
632
+ typeField.appendChild(typeLabel);
633
+ typeField.appendChild(typeBadge);
634
+ entitySection.appendChild(typeField);
635
+
636
+ // Created date
637
+ var createdField = document.createElement('div');
638
+ createdField.className = 'detail-field';
639
+ var createdLabel = document.createElement('span');
640
+ createdLabel.className = 'field-label';
641
+ createdLabel.textContent = 'Created: ';
642
+ var createdValue = document.createElement('span');
643
+ createdValue.className = 'field-value';
644
+ createdValue.textContent = formatTime(nodeData.entity.createdAt);
645
+ createdField.appendChild(createdLabel);
646
+ createdField.appendChild(createdValue);
647
+ entitySection.appendChild(createdField);
648
+
649
+ body.appendChild(entitySection);
650
+
651
+ // Observations section -- scrollable list sorted most recent first
652
+ if (nodeData.observations && nodeData.observations.length > 0) {
653
+ var obsSection = document.createElement('div');
654
+ obsSection.className = 'detail-section';
655
+
656
+ var obsTitle = document.createElement('div');
657
+ obsTitle.className = 'detail-section-title';
658
+ obsTitle.textContent = 'Observations (' + nodeData.observations.length + ')';
659
+ obsSection.appendChild(obsTitle);
660
+
661
+ var obsList = document.createElement('div');
662
+ obsList.className = 'observation-list-panel';
663
+
664
+ nodeData.observations.forEach(function (obs) {
665
+ var item = document.createElement('div');
666
+ item.className = 'observation-item';
667
+
668
+ var timestamp = document.createElement('span');
669
+ timestamp.className = 'obs-timestamp';
670
+ timestamp.textContent = formatTime(obs.createdAt);
671
+ item.appendChild(timestamp);
672
+
673
+ var text = document.createElement('span');
674
+ text.className = 'obs-text-content';
675
+ text.textContent = obs.text.length > 200 ? obs.text.substring(0, 200) + '...' : obs.text;
676
+ item.appendChild(text);
677
+
678
+ obsList.appendChild(item);
679
+ });
680
+
681
+ obsSection.appendChild(obsList);
682
+ body.appendChild(obsSection);
683
+ }
684
+
685
+ // Relationships section -- each clickable to navigate to that node
686
+ if (nodeData.relationships && nodeData.relationships.length > 0) {
687
+ var relSection = document.createElement('div');
688
+ relSection.className = 'detail-section';
689
+
690
+ var relTitle = document.createElement('div');
691
+ relTitle.className = 'detail-section-title';
692
+ relTitle.textContent = 'Relationships (' + nodeData.relationships.length + ')';
693
+ relSection.appendChild(relTitle);
694
+
695
+ nodeData.relationships.forEach(function (rel) {
696
+ var item = document.createElement('div');
697
+ item.className = 'relationship-item';
698
+ item.setAttribute('data-target-id', rel.targetId);
699
+ item.style.cursor = 'pointer';
700
+
701
+ var relType = document.createElement('span');
702
+ relType.className = 'rel-type';
703
+ relType.textContent = rel.type;
704
+ item.appendChild(relType);
705
+
706
+ var arrow = document.createElement('span');
707
+ arrow.className = 'rel-arrow';
708
+ arrow.textContent = rel.direction === 'outgoing' ? ' \u2192 ' : ' \u2190 ';
709
+ item.appendChild(arrow);
710
+
711
+ var target = document.createElement('span');
712
+ target.className = 'rel-target';
713
+ target.textContent = rel.targetLabel;
714
+ item.appendChild(target);
715
+
716
+ // Click to navigate to the related node in the graph
717
+ item.addEventListener('click', function () {
718
+ if (window.laminarkGraph && window.laminarkGraph.selectAndCenterNode) {
719
+ window.laminarkGraph.selectAndCenterNode(rel.targetId);
720
+ }
721
+ });
722
+
723
+ relSection.appendChild(item);
724
+ });
725
+
726
+ body.appendChild(relSection);
727
+ }
728
+
729
+ if (!nodeData.observations?.length && !nodeData.relationships?.length) {
730
+ var emptyMsg = document.createElement('p');
731
+ emptyMsg.className = 'empty-state';
732
+ emptyMsg.textContent = 'No details available for this node.';
733
+ body.appendChild(emptyMsg);
734
+ }
735
+
736
+ panel.classList.remove('hidden');
737
+ }
738
+
739
+ function showPathDetails(pathData) {
740
+ var panel = document.getElementById('path-detail-panel');
741
+ var title = document.getElementById('path-detail-title');
742
+ var body = document.getElementById('path-detail-body');
743
+
744
+ if (!panel || !title || !body) return;
745
+
746
+ var path = pathData.path;
747
+ var waypoints = pathData.waypoints || [];
748
+
749
+ title.textContent = 'Debug Path';
750
+ body.innerHTML = '';
751
+
752
+ // Status + trigger section
753
+ var infoSection = document.createElement('div');
754
+ infoSection.className = 'path-info-section';
755
+
756
+ var statusBadge = document.createElement('span');
757
+ statusBadge.className = 'path-status-badge ' + path.status;
758
+ statusBadge.textContent = path.status;
759
+ infoSection.appendChild(statusBadge);
760
+
761
+ var triggerLabel = document.createElement('div');
762
+ triggerLabel.className = 'path-info-label';
763
+ triggerLabel.style.marginTop = '8px';
764
+ triggerLabel.textContent = 'Trigger';
765
+ infoSection.appendChild(triggerLabel);
766
+
767
+ var triggerValue = document.createElement('div');
768
+ triggerValue.className = 'path-info-value';
769
+ triggerValue.textContent = path.trigger_summary || 'Unknown trigger';
770
+ infoSection.appendChild(triggerValue);
771
+
772
+ // Started at
773
+ var startedLabel = document.createElement('div');
774
+ startedLabel.className = 'path-info-label';
775
+ startedLabel.style.marginTop = '6px';
776
+ startedLabel.textContent = 'Started';
777
+ infoSection.appendChild(startedLabel);
778
+
779
+ var startedValue = document.createElement('div');
780
+ startedValue.className = 'path-info-value';
781
+ startedValue.textContent = formatTime(path.started_at);
782
+ infoSection.appendChild(startedValue);
783
+
784
+ // Resolved at (if resolved)
785
+ if (path.resolved_at) {
786
+ var resolvedLabel = document.createElement('div');
787
+ resolvedLabel.className = 'path-info-label';
788
+ resolvedLabel.style.marginTop = '6px';
789
+ resolvedLabel.textContent = 'Resolved';
790
+ infoSection.appendChild(resolvedLabel);
791
+
792
+ var resolvedValue = document.createElement('div');
793
+ resolvedValue.className = 'path-info-value';
794
+ resolvedValue.textContent = formatTime(path.resolved_at);
795
+ infoSection.appendChild(resolvedValue);
796
+ }
797
+
798
+ // Resolution summary
799
+ if (path.resolution_summary) {
800
+ var resLabel = document.createElement('div');
801
+ resLabel.className = 'path-info-label';
802
+ resLabel.style.marginTop = '6px';
803
+ resLabel.textContent = 'Resolution';
804
+ infoSection.appendChild(resLabel);
805
+
806
+ var resValue = document.createElement('div');
807
+ resValue.className = 'path-info-value';
808
+ resValue.textContent = path.resolution_summary;
809
+ infoSection.appendChild(resValue);
810
+ }
811
+
812
+ body.appendChild(infoSection);
813
+
814
+ // KISS Summary (if present)
815
+ if (path.kiss_summary) {
816
+ var kiss;
817
+ try {
818
+ kiss = typeof path.kiss_summary === 'string' ? JSON.parse(path.kiss_summary) : path.kiss_summary;
819
+ } catch (e) { kiss = null; }
820
+
821
+ if (kiss) {
822
+ var kissSection = document.createElement('div');
823
+ kissSection.className = 'kiss-summary-section';
824
+
825
+ var kissTitle = document.createElement('div');
826
+ kissTitle.className = 'kiss-summary-title';
827
+ kissTitle.textContent = 'KISS Summary';
828
+ kissSection.appendChild(kissTitle);
829
+
830
+ var kissFields = [
831
+ { label: 'Problem', value: kiss.problem },
832
+ { label: 'Cause', value: kiss.cause },
833
+ { label: 'Fix', value: kiss.fix },
834
+ { label: 'Prevention', value: kiss.prevention },
835
+ ];
836
+
837
+ kissFields.forEach(function(field) {
838
+ if (!field.value) return;
839
+ var fieldDiv = document.createElement('div');
840
+ fieldDiv.className = 'kiss-summary-field';
841
+
842
+ var fieldLabel = document.createElement('div');
843
+ fieldLabel.className = 'kiss-summary-field-label';
844
+ fieldLabel.textContent = field.label;
845
+ fieldDiv.appendChild(fieldLabel);
846
+
847
+ var fieldValue = document.createElement('div');
848
+ fieldValue.className = 'kiss-summary-field-value';
849
+ fieldValue.textContent = field.value;
850
+ fieldDiv.appendChild(fieldValue);
851
+
852
+ kissSection.appendChild(fieldDiv);
853
+ });
854
+
855
+ body.appendChild(kissSection);
856
+ }
857
+ }
858
+
859
+ // Waypoint timeline
860
+ if (waypoints.length > 0) {
861
+ var wpSectionTitle = document.createElement('div');
862
+ wpSectionTitle.className = 'waypoint-section-title';
863
+ wpSectionTitle.textContent = 'Waypoints (' + waypoints.length + ')';
864
+ body.appendChild(wpSectionTitle);
865
+
866
+ var timeline = document.createElement('div');
867
+ timeline.className = 'waypoint-timeline';
868
+
869
+ waypoints.forEach(function(wp) {
870
+ var item = document.createElement('div');
871
+ item.className = 'waypoint-item';
872
+ item.setAttribute('data-type', wp.waypoint_type);
873
+
874
+ var header = document.createElement('div');
875
+ header.className = 'waypoint-header';
876
+
877
+ var seq = document.createElement('span');
878
+ seq.className = 'waypoint-seq';
879
+ seq.textContent = '#' + wp.sequence_order;
880
+ header.appendChild(seq);
881
+
882
+ var typeLabel = document.createElement('span');
883
+ typeLabel.className = 'waypoint-type-label';
884
+ var WAYPOINT_COLORS = {
885
+ error: '#f85149', attempt: '#d29922', failure: '#f0883e',
886
+ success: '#3fb950', pivot: '#a371f7', revert: '#79c0ff',
887
+ discovery: '#58a6ff', resolution: '#3fb950'
888
+ };
889
+ typeLabel.style.color = WAYPOINT_COLORS[wp.waypoint_type] || '#8b949e';
890
+ typeLabel.textContent = wp.waypoint_type;
891
+ header.appendChild(typeLabel);
892
+
893
+ var time = document.createElement('span');
894
+ time.className = 'waypoint-time';
895
+ time.textContent = formatTime(wp.created_at);
896
+ header.appendChild(time);
897
+
898
+ item.appendChild(header);
899
+
900
+ if (wp.summary) {
901
+ var summary = document.createElement('div');
902
+ summary.className = 'waypoint-summary';
903
+ summary.textContent = wp.summary;
904
+ item.appendChild(summary);
905
+ }
906
+
907
+ timeline.appendChild(item);
908
+ });
909
+
910
+ body.appendChild(timeline);
911
+ } else {
912
+ var emptyMsg = document.createElement('p');
913
+ emptyMsg.className = 'empty-state';
914
+ emptyMsg.textContent = 'No waypoints recorded yet.';
915
+ body.appendChild(emptyMsg);
916
+ }
917
+
918
+ // Hide the node detail panel if it's open (avoid two panels)
919
+ var nodePanel = document.getElementById('detail-panel');
920
+ if (nodePanel) nodePanel.classList.add('hidden');
921
+
922
+ panel.classList.remove('hidden');
923
+ }
924
+
925
+ function initPathDetailPanel() {
926
+ var closeBtn = document.getElementById('path-detail-close');
927
+ if (closeBtn) {
928
+ closeBtn.addEventListener('click', function() {
929
+ var panel = document.getElementById('path-detail-panel');
930
+ if (panel) panel.classList.add('hidden');
931
+ });
932
+ }
933
+
934
+ // Listen for path detail events from graph overlay clicks
935
+ document.addEventListener('laminark:show_path_detail', function(e) {
936
+ if (e.detail) {
937
+ showPathDetails(e.detail);
938
+ }
939
+ });
940
+ }
941
+
942
+ function escapeHtml(text) {
943
+ const div = document.createElement('div');
944
+ div.textContent = text;
945
+ return div.innerHTML;
946
+ }
947
+
948
+ // ---------------------------------------------------------------------------
949
+ // Graph Search
950
+ // ---------------------------------------------------------------------------
951
+
952
+ function initSearch() {
953
+ var searchInput = document.getElementById('graph-search');
954
+ var dropdown = document.getElementById('search-results');
955
+ if (!searchInput || !dropdown) return;
956
+
957
+ var debounceTimer = null;
958
+ var activeIndex = -1;
959
+ var currentResults = [];
960
+
961
+ function renderDropdown(results) {
962
+ currentResults = results;
963
+ activeIndex = -1;
964
+
965
+ if (results.length === 0 && searchInput.value.trim().length > 0) {
966
+ dropdown.innerHTML = '<div class="search-no-results">No matches found</div>';
967
+ dropdown.classList.remove('hidden');
968
+ return;
969
+ }
970
+
971
+ if (results.length === 0) {
972
+ dropdown.classList.add('hidden');
973
+ return;
974
+ }
975
+
976
+ dropdown.innerHTML = '';
977
+ results.forEach(function (r, idx) {
978
+ var item = document.createElement('div');
979
+ item.className = 'search-result-item';
980
+ item.setAttribute('data-index', idx);
981
+
982
+ var dot = document.createElement('span');
983
+ dot.className = 'search-result-dot';
984
+ var style = (window.laminarkGraph && window.laminarkGraph.ENTITY_STYLES[r.type]) || { color: '#8b949e' };
985
+ dot.style.background = style.color;
986
+ item.appendChild(dot);
987
+
988
+ var labelWrap = document.createElement('div');
989
+ labelWrap.style.flex = '1';
990
+ labelWrap.style.minWidth = '0';
991
+
992
+ var label = document.createElement('div');
993
+ label.className = 'search-result-label';
994
+ label.textContent = r.label;
995
+ labelWrap.appendChild(label);
996
+
997
+ if (r.snippet) {
998
+ var snippet = document.createElement('div');
999
+ snippet.className = 'search-result-snippet';
1000
+ snippet.textContent = r.snippet;
1001
+ labelWrap.appendChild(snippet);
1002
+ }
1003
+
1004
+ item.appendChild(labelWrap);
1005
+
1006
+ var typeBadge = document.createElement('span');
1007
+ typeBadge.className = 'search-result-type';
1008
+ typeBadge.textContent = r.type;
1009
+ item.appendChild(typeBadge);
1010
+
1011
+ if (r.matchSource) {
1012
+ var src = document.createElement('span');
1013
+ src.className = 'search-result-source';
1014
+ src.textContent = r.matchSource;
1015
+ item.appendChild(src);
1016
+ }
1017
+
1018
+ item.addEventListener('click', function () {
1019
+ selectResult(r);
1020
+ });
1021
+
1022
+ dropdown.appendChild(item);
1023
+ });
1024
+
1025
+ dropdown.classList.remove('hidden');
1026
+ }
1027
+
1028
+ function selectResult(r) {
1029
+ dropdown.classList.add('hidden');
1030
+ searchInput.value = r.label;
1031
+
1032
+ if (window.laminarkGraph) {
1033
+ window.laminarkGraph.selectAndCenterNode(r.id);
1034
+ window.laminarkGraph.highlightSearchMatches([r.id]);
1035
+ }
1036
+ }
1037
+
1038
+ function clearSearch() {
1039
+ searchInput.value = '';
1040
+ dropdown.classList.add('hidden');
1041
+ currentResults = [];
1042
+ activeIndex = -1;
1043
+ if (window.laminarkGraph) {
1044
+ window.laminarkGraph.clearSearchHighlight();
1045
+ }
1046
+ }
1047
+
1048
+ // Debounced client-side search on keyup
1049
+ searchInput.addEventListener('input', function () {
1050
+ var query = searchInput.value.trim();
1051
+
1052
+ if (debounceTimer) clearTimeout(debounceTimer);
1053
+
1054
+ if (!query) {
1055
+ clearSearch();
1056
+ return;
1057
+ }
1058
+
1059
+ debounceTimer = setTimeout(function () {
1060
+ // Client-side search on loaded Cytoscape data
1061
+ var results = [];
1062
+ if (window.laminarkGraph && window.laminarkGraph.searchNodes) {
1063
+ var clientResults = window.laminarkGraph.searchNodes(query);
1064
+ results = clientResults.map(function (r) {
1065
+ return {
1066
+ id: r.id,
1067
+ label: r.label,
1068
+ type: r.type,
1069
+ matchSource: null,
1070
+ snippet: null,
1071
+ };
1072
+ });
1073
+ }
1074
+
1075
+ // Highlight matching nodes in graph
1076
+ if (results.length > 0 && window.laminarkGraph) {
1077
+ window.laminarkGraph.highlightSearchMatches(results.map(function (r) { return r.id; }));
1078
+ }
1079
+
1080
+ renderDropdown(results);
1081
+ }, 250);
1082
+ });
1083
+
1084
+ // Enter triggers server-side search
1085
+ searchInput.addEventListener('keydown', function (e) {
1086
+ if (e.key === 'Enter') {
1087
+ e.preventDefault();
1088
+ var query = searchInput.value.trim();
1089
+ if (!query) return;
1090
+
1091
+ var params = new URLSearchParams({ q: query, limit: '20' });
1092
+ if (window.laminarkState.currentProject) {
1093
+ params.set('project', window.laminarkState.currentProject);
1094
+ }
1095
+
1096
+ fetch('/api/graph/search?' + params.toString())
1097
+ .then(function (res) { return res.json(); })
1098
+ .then(function (data) {
1099
+ renderDropdown(data.results || []);
1100
+
1101
+ // Highlight all matching nodes in graph
1102
+ if (data.results && data.results.length > 0 && window.laminarkGraph) {
1103
+ window.laminarkGraph.highlightSearchMatches(
1104
+ data.results.map(function (r) { return r.id; })
1105
+ );
1106
+ }
1107
+ })
1108
+ .catch(function (err) {
1109
+ console.error('[laminark] Search failed:', err);
1110
+ });
1111
+ } else if (e.key === 'Escape') {
1112
+ clearSearch();
1113
+ } else if (e.key === 'ArrowDown') {
1114
+ e.preventDefault();
1115
+ if (currentResults.length > 0) {
1116
+ activeIndex = Math.min(activeIndex + 1, currentResults.length - 1);
1117
+ updateActiveItem();
1118
+ }
1119
+ } else if (e.key === 'ArrowUp') {
1120
+ e.preventDefault();
1121
+ if (currentResults.length > 0) {
1122
+ activeIndex = Math.max(activeIndex - 1, 0);
1123
+ updateActiveItem();
1124
+ }
1125
+ }
1126
+
1127
+ // Enter on active dropdown item
1128
+ if (e.key === 'Enter' && activeIndex >= 0 && currentResults[activeIndex]) {
1129
+ selectResult(currentResults[activeIndex]);
1130
+ }
1131
+ });
1132
+
1133
+ function updateActiveItem() {
1134
+ var items = dropdown.querySelectorAll('.search-result-item');
1135
+ items.forEach(function (item, idx) {
1136
+ item.classList.toggle('active', idx === activeIndex);
1137
+ });
1138
+ }
1139
+
1140
+ // Close dropdown on outside click
1141
+ document.addEventListener('click', function (e) {
1142
+ if (!searchInput.contains(e.target) && !dropdown.contains(e.target)) {
1143
+ dropdown.classList.add('hidden');
1144
+ }
1145
+ });
1146
+ }
1147
+
1148
+ // ---------------------------------------------------------------------------
1149
+ // Graph Analysis Panel
1150
+ // ---------------------------------------------------------------------------
1151
+
1152
+ var analysisOpen = false;
1153
+
1154
+ function initAnalysis() {
1155
+ var btn = document.getElementById('analysis-btn');
1156
+ var panel = document.getElementById('analysis-panel');
1157
+ var closeBtn = document.getElementById('analysis-close');
1158
+ if (!btn || !panel) return;
1159
+
1160
+ btn.addEventListener('click', function () {
1161
+ analysisOpen = !analysisOpen;
1162
+ btn.classList.toggle('active', analysisOpen);
1163
+ if (analysisOpen) {
1164
+ panel.classList.remove('hidden');
1165
+ loadAnalysis();
1166
+ } else {
1167
+ panel.classList.add('hidden');
1168
+ // Clear any cluster highlights
1169
+ if (window.laminarkGraph) {
1170
+ window.laminarkGraph.clearSearchHighlight();
1171
+ }
1172
+ }
1173
+ // Let CSS transition finish, then refit graph to new width
1174
+ setTimeout(function () {
1175
+ if (window.laminarkGraph) window.laminarkGraph.fitToView();
1176
+ }, 350);
1177
+ });
1178
+
1179
+ if (closeBtn) {
1180
+ closeBtn.addEventListener('click', function () {
1181
+ analysisOpen = false;
1182
+ panel.classList.add('hidden');
1183
+ btn.classList.remove('active');
1184
+ if (window.laminarkGraph) {
1185
+ window.laminarkGraph.clearSearchHighlight();
1186
+ }
1187
+ setTimeout(function () {
1188
+ if (window.laminarkGraph) window.laminarkGraph.fitToView();
1189
+ }, 350);
1190
+ });
1191
+ }
1192
+ }
1193
+
1194
+ function loadAnalysis() {
1195
+ var body = document.getElementById('analysis-body');
1196
+ if (!body) return;
1197
+
1198
+ body.innerHTML = '<p class="empty-state">Loading analysis...</p>';
1199
+
1200
+ var params = new URLSearchParams();
1201
+ if (window.laminarkState.currentProject) {
1202
+ params.set('project', window.laminarkState.currentProject);
1203
+ }
1204
+
1205
+ fetch('/api/graph/analysis' + (params.toString() ? '?' + params.toString() : ''))
1206
+ .then(function (res) { return res.json(); })
1207
+ .then(function (data) {
1208
+ renderAnalysis(body, data);
1209
+ })
1210
+ .catch(function (err) {
1211
+ console.error('[laminark] Analysis failed:', err);
1212
+ body.innerHTML = '<p class="empty-state">Failed to load analysis</p>';
1213
+ });
1214
+ }
1215
+
1216
+ function renderAnalysis(container, data) {
1217
+ container.innerHTML = '';
1218
+
1219
+ var entityStyles = (window.laminarkGraph && window.laminarkGraph.ENTITY_STYLES) || {};
1220
+
1221
+ // Recent activity
1222
+ if (data.recentActivity) {
1223
+ var actSection = document.createElement('div');
1224
+ actSection.className = 'analysis-section';
1225
+
1226
+ var actTitle = document.createElement('div');
1227
+ actTitle.className = 'analysis-section-title';
1228
+ actTitle.textContent = 'Recent Activity';
1229
+ actSection.appendChild(actTitle);
1230
+
1231
+ var rows = [
1232
+ { label: 'Last 24 hours', value: data.recentActivity.lastDay },
1233
+ { label: 'Last 7 days', value: data.recentActivity.lastWeek },
1234
+ ];
1235
+
1236
+ rows.forEach(function (r) {
1237
+ var row = document.createElement('div');
1238
+ row.className = 'analysis-stat-row';
1239
+ var lbl = document.createElement('span');
1240
+ lbl.className = 'analysis-stat-label';
1241
+ lbl.textContent = r.label;
1242
+ var val = document.createElement('span');
1243
+ val.className = 'analysis-stat-value';
1244
+ val.textContent = r.value + ' new entities';
1245
+ row.appendChild(lbl);
1246
+ row.appendChild(val);
1247
+ actSection.appendChild(row);
1248
+ });
1249
+
1250
+ container.appendChild(actSection);
1251
+ }
1252
+
1253
+ // Entity type distribution
1254
+ if (data.entityTypes && data.entityTypes.length > 0) {
1255
+ var etSection = document.createElement('div');
1256
+ etSection.className = 'analysis-section';
1257
+
1258
+ var etTitle = document.createElement('div');
1259
+ etTitle.className = 'analysis-section-title';
1260
+ etTitle.textContent = 'Entity Types';
1261
+ etSection.appendChild(etTitle);
1262
+
1263
+ var maxCount = data.entityTypes[0].count;
1264
+
1265
+ data.entityTypes.forEach(function (et) {
1266
+ var row = document.createElement('div');
1267
+ row.className = 'analysis-bar-row';
1268
+
1269
+ var label = document.createElement('span');
1270
+ label.className = 'analysis-bar-label';
1271
+ label.textContent = et.type;
1272
+ row.appendChild(label);
1273
+
1274
+ var track = document.createElement('div');
1275
+ track.className = 'analysis-bar-track';
1276
+ var fill = document.createElement('div');
1277
+ fill.className = 'analysis-bar-fill';
1278
+ fill.style.width = Math.round((et.count / maxCount) * 100) + '%';
1279
+ fill.style.background = (entityStyles[et.type] || { color: '#8b949e' }).color;
1280
+ track.appendChild(fill);
1281
+ row.appendChild(track);
1282
+
1283
+ var count = document.createElement('span');
1284
+ count.className = 'analysis-bar-count';
1285
+ count.textContent = et.count;
1286
+ row.appendChild(count);
1287
+
1288
+ etSection.appendChild(row);
1289
+ });
1290
+
1291
+ container.appendChild(etSection);
1292
+ }
1293
+
1294
+ // Relationship type distribution
1295
+ if (data.relationshipTypes && data.relationshipTypes.length > 0) {
1296
+ var rtSection = document.createElement('div');
1297
+ rtSection.className = 'analysis-section';
1298
+
1299
+ var rtTitle = document.createElement('div');
1300
+ rtTitle.className = 'analysis-section-title';
1301
+ rtTitle.textContent = 'Relationship Types';
1302
+ rtSection.appendChild(rtTitle);
1303
+
1304
+ var maxRel = data.relationshipTypes[0].count;
1305
+
1306
+ data.relationshipTypes.forEach(function (rt) {
1307
+ var row = document.createElement('div');
1308
+ row.className = 'analysis-bar-row';
1309
+
1310
+ var label = document.createElement('span');
1311
+ label.className = 'analysis-bar-label';
1312
+ label.textContent = rt.type;
1313
+ row.appendChild(label);
1314
+
1315
+ var track = document.createElement('div');
1316
+ track.className = 'analysis-bar-track';
1317
+ var fill = document.createElement('div');
1318
+ fill.className = 'analysis-bar-fill';
1319
+ fill.style.width = Math.round((rt.count / maxRel) * 100) + '%';
1320
+ fill.style.background = '#8b949e';
1321
+ track.appendChild(fill);
1322
+ row.appendChild(track);
1323
+
1324
+ var count = document.createElement('span');
1325
+ count.className = 'analysis-bar-count';
1326
+ count.textContent = rt.count;
1327
+ row.appendChild(count);
1328
+
1329
+ rtSection.appendChild(row);
1330
+ });
1331
+
1332
+ container.appendChild(rtSection);
1333
+ }
1334
+
1335
+ // Top 10 entities by degree
1336
+ if (data.topEntities && data.topEntities.length > 0) {
1337
+ var teSection = document.createElement('div');
1338
+ teSection.className = 'analysis-section';
1339
+
1340
+ var teTitle = document.createElement('div');
1341
+ teTitle.className = 'analysis-section-title';
1342
+ teTitle.textContent = 'Most Connected Entities';
1343
+ teSection.appendChild(teTitle);
1344
+
1345
+ data.topEntities.forEach(function (ent) {
1346
+ var item = document.createElement('div');
1347
+ item.className = 'analysis-entity-item';
1348
+
1349
+ var dot = document.createElement('span');
1350
+ dot.className = 'analysis-entity-dot';
1351
+ dot.style.background = (entityStyles[ent.type] || { color: '#8b949e' }).color;
1352
+ item.appendChild(dot);
1353
+
1354
+ var name = document.createElement('span');
1355
+ name.className = 'analysis-entity-name';
1356
+ name.textContent = ent.label;
1357
+ item.appendChild(name);
1358
+
1359
+ var degree = document.createElement('span');
1360
+ degree.className = 'analysis-entity-degree';
1361
+ degree.textContent = ent.degree + ' links';
1362
+ item.appendChild(degree);
1363
+
1364
+ item.addEventListener('click', function () {
1365
+ if (window.laminarkGraph) {
1366
+ window.laminarkGraph.selectAndCenterNode(ent.id);
1367
+ }
1368
+ });
1369
+
1370
+ teSection.appendChild(item);
1371
+ });
1372
+
1373
+ container.appendChild(teSection);
1374
+ }
1375
+
1376
+ // Connected components / clusters
1377
+ if (data.components && data.components.length > 0) {
1378
+ var ccSection = document.createElement('div');
1379
+ ccSection.className = 'analysis-section';
1380
+
1381
+ var ccTitle = document.createElement('div');
1382
+ ccTitle.className = 'analysis-section-title';
1383
+ ccTitle.textContent = 'Clusters (' + data.components.length + ')';
1384
+ ccSection.appendChild(ccTitle);
1385
+
1386
+ data.components.forEach(function (comp) {
1387
+ var card = document.createElement('div');
1388
+ card.className = 'analysis-cluster-card';
1389
+
1390
+ var label = document.createElement('div');
1391
+ label.className = 'analysis-cluster-label';
1392
+ label.textContent = comp.label;
1393
+ card.appendChild(label);
1394
+
1395
+ var meta = document.createElement('div');
1396
+ meta.className = 'analysis-cluster-meta';
1397
+ meta.textContent = comp.nodeCount + ' nodes, ' + comp.edgeCount + ' edges';
1398
+ card.appendChild(meta);
1399
+
1400
+ card.addEventListener('click', function () {
1401
+ if (window.laminarkGraph) {
1402
+ window.laminarkGraph.highlightCluster(comp.nodeIds);
1403
+ }
1404
+ });
1405
+
1406
+ ccSection.appendChild(card);
1407
+ });
1408
+
1409
+ container.appendChild(ccSection);
1410
+ }
1411
+
1412
+ if (!data.entityTypes?.length && !data.topEntities?.length) {
1413
+ var empty = document.createElement('p');
1414
+ empty.className = 'empty-state';
1415
+ empty.textContent = 'No graph data to analyze yet.';
1416
+ container.appendChild(empty);
1417
+ }
1418
+ }
1419
+
1420
+ // ---------------------------------------------------------------------------
1421
+ // Initialization
1422
+ // ---------------------------------------------------------------------------
1423
+
1424
+ document.addEventListener('DOMContentLoaded', async function () {
1425
+ console.log('[laminark] Initializing application');
1426
+
1427
+ await initProjectSelector();
1428
+ initNavigation();
1429
+ initFilters();
1430
+ initTimeRange();
1431
+ initDetailPanel();
1432
+ initPathDetailPanel();
1433
+ initSearch();
1434
+ initAnalysis();
1435
+ connectSSE();
1436
+
1437
+ // Initialize activity feed
1438
+ if (window.laminarkActivity) {
1439
+ window.laminarkActivity.initActivityFeed();
1440
+ }
1441
+
1442
+ // Initialize settings
1443
+ if (window.laminarkSettings) {
1444
+ window.laminarkSettings.initSettings();
1445
+ }
1446
+
1447
+ // Fetch initial data
1448
+ const [graphData, timelineData] = await Promise.all([
1449
+ fetchGraphData(),
1450
+ fetchTimelineData(),
1451
+ ]);
1452
+
1453
+ window.laminarkState.graph = graphData;
1454
+ window.laminarkState.timeline = timelineData;
1455
+
1456
+ console.log('[laminark] Initial data loaded:', {
1457
+ nodes: graphData.nodes.length,
1458
+ edges: graphData.edges.length,
1459
+ sessions: timelineData.sessions.length,
1460
+ observations: timelineData.observations.length,
1461
+ });
1462
+
1463
+ // Initialize the knowledge graph (active by default)
1464
+ if (window.laminarkGraph) {
1465
+ window.laminarkGraph.initGraph('cy');
1466
+ await window.laminarkGraph.loadGraphData();
1467
+ window.laminarkState.graphInitialized = true;
1468
+ }
1469
+
1470
+ // Initialize timeline module (lazy -- waits for tab click, or pre-init if timeline tab is active)
1471
+ var activeTab = document.querySelector('.nav-tab.active');
1472
+ if (activeTab && activeTab.getAttribute('data-view') === 'timeline-view') {
1473
+ if (window.laminarkTimeline) {
1474
+ window.laminarkTimeline.initTimeline('timeline-view');
1475
+ window.laminarkTimeline.loadTimelineData();
1476
+ window.laminarkState.timelineInitialized = true;
1477
+ }
1478
+ }
1479
+
1480
+ // Listen for SSE-dispatched events for graph updates (batched for performance)
1481
+ document.addEventListener('laminark:entity_updated', function (e) {
1482
+ if (!window.laminarkGraph) return;
1483
+ var data = e.detail;
1484
+ if (data && data.id) {
1485
+ window.laminarkGraph.queueBatchUpdate({
1486
+ type: 'addNode',
1487
+ data: {
1488
+ id: data.id,
1489
+ label: data.label || data.name,
1490
+ type: data.type,
1491
+ observationCount: data.observationCount || 0,
1492
+ createdAt: data.createdAt,
1493
+ },
1494
+ });
1495
+ }
1496
+ });
1497
+
1498
+ document.addEventListener('laminark:new_observation', function () {
1499
+ // Refresh observation counts by reloading graph data
1500
+ if (window.laminarkGraph) {
1501
+ var filters = getActiveFilters();
1502
+ window.laminarkGraph.loadGraphData(filters ? { type: filters.join(',') } : undefined);
1503
+ }
1504
+ });
1505
+
1506
+ // Path overlay event handlers (Plan 02 adds the graph overlay functions)
1507
+ document.addEventListener('laminark:path_started', function (e) {
1508
+ if (window.laminarkGraph && window.laminarkGraph.addPathOverlay) {
1509
+ window.laminarkGraph.addPathOverlay(e.detail);
1510
+ }
1511
+ });
1512
+
1513
+ document.addEventListener('laminark:path_waypoint', function (e) {
1514
+ if (window.laminarkGraph && window.laminarkGraph.updatePathOverlay) {
1515
+ window.laminarkGraph.updatePathOverlay(e.detail);
1516
+ }
1517
+ });
1518
+
1519
+ document.addEventListener('laminark:path_resolved', function (e) {
1520
+ if (window.laminarkGraph && window.laminarkGraph.resolvePathOverlay) {
1521
+ window.laminarkGraph.resolvePathOverlay(e.detail);
1522
+ }
1523
+ });
1524
+
1525
+ // Filter change handler for graph (legacy listener)
1526
+ document.addEventListener('laminark:filter_change', function () {
1527
+ // Filter changes now handled directly in initFilters via graph.filterByType/resetFilters
1528
+ // This listener remains for any external consumers
1529
+ });
1530
+
1531
+ // After initial graph load, update filter pill counts
1532
+ setTimeout(function () {
1533
+ if (window.laminarkGraph && window.laminarkGraph.updateFilterCounts) {
1534
+ window.laminarkGraph.updateFilterCounts();
1535
+ }
1536
+ }, 1000);
1537
+ });
1538
+
1539
+ // ---------------------------------------------------------------------------
1540
+ // Timeline rendering
1541
+ // ---------------------------------------------------------------------------
1542
+
1543
+ function renderTimeline(data) {
1544
+ const container = document.getElementById('timeline-content');
1545
+ if (!container) return;
1546
+
1547
+ if (!data.sessions.length && !data.observations.length) {
1548
+ container.innerHTML = '<p class="empty-state">No timeline data yet. Observations will appear here as they are captured.</p>';
1549
+ return;
1550
+ }
1551
+
1552
+ let html = '';
1553
+
1554
+ // Group observations by session
1555
+ const sessionMap = new Map();
1556
+ data.sessions.forEach(function (s) { sessionMap.set(s.id, s); });
1557
+
1558
+ // Observations without sessions
1559
+ const ungrouped = data.observations.filter(function (o) { return !o.sessionId; });
1560
+
1561
+ // Build session groups
1562
+ if (data.sessions.length > 0) {
1563
+ data.sessions.forEach(function (session) {
1564
+ const sessionObs = data.observations.filter(function (o) { return o.sessionId === session.id; });
1565
+ const shifts = data.topicShifts.filter(function (ts) {
1566
+ return ts.timestamp >= session.startedAt && (!session.endedAt || ts.timestamp <= session.endedAt);
1567
+ });
1568
+
1569
+ html += '<div class="timeline-session">';
1570
+ html += '<div class="timeline-session-header">';
1571
+ html += '<span class="session-time">' + formatTime(session.startedAt) + '</span>';
1572
+ html += '<span class="session-summary">' + escapeHtml(session.summary || 'Session ' + session.id.substring(0, 8)) + '</span>';
1573
+ html += '</div>';
1574
+
1575
+ // Interleave observations and topic shifts by time
1576
+ const items = [];
1577
+ sessionObs.forEach(function (o) { items.push({ time: o.createdAt, type: 'obs', data: o }); });
1578
+ shifts.forEach(function (s) { items.push({ time: s.timestamp, type: 'shift', data: s }); });
1579
+ items.sort(function (a, b) { return a.time.localeCompare(b.time); });
1580
+
1581
+ items.forEach(function (item) {
1582
+ if (item.type === 'obs') {
1583
+ const text = item.data.text.length > 300 ? item.data.text.substring(0, 300) + '...' : item.data.text;
1584
+ html += '<div class="timeline-observation">';
1585
+ html += '<span class="obs-time">' + formatTime(item.data.createdAt) + '</span>';
1586
+ html += '<span class="obs-text">' + escapeHtml(text) + '</span>';
1587
+ html += '</div>';
1588
+ } else {
1589
+ html += '<div class="timeline-topic-shift">';
1590
+ html += '\u21BB Topic shift detected';
1591
+ if (item.data.confidence != null) {
1592
+ html += ' (confidence: ' + (item.data.confidence * 100).toFixed(0) + '%)';
1593
+ }
1594
+ html += '</div>';
1595
+ }
1596
+ });
1597
+
1598
+ html += '</div>';
1599
+ });
1600
+ }
1601
+
1602
+ // Ungrouped observations
1603
+ if (ungrouped.length > 0) {
1604
+ html += '<div class="timeline-session">';
1605
+ html += '<div class="timeline-session-header">';
1606
+ html += '<span class="session-summary">Ungrouped observations</span>';
1607
+ html += '</div>';
1608
+ ungrouped.forEach(function (obs) {
1609
+ const text = obs.text.length > 300 ? obs.text.substring(0, 300) + '...' : obs.text;
1610
+ html += '<div class="timeline-observation">';
1611
+ html += '<span class="obs-time">' + formatTime(obs.createdAt) + '</span>';
1612
+ html += '<span class="obs-text">' + escapeHtml(text) + '</span>';
1613
+ html += '</div>';
1614
+ });
1615
+ html += '</div>';
1616
+ }
1617
+
1618
+ container.innerHTML = html;
1619
+ }
1620
+
1621
+ function formatTime(isoString) {
1622
+ if (!isoString) return '';
1623
+ try {
1624
+ const d = new Date(isoString);
1625
+ return d.toLocaleString();
1626
+ } catch {
1627
+ return isoString;
1628
+ }
1629
+ }
1630
+
1631
+ // Export helpers for potential use by graph/timeline modules
1632
+ window.laminarkApp = {
1633
+ fetchGraphData: fetchGraphData,
1634
+ fetchTimelineData: fetchTimelineData,
1635
+ fetchNodeDetails: fetchNodeDetails,
1636
+ fetchProjects: fetchProjects,
1637
+ fetchPaths: fetchPaths,
1638
+ fetchPathDetail: fetchPathDetail,
1639
+ showNodeDetails: showNodeDetails,
1640
+ showPathDetails: showPathDetails,
1641
+ getActiveFilters: getActiveFilters,
1642
+ };