@jhizzard/termdeck 0.10.4 → 0.12.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/package.json +3 -2
- package/packages/cli/src/init-rumen.js +153 -83
- package/packages/client/public/app.js +207 -4
- package/packages/client/public/flashback-history.html +331 -0
- package/packages/client/public/flashback-history.js +258 -0
- package/packages/client/public/graph-controls.js +217 -0
- package/packages/client/public/graph.html +36 -0
- package/packages/client/public/graph.js +131 -15
- package/packages/client/public/index.html +25 -0
- package/packages/client/public/style.css +230 -0
- package/packages/server/src/config.js +49 -0
- package/packages/server/src/database.js +49 -1
- package/packages/server/src/flashback-diag.js +187 -13
- package/packages/server/src/index.js +132 -19
- package/packages/server/src/projects-routes.js +119 -0
- package/packages/server/src/pty-reaper.js +297 -0
- package/packages/server/src/setup/index.js +1 -0
- package/packages/server/src/setup/migration-templating.js +76 -0
- package/packages/server/src/setup/migrations.js +44 -4
- package/packages/server/src/setup/rumen/functions/graph-inference/index.ts +381 -0
- package/packages/server/src/setup/rumen/functions/graph-inference/tsconfig.json +14 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/* TermDeck — Knowledge Graph view-controls (Sprint 43 T1)
|
|
2
|
+
*
|
|
3
|
+
* Pure functions for filtering and laying out the D3 force-directed graph.
|
|
4
|
+
* Extracted from graph.js so the filter logic can be unit-tested under
|
|
5
|
+
* `node --test` without a bundler.
|
|
6
|
+
*
|
|
7
|
+
* Dual-export pattern: in Node, `module.exports` returns the API object; in
|
|
8
|
+
* the browser, the same API attaches to `window.GraphControls`. graph.html
|
|
9
|
+
* loads this file with a plain <script> tag BEFORE graph.js so the IIFE in
|
|
10
|
+
* graph.js can read `window.GraphControls.applyControls(...)`.
|
|
11
|
+
*
|
|
12
|
+
* The four user-visible controls are:
|
|
13
|
+
* 1. hide-isolated — drop nodes with degree 0
|
|
14
|
+
* 2. min-degree — keep nodes with degree >= N (N ∈ {0,1,2,3,5})
|
|
15
|
+
* 3. time-window — keep nodes with createdAt within last N days
|
|
16
|
+
* 4. layout — 'force' (default) | 'hierarchical' | 'radial'
|
|
17
|
+
*
|
|
18
|
+
* Filter precedence inside applyControls:
|
|
19
|
+
* time-window ⇒ degree. Time-window first because dropping nodes by age
|
|
20
|
+
* also drops their edges, which changes the degree count for the survivors.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
(function (root) {
|
|
24
|
+
'use strict';
|
|
25
|
+
|
|
26
|
+
const WINDOW_DAYS = { '7d': 7, '30d': 30, '90d': 90 };
|
|
27
|
+
const LAYOUTS = ['force', 'hierarchical', 'radial'];
|
|
28
|
+
const VALID_WINDOWS = ['all', '7d', '30d', '90d'];
|
|
29
|
+
|
|
30
|
+
function edgeEndpointId(end) {
|
|
31
|
+
return (end && typeof end === 'object') ? end.id : end;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function computeDegrees(nodes, edges) {
|
|
35
|
+
const deg = new Map();
|
|
36
|
+
for (const n of nodes) deg.set(n.id, 0);
|
|
37
|
+
for (const e of edges) {
|
|
38
|
+
const sId = edgeEndpointId(e.source);
|
|
39
|
+
const tId = edgeEndpointId(e.target);
|
|
40
|
+
// Only count edges where BOTH endpoints are in the node set; a half-known
|
|
41
|
+
// edge is "broken" and shouldn't bump the survivor's degree.
|
|
42
|
+
if (!deg.has(sId) || !deg.has(tId)) continue;
|
|
43
|
+
deg.set(sId, deg.get(sId) + 1);
|
|
44
|
+
deg.set(tId, deg.get(tId) + 1);
|
|
45
|
+
}
|
|
46
|
+
return deg;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function windowMinTime(windowKey, now) {
|
|
50
|
+
const days = WINDOW_DAYS[windowKey];
|
|
51
|
+
if (!days) return null;
|
|
52
|
+
const ref = (typeof now === 'number' && Number.isFinite(now)) ? now : Date.now();
|
|
53
|
+
return ref - days * 86_400_000;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function filterEdgesByNodeSet(edges, idSet) {
|
|
57
|
+
const out = [];
|
|
58
|
+
for (const e of edges) {
|
|
59
|
+
const sId = edgeEndpointId(e.source);
|
|
60
|
+
const tId = edgeEndpointId(e.target);
|
|
61
|
+
if (idSet.has(sId) && idSet.has(tId)) out.push(e);
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function applyControls(nodes, edges, controls, now) {
|
|
67
|
+
const c = controls || {};
|
|
68
|
+
let outNodes = Array.isArray(nodes) ? nodes.slice() : [];
|
|
69
|
+
let outEdges = Array.isArray(edges) ? edges.slice() : [];
|
|
70
|
+
|
|
71
|
+
const minTime = windowMinTime(c.window, now);
|
|
72
|
+
if (minTime != null) {
|
|
73
|
+
outNodes = outNodes.filter((n) => {
|
|
74
|
+
if (!n.createdAt) return false;
|
|
75
|
+
const t = Date.parse(n.createdAt);
|
|
76
|
+
return Number.isFinite(t) && t >= minTime;
|
|
77
|
+
});
|
|
78
|
+
outEdges = filterEdgesByNodeSet(outEdges, new Set(outNodes.map((n) => n.id)));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const minDegreeRaw = Number.isFinite(c.minDegree) ? c.minDegree : 0;
|
|
82
|
+
const minDegree = Math.max(0, Math.floor(minDegreeRaw));
|
|
83
|
+
const hideIsolated = !!c.hideIsolated;
|
|
84
|
+
const threshold = Math.max(minDegree, hideIsolated ? 1 : 0);
|
|
85
|
+
|
|
86
|
+
if (threshold > 0) {
|
|
87
|
+
const deg = computeDegrees(outNodes, outEdges);
|
|
88
|
+
outNodes = outNodes.filter((n) => (deg.get(n.id) || 0) >= threshold);
|
|
89
|
+
outEdges = filterEdgesByNodeSet(outEdges, new Set(outNodes.map((n) => n.id)));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { nodes: outNodes, edges: outEdges };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function defaultControls() {
|
|
96
|
+
return { hideIsolated: false, minDegree: 0, window: 'all', layout: 'force' };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function normalizeControls(c) {
|
|
100
|
+
const def = defaultControls();
|
|
101
|
+
const out = Object.assign({}, def, c || {});
|
|
102
|
+
out.hideIsolated = !!out.hideIsolated;
|
|
103
|
+
const md = Number(out.minDegree);
|
|
104
|
+
out.minDegree = Number.isFinite(md) && md > 0 ? Math.max(0, Math.floor(md)) : 0;
|
|
105
|
+
if (!VALID_WINDOWS.includes(out.window)) out.window = 'all';
|
|
106
|
+
if (!LAYOUTS.includes(out.layout)) out.layout = 'force';
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function encodeControls(qs, controls) {
|
|
111
|
+
const c = normalizeControls(controls);
|
|
112
|
+
if (c.hideIsolated) qs.set('hideIsolated', '1');
|
|
113
|
+
else qs.delete('hideIsolated');
|
|
114
|
+
if (c.minDegree > 0) qs.set('minDegree', String(c.minDegree));
|
|
115
|
+
else qs.delete('minDegree');
|
|
116
|
+
if (c.window !== 'all') qs.set('window', c.window);
|
|
117
|
+
else qs.delete('window');
|
|
118
|
+
if (c.layout !== 'force') qs.set('layout', c.layout);
|
|
119
|
+
else qs.delete('layout');
|
|
120
|
+
return qs;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function decodeControls(qs) {
|
|
124
|
+
const out = defaultControls();
|
|
125
|
+
if (qs.get('hideIsolated') === '1') out.hideIsolated = true;
|
|
126
|
+
const md = parseInt(qs.get('minDegree'), 10);
|
|
127
|
+
if (Number.isFinite(md) && md > 0) out.minDegree = md;
|
|
128
|
+
const win = qs.get('window');
|
|
129
|
+
if (win && VALID_WINDOWS.includes(win)) out.window = win;
|
|
130
|
+
const lay = qs.get('layout');
|
|
131
|
+
if (lay && LAYOUTS.includes(lay)) out.layout = lay;
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Hierarchical layering — assigns each node a "level" by BFS from roots
|
|
136
|
+
// (nodes with no incoming `supersedes` / `caused_by` edges). Used by the
|
|
137
|
+
// hierarchical layout to map level → y position. Falls back to a single
|
|
138
|
+
// level when no directional edges are present.
|
|
139
|
+
function computeHierarchyLevels(nodes, edges) {
|
|
140
|
+
const directional = new Set(['supersedes', 'caused_by', 'blocks']);
|
|
141
|
+
const incoming = new Map();
|
|
142
|
+
const outgoing = new Map();
|
|
143
|
+
for (const n of nodes) {
|
|
144
|
+
incoming.set(n.id, []);
|
|
145
|
+
outgoing.set(n.id, []);
|
|
146
|
+
}
|
|
147
|
+
for (const e of edges) {
|
|
148
|
+
if (!directional.has(e.kind)) continue;
|
|
149
|
+
const sId = edgeEndpointId(e.source);
|
|
150
|
+
const tId = edgeEndpointId(e.target);
|
|
151
|
+
if (incoming.has(tId)) incoming.get(tId).push(sId);
|
|
152
|
+
if (outgoing.has(sId)) outgoing.get(sId).push(tId);
|
|
153
|
+
}
|
|
154
|
+
const levels = new Map();
|
|
155
|
+
const roots = [];
|
|
156
|
+
for (const n of nodes) {
|
|
157
|
+
if ((incoming.get(n.id) || []).length === 0) {
|
|
158
|
+
roots.push(n.id);
|
|
159
|
+
levels.set(n.id, 0);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (roots.length === 0) {
|
|
163
|
+
for (const n of nodes) levels.set(n.id, 0);
|
|
164
|
+
return levels;
|
|
165
|
+
}
|
|
166
|
+
const queue = roots.slice();
|
|
167
|
+
while (queue.length) {
|
|
168
|
+
const id = queue.shift();
|
|
169
|
+
const cur = levels.get(id) || 0;
|
|
170
|
+
for (const next of (outgoing.get(id) || [])) {
|
|
171
|
+
const prev = levels.get(next);
|
|
172
|
+
const candidate = cur + 1;
|
|
173
|
+
if (prev == null || candidate > prev) {
|
|
174
|
+
levels.set(next, candidate);
|
|
175
|
+
queue.push(next);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
for (const n of nodes) {
|
|
180
|
+
if (!levels.has(n.id)) levels.set(n.id, 0);
|
|
181
|
+
}
|
|
182
|
+
return levels;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Radial radius helper — high degree → small radius (center). Returns a
|
|
186
|
+
// function that takes a node and returns a target radius (0 < r ≤ rMax).
|
|
187
|
+
function radialRadiusFn(degreesMap, rMax) {
|
|
188
|
+
let maxDeg = 0;
|
|
189
|
+
for (const v of degreesMap.values()) if (v > maxDeg) maxDeg = v;
|
|
190
|
+
if (maxDeg === 0) return () => rMax * 0.5;
|
|
191
|
+
return (n) => {
|
|
192
|
+
const deg = degreesMap.get(n.id) || 0;
|
|
193
|
+
const t = 1 - (deg / maxDeg);
|
|
194
|
+
return rMax * (0.15 + 0.75 * t);
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const api = {
|
|
199
|
+
LAYOUTS,
|
|
200
|
+
VALID_WINDOWS,
|
|
201
|
+
computeDegrees,
|
|
202
|
+
windowMinTime,
|
|
203
|
+
applyControls,
|
|
204
|
+
defaultControls,
|
|
205
|
+
normalizeControls,
|
|
206
|
+
encodeControls,
|
|
207
|
+
decodeControls,
|
|
208
|
+
computeHierarchyLevels,
|
|
209
|
+
radialRadiusFn,
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
213
|
+
module.exports = api;
|
|
214
|
+
} else {
|
|
215
|
+
root.GraphControls = api;
|
|
216
|
+
}
|
|
217
|
+
})(typeof window !== 'undefined' ? window : globalThis);
|
|
@@ -42,6 +42,41 @@
|
|
|
42
42
|
|
|
43
43
|
<div class="graph-filters" id="graphFilters" role="toolbar" aria-label="Edge type filters"></div>
|
|
44
44
|
|
|
45
|
+
<div class="graph-filters graph-filters-row2" id="graphControls" role="toolbar" aria-label="Graph view controls">
|
|
46
|
+
<label class="graph-control graph-control-toggle" title="Drop nodes with zero edges">
|
|
47
|
+
<input type="checkbox" id="ctlHideIsolated">
|
|
48
|
+
<span>hide isolated</span>
|
|
49
|
+
</label>
|
|
50
|
+
<label class="graph-control" title="Keep nodes with at least N edges">
|
|
51
|
+
<span>min degree</span>
|
|
52
|
+
<select id="ctlMinDegree">
|
|
53
|
+
<option value="0">all</option>
|
|
54
|
+
<option value="1">≥ 1</option>
|
|
55
|
+
<option value="2">≥ 2</option>
|
|
56
|
+
<option value="3">≥ 3</option>
|
|
57
|
+
<option value="5">≥ 5</option>
|
|
58
|
+
</select>
|
|
59
|
+
</label>
|
|
60
|
+
<label class="graph-control" title="Keep nodes created within window">
|
|
61
|
+
<span>window</span>
|
|
62
|
+
<select id="ctlWindow">
|
|
63
|
+
<option value="all">all time</option>
|
|
64
|
+
<option value="7d">last 7 days</option>
|
|
65
|
+
<option value="30d">last 30 days</option>
|
|
66
|
+
<option value="90d">last 90 days</option>
|
|
67
|
+
</select>
|
|
68
|
+
</label>
|
|
69
|
+
<label class="graph-control" title="Force algorithm used to position nodes">
|
|
70
|
+
<span>layout</span>
|
|
71
|
+
<select id="ctlLayout">
|
|
72
|
+
<option value="force">force-directed</option>
|
|
73
|
+
<option value="hierarchical">hierarchical</option>
|
|
74
|
+
<option value="radial">radial</option>
|
|
75
|
+
</select>
|
|
76
|
+
</label>
|
|
77
|
+
<span class="graph-control-stat" id="ctlVisibleStat" aria-live="polite"></span>
|
|
78
|
+
</div>
|
|
79
|
+
|
|
45
80
|
<main class="graph-stage" id="graphStage">
|
|
46
81
|
<div class="graph-loading" id="graphLoading">
|
|
47
82
|
<div class="graph-loading-spinner" aria-hidden="true"></div>
|
|
@@ -100,6 +135,7 @@
|
|
|
100
135
|
</main>
|
|
101
136
|
|
|
102
137
|
<script src="https://cdn.jsdelivr.net/npm/d3@7"></script>
|
|
138
|
+
<script src="graph-controls.js"></script>
|
|
103
139
|
<script src="graph.js" defer></script>
|
|
104
140
|
</body>
|
|
105
141
|
</html>
|
|
@@ -76,14 +76,19 @@
|
|
|
76
76
|
|
|
77
77
|
// -------- State ----------------------------------------------------------
|
|
78
78
|
|
|
79
|
+
const GC = (typeof window !== 'undefined' && window.GraphControls) || null;
|
|
80
|
+
|
|
79
81
|
const state = {
|
|
80
82
|
mode: 'project', // 'project' | 'memory'
|
|
81
83
|
project: null,
|
|
82
84
|
memoryId: null,
|
|
83
85
|
depth: 2,
|
|
84
|
-
nodes: [],
|
|
85
|
-
edges: [],
|
|
86
|
+
nodes: [], // raw nodes from /api/graph/* — unfiltered
|
|
87
|
+
edges: [], // raw edges from /api/graph/* — unfiltered
|
|
88
|
+
visibleNodes: [], // post-filter, what the simulation sees
|
|
89
|
+
visibleEdges: [],
|
|
86
90
|
activeKinds: new Set(Object.keys(EDGE_COLORS)),
|
|
91
|
+
controls: GC ? GC.defaultControls() : { hideIsolated: false, minDegree: 0, window: 'all', layout: 'force' },
|
|
87
92
|
selectedNodeId: null,
|
|
88
93
|
hoverNodeId: null,
|
|
89
94
|
searchTerm: '',
|
|
@@ -172,6 +177,7 @@
|
|
|
172
177
|
state.mode = 'project';
|
|
173
178
|
state.project = project;
|
|
174
179
|
}
|
|
180
|
+
if (GC) state.controls = GC.decodeControls(qs);
|
|
175
181
|
}
|
|
176
182
|
|
|
177
183
|
function writeUrlState() {
|
|
@@ -182,6 +188,7 @@
|
|
|
182
188
|
} else if (state.project) {
|
|
183
189
|
qs.set('project', state.project);
|
|
184
190
|
}
|
|
191
|
+
if (GC) GC.encodeControls(qs, state.controls);
|
|
185
192
|
const url = qs.toString() ? `?${qs.toString()}` : window.location.pathname;
|
|
186
193
|
window.history.replaceState(null, '', url);
|
|
187
194
|
}
|
|
@@ -259,6 +266,8 @@
|
|
|
259
266
|
// node render all paint over each other after a mode/project switch.
|
|
260
267
|
hideEmpty();
|
|
261
268
|
clearGraphSvg();
|
|
269
|
+
state.rawNodes = [];
|
|
270
|
+
state.rawEdges = [];
|
|
262
271
|
state.nodes = [];
|
|
263
272
|
state.edges = [];
|
|
264
273
|
setLoading('Loading graph…');
|
|
@@ -267,18 +276,18 @@
|
|
|
267
276
|
if (state.mode === 'project' && state.project === '__all__') {
|
|
268
277
|
data = await api('/api/graph/all');
|
|
269
278
|
if (data.enabled === false) return showDisabled(data);
|
|
270
|
-
state.
|
|
271
|
-
state.
|
|
279
|
+
state.rawNodes = data.nodes || [];
|
|
280
|
+
state.rawEdges = data.edges || [];
|
|
272
281
|
if (data.truncated) {
|
|
273
282
|
showToast(
|
|
274
|
-
`Showing ${state.
|
|
283
|
+
`Showing ${state.rawNodes.length} most-recent of ${data.totalAvailable} memories — narrow by project to see specific clusters.`,
|
|
275
284
|
);
|
|
276
285
|
}
|
|
277
286
|
} else if (state.mode === 'memory') {
|
|
278
287
|
data = await api(`/api/graph/memory/${encodeURIComponent(state.memoryId)}?depth=${state.depth}`);
|
|
279
288
|
if (data.enabled === false) return showDisabled(data);
|
|
280
|
-
state.
|
|
281
|
-
state.
|
|
289
|
+
state.rawNodes = data.nodes || [];
|
|
290
|
+
state.rawEdges = data.edges || [];
|
|
282
291
|
// Use the root memory's project as the view's "current project" for
|
|
283
292
|
// the legend / drawer / fallback color when nodes span projects.
|
|
284
293
|
if (data.root && data.root.project) state.project = data.root.project;
|
|
@@ -287,19 +296,17 @@
|
|
|
287
296
|
state.project = name;
|
|
288
297
|
data = await api(`/api/graph/project/${encodeURIComponent(name)}`);
|
|
289
298
|
if (data.enabled === false) return showDisabled(data);
|
|
290
|
-
state.
|
|
291
|
-
state.
|
|
299
|
+
state.rawNodes = data.nodes || [];
|
|
300
|
+
state.rawEdges = data.edges || [];
|
|
292
301
|
}
|
|
293
302
|
writeUrlState();
|
|
294
303
|
hideLoading();
|
|
295
|
-
if (state.
|
|
304
|
+
if (state.rawNodes.length === 0) {
|
|
296
305
|
showEmpty();
|
|
297
306
|
return;
|
|
298
307
|
}
|
|
299
308
|
hideEmpty();
|
|
300
|
-
|
|
301
|
-
renderGraph();
|
|
302
|
-
updateStats();
|
|
309
|
+
applyControlsAndRender();
|
|
303
310
|
} catch (err) {
|
|
304
311
|
// Even on error, drop the empty-state overlay so the failure message
|
|
305
312
|
// shows alone instead of stacking on top of "No memories yet".
|
|
@@ -308,6 +315,35 @@
|
|
|
308
315
|
}
|
|
309
316
|
}
|
|
310
317
|
|
|
318
|
+
// Sprint 43 T1 — re-derive state.nodes/state.edges from the raw API result
|
|
319
|
+
// and the four user controls, then drive the existing render pipeline.
|
|
320
|
+
// Called after every fetch and after any control change.
|
|
321
|
+
function applyControlsAndRender() {
|
|
322
|
+
const raw = { nodes: state.rawNodes || [], edges: state.rawEdges || [] };
|
|
323
|
+
let visible;
|
|
324
|
+
if (GC) {
|
|
325
|
+
visible = GC.applyControls(raw.nodes, raw.edges, state.controls);
|
|
326
|
+
} else {
|
|
327
|
+
visible = { nodes: raw.nodes.slice(), edges: raw.edges.slice() };
|
|
328
|
+
}
|
|
329
|
+
state.nodes = visible.nodes;
|
|
330
|
+
state.edges = visible.edges;
|
|
331
|
+
renderFilters();
|
|
332
|
+
renderGraph();
|
|
333
|
+
updateStats();
|
|
334
|
+
updateControlStat(raw.nodes.length, visible.nodes.length);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function updateControlStat(total, visible) {
|
|
338
|
+
const el = document.getElementById('ctlVisibleStat');
|
|
339
|
+
if (!el) return;
|
|
340
|
+
if (total === visible) {
|
|
341
|
+
el.textContent = '';
|
|
342
|
+
} else {
|
|
343
|
+
el.textContent = `${visible} of ${total} visible`;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
311
347
|
// -------- Render --------------------------------------------------------
|
|
312
348
|
|
|
313
349
|
function setLoading(msg) {
|
|
@@ -417,6 +453,48 @@
|
|
|
417
453
|
svg().setAttribute('height', state.height);
|
|
418
454
|
}
|
|
419
455
|
|
|
456
|
+
// Sprint 43 T1 — apply layout-specific forces to the simulation.
|
|
457
|
+
// force — forceCenter at midpoint (default).
|
|
458
|
+
// hierarchical — forceY from BFS levels over directional edges (supersedes /
|
|
459
|
+
// caused_by / blocks). Roots float top, leaves drift down.
|
|
460
|
+
// radial — forceRadial centered at midpoint, radius from inverse
|
|
461
|
+
// degree (high degree → near center).
|
|
462
|
+
function applyLayoutForces(sim, nodes, links) {
|
|
463
|
+
const cx = state.width / 2;
|
|
464
|
+
const cy = state.height / 2;
|
|
465
|
+
const layout = (state.controls && state.controls.layout) || 'force';
|
|
466
|
+
|
|
467
|
+
sim.force('center', null);
|
|
468
|
+
sim.force('hier-y', null);
|
|
469
|
+
sim.force('hier-x', null);
|
|
470
|
+
sim.force('radial', null);
|
|
471
|
+
|
|
472
|
+
if (layout === 'hierarchical' && GC) {
|
|
473
|
+
const levels = GC.computeHierarchyLevels(state.nodes, state.edges);
|
|
474
|
+
let maxLevel = 0;
|
|
475
|
+
for (const v of levels.values()) if (v > maxLevel) maxLevel = v;
|
|
476
|
+
const denom = Math.max(1, maxLevel);
|
|
477
|
+
const top = state.height * 0.10;
|
|
478
|
+
const bottom = state.height * 0.90;
|
|
479
|
+
sim.force('hier-y', window.d3.forceY((d) => {
|
|
480
|
+
const lvl = levels.get(d.id) || 0;
|
|
481
|
+
return top + (lvl / denom) * (bottom - top);
|
|
482
|
+
}).strength(0.65));
|
|
483
|
+
sim.force('hier-x', window.d3.forceX(cx).strength(0.04));
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (layout === 'radial' && GC) {
|
|
488
|
+
const deg = GC.computeDegrees(state.nodes, state.edges);
|
|
489
|
+
const rMax = Math.min(state.width, state.height) * 0.42;
|
|
490
|
+
const radiusFn = GC.radialRadiusFn(deg, rMax);
|
|
491
|
+
sim.force('radial', window.d3.forceRadial(radiusFn, cx, cy).strength(0.55));
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
sim.force('center', window.d3.forceCenter(cx, cy));
|
|
496
|
+
}
|
|
497
|
+
|
|
420
498
|
function renderGraph() {
|
|
421
499
|
if (!window.d3) {
|
|
422
500
|
setLoading('D3.js failed to load (CDN blocked?)');
|
|
@@ -441,10 +519,11 @@
|
|
|
441
519
|
const sim = window.d3.forceSimulation(nodes)
|
|
442
520
|
.force('link', window.d3.forceLink(links).id((d) => d.id).distance((l) => 60 + (1 - (l.weight ?? 0.5)) * 40))
|
|
443
521
|
.force('charge', window.d3.forceManyBody().strength(-260))
|
|
444
|
-
.force('center', window.d3.forceCenter(state.width / 2, state.height / 2))
|
|
445
522
|
.force('collide', window.d3.forceCollide().radius((d) => nodeRadius(d) + 4))
|
|
446
523
|
.alphaDecay(0.02);
|
|
447
524
|
|
|
525
|
+
applyLayoutForces(sim, nodes, links);
|
|
526
|
+
|
|
448
527
|
state.sim = sim;
|
|
449
528
|
|
|
450
529
|
const rootSel = window.d3.select(root());
|
|
@@ -742,6 +821,41 @@
|
|
|
742
821
|
});
|
|
743
822
|
$('graphFit').addEventListener('click', () => fitToView());
|
|
744
823
|
|
|
824
|
+
// Sprint 43 T1 — graph view-controls. Hydrate input values from state, then
|
|
825
|
+
// wire change handlers that mutate state.controls + URL + re-render from
|
|
826
|
+
// the cached raw fetch so toggling is fast (no API round-trip).
|
|
827
|
+
const ctlHide = $('ctlHideIsolated');
|
|
828
|
+
const ctlMin = $('ctlMinDegree');
|
|
829
|
+
const ctlWin = $('ctlWindow');
|
|
830
|
+
const ctlLay = $('ctlLayout');
|
|
831
|
+
if (ctlHide) ctlHide.checked = !!state.controls.hideIsolated;
|
|
832
|
+
if (ctlMin) ctlMin.value = String(state.controls.minDegree || 0);
|
|
833
|
+
if (ctlWin) ctlWin.value = state.controls.window || 'all';
|
|
834
|
+
if (ctlLay) ctlLay.value = state.controls.layout || 'force';
|
|
835
|
+
|
|
836
|
+
function onControlChange() {
|
|
837
|
+
state.controls = (GC ? GC.normalizeControls(state.controls) : state.controls);
|
|
838
|
+
writeUrlState();
|
|
839
|
+
if ((state.rawNodes || []).length === 0) return;
|
|
840
|
+
applyControlsAndRender();
|
|
841
|
+
}
|
|
842
|
+
if (ctlHide) ctlHide.addEventListener('change', () => {
|
|
843
|
+
state.controls.hideIsolated = !!ctlHide.checked;
|
|
844
|
+
onControlChange();
|
|
845
|
+
});
|
|
846
|
+
if (ctlMin) ctlMin.addEventListener('change', () => {
|
|
847
|
+
state.controls.minDegree = parseInt(ctlMin.value, 10) || 0;
|
|
848
|
+
onControlChange();
|
|
849
|
+
});
|
|
850
|
+
if (ctlWin) ctlWin.addEventListener('change', () => {
|
|
851
|
+
state.controls.window = ctlWin.value;
|
|
852
|
+
onControlChange();
|
|
853
|
+
});
|
|
854
|
+
if (ctlLay) ctlLay.addEventListener('change', () => {
|
|
855
|
+
state.controls.layout = ctlLay.value;
|
|
856
|
+
onControlChange();
|
|
857
|
+
});
|
|
858
|
+
|
|
745
859
|
$('gdClose').addEventListener('click', closeDrawer);
|
|
746
860
|
$('gdExpand').addEventListener('click', () => {
|
|
747
861
|
if (!state.selectedNodeId) return;
|
|
@@ -763,7 +877,9 @@
|
|
|
763
877
|
window.addEventListener('resize', () => {
|
|
764
878
|
sizeStage();
|
|
765
879
|
if (state.sim) {
|
|
766
|
-
|
|
880
|
+
// Re-apply layout-specific forces with the new dimensions; this
|
|
881
|
+
// handles all three layouts (force / hierarchical / radial).
|
|
882
|
+
applyLayoutForces(state.sim, null, null);
|
|
767
883
|
state.sim.alpha(0.3).restart();
|
|
768
884
|
}
|
|
769
885
|
});
|
|
@@ -62,6 +62,7 @@
|
|
|
62
62
|
<button id="btn-config">config</button>
|
|
63
63
|
<button id="btn-sprint" title="Define and kick off a 4+1 sprint">sprint</button>
|
|
64
64
|
<button id="btn-graph" title="Open the knowledge-graph view (memory_items + memory_relationships, force-directed)" onclick="window.open('/graph.html','_blank','noopener')">graph</button>
|
|
65
|
+
<button id="btn-flashback-history" title="Audit dashboard: every Flashback fire, dismiss/click-through funnel" onclick="window.open('/flashback-history.html','_blank','noopener')">flashback history</button>
|
|
65
66
|
<button id="btn-how" title="Walkthrough of every TermDeck feature">how this works</button>
|
|
66
67
|
<button id="btn-help" title="Open the TermDeck documentation" onclick="window.open('https://termdeck-docs.vercel.app','_blank','noopener')">help</button>
|
|
67
68
|
</div>
|
|
@@ -135,6 +136,7 @@
|
|
|
135
136
|
<option value="">no project</option>
|
|
136
137
|
</select>
|
|
137
138
|
<button class="prompt-add-project" id="btnAddProject" title="Add a new project">+</button>
|
|
139
|
+
<button class="prompt-remove-project" id="btnRemoveProject" title="Remove a project (files on disk are untouched)">−</button>
|
|
138
140
|
<button class="prompt-preview-project" id="btnPreviewProject" title="Preview orchestration scaffolding for the selected project" disabled>preview</button>
|
|
139
141
|
<button class="prompt-launch" id="promptLaunch">launch</button>
|
|
140
142
|
</div>
|
|
@@ -169,6 +171,29 @@
|
|
|
169
171
|
</div>
|
|
170
172
|
</div>
|
|
171
173
|
|
|
174
|
+
<!-- Remove-project modal (Sprint 42 T4). Hidden by default. -->
|
|
175
|
+
<div class="remove-project-modal" id="removeProjectModal" role="dialog" aria-modal="true" aria-labelledby="rpmTitle">
|
|
176
|
+
<div class="remove-project-backdrop"></div>
|
|
177
|
+
<div class="remove-project-card">
|
|
178
|
+
<h3 id="rpmTitle">Remove a project</h3>
|
|
179
|
+
<p class="rpm-help">
|
|
180
|
+
This unregisters the project from <code>~/.termdeck/config.yaml</code>.
|
|
181
|
+
<strong>Files on disk are untouched</strong> — your source code, git history,
|
|
182
|
+
and everything at the project's path stay exactly where they are.
|
|
183
|
+
</p>
|
|
184
|
+
<label>
|
|
185
|
+
<span>Project to remove</span>
|
|
186
|
+
<select id="rpmSelect"><option value="">— pick a project —</option></select>
|
|
187
|
+
</label>
|
|
188
|
+
<div class="rpm-warning" id="rpmWarning" hidden></div>
|
|
189
|
+
<div class="rpm-status" id="rpmStatus"></div>
|
|
190
|
+
<div class="rpm-actions">
|
|
191
|
+
<button class="rpm-cancel" id="rpmCancel">cancel</button>
|
|
192
|
+
<button class="rpm-confirm" id="rpmConfirm" disabled>remove project</button>
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
|
|
172
197
|
<!-- Orchestration-preview modal (Sprint 37 T3). Hidden by default. -->
|
|
173
198
|
<div class="preview-project-modal" id="previewProjectModal" role="dialog" aria-modal="true" aria-labelledby="ppmTitle">
|
|
174
199
|
<div class="preview-project-backdrop"></div>
|