@unbrained/pm-web 1.0.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 +7 -0
- package/README.md +107 -0
- package/dist/auth.js +20 -0
- package/dist/auth.js.map +1 -0
- package/dist/crypto.js +42 -0
- package/dist/crypto.js.map +1 -0
- package/dist/db.js +111 -0
- package/dist/db.js.map +1 -0
- package/dist/index.js +88 -0
- package/dist/index.js.map +1 -0
- package/dist/middleware/auth.js +16 -0
- package/dist/middleware/auth.js.map +1 -0
- package/dist/routes/admin.js +207 -0
- package/dist/routes/admin.js.map +1 -0
- package/dist/routes/auth.js +163 -0
- package/dist/routes/auth.js.map +1 -0
- package/dist/routes/github.js +354 -0
- package/dist/routes/github.js.map +1 -0
- package/dist/routes/groups.js +180 -0
- package/dist/routes/groups.js.map +1 -0
- package/dist/routes/pm.js +2446 -0
- package/dist/routes/pm.js.map +1 -0
- package/dist/routes/projects.js +151 -0
- package/dist/routes/projects.js.map +1 -0
- package/dist/routes/sharing.js +155 -0
- package/dist/routes/sharing.js.map +1 -0
- package/dist/server.js +64 -0
- package/dist/server.js.map +1 -0
- package/dist/services/pm-runner.js +190 -0
- package/dist/services/pm-runner.js.map +1 -0
- package/dist/services/sse.js +111 -0
- package/dist/services/sse.js.map +1 -0
- package/manifest.json +15 -0
- package/package.json +111 -0
- package/public/icons/icon-192.png +0 -0
- package/public/icons/icon-512.png +0 -0
- package/public/index.html +265 -0
- package/public/manifest.json +66 -0
- package/public/src/api.js +28 -0
- package/public/src/api.js.map +1 -0
- package/public/src/api.ts +29 -0
- package/public/src/app.js +926 -0
- package/public/src/app.js.map +1 -0
- package/public/src/app.ts +929 -0
- package/public/src/components/modals.js +62 -0
- package/public/src/components/modals.js.map +1 -0
- package/public/src/components/modals.ts +73 -0
- package/public/src/components/toast.js +10 -0
- package/public/src/components/toast.js.map +1 -0
- package/public/src/components/toast.ts +13 -0
- package/public/src/constants.js +30 -0
- package/public/src/constants.js.map +1 -0
- package/public/src/constants.ts +41 -0
- package/public/src/state.js +15 -0
- package/public/src/state.js.map +1 -0
- package/public/src/state.ts +19 -0
- package/public/src/types.js +5 -0
- package/public/src/types.js.map +1 -0
- package/public/src/types.ts +253 -0
- package/public/src/utils.js +57 -0
- package/public/src/utils.js.map +1 -0
- package/public/src/utils.ts +56 -0
- package/public/src/views/activity.js +47 -0
- package/public/src/views/activity.js.map +1 -0
- package/public/src/views/activity.ts +41 -0
- package/public/src/views/admin.js +435 -0
- package/public/src/views/admin.js.map +1 -0
- package/public/src/views/admin.ts +504 -0
- package/public/src/views/auth.js +81 -0
- package/public/src/views/auth.js.map +1 -0
- package/public/src/views/auth.ts +74 -0
- package/public/src/views/calendar.js +133 -0
- package/public/src/views/calendar.js.map +1 -0
- package/public/src/views/calendar.ts +129 -0
- package/public/src/views/comments-audit.js +109 -0
- package/public/src/views/comments-audit.js.map +1 -0
- package/public/src/views/comments-audit.ts +108 -0
- package/public/src/views/config.js +322 -0
- package/public/src/views/config.js.map +1 -0
- package/public/src/views/config.ts +344 -0
- package/public/src/views/context.js +98 -0
- package/public/src/views/context.js.map +1 -0
- package/public/src/views/context.ts +100 -0
- package/public/src/views/create.js +293 -0
- package/public/src/views/create.js.map +1 -0
- package/public/src/views/create.ts +246 -0
- package/public/src/views/dedupe.js +51 -0
- package/public/src/views/dedupe.js.map +1 -0
- package/public/src/views/dedupe.ts +43 -0
- package/public/src/views/export.js +300 -0
- package/public/src/views/export.js.map +1 -0
- package/public/src/views/export.ts +274 -0
- package/public/src/views/github.js +360 -0
- package/public/src/views/github.js.map +1 -0
- package/public/src/views/github.ts +308 -0
- package/public/src/views/graph-canvas.js +1986 -0
- package/public/src/views/graph-canvas.js.map +1 -0
- package/public/src/views/graph-canvas.ts +2218 -0
- package/public/src/views/graph.js +1824 -0
- package/public/src/views/graph.js.map +1 -0
- package/public/src/views/graph.ts +1891 -0
- package/public/src/views/groups.js +186 -0
- package/public/src/views/groups.js.map +1 -0
- package/public/src/views/groups.ts +172 -0
- package/public/src/views/guide.js +151 -0
- package/public/src/views/guide.js.map +1 -0
- package/public/src/views/guide.ts +162 -0
- package/public/src/views/health.js +105 -0
- package/public/src/views/health.js.map +1 -0
- package/public/src/views/health.ts +102 -0
- package/public/src/views/items.js +1306 -0
- package/public/src/views/items.js.map +1 -0
- package/public/src/views/items.ts +1196 -0
- package/public/src/views/normalize.js +67 -0
- package/public/src/views/normalize.js.map +1 -0
- package/public/src/views/normalize.ts +58 -0
- package/public/src/views/plan.js +454 -0
- package/public/src/views/plan.js.map +1 -0
- package/public/src/views/plan.ts +496 -0
- package/public/src/views/projects.js +204 -0
- package/public/src/views/projects.js.map +1 -0
- package/public/src/views/projects.ts +196 -0
- package/public/src/views/router.js +227 -0
- package/public/src/views/router.js.map +1 -0
- package/public/src/views/router.ts +188 -0
- package/public/src/views/search.js +103 -0
- package/public/src/views/search.js.map +1 -0
- package/public/src/views/search.ts +94 -0
- package/public/src/views/settings.js +272 -0
- package/public/src/views/settings.js.map +1 -0
- package/public/src/views/settings.ts +190 -0
- package/public/src/views/shared.js +49 -0
- package/public/src/views/shared.js.map +1 -0
- package/public/src/views/shared.ts +49 -0
- package/public/src/views/sharing.js +152 -0
- package/public/src/views/sharing.js.map +1 -0
- package/public/src/views/sharing.ts +139 -0
- package/public/src/views/stats.js +92 -0
- package/public/src/views/stats.js.map +1 -0
- package/public/src/views/stats.ts +88 -0
- package/public/src/views/templates.js +117 -0
- package/public/src/views/templates.js.map +1 -0
- package/public/src/views/templates.ts +113 -0
- package/public/src/views/validate.js +54 -0
- package/public/src/views/validate.js.map +1 -0
- package/public/src/views/validate.ts +48 -0
- package/public/styles.css +2231 -0
- package/public/sw.js +318 -0
- package/public/tsconfig.json +20 -0
- package/sql/schema.sql +105 -0
|
@@ -0,0 +1,1824 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════
|
|
2
|
+
// GRAPH VIEW — Obsidian-quality immersive knowledge & dependency graph
|
|
3
|
+
// ═══════════════════════════════════════════════════════════════
|
|
4
|
+
import { api } from '../api.js';
|
|
5
|
+
import { state } from '../state.js';
|
|
6
|
+
import { escHtml } from '../utils.js';
|
|
7
|
+
import { toast } from '../components/toast.js';
|
|
8
|
+
import { GraphCanvas } from './graph-canvas.js';
|
|
9
|
+
// ── Module state ──────────────────────────────────────────────
|
|
10
|
+
let currentGraph = null;
|
|
11
|
+
let selectedNodeId = '';
|
|
12
|
+
const canvasRef = { current: null };
|
|
13
|
+
let physicsLabel = 'Pause Physics';
|
|
14
|
+
let graphSyncInFlight = false;
|
|
15
|
+
let infoDrawerOpen = false;
|
|
16
|
+
let relDrawerOpen = false;
|
|
17
|
+
let filterOpen = false;
|
|
18
|
+
let physicsOpen = false;
|
|
19
|
+
let filter = {
|
|
20
|
+
query: '',
|
|
21
|
+
kind: 'items',
|
|
22
|
+
rel: 'all',
|
|
23
|
+
direction: 'all',
|
|
24
|
+
scope: 'all',
|
|
25
|
+
depth: '1',
|
|
26
|
+
colorMode: 'status',
|
|
27
|
+
depMode: false,
|
|
28
|
+
layout: 'force',
|
|
29
|
+
edgeBundling: false,
|
|
30
|
+
statusFilter: '',
|
|
31
|
+
};
|
|
32
|
+
let selectedItemCache = null;
|
|
33
|
+
let criticalPath = new Set();
|
|
34
|
+
const DEP_REL_TYPES = new Set([
|
|
35
|
+
'DEPENDS_ON',
|
|
36
|
+
'BLOCKED_BY',
|
|
37
|
+
'BLOCKS',
|
|
38
|
+
'PARENT',
|
|
39
|
+
'PARENT_OF',
|
|
40
|
+
'CHILD',
|
|
41
|
+
'CHILD_OF',
|
|
42
|
+
'RELATED',
|
|
43
|
+
'RELATES_TO',
|
|
44
|
+
]);
|
|
45
|
+
// ── Context menu ──────────────────────────────────────────────
|
|
46
|
+
let ctxMenuEl = null;
|
|
47
|
+
function removeCtxMenu() {
|
|
48
|
+
if (ctxMenuEl) {
|
|
49
|
+
ctxMenuEl.remove();
|
|
50
|
+
ctxMenuEl = null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function showCtxMenu(nodeId, x, y) {
|
|
54
|
+
removeCtxMenu();
|
|
55
|
+
const graph = currentGraph?.graph || {};
|
|
56
|
+
const nodes = graph.nodes || [];
|
|
57
|
+
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
58
|
+
const node = byId.get(nodeId);
|
|
59
|
+
const isItem = node ? isItemNode(node) : false;
|
|
60
|
+
const menu = document.createElement('div');
|
|
61
|
+
menu.className = 'graph-ctx-menu';
|
|
62
|
+
menu.style.left = `${Math.min(x, window.innerWidth - 180)}px`;
|
|
63
|
+
menu.style.top = `${Math.min(y, window.innerHeight - 160)}px`;
|
|
64
|
+
const btn = (icon, label, action, danger = false) => {
|
|
65
|
+
const b = document.createElement('button');
|
|
66
|
+
b.className = 'graph-ctx-item' + (danger ? ' danger' : '');
|
|
67
|
+
b.innerHTML = `<span style="opacity:0.6;font-size:11px">${icon}</span>${escHtml(label)}`;
|
|
68
|
+
b.addEventListener('click', () => { removeCtxMenu(); action(); });
|
|
69
|
+
return b;
|
|
70
|
+
};
|
|
71
|
+
if (isItem) {
|
|
72
|
+
menu.appendChild(btn('⊡', 'Open Item', () => window.__app.openItemDetail(nodeId)));
|
|
73
|
+
const sep1 = document.createElement('div');
|
|
74
|
+
sep1.className = 'graph-ctx-sep';
|
|
75
|
+
menu.appendChild(sep1);
|
|
76
|
+
}
|
|
77
|
+
menu.appendChild(btn('⊙', 'Select & Focus', () => {
|
|
78
|
+
selectedNodeId = nodeId;
|
|
79
|
+
filter = { ...filter, scope: 'focus' };
|
|
80
|
+
canvasRef.current?.setSelected(nodeId);
|
|
81
|
+
updateInfoPanel();
|
|
82
|
+
syncCanvas();
|
|
83
|
+
updateFilterToolbarState();
|
|
84
|
+
if (!infoDrawerOpen) {
|
|
85
|
+
infoDrawerOpen = true;
|
|
86
|
+
document.getElementById('graph-info-drawer')?.classList.add('open');
|
|
87
|
+
document.getElementById('graph-info-toggle')?.classList.add('active');
|
|
88
|
+
}
|
|
89
|
+
}));
|
|
90
|
+
menu.appendChild(btn('⊕', 'Show Neighborhood', () => {
|
|
91
|
+
selectedNodeId = nodeId;
|
|
92
|
+
filter = { ...filter, scope: 'focus', depth: '1' };
|
|
93
|
+
canvasRef.current?.setSelected(nodeId);
|
|
94
|
+
updateInfoPanel();
|
|
95
|
+
syncCanvas();
|
|
96
|
+
updateFilterToolbarState();
|
|
97
|
+
}));
|
|
98
|
+
menu.appendChild(btn('⊛', 'Expand 2 Hops', () => {
|
|
99
|
+
selectedNodeId = nodeId;
|
|
100
|
+
filter = { ...filter, scope: 'focus', depth: '2' };
|
|
101
|
+
canvasRef.current?.setSelected(nodeId);
|
|
102
|
+
updateInfoPanel();
|
|
103
|
+
syncCanvas();
|
|
104
|
+
updateFilterToolbarState();
|
|
105
|
+
}));
|
|
106
|
+
const sep2 = document.createElement('div');
|
|
107
|
+
sep2.className = 'graph-ctx-sep';
|
|
108
|
+
menu.appendChild(sep2);
|
|
109
|
+
menu.appendChild(btn('⊞', 'Copy ID', () => { void navigator.clipboard?.writeText(nodeId); }));
|
|
110
|
+
document.body.appendChild(menu);
|
|
111
|
+
ctxMenuEl = menu;
|
|
112
|
+
const dismiss = (ev) => {
|
|
113
|
+
if (!menu.contains(ev.target)) {
|
|
114
|
+
removeCtxMenu();
|
|
115
|
+
document.removeEventListener('mousedown', dismiss);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
setTimeout(() => document.addEventListener('mousedown', dismiss), 0);
|
|
119
|
+
}
|
|
120
|
+
// ── Node helpers ──────────────────────────────────────────────
|
|
121
|
+
function nodeTitle(node) {
|
|
122
|
+
return String(node.properties?.title || node.id);
|
|
123
|
+
}
|
|
124
|
+
function nodeType(node) {
|
|
125
|
+
return String(node.properties?.kind
|
|
126
|
+
|| node.properties?.type
|
|
127
|
+
|| node.labels?.find((l) => l !== 'PmItem' && l !== 'PmFacet')
|
|
128
|
+
|| 'Item');
|
|
129
|
+
}
|
|
130
|
+
function nodeStatus(node) {
|
|
131
|
+
return String(node.properties?.status || 'unknown');
|
|
132
|
+
}
|
|
133
|
+
function isItemNode(node) {
|
|
134
|
+
return Boolean(node.labels?.includes('PmItem') || !node.id.includes(':'));
|
|
135
|
+
}
|
|
136
|
+
function isFacetNode(node) {
|
|
137
|
+
return Boolean(node.labels?.includes('PmFacet'));
|
|
138
|
+
}
|
|
139
|
+
function nodeLane(node) {
|
|
140
|
+
if (isFacetNode(node))
|
|
141
|
+
return 'facet';
|
|
142
|
+
if (node.labels?.includes('ExternalPmItem'))
|
|
143
|
+
return 'external';
|
|
144
|
+
return 'item';
|
|
145
|
+
}
|
|
146
|
+
function compactError(raw) {
|
|
147
|
+
if (!raw)
|
|
148
|
+
return '';
|
|
149
|
+
try {
|
|
150
|
+
const parsed = JSON.parse(raw);
|
|
151
|
+
const message = parsed.detail || parsed.title || parsed.code || '';
|
|
152
|
+
return message.includes('does not expose command path "pm-graph"') ? '' : message;
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
const message = raw.replace(/\s+/g, ' ').trim();
|
|
156
|
+
return message.includes('does not expose command path "pm-graph"') ? '' : message;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// ── Graph data processing ─────────────────────────────────────
|
|
160
|
+
function directNeighborIds(nodeId, rels) {
|
|
161
|
+
const ids = new Set([nodeId]);
|
|
162
|
+
for (const r of rels) {
|
|
163
|
+
if (r.from === nodeId)
|
|
164
|
+
ids.add(r.to);
|
|
165
|
+
if (r.to === nodeId)
|
|
166
|
+
ids.add(r.from);
|
|
167
|
+
}
|
|
168
|
+
return ids;
|
|
169
|
+
}
|
|
170
|
+
function walkDirectionMatch(rel, nodeId, dir) {
|
|
171
|
+
if (dir === 'incoming') {
|
|
172
|
+
return rel.to === nodeId ? [rel.from] : [];
|
|
173
|
+
}
|
|
174
|
+
if (dir === 'outgoing') {
|
|
175
|
+
return rel.from === nodeId ? [rel.to] : [];
|
|
176
|
+
}
|
|
177
|
+
return rel.to === nodeId ? [rel.from] : rel.from === nodeId ? [rel.to] : [];
|
|
178
|
+
}
|
|
179
|
+
function expandedNeighborIds(nodeId, rels, depth, direction) {
|
|
180
|
+
const ids = new Set([nodeId]);
|
|
181
|
+
let frontier = new Set([nodeId]);
|
|
182
|
+
const dir = direction === 'all' ? 'connected' : direction;
|
|
183
|
+
for (let d = 0; d < depth; d++) {
|
|
184
|
+
const next = new Set();
|
|
185
|
+
for (const r of rels) {
|
|
186
|
+
for (const f of frontier) {
|
|
187
|
+
for (const n of walkDirectionMatch(r, f, dir)) {
|
|
188
|
+
if (!ids.has(n))
|
|
189
|
+
next.add(n);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
for (const id of next)
|
|
194
|
+
ids.add(id);
|
|
195
|
+
frontier = next;
|
|
196
|
+
if (!frontier.size)
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
return ids;
|
|
200
|
+
}
|
|
201
|
+
function degreeMap(rels) {
|
|
202
|
+
const m = new Map();
|
|
203
|
+
for (const r of rels) {
|
|
204
|
+
m.set(r.from, (m.get(r.from) || 0) + 1);
|
|
205
|
+
m.set(r.to, (m.get(r.to) || 0) + 1);
|
|
206
|
+
}
|
|
207
|
+
return m;
|
|
208
|
+
}
|
|
209
|
+
function isDependencyRel(rel) {
|
|
210
|
+
return DEP_REL_TYPES.has(rel.type);
|
|
211
|
+
}
|
|
212
|
+
function blockingPair(rel) {
|
|
213
|
+
if (rel.type === 'BLOCKS')
|
|
214
|
+
return { blocked: rel.to, blocker: rel.from };
|
|
215
|
+
if (rel.type === 'DEPENDS_ON' || rel.type === 'BLOCKED_BY')
|
|
216
|
+
return { blocked: rel.from, blocker: rel.to };
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
function dependencyLabel(rel) {
|
|
220
|
+
const labels = {
|
|
221
|
+
DEPENDS_ON: 'Depends on',
|
|
222
|
+
BLOCKED_BY: 'Blocked by',
|
|
223
|
+
BLOCKS: 'Blocks',
|
|
224
|
+
PARENT: 'Parent',
|
|
225
|
+
PARENT_OF: 'Parent of',
|
|
226
|
+
CHILD: 'Child',
|
|
227
|
+
CHILD_OF: 'Child of',
|
|
228
|
+
RELATED: 'Related',
|
|
229
|
+
RELATES_TO: 'Related to',
|
|
230
|
+
};
|
|
231
|
+
return labels[rel.type] ?? rel.type.replace(/_/g, ' ').toLowerCase();
|
|
232
|
+
}
|
|
233
|
+
function computeCriticalPath(rels) {
|
|
234
|
+
const depRels = rels.filter(isDependencyRel);
|
|
235
|
+
if (!depRels.length)
|
|
236
|
+
return new Set();
|
|
237
|
+
const blockedBy = new Map();
|
|
238
|
+
const allIds = new Set();
|
|
239
|
+
for (const r of depRels) {
|
|
240
|
+
const pair = blockingPair(r);
|
|
241
|
+
if (!pair)
|
|
242
|
+
continue;
|
|
243
|
+
if (!blockedBy.has(pair.blocked))
|
|
244
|
+
blockedBy.set(pair.blocked, new Set());
|
|
245
|
+
blockedBy.get(pair.blocked).add(pair.blocker);
|
|
246
|
+
allIds.add(pair.blocked);
|
|
247
|
+
allIds.add(pair.blocker);
|
|
248
|
+
}
|
|
249
|
+
const memo = new Map();
|
|
250
|
+
function longestPathFrom(id, seen = new Set()) {
|
|
251
|
+
if (memo.has(id))
|
|
252
|
+
return memo.get(id);
|
|
253
|
+
if (seen.has(id))
|
|
254
|
+
return [id];
|
|
255
|
+
const nextSeen = new Set(seen);
|
|
256
|
+
nextSeen.add(id);
|
|
257
|
+
const prereqs = [...(blockedBy.get(id) ?? new Set())];
|
|
258
|
+
if (!prereqs.length) {
|
|
259
|
+
memo.set(id, [id]);
|
|
260
|
+
return [id];
|
|
261
|
+
}
|
|
262
|
+
const bestPrereq = prereqs
|
|
263
|
+
.map((prereq) => longestPathFrom(prereq, nextSeen))
|
|
264
|
+
.sort((a, b) => b.length - a.length)[0] ?? [];
|
|
265
|
+
const path = [id, ...bestPrereq];
|
|
266
|
+
memo.set(id, path);
|
|
267
|
+
return path;
|
|
268
|
+
}
|
|
269
|
+
const longest = [...allIds]
|
|
270
|
+
.map((id) => longestPathFrom(id))
|
|
271
|
+
.sort((a, b) => b.length - a.length)[0] ?? [];
|
|
272
|
+
return longest.length < 2 ? new Set() : new Set(longest);
|
|
273
|
+
}
|
|
274
|
+
function blockerStats(rels) {
|
|
275
|
+
const stats = new Map();
|
|
276
|
+
const entry = (id) => {
|
|
277
|
+
if (!stats.has(id))
|
|
278
|
+
stats.set(id, { blockers: new Set(), blocked: new Set() });
|
|
279
|
+
return stats.get(id);
|
|
280
|
+
};
|
|
281
|
+
for (const rel of rels) {
|
|
282
|
+
const pair = blockingPair(rel);
|
|
283
|
+
if (!pair)
|
|
284
|
+
continue;
|
|
285
|
+
entry(pair.blocked).blockers.add(pair.blocker);
|
|
286
|
+
entry(pair.blocker).blocked.add(pair.blocked);
|
|
287
|
+
}
|
|
288
|
+
return stats;
|
|
289
|
+
}
|
|
290
|
+
function visibleGraph(graph) {
|
|
291
|
+
const nodes = graph.nodes || [];
|
|
292
|
+
let rels = graph.relationships || [];
|
|
293
|
+
const connected = new Set(rels.flatMap((r) => [r.from, r.to]));
|
|
294
|
+
// Dep mode: restrict to dependency/block edges
|
|
295
|
+
if (filter.depMode) {
|
|
296
|
+
rels = rels.filter(isDependencyRel);
|
|
297
|
+
}
|
|
298
|
+
const focusIds = selectedNodeId && filter.scope === 'focus'
|
|
299
|
+
? expandedNeighborIds(selectedNodeId, rels, Number(filter.depth), filter.direction)
|
|
300
|
+
: null;
|
|
301
|
+
const q = filter.query.trim().toLowerCase();
|
|
302
|
+
const depConnected = filter.depMode
|
|
303
|
+
? new Set(rels.flatMap((r) => [r.from, r.to]))
|
|
304
|
+
: null;
|
|
305
|
+
const nodeVisible = (n) => {
|
|
306
|
+
if (depConnected && !depConnected.has(n.id))
|
|
307
|
+
return false;
|
|
308
|
+
if (focusIds && !focusIds.has(n.id))
|
|
309
|
+
return false;
|
|
310
|
+
if (filter.kind === 'items' && !isItemNode(n))
|
|
311
|
+
return false;
|
|
312
|
+
if (filter.kind === 'facets' && !isFacetNode(n))
|
|
313
|
+
return false;
|
|
314
|
+
if (filter.kind === 'external' && !n.labels?.includes('ExternalPmItem'))
|
|
315
|
+
return false;
|
|
316
|
+
if (filter.kind === 'unlinked' && (!isItemNode(n) || connected.has(n.id)))
|
|
317
|
+
return false;
|
|
318
|
+
if (filter.statusFilter && nodeStatus(n) !== filter.statusFilter)
|
|
319
|
+
return false;
|
|
320
|
+
if (!q)
|
|
321
|
+
return true;
|
|
322
|
+
const hay = [n.id, nodeTitle(n), nodeType(n), nodeStatus(n),
|
|
323
|
+
n.properties?.tags?.join(' ') || ''].join(' ').toLowerCase();
|
|
324
|
+
return hay.includes(q);
|
|
325
|
+
};
|
|
326
|
+
const visNodes = nodes.filter(nodeVisible);
|
|
327
|
+
const visIds = new Set(visNodes.map((n) => n.id));
|
|
328
|
+
const visRels = rels.filter((r) => {
|
|
329
|
+
if (!visIds.has(r.from) || !visIds.has(r.to))
|
|
330
|
+
return false;
|
|
331
|
+
if (filter.rel !== 'all' && r.type !== filter.rel)
|
|
332
|
+
return false;
|
|
333
|
+
if (!selectedNodeId || filter.direction === 'all')
|
|
334
|
+
return true;
|
|
335
|
+
if (filter.direction === 'incoming')
|
|
336
|
+
return r.to === selectedNodeId;
|
|
337
|
+
if (filter.direction === 'outgoing')
|
|
338
|
+
return r.from === selectedNodeId;
|
|
339
|
+
return r.from === selectedNodeId || r.to === selectedNodeId;
|
|
340
|
+
});
|
|
341
|
+
return { nodes: visNodes, rels: visRels, connected };
|
|
342
|
+
}
|
|
343
|
+
// ── Canvas data conversion ────────────────────────────────────
|
|
344
|
+
function toCanvasNodes(nodes, rels) {
|
|
345
|
+
const deg = degreeMap(rels);
|
|
346
|
+
return nodes.map((n) => ({
|
|
347
|
+
id: n.id,
|
|
348
|
+
label: nodeTitle(n),
|
|
349
|
+
type: nodeType(n),
|
|
350
|
+
status: nodeStatus(n),
|
|
351
|
+
lane: nodeLane(n),
|
|
352
|
+
degree: deg.get(n.id) || 0,
|
|
353
|
+
tags: Array.isArray(n.properties?.tags)
|
|
354
|
+
? n.properties.tags.map(String)
|
|
355
|
+
: [],
|
|
356
|
+
priority: n.properties?.priority !== undefined ? Number(n.properties.priority) : undefined,
|
|
357
|
+
assignee: n.properties?.assignee ? String(n.properties.assignee) : undefined,
|
|
358
|
+
}));
|
|
359
|
+
}
|
|
360
|
+
function toCanvasEdges(rels) {
|
|
361
|
+
return rels.map((r) => ({ from: r.from, to: r.to, type: r.type }));
|
|
362
|
+
}
|
|
363
|
+
// ── Info panel rendering ──────────────────────────────────────
|
|
364
|
+
function renderCoverage(itemNodes, rels, connected, relCounts) {
|
|
365
|
+
const linked = itemNodes.filter((n) => connected.has(n.id)).length;
|
|
366
|
+
const linkedPct = itemNodes.length > 0 ? Math.round((linked / itemNodes.length) * 100) : 0;
|
|
367
|
+
const unlinked = itemNodes.length - linked;
|
|
368
|
+
const external = new Set(rels.flatMap((r) => [r.from, r.to]).filter((id) => id.includes(':external') || id.startsWith('external:'))).size;
|
|
369
|
+
const top = Object.entries(relCounts).sort((a, b) => b[1] - a[1])[0];
|
|
370
|
+
const topRelLabel = top?.[0]
|
|
371
|
+
? top[0].replace(/^HAS_/, '').replace(/_/g, ' ').slice(0, 8)
|
|
372
|
+
: 'None';
|
|
373
|
+
return `
|
|
374
|
+
<div class="graph-coverage-grid">
|
|
375
|
+
<div class="graph-coverage-card">
|
|
376
|
+
<span>Linked</span>
|
|
377
|
+
<strong>${linkedPct}%</strong>
|
|
378
|
+
<div class="graph-coverage-bar"><div class="graph-coverage-fill" style="width:${linkedPct}%"></div></div>
|
|
379
|
+
<em>${linked} of ${itemNodes.length} items</em>
|
|
380
|
+
</div>
|
|
381
|
+
<div class="graph-coverage-card">
|
|
382
|
+
<span>External</span>
|
|
383
|
+
<strong>${external}</strong>
|
|
384
|
+
<em>Cross-project refs</em>
|
|
385
|
+
</div>
|
|
386
|
+
<div class="graph-coverage-card" title="${escHtml(top?.[0] || 'None')} (${top?.[1] ?? 0} edges)">
|
|
387
|
+
<span>Top rel</span>
|
|
388
|
+
<strong>${escHtml(topRelLabel)}</strong>
|
|
389
|
+
<em>${top?.[1] ?? 0} edges</em>
|
|
390
|
+
</div>
|
|
391
|
+
</div>`;
|
|
392
|
+
}
|
|
393
|
+
function renderSelectedNode(node, rels, byId, fullItem) {
|
|
394
|
+
if (!node)
|
|
395
|
+
return '<div class="graph-node-empty">Click any node in the graph to inspect it.</div>';
|
|
396
|
+
const outgoing = rels.filter((r) => r.from === node.id);
|
|
397
|
+
const incoming = rels.filter((r) => r.to === node.id);
|
|
398
|
+
const direct = [...outgoing, ...incoming];
|
|
399
|
+
const blocks = blockerStats(rels).get(node.id) ?? { blockers: new Set(), blocked: new Set() };
|
|
400
|
+
const props = [
|
|
401
|
+
['Status', nodeStatus(node)],
|
|
402
|
+
['Type', nodeType(node)],
|
|
403
|
+
['Priority', node.properties?.priority ?? ''],
|
|
404
|
+
['Assignee', node.properties?.assignee ?? ''],
|
|
405
|
+
['Sprint', node.properties?.sprint ?? ''],
|
|
406
|
+
['Release', node.properties?.release ?? ''],
|
|
407
|
+
['Deadline', node.properties?.deadline ?? ''],
|
|
408
|
+
['Updated', node.properties?.updated_at ?? ''],
|
|
409
|
+
].filter(([, v]) => v !== null && v !== undefined && String(v).trim() !== '');
|
|
410
|
+
const tags = Array.isArray(node.properties?.tags)
|
|
411
|
+
? node.properties.tags.map(String).filter(Boolean) : [];
|
|
412
|
+
return `
|
|
413
|
+
<div class="graph-selected">
|
|
414
|
+
<div class="graph-selected-title">${escHtml(nodeTitle(node))}</div>
|
|
415
|
+
<div class="graph-selected-meta">${escHtml(node.id)} · ${escHtml(nodeType(node))} · ${escHtml(nodeStatus(node))}</div>
|
|
416
|
+
<div class="graph-selected-actions">
|
|
417
|
+
${isItemNode(node) ? `<button class="btn btn-secondary btn-sm" id="graph-open-selected">Open Item</button>` : ''}
|
|
418
|
+
<button class="btn btn-ghost btn-sm" id="graph-clear-selected">Clear</button>
|
|
419
|
+
</div>
|
|
420
|
+
<div class="graph-selected-counts"><span>${outgoing.length} outgoing</span><span>${incoming.length} incoming</span></div>
|
|
421
|
+
${blocks.blockers.size || blocks.blocked.size ? `
|
|
422
|
+
<div class="graph-blocker-strip">
|
|
423
|
+
<span><strong>${blocks.blockers.size}</strong> blockers</span>
|
|
424
|
+
<span><strong>${blocks.blocked.size}</strong> blocked by this</span>
|
|
425
|
+
${criticalPath.has(node.id) ? '<span class="critical">critical path</span>' : ''}
|
|
426
|
+
</div>` : ''}
|
|
427
|
+
<div class="graph-property-grid">
|
|
428
|
+
${props.map(([l, v]) => `<div class="graph-property-row"><span>${escHtml(String(l))}</span><strong>${escHtml(String(v))}</strong></div>`).join('')}
|
|
429
|
+
</div>
|
|
430
|
+
${tags.length > 0 ? `<div class="graph-tag-list">${tags.map((t) => `<button class="graph-tag-chip" data-graph-query="${escHtml(t)}">${escHtml(t)}</button>`).join('')}</div>` : ''}
|
|
431
|
+
${fullItem?.body ? `
|
|
432
|
+
<div class="graph-panel-title graph-panel-title-spaced">Description</div>
|
|
433
|
+
<div style="font-size:12px;color:var(--text-secondary);line-height:1.6;white-space:pre-wrap;max-height:120px;overflow-y:auto;padding:8px;background:rgba(15,23,42,0.5);border-radius:6px;border:1px solid rgba(148,163,184,0.1)">${escHtml(String(fullItem.body).slice(0, 500))}${String(fullItem.body).length > 500 ? '…' : ''}</div>
|
|
434
|
+
` : ''}
|
|
435
|
+
${blocks.blockers.size || blocks.blocked.size ? `
|
|
436
|
+
<div class="graph-panel-title graph-panel-title-spaced">Blockers</div>
|
|
437
|
+
<div class="graph-blocker-list">
|
|
438
|
+
${[...blocks.blockers].slice(0, 8).map((id) => `<button class="graph-blocker-row blocker" data-graph-node-id="${escHtml(id)}"><span>Blocked by</span><strong>${escHtml(nodeTitle(byId.get(id) || { id }))}</strong></button>`).join('')}
|
|
439
|
+
${[...blocks.blocked].slice(0, 8).map((id) => `<button class="graph-blocker-row blocked" data-graph-node-id="${escHtml(id)}"><span>Blocks</span><strong>${escHtml(nodeTitle(byId.get(id) || { id }))}</strong></button>`).join('')}
|
|
440
|
+
</div>` : ''}
|
|
441
|
+
<div class="graph-panel-title graph-panel-title-spaced">Direct Relationships</div>
|
|
442
|
+
${direct.length === 0
|
|
443
|
+
? '<div class="graph-node-empty">No relationships.</div>'
|
|
444
|
+
: direct.slice(0, 16).map((r) => {
|
|
445
|
+
const otherId = r.from === node.id ? r.to : r.from;
|
|
446
|
+
const other = byId.get(otherId);
|
|
447
|
+
const dir = r.from === node.id ? '→' : '←';
|
|
448
|
+
// Show any non-trivial relationship properties (e.g. weight, since, note)
|
|
449
|
+
const relProps = r.properties ? Object.entries(r.properties).filter(([, v]) => v !== null && v !== undefined && String(v).trim() !== '') : [];
|
|
450
|
+
const propHint = relProps.length > 0
|
|
451
|
+
? ` <span style="font-size:10px;color:var(--text-muted);opacity:0.8">[${relProps.slice(0, 3).map(([k, v]) => `${k}:${escHtml(String(v))}`).join(', ')}]</span>`
|
|
452
|
+
: '';
|
|
453
|
+
return `<button class="graph-neighbor" data-graph-node-id="${escHtml(otherId)}"><span class="graph-rel-badge">${escHtml(r.type)}</span> ${dir} <strong>${escHtml(nodeTitle(other || { id: otherId }))}</strong>${propHint}</button>`;
|
|
454
|
+
}).join('') + (direct.length > 16 ? `<div class="graph-limit-note">+${direct.length - 16} more — use Focus scope to narrow</div>` : '')}
|
|
455
|
+
</div>`;
|
|
456
|
+
}
|
|
457
|
+
function renderPaths(node, rels, byId) {
|
|
458
|
+
if (!node)
|
|
459
|
+
return '<div class="graph-node-empty">Select a node to explore paths.</div>';
|
|
460
|
+
const oneHop = directNeighborIds(node.id, rels);
|
|
461
|
+
oneHop.delete(node.id);
|
|
462
|
+
const twoHop = expandedNeighborIds(node.id, rels, 2, 'connected');
|
|
463
|
+
for (const id of oneHop)
|
|
464
|
+
twoHop.delete(id);
|
|
465
|
+
twoHop.delete(node.id);
|
|
466
|
+
const chips = (ids) => Array.from(ids).slice(0, 10).map((id) => {
|
|
467
|
+
const t = byId.get(id);
|
|
468
|
+
return `<button class="graph-path-chip" data-graph-node-id="${escHtml(id)}">${escHtml(nodeTitle(t || { id }))}</button>`;
|
|
469
|
+
}).join('');
|
|
470
|
+
return `
|
|
471
|
+
<div class="graph-path-section"><div class="graph-path-label">One hop (${oneHop.size})</div><div class="graph-path-chips">${chips(oneHop) || '<span>None.</span>'}</div></div>
|
|
472
|
+
<div class="graph-path-section"><div class="graph-path-label">Two hops (${twoHop.size})</div><div class="graph-path-chips">${chips(twoHop) || '<span>None.</span>'}</div></div>`;
|
|
473
|
+
}
|
|
474
|
+
function renderHubs(nodes, rels) {
|
|
475
|
+
const deg = degreeMap(rels);
|
|
476
|
+
const hubs = nodes.filter(isItemNode)
|
|
477
|
+
.map((n) => ({ n, d: deg.get(n.id) || 0, out: rels.filter((r) => r.from === n.id).length, inn: rels.filter((r) => r.to === n.id).length }))
|
|
478
|
+
.filter((e) => e.d > 0)
|
|
479
|
+
.sort((a, b) => b.d - a.d || nodeTitle(a.n).localeCompare(nodeTitle(b.n)))
|
|
480
|
+
.slice(0, 8);
|
|
481
|
+
if (!hubs.length)
|
|
482
|
+
return '<div class="graph-node-empty">No linked item hubs yet.</div>';
|
|
483
|
+
return hubs.map((e) => `
|
|
484
|
+
<button class="graph-insight-row" data-graph-node-id="${escHtml(e.n.id)}">
|
|
485
|
+
<span><strong>${escHtml(nodeTitle(e.n))}</strong><em>${escHtml(e.n.id)}</em></span>
|
|
486
|
+
<b>${e.d}</b>
|
|
487
|
+
<small>${e.out}↑ ${e.inn}↓</small>
|
|
488
|
+
</button>`).join('');
|
|
489
|
+
}
|
|
490
|
+
function renderBlockingInsights(nodes, rels) {
|
|
491
|
+
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
492
|
+
const stats = blockerStats(rels);
|
|
493
|
+
const rows = [...stats.entries()]
|
|
494
|
+
.map(([id, value]) => ({ id, ...value, node: byId.get(id) }))
|
|
495
|
+
.filter((row) => row.node && isItemNode(row.node) && (row.blockers.size || row.blocked.size))
|
|
496
|
+
.sort((a, b) => b.blocked.size - a.blocked.size || b.blockers.size - a.blockers.size || nodeTitle(a.node).localeCompare(nodeTitle(b.node)))
|
|
497
|
+
.slice(0, 8);
|
|
498
|
+
if (!rows.length)
|
|
499
|
+
return '<div class="graph-node-empty">No dependency blockers in this view.</div>';
|
|
500
|
+
return rows.map((row) => `
|
|
501
|
+
<button class="graph-insight-row${criticalPath.has(row.id) ? ' critical' : ''}" data-graph-node-id="${escHtml(row.id)}">
|
|
502
|
+
<span><strong>${escHtml(nodeTitle(row.node))}</strong><em>${escHtml(row.id)}</em></span>
|
|
503
|
+
<b>${row.blocked.size}</b>
|
|
504
|
+
<small>${row.blockers.size} blockers · ${row.blocked.size} items blocked${criticalPath.has(row.id) ? ' · critical path' : ''}</small>
|
|
505
|
+
</button>`).join('');
|
|
506
|
+
}
|
|
507
|
+
function renderDependencyChains(nodes, rels) {
|
|
508
|
+
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
509
|
+
const depRels = rels.filter(isDependencyRel);
|
|
510
|
+
if (!depRels.length) {
|
|
511
|
+
return '<div class="graph-node-empty">No dependency chains in this view.</div>';
|
|
512
|
+
}
|
|
513
|
+
const blockersByItem = new Map();
|
|
514
|
+
const blockedByItem = new Map();
|
|
515
|
+
for (const rel of depRels) {
|
|
516
|
+
const pair = blockingPair(rel);
|
|
517
|
+
if (!pair)
|
|
518
|
+
continue;
|
|
519
|
+
blockersByItem.set(pair.blocked, [...(blockersByItem.get(pair.blocked) ?? []), pair.blocker]);
|
|
520
|
+
blockedByItem.set(pair.blocker, [...(blockedByItem.get(pair.blocker) ?? []), pair.blocked]);
|
|
521
|
+
}
|
|
522
|
+
const cycleStarts = new Set();
|
|
523
|
+
const leafBlockers = [...blockedByItem.entries()]
|
|
524
|
+
.filter(([id, blocked]) => blocked.length > 0 && !(blockersByItem.get(id)?.length))
|
|
525
|
+
.map(([id, blocked]) => ({ id, blocked: blocked.length }))
|
|
526
|
+
.sort((a, b) => b.blocked - a.blocked || nodeTitle(byId.get(a.id) || { id: a.id }).localeCompare(nodeTitle(byId.get(b.id) || { id: b.id })))
|
|
527
|
+
.slice(0, 4);
|
|
528
|
+
function traceFrom(id, seen = new Set()) {
|
|
529
|
+
if (seen.has(id)) {
|
|
530
|
+
cycleStarts.add(id);
|
|
531
|
+
return [id];
|
|
532
|
+
}
|
|
533
|
+
const nextSeen = new Set(seen);
|
|
534
|
+
nextSeen.add(id);
|
|
535
|
+
const blockers = blockersByItem.get(id) ?? [];
|
|
536
|
+
if (!blockers.length)
|
|
537
|
+
return [id];
|
|
538
|
+
const best = blockers
|
|
539
|
+
.map((blocker) => traceFrom(blocker, nextSeen))
|
|
540
|
+
.sort((a, b) => b.length - a.length)[0] ?? [];
|
|
541
|
+
return [id, ...best];
|
|
542
|
+
}
|
|
543
|
+
const chains = [...new Set([...blockersByItem.keys(), ...blockedByItem.keys()])]
|
|
544
|
+
.map((id) => traceFrom(id))
|
|
545
|
+
.filter((chain) => chain.length > 1)
|
|
546
|
+
.sort((a, b) => b.length - a.length || nodeTitle(byId.get(a[0]) || { id: a[0] }).localeCompare(nodeTitle(byId.get(b[0]) || { id: b[0] })))
|
|
547
|
+
.slice(0, 3);
|
|
548
|
+
const chainRows = chains.map((chain) => {
|
|
549
|
+
const isCritical = chain.some((id) => criticalPath.has(id));
|
|
550
|
+
return `
|
|
551
|
+
<button class="graph-chain-row${isCritical ? ' critical' : ''}" data-graph-node-id="${escHtml(chain[0])}">
|
|
552
|
+
<span>${chain.map((id) => escHtml(nodeTitle(byId.get(id) || { id }))).join('<i>←</i>')}</span>
|
|
553
|
+
<small>${chain.length} items · ${isCritical ? 'critical dependency chain' : 'dependency chain'}</small>
|
|
554
|
+
</button>`;
|
|
555
|
+
}).join('');
|
|
556
|
+
const cycleRows = [...cycleStarts].slice(0, 3).map((id) => `
|
|
557
|
+
<button class="graph-chain-row warning" data-graph-node-id="${escHtml(id)}">
|
|
558
|
+
<span>${escHtml(nodeTitle(byId.get(id) || { id }))}</span>
|
|
559
|
+
<small>Potential dependency cycle detected from this item.</small>
|
|
560
|
+
</button>`).join('');
|
|
561
|
+
const leafRows = leafBlockers.map(({ id, blocked }) => `
|
|
562
|
+
<button class="graph-chain-row root" data-graph-node-id="${escHtml(id)}">
|
|
563
|
+
<span>${escHtml(nodeTitle(byId.get(id) || { id }))}</span>
|
|
564
|
+
<small>Root blocker for ${blocked} item${blocked === 1 ? '' : 's'}.</small>
|
|
565
|
+
</button>`).join('');
|
|
566
|
+
return `
|
|
567
|
+
<div class="graph-chain-list">
|
|
568
|
+
${chainRows || '<div class="graph-node-empty">No multi-step dependency chains.</div>'}
|
|
569
|
+
${cycleRows}
|
|
570
|
+
${leafRows}
|
|
571
|
+
</div>`;
|
|
572
|
+
}
|
|
573
|
+
function renderInfoPanel(data, fullItem) {
|
|
574
|
+
const graph = data.graph || {};
|
|
575
|
+
const nodes = graph.nodes || [];
|
|
576
|
+
const rels = graph.relationships || [];
|
|
577
|
+
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
578
|
+
const connected = new Set(rels.flatMap((r) => [r.from, r.to]));
|
|
579
|
+
const itemNodes = nodes.filter(isItemNode);
|
|
580
|
+
const relCounts = rels.reduce((acc, r) => { acc[r.type] = (acc[r.type] || 0) + 1; return acc; }, {});
|
|
581
|
+
const selectedNode = selectedNodeId ? byId.get(selectedNodeId) : undefined;
|
|
582
|
+
const typeCounts = nodes.reduce((acc, n) => {
|
|
583
|
+
const t = nodeType(n);
|
|
584
|
+
acc[t] = (acc[t] || 0) + 1;
|
|
585
|
+
return acc;
|
|
586
|
+
}, {});
|
|
587
|
+
return `
|
|
588
|
+
<div class="graph-panel-title">Graph Coverage</div>
|
|
589
|
+
${renderCoverage(itemNodes, rels, connected, relCounts)}
|
|
590
|
+
<div class="graph-panel-title graph-panel-title-spaced">Selected Node</div>
|
|
591
|
+
${renderSelectedNode(selectedNode, rels, byId, fullItem)}
|
|
592
|
+
<div class="graph-panel-title graph-panel-title-spaced">Neighborhood</div>
|
|
593
|
+
${renderPaths(selectedNode, rels, byId)}
|
|
594
|
+
<div class="graph-panel-title graph-panel-title-spaced">Item Hubs</div>
|
|
595
|
+
<div class="graph-insight-list">${renderHubs(nodes, rels)}</div>
|
|
596
|
+
<div class="graph-panel-title graph-panel-title-spaced">Dependency Blockers</div>
|
|
597
|
+
<div class="graph-insight-list">${renderBlockingInsights(nodes, rels)}</div>
|
|
598
|
+
<div class="graph-panel-title graph-panel-title-spaced">Dependency Chains</div>
|
|
599
|
+
${renderDependencyChains(nodes, rels)}
|
|
600
|
+
<div class="graph-panel-title graph-panel-title-spaced">Nodes by Type</div>
|
|
601
|
+
<div class="graph-type-list">
|
|
602
|
+
${Object.entries(typeCounts).sort((a, b) => b[1] - a[1]).map(([t, c]) => `<div class="graph-type-row"><span>${escHtml(t)}</span><strong>${c}</strong></div>`).join('') || '<div class="graph-node-empty">No items.</div>'}
|
|
603
|
+
</div>
|
|
604
|
+
<div class="graph-panel-title graph-panel-title-spaced">Relationships by Type</div>
|
|
605
|
+
<div class="graph-type-list">
|
|
606
|
+
${Object.entries(relCounts).sort((a, b) => b[1] - a[1]).map(([t, c]) => `<div class="graph-type-row"><span>${escHtml(t)}</span><strong>${c}</strong></div>`).join('') || '<div class="graph-node-empty">No relationships.</div>'}
|
|
607
|
+
</div>`;
|
|
608
|
+
}
|
|
609
|
+
// ── Rel list (inside bottom drawer) ──────────────────────────
|
|
610
|
+
function renderRelList(data) {
|
|
611
|
+
const graph = data.graph || {};
|
|
612
|
+
const nodes = graph.nodes || [];
|
|
613
|
+
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
614
|
+
const { rels: visRels } = visibleGraph(graph);
|
|
615
|
+
const editBtns = `
|
|
616
|
+
<div class="graph-rel-edit-bar">
|
|
617
|
+
<button class="btn btn-secondary btn-sm" id="graph-add-dep-btn">+ Add Dependency</button>
|
|
618
|
+
<button class="btn btn-ghost btn-sm" id="graph-remove-dep-btn">− Remove Dependency</button>
|
|
619
|
+
</div>`;
|
|
620
|
+
if (!visRels.length) {
|
|
621
|
+
return editBtns + '<div class="graph-node-empty" style="padding:14px 0">No relationships match current filters.</div>';
|
|
622
|
+
}
|
|
623
|
+
const rows = visRels.slice(0, 100).map((r) => {
|
|
624
|
+
const from = byId.get(r.from);
|
|
625
|
+
const to = byId.get(r.to);
|
|
626
|
+
const relProps = r.properties ? Object.entries(r.properties).filter(([, v]) => v !== null && v !== undefined && String(v).trim() !== '') : [];
|
|
627
|
+
const propHtml = relProps.length > 0
|
|
628
|
+
? `<div class="graph-rel-props">${relProps.slice(0, 4).map(([k, v]) => `<span><em>${escHtml(k)}</em> ${escHtml(String(v))}</span>`).join('')}</div>`
|
|
629
|
+
: '';
|
|
630
|
+
return `
|
|
631
|
+
<button class="graph-rel-row" data-graph-from-id="${escHtml(r.from)}" data-graph-to-id="${escHtml(r.to)}" data-graph-rel-type="${escHtml(r.type)}">
|
|
632
|
+
<div><div class="graph-rel-title">${escHtml(nodeTitle(from || { id: r.from }))}</div><div class="graph-rel-id">${escHtml(r.from)}</div></div>
|
|
633
|
+
<div class="graph-rel-type">${escHtml(r.type)}${propHtml}</div>
|
|
634
|
+
<div><div class="graph-rel-title">${escHtml(nodeTitle(to || { id: r.to }))}</div><div class="graph-rel-id">${escHtml(r.to)}</div></div>
|
|
635
|
+
</button>`;
|
|
636
|
+
}).join('');
|
|
637
|
+
const limitNote = visRels.length > 100
|
|
638
|
+
? `<div class="graph-limit-note" style="padding:10px 0">Showing 100 of ${visRels.length} — use filters to narrow.</div>`
|
|
639
|
+
: '';
|
|
640
|
+
return editBtns + rows + limitNote;
|
|
641
|
+
}
|
|
642
|
+
// ── Immersive shell ───────────────────────────────────────────
|
|
643
|
+
function graphPresetActive(id) {
|
|
644
|
+
if (id === 'knowledge') {
|
|
645
|
+
return !filter.depMode && (filter.kind === 'items' || filter.kind === 'all') && filter.rel === 'all' && filter.scope === 'all' && filter.colorMode === 'status';
|
|
646
|
+
}
|
|
647
|
+
if (id === 'dependency')
|
|
648
|
+
return filter.depMode;
|
|
649
|
+
if (id === 'unlinked')
|
|
650
|
+
return filter.kind === 'unlinked';
|
|
651
|
+
if (id === 'metadata')
|
|
652
|
+
return filter.kind === 'facets' || (filter.kind === 'all' && !filter.depMode && filter.colorMode !== 'tag');
|
|
653
|
+
if (id === 'critical')
|
|
654
|
+
return filter.depMode && filter.scope === 'focus' && Boolean(selectedNodeId);
|
|
655
|
+
if (id === 'tags')
|
|
656
|
+
return !filter.depMode && filter.colorMode === 'tag';
|
|
657
|
+
return false;
|
|
658
|
+
}
|
|
659
|
+
function renderGraphPresets(depRels, isolatedCount) {
|
|
660
|
+
const allNodes = currentGraph?.graph?.nodes ?? [];
|
|
661
|
+
const tagSet = new Set();
|
|
662
|
+
for (const n of allNodes) {
|
|
663
|
+
const tags = Array.isArray(n.properties?.tags) ? n.properties.tags.map(String) : [];
|
|
664
|
+
for (const t of tags)
|
|
665
|
+
tagSet.add(t);
|
|
666
|
+
}
|
|
667
|
+
const presets = [
|
|
668
|
+
{ id: 'knowledge', label: 'Knowledge', value: String(allNodes.length) },
|
|
669
|
+
{ id: 'dependency', label: 'Dependencies', value: String(depRels.length) },
|
|
670
|
+
{ id: 'unlinked', label: 'Unlinked', value: String(isolatedCount) },
|
|
671
|
+
{ id: 'tags', label: 'Tags', value: tagSet.size ? String(tagSet.size) : 'none' },
|
|
672
|
+
{ id: 'metadata', label: 'Metadata', value: 'facets' },
|
|
673
|
+
{ id: 'critical', label: 'Critical', value: criticalPath.size ? String(criticalPath.size) : 'pick node' },
|
|
674
|
+
];
|
|
675
|
+
return `
|
|
676
|
+
<div class="graph-preset-rail" role="toolbar" aria-label="Graph views">
|
|
677
|
+
${presets.map((preset) => `
|
|
678
|
+
<button class="graph-preset-btn${graphPresetActive(preset.id) ? ' active' : ''}" data-graph-preset="${preset.id}">
|
|
679
|
+
<span>${escHtml(preset.label)}</span>
|
|
680
|
+
<strong>${escHtml(preset.value)}</strong>
|
|
681
|
+
</button>`).join('')}
|
|
682
|
+
</div>`;
|
|
683
|
+
}
|
|
684
|
+
function renderGraphShell(data) {
|
|
685
|
+
const graph = data.graph || {};
|
|
686
|
+
const nodes = graph.nodes || [];
|
|
687
|
+
const rels = graph.relationships || [];
|
|
688
|
+
const itemNodes = nodes.filter(isItemNode);
|
|
689
|
+
const facetNodes = nodes.filter(isFacetNode);
|
|
690
|
+
const connected = new Set(rels.flatMap((r) => [r.from, r.to]));
|
|
691
|
+
const isolated = itemNodes.filter((n) => !connected.has(n.id)).length;
|
|
692
|
+
const relCounts = rels.reduce((acc, r) => { acc[r.type] = (acc[r.type] || 0) + 1; return acc; }, {});
|
|
693
|
+
const relOptions = Object.keys(relCounts).sort();
|
|
694
|
+
const errText = compactError(data.extensionError);
|
|
695
|
+
const { rels: visRels } = visibleGraph(graph);
|
|
696
|
+
const depRels = rels.filter(isDependencyRel);
|
|
697
|
+
return `
|
|
698
|
+
<div class="graph-immersive-wrap">
|
|
699
|
+
|
|
700
|
+
<!-- Canvas (fills entire wrap) -->
|
|
701
|
+
<div class="graph-canvas-host" id="graph-canvas-host"></div>
|
|
702
|
+
|
|
703
|
+
<!-- Top HUD bar -->
|
|
704
|
+
<div class="graph-hud-top">
|
|
705
|
+
<div class="graph-hud-left">
|
|
706
|
+
<div style="display:flex;align-items:center;gap:8px;">
|
|
707
|
+
<button class="graph-hud-btn graph-back-btn" id="graph-back-btn" title="Exit graph view" style="padding:4px 9px;font-size:11px">← Back</button>
|
|
708
|
+
<div class="graph-hud-title">
|
|
709
|
+
◎ Knowledge Graph
|
|
710
|
+
<span class="graph-mode-chip${data.extensionAvailable ? ' neo4j' : ''}">
|
|
711
|
+
${data.extensionAvailable ? 'neo4j' : 'built-in'}
|
|
712
|
+
</span>
|
|
713
|
+
</div>
|
|
714
|
+
</div>
|
|
715
|
+
<div class="graph-hud-stats">
|
|
716
|
+
<span><b>${itemNodes.length}</b> items</span>
|
|
717
|
+
<span><b>${rels.length}</b> edges</span>
|
|
718
|
+
<span><b>${facetNodes.length}</b> facets</span>
|
|
719
|
+
${isolated > 0 ? `<span class="graph-hud-warn"><b>${isolated}</b> unlinked</span>` : ''}
|
|
720
|
+
</div>
|
|
721
|
+
</div>
|
|
722
|
+
<div class="graph-hud-center">
|
|
723
|
+
<div class="graph-search-hud">
|
|
724
|
+
<span class="graph-search-hud-icon">⌕</span>
|
|
725
|
+
<input class="graph-search-hud-input" id="graph-filter-query" type="text" placeholder="Search nodes… (Ctrl+F)" value="${escHtml(filter.query)}" autocomplete="off">
|
|
726
|
+
</div>
|
|
727
|
+
${renderGraphPresets(depRels, isolated)}
|
|
728
|
+
</div>
|
|
729
|
+
<div class="graph-hud-right">
|
|
730
|
+
<button class="graph-hud-btn" id="graph-refresh" title="Reload graph data (R)">↻</button>
|
|
731
|
+
<button class="graph-hud-btn" id="graph-sync-btn" title="Run backend graph sync (S)">${graphSyncInFlight ? 'Syncing…' : '⧉ Sync'}</button>
|
|
732
|
+
<button class="graph-hud-btn" id="graph-fit-btn" title="Fit all in view (F)">⊡ Fit</button>
|
|
733
|
+
<button class="graph-hud-btn" id="graph-physics-btn" title="Pause/Resume physics (Space)">${physicsLabel}</button>
|
|
734
|
+
<button class="graph-hud-btn" id="graph-export-png" title="Export as PNG">PNG</button>
|
|
735
|
+
<div class="graph-hud-select-wrap">
|
|
736
|
+
<select class="graph-hud-select" id="graph-layout-select" title="Layout mode">
|
|
737
|
+
<option value="force"${filter.layout === 'force' ? ' selected' : ''}>Force</option>
|
|
738
|
+
<option value="hierarchical"${filter.layout === 'hierarchical' ? ' selected' : ''}>Hierarchy</option>
|
|
739
|
+
</select>
|
|
740
|
+
</div>
|
|
741
|
+
<button class="graph-hud-btn${filter.edgeBundling ? ' active' : ''}" id="graph-bundle-btn" title="Toggle edge bundling">Bundle</button>
|
|
742
|
+
<button class="graph-hud-btn${physicsOpen ? ' active' : ''}" id="graph-physics-panel-toggle" title="Physics controls">⚡ Physics</button>
|
|
743
|
+
<button class="graph-hud-btn${filterOpen ? ' active' : ''}" id="graph-filter-toggle" title="Toggle filters (G)">⚙ Filters</button>
|
|
744
|
+
<button class="graph-hud-btn${infoDrawerOpen ? ' active' : ''}" id="graph-info-toggle" title="Toggle analysis panel (I)">⊞ Info</button>
|
|
745
|
+
<button class="graph-hud-btn${relDrawerOpen ? ' active' : ''}" id="graph-rel-toggle" title="Show all relationships">⇄ Rels</button>
|
|
746
|
+
</div>
|
|
747
|
+
</div>
|
|
748
|
+
|
|
749
|
+
<!-- Physics controls panel (bottom-left, above filter overlay) -->
|
|
750
|
+
<div class="graph-physics-panel${physicsOpen ? ' open' : ''}" id="graph-physics-panel">
|
|
751
|
+
<div class="graph-filter-overlay-header">
|
|
752
|
+
<span>⚡ Physics Controls</span>
|
|
753
|
+
<button class="graph-filter-close-btn" id="graph-physics-close">✕</button>
|
|
754
|
+
</div>
|
|
755
|
+
<div class="graph-physics-body">
|
|
756
|
+
<div class="graph-physics-row">
|
|
757
|
+
<label>Repulsion</label>
|
|
758
|
+
<input type="range" id="graph-physics-repulsion" class="graph-physics-slider" min="200" max="8000" step="100" value="2000">
|
|
759
|
+
<span class="graph-physics-val" id="graph-physics-repulsion-val">2000</span>
|
|
760
|
+
</div>
|
|
761
|
+
<div class="graph-physics-row">
|
|
762
|
+
<label>Link distance</label>
|
|
763
|
+
<input type="range" id="graph-physics-linkdist" class="graph-physics-slider" min="30" max="400" step="10" value="140">
|
|
764
|
+
<span class="graph-physics-val" id="graph-physics-linkdist-val">140</span>
|
|
765
|
+
</div>
|
|
766
|
+
<div class="graph-physics-row">
|
|
767
|
+
<label>Link strength</label>
|
|
768
|
+
<input type="range" id="graph-physics-linkstr" class="graph-physics-slider" min="1" max="30" step="1" value="7">
|
|
769
|
+
<span class="graph-physics-val" id="graph-physics-linkstr-val">0.065</span>
|
|
770
|
+
</div>
|
|
771
|
+
<div class="graph-physics-row">
|
|
772
|
+
<label>Gravity</label>
|
|
773
|
+
<input type="range" id="graph-physics-gravity" class="graph-physics-slider" min="0" max="50" step="1" value="10">
|
|
774
|
+
<span class="graph-physics-val" id="graph-physics-gravity-val">0.010</span>
|
|
775
|
+
</div>
|
|
776
|
+
<button class="graph-scope-btn" id="graph-physics-reset" style="margin-top:4px">↺ Reset defaults</button>
|
|
777
|
+
</div>
|
|
778
|
+
</div>
|
|
779
|
+
|
|
780
|
+
<!-- Filter overlay (bottom-left) -->
|
|
781
|
+
<div class="graph-filter-overlay${filterOpen ? ' open' : ''}" id="graph-filter-overlay">
|
|
782
|
+
<div class="graph-filter-overlay-header">
|
|
783
|
+
<span>⚙ Filters</span>
|
|
784
|
+
<button class="graph-filter-close-btn" id="graph-filter-close">✕</button>
|
|
785
|
+
</div>
|
|
786
|
+
<div class="graph-filter-overlay-body">
|
|
787
|
+
<div class="graph-filter-row">
|
|
788
|
+
<label>Color by</label>
|
|
789
|
+
<select id="graph-color-mode">
|
|
790
|
+
<option value="status"${filter.colorMode === 'status' ? ' selected' : ''}>Status</option>
|
|
791
|
+
<option value="type"${filter.colorMode === 'type' ? ' selected' : ''}>Node type</option>
|
|
792
|
+
<option value="tag"${filter.colorMode === 'tag' ? ' selected' : ''}>Tags (auto)</option>
|
|
793
|
+
</select>
|
|
794
|
+
</div>
|
|
795
|
+
<button class="graph-dep-mode-btn${filter.depMode ? ' active' : ''}" id="graph-dep-mode-btn">
|
|
796
|
+
<span>Dependency Graph</span>
|
|
797
|
+
<strong>${filter.depMode ? 'On' : 'Off'}</strong>
|
|
798
|
+
</button>
|
|
799
|
+
<div class="graph-filter-note">${depRels.length} dep/block edges · ${criticalPath.size} critical-path nodes</div>
|
|
800
|
+
<div class="graph-filter-row">
|
|
801
|
+
<label>Show</label>
|
|
802
|
+
<select id="graph-filter-kind">
|
|
803
|
+
${[['all', 'All nodes'], ['items', 'Items only'], ['facets', 'Metadata only'], ['external', 'External'], ['unlinked', 'Unlinked']]
|
|
804
|
+
.map(([v, l]) => `<option value="${v}"${filter.kind === v ? ' selected' : ''}>${l}</option>`).join('')}
|
|
805
|
+
</select>
|
|
806
|
+
</div>
|
|
807
|
+
<div class="graph-filter-row">
|
|
808
|
+
<label>Status</label>
|
|
809
|
+
<select id="graph-filter-status">
|
|
810
|
+
${[['', 'All statuses'], ['open', 'Open'], ['in-progress', 'In Progress'], ['blocked', 'Blocked'], ['draft', 'Draft'], ['closed', 'Closed']]
|
|
811
|
+
.map(([v, l]) => `<option value="${v}"${filter.statusFilter === v ? ' selected' : ''}>${l}</option>`).join('')}
|
|
812
|
+
</select>
|
|
813
|
+
</div>
|
|
814
|
+
<div class="graph-filter-row">
|
|
815
|
+
<label>Relation</label>
|
|
816
|
+
<select id="graph-filter-rel">
|
|
817
|
+
<option value="all">All types</option>
|
|
818
|
+
${relOptions.map((r) => `<option value="${escHtml(r)}"${filter.rel === r ? ' selected' : ''}>${escHtml(r)}</option>`).join('')}
|
|
819
|
+
</select>
|
|
820
|
+
</div>
|
|
821
|
+
<div class="graph-filter-row">
|
|
822
|
+
<label>Direction</label>
|
|
823
|
+
<select id="graph-filter-direction" ${selectedNodeId ? '' : 'disabled'}>
|
|
824
|
+
${[['all', 'Any direction'], ['connected', 'All connected'], ['outgoing', 'Outgoing →'], ['incoming', '← Incoming']]
|
|
825
|
+
.map(([v, l]) => `<option value="${v}"${filter.direction === v ? ' selected' : ''}>${l}</option>`).join('')}
|
|
826
|
+
</select>
|
|
827
|
+
</div>
|
|
828
|
+
<div class="graph-filter-row graph-filter-row-depth">
|
|
829
|
+
<label>Depth <span id="graph-depth-label" class="graph-depth-val">${filter.depth}</span></label>
|
|
830
|
+
<input type="range" id="graph-filter-depth" class="graph-depth-slider"
|
|
831
|
+
min="1" max="5" step="1" value="${filter.depth}"
|
|
832
|
+
${selectedNodeId && filter.scope === 'focus' ? '' : 'disabled'}>
|
|
833
|
+
</div>
|
|
834
|
+
<button class="graph-scope-btn" id="graph-scope-btn">
|
|
835
|
+
${filter.scope === 'focus' ? '⊙ Show All Nodes' : '⊕ Focus on Selected'}
|
|
836
|
+
</button>
|
|
837
|
+
${errText ? `<div style="margin-top:8px;font-size:11px;color:#fb923c;line-height:1.4">⚠ ${escHtml(errText)}</div>` : ''}
|
|
838
|
+
</div>
|
|
839
|
+
</div>
|
|
840
|
+
|
|
841
|
+
<!-- Legend HUD (bottom-center) -->
|
|
842
|
+
<div class="graph-legend-hud" id="graph-legend-hud">
|
|
843
|
+
<span><i class="legend-dot legend-item"></i>Item</span>
|
|
844
|
+
<span><i class="legend-dot legend-facet"></i>Metadata</span>
|
|
845
|
+
<span><i class="legend-dot legend-external"></i>External</span>
|
|
846
|
+
<span class="legend-sep">·</span>
|
|
847
|
+
<span><i class="legend-dot" style="background:#2dd4bf;box-shadow:0 0 4px #2dd4bf66"></i>open</span>
|
|
848
|
+
<span><i class="legend-dot" style="background:#fb923c;box-shadow:0 0 4px #fb923c66"></i>in-progress</span>
|
|
849
|
+
<span><i class="legend-dot" style="background:#f87171;box-shadow:0 0 4px #f8717166"></i>blocked</span>
|
|
850
|
+
<span><i class="legend-dot" style="background:#64748b"></i>closed</span>
|
|
851
|
+
<span><i class="legend-dot" style="background:#94a3b8"></i>draft</span>
|
|
852
|
+
<span class="legend-sep">·</span>
|
|
853
|
+
<span style="color:rgba(148,163,184,0.5);font-size:10px">Tab/↑↓ navigate · F fit · Space pause · Esc deselect</span>
|
|
854
|
+
</div>
|
|
855
|
+
|
|
856
|
+
<!-- Info drawer (right side) -->
|
|
857
|
+
<div class="graph-info-drawer${infoDrawerOpen ? ' open' : ''}" id="graph-info-drawer">
|
|
858
|
+
<div class="graph-info-drawer-header">
|
|
859
|
+
<span class="graph-info-drawer-title">Graph Analysis</span>
|
|
860
|
+
<button class="graph-hud-btn" id="graph-info-close" style="height:26px;padding:3px 8px;font-size:12px">✕</button>
|
|
861
|
+
</div>
|
|
862
|
+
<div class="graph-info-drawer-body" id="graph-info-panel">
|
|
863
|
+
${renderInfoPanel(data)}
|
|
864
|
+
</div>
|
|
865
|
+
</div>
|
|
866
|
+
|
|
867
|
+
<!-- Relationship drawer (bottom) -->
|
|
868
|
+
<div class="graph-rel-drawer${relDrawerOpen ? ' open' : ''}" id="graph-rel-drawer">
|
|
869
|
+
<div class="graph-rel-drawer-header">
|
|
870
|
+
<span>Relationships (${visRels.length})</span>
|
|
871
|
+
<button class="graph-filter-close-btn" id="graph-rel-close">✕</button>
|
|
872
|
+
</div>
|
|
873
|
+
<div class="graph-rel-drawer-body">
|
|
874
|
+
<div id="graph-rel-list">${renderRelList(data)}</div>
|
|
875
|
+
</div>
|
|
876
|
+
</div>
|
|
877
|
+
|
|
878
|
+
</div>`;
|
|
879
|
+
}
|
|
880
|
+
// ── Canvas init / update ──────────────────────────────────────
|
|
881
|
+
async function fetchAndUpdateSelectedItem(nodeId) {
|
|
882
|
+
if (!state.currentProject || !nodeId)
|
|
883
|
+
return;
|
|
884
|
+
// Only fetch for item-lane nodes (not facets)
|
|
885
|
+
const graph = currentGraph?.graph || {};
|
|
886
|
+
const node = (graph.nodes || []).find((n) => n.id === nodeId);
|
|
887
|
+
if (!node || !isItemNode(node)) {
|
|
888
|
+
selectedItemCache = null;
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
try {
|
|
892
|
+
const result = await api('GET', `/projects/${state.currentProject.id}/pm/get/${encodeURIComponent(nodeId)}`);
|
|
893
|
+
selectedItemCache = result.item ?? result ?? null;
|
|
894
|
+
// Re-render panel with the full item
|
|
895
|
+
if (currentGraph) {
|
|
896
|
+
const panel = document.getElementById('graph-info-panel');
|
|
897
|
+
if (panel)
|
|
898
|
+
panel.innerHTML = renderInfoPanel(currentGraph, selectedItemCache ?? undefined);
|
|
899
|
+
bindInfoPanelEvents();
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
catch {
|
|
903
|
+
selectedItemCache = null;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
function syncCanvas() {
|
|
907
|
+
if (!canvasRef.current || !currentGraph)
|
|
908
|
+
return;
|
|
909
|
+
const graph = currentGraph.graph || {};
|
|
910
|
+
const { nodes: visNodes } = visibleGraph(graph);
|
|
911
|
+
const visIds = new Set(visNodes.map((n) => n.id));
|
|
912
|
+
const useAll = filter.kind === 'all' && !filter.query && filter.scope === 'all';
|
|
913
|
+
canvasRef.current.setFilter({
|
|
914
|
+
visibleNodeIds: useAll ? null : visIds,
|
|
915
|
+
selectedId: selectedNodeId || null,
|
|
916
|
+
query: filter.query,
|
|
917
|
+
highlightRelTypes: filter.rel !== 'all' ? new Set([filter.rel]) : new Set(),
|
|
918
|
+
colorMode: filter.colorMode,
|
|
919
|
+
colorTag: '',
|
|
920
|
+
criticalPathIds: filter.depMode ? criticalPath : new Set(),
|
|
921
|
+
});
|
|
922
|
+
if (filter.query && !selectedNodeId) {
|
|
923
|
+
const q = filter.query.toLowerCase();
|
|
924
|
+
const match = visNodes.find((n) => nodeTitle(n).toLowerCase().includes(q) || n.id.toLowerCase().includes(q));
|
|
925
|
+
if (match)
|
|
926
|
+
canvasRef.current.jumpToNode(match.id);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
function initCanvas() {
|
|
930
|
+
const host = document.getElementById('graph-canvas-host');
|
|
931
|
+
if (!host || !currentGraph)
|
|
932
|
+
return;
|
|
933
|
+
canvasRef.current?.destroy();
|
|
934
|
+
canvasRef.current = null;
|
|
935
|
+
const graph = currentGraph.graph || {};
|
|
936
|
+
const nodes = graph.nodes || [];
|
|
937
|
+
const rels = graph.relationships || [];
|
|
938
|
+
criticalPath = computeCriticalPath(rels);
|
|
939
|
+
canvasRef.current = new GraphCanvas(host, {
|
|
940
|
+
layout: filter.layout,
|
|
941
|
+
edgeBundling: filter.edgeBundling,
|
|
942
|
+
onSelectNode(id) {
|
|
943
|
+
selectedNodeId = id || '';
|
|
944
|
+
filter = { ...filter, direction: 'all' };
|
|
945
|
+
updateInfoPanel();
|
|
946
|
+
syncCanvas();
|
|
947
|
+
updateFilterToolbarState();
|
|
948
|
+
// Auto-open info drawer when node selected
|
|
949
|
+
if (id && !infoDrawerOpen) {
|
|
950
|
+
infoDrawerOpen = true;
|
|
951
|
+
document.getElementById('graph-info-drawer')?.classList.add('open');
|
|
952
|
+
document.getElementById('graph-info-toggle')?.classList.add('active');
|
|
953
|
+
}
|
|
954
|
+
if (id)
|
|
955
|
+
void fetchAndUpdateSelectedItem(id);
|
|
956
|
+
else
|
|
957
|
+
selectedItemCache = null;
|
|
958
|
+
},
|
|
959
|
+
onOpenNode(id) {
|
|
960
|
+
window.__app.openItemDetail(id);
|
|
961
|
+
},
|
|
962
|
+
onContextMenu(id, x, y) { showCtxMenu(id, x, y); },
|
|
963
|
+
});
|
|
964
|
+
canvasRef.current.setData(toCanvasNodes(nodes, rels), toCanvasEdges(rels));
|
|
965
|
+
syncCanvas();
|
|
966
|
+
}
|
|
967
|
+
// ── Panel / drawer updates ────────────────────────────────────
|
|
968
|
+
function updateInfoPanel() {
|
|
969
|
+
if (!currentGraph)
|
|
970
|
+
return;
|
|
971
|
+
const panel = document.getElementById('graph-info-panel');
|
|
972
|
+
if (panel)
|
|
973
|
+
panel.innerHTML = renderInfoPanel(currentGraph, selectedItemCache ?? undefined);
|
|
974
|
+
const relList = document.getElementById('graph-rel-list');
|
|
975
|
+
if (relList)
|
|
976
|
+
relList.innerHTML = renderRelList(currentGraph);
|
|
977
|
+
bindInfoPanelEvents();
|
|
978
|
+
}
|
|
979
|
+
function updateFilterToolbarState() {
|
|
980
|
+
const dirSel = document.getElementById('graph-filter-direction');
|
|
981
|
+
const depthSldr = document.getElementById('graph-filter-depth');
|
|
982
|
+
const depthLbl = document.getElementById('graph-depth-label');
|
|
983
|
+
const scopeBtn = document.getElementById('graph-scope-btn');
|
|
984
|
+
if (dirSel)
|
|
985
|
+
dirSel.disabled = !selectedNodeId;
|
|
986
|
+
if (depthSldr) {
|
|
987
|
+
depthSldr.disabled = !(selectedNodeId && filter.scope === 'focus');
|
|
988
|
+
depthSldr.value = filter.depth;
|
|
989
|
+
}
|
|
990
|
+
if (depthLbl)
|
|
991
|
+
depthLbl.textContent = filter.depth;
|
|
992
|
+
if (scopeBtn)
|
|
993
|
+
scopeBtn.textContent = filter.scope === 'focus' ? '⊙ Show All Nodes' : '⊕ Focus on Selected';
|
|
994
|
+
document.querySelectorAll('[data-graph-preset]').forEach((presetBtn) => {
|
|
995
|
+
presetBtn.classList.toggle('active', graphPresetActive(presetBtn.dataset.graphPreset || ''));
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
// ── Legend update ─────────────────────────────────────────────
|
|
999
|
+
const TYPE_COLORS_MAP = {
|
|
1000
|
+
task: '#2dd4bf', feature: '#60a5fa', epic: '#a78bfa', bug: '#f87171',
|
|
1001
|
+
milestone: '#fbbf24', story: '#34d399', chore: '#94a3b8', release: '#38bdf8',
|
|
1002
|
+
};
|
|
1003
|
+
const TAG_PALETTE_JS = ['#2dd4bf', '#60a5fa', '#a78bfa', '#f87171', '#fbbf24', '#34d399', '#fb923c', '#e879f9'];
|
|
1004
|
+
function computeTagColorMap(nodes) {
|
|
1005
|
+
const freq = new Map();
|
|
1006
|
+
for (const n of nodes) {
|
|
1007
|
+
const tags = Array.isArray(n.properties?.tags) ? n.properties.tags.map(String) : [];
|
|
1008
|
+
for (const t of tags)
|
|
1009
|
+
freq.set(t, (freq.get(t) ?? 0) + 1);
|
|
1010
|
+
}
|
|
1011
|
+
const top = [...freq.entries()].sort((a, b) => b[1] - a[1]).slice(0, TAG_PALETTE_JS.length).map(([t]) => t);
|
|
1012
|
+
return new Map(top.map((t, i) => [t, TAG_PALETTE_JS[i]]));
|
|
1013
|
+
}
|
|
1014
|
+
function updateLegend() {
|
|
1015
|
+
const legend = document.getElementById('graph-legend-hud');
|
|
1016
|
+
if (!legend)
|
|
1017
|
+
return;
|
|
1018
|
+
const nodes = currentGraph?.graph?.nodes ?? [];
|
|
1019
|
+
if (filter.colorMode === 'type') {
|
|
1020
|
+
const typeCounts = nodes.filter(isItemNode).reduce((acc, n) => {
|
|
1021
|
+
const t = nodeType(n);
|
|
1022
|
+
acc[t] = (acc[t] || 0) + 1;
|
|
1023
|
+
return acc;
|
|
1024
|
+
}, {});
|
|
1025
|
+
const shown = Object.entries(typeCounts).sort((a, b) => b[1] - a[1]).slice(0, 6);
|
|
1026
|
+
legend.innerHTML = `
|
|
1027
|
+
<span><i class="legend-dot legend-facet"></i>Metadata</span>
|
|
1028
|
+
<span><i class="legend-dot legend-external"></i>External</span>
|
|
1029
|
+
<span class="legend-sep">·</span>
|
|
1030
|
+
${shown.map(([t]) => {
|
|
1031
|
+
const c = TYPE_COLORS_MAP[t.toLowerCase()] ?? '#64748b';
|
|
1032
|
+
return `<span><i class="legend-dot" style="background:${c};box-shadow:0 0 4px ${c}66"></i>${escHtml(t)}</span>`;
|
|
1033
|
+
}).join('')}
|
|
1034
|
+
`;
|
|
1035
|
+
}
|
|
1036
|
+
else if (filter.colorMode === 'tag') {
|
|
1037
|
+
const tagMap = computeTagColorMap(nodes);
|
|
1038
|
+
legend.innerHTML = `
|
|
1039
|
+
<span><i class="legend-dot legend-facet"></i>Metadata</span>
|
|
1040
|
+
<span><i class="legend-dot legend-external"></i>External</span>
|
|
1041
|
+
<span class="legend-sep">·</span>
|
|
1042
|
+
${[...tagMap.entries()].slice(0, 6).map(([t, c]) => `<button class="legend-tag-btn" data-legend-tag="${escHtml(t)}" style="border-color:${c}33;color:${c}"><i class="legend-dot" style="background:${c};box-shadow:0 0 4px ${c}66;flex-shrink:0"></i>#${escHtml(t)}</button>`).join('')}
|
|
1043
|
+
${tagMap.size === 0 ? '<span style="color:var(--text-muted);font-size:11px">No tags</span>' : ''}
|
|
1044
|
+
`;
|
|
1045
|
+
// Bind tag filter clicks
|
|
1046
|
+
legend.querySelectorAll('[data-legend-tag]').forEach((btn) => {
|
|
1047
|
+
btn.addEventListener('click', () => {
|
|
1048
|
+
const tag = btn.dataset.legendTag || '';
|
|
1049
|
+
const isActive = filter.query === tag;
|
|
1050
|
+
filter = { ...filter, query: isActive ? '' : tag };
|
|
1051
|
+
const inp = document.getElementById('graph-filter-query');
|
|
1052
|
+
if (inp)
|
|
1053
|
+
inp.value = filter.query;
|
|
1054
|
+
syncCanvas();
|
|
1055
|
+
updateInfoPanel();
|
|
1056
|
+
legend.querySelectorAll('[data-legend-tag]').forEach((b) => {
|
|
1057
|
+
b.classList.toggle('active', !isActive && b.dataset.legendTag === tag);
|
|
1058
|
+
});
|
|
1059
|
+
});
|
|
1060
|
+
});
|
|
1061
|
+
}
|
|
1062
|
+
else if (filter.depMode) {
|
|
1063
|
+
legend.innerHTML = `
|
|
1064
|
+
<span><i class="legend-dot legend-item"></i>Item</span>
|
|
1065
|
+
<span class="legend-sep">·</span>
|
|
1066
|
+
<span><i class="legend-line" style="background:#fb923c"></i>depends on</span>
|
|
1067
|
+
<span><i class="legend-line" style="background:#f87171"></i>blocked by</span>
|
|
1068
|
+
<span><i class="legend-line" style="background:#60a5fa"></i>parent / child</span>
|
|
1069
|
+
<span><i class="legend-line" style="background:#94a3b8"></i>related</span>
|
|
1070
|
+
<span class="legend-sep">·</span>
|
|
1071
|
+
<span><i class="legend-dot" style="background:#fbbf24;box-shadow:0 0 6px #fbbf2488"></i>critical path</span>
|
|
1072
|
+
`;
|
|
1073
|
+
}
|
|
1074
|
+
else {
|
|
1075
|
+
legend.innerHTML = `
|
|
1076
|
+
<span><i class="legend-dot legend-item"></i>Item</span>
|
|
1077
|
+
<span><i class="legend-dot legend-facet"></i>Metadata</span>
|
|
1078
|
+
<span><i class="legend-dot legend-external"></i>External</span>
|
|
1079
|
+
<span class="legend-sep">·</span>
|
|
1080
|
+
<span><i class="legend-dot" style="background:#2dd4bf;box-shadow:0 0 4px #2dd4bf66"></i>open</span>
|
|
1081
|
+
<span><i class="legend-dot" style="background:#fb923c;box-shadow:0 0 4px #fb923c66"></i>in-progress</span>
|
|
1082
|
+
<span><i class="legend-dot" style="background:#f87171;box-shadow:0 0 4px #f8717166"></i>blocked</span>
|
|
1083
|
+
<span><i class="legend-dot" style="background:#64748b"></i>closed</span>
|
|
1084
|
+
<span><i class="legend-dot" style="background:#94a3b8"></i>draft</span>
|
|
1085
|
+
`;
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
// ── Event bindings ────────────────────────────────────────────
|
|
1089
|
+
function bindInfoPanelEvents() {
|
|
1090
|
+
document.getElementById('graph-open-selected')?.addEventListener('click', () => {
|
|
1091
|
+
if (selectedNodeId)
|
|
1092
|
+
window.__app.openItemDetail(selectedNodeId);
|
|
1093
|
+
});
|
|
1094
|
+
document.getElementById('graph-clear-selected')?.addEventListener('click', () => {
|
|
1095
|
+
selectedNodeId = '';
|
|
1096
|
+
filter = { ...filter, scope: 'all', direction: 'all' };
|
|
1097
|
+
canvasRef.current?.setSelected(null);
|
|
1098
|
+
updateInfoPanel();
|
|
1099
|
+
syncCanvas();
|
|
1100
|
+
updateFilterToolbarState();
|
|
1101
|
+
pushGraphState();
|
|
1102
|
+
});
|
|
1103
|
+
// Add Dependency button
|
|
1104
|
+
document.getElementById('graph-add-dep-btn')?.addEventListener('click', () => {
|
|
1105
|
+
showAddDependencyModal();
|
|
1106
|
+
});
|
|
1107
|
+
// Remove Dependency button
|
|
1108
|
+
document.getElementById('graph-remove-dep-btn')?.addEventListener('click', () => {
|
|
1109
|
+
showRemoveDependencyModal();
|
|
1110
|
+
});
|
|
1111
|
+
document.querySelectorAll('[data-graph-node-id]').forEach((btn) => {
|
|
1112
|
+
btn.addEventListener('click', () => {
|
|
1113
|
+
selectedNodeId = btn.dataset.graphNodeId || '';
|
|
1114
|
+
canvasRef.current?.setSelected(selectedNodeId || null);
|
|
1115
|
+
updateInfoPanel();
|
|
1116
|
+
syncCanvas();
|
|
1117
|
+
updateFilterToolbarState();
|
|
1118
|
+
});
|
|
1119
|
+
});
|
|
1120
|
+
document.querySelectorAll('[data-graph-query]').forEach((btn) => {
|
|
1121
|
+
btn.addEventListener('click', () => {
|
|
1122
|
+
filter = { ...filter, query: btn.dataset.graphQuery || '' };
|
|
1123
|
+
const inp = document.getElementById('graph-filter-query');
|
|
1124
|
+
if (inp)
|
|
1125
|
+
inp.value = filter.query;
|
|
1126
|
+
updateInfoPanel();
|
|
1127
|
+
syncCanvas();
|
|
1128
|
+
});
|
|
1129
|
+
});
|
|
1130
|
+
document.querySelectorAll('[data-graph-from-id][data-graph-to-id]').forEach((btn) => {
|
|
1131
|
+
btn.addEventListener('click', () => {
|
|
1132
|
+
const toId = btn.dataset.graphToId || '';
|
|
1133
|
+
selectedNodeId = toId;
|
|
1134
|
+
canvasRef.current?.setSelected(toId);
|
|
1135
|
+
updateInfoPanel();
|
|
1136
|
+
syncCanvas();
|
|
1137
|
+
updateFilterToolbarState();
|
|
1138
|
+
});
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1141
|
+
function bindHudEvents() {
|
|
1142
|
+
// Back button
|
|
1143
|
+
document.getElementById('graph-back-btn')?.addEventListener('click', () => {
|
|
1144
|
+
removeCtxMenu();
|
|
1145
|
+
// Remove graph keyboard handler
|
|
1146
|
+
const kh = window.__graphKeyHandler;
|
|
1147
|
+
if (kh) {
|
|
1148
|
+
document.removeEventListener('keydown', kh);
|
|
1149
|
+
delete window.__graphKeyHandler;
|
|
1150
|
+
}
|
|
1151
|
+
window.__app.showView('items');
|
|
1152
|
+
});
|
|
1153
|
+
const runGraphSync = async () => {
|
|
1154
|
+
if (!state.currentProject || graphSyncInFlight)
|
|
1155
|
+
return;
|
|
1156
|
+
const syncBtn = document.getElementById('graph-sync-btn');
|
|
1157
|
+
graphSyncInFlight = true;
|
|
1158
|
+
if (syncBtn) {
|
|
1159
|
+
syncBtn.disabled = true;
|
|
1160
|
+
syncBtn.textContent = 'Syncing…';
|
|
1161
|
+
}
|
|
1162
|
+
try {
|
|
1163
|
+
await api('POST', `/projects/${state.currentProject.id}/pm/graph/sync`);
|
|
1164
|
+
toast('Graph sync completed', 'success');
|
|
1165
|
+
await renderGraphView();
|
|
1166
|
+
}
|
|
1167
|
+
catch (err) {
|
|
1168
|
+
toast(err instanceof Error ? err.message : String(err), 'error');
|
|
1169
|
+
}
|
|
1170
|
+
finally {
|
|
1171
|
+
graphSyncInFlight = false;
|
|
1172
|
+
if (syncBtn) {
|
|
1173
|
+
syncBtn.disabled = false;
|
|
1174
|
+
syncBtn.textContent = '⧉ Sync';
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
};
|
|
1178
|
+
// Refresh
|
|
1179
|
+
document.getElementById('graph-refresh')?.addEventListener('click', () => {
|
|
1180
|
+
canvasRef.current?.destroy();
|
|
1181
|
+
canvasRef.current = null;
|
|
1182
|
+
void renderGraphView();
|
|
1183
|
+
});
|
|
1184
|
+
document.getElementById('graph-sync-btn')?.addEventListener('click', () => {
|
|
1185
|
+
void runGraphSync();
|
|
1186
|
+
});
|
|
1187
|
+
// Fit view
|
|
1188
|
+
document.getElementById('graph-fit-btn')?.addEventListener('click', () => canvasRef.current?.fitView());
|
|
1189
|
+
document.querySelectorAll('[data-graph-preset]').forEach((btn) => {
|
|
1190
|
+
btn.addEventListener('click', () => {
|
|
1191
|
+
const preset = btn.dataset.graphPreset || 'knowledge';
|
|
1192
|
+
if (preset === 'knowledge') {
|
|
1193
|
+
filter = { ...filter, depMode: false, kind: 'items', rel: 'all', scope: 'all', direction: 'all', colorMode: 'status' };
|
|
1194
|
+
}
|
|
1195
|
+
else if (preset === 'dependency') {
|
|
1196
|
+
filter = { ...filter, depMode: true, kind: 'items', rel: 'all', scope: 'all', direction: 'all', layout: 'hierarchical' };
|
|
1197
|
+
canvasRef.current?.setLayout('hierarchical');
|
|
1198
|
+
}
|
|
1199
|
+
else if (preset === 'unlinked') {
|
|
1200
|
+
filter = { ...filter, depMode: false, kind: 'unlinked', rel: 'all', scope: 'all', direction: 'all' };
|
|
1201
|
+
}
|
|
1202
|
+
else if (preset === 'metadata') {
|
|
1203
|
+
filter = { ...filter, depMode: false, kind: 'all', rel: 'all', scope: 'all', direction: 'all', colorMode: 'type' };
|
|
1204
|
+
}
|
|
1205
|
+
else if (preset === 'critical') {
|
|
1206
|
+
const nextSelected = selectedNodeId || [...criticalPath][0] || '';
|
|
1207
|
+
selectedNodeId = nextSelected;
|
|
1208
|
+
filter = { ...filter, depMode: true, kind: 'items', rel: 'all', scope: nextSelected ? 'focus' : 'all', depth: '2', direction: 'connected', layout: 'hierarchical' };
|
|
1209
|
+
canvasRef.current?.setLayout('hierarchical');
|
|
1210
|
+
canvasRef.current?.setSelected(nextSelected || null);
|
|
1211
|
+
}
|
|
1212
|
+
else if (preset === 'tags') {
|
|
1213
|
+
filter = { ...filter, depMode: false, kind: 'all', rel: 'all', scope: 'all', direction: 'all', colorMode: 'tag', layout: 'force' };
|
|
1214
|
+
canvasRef.current?.setLayout('force');
|
|
1215
|
+
}
|
|
1216
|
+
updateInfoPanel();
|
|
1217
|
+
syncCanvas();
|
|
1218
|
+
updateFilterToolbarState();
|
|
1219
|
+
updateLegend();
|
|
1220
|
+
// Sync color-mode select to reflect new colorMode (for Filters panel)
|
|
1221
|
+
const colorSel = document.getElementById('graph-color-mode');
|
|
1222
|
+
if (colorSel)
|
|
1223
|
+
colorSel.value = filter.colorMode;
|
|
1224
|
+
// Fit and reheat after preset switch so the new layout settles cleanly
|
|
1225
|
+
setTimeout(() => { canvasRef.current?.reheat(); canvasRef.current?.fitView(); }, 80);
|
|
1226
|
+
document.querySelectorAll('[data-graph-preset]').forEach((presetBtn) => {
|
|
1227
|
+
presetBtn.classList.toggle('active', graphPresetActive(presetBtn.dataset.graphPreset || ''));
|
|
1228
|
+
});
|
|
1229
|
+
pushGraphState();
|
|
1230
|
+
});
|
|
1231
|
+
});
|
|
1232
|
+
// Physics toggle
|
|
1233
|
+
document.getElementById('graph-physics-btn')?.addEventListener('click', (e) => {
|
|
1234
|
+
const paused = canvasRef.current?.togglePhysics() ?? false;
|
|
1235
|
+
physicsLabel = paused ? 'Resume Physics' : 'Pause Physics';
|
|
1236
|
+
e.target.textContent = physicsLabel;
|
|
1237
|
+
});
|
|
1238
|
+
// Export PNG
|
|
1239
|
+
document.getElementById('graph-export-png')?.addEventListener('click', () => {
|
|
1240
|
+
canvasRef.current?.exportPng();
|
|
1241
|
+
});
|
|
1242
|
+
// Layout selector
|
|
1243
|
+
document.getElementById('graph-layout-select')?.addEventListener('change', (e) => {
|
|
1244
|
+
const layout = e.target.value;
|
|
1245
|
+
filter = { ...filter, layout };
|
|
1246
|
+
canvasRef.current?.setLayout(layout);
|
|
1247
|
+
pushGraphState();
|
|
1248
|
+
});
|
|
1249
|
+
// Edge bundling toggle
|
|
1250
|
+
document.getElementById('graph-bundle-btn')?.addEventListener('click', () => {
|
|
1251
|
+
filter = { ...filter, edgeBundling: !filter.edgeBundling };
|
|
1252
|
+
canvasRef.current?.setEdgeBundling(filter.edgeBundling);
|
|
1253
|
+
document.getElementById('graph-bundle-btn')?.classList.toggle('active', filter.edgeBundling);
|
|
1254
|
+
});
|
|
1255
|
+
// Filter toggle
|
|
1256
|
+
document.getElementById('graph-filter-toggle')?.addEventListener('click', () => {
|
|
1257
|
+
filterOpen = !filterOpen;
|
|
1258
|
+
document.getElementById('graph-filter-overlay')?.classList.toggle('open', filterOpen);
|
|
1259
|
+
document.getElementById('graph-filter-toggle')?.classList.toggle('active', filterOpen);
|
|
1260
|
+
});
|
|
1261
|
+
document.getElementById('graph-filter-close')?.addEventListener('click', () => {
|
|
1262
|
+
filterOpen = false;
|
|
1263
|
+
document.getElementById('graph-filter-overlay')?.classList.remove('open');
|
|
1264
|
+
document.getElementById('graph-filter-toggle')?.classList.remove('active');
|
|
1265
|
+
});
|
|
1266
|
+
// Info drawer toggle
|
|
1267
|
+
document.getElementById('graph-info-toggle')?.addEventListener('click', () => {
|
|
1268
|
+
infoDrawerOpen = !infoDrawerOpen;
|
|
1269
|
+
document.getElementById('graph-info-drawer')?.classList.toggle('open', infoDrawerOpen);
|
|
1270
|
+
document.getElementById('graph-info-toggle')?.classList.toggle('active', infoDrawerOpen);
|
|
1271
|
+
});
|
|
1272
|
+
document.getElementById('graph-info-close')?.addEventListener('click', () => {
|
|
1273
|
+
infoDrawerOpen = false;
|
|
1274
|
+
document.getElementById('graph-info-drawer')?.classList.remove('open');
|
|
1275
|
+
document.getElementById('graph-info-toggle')?.classList.remove('active');
|
|
1276
|
+
});
|
|
1277
|
+
// Rel drawer toggle
|
|
1278
|
+
document.getElementById('graph-rel-toggle')?.addEventListener('click', () => {
|
|
1279
|
+
relDrawerOpen = !relDrawerOpen;
|
|
1280
|
+
document.getElementById('graph-rel-drawer')?.classList.toggle('open', relDrawerOpen);
|
|
1281
|
+
document.getElementById('graph-rel-toggle')?.classList.toggle('active', relDrawerOpen);
|
|
1282
|
+
});
|
|
1283
|
+
document.getElementById('graph-rel-close')?.addEventListener('click', () => {
|
|
1284
|
+
relDrawerOpen = false;
|
|
1285
|
+
document.getElementById('graph-rel-drawer')?.classList.remove('open');
|
|
1286
|
+
document.getElementById('graph-rel-toggle')?.classList.remove('active');
|
|
1287
|
+
});
|
|
1288
|
+
// Scope (focus) button
|
|
1289
|
+
document.getElementById('graph-scope-btn')?.addEventListener('click', () => {
|
|
1290
|
+
filter = { ...filter, scope: filter.scope === 'focus' ? 'all' : 'focus' };
|
|
1291
|
+
updateInfoPanel();
|
|
1292
|
+
syncCanvas();
|
|
1293
|
+
updateFilterToolbarState();
|
|
1294
|
+
});
|
|
1295
|
+
// Filter selects
|
|
1296
|
+
const onFilterChange = (id, key, getValue) => {
|
|
1297
|
+
document.getElementById(id)?.addEventListener('change', (e) => {
|
|
1298
|
+
const val = getValue(e.target);
|
|
1299
|
+
filter = { ...filter, [key]: val };
|
|
1300
|
+
if (key === 'rel') {
|
|
1301
|
+
canvasRef.current?.setFilter({ highlightRelTypes: val !== 'all' ? new Set([val]) : new Set() });
|
|
1302
|
+
}
|
|
1303
|
+
updateInfoPanel();
|
|
1304
|
+
syncCanvas();
|
|
1305
|
+
updateFilterToolbarState();
|
|
1306
|
+
});
|
|
1307
|
+
};
|
|
1308
|
+
onFilterChange('graph-filter-kind', 'kind', (el) => el.value);
|
|
1309
|
+
onFilterChange('graph-filter-rel', 'rel', (el) => el.value);
|
|
1310
|
+
onFilterChange('graph-filter-direction', 'direction', (el) => el.value);
|
|
1311
|
+
onFilterChange('graph-filter-status', 'statusFilter', (el) => el.value);
|
|
1312
|
+
// Depth slider (range input, not select)
|
|
1313
|
+
const depthSlider = document.getElementById('graph-filter-depth');
|
|
1314
|
+
const depthLabel = document.getElementById('graph-depth-label');
|
|
1315
|
+
depthSlider?.addEventListener('input', () => {
|
|
1316
|
+
filter = { ...filter, depth: depthSlider.value };
|
|
1317
|
+
if (depthLabel)
|
|
1318
|
+
depthLabel.textContent = depthSlider.value;
|
|
1319
|
+
updateInfoPanel();
|
|
1320
|
+
syncCanvas();
|
|
1321
|
+
updateFilterToolbarState();
|
|
1322
|
+
pushGraphState();
|
|
1323
|
+
});
|
|
1324
|
+
// All filter changes push URL state
|
|
1325
|
+
document.querySelectorAll('#graph-filter-overlay select').forEach((sel) => {
|
|
1326
|
+
sel.addEventListener('change', () => pushGraphState());
|
|
1327
|
+
});
|
|
1328
|
+
// Physics panel toggle
|
|
1329
|
+
document.getElementById('graph-physics-panel-toggle')?.addEventListener('click', () => {
|
|
1330
|
+
physicsOpen = !physicsOpen;
|
|
1331
|
+
document.getElementById('graph-physics-panel')?.classList.toggle('open', physicsOpen);
|
|
1332
|
+
document.getElementById('graph-physics-panel-toggle')?.classList.toggle('active', physicsOpen);
|
|
1333
|
+
if (physicsOpen && canvasRef.current) {
|
|
1334
|
+
const params = canvasRef.current.getPhysicsParams();
|
|
1335
|
+
const repEl = document.getElementById('graph-physics-repulsion');
|
|
1336
|
+
const ldEl = document.getElementById('graph-physics-linkdist');
|
|
1337
|
+
const lsEl = document.getElementById('graph-physics-linkstr');
|
|
1338
|
+
const gEl = document.getElementById('graph-physics-gravity');
|
|
1339
|
+
if (repEl) {
|
|
1340
|
+
repEl.value = String(params.repulsion);
|
|
1341
|
+
}
|
|
1342
|
+
if (ldEl) {
|
|
1343
|
+
ldEl.value = String(params.linkDistance);
|
|
1344
|
+
}
|
|
1345
|
+
if (lsEl) {
|
|
1346
|
+
lsEl.value = String(Math.round(params.linkStrength * 100));
|
|
1347
|
+
}
|
|
1348
|
+
if (gEl) {
|
|
1349
|
+
gEl.value = String(Math.round(params.centerForce * 1000));
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
});
|
|
1353
|
+
document.getElementById('graph-physics-close')?.addEventListener('click', () => {
|
|
1354
|
+
physicsOpen = false;
|
|
1355
|
+
document.getElementById('graph-physics-panel')?.classList.remove('open');
|
|
1356
|
+
document.getElementById('graph-physics-panel-toggle')?.classList.remove('active');
|
|
1357
|
+
});
|
|
1358
|
+
const bindPhysicsSlider = (id, valId, key, scale, decimals) => {
|
|
1359
|
+
const el = document.getElementById(id);
|
|
1360
|
+
const valEl = document.getElementById(valId);
|
|
1361
|
+
el?.addEventListener('input', () => {
|
|
1362
|
+
const raw = parseFloat(el.value) / scale;
|
|
1363
|
+
if (valEl)
|
|
1364
|
+
valEl.textContent = decimals > 0 ? raw.toFixed(decimals) : String(Math.round(raw * scale));
|
|
1365
|
+
canvasRef.current?.setPhysicsParams({ [key]: raw });
|
|
1366
|
+
});
|
|
1367
|
+
};
|
|
1368
|
+
bindPhysicsSlider('graph-physics-repulsion', 'graph-physics-repulsion-val', 'repulsion', 1, 0);
|
|
1369
|
+
bindPhysicsSlider('graph-physics-linkdist', 'graph-physics-linkdist-val', 'linkDistance', 1, 0);
|
|
1370
|
+
bindPhysicsSlider('graph-physics-linkstr', 'graph-physics-linkstr-val', 'linkStrength', 100, 3);
|
|
1371
|
+
bindPhysicsSlider('graph-physics-gravity', 'graph-physics-gravity-val', 'centerForce', 1000, 3);
|
|
1372
|
+
document.getElementById('graph-physics-reset')?.addEventListener('click', () => {
|
|
1373
|
+
canvasRef.current?.setPhysicsParams({ repulsion: 2000, linkDistance: 140, linkStrength: 0.065, centerForce: 0.010 });
|
|
1374
|
+
const repEl = document.getElementById('graph-physics-repulsion');
|
|
1375
|
+
const ldEl = document.getElementById('graph-physics-linkdist');
|
|
1376
|
+
const lsEl = document.getElementById('graph-physics-linkstr');
|
|
1377
|
+
const gEl = document.getElementById('graph-physics-gravity');
|
|
1378
|
+
if (repEl)
|
|
1379
|
+
repEl.value = '2000';
|
|
1380
|
+
if (ldEl)
|
|
1381
|
+
ldEl.value = '140';
|
|
1382
|
+
if (lsEl)
|
|
1383
|
+
lsEl.value = '7';
|
|
1384
|
+
if (gEl)
|
|
1385
|
+
gEl.value = '10';
|
|
1386
|
+
const repValEl = document.getElementById('graph-physics-repulsion-val');
|
|
1387
|
+
const ldValEl = document.getElementById('graph-physics-linkdist-val');
|
|
1388
|
+
const lsValEl = document.getElementById('graph-physics-linkstr-val');
|
|
1389
|
+
const gValEl = document.getElementById('graph-physics-gravity-val');
|
|
1390
|
+
if (repValEl)
|
|
1391
|
+
repValEl.textContent = '2000';
|
|
1392
|
+
if (ldValEl)
|
|
1393
|
+
ldValEl.textContent = '140';
|
|
1394
|
+
if (lsValEl)
|
|
1395
|
+
lsValEl.textContent = '0.065';
|
|
1396
|
+
if (gValEl)
|
|
1397
|
+
gValEl.textContent = '0.010';
|
|
1398
|
+
});
|
|
1399
|
+
document.getElementById('graph-dep-mode-btn')?.addEventListener('click', () => {
|
|
1400
|
+
filter = {
|
|
1401
|
+
...filter,
|
|
1402
|
+
depMode: !filter.depMode,
|
|
1403
|
+
rel: filter.depMode ? filter.rel : 'all',
|
|
1404
|
+
kind: filter.depMode ? filter.kind : 'items',
|
|
1405
|
+
};
|
|
1406
|
+
updateInfoPanel();
|
|
1407
|
+
syncCanvas();
|
|
1408
|
+
updateFilterToolbarState();
|
|
1409
|
+
updateLegend();
|
|
1410
|
+
const btn = document.getElementById('graph-dep-mode-btn');
|
|
1411
|
+
btn?.classList.toggle('active', filter.depMode);
|
|
1412
|
+
const strong = btn?.querySelector('strong');
|
|
1413
|
+
if (strong)
|
|
1414
|
+
strong.textContent = filter.depMode ? 'On' : 'Off';
|
|
1415
|
+
});
|
|
1416
|
+
document.getElementById('graph-color-mode')?.addEventListener('change', (e) => {
|
|
1417
|
+
filter = { ...filter, colorMode: e.target.value };
|
|
1418
|
+
syncCanvas();
|
|
1419
|
+
updateLegend();
|
|
1420
|
+
// If switching to tag mode, reheat so clustering force takes effect
|
|
1421
|
+
if (filter.colorMode === 'tag')
|
|
1422
|
+
canvasRef.current?.reheat();
|
|
1423
|
+
// Sync preset buttons
|
|
1424
|
+
document.querySelectorAll('[data-graph-preset]').forEach((b) => {
|
|
1425
|
+
b.classList.toggle('active', graphPresetActive(b.dataset.graphPreset || ''));
|
|
1426
|
+
});
|
|
1427
|
+
pushGraphState();
|
|
1428
|
+
});
|
|
1429
|
+
// Search input
|
|
1430
|
+
const queryInput = document.getElementById('graph-filter-query');
|
|
1431
|
+
queryInput?.addEventListener('input', (e) => {
|
|
1432
|
+
filter = { ...filter, query: e.target.value };
|
|
1433
|
+
updateInfoPanel();
|
|
1434
|
+
syncCanvas();
|
|
1435
|
+
});
|
|
1436
|
+
// Global keyboard shortcuts for the graph view
|
|
1437
|
+
const graphKeyHandler = (e) => {
|
|
1438
|
+
const tag = e.target.tagName.toLowerCase();
|
|
1439
|
+
if (tag === 'input' || tag === 'select' || tag === 'textarea')
|
|
1440
|
+
return;
|
|
1441
|
+
if (e.code === 'Space' && !e.ctrlKey && !e.metaKey) {
|
|
1442
|
+
e.preventDefault();
|
|
1443
|
+
const paused = canvasRef.current?.togglePhysics() ?? false;
|
|
1444
|
+
physicsLabel = paused ? 'Resume Physics' : 'Pause Physics';
|
|
1445
|
+
const physBtn = document.getElementById('graph-physics-btn');
|
|
1446
|
+
if (physBtn)
|
|
1447
|
+
physBtn.textContent = physicsLabel;
|
|
1448
|
+
}
|
|
1449
|
+
if ((e.key === 'f' || e.key === 'F') && !e.ctrlKey && !e.metaKey) {
|
|
1450
|
+
e.preventDefault();
|
|
1451
|
+
canvasRef.current?.fitView();
|
|
1452
|
+
}
|
|
1453
|
+
if ((e.key === 'r' || e.key === 'R') && !e.ctrlKey && !e.metaKey) {
|
|
1454
|
+
e.preventDefault();
|
|
1455
|
+
canvasRef.current?.destroy();
|
|
1456
|
+
canvasRef.current = null;
|
|
1457
|
+
void renderGraphView();
|
|
1458
|
+
}
|
|
1459
|
+
if ((e.key === 's' || e.key === 'S') && !e.ctrlKey && !e.metaKey) {
|
|
1460
|
+
e.preventDefault();
|
|
1461
|
+
void runGraphSync();
|
|
1462
|
+
}
|
|
1463
|
+
if ((e.key === 'i' || e.key === 'I') && !e.ctrlKey && !e.metaKey) {
|
|
1464
|
+
e.preventDefault();
|
|
1465
|
+
infoDrawerOpen = !infoDrawerOpen;
|
|
1466
|
+
document.getElementById('graph-info-drawer')?.classList.toggle('open', infoDrawerOpen);
|
|
1467
|
+
document.getElementById('graph-info-toggle')?.classList.toggle('active', infoDrawerOpen);
|
|
1468
|
+
}
|
|
1469
|
+
if ((e.key === 'g' || e.key === 'G') && !e.ctrlKey && !e.metaKey) {
|
|
1470
|
+
e.preventDefault();
|
|
1471
|
+
filterOpen = !filterOpen;
|
|
1472
|
+
document.getElementById('graph-filter-overlay')?.classList.toggle('open', filterOpen);
|
|
1473
|
+
document.getElementById('graph-filter-toggle')?.classList.toggle('active', filterOpen);
|
|
1474
|
+
}
|
|
1475
|
+
if ((e.key === 'f' || e.key === 'F') && (e.ctrlKey || e.metaKey)) {
|
|
1476
|
+
e.preventDefault();
|
|
1477
|
+
queryInput?.focus();
|
|
1478
|
+
}
|
|
1479
|
+
};
|
|
1480
|
+
document.addEventListener('keydown', graphKeyHandler);
|
|
1481
|
+
// Store for cleanup on graph exit
|
|
1482
|
+
window.__graphKeyHandler = graphKeyHandler;
|
|
1483
|
+
}
|
|
1484
|
+
// ── URL routing (pushState) ─────────────────────────────────
|
|
1485
|
+
function pushGraphState() {
|
|
1486
|
+
if (!state.currentProject)
|
|
1487
|
+
return;
|
|
1488
|
+
const params = new URLSearchParams();
|
|
1489
|
+
params.set('project', state.currentProject.id);
|
|
1490
|
+
params.set('graph', '1');
|
|
1491
|
+
if (selectedNodeId)
|
|
1492
|
+
params.set('node', selectedNodeId);
|
|
1493
|
+
if (filter.scope === 'focus')
|
|
1494
|
+
params.set('scope', 'focus');
|
|
1495
|
+
if (filter.kind !== 'all')
|
|
1496
|
+
params.set('kind', filter.kind);
|
|
1497
|
+
if (filter.colorMode !== 'status')
|
|
1498
|
+
params.set('color', filter.colorMode);
|
|
1499
|
+
if (filter.depMode)
|
|
1500
|
+
params.set('dep', '1');
|
|
1501
|
+
if (filter.layout !== 'force')
|
|
1502
|
+
params.set('layout', filter.layout);
|
|
1503
|
+
const qs = params.toString();
|
|
1504
|
+
const url = qs ? `?${qs}` : window.location.pathname;
|
|
1505
|
+
history.replaceState(null, '', url);
|
|
1506
|
+
}
|
|
1507
|
+
let urlStateRestored = false;
|
|
1508
|
+
function restoreGraphState() {
|
|
1509
|
+
const params = new URLSearchParams(window.location.search);
|
|
1510
|
+
if (!params.has('graph'))
|
|
1511
|
+
return;
|
|
1512
|
+
urlStateRestored = true;
|
|
1513
|
+
if (params.has('node'))
|
|
1514
|
+
selectedNodeId = params.get('node') || '';
|
|
1515
|
+
if (params.has('scope'))
|
|
1516
|
+
filter = { ...filter, scope: params.get('scope') };
|
|
1517
|
+
if (params.has('kind'))
|
|
1518
|
+
filter = { ...filter, kind: (params.get('kind') || 'all') };
|
|
1519
|
+
if (params.has('color'))
|
|
1520
|
+
filter = { ...filter, colorMode: (params.get('color') || 'status') };
|
|
1521
|
+
if (params.has('dep'))
|
|
1522
|
+
filter = { ...filter, depMode: params.get('dep') === '1' };
|
|
1523
|
+
if (params.has('layout'))
|
|
1524
|
+
filter = { ...filter, layout: (params.get('layout') || 'force') };
|
|
1525
|
+
}
|
|
1526
|
+
// ── Dependency editing modals ────────────────────────────────
|
|
1527
|
+
function showAddDependencyModal() {
|
|
1528
|
+
if (!state.currentProject || !currentGraph)
|
|
1529
|
+
return;
|
|
1530
|
+
const graph = currentGraph.graph || {};
|
|
1531
|
+
const nodes = (graph.nodes || []).filter(isItemNode);
|
|
1532
|
+
const options = nodes.map((n) => `<option value="${escHtml(n.id)}">${escHtml(nodeTitle(n))} (${escHtml(n.id)})</option>`).join('');
|
|
1533
|
+
const html = `
|
|
1534
|
+
<div class="modal-backdrop" id="graph-add-dep-modal" style="display:flex">
|
|
1535
|
+
<div class="modal" style="max-width:440px">
|
|
1536
|
+
<div class="modal-header">
|
|
1537
|
+
<div class="modal-title">Add Dependency</div>
|
|
1538
|
+
<button class="modal-close" onclick="document.getElementById('graph-add-dep-modal')?.remove()">✕</button>
|
|
1539
|
+
</div>
|
|
1540
|
+
<div class="modal-body">
|
|
1541
|
+
<div class="form-group">
|
|
1542
|
+
<label class="form-label">Source item</label>
|
|
1543
|
+
<select class="form-select" id="graph-dep-from">${options}</select>
|
|
1544
|
+
</div>
|
|
1545
|
+
<div class="form-group">
|
|
1546
|
+
<label class="form-label">Depends on (target)</label>
|
|
1547
|
+
<select class="form-select" id="graph-dep-to">${options}</select>
|
|
1548
|
+
</div>
|
|
1549
|
+
<div class="form-group">
|
|
1550
|
+
<label class="form-label">Relationship type</label>
|
|
1551
|
+
<select class="form-select" id="graph-dep-type">
|
|
1552
|
+
<option value="blocked_by">Blocked by / depends on</option>
|
|
1553
|
+
<option value="blocks">Blocks</option>
|
|
1554
|
+
<option value="parent">Parent</option>
|
|
1555
|
+
<option value="child">Child</option>
|
|
1556
|
+
<option value="related">Related</option>
|
|
1557
|
+
</select>
|
|
1558
|
+
</div>
|
|
1559
|
+
<div id="graph-dep-error" class="form-error" style="display:none"></div>
|
|
1560
|
+
</div>
|
|
1561
|
+
<div class="modal-footer">
|
|
1562
|
+
<button class="btn btn-ghost" onclick="document.getElementById('graph-add-dep-modal')?.remove()">Cancel</button>
|
|
1563
|
+
<button class="btn btn-primary" id="graph-dep-submit">Add</button>
|
|
1564
|
+
</div>
|
|
1565
|
+
</div>
|
|
1566
|
+
</div>`;
|
|
1567
|
+
document.body.insertAdjacentHTML('beforeend', html);
|
|
1568
|
+
if (selectedNodeId) {
|
|
1569
|
+
const fromSel = document.getElementById('graph-dep-from');
|
|
1570
|
+
if (fromSel)
|
|
1571
|
+
fromSel.value = selectedNodeId;
|
|
1572
|
+
}
|
|
1573
|
+
document.getElementById('graph-dep-submit')?.addEventListener('click', async () => {
|
|
1574
|
+
const fromId = document.getElementById('graph-dep-from')?.value;
|
|
1575
|
+
const toId = document.getElementById('graph-dep-to')?.value;
|
|
1576
|
+
const relType = document.getElementById('graph-dep-type')?.value;
|
|
1577
|
+
const errEl = document.getElementById('graph-dep-error');
|
|
1578
|
+
if (!fromId || !toId || fromId === toId) {
|
|
1579
|
+
if (errEl) {
|
|
1580
|
+
errEl.textContent = 'Select two different items.';
|
|
1581
|
+
errEl.style.display = '';
|
|
1582
|
+
}
|
|
1583
|
+
return;
|
|
1584
|
+
}
|
|
1585
|
+
try {
|
|
1586
|
+
await api('POST', `/projects/${state.currentProject.id}/pm/deps/${fromId}`, { targetId: toId, rel: relType });
|
|
1587
|
+
document.getElementById('graph-add-dep-modal')?.remove();
|
|
1588
|
+
// Refresh graph
|
|
1589
|
+
canvasRef.current?.destroy();
|
|
1590
|
+
canvasRef.current = null;
|
|
1591
|
+
void renderGraphView();
|
|
1592
|
+
}
|
|
1593
|
+
catch (err) {
|
|
1594
|
+
if (errEl) {
|
|
1595
|
+
errEl.textContent = err instanceof Error ? err.message : String(err);
|
|
1596
|
+
errEl.style.display = '';
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
});
|
|
1600
|
+
}
|
|
1601
|
+
function showRemoveDependencyModal() {
|
|
1602
|
+
if (!state.currentProject || !currentGraph)
|
|
1603
|
+
return;
|
|
1604
|
+
const graph = currentGraph.graph || {};
|
|
1605
|
+
const rels = graph.relationships || [];
|
|
1606
|
+
const depRels = rels.filter(isDependencyRel);
|
|
1607
|
+
const options = depRels.map((r, i) => {
|
|
1608
|
+
const nodes = graph.nodes || [];
|
|
1609
|
+
const from = nodes.find((n) => n.id === r.from);
|
|
1610
|
+
const to = nodes.find((n) => n.id === r.to);
|
|
1611
|
+
return `<option value="${i}">${escHtml(dependencyLabel(r))}: ${escHtml(nodeTitle(from || { id: r.from }))} → ${escHtml(nodeTitle(to || { id: r.to }))}</option>`;
|
|
1612
|
+
}).join('');
|
|
1613
|
+
if (!depRels.length) {
|
|
1614
|
+
window.__app.toast('No dependencies to remove', 'info');
|
|
1615
|
+
return;
|
|
1616
|
+
}
|
|
1617
|
+
const html = `
|
|
1618
|
+
<div class="modal-backdrop" id="graph-remove-dep-modal" style="display:flex">
|
|
1619
|
+
<div class="modal" style="max-width:440px">
|
|
1620
|
+
<div class="modal-header">
|
|
1621
|
+
<div class="modal-title">Remove Dependency</div>
|
|
1622
|
+
<button class="modal-close" onclick="document.getElementById('graph-remove-dep-modal')?.remove()">✕</button>
|
|
1623
|
+
</div>
|
|
1624
|
+
<div class="modal-body">
|
|
1625
|
+
<div class="form-group">
|
|
1626
|
+
<label class="form-label">Select dependency to remove</label>
|
|
1627
|
+
<select class="form-select" id="graph-remove-dep-select" size="8" style="min-height:140px">${options}</select>
|
|
1628
|
+
</div>
|
|
1629
|
+
<div id="graph-remove-dep-error" class="form-error" style="display:none"></div>
|
|
1630
|
+
</div>
|
|
1631
|
+
<div class="modal-footer">
|
|
1632
|
+
<button class="btn btn-ghost" onclick="document.getElementById('graph-remove-dep-modal')?.remove()">Cancel</button>
|
|
1633
|
+
<button class="btn btn-danger" id="graph-remove-dep-submit">Remove</button>
|
|
1634
|
+
</div>
|
|
1635
|
+
</div>
|
|
1636
|
+
</div>`;
|
|
1637
|
+
document.body.insertAdjacentHTML('beforeend', html);
|
|
1638
|
+
document.getElementById('graph-remove-dep-submit')?.addEventListener('click', async () => {
|
|
1639
|
+
const selIdx = parseInt(document.getElementById('graph-remove-dep-select')?.value ?? '-1', 10);
|
|
1640
|
+
const errEl = document.getElementById('graph-remove-dep-error');
|
|
1641
|
+
const rel = depRels[selIdx];
|
|
1642
|
+
if (!rel) {
|
|
1643
|
+
if (errEl) {
|
|
1644
|
+
errEl.textContent = 'Select a dependency.';
|
|
1645
|
+
errEl.style.display = '';
|
|
1646
|
+
}
|
|
1647
|
+
return;
|
|
1648
|
+
}
|
|
1649
|
+
try {
|
|
1650
|
+
await api('DELETE', `/projects/${state.currentProject.id}/pm/deps/${rel.from}`, { targetId: rel.to, rel: rel.type });
|
|
1651
|
+
document.getElementById('graph-remove-dep-modal')?.remove();
|
|
1652
|
+
// Refresh graph
|
|
1653
|
+
canvasRef.current?.destroy();
|
|
1654
|
+
canvasRef.current = null;
|
|
1655
|
+
void renderGraphView();
|
|
1656
|
+
}
|
|
1657
|
+
catch (err) {
|
|
1658
|
+
if (errEl) {
|
|
1659
|
+
errEl.textContent = err instanceof Error ? err.message : String(err);
|
|
1660
|
+
errEl.style.display = '';
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
});
|
|
1664
|
+
}
|
|
1665
|
+
// ── Main entry point ──────────────────────────────────────────
|
|
1666
|
+
export async function renderGraphView() {
|
|
1667
|
+
const el = document.getElementById('content-graph');
|
|
1668
|
+
if (!el)
|
|
1669
|
+
return;
|
|
1670
|
+
if (!state.currentProject) {
|
|
1671
|
+
canvasRef.current?.destroy();
|
|
1672
|
+
canvasRef.current = null;
|
|
1673
|
+
el.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-muted);font-size:14px">Select a project to view its knowledge graph.</div>';
|
|
1674
|
+
return;
|
|
1675
|
+
}
|
|
1676
|
+
canvasRef.current?.destroy();
|
|
1677
|
+
canvasRef.current = null;
|
|
1678
|
+
el.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;gap:12px;color:var(--text-muted);font-size:13px"><div class="loading-spinner"></div>Loading graph…</div>';
|
|
1679
|
+
// Restore state from URL on first load
|
|
1680
|
+
restoreGraphState();
|
|
1681
|
+
try {
|
|
1682
|
+
currentGraph = await api('GET', `/projects/${state.currentProject.id}/pm/graph`);
|
|
1683
|
+
selectedItemCache = null;
|
|
1684
|
+
if (!urlStateRestored) {
|
|
1685
|
+
selectedNodeId = '';
|
|
1686
|
+
filter = { query: '', kind: 'items', rel: 'all', direction: 'all', scope: 'all', depth: '1', colorMode: 'status', depMode: false, layout: 'force', edgeBundling: false, statusFilter: '' };
|
|
1687
|
+
}
|
|
1688
|
+
criticalPath = computeCriticalPath(currentGraph.graph?.relationships ?? []);
|
|
1689
|
+
el.innerHTML = renderGraphShell(currentGraph);
|
|
1690
|
+
bindHudEvents();
|
|
1691
|
+
bindInfoPanelEvents();
|
|
1692
|
+
initCanvas();
|
|
1693
|
+
// Restore selected node after canvas init
|
|
1694
|
+
if (selectedNodeId) {
|
|
1695
|
+
canvasRef.current?.setSelected(selectedNodeId);
|
|
1696
|
+
updateInfoPanel();
|
|
1697
|
+
syncCanvas();
|
|
1698
|
+
// Auto-open info drawer when node is restored from URL
|
|
1699
|
+
if (!infoDrawerOpen) {
|
|
1700
|
+
infoDrawerOpen = true;
|
|
1701
|
+
document.getElementById('graph-info-drawer')?.classList.add('open');
|
|
1702
|
+
document.getElementById('graph-info-toggle')?.classList.add('active');
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
pushGraphState();
|
|
1706
|
+
}
|
|
1707
|
+
catch (err) {
|
|
1708
|
+
canvasRef.current?.destroy();
|
|
1709
|
+
canvasRef.current = null;
|
|
1710
|
+
el.innerHTML = `<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-muted);font-size:13px">Graph failed to load: ${escHtml(err instanceof Error ? err.message : String(err))}</div>`;
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
/**
|
|
1714
|
+
* Lightweight refresh from SSE events — updates graph data in-place
|
|
1715
|
+
* without destroying zoom/pan state.
|
|
1716
|
+
*/
|
|
1717
|
+
export async function refreshGraphData() {
|
|
1718
|
+
if (!state.currentProject || !canvasRef.current) {
|
|
1719
|
+
return renderGraphView();
|
|
1720
|
+
}
|
|
1721
|
+
try {
|
|
1722
|
+
const data = await api('GET', `/projects/${state.currentProject.id}/pm/graph`);
|
|
1723
|
+
currentGraph = data;
|
|
1724
|
+
const graph = data.graph || {};
|
|
1725
|
+
const nodes = graph.nodes || [];
|
|
1726
|
+
const rels = graph.relationships || [];
|
|
1727
|
+
canvasRef.current.setData(toCanvasNodes(nodes, rels), toCanvasEdges(rels));
|
|
1728
|
+
updateInfoPanel();
|
|
1729
|
+
syncCanvas();
|
|
1730
|
+
}
|
|
1731
|
+
catch {
|
|
1732
|
+
// Silently ignore — user can hit Refresh manually
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
// ── Local graph (embedded mini-graph for item detail) ─────────
|
|
1736
|
+
// Registry of active local graph canvases so they can be cleaned up
|
|
1737
|
+
const localGraphRegistry = new Map();
|
|
1738
|
+
export function destroyLocalGraph(containerId) {
|
|
1739
|
+
const existing = localGraphRegistry.get(containerId);
|
|
1740
|
+
if (existing) {
|
|
1741
|
+
existing.destroy();
|
|
1742
|
+
localGraphRegistry.delete(containerId);
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
export async function renderLocalGraph(containerId, nodeId, depth = 2) {
|
|
1746
|
+
const container = document.getElementById(containerId);
|
|
1747
|
+
if (!container || !state.currentProject)
|
|
1748
|
+
return;
|
|
1749
|
+
// Cleanup previous instance
|
|
1750
|
+
destroyLocalGraph(containerId);
|
|
1751
|
+
container.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;gap:8px;color:var(--text-muted);font-size:12px"><div class="loading-spinner" style="width:16px;height:16px"></div>Loading…</div>';
|
|
1752
|
+
// Use cached graph data if available, otherwise fetch
|
|
1753
|
+
let graphData = currentGraph;
|
|
1754
|
+
if (!graphData?.graph?.nodes?.length) {
|
|
1755
|
+
try {
|
|
1756
|
+
graphData = await api('GET', `/projects/${state.currentProject.id}/pm/graph`);
|
|
1757
|
+
}
|
|
1758
|
+
catch {
|
|
1759
|
+
container.innerHTML = '<div style="color:var(--text-muted);font-size:12px;padding:8px">Graph unavailable.</div>';
|
|
1760
|
+
return;
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
const graph = graphData.graph || {};
|
|
1764
|
+
const nodes = graph.nodes || [];
|
|
1765
|
+
const rels = graph.relationships || [];
|
|
1766
|
+
// Get neighborhood
|
|
1767
|
+
const neighborIds = expandedNeighborIds(nodeId, rels, depth, 'connected');
|
|
1768
|
+
if (neighborIds.size < 2) {
|
|
1769
|
+
container.innerHTML = '<div style="color:var(--text-muted);font-size:12px;padding:8px;text-align:center">No connections to display.<br><small>Add dependencies or tags to see the local graph.</small></div>';
|
|
1770
|
+
return;
|
|
1771
|
+
}
|
|
1772
|
+
const subNodes = nodes.filter((n) => neighborIds.has(n.id));
|
|
1773
|
+
const subRels = rels.filter((r) => neighborIds.has(r.from) && neighborIds.has(r.to));
|
|
1774
|
+
const deg = degreeMap(subRels);
|
|
1775
|
+
container.innerHTML = '';
|
|
1776
|
+
container.style.position = 'relative';
|
|
1777
|
+
container.style.background = '#080d1a';
|
|
1778
|
+
container.style.borderRadius = '8px';
|
|
1779
|
+
container.style.overflow = 'hidden';
|
|
1780
|
+
const canvas = new GraphCanvas(container, {
|
|
1781
|
+
layout: 'force',
|
|
1782
|
+
onSelectNode(id) {
|
|
1783
|
+
if (!id)
|
|
1784
|
+
return;
|
|
1785
|
+
canvas.setSelected(id);
|
|
1786
|
+
// Clicking a neighbor node navigates to its item detail
|
|
1787
|
+
if (id !== nodeId) {
|
|
1788
|
+
const n = nodes.find((nd) => nd.id === id);
|
|
1789
|
+
if (n && isItemNode(n)) {
|
|
1790
|
+
const appw = window;
|
|
1791
|
+
appw.__app?.openItemDetail(id);
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
},
|
|
1795
|
+
onOpenNode(id) {
|
|
1796
|
+
const appw = window;
|
|
1797
|
+
appw.__app?.openItemDetail(id);
|
|
1798
|
+
},
|
|
1799
|
+
onContextMenu() { },
|
|
1800
|
+
});
|
|
1801
|
+
const canvasNodes = subNodes.map((n) => ({
|
|
1802
|
+
id: n.id,
|
|
1803
|
+
label: nodeTitle(n),
|
|
1804
|
+
type: nodeType(n),
|
|
1805
|
+
status: nodeStatus(n),
|
|
1806
|
+
lane: nodeLane(n),
|
|
1807
|
+
degree: deg.get(n.id) || 0,
|
|
1808
|
+
tags: Array.isArray(n.properties?.tags) ? n.properties.tags.map(String) : [],
|
|
1809
|
+
}));
|
|
1810
|
+
const canvasEdges = subRels.map((r) => ({ from: r.from, to: r.to, type: r.type }));
|
|
1811
|
+
canvas.setData(canvasNodes, canvasEdges);
|
|
1812
|
+
canvas.setSelected(nodeId);
|
|
1813
|
+
canvas.setFilter({
|
|
1814
|
+
visibleNodeIds: null,
|
|
1815
|
+
selectedId: nodeId,
|
|
1816
|
+
query: '',
|
|
1817
|
+
highlightRelTypes: new Set(),
|
|
1818
|
+
colorMode: 'status',
|
|
1819
|
+
colorTag: '',
|
|
1820
|
+
criticalPathIds: new Set(),
|
|
1821
|
+
});
|
|
1822
|
+
localGraphRegistry.set(containerId, canvas);
|
|
1823
|
+
}
|
|
1824
|
+
//# sourceMappingURL=graph.js.map
|