@unson/brainbase-mcp 0.9.0 → 0.10.1
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/dist/local-web-host.js +6 -0
- package/package.json +12 -4
- package/ui/graph-projects-view.css +66 -0
- package/ui/graph-projects-view.js +126 -10
- package/ui/project-graph-entry.js +293 -0
- package/ui/project-graph-vendor.LICENSE.txt +109 -0
- package/ui/project-graph-vendor.js +312 -0
- package/ui/project-graph.js +358 -0
- package/ui/project-workspace.css +491 -0
- package/ui/project-workspace.js +727 -0
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Small, shared project graph contract.
|
|
3
|
+
*
|
|
4
|
+
* This module deliberately contains no Sigma or DOM imports. The OSS screen
|
|
5
|
+
* can therefore use the projection and layout in tests, while the browser
|
|
6
|
+
* entry loads the bundled renderer only when a graph is mounted.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const PROJECT_GRAPH_CONTRACT_VERSION = 'brainbase.project-graph.v1';
|
|
10
|
+
|
|
11
|
+
const TYPE_ORDER = Object.freeze(['project', 'decision', 'person', 'org']);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* One colour per semantic type keeps a graph readable when it contains more
|
|
15
|
+
* than the four types known by an older host. Unknown types intentionally
|
|
16
|
+
* remain visible and use the neutral colour.
|
|
17
|
+
*/
|
|
18
|
+
export const PROJECT_GRAPH_TYPE_COLORS = Object.freeze({
|
|
19
|
+
project: '#4f46e5',
|
|
20
|
+
decision: '#b45309',
|
|
21
|
+
person: '#0f766e',
|
|
22
|
+
org: '#166534',
|
|
23
|
+
unknown: '#64748b',
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const EMPTY_ARRAY = Object.freeze([]);
|
|
27
|
+
const MAX_SELECTED_NODE_SIZE = 18;
|
|
28
|
+
const MAX_NEIGHBOR_NODE_SIZE = 12;
|
|
29
|
+
const MAX_UNRELATED_NODE_SIZE = 10;
|
|
30
|
+
|
|
31
|
+
function isRecord(value) {
|
|
32
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function stringValue(value) {
|
|
36
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function finitePositive(value, fallback) {
|
|
40
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function typeRank(type) {
|
|
44
|
+
const index = TYPE_ORDER.indexOf(type);
|
|
45
|
+
return index === -1 ? TYPE_ORDER.length : index;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function stableCompare(left, right) {
|
|
49
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function compareNodes(left, right) {
|
|
53
|
+
const type = typeRank(left.type) - typeRank(right.type);
|
|
54
|
+
if (type) return type;
|
|
55
|
+
const byType = stableCompare(left.type, right.type);
|
|
56
|
+
if (byType) return byType;
|
|
57
|
+
const label = stableCompare(left.label, right.label);
|
|
58
|
+
if (label) return label;
|
|
59
|
+
return stableCompare(left.id, right.id);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function compareEdges(left, right) {
|
|
63
|
+
const source = stableCompare(left.source, right.source);
|
|
64
|
+
if (source) return source;
|
|
65
|
+
const target = stableCompare(left.target, right.target);
|
|
66
|
+
if (target) return target;
|
|
67
|
+
return stableCompare(left.id, right.id);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function uniqueKey(candidate, used) {
|
|
71
|
+
const base = candidate || 'edge';
|
|
72
|
+
if (!used.has(base)) {
|
|
73
|
+
used.add(base);
|
|
74
|
+
return base;
|
|
75
|
+
}
|
|
76
|
+
let suffix = 2;
|
|
77
|
+
while (used.has(`${base}~${suffix}`)) suffix += 1;
|
|
78
|
+
const key = `${base}~${suffix}`;
|
|
79
|
+
used.add(key);
|
|
80
|
+
return key;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Converts host data into the small shape consumed by Sigma.
|
|
85
|
+
*
|
|
86
|
+
* Invalid records are reported under `issues` and omitted. In particular an
|
|
87
|
+
* edge whose source or target is not present is never used to invent a node.
|
|
88
|
+
* The first occurrence of a node id wins, preserving the host's order while
|
|
89
|
+
* keeping the result deterministic.
|
|
90
|
+
*/
|
|
91
|
+
export function normalizeProjectGraph(input = {}) {
|
|
92
|
+
const rawNodes = Array.isArray(input?.nodes) ? input.nodes : EMPTY_ARRAY;
|
|
93
|
+
const rawEdges = Array.isArray(input?.edges) ? input.edges : EMPTY_ARRAY;
|
|
94
|
+
const nodes = [];
|
|
95
|
+
const nodeIds = new Set();
|
|
96
|
+
const issues = [];
|
|
97
|
+
|
|
98
|
+
rawNodes.forEach((raw, index) => {
|
|
99
|
+
const id = isRecord(raw) ? stringValue(raw.id) : null;
|
|
100
|
+
if (!id) {
|
|
101
|
+
issues.push({ kind: 'invalid_node', index, reason: 'id_required' });
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (nodeIds.has(id)) {
|
|
105
|
+
issues.push({ kind: 'duplicate_node', index, id, reason: 'id_already_seen' });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
nodeIds.add(id);
|
|
109
|
+
const type = stringValue(raw.type) || 'unknown';
|
|
110
|
+
const label = stringValue(raw.label) || id;
|
|
111
|
+
nodes.push({
|
|
112
|
+
id,
|
|
113
|
+
label,
|
|
114
|
+
type,
|
|
115
|
+
color: stringValue(raw.color) || PROJECT_GRAPH_TYPE_COLORS[type] || PROJECT_GRAPH_TYPE_COLORS.unknown,
|
|
116
|
+
size: finitePositive(raw.size, type === 'project' ? 13 : 9),
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const edges = [];
|
|
121
|
+
const edgeIds = new Set();
|
|
122
|
+
rawEdges.forEach((raw, index) => {
|
|
123
|
+
const source = isRecord(raw) ? stringValue(raw.source) : null;
|
|
124
|
+
const target = isRecord(raw) ? stringValue(raw.target) : null;
|
|
125
|
+
const requestedId = isRecord(raw) ? stringValue(raw.id) : null;
|
|
126
|
+
const issue = !source
|
|
127
|
+
? 'source_required'
|
|
128
|
+
: !target
|
|
129
|
+
? 'target_required'
|
|
130
|
+
: !nodeIds.has(source)
|
|
131
|
+
? 'source_not_found'
|
|
132
|
+
: !nodeIds.has(target)
|
|
133
|
+
? 'target_not_found'
|
|
134
|
+
: null;
|
|
135
|
+
if (issue) {
|
|
136
|
+
issues.push({ kind: 'invalid_edge', index, id: requestedId, source, target, reason: issue });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const id = uniqueKey(requestedId || `edge-${index + 1}`, edgeIds);
|
|
140
|
+
edges.push({
|
|
141
|
+
id,
|
|
142
|
+
originalId: requestedId || id,
|
|
143
|
+
source,
|
|
144
|
+
target,
|
|
145
|
+
label: stringValue(raw.label) || '',
|
|
146
|
+
color: stringValue(raw.color) || '#cbd5e1',
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const sortedNodes = nodes.slice().sort(compareNodes);
|
|
151
|
+
const sortedEdges = edges.slice().sort(compareEdges);
|
|
152
|
+
return {
|
|
153
|
+
nodes: sortedNodes,
|
|
154
|
+
edges: sortedEdges,
|
|
155
|
+
issues,
|
|
156
|
+
invalidNodeCount: issues.filter((issue) => issue.kind === 'invalid_node' || issue.kind === 'duplicate_node').length,
|
|
157
|
+
invalidEdgeCount: issues.filter((issue) => issue.kind === 'invalid_edge').length,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Returns the ids attached to `selectedId`, including only real endpoints. */
|
|
162
|
+
export function projectGraphNeighborIds(nodes, edges, selectedId) {
|
|
163
|
+
const ids = new Set((Array.isArray(nodes) ? nodes : EMPTY_ARRAY).map((node) => node?.id).filter(Boolean));
|
|
164
|
+
if (!selectedId || !ids.has(selectedId)) return new Set();
|
|
165
|
+
const neighbors = new Set();
|
|
166
|
+
for (const edge of Array.isArray(edges) ? edges : EMPTY_ARRAY) {
|
|
167
|
+
if (!ids.has(edge?.source) || !ids.has(edge?.target)) continue;
|
|
168
|
+
if (edge.source === selectedId) neighbors.add(edge.target);
|
|
169
|
+
if (edge.target === selectedId) neighbors.add(edge.source);
|
|
170
|
+
}
|
|
171
|
+
return neighbors;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function toIdSet(value) {
|
|
175
|
+
return value instanceof Set
|
|
176
|
+
? value
|
|
177
|
+
: new Set(Array.isArray(value) ? value.filter(Boolean) : EMPTY_ARRAY);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Returns the visual state for one node in a selected graph neighborhood.
|
|
182
|
+
*
|
|
183
|
+
* A selected node is the only node that bypasses Sigma's label collision
|
|
184
|
+
* avoidance. Neighbors keep their labels available to Sigma's normal label
|
|
185
|
+
* grid, while their size and hover state stay bounded in dense neighborhoods.
|
|
186
|
+
*/
|
|
187
|
+
export function projectGraphNodePresentation(node, { selectedId = null, neighborIds = EMPTY_ARRAY } = {}) {
|
|
188
|
+
const id = stringValue(node?.id);
|
|
189
|
+
const neighbors = toIdSet(neighborIds);
|
|
190
|
+
const state = id && id === selectedId
|
|
191
|
+
? 'selected'
|
|
192
|
+
: id && neighbors.has(id)
|
|
193
|
+
? 'neighbor'
|
|
194
|
+
: 'unrelated';
|
|
195
|
+
const baseSize = Math.min(finitePositive(node?.size, 9), MAX_SELECTED_NODE_SIZE);
|
|
196
|
+
|
|
197
|
+
if (state === 'selected') {
|
|
198
|
+
return {
|
|
199
|
+
state,
|
|
200
|
+
size: Math.min(MAX_SELECTED_NODE_SIZE, Math.max(10, baseSize + 3)),
|
|
201
|
+
forceLabel: true,
|
|
202
|
+
highlighted: true,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (state === 'neighbor') {
|
|
207
|
+
return {
|
|
208
|
+
state,
|
|
209
|
+
size: Math.min(MAX_NEIGHBOR_NODE_SIZE, Math.max(6, baseSize)),
|
|
210
|
+
forceLabel: false,
|
|
211
|
+
highlighted: false,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
state,
|
|
217
|
+
size: Math.min(MAX_UNRELATED_NODE_SIZE, Math.max(4, baseSize * 0.72)),
|
|
218
|
+
label: null,
|
|
219
|
+
forceLabel: false,
|
|
220
|
+
highlighted: false,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Returns the label policy for one edge in a selected graph neighborhood.
|
|
226
|
+
* Dense neighborhoods omit edge labels; relation details remain available
|
|
227
|
+
* in the selected record panel. Small neighborhoods show relevant labels.
|
|
228
|
+
*/
|
|
229
|
+
export function projectGraphEdgePresentation(edge, { selectedId = null, neighborIds = EMPTY_ARRAY } = {}) {
|
|
230
|
+
const neighbors = toIdSet(neighborIds);
|
|
231
|
+
const relevant = Boolean(
|
|
232
|
+
selectedId && edge && (
|
|
233
|
+
edge.source === selectedId ||
|
|
234
|
+
edge.target === selectedId ||
|
|
235
|
+
(neighbors.has(edge.source) && neighbors.has(edge.target))
|
|
236
|
+
),
|
|
237
|
+
);
|
|
238
|
+
// Sigma's edge-label renderer does not apply the same collision grid as
|
|
239
|
+
// node labels. Hide labels for a dense neighborhood and leave the relation
|
|
240
|
+
// available through selection/details instead of painting a radial stack.
|
|
241
|
+
const denseNeighborhood = neighbors.size > 12;
|
|
242
|
+
return {
|
|
243
|
+
relevant,
|
|
244
|
+
showLabel: relevant && !denseNeighborhood && Boolean(stringValue(edge?.label)),
|
|
245
|
+
forceLabel: false,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Computes a stable clustered layout. The selected node is the anchor when
|
|
251
|
+
* it exists; its direct neighbours form the first ring and the remaining
|
|
252
|
+
* nodes are grouped by their declared type. No random seed or force solver
|
|
253
|
+
* is involved, so the same Graph snapshot produces the same positions.
|
|
254
|
+
*/
|
|
255
|
+
export function computeProjectGraphLayout(nodes, edges = EMPTY_ARRAY, { selectedId = null } = {}) {
|
|
256
|
+
const list = (Array.isArray(nodes) ? nodes : EMPTY_ARRAY).filter((node) => stringValue(node?.id));
|
|
257
|
+
if (!list.length) return {};
|
|
258
|
+
const byId = new Map(list.map((node) => [node.id, node]));
|
|
259
|
+
const anchorId = selectedId && byId.has(selectedId) ? selectedId : list.slice().sort(compareNodes)[0].id;
|
|
260
|
+
const neighborIds = projectGraphNeighborIds(list, edges, anchorId);
|
|
261
|
+
// A null-prototype map keeps a host supplied id such as "__proto__" from
|
|
262
|
+
// changing the layout object itself.
|
|
263
|
+
const positions = Object.create(null);
|
|
264
|
+
positions[anchorId] = { x: 0, y: 0 };
|
|
265
|
+
|
|
266
|
+
const neighbors = list
|
|
267
|
+
.filter((node) => neighborIds.has(node.id))
|
|
268
|
+
.sort(compareNodes);
|
|
269
|
+
// Keep high-degree selections readable by spreading neighbors over stable
|
|
270
|
+
// concentric rings. A single ring turns a person with dozens of relations
|
|
271
|
+
// into a dense necklace even when Sigma's label grid is working correctly.
|
|
272
|
+
const neighborRingCount = neighbors.length ? Math.ceil(neighbors.length / 24) : 0;
|
|
273
|
+
const neighborRingRadius = 2.6;
|
|
274
|
+
const neighborRingGap = 1.8;
|
|
275
|
+
let neighborOffset = 0;
|
|
276
|
+
for (let ringIndex = 0; ringIndex < neighborRingCount; ringIndex += 1) {
|
|
277
|
+
const remaining = neighbors.length - neighborOffset;
|
|
278
|
+
const ringsLeft = neighborRingCount - ringIndex;
|
|
279
|
+
const ringSize = Math.ceil(remaining / ringsLeft);
|
|
280
|
+
const radius = neighborRingRadius + ringIndex * neighborRingGap;
|
|
281
|
+
for (let index = 0; index < ringSize; index += 1) {
|
|
282
|
+
const angle = -Math.PI / 2 + (2 * Math.PI * index) / Math.max(1, ringSize) + ringIndex * 0.19;
|
|
283
|
+
const node = neighbors[neighborOffset + index];
|
|
284
|
+
positions[node.id] = { x: Math.cos(angle) * radius, y: Math.sin(angle) * radius };
|
|
285
|
+
}
|
|
286
|
+
neighborOffset += ringSize;
|
|
287
|
+
}
|
|
288
|
+
const neighborRadius = neighborRingCount
|
|
289
|
+
? neighborRingRadius + (neighborRingCount - 1) * neighborRingGap
|
|
290
|
+
: 1.5;
|
|
291
|
+
|
|
292
|
+
const remaining = list.filter((node) => !positions[node.id]);
|
|
293
|
+
const groups = new Map();
|
|
294
|
+
for (const node of remaining) {
|
|
295
|
+
const group = groups.get(node.type) || [];
|
|
296
|
+
group.push(node);
|
|
297
|
+
groups.set(node.type, group);
|
|
298
|
+
}
|
|
299
|
+
const groupTypes = Array.from(groups.keys()).sort((left, right) => {
|
|
300
|
+
const rank = typeRank(left) - typeRank(right);
|
|
301
|
+
return rank || stableCompare(left, right);
|
|
302
|
+
});
|
|
303
|
+
const groupRadius = Math.max(neighborRadius + 1.15, 2.25);
|
|
304
|
+
groupTypes.forEach((type, groupIndex) => {
|
|
305
|
+
const group = groups.get(type).sort(compareNodes);
|
|
306
|
+
const angle = -Math.PI / 2 + (2 * Math.PI * groupIndex) / Math.max(1, groupTypes.length);
|
|
307
|
+
const centerX = Math.cos(angle) * groupRadius;
|
|
308
|
+
const centerY = Math.sin(angle) * groupRadius;
|
|
309
|
+
const innerRadius = Math.max(0.35, 0.28 + group.length * 0.1);
|
|
310
|
+
group.forEach((node, index) => {
|
|
311
|
+
const nodeAngle = (2 * Math.PI * index) / Math.max(1, group.length) + angle;
|
|
312
|
+
positions[node.id] = {
|
|
313
|
+
x: centerX + Math.cos(nodeAngle) * innerRadius,
|
|
314
|
+
y: centerY + Math.sin(nodeAngle) * innerRadius,
|
|
315
|
+
};
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
return positions;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Creates the fully positioned graph model used by the browser entry. */
|
|
322
|
+
export function createProjectGraphModel(input = {}, options = {}) {
|
|
323
|
+
const normalized = normalizeProjectGraph(input);
|
|
324
|
+
const positions = computeProjectGraphLayout(normalized.nodes, normalized.edges, options);
|
|
325
|
+
return {
|
|
326
|
+
...normalized,
|
|
327
|
+
nodes: normalized.nodes.map((node) => ({ ...node, ...positions[node.id] })),
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Lazy browser entry. Keeping the renderer behind this boundary means the
|
|
333
|
+
* local Graph screen can import this module in Node-based UI tests without
|
|
334
|
+
* evaluating WebGL or browser globals.
|
|
335
|
+
*/
|
|
336
|
+
export async function mountProjectGraph(container, options = {}) {
|
|
337
|
+
if (options?.signal?.aborted) throw abortError();
|
|
338
|
+
let entry;
|
|
339
|
+
try {
|
|
340
|
+
entry = await import('./project-graph-entry.js');
|
|
341
|
+
} catch (error) {
|
|
342
|
+
// This includes browsers that expose no WebGL2/WebGL globals while the
|
|
343
|
+
// Sigma vendor module is evaluated. Hosts can use the code to switch to
|
|
344
|
+
// their accessible list fallback without swallowing the cause.
|
|
345
|
+
if (error && typeof error === 'object' && !error.code) error.code = 'project_graph_renderer_unavailable';
|
|
346
|
+
throw error;
|
|
347
|
+
}
|
|
348
|
+
if (options?.signal?.aborted) throw abortError();
|
|
349
|
+
return entry.mountProjectGraph(container, options);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function abortError() {
|
|
353
|
+
const error = new Error('project_graph_mount_aborted');
|
|
354
|
+
error.name = 'AbortError';
|
|
355
|
+
return error;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export default createProjectGraphModel;
|