@jungjaehoon/mama-os 0.62.0 → 0.63.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/CHANGELOG.md +34 -0
- package/README.md +1 -1
- package/dist/api/viewer-data.d.ts +77 -0
- package/dist/api/viewer-data.d.ts.map +1 -1
- package/dist/api/viewer-data.js +116 -0
- package/dist/api/viewer-data.js.map +1 -1
- package/dist/api/viewer-server.d.ts +1 -1
- package/dist/api/viewer-server.d.ts.map +1 -1
- package/dist/api/viewer-server.js +87 -3
- package/dist/api/viewer-server.js.map +1 -1
- package/dist/api/work-actions.d.ts +2 -0
- package/dist/api/work-actions.d.ts.map +1 -1
- package/dist/api/work-actions.js +4 -5
- package/dist/api/work-actions.js.map +1 -1
- package/dist/runtime/action-surface.d.ts +1 -1
- package/dist/runtime/action-surface.d.ts.map +1 -1
- package/dist/runtime/action-surface.js +3 -0
- package/dist/runtime/action-surface.js.map +1 -1
- package/dist/runtime/turn-orders.d.ts.map +1 -1
- package/dist/runtime/turn-orders.js +6 -2
- package/dist/runtime/turn-orders.js.map +1 -1
- package/package.json +2 -2
- package/public/viewer/js/modules/graph.js +169 -957
- package/public/viewer/js/modules/memory-timeline.js +215 -0
- package/public/viewer/js/utils/api.js +7 -26
- package/public/viewer/src/modules/graph.ts +185 -1132
- package/public/viewer/src/modules/memory-timeline.ts +272 -0
- package/public/viewer/src/types/global.d.ts +1 -1
- package/public/viewer/src/utils/api.ts +43 -47
- package/public/viewer/viewer.css +257 -7
- package/public/viewer/viewer.html +68 -292
|
@@ -1,43 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Graph Module -
|
|
2
|
+
* Graph Module - one memory record and what it links to
|
|
3
3
|
* @module modules/graph
|
|
4
|
-
* @version 1.0.0
|
|
5
4
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* - Search and filter functionality
|
|
10
|
-
* - Detail panel for node information
|
|
11
|
-
* - BFS traversal for connected nodes
|
|
5
|
+
* The memory view picks a record from the saved timeline. This draws that record with its direct
|
|
6
|
+
* links (the source messages it rests on, what it builds on, what it replaced), names every colour
|
|
7
|
+
* and line in plain words, and shows the detail of whichever dot is clicked.
|
|
12
8
|
*/
|
|
13
9
|
|
|
14
10
|
/* eslint-env browser */
|
|
15
11
|
/* global vis */
|
|
16
12
|
|
|
17
|
-
import {
|
|
18
|
-
escapeHtml,
|
|
19
|
-
escapeAttr,
|
|
20
|
-
debounce,
|
|
21
|
-
showToast,
|
|
22
|
-
getElementByIdOrNull,
|
|
23
|
-
getErrorMessage,
|
|
24
|
-
} from '../utils/dom.js';
|
|
13
|
+
import { escapeHtml, getElementByIdOrNull } from '../utils/dom.js';
|
|
25
14
|
import { DebugLogger } from '../utils/debug-logger.js';
|
|
26
|
-
import {
|
|
27
|
-
API,
|
|
28
|
-
type GraphNode,
|
|
29
|
-
type GraphEdge,
|
|
30
|
-
type SimilarDecision,
|
|
31
|
-
type QueryParams,
|
|
32
|
-
} from '../utils/api.js';
|
|
15
|
+
import { API, type GraphNode, type GraphEdge } from '../utils/api.js';
|
|
33
16
|
import { renderSafeMarkdown } from '../utils/markdown.js';
|
|
34
17
|
|
|
35
18
|
type GraphNodeRecord = GraphNode & {
|
|
36
19
|
topic?: string;
|
|
37
|
-
outcome?: string;
|
|
38
20
|
decision?: string;
|
|
39
21
|
reasoning?: string;
|
|
40
|
-
confidence?: number;
|
|
41
22
|
created_at?: string | number;
|
|
42
23
|
};
|
|
43
24
|
|
|
@@ -52,12 +33,6 @@ type EdgeStyle = {
|
|
|
52
33
|
width: number;
|
|
53
34
|
};
|
|
54
35
|
|
|
55
|
-
type ConnectedEdges = {
|
|
56
|
-
outgoing: GraphEdgeRecord[];
|
|
57
|
-
incoming: GraphEdgeRecord[];
|
|
58
|
-
all: GraphEdgeRecord[];
|
|
59
|
-
};
|
|
60
|
-
|
|
61
36
|
type GraphInput = {
|
|
62
37
|
nodes: GraphNodeRecord[];
|
|
63
38
|
edges: GraphEdgeRecord[];
|
|
@@ -65,7 +40,44 @@ type GraphInput = {
|
|
|
65
40
|
};
|
|
66
41
|
|
|
67
42
|
const logger = new DebugLogger('Graph');
|
|
68
|
-
|
|
43
|
+
|
|
44
|
+
/** What each dot colour stands for, in the words the legend shows. */
|
|
45
|
+
export const KIND_LABELS: Record<string, string> = {
|
|
46
|
+
decision: 'Decision',
|
|
47
|
+
preference: 'Preference',
|
|
48
|
+
constraint: 'Constraint',
|
|
49
|
+
lesson: 'Lesson',
|
|
50
|
+
workflow: 'Workflow',
|
|
51
|
+
fact: 'Fact',
|
|
52
|
+
commitment: 'Work update',
|
|
53
|
+
observation: 'Source message',
|
|
54
|
+
memory: 'Memory without a kind',
|
|
55
|
+
registry: 'Registered name',
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** Every relation the engine stores, read from the record that holds the link. */
|
|
59
|
+
export const RELATION_LABELS: Record<string, string> = {
|
|
60
|
+
derived_from: 'rests on (evidence)',
|
|
61
|
+
mentions: 'mentions',
|
|
62
|
+
builds_on: 'builds on',
|
|
63
|
+
amends: 'amends',
|
|
64
|
+
refines: 'refines',
|
|
65
|
+
supersedes: 'replaces',
|
|
66
|
+
contradicts: 'contradicts',
|
|
67
|
+
debates: 'argues with',
|
|
68
|
+
synthesizes: 'combines',
|
|
69
|
+
case_member: 'belongs to case',
|
|
70
|
+
alias_of: 'another name for',
|
|
71
|
+
next_action_for: 'next step for',
|
|
72
|
+
blocks: 'blocks',
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const LABEL_LENGTH = 40;
|
|
76
|
+
|
|
77
|
+
function shortLabel(text: string): string {
|
|
78
|
+
const flat = text.replace(/\s+/g, ' ').trim();
|
|
79
|
+
return flat.length > LABEL_LENGTH ? `${flat.slice(0, LABEL_LENGTH - 1)}…` : flat;
|
|
80
|
+
}
|
|
69
81
|
|
|
70
82
|
/**
|
|
71
83
|
* Graph Module Class
|
|
@@ -73,24 +85,16 @@ const INITIAL_GRAPH_LIMIT = 2000;
|
|
|
73
85
|
export class GraphModule {
|
|
74
86
|
network: VisNetwork | null = null;
|
|
75
87
|
graphData: GraphInput = { nodes: [], edges: [], meta: {} };
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
debouncedSearch = debounce(() => this.search(), 300);
|
|
81
|
-
private similarDecisionClickHandler = (event: Event): void => {
|
|
82
|
-
const target = event.target as HTMLElement;
|
|
83
|
-
const btn = target.closest('.similar-decision-btn') as HTMLElement | null;
|
|
84
|
-
if (!btn || !btn.dataset.nodeId) {
|
|
85
|
-
return;
|
|
86
|
-
}
|
|
87
|
-
this.navigateToNode(btn.dataset.nodeId);
|
|
88
|
-
};
|
|
88
|
+
centerId: string | null = null;
|
|
89
|
+
// A slower earlier read must not draw or describe a record the owner has moved away from.
|
|
90
|
+
private recordRequest = 0;
|
|
91
|
+
private detailRequest = 0;
|
|
89
92
|
nodeColors: Record<string, string> = {
|
|
90
93
|
decision: '#60A5FA',
|
|
91
94
|
preference: '#C084FC',
|
|
92
95
|
constraint: '#FB7185',
|
|
93
96
|
lesson: '#FBBF24',
|
|
97
|
+
workflow: '#F472B6',
|
|
94
98
|
fact: '#2DD4BF',
|
|
95
99
|
commitment: '#FB923C',
|
|
96
100
|
observation: '#94A3B8',
|
|
@@ -103,39 +107,35 @@ export class GraphModule {
|
|
|
103
107
|
derived_from: { color: '#3A9E7E', dashes: false, width: 2 },
|
|
104
108
|
amends: { color: '#2563EB', dashes: [8, 4], width: 2 },
|
|
105
109
|
refines: { color: '#B8860B', dashes: false, width: 2 },
|
|
106
|
-
builds_on: { color: '#B8860B', dashes: [5, 5], width: 2.5 },
|
|
107
|
-
|
|
108
|
-
|
|
110
|
+
builds_on: { color: '#B8860B', dashes: [5, 5], width: 2.5 },
|
|
111
|
+
contradicts: { color: '#DC143C', dashes: false, width: 2.5 },
|
|
112
|
+
debates: { color: '#DC143C', dashes: [5, 5], width: 2.5 },
|
|
113
|
+
synthesizes: { color: '#6B4C9A', width: 3, dashes: false },
|
|
109
114
|
};
|
|
110
115
|
|
|
111
|
-
constructor() {
|
|
112
|
-
// Network state
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// =============================================
|
|
116
|
-
// Data Loading
|
|
117
|
-
// =============================================
|
|
118
|
-
|
|
119
116
|
/**
|
|
120
|
-
*
|
|
117
|
+
* Draw a record and its direct links, then show its detail.
|
|
121
118
|
*/
|
|
122
|
-
async
|
|
119
|
+
async showRecord(id: string): Promise<void> {
|
|
120
|
+
const request = ++this.recordRequest;
|
|
121
|
+
const empty = getElementByIdOrNull<HTMLElement>('graph-empty');
|
|
122
|
+
const loading = getElementByIdOrNull<HTMLElement>('graph-loading');
|
|
123
|
+
if (empty) empty.style.display = 'none';
|
|
124
|
+
if (loading) loading.style.display = 'flex';
|
|
123
125
|
try {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
126
|
+
const data = (await API.getGraphNeighbors(id)) as GraphInput;
|
|
127
|
+
if (request !== this.recordRequest) return;
|
|
128
|
+
this.centerId = id;
|
|
129
|
+
this.init(data);
|
|
130
|
+
const center = data.nodes.find((node) => String(node.id) === id);
|
|
131
|
+
if (center) void this.showDetail(center);
|
|
132
|
+
} finally {
|
|
133
|
+
if (loading && request === this.recordRequest) loading.style.display = 'none';
|
|
130
134
|
}
|
|
131
135
|
}
|
|
132
136
|
|
|
133
|
-
// =============================================
|
|
134
|
-
// Graph Initialization
|
|
135
|
-
// =============================================
|
|
136
|
-
|
|
137
137
|
/**
|
|
138
|
-
* Initialize vis-network
|
|
138
|
+
* Initialize vis-network over one record's neighbourhood.
|
|
139
139
|
*/
|
|
140
140
|
init(data: GraphInput): void {
|
|
141
141
|
const container = getElementByIdOrNull<HTMLDivElement>('graph-canvas');
|
|
@@ -143,40 +143,33 @@ export class GraphModule {
|
|
|
143
143
|
logger.error('graph-canvas element not found');
|
|
144
144
|
return;
|
|
145
145
|
}
|
|
146
|
-
|
|
147
|
-
// Ensure container has dimensions for vis-network
|
|
148
|
-
if (container.offsetHeight === 0) {
|
|
149
|
-
container.style.minHeight = '400px';
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
logger.debug('Graph canvas dimensions:', container.offsetWidth, 'x', container.offsetHeight);
|
|
153
|
-
|
|
154
146
|
this.graphData = data;
|
|
147
|
+
this.network?.destroy?.();
|
|
155
148
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
149
|
+
const nodes = data.nodes.map((n) => {
|
|
150
|
+
const kind = n.kind ?? 'memory';
|
|
151
|
+
const center = String(n.id) === this.centerId;
|
|
152
|
+
const color = this.getNodeColor(kind);
|
|
153
|
+
return {
|
|
154
|
+
id: n.id,
|
|
155
|
+
// Source messages stay unlabelled dots: their time and text are on hover and click.
|
|
156
|
+
label:
|
|
157
|
+
kind === 'observation' && !center
|
|
158
|
+
? undefined
|
|
159
|
+
: shortLabel(String(n.decision_preview ?? n.decision ?? n.topic ?? n.id)),
|
|
160
|
+
title: this.createNodeTooltip(n),
|
|
161
|
+
color: {
|
|
162
|
+
background: color,
|
|
163
|
+
border: center ? '#131313' : color,
|
|
164
|
+
highlight: { background: color, border: '#131313' },
|
|
165
|
+
},
|
|
166
|
+
size: center ? 22 : kind === 'observation' ? 9 : 14,
|
|
167
|
+
borderWidth: center ? 3 : 1,
|
|
168
|
+
font: { color: '#131313', size: center ? 15 : 13 },
|
|
169
|
+
data: n,
|
|
170
|
+
};
|
|
171
|
+
});
|
|
178
172
|
|
|
179
|
-
// Map edges to vis-network format
|
|
180
173
|
const edges = data.edges.map((e) => {
|
|
181
174
|
const style = this.getEdgeStyle(e.relationship);
|
|
182
175
|
return {
|
|
@@ -185,190 +178,108 @@ export class GraphModule {
|
|
|
185
178
|
arrows: { to: { enabled: true, scaleFactor: 0.5 } },
|
|
186
179
|
color: style.color,
|
|
187
180
|
dashes: style.dashes,
|
|
188
|
-
width: style.width
|
|
189
|
-
title: e.relationship,
|
|
181
|
+
width: style.width,
|
|
182
|
+
title: this.relationLabel(e.relationship),
|
|
190
183
|
};
|
|
191
184
|
});
|
|
192
185
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
edges: new vis.DataSet(edges),
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
shape: 'dot',
|
|
201
|
-
scaling: {
|
|
202
|
-
min: 10,
|
|
203
|
-
max: 30,
|
|
204
|
-
label: { enabled: true, min: 12, max: 22, maxVisible: 22, drawThreshold: 8 },
|
|
205
|
-
},
|
|
206
|
-
},
|
|
207
|
-
edges: {
|
|
208
|
-
smooth: { type: 'continuous', roundness: 0.5 },
|
|
209
|
-
width: 2,
|
|
210
|
-
},
|
|
211
|
-
physics: {
|
|
212
|
-
// The initial layout always runs; the stabilized handler turns physics off afterwards.
|
|
213
|
-
enabled: true,
|
|
214
|
-
barnesHut: {
|
|
215
|
-
gravitationalConstant: -8000,
|
|
216
|
-
centralGravity: 0.3,
|
|
217
|
-
springLength: 150,
|
|
218
|
-
springConstant: 0.04,
|
|
219
|
-
damping: 0.09,
|
|
220
|
-
avoidOverlap: 1,
|
|
221
|
-
},
|
|
222
|
-
stabilization: {
|
|
186
|
+
this.network = new vis.Network(
|
|
187
|
+
container,
|
|
188
|
+
{ nodes: new vis.DataSet(nodes), edges: new vis.DataSet(edges) },
|
|
189
|
+
{
|
|
190
|
+
nodes: { shape: 'dot' },
|
|
191
|
+
edges: { smooth: { type: 'continuous', roundness: 0.5 }, width: 2 },
|
|
192
|
+
physics: {
|
|
223
193
|
enabled: true,
|
|
224
|
-
|
|
225
|
-
|
|
194
|
+
barnesHut: {
|
|
195
|
+
gravitationalConstant: -6000,
|
|
196
|
+
centralGravity: 0.4,
|
|
197
|
+
springLength: 140,
|
|
198
|
+
springConstant: 0.04,
|
|
199
|
+
damping: 0.09,
|
|
200
|
+
avoidOverlap: 1,
|
|
201
|
+
},
|
|
202
|
+
stabilization: { enabled: true, iterations: 200, updateInterval: 25 },
|
|
226
203
|
},
|
|
227
|
-
|
|
228
|
-
interaction: {
|
|
229
|
-
hover: true,
|
|
230
|
-
tooltipDelay: 100,
|
|
231
|
-
zoomView: true,
|
|
232
|
-
dragView: true,
|
|
233
|
-
},
|
|
234
|
-
};
|
|
235
|
-
|
|
236
|
-
this.network = new vis.Network(container, networkData, options);
|
|
237
|
-
|
|
238
|
-
// Event handlers
|
|
239
|
-
this.network.on('click', (params: { nodes: Array<string | number> }) => {
|
|
240
|
-
try {
|
|
241
|
-
if (params.nodes.length > 0) {
|
|
242
|
-
const nodeId = params.nodes[0];
|
|
243
|
-
const targetId = String(nodeId);
|
|
244
|
-
const node = data.nodes.find((n) => String(n.id) === targetId);
|
|
245
|
-
if (node) {
|
|
246
|
-
logger.debug('Node clicked:', nodeId, node);
|
|
247
|
-
this.showDetail(node);
|
|
248
|
-
this.highlightConnectedNodes(targetId);
|
|
249
|
-
}
|
|
250
|
-
} else {
|
|
251
|
-
this.closeDetail();
|
|
252
|
-
this.resetNodeHighlight();
|
|
253
|
-
}
|
|
254
|
-
} catch (error) {
|
|
255
|
-
logger.error('Error handling click:', error);
|
|
256
|
-
if (error instanceof Error && error.stack) {
|
|
257
|
-
logger.error('Error stack:', error.stack);
|
|
258
|
-
}
|
|
204
|
+
interaction: { hover: true, tooltipDelay: 100, zoomView: true, dragView: true },
|
|
259
205
|
}
|
|
260
|
-
|
|
206
|
+
);
|
|
261
207
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
const fontSize = Math.round(targetVisualSize / scale);
|
|
268
|
-
networkData.nodes.update({
|
|
269
|
-
id: params.node,
|
|
270
|
-
font: { color: '#131313', size: Math.max(fontSize, 14), vadjust: -2 },
|
|
271
|
-
});
|
|
272
|
-
} catch {
|
|
273
|
-
/* ignore */
|
|
274
|
-
}
|
|
208
|
+
this.network.on('click', (params: { nodes: Array<string | number> }) => {
|
|
209
|
+
const targetId = params.nodes[0];
|
|
210
|
+
if (targetId === undefined) return;
|
|
211
|
+
const node = data.nodes.find((n) => String(n.id) === String(targetId));
|
|
212
|
+
if (node) void this.showDetail(node);
|
|
275
213
|
});
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
});
|
|
282
|
-
} catch {
|
|
283
|
-
/* ignore */
|
|
214
|
+
// Stepping to a linked record redraws the graph around it.
|
|
215
|
+
this.network.on('doubleClick', (params: { nodes: Array<string | number> }) => {
|
|
216
|
+
const targetId = params.nodes[0];
|
|
217
|
+
if (targetId !== undefined && String(targetId) !== this.centerId) {
|
|
218
|
+
void this.showRecord(String(targetId));
|
|
284
219
|
}
|
|
285
220
|
});
|
|
286
|
-
|
|
287
|
-
// A large graph may never reach vis-network's 'stabilized'; stop when the bounded iterations end.
|
|
288
221
|
this.network.on('stabilizationIterationsDone', () => {
|
|
289
222
|
this.network?.setOptions?.({ physics: { enabled: false } });
|
|
290
223
|
});
|
|
291
224
|
|
|
292
|
-
this.network.on('stabilized', () => {
|
|
293
|
-
const loadingEl = getElementByIdOrNull<HTMLElement>('graph-loading');
|
|
294
|
-
if (loadingEl) {
|
|
295
|
-
loadingEl.style.display = 'none';
|
|
296
|
-
}
|
|
297
|
-
// Disable physics after initial layout to free CPU
|
|
298
|
-
this.network?.setOptions?.({ physics: { enabled: false } });
|
|
299
|
-
logger.info('Graph stabilized, physics disabled');
|
|
300
|
-
});
|
|
301
|
-
|
|
302
|
-
// Backup: hide loading after 3 seconds even if stabilization doesn't complete
|
|
303
|
-
setTimeout(() => {
|
|
304
|
-
const loadingEl = getElementByIdOrNull<HTMLElement>('graph-loading');
|
|
305
|
-
if (loadingEl && loadingEl.style.display !== 'none') {
|
|
306
|
-
loadingEl.style.display = 'none';
|
|
307
|
-
logger.warn('Graph loading hidden by timeout');
|
|
308
|
-
}
|
|
309
|
-
}, 3000);
|
|
310
|
-
|
|
311
|
-
// Populate topic filter — sorted by most recent created_at (newest first)
|
|
312
|
-
const topicLatest = new Map<string, number>();
|
|
313
|
-
for (const n of data.nodes) {
|
|
314
|
-
const t = n.topic || '';
|
|
315
|
-
const ts = typeof n.created_at === 'number' ? n.created_at : 0;
|
|
316
|
-
if (!topicLatest.has(t) || ts > topicLatest.get(t)!) {
|
|
317
|
-
topicLatest.set(t, ts);
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
const topics = [...topicLatest.entries()].sort((a, b) => b[1] - a[1]).map(([topic]) => topic);
|
|
321
|
-
this.populateTopicFilter(topics);
|
|
322
|
-
this.populateDecisionList(data.nodes);
|
|
323
225
|
this.renderLegend();
|
|
324
|
-
|
|
226
|
+
const legend = getElementByIdOrNull<HTMLElement>('legend-panel');
|
|
227
|
+
legend?.classList.add('visible');
|
|
325
228
|
const stats = getElementByIdOrNull<HTMLElement>('graph-stats');
|
|
326
229
|
if (stats) {
|
|
327
|
-
stats.
|
|
230
|
+
stats.style.display = '';
|
|
231
|
+
stats.textContent = `${data.nodes.length} records and source messages · ${data.edges.length} links · click a dot for its detail, double-click to step to it`;
|
|
328
232
|
}
|
|
329
|
-
|
|
330
|
-
logger.info('Graph initialized with', nodes.length, 'nodes and', edges.length, 'edges');
|
|
331
233
|
}
|
|
332
234
|
|
|
333
235
|
// =============================================
|
|
334
|
-
// Styling
|
|
236
|
+
// Styling and legend
|
|
335
237
|
// =============================================
|
|
336
238
|
|
|
337
239
|
getNodeColor(kind = 'memory'): string {
|
|
338
240
|
return this.nodeColors[kind] ?? '#CBD5E1';
|
|
339
241
|
}
|
|
340
242
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
(this.network ? this.network.body.data.nodes.get() : this.graphData.nodes)
|
|
345
|
-
.filter((node) => !node.hidden)
|
|
346
|
-
.map((node) => String(node.id))
|
|
243
|
+
getEdgeStyle(relationship?: string): EdgeStyle {
|
|
244
|
+
return (
|
|
245
|
+
this.edgeStyles[relationship ?? 'default'] ?? { color: '#4a4a6a', dashes: false, width: 2 }
|
|
347
246
|
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
kindLabel(kind: string): string {
|
|
250
|
+
return KIND_LABELS[kind] ?? kind;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
relationLabel(relationship?: string): string {
|
|
254
|
+
return relationship === undefined ? 'link' : (RELATION_LABELS[relationship] ?? relationship);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Count what is drawn, by kind and by relationship, with the words the legend shows. */
|
|
258
|
+
getLegendEntries() {
|
|
348
259
|
const kinds = new Map<string, number>();
|
|
349
260
|
const relations = new Map<string, number>();
|
|
350
261
|
for (const node of this.graphData.nodes) {
|
|
351
|
-
if (!visible.has(String(node.id))) continue;
|
|
352
262
|
const kind = node.kind ?? 'memory';
|
|
353
263
|
kinds.set(kind, (kinds.get(kind) ?? 0) + 1);
|
|
354
264
|
}
|
|
355
265
|
for (const edge of this.graphData.edges) {
|
|
356
|
-
if (!visible.has(String(edge.from)) || !visible.has(String(edge.to))) continue;
|
|
357
266
|
const relation = edge.relationship ?? 'default';
|
|
358
267
|
relations.set(relation, (relations.get(relation) ?? 0) + 1);
|
|
359
268
|
}
|
|
360
269
|
return {
|
|
361
270
|
nodes: [...kinds]
|
|
362
|
-
.sort(([a], [b]) => a
|
|
271
|
+
.sort(([, a], [, b]) => b - a)
|
|
363
272
|
.map(([kind, count]) => ({
|
|
364
273
|
kind,
|
|
274
|
+
label: this.kindLabel(kind),
|
|
365
275
|
count,
|
|
366
276
|
color: this.getNodeColor(kind),
|
|
367
277
|
})),
|
|
368
278
|
edges: [...relations]
|
|
369
|
-
.sort(([a], [b]) => a
|
|
279
|
+
.sort(([, a], [, b]) => b - a)
|
|
370
280
|
.map(([relationship, count]) => ({
|
|
371
281
|
relationship,
|
|
282
|
+
label: this.relationLabel(relationship),
|
|
372
283
|
count,
|
|
373
284
|
...this.getEdgeStyle(relationship),
|
|
374
285
|
})),
|
|
@@ -382,933 +293,75 @@ export class GraphModule {
|
|
|
382
293
|
const section = (title: string, rows: string[]) =>
|
|
383
294
|
rows.length === 0
|
|
384
295
|
? ''
|
|
385
|
-
: `<div><div class="
|
|
296
|
+
: `<div><div class="legend-heading">${title}</div>${rows.join('')}</div>`;
|
|
386
297
|
const row = (sample: string, label: string, count: number) =>
|
|
387
|
-
`<div class="
|
|
298
|
+
`<div class="legend-row">${sample}<span class="legend-label">${escapeHtml(label)}</span><span class="legend-count">${count}</span></div>`;
|
|
388
299
|
container.innerHTML =
|
|
389
300
|
section(
|
|
390
|
-
'
|
|
391
|
-
entries.nodes.map(({
|
|
392
|
-
row(
|
|
393
|
-
`<span class="w-3 h-3 rounded-full shrink-0" style="background:${color}"></span>`,
|
|
394
|
-
kind,
|
|
395
|
-
count
|
|
396
|
-
)
|
|
301
|
+
'Dots',
|
|
302
|
+
entries.nodes.map(({ label, count, color }) =>
|
|
303
|
+
row(`<span class="legend-dot" style="background:${color}"></span>`, label, count)
|
|
397
304
|
)
|
|
398
305
|
) +
|
|
399
306
|
section(
|
|
400
|
-
'
|
|
401
|
-
entries.edges.map(({
|
|
307
|
+
'Lines (arrow points at what the record links to)',
|
|
308
|
+
entries.edges.map(({ label, count, color, dashes, width }) => {
|
|
402
309
|
const dash = Array.isArray(dashes) ? dashes.join(' ') : dashes ? '5 5' : 'none';
|
|
403
310
|
return row(
|
|
404
|
-
`<svg width="
|
|
405
|
-
|
|
311
|
+
`<svg width="28" height="10" class="shrink-0" aria-hidden="true"><line x1="0" y1="5" x2="28" y2="5" stroke="${color}" stroke-width="${width}" stroke-dasharray="${dash}" /></svg>`,
|
|
312
|
+
label,
|
|
406
313
|
count
|
|
407
314
|
);
|
|
408
315
|
})
|
|
409
316
|
);
|
|
410
|
-
if (entries.nodes.length === 0) container.textContent = '
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
/**
|
|
414
|
-
* Get border color based on outcome
|
|
415
|
-
*/
|
|
416
|
-
getOutcomeBorderColor(outcome?: string, fallback = '#4a4a6a'): string {
|
|
417
|
-
switch (outcome?.toLowerCase()) {
|
|
418
|
-
case 'success':
|
|
419
|
-
return '#22c55e';
|
|
420
|
-
case 'failed':
|
|
421
|
-
return '#ef4444';
|
|
422
|
-
case 'partial':
|
|
423
|
-
return '#f59e0b';
|
|
424
|
-
default:
|
|
425
|
-
return fallback;
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
/**
|
|
430
|
-
* Get edge style by relationship type
|
|
431
|
-
*/
|
|
432
|
-
getEdgeStyle(relationship?: string): EdgeStyle {
|
|
433
|
-
return (
|
|
434
|
-
this.edgeStyles[relationship || 'default'] || { color: '#4a4a6a', dashes: false, width: 2 }
|
|
435
|
-
);
|
|
317
|
+
if (entries.nodes.length === 0) container.textContent = 'Nothing drawn';
|
|
436
318
|
}
|
|
437
319
|
|
|
438
|
-
/**
|
|
439
|
-
* Get node size based on connection count
|
|
440
|
-
*/
|
|
441
|
-
getNodeSize(connectionCount: number): number {
|
|
442
|
-
if (connectionCount <= 2) {
|
|
443
|
-
return 12;
|
|
444
|
-
}
|
|
445
|
-
if (connectionCount <= 5) {
|
|
446
|
-
return 18;
|
|
447
|
-
}
|
|
448
|
-
if (connectionCount <= 10) {
|
|
449
|
-
return 24;
|
|
450
|
-
}
|
|
451
|
-
return 30;
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
/**
|
|
455
|
-
* Create node tooltip
|
|
456
|
-
*/
|
|
457
320
|
createNodeTooltip(node: GraphNodeRecord): string {
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
${node.outcome ? `Outcome: ${escapeHtml(node.outcome)}<br>` : ''}
|
|
462
|
-
Confidence: ${Math.round((node.confidence || 0) * 100)}%
|
|
463
|
-
`;
|
|
321
|
+
const kind = this.kindLabel(node.kind ?? 'memory');
|
|
322
|
+
const text = String(node.decision_preview ?? node.decision ?? '');
|
|
323
|
+
return `${kind}${node.topic ? ` · ${node.topic}` : ''}\n${text.slice(0, 160)}`;
|
|
464
324
|
}
|
|
465
325
|
|
|
466
326
|
// =============================================
|
|
467
|
-
//
|
|
468
|
-
// =============================================
|
|
469
|
-
|
|
470
|
-
/**
|
|
471
|
-
* Build adjacency list for BFS
|
|
472
|
-
*/
|
|
473
|
-
buildAdjacencyList(edges: GraphEdgeRecord[]): void {
|
|
474
|
-
this.adjacencyList = new Map();
|
|
475
|
-
|
|
476
|
-
edges.forEach((edge) => {
|
|
477
|
-
const from = String(edge.from);
|
|
478
|
-
const to = String(edge.to);
|
|
479
|
-
|
|
480
|
-
if (!this.adjacencyList.has(from)) {
|
|
481
|
-
this.adjacencyList.set(from, []);
|
|
482
|
-
}
|
|
483
|
-
if (!this.adjacencyList.has(to)) {
|
|
484
|
-
this.adjacencyList.set(to, []);
|
|
485
|
-
}
|
|
486
|
-
this.adjacencyList.get(from).push(to);
|
|
487
|
-
this.adjacencyList.get(to).push(from);
|
|
488
|
-
});
|
|
489
|
-
|
|
490
|
-
logger.debug('Adjacency list built with', this.adjacencyList.size, 'nodes');
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
/**
|
|
494
|
-
* Calculate connection count for each node
|
|
495
|
-
*/
|
|
496
|
-
calculateConnectionCounts(
|
|
497
|
-
nodes: GraphNodeRecord[],
|
|
498
|
-
edges: GraphEdgeRecord[]
|
|
499
|
-
): Record<string, number> {
|
|
500
|
-
const counts: Record<string, number> = {};
|
|
501
|
-
nodes.forEach((n) => {
|
|
502
|
-
counts[String(n.id)] = 0;
|
|
503
|
-
});
|
|
504
|
-
|
|
505
|
-
edges.forEach((edge) => {
|
|
506
|
-
const from = String(edge.from);
|
|
507
|
-
const to = String(edge.to);
|
|
508
|
-
if (counts[from] !== undefined) {
|
|
509
|
-
counts[from]++;
|
|
510
|
-
}
|
|
511
|
-
if (counts[to] !== undefined) {
|
|
512
|
-
counts[to]++;
|
|
513
|
-
}
|
|
514
|
-
});
|
|
515
|
-
|
|
516
|
-
return counts;
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
// =============================================
|
|
520
|
-
// Graph Traversal & Highlighting
|
|
327
|
+
// Detail Panel
|
|
521
328
|
// =============================================
|
|
522
329
|
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
const
|
|
528
|
-
const
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
const next = queue.shift();
|
|
533
|
-
if (!next) {
|
|
534
|
-
continue;
|
|
535
|
-
}
|
|
536
|
-
const { id, depth } = next;
|
|
537
|
-
|
|
538
|
-
if (depth >= maxDepth) {
|
|
539
|
-
continue;
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
const neighbors = this.adjacencyList.get(id) || [];
|
|
543
|
-
neighbors.forEach((neighborId) => {
|
|
544
|
-
if (!visited.has(neighborId)) {
|
|
545
|
-
visited.add(neighborId);
|
|
546
|
-
queue.push({ id: neighborId, depth: depth + 1 });
|
|
547
|
-
}
|
|
548
|
-
});
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
return Array.from(visited);
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
/**
|
|
555
|
-
* Highlight connected nodes
|
|
556
|
-
*/
|
|
557
|
-
highlightConnectedNodes(nodeId: string | number): void {
|
|
558
|
-
const targetId = String(nodeId);
|
|
559
|
-
if (!this.network) {
|
|
330
|
+
async showDetail(node: GraphNodeRecord): Promise<void> {
|
|
331
|
+
const panel = getElementByIdOrNull<HTMLDivElement>('decision-detail-modal');
|
|
332
|
+
const topicEl = getElementByIdOrNull<HTMLElement>('detail-topic');
|
|
333
|
+
const kindEl = getElementByIdOrNull<HTMLElement>('detail-kind');
|
|
334
|
+
const decisionEl = getElementByIdOrNull<HTMLElement>('detail-decision');
|
|
335
|
+
const reasoningEl = getElementByIdOrNull<HTMLElement>('detail-reasoning');
|
|
336
|
+
const createdEl = getElementByIdOrNull<HTMLElement>('detail-created');
|
|
337
|
+
if (!panel || !decisionEl || !reasoningEl) {
|
|
338
|
+
logger.error('Detail panel elements missing');
|
|
560
339
|
return;
|
|
561
340
|
}
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
const isConnected = connectedIds.includes(String(node.id));
|
|
567
|
-
const opacity = isConnected ? 1.0 : 0.2;
|
|
568
|
-
return {
|
|
569
|
-
id: node.id,
|
|
570
|
-
opacity: opacity,
|
|
571
|
-
font: { ...node.font, color: isConnected ? '#131313' : '#999' },
|
|
572
|
-
};
|
|
573
|
-
});
|
|
574
|
-
this.network.body.data.nodes.update(nodeUpdates);
|
|
575
|
-
|
|
576
|
-
const allEdges = this.network.body.data.edges.get();
|
|
577
|
-
const edgeUpdates = allEdges.map((edge) => {
|
|
578
|
-
const isConnected =
|
|
579
|
-
connectedIds.includes(String(edge.from)) && connectedIds.includes(String(edge.to));
|
|
580
|
-
return {
|
|
581
|
-
id: edge.id,
|
|
582
|
-
opacity: isConnected ? 1.0 : 0.1,
|
|
583
|
-
};
|
|
584
|
-
});
|
|
585
|
-
this.network.body.data.edges.update(edgeUpdates);
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
/**
|
|
589
|
-
* Reset node highlight
|
|
590
|
-
*/
|
|
591
|
-
resetNodeHighlight(): void {
|
|
592
|
-
if (!this.network) {
|
|
593
|
-
return;
|
|
341
|
+
if (topicEl) topicEl.textContent = node.topic || this.kindLabel(node.kind ?? 'memory');
|
|
342
|
+
if (kindEl) kindEl.textContent = this.kindLabel(node.kind ?? 'memory');
|
|
343
|
+
if (createdEl) {
|
|
344
|
+
createdEl.textContent = node.created_at ? new Date(node.created_at).toLocaleString() : '-';
|
|
594
345
|
}
|
|
346
|
+
decisionEl.innerHTML = '<span class="loading-similar">Loading details...</span>';
|
|
347
|
+
reasoningEl.innerHTML = '';
|
|
348
|
+
panel.classList.add('visible');
|
|
595
349
|
|
|
596
|
-
const
|
|
597
|
-
this.network.body.data.nodes.update(
|
|
598
|
-
allNodes.map((node) => ({
|
|
599
|
-
id: node.id,
|
|
600
|
-
opacity: 1.0,
|
|
601
|
-
font: { ...node.font, color: '#131313' },
|
|
602
|
-
}))
|
|
603
|
-
);
|
|
604
|
-
|
|
605
|
-
const allEdges = this.network.body.data.edges.get();
|
|
606
|
-
this.network.body.data.edges.update(
|
|
607
|
-
allEdges.map((edge) => ({
|
|
608
|
-
id: edge.id,
|
|
609
|
-
opacity: 1.0,
|
|
610
|
-
}))
|
|
611
|
-
);
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
// =============================================
|
|
615
|
-
// Detail Panel
|
|
616
|
-
// =============================================
|
|
617
|
-
|
|
618
|
-
/**
|
|
619
|
-
* Show node detail panel
|
|
620
|
-
*/
|
|
621
|
-
async showDetail(node: GraphNodeRecord): Promise<void> {
|
|
350
|
+
const request = ++this.detailRequest;
|
|
622
351
|
try {
|
|
623
|
-
logger.debug('showDetail called with node:', node);
|
|
624
|
-
this.currentNodeId = String(node.id);
|
|
625
|
-
const panel = getElementByIdOrNull<HTMLDivElement>('decision-detail-modal');
|
|
626
|
-
|
|
627
|
-
if (!panel) {
|
|
628
|
-
logger.error('decision-detail-modal element not found');
|
|
629
|
-
return;
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
// Update existing DOM elements with markdown rendering
|
|
633
|
-
const topicEl = getElementByIdOrNull<HTMLElement>('detail-topic');
|
|
634
|
-
const decisionEl = getElementByIdOrNull<HTMLElement>('detail-decision');
|
|
635
|
-
const reasoningEl = getElementByIdOrNull<HTMLElement>('detail-reasoning');
|
|
636
|
-
if (!decisionEl || !reasoningEl) {
|
|
637
|
-
logger.error('Required detail elements missing');
|
|
638
|
-
return;
|
|
639
|
-
}
|
|
640
|
-
if (topicEl) {
|
|
641
|
-
topicEl.textContent = node.topic || 'Unknown Topic';
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
decisionEl.innerHTML = '<span class="loading-similar">Loading details...</span>';
|
|
645
|
-
reasoningEl.innerHTML = '<span class="loading-similar">Loading details...</span>';
|
|
646
|
-
|
|
647
|
-
const outcomeSelect = getElementByIdOrNull<HTMLSelectElement>('detail-outcome-select');
|
|
648
|
-
if (outcomeSelect) {
|
|
649
|
-
outcomeSelect.value = node.outcome ? node.outcome.toUpperCase() : '';
|
|
650
|
-
outcomeSelect.disabled = Boolean(
|
|
651
|
-
(node.kind && node.kind !== 'memory') || (node.state && node.state !== 'current')
|
|
652
|
-
);
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
// Clear outcome status
|
|
656
|
-
const outcomeStatus = getElementByIdOrNull<HTMLElement>('outcome-status');
|
|
657
|
-
if (outcomeStatus) {
|
|
658
|
-
outcomeStatus.textContent = '';
|
|
659
|
-
outcomeStatus.className = '';
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
const confidenceEl = getElementByIdOrNull<HTMLElement>('detail-confidence');
|
|
663
|
-
if (confidenceEl) {
|
|
664
|
-
confidenceEl.textContent = node.confidence ? `${(node.confidence * 100).toFixed(0)}%` : '-';
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
const createdEl = getElementByIdOrNull<HTMLElement>('detail-created');
|
|
668
|
-
if (createdEl) {
|
|
669
|
-
createdEl.textContent = node.created_at ? new Date(node.created_at).toLocaleString() : '-';
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
// Reset reasoning toggle
|
|
673
|
-
const reasoningArrow = getElementByIdOrNull<HTMLElement>('reasoning-arrow');
|
|
674
|
-
if (reasoningArrow) {
|
|
675
|
-
reasoningArrow.textContent = '▶';
|
|
676
|
-
}
|
|
677
|
-
reasoningEl.classList.add('hidden');
|
|
678
|
-
|
|
679
|
-
// Show loading state for similar decisions
|
|
680
|
-
const similarEl = getElementByIdOrNull<HTMLElement>('detail-similar');
|
|
681
|
-
if (similarEl) {
|
|
682
|
-
similarEl.innerHTML = '<span class="loading-similar">Searching...</span>';
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
// Show panel
|
|
686
|
-
panel.classList.add('visible');
|
|
687
|
-
|
|
688
352
|
const detail = await API.getGraphDetail(String(node.id));
|
|
689
|
-
|
|
690
|
-
const
|
|
691
|
-
if (localNode) {
|
|
692
|
-
Object.assign(localNode, detailNode);
|
|
693
|
-
}
|
|
694
|
-
|
|
353
|
+
if (request !== this.detailRequest) return;
|
|
354
|
+
const detailNode = detail.node;
|
|
695
355
|
decisionEl.innerHTML = renderSafeMarkdown(
|
|
696
356
|
String(detailNode.decision || detailNode.decision_preview || '-')
|
|
697
357
|
);
|
|
698
358
|
reasoningEl.innerHTML = renderSafeMarkdown(String(detailNode.reasoning || '-'));
|
|
699
|
-
|
|
700
|
-
// Canonical graph relations are already visible as edges. The old
|
|
701
|
-
// vector-similarity endpoint only understands legacy decision ids.
|
|
702
|
-
if (node.kind) {
|
|
703
|
-
if (similarEl) similarEl.textContent = 'Related records are shown as graph edges.';
|
|
704
|
-
} else {
|
|
705
|
-
void this.fetchSimilarDecisions(String(node.id));
|
|
706
|
-
}
|
|
707
|
-
logger.debug('showDetail completed successfully');
|
|
708
359
|
} catch (error) {
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
}
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
/**
|
|
718
|
-
* Get outcome icon name
|
|
719
|
-
*/
|
|
720
|
-
getOutcomeIcon(outcome?: string): string {
|
|
721
|
-
const outcomeMap = {
|
|
722
|
-
pending: 'clock',
|
|
723
|
-
success: 'check-circle',
|
|
724
|
-
failed: 'x-circle',
|
|
725
|
-
partial: 'alert-circle',
|
|
726
|
-
};
|
|
727
|
-
return outcomeMap[(outcome || 'pending').toLowerCase()] || 'clock';
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
/**
|
|
731
|
-
* Toggle reasoning full text
|
|
732
|
-
*/
|
|
733
|
-
toggleReasoning(): void {
|
|
734
|
-
const arrow = getElementByIdOrNull<HTMLElement>('reasoning-arrow');
|
|
735
|
-
const content = getElementByIdOrNull<HTMLElement>('detail-reasoning');
|
|
736
|
-
if (arrow && content) {
|
|
737
|
-
const isHidden = content.classList.contains('hidden');
|
|
738
|
-
content.classList.toggle('hidden');
|
|
739
|
-
arrow.textContent = isHidden ? '▼' : '▶';
|
|
740
|
-
}
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
/**
|
|
744
|
-
* Close detail panel
|
|
745
|
-
*/
|
|
746
|
-
closeDetail(): void {
|
|
747
|
-
const panel = getElementByIdOrNull<HTMLDivElement>('decision-detail-modal');
|
|
748
|
-
if (panel) {
|
|
749
|
-
panel.classList.remove('visible');
|
|
750
|
-
}
|
|
751
|
-
this.currentNodeId = null;
|
|
752
|
-
this.resetNodeHighlight();
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
/**
|
|
756
|
-
* Fetch similar decisions
|
|
757
|
-
*/
|
|
758
|
-
async fetchSimilarDecisions(nodeId: string): Promise<void> {
|
|
759
|
-
logger.debug('fetchSimilarDecisions called for node:', nodeId);
|
|
760
|
-
const container = getElementByIdOrNull<HTMLElement>('detail-similar');
|
|
761
|
-
|
|
762
|
-
if (!container) {
|
|
763
|
-
logger.warn('detail-similar element not found');
|
|
764
|
-
return;
|
|
765
|
-
}
|
|
766
|
-
|
|
767
|
-
try {
|
|
768
|
-
logger.info('Calling API.getSimilarDecisions...');
|
|
769
|
-
const data = await API.getSimilarDecisions(nodeId);
|
|
770
|
-
logger.debug('Similar decisions received:', data);
|
|
771
|
-
|
|
772
|
-
const similar = (data.similar || []) as SimilarDecision[];
|
|
773
|
-
|
|
774
|
-
if (similar.length === 0) {
|
|
775
|
-
logger.debug('[MAMA] No similar decisions found');
|
|
776
|
-
container.innerHTML = '<span style="color:#666">No similar decisions found</span>';
|
|
777
|
-
return;
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
logger.debug('[MAMA] Building similar decisions HTML for', similar.length, 'items');
|
|
781
|
-
const html = similar
|
|
782
|
-
.map(
|
|
783
|
-
(s) => `
|
|
784
|
-
<button class="similar-decision-btn w-full text-left p-2 mb-2 bg-gray-100 dark:bg-gray-800 hover:bg-indigo-100 dark:hover:bg-indigo-900/30 border border-gray-200 dark:border-gray-700 rounded-lg transition-colors" data-node-id="${escapeAttr(String(s.id))}">
|
|
785
|
-
<div class="text-xs font-semibold text-indigo-600 dark:text-indigo-400">${escapeHtml(
|
|
786
|
-
String(s.topic || '')
|
|
787
|
-
)}</div>
|
|
788
|
-
<div class="text-xs text-gray-600 dark:text-gray-400 mt-1 line-clamp-2">${escapeHtml(
|
|
789
|
-
String(s.decision || '').substring(0, 80)
|
|
790
|
-
)}...</div>
|
|
791
|
-
<div class="text-xs text-gray-500 mt-1">${Math.round((s.similarity || 0) * 100)}% match</div>
|
|
792
|
-
</button>
|
|
793
|
-
`
|
|
794
|
-
)
|
|
795
|
-
.join('');
|
|
796
|
-
|
|
797
|
-
logger.debug('[MAMA] Setting similar decisions HTML');
|
|
798
|
-
container.innerHTML = html;
|
|
799
|
-
|
|
800
|
-
// Bind click handler once to avoid duplicate listeners.
|
|
801
|
-
container.removeEventListener('click', this.similarDecisionClickHandler);
|
|
802
|
-
container.addEventListener('click', this.similarDecisionClickHandler);
|
|
803
|
-
logger.debug('[MAMA] fetchSimilarDecisions completed');
|
|
804
|
-
} catch (error) {
|
|
805
|
-
const message = getErrorMessage(error);
|
|
806
|
-
logger.error('[MAMA] Failed to fetch similar decisions:', message, error);
|
|
807
|
-
if (error instanceof Error && error.stack) {
|
|
808
|
-
logger.error('Error stack:', error.stack);
|
|
809
|
-
}
|
|
810
|
-
container.innerHTML = '<span style="color:#f66">Failed to load</span>';
|
|
811
|
-
}
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
/**
|
|
815
|
-
* Save outcome for current node
|
|
816
|
-
*/
|
|
817
|
-
async saveOutcome(): Promise<void> {
|
|
818
|
-
const current = this.graphData.nodes.find((node) => String(node.id) === this.currentNodeId);
|
|
819
|
-
if (
|
|
820
|
-
(current?.kind && current.kind !== 'memory') ||
|
|
821
|
-
(current?.state && current.state !== 'current')
|
|
822
|
-
)
|
|
823
|
-
return;
|
|
824
|
-
const select = getElementByIdOrNull<HTMLSelectElement>('detail-outcome-select');
|
|
825
|
-
if (!select) {
|
|
826
|
-
return;
|
|
827
|
-
}
|
|
828
|
-
const newOutcome = select.value;
|
|
829
|
-
|
|
830
|
-
if (!this.currentNodeId || !newOutcome) {
|
|
831
|
-
return;
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
try {
|
|
835
|
-
await API.updateOutcome(this.currentNodeId, newOutcome);
|
|
836
|
-
|
|
837
|
-
// Update local data
|
|
838
|
-
const node = this.graphData.nodes.find((n) => String(n.id) === this.currentNodeId);
|
|
839
|
-
if (node) {
|
|
840
|
-
node.outcome = newOutcome.toUpperCase();
|
|
841
|
-
|
|
842
|
-
// Update visualization
|
|
843
|
-
this.network.body.data.nodes.update({
|
|
844
|
-
id: this.currentNodeId,
|
|
845
|
-
color: {
|
|
846
|
-
border: this.getOutcomeBorderColor(newOutcome),
|
|
847
|
-
},
|
|
848
|
-
});
|
|
849
|
-
|
|
850
|
-
// Refresh detail panel
|
|
851
|
-
this.showDetail(node);
|
|
852
|
-
}
|
|
853
|
-
|
|
854
|
-
logger.debug('[MAMA] Outcome updated:', this.currentNodeId, newOutcome);
|
|
855
|
-
} catch (error) {
|
|
856
|
-
const message = getErrorMessage(error);
|
|
857
|
-
logger.error('[MAMA] Failed to update outcome:', message);
|
|
858
|
-
alert(`Failed to update outcome: ${message}`);
|
|
859
|
-
}
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
// =============================================
|
|
863
|
-
// Navigation
|
|
864
|
-
// =============================================
|
|
865
|
-
|
|
866
|
-
/**
|
|
867
|
-
* Navigate to specific node
|
|
868
|
-
*/
|
|
869
|
-
async navigateToNode(nodeId: string | number): Promise<void> {
|
|
870
|
-
if (!this.network) {
|
|
871
|
-
return;
|
|
872
|
-
}
|
|
873
|
-
|
|
874
|
-
let nodeIdString = String(nodeId);
|
|
875
|
-
|
|
876
|
-
// Try exact match first
|
|
877
|
-
let node = this.graphData.nodes.find((n) => String(n.id) === nodeIdString);
|
|
878
|
-
|
|
879
|
-
// If not found, try partial match (for short IDs from checkpoints)
|
|
880
|
-
if (!node) {
|
|
881
|
-
logger.debug('[MAMA] Exact match not found, trying partial match for:', nodeIdString);
|
|
882
|
-
node = this.graphData.nodes.find((n) => String(n.id).startsWith(nodeIdString));
|
|
883
|
-
|
|
884
|
-
if (node) {
|
|
885
|
-
logger.debug('[MAMA] Found node via partial match:', node.id);
|
|
886
|
-
nodeIdString = String(node.id); // Update to the full ID
|
|
887
|
-
}
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
// If node not in current graph, reload without filters
|
|
891
|
-
if (!node) {
|
|
892
|
-
logger.warn('[MAMA] Node not in current graph, reloading all nodes...');
|
|
893
|
-
|
|
894
|
-
// Reset topic filter
|
|
895
|
-
const topicFilter = getElementByIdOrNull<HTMLSelectElement>('topic-filter');
|
|
896
|
-
if (topicFilter) {
|
|
897
|
-
topicFilter.value = '';
|
|
898
|
-
}
|
|
899
|
-
|
|
900
|
-
// Reload graph: fetch fresh data and reinitialize
|
|
901
|
-
await this.fetchData({ full: true });
|
|
902
|
-
this.init(this.graphData);
|
|
903
|
-
|
|
904
|
-
// Try exact match again
|
|
905
|
-
node = this.graphData.nodes.find((n) => String(n.id) === nodeIdString);
|
|
906
|
-
|
|
907
|
-
// If still not found, try partial match
|
|
908
|
-
if (!node) {
|
|
909
|
-
node = this.graphData.nodes.find((n) => String(n.id).startsWith(nodeIdString));
|
|
910
|
-
if (node) {
|
|
911
|
-
logger.debug('[MAMA] Found node via partial match after reload:', node.id);
|
|
912
|
-
nodeIdString = String(node.id);
|
|
913
|
-
}
|
|
914
|
-
}
|
|
915
|
-
|
|
916
|
-
if (!node) {
|
|
917
|
-
logger.warn('[MAMA] Node not found even after reload:', nodeIdString);
|
|
918
|
-
showToast('⚠️ Decision not found in graph');
|
|
919
|
-
return;
|
|
920
|
-
}
|
|
921
|
-
}
|
|
922
|
-
|
|
923
|
-
// Focus on node (use resolved nodeIdString, not original nodeId)
|
|
924
|
-
this.network.focus(nodeIdString, {
|
|
925
|
-
scale: 1.5,
|
|
926
|
-
animation: { duration: 500, easingFunction: 'easeInOutQuad' },
|
|
927
|
-
});
|
|
928
|
-
|
|
929
|
-
// Select node (triggers click event)
|
|
930
|
-
this.network.selectNodes([nodeIdString]);
|
|
931
|
-
|
|
932
|
-
// Show detail
|
|
933
|
-
this.showDetail(node);
|
|
934
|
-
this.highlightConnectedNodes(nodeIdString);
|
|
935
|
-
}
|
|
936
|
-
|
|
937
|
-
/**
|
|
938
|
-
* Get connected edge types
|
|
939
|
-
*/
|
|
940
|
-
getConnectedEdges(nodeId: string): ConnectedEdges {
|
|
941
|
-
const edges = this.graphData.edges.filter(
|
|
942
|
-
(e) => String(e.from) === nodeId || String(e.to) === nodeId
|
|
943
|
-
);
|
|
944
|
-
|
|
945
|
-
const outgoing = edges.filter((e) => String(e.from) === nodeId);
|
|
946
|
-
const incoming = edges.filter((e) => String(e.to) === nodeId);
|
|
947
|
-
|
|
948
|
-
return { outgoing, incoming, all: edges };
|
|
949
|
-
}
|
|
950
|
-
|
|
951
|
-
// =============================================
|
|
952
|
-
// Filtering
|
|
953
|
-
// =============================================
|
|
954
|
-
|
|
955
|
-
/**
|
|
956
|
-
* Populate topic filter dropdown
|
|
957
|
-
*/
|
|
958
|
-
populateTopicFilter(topics: string[]): void {
|
|
959
|
-
const datalist = getElementByIdOrNull<HTMLDataListElement>('topic-filter-list');
|
|
960
|
-
if (!datalist) {
|
|
961
|
-
// Fallback for old select element
|
|
962
|
-
const select = getElementByIdOrNull<HTMLSelectElement>('topic-filter');
|
|
963
|
-
if (!select) return;
|
|
964
|
-
select.innerHTML = '<option value="">All Topics</option>';
|
|
965
|
-
topics.forEach((topic) => {
|
|
966
|
-
const option = document.createElement('option');
|
|
967
|
-
option.value = topic;
|
|
968
|
-
option.textContent = topic;
|
|
969
|
-
select.appendChild(option);
|
|
970
|
-
});
|
|
971
|
-
return;
|
|
972
|
-
}
|
|
973
|
-
|
|
974
|
-
datalist.innerHTML = '';
|
|
975
|
-
topics.forEach((topic) => {
|
|
976
|
-
const option = document.createElement('option');
|
|
977
|
-
option.value = topic;
|
|
978
|
-
datalist.appendChild(option);
|
|
979
|
-
});
|
|
980
|
-
}
|
|
981
|
-
|
|
982
|
-
/**
|
|
983
|
-
* Populate left-side decision list (time-sorted, newest first)
|
|
984
|
-
*/
|
|
985
|
-
populateDecisionList(nodes: GraphNodeRecord[]): void {
|
|
986
|
-
const listEl = getElementByIdOrNull<HTMLElement>('decision-list');
|
|
987
|
-
const countEl = getElementByIdOrNull<HTMLElement>('decision-list-count');
|
|
988
|
-
if (!listEl) return;
|
|
989
|
-
|
|
990
|
-
const sorted = [...nodes].sort((a, b) => {
|
|
991
|
-
const ta = typeof a.created_at === 'number' ? a.created_at : 0;
|
|
992
|
-
const tb = typeof b.created_at === 'number' ? b.created_at : 0;
|
|
993
|
-
return tb - ta;
|
|
994
|
-
});
|
|
995
|
-
|
|
996
|
-
if (countEl) countEl.textContent = String(sorted.length);
|
|
997
|
-
|
|
998
|
-
if (sorted.length === 0) {
|
|
999
|
-
listEl.innerHTML =
|
|
1000
|
-
'<div style="padding:16px;text-align:center;color:#9E9891;font-size:11px">No decisions yet</div>';
|
|
1001
|
-
return;
|
|
1002
|
-
}
|
|
1003
|
-
|
|
1004
|
-
const formatTime = (ts: number | string | undefined): string => {
|
|
1005
|
-
if (!ts) return '';
|
|
1006
|
-
const d = new Date(typeof ts === 'number' ? ts : parseInt(String(ts), 10));
|
|
1007
|
-
const now = Date.now();
|
|
1008
|
-
const diffH = Math.floor((now - d.getTime()) / 3600000);
|
|
1009
|
-
if (diffH < 1) return 'just now';
|
|
1010
|
-
if (diffH < 24) return `${diffH}h ago`;
|
|
1011
|
-
const diffD = Math.floor(diffH / 24);
|
|
1012
|
-
return `${diffD}d ago`;
|
|
1013
|
-
};
|
|
1014
|
-
|
|
1015
|
-
const topicLabel = (topic: string): string => {
|
|
1016
|
-
const parts = topic.split('/').filter(Boolean);
|
|
1017
|
-
return parts[parts.length - 1] || topic;
|
|
1018
|
-
};
|
|
1019
|
-
|
|
1020
|
-
listEl.innerHTML = sorted
|
|
1021
|
-
.map((n) => {
|
|
1022
|
-
const preview = escapeHtml((n.decision_preview || n.decision || '').slice(0, 80));
|
|
1023
|
-
const topic = escapeHtml(topicLabel(n.topic || ''));
|
|
1024
|
-
const time = formatTime(n.created_at);
|
|
1025
|
-
const id = escapeAttr(String(n.id));
|
|
1026
|
-
return `<div class="mama-decision-item" data-node-id="${id}" onclick="window.graphModule?.focusNode('${id}')" style="padding:6px 8px;border-bottom:1px solid #F5F2ED;cursor:pointer;transition:background 0.1s" onmouseover="this.style.background='#FFFDF5'" onmouseout="this.style.background=''"><div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:2px"><span style="font-size:11px;font-weight:600;color:#1A1A1A;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:160px">${topic}</span><span style="font-size:9px;color:#9E9891;flex-shrink:0;font-variant-numeric:tabular-nums">${time}</span></div><div style="font-size:10px;color:#6B6560;line-height:1.4;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical">${preview}</div></div>`;
|
|
1027
|
-
})
|
|
1028
|
-
.join('');
|
|
1029
|
-
}
|
|
1030
|
-
|
|
1031
|
-
/**
|
|
1032
|
-
* Focus a specific node in the graph (called from decision list click)
|
|
1033
|
-
*/
|
|
1034
|
-
focusNode(nodeId: string): void {
|
|
1035
|
-
if (!this.network) return;
|
|
1036
|
-
try {
|
|
1037
|
-
this.network.selectNodes([nodeId]);
|
|
1038
|
-
this.network.focus(nodeId, {
|
|
1039
|
-
scale: 1.5,
|
|
1040
|
-
animation: { duration: 300, easingFunction: 'easeInOutQuad' },
|
|
1041
|
-
});
|
|
1042
|
-
// Trigger the detail panel
|
|
1043
|
-
const nodeData = this.graphData.nodes.find((n) => String(n.id) === nodeId);
|
|
1044
|
-
if (nodeData) {
|
|
1045
|
-
this.showDetail(nodeData);
|
|
1046
|
-
}
|
|
1047
|
-
} catch (err) {
|
|
1048
|
-
logger.warn('focusNode failed:', err);
|
|
1049
|
-
}
|
|
1050
|
-
}
|
|
1051
|
-
|
|
1052
|
-
/**
|
|
1053
|
-
* Filter by topic
|
|
1054
|
-
*/
|
|
1055
|
-
filterByTopic(topic: string): void {
|
|
1056
|
-
if (!this.network) {
|
|
1057
|
-
return;
|
|
1058
|
-
}
|
|
1059
|
-
|
|
1060
|
-
const allNodes = this.network.body.data.nodes.get();
|
|
1061
|
-
|
|
1062
|
-
if (!topic) {
|
|
1063
|
-
// Show all
|
|
1064
|
-
allNodes.forEach((node) => {
|
|
1065
|
-
this.network.body.data.nodes.update({
|
|
1066
|
-
id: node.id,
|
|
1067
|
-
hidden: false,
|
|
1068
|
-
});
|
|
1069
|
-
});
|
|
1070
|
-
} else {
|
|
1071
|
-
// Filter — supports exact match from datalist or partial substring match from typing
|
|
1072
|
-
const lowerTopic = topic.toLowerCase();
|
|
1073
|
-
allNodes.forEach((node) => {
|
|
1074
|
-
const nodeData = this.graphData.nodes.find((n) => String(n.id) === String(node.id));
|
|
1075
|
-
const nodeTopic = nodeData?.topic || '';
|
|
1076
|
-
const matches = nodeTopic === topic || nodeTopic.toLowerCase().includes(lowerTopic);
|
|
1077
|
-
this.network.body.data.nodes.update({
|
|
1078
|
-
id: node.id,
|
|
1079
|
-
hidden: !matches,
|
|
1080
|
-
});
|
|
1081
|
-
});
|
|
1082
|
-
}
|
|
1083
|
-
|
|
1084
|
-
this.renderLegend();
|
|
1085
|
-
logger.debug('[MAMA] Filtered by topic:', topic || 'all');
|
|
1086
|
-
}
|
|
1087
|
-
|
|
1088
|
-
/**
|
|
1089
|
-
* Filter by outcome
|
|
1090
|
-
*/
|
|
1091
|
-
filterByOutcome(outcome: string): void {
|
|
1092
|
-
if (!this.network) {
|
|
1093
|
-
return;
|
|
1094
|
-
}
|
|
1095
|
-
|
|
1096
|
-
const allNodes = this.network.body.data.nodes.get();
|
|
1097
|
-
|
|
1098
|
-
if (!outcome) {
|
|
1099
|
-
// Show all
|
|
1100
|
-
allNodes.forEach((node) => {
|
|
1101
|
-
this.network.body.data.nodes.update({
|
|
1102
|
-
id: node.id,
|
|
1103
|
-
hidden: false,
|
|
1104
|
-
});
|
|
1105
|
-
});
|
|
1106
|
-
} else {
|
|
1107
|
-
// Filter by outcome
|
|
1108
|
-
allNodes.forEach((node) => {
|
|
1109
|
-
const nodeData = this.graphData.nodes.find((n) => String(n.id) === String(node.id));
|
|
1110
|
-
const nodeOutcome = (nodeData?.outcome || '').toLowerCase();
|
|
1111
|
-
this.network.body.data.nodes.update({
|
|
1112
|
-
id: node.id,
|
|
1113
|
-
hidden: nodeOutcome !== outcome.toLowerCase(),
|
|
1114
|
-
});
|
|
1115
|
-
});
|
|
1116
|
-
}
|
|
1117
|
-
|
|
1118
|
-
this.renderLegend();
|
|
1119
|
-
logger.debug('[MAMA] Filtered by outcome:', outcome || 'all');
|
|
1120
|
-
}
|
|
1121
|
-
|
|
1122
|
-
/**
|
|
1123
|
-
* Clear all filters
|
|
1124
|
-
*/
|
|
1125
|
-
clearFilters(): void {
|
|
1126
|
-
if (!this.network) {
|
|
1127
|
-
return;
|
|
1128
|
-
}
|
|
1129
|
-
|
|
1130
|
-
// Show all nodes
|
|
1131
|
-
const allNodes = this.network.body.data.nodes.get();
|
|
1132
|
-
allNodes.forEach((node) => {
|
|
1133
|
-
this.network.body.data.nodes.update({
|
|
1134
|
-
id: node.id,
|
|
1135
|
-
hidden: false,
|
|
1136
|
-
opacity: 1.0,
|
|
1137
|
-
font: { ...node.font, color: '#131313' },
|
|
1138
|
-
});
|
|
1139
|
-
});
|
|
1140
|
-
|
|
1141
|
-
// Show all edges
|
|
1142
|
-
const allEdges = this.network.body.data.edges.get();
|
|
1143
|
-
allEdges.forEach((edge) => {
|
|
1144
|
-
this.network.body.data.edges.update({
|
|
1145
|
-
id: edge.id,
|
|
1146
|
-
opacity: 1.0,
|
|
1147
|
-
});
|
|
1148
|
-
});
|
|
1149
|
-
|
|
1150
|
-
// Clear search state
|
|
1151
|
-
this.searchMatches = [];
|
|
1152
|
-
this.currentSearchIndex = 0;
|
|
1153
|
-
|
|
1154
|
-
const countEl = getElementByIdOrNull<HTMLElement>('search-count');
|
|
1155
|
-
if (countEl) {
|
|
1156
|
-
countEl.style.display = 'none';
|
|
1157
|
-
}
|
|
1158
|
-
|
|
1159
|
-
this.renderLegend();
|
|
1160
|
-
logger.debug('[MAMA] All filters cleared');
|
|
1161
|
-
}
|
|
1162
|
-
|
|
1163
|
-
// =============================================
|
|
1164
|
-
// Search
|
|
1165
|
-
// =============================================
|
|
1166
|
-
|
|
1167
|
-
/**
|
|
1168
|
-
* Perform search
|
|
1169
|
-
*/
|
|
1170
|
-
search(): void {
|
|
1171
|
-
const queryInput = getElementByIdOrNull<HTMLInputElement>('search-input');
|
|
1172
|
-
const query = queryInput ? queryInput.value.trim().toLowerCase() : '';
|
|
1173
|
-
|
|
1174
|
-
if (!query) {
|
|
1175
|
-
this.clearSearch();
|
|
1176
|
-
return;
|
|
1177
|
-
}
|
|
1178
|
-
|
|
1179
|
-
// Search in topic, decision, and reasoning
|
|
1180
|
-
this.searchMatches = this.graphData.nodes.filter(
|
|
1181
|
-
(node) =>
|
|
1182
|
-
(node.topic || '').toLowerCase().includes(query) ||
|
|
1183
|
-
(node.decision || '').toLowerCase().includes(query) ||
|
|
1184
|
-
(node.reasoning || '').toLowerCase().includes(query)
|
|
1185
|
-
);
|
|
1186
|
-
|
|
1187
|
-
this.currentSearchIndex = 0;
|
|
1188
|
-
this.updateSearchResults();
|
|
1189
|
-
|
|
1190
|
-
if (this.searchMatches.length > 0) {
|
|
1191
|
-
this.highlightSearchResults();
|
|
1192
|
-
this.navigateToNode(String(this.searchMatches[0].id));
|
|
1193
|
-
}
|
|
1194
|
-
|
|
1195
|
-
logger.debug('[MAMA] Search:', query, '- Found', this.searchMatches.length, 'matches');
|
|
1196
|
-
}
|
|
1197
|
-
|
|
1198
|
-
/**
|
|
1199
|
-
* Handle search input
|
|
1200
|
-
*/
|
|
1201
|
-
handleSearchInput(event: KeyboardEvent): void {
|
|
1202
|
-
if (event.key === 'Enter' && this.searchMatches.length > 0) {
|
|
1203
|
-
this.nextSearchResult();
|
|
1204
|
-
} else {
|
|
1205
|
-
this.debouncedSearch();
|
|
1206
|
-
}
|
|
1207
|
-
}
|
|
1208
|
-
|
|
1209
|
-
/**
|
|
1210
|
-
* Navigate to next search result
|
|
1211
|
-
*/
|
|
1212
|
-
nextSearchResult(): void {
|
|
1213
|
-
if (this.searchMatches.length === 0) {
|
|
1214
|
-
return;
|
|
1215
|
-
}
|
|
1216
|
-
|
|
1217
|
-
this.currentSearchIndex = (this.currentSearchIndex + 1) % this.searchMatches.length;
|
|
1218
|
-
this.navigateToNode(this.searchMatches[this.currentSearchIndex].id);
|
|
1219
|
-
this.updateSearchResults();
|
|
1220
|
-
}
|
|
1221
|
-
|
|
1222
|
-
/**
|
|
1223
|
-
* Navigate to previous search result
|
|
1224
|
-
*/
|
|
1225
|
-
prevSearchResult(): void {
|
|
1226
|
-
if (this.searchMatches.length === 0) {
|
|
1227
|
-
return;
|
|
1228
|
-
}
|
|
1229
|
-
|
|
1230
|
-
this.currentSearchIndex =
|
|
1231
|
-
(this.currentSearchIndex - 1 + this.searchMatches.length) % this.searchMatches.length;
|
|
1232
|
-
this.navigateToNode(this.searchMatches[this.currentSearchIndex].id);
|
|
1233
|
-
this.updateSearchResults();
|
|
1234
|
-
}
|
|
1235
|
-
|
|
1236
|
-
/**
|
|
1237
|
-
* Update search count display
|
|
1238
|
-
*/
|
|
1239
|
-
updateSearchResults(): void {
|
|
1240
|
-
const countEl = getElementByIdOrNull<HTMLElement>('search-count');
|
|
1241
|
-
if (!countEl) {
|
|
1242
|
-
return;
|
|
1243
|
-
}
|
|
1244
|
-
|
|
1245
|
-
if (this.searchMatches.length > 0) {
|
|
1246
|
-
countEl.textContent = `${this.currentSearchIndex + 1} / ${this.searchMatches.length}`;
|
|
1247
|
-
countEl.style.display = 'inline';
|
|
1248
|
-
} else {
|
|
1249
|
-
countEl.textContent = 'No results';
|
|
1250
|
-
countEl.style.display = 'inline';
|
|
1251
|
-
}
|
|
1252
|
-
}
|
|
1253
|
-
|
|
1254
|
-
/**
|
|
1255
|
-
* Highlight search results
|
|
1256
|
-
*/
|
|
1257
|
-
highlightSearchResults(): void {
|
|
1258
|
-
if (!this.network) {
|
|
1259
|
-
return;
|
|
1260
|
-
}
|
|
1261
|
-
|
|
1262
|
-
const matchIds = this.searchMatches.map((n) => n.id);
|
|
1263
|
-
const allNodes = this.network.body.data.nodes.get();
|
|
1264
|
-
|
|
1265
|
-
allNodes.forEach((node) => {
|
|
1266
|
-
const isMatch = matchIds.includes(String(node.id));
|
|
1267
|
-
this.network.body.data.nodes.update({
|
|
1268
|
-
id: node.id,
|
|
1269
|
-
opacity: isMatch ? 1.0 : 0.2,
|
|
1270
|
-
font: { ...node.font, color: isMatch ? '#131313' : '#999' },
|
|
1271
|
-
});
|
|
1272
|
-
});
|
|
1273
|
-
}
|
|
1274
|
-
|
|
1275
|
-
/**
|
|
1276
|
-
* Clear search
|
|
1277
|
-
*/
|
|
1278
|
-
clearSearch(): void {
|
|
1279
|
-
this.searchMatches = [];
|
|
1280
|
-
this.currentSearchIndex = 0;
|
|
1281
|
-
this.resetNodeHighlight();
|
|
1282
|
-
|
|
1283
|
-
const countEl = getElementByIdOrNull<HTMLElement>('search-count');
|
|
1284
|
-
if (countEl) {
|
|
1285
|
-
countEl.style.display = 'none';
|
|
1286
|
-
}
|
|
1287
|
-
}
|
|
1288
|
-
|
|
1289
|
-
/**
|
|
1290
|
-
* Open search panel
|
|
1291
|
-
*/
|
|
1292
|
-
openSearch(): void {
|
|
1293
|
-
const searchContainer = getElementByIdOrNull<HTMLElement>('search-container');
|
|
1294
|
-
const searchInput = getElementByIdOrNull<HTMLInputElement>('search-input');
|
|
1295
|
-
|
|
1296
|
-
if (!searchContainer || !searchInput) {
|
|
1297
|
-
return;
|
|
1298
|
-
}
|
|
1299
|
-
searchContainer.style.display = 'flex';
|
|
1300
|
-
searchInput.focus();
|
|
1301
|
-
}
|
|
1302
|
-
|
|
1303
|
-
/**
|
|
1304
|
-
* Close search panel
|
|
1305
|
-
*/
|
|
1306
|
-
closeSearch(): void {
|
|
1307
|
-
const searchContainer = getElementByIdOrNull<HTMLElement>('search-container');
|
|
1308
|
-
if (!searchContainer) {
|
|
1309
|
-
return;
|
|
360
|
+
if (request !== this.detailRequest) return;
|
|
361
|
+
logger.error('Failed to read record detail:', error);
|
|
362
|
+
decisionEl.textContent = `Could not read this record: ${
|
|
363
|
+
error instanceof Error ? error.message : String(error)
|
|
364
|
+
}`;
|
|
1310
365
|
}
|
|
1311
|
-
searchContainer.style.display = 'none';
|
|
1312
|
-
this.clearSearch();
|
|
1313
366
|
}
|
|
1314
367
|
}
|