declare-cc 0.4.0 → 0.4.5
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/bin/install.js +3 -2
- package/commands/declare/actions.md +8 -3
- package/commands/declare/future.md +23 -3
- package/commands/declare/milestones.md +7 -2
- package/commands/declare/plan.md +56 -4
- package/dist/declare-tools.cjs +44 -3
- package/dist/public/app.js +1112 -0
- package/dist/public/index.html +609 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Declare DAG Visualizer — app.js
|
|
3
|
+
*
|
|
4
|
+
* Fetches /api/graph and /api/status, renders a layered DAG with SVG edges,
|
|
5
|
+
* supports node click for full details in a side panel, and live-updates via SSE when .planning/ changes.
|
|
6
|
+
*
|
|
7
|
+
* Zero external dependencies. Vanilla JS, no build step.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
const API_GRAPH = '/api/graph';
|
|
15
|
+
const API_STATUS = '/api/status';
|
|
16
|
+
|
|
17
|
+
// ─── State ───────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/** @type {{ declarations: any[], milestones: any[], actions: any[], stats: any } | null} */
|
|
20
|
+
let graphData = null;
|
|
21
|
+
|
|
22
|
+
/** @type {any} */
|
|
23
|
+
let statusData = null;
|
|
24
|
+
|
|
25
|
+
/** @type {string | null} */
|
|
26
|
+
let selectedNodeId = null;
|
|
27
|
+
|
|
28
|
+
/** @type {string | null} Focus node ID — the declaration or milestone being focused */
|
|
29
|
+
let focusNodeId = null;
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
// ─── DOM refs ─────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
const $overlay = document.getElementById('overlay');
|
|
35
|
+
const $overlayMsg = document.getElementById('overlay-message');
|
|
36
|
+
const $overlayErr = document.getElementById('overlay-error');
|
|
37
|
+
const $overlayRetry = document.getElementById('overlay-retry');
|
|
38
|
+
const $spinner = document.querySelector('.spinner');
|
|
39
|
+
|
|
40
|
+
const $projectName = document.getElementById('project-name');
|
|
41
|
+
const $statDecls = document.getElementById('stat-decls');
|
|
42
|
+
const $statMiles = document.getElementById('stat-miles');
|
|
43
|
+
const $statActs = document.getElementById('stat-acts');
|
|
44
|
+
const $healthBadge = document.getElementById('health-badge');
|
|
45
|
+
const $lastUpdated = document.getElementById('last-updated');
|
|
46
|
+
const $refreshBtn = document.getElementById('refresh-btn');
|
|
47
|
+
|
|
48
|
+
const $nodesDecls = document.getElementById('nodes-declarations');
|
|
49
|
+
const $nodesMiles = document.getElementById('nodes-milestones');
|
|
50
|
+
const $nodesActs = document.getElementById('nodes-actions');
|
|
51
|
+
const $edgesSvg = document.getElementById('edges-svg');
|
|
52
|
+
|
|
53
|
+
const $sidePanel = document.getElementById('side-panel');
|
|
54
|
+
const $panelBody = document.getElementById('panel-body');
|
|
55
|
+
const $panelEmpty = document.getElementById('panel-empty');
|
|
56
|
+
|
|
57
|
+
// ─── Utilities ────────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Truncate text to maxLen characters, appending ellipsis if needed.
|
|
61
|
+
* @param {string} text
|
|
62
|
+
* @param {number} maxLen
|
|
63
|
+
* @returns {string}
|
|
64
|
+
*/
|
|
65
|
+
function truncate(text, maxLen) {
|
|
66
|
+
if (!text) return '';
|
|
67
|
+
return text.length > maxLen ? text.slice(0, maxLen - 1) + '…' : text;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Format a Date as "HH:MM:SS".
|
|
72
|
+
* @param {Date} d
|
|
73
|
+
* @returns {string}
|
|
74
|
+
*/
|
|
75
|
+
function fmtTime(d) {
|
|
76
|
+
return d.toTimeString().slice(0, 8);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Derive a CSS class suffix for a status string.
|
|
81
|
+
* @param {string} status
|
|
82
|
+
* @returns {string}
|
|
83
|
+
*/
|
|
84
|
+
function statusClass(status) {
|
|
85
|
+
if (!status) return 'pending';
|
|
86
|
+
return status.toLowerCase().replace(/[^a-z]/g, '-');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ─── Fetch helpers ────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Fetch JSON from a URL, returning null on network/parse errors.
|
|
93
|
+
* @param {string} url
|
|
94
|
+
* @returns {Promise<any>}
|
|
95
|
+
*/
|
|
96
|
+
async function fetchJson(url) {
|
|
97
|
+
const res = await fetch(url);
|
|
98
|
+
if (!res.ok) throw new Error(`HTTP ${res.status} from ${url}`);
|
|
99
|
+
return res.json();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ─── Overlay helpers ──────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
function showLoading() {
|
|
105
|
+
$overlayMsg.textContent = 'Loading graph…';
|
|
106
|
+
$overlayErr.textContent = '';
|
|
107
|
+
$overlayRetry.style.display = 'none';
|
|
108
|
+
$spinner.style.display = 'block';
|
|
109
|
+
$overlay.classList.remove('hidden');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function showError(msg) {
|
|
113
|
+
$overlayMsg.textContent = '';
|
|
114
|
+
$overlayErr.textContent = msg;
|
|
115
|
+
$overlayRetry.style.display = 'inline-block';
|
|
116
|
+
$spinner.style.display = 'none';
|
|
117
|
+
$overlay.classList.remove('hidden');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function hideOverlay() {
|
|
121
|
+
$overlay.classList.add('hidden');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ─── Data loading ─────────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Load /api/graph and /api/status in parallel.
|
|
128
|
+
* On success: hide overlay, render.
|
|
129
|
+
* On failure: show error overlay.
|
|
130
|
+
*/
|
|
131
|
+
async function loadData() {
|
|
132
|
+
try {
|
|
133
|
+
const [graph, status] = await Promise.all([
|
|
134
|
+
fetchJson(API_GRAPH),
|
|
135
|
+
fetchJson(API_STATUS).catch(() => null), // status is supplementary
|
|
136
|
+
]);
|
|
137
|
+
|
|
138
|
+
if (graph && graph.error) {
|
|
139
|
+
throw new Error(graph.error);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
graphData = graph;
|
|
143
|
+
statusData = status;
|
|
144
|
+
|
|
145
|
+
hideOverlay();
|
|
146
|
+
renderStatusBar();
|
|
147
|
+
renderGraph();
|
|
148
|
+
updateLastUpdated();
|
|
149
|
+
|
|
150
|
+
// Re-apply selection highlight if node still exists
|
|
151
|
+
if (selectedNodeId) {
|
|
152
|
+
const el = document.querySelector(`[data-node-id="${selectedNodeId}"]`);
|
|
153
|
+
if (el) el.classList.add('selected');
|
|
154
|
+
}
|
|
155
|
+
} catch (err) {
|
|
156
|
+
showError(
|
|
157
|
+
`Could not reach the Declare server.\n${err.message}\n\nMake sure the server is running:\n node dist/declare-tools.cjs serve`
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function updateLastUpdated() {
|
|
163
|
+
$lastUpdated.textContent = `Last updated: ${fmtTime(new Date())}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ─── Status bar ───────────────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
function renderStatusBar() {
|
|
169
|
+
// Project name from status or graph stats
|
|
170
|
+
const project = statusData ? statusData.project : null;
|
|
171
|
+
if (project) $projectName.textContent = project;
|
|
172
|
+
|
|
173
|
+
// Counts
|
|
174
|
+
const stats = (graphData && graphData.stats) || {};
|
|
175
|
+
$statDecls.textContent = stats.declarations != null ? stats.declarations : '–';
|
|
176
|
+
$statMiles.textContent = stats.milestones != null ? stats.milestones : '–';
|
|
177
|
+
$statActs.textContent = stats.actions != null ? stats.actions : '–';
|
|
178
|
+
|
|
179
|
+
// Health badge
|
|
180
|
+
const health = statusData ? statusData.health : null;
|
|
181
|
+
const healthLabel = health || 'unknown';
|
|
182
|
+
$healthBadge.textContent = healthLabel;
|
|
183
|
+
$healthBadge.className = `health-badge health-${healthLabel}`;
|
|
184
|
+
|
|
185
|
+
// Performance summary from /api/status
|
|
186
|
+
if (statusData && statusData.performance && statusData.performance.rollup) {
|
|
187
|
+
const rollup = statusData.performance.rollup;
|
|
188
|
+
// Inject a performance pill next to health if not already present
|
|
189
|
+
let perfPill = document.getElementById('perf-pill');
|
|
190
|
+
if (!perfPill) {
|
|
191
|
+
perfPill = document.createElement('span');
|
|
192
|
+
perfPill.id = 'perf-pill';
|
|
193
|
+
perfPill.style.cssText = 'font-size:11px;color:var(--text-dim);';
|
|
194
|
+
$healthBadge.after(perfPill);
|
|
195
|
+
}
|
|
196
|
+
const align = rollup.alignment ? rollup.alignment.level : '–';
|
|
197
|
+
const integ = rollup.integrity ? rollup.integrity.level : '–';
|
|
198
|
+
const perf = rollup.performance || '–';
|
|
199
|
+
perfPill.textContent = `Alignment: ${align} · Integrity: ${integ} · Performance: ${perf}`;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ─── Node element builder ─────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Build a node DOM element.
|
|
207
|
+
* @param {{ id: string, title?: string, statement?: string, status?: string }} item
|
|
208
|
+
* @param {'declaration'|'milestone'|'action'} type
|
|
209
|
+
* @returns {HTMLElement}
|
|
210
|
+
*/
|
|
211
|
+
function buildNodeEl(item, type) {
|
|
212
|
+
const el = document.createElement('div');
|
|
213
|
+
el.className = `node node-${type} status-${statusClass(item.status || 'pending')}`;
|
|
214
|
+
el.dataset.nodeId = item.id;
|
|
215
|
+
el.dataset.nodeType = type;
|
|
216
|
+
|
|
217
|
+
const title = item.title || item.statement || item.id;
|
|
218
|
+
|
|
219
|
+
el.innerHTML = `
|
|
220
|
+
<div class="node-id">${item.id}</div>
|
|
221
|
+
<div class="node-title">${truncate(title, 55)}</div>
|
|
222
|
+
<span class="status-badge">${item.status || 'PENDING'}</span>
|
|
223
|
+
`;
|
|
224
|
+
|
|
225
|
+
el.addEventListener('click', () => selectNode(item.id, type));
|
|
226
|
+
return el;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ─── Graph renderer ───────────────────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
function renderGraph() {
|
|
232
|
+
if (!graphData) return;
|
|
233
|
+
|
|
234
|
+
const { declarations, milestones, actions } = graphData;
|
|
235
|
+
|
|
236
|
+
// Clear containers
|
|
237
|
+
$nodesDecls.innerHTML = '';
|
|
238
|
+
$nodesMiles.innerHTML = '';
|
|
239
|
+
$nodesActs.innerHTML = '';
|
|
240
|
+
|
|
241
|
+
// Render declarations
|
|
242
|
+
(declarations || []).forEach(d => {
|
|
243
|
+
$nodesDecls.appendChild(buildNodeEl(d, 'declaration'));
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// Render milestones
|
|
247
|
+
(milestones || []).forEach(m => {
|
|
248
|
+
$nodesMiles.appendChild(buildNodeEl(m, 'milestone'));
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// Render actions
|
|
252
|
+
(actions || []).forEach(a => {
|
|
253
|
+
$nodesActs.appendChild(buildNodeEl(a, 'action'));
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// Draw edges after layout settles
|
|
257
|
+
requestAnimationFrame(() => drawEdges());
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ─── Edge drawing ─────────────────────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Get the center-bottom point of a DOM element relative to #canvas-container.
|
|
264
|
+
* @param {Element} el
|
|
265
|
+
* @returns {{ x: number, y: number }}
|
|
266
|
+
*/
|
|
267
|
+
function getBottomCenter(el) {
|
|
268
|
+
const containerRect = document.getElementById('canvas-container').getBoundingClientRect();
|
|
269
|
+
const scrollLeft = document.getElementById('canvas-wrap').scrollLeft;
|
|
270
|
+
const scrollTop = document.getElementById('canvas-wrap').scrollTop;
|
|
271
|
+
const r = el.getBoundingClientRect();
|
|
272
|
+
return {
|
|
273
|
+
x: r.left - containerRect.left + scrollLeft + r.width / 2,
|
|
274
|
+
y: r.top - containerRect.top + scrollTop + r.height,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Get the center-top point of a DOM element relative to #canvas-container.
|
|
280
|
+
* @param {Element} el
|
|
281
|
+
* @returns {{ x: number, y: number }}
|
|
282
|
+
*/
|
|
283
|
+
function getTopCenter(el) {
|
|
284
|
+
const containerRect = document.getElementById('canvas-container').getBoundingClientRect();
|
|
285
|
+
const scrollLeft = document.getElementById('canvas-wrap').scrollLeft;
|
|
286
|
+
const scrollTop = document.getElementById('canvas-wrap').scrollTop;
|
|
287
|
+
const r = el.getBoundingClientRect();
|
|
288
|
+
return {
|
|
289
|
+
x: r.left - containerRect.left + scrollLeft + r.width / 2,
|
|
290
|
+
y: r.top - containerRect.top + scrollTop,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Draw a cubic bezier SVG path from (x1,y1) to (x2,y2).
|
|
296
|
+
* @param {number} x1
|
|
297
|
+
* @param {number} y1
|
|
298
|
+
* @param {number} x2
|
|
299
|
+
* @param {number} y2
|
|
300
|
+
* @returns {string}
|
|
301
|
+
*/
|
|
302
|
+
function curvePath(x1, y1, x2, y2) {
|
|
303
|
+
const cy = (y1 + y2) / 2;
|
|
304
|
+
return `M ${x1} ${y1} C ${x1} ${cy}, ${x2} ${cy}, ${x2} ${y2}`;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Build an SVG path element for an edge.
|
|
309
|
+
* @param {string} d
|
|
310
|
+
* @param {boolean} highlight
|
|
311
|
+
* @returns {SVGPathElement}
|
|
312
|
+
*/
|
|
313
|
+
function makePath(d, highlight) {
|
|
314
|
+
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
315
|
+
path.setAttribute('d', d);
|
|
316
|
+
path.setAttribute('class', highlight ? 'edge highlight' : 'edge');
|
|
317
|
+
return path;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function drawEdges() {
|
|
321
|
+
if (!graphData) return;
|
|
322
|
+
|
|
323
|
+
const { milestones, actions } = graphData;
|
|
324
|
+
const container = document.getElementById('canvas-container');
|
|
325
|
+
|
|
326
|
+
// Resize SVG to container dimensions
|
|
327
|
+
$edgesSvg.setAttribute('width', String(container.scrollWidth));
|
|
328
|
+
$edgesSvg.setAttribute('height', String(container.scrollHeight));
|
|
329
|
+
$edgesSvg.innerHTML = '';
|
|
330
|
+
|
|
331
|
+
const fragment = document.createDocumentFragment();
|
|
332
|
+
|
|
333
|
+
// Milestone → Declaration edges (realizes)
|
|
334
|
+
(milestones || []).forEach(m => {
|
|
335
|
+
const mEl = document.querySelector(`[data-node-id="${m.id}"]`);
|
|
336
|
+
if (!mEl) return;
|
|
337
|
+
const mTop = getTopCenter(mEl);
|
|
338
|
+
|
|
339
|
+
(m.realizes || []).forEach(dId => {
|
|
340
|
+
const dEl = document.querySelector(`[data-node-id="${dId}"]`);
|
|
341
|
+
if (!dEl) return;
|
|
342
|
+
const dBot = getBottomCenter(dEl);
|
|
343
|
+
const isHighlighted = selectedNodeId === m.id || selectedNodeId === dId;
|
|
344
|
+
fragment.appendChild(makePath(curvePath(dBot.x, dBot.y, mTop.x, mTop.y), isHighlighted));
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
// Action → Milestone edges (causes)
|
|
349
|
+
(actions || []).forEach(a => {
|
|
350
|
+
const aEl = document.querySelector(`[data-node-id="${a.id}"]`);
|
|
351
|
+
if (!aEl) return;
|
|
352
|
+
const aTop = getTopCenter(aEl);
|
|
353
|
+
|
|
354
|
+
(a.causes || []).forEach(mId => {
|
|
355
|
+
const mEl = document.querySelector(`[data-node-id="${mId}"]`);
|
|
356
|
+
if (!mEl) return;
|
|
357
|
+
const mBot = getBottomCenter(mEl);
|
|
358
|
+
const isHighlighted = selectedNodeId === a.id || selectedNodeId === mId;
|
|
359
|
+
fragment.appendChild(makePath(curvePath(mBot.x, mBot.y, aTop.x, aTop.y), isHighlighted));
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
$edgesSvg.appendChild(fragment);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ─── Side panel ───────────────────────────────────────────────────────────────
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Select a node and show its details.
|
|
370
|
+
* @param {string} nodeId
|
|
371
|
+
* @param {string} type
|
|
372
|
+
*/
|
|
373
|
+
function selectNode(nodeId, type) {
|
|
374
|
+
// Deselect previous
|
|
375
|
+
document.querySelectorAll('.node.selected').forEach(el => el.classList.remove('selected'));
|
|
376
|
+
|
|
377
|
+
if (selectedNodeId === nodeId) {
|
|
378
|
+
// Toggle off
|
|
379
|
+
selectedNodeId = null;
|
|
380
|
+
exitFocusMode();
|
|
381
|
+
if ($panelEmpty) $panelEmpty.style.display = '';
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
selectedNodeId = nodeId;
|
|
386
|
+
|
|
387
|
+
// Highlight node
|
|
388
|
+
const el = document.querySelector(`[data-node-id="${nodeId}"]`);
|
|
389
|
+
if (el) el.classList.add('selected');
|
|
390
|
+
|
|
391
|
+
// If clicking a node already visible in the current focused subtree, skip re-animation
|
|
392
|
+
const alreadyInFocus = focusNodeId && getFocusSubtree(
|
|
393
|
+
focusNodeId,
|
|
394
|
+
document.querySelector(`[data-node-id="${focusNodeId}"]`)?.dataset.nodeType || 'declaration'
|
|
395
|
+
).has(nodeId);
|
|
396
|
+
|
|
397
|
+
if (!alreadyInFocus) {
|
|
398
|
+
enterFocusMode(nodeId, type);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Populate panel
|
|
402
|
+
let item = null;
|
|
403
|
+
if (graphData) {
|
|
404
|
+
if (type === 'declaration') item = graphData.declarations.find(d => d.id === nodeId);
|
|
405
|
+
if (type === 'milestone') item = graphData.milestones.find(m => m.id === nodeId);
|
|
406
|
+
if (type === 'action') item = graphData.actions.find(a => a.id === nodeId);
|
|
407
|
+
}
|
|
408
|
+
if (!item) return;
|
|
409
|
+
|
|
410
|
+
if ($panelEmpty) $panelEmpty.style.display = 'none';
|
|
411
|
+
renderPanelChain(item, type);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Render the detail panel for a selected node.
|
|
416
|
+
* @param {any} item
|
|
417
|
+
* @param {string} type
|
|
418
|
+
*/
|
|
419
|
+
function renderPanelContent(item, type) {
|
|
420
|
+
const status = item.status || 'PENDING';
|
|
421
|
+
|
|
422
|
+
// Color for badge
|
|
423
|
+
const colorMap = {
|
|
424
|
+
declaration: 'var(--decl-color)',
|
|
425
|
+
milestone: 'var(--mile-color)',
|
|
426
|
+
action: 'var(--act-color)',
|
|
427
|
+
};
|
|
428
|
+
const bgMap = {
|
|
429
|
+
declaration: 'var(--decl-bg)',
|
|
430
|
+
milestone: 'var(--mile-bg)',
|
|
431
|
+
action: 'var(--act-bg)',
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
let badgeStyle = `background:${bgMap[type]};color:${colorMap[type]};border:1px solid;`;
|
|
435
|
+
if (['DONE', 'HONORED', 'KEPT'].includes(status)) {
|
|
436
|
+
badgeStyle = 'background:var(--done-bg);color:var(--done-color);border:1px solid var(--done-border);';
|
|
437
|
+
} else if (status === 'BROKEN') {
|
|
438
|
+
badgeStyle = 'background:var(--broken-bg);color:var(--broken-color);border:1px solid var(--broken-border);';
|
|
439
|
+
} else if (status === 'RENEGOTIATED') {
|
|
440
|
+
badgeStyle = 'background:var(--renegotiated-bg);color:var(--renegotiated-color);border:1px solid var(--renegotiated-border);';
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const title = item.title || item.statement || item.id;
|
|
444
|
+
|
|
445
|
+
let html = `
|
|
446
|
+
<div class="detail-id">${type.toUpperCase()} · ${item.id}</div>
|
|
447
|
+
<div class="detail-title">${escHtml(title)}</div>
|
|
448
|
+
<div class="detail-badge" style="${badgeStyle}">${status}</div>
|
|
449
|
+
`;
|
|
450
|
+
|
|
451
|
+
// Type-specific fields
|
|
452
|
+
if (type === 'declaration') {
|
|
453
|
+
if (item.statement) {
|
|
454
|
+
html += section('Statement', escHtml(item.statement));
|
|
455
|
+
}
|
|
456
|
+
if (item.integrity) {
|
|
457
|
+
html += section('Integrity', escHtml(String(item.integrity)));
|
|
458
|
+
}
|
|
459
|
+
// Realized by
|
|
460
|
+
const realizedBy = (graphData.milestones || []).filter(m =>
|
|
461
|
+
(m.realizes || []).some(r => r === item.id)
|
|
462
|
+
);
|
|
463
|
+
if (realizedBy.length) {
|
|
464
|
+
html += tagSection('Realized by', realizedBy, 'milestone');
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (type === 'milestone') {
|
|
469
|
+
if (item.produces) {
|
|
470
|
+
html += section('Produces', escHtml(item.produces));
|
|
471
|
+
}
|
|
472
|
+
if (item.realizes && item.realizes.length) {
|
|
473
|
+
const decls = (graphData.declarations || []).filter(d => item.realizes.includes(d.id));
|
|
474
|
+
html += tagSection('Realizes', decls.length ? decls : item.realizes.map(id => ({ id })), 'declaration');
|
|
475
|
+
}
|
|
476
|
+
// Actions that cause this milestone
|
|
477
|
+
const causedBy = (graphData.actions || []).filter(a =>
|
|
478
|
+
(a.causes || []).some(c => c === item.id)
|
|
479
|
+
);
|
|
480
|
+
if (causedBy.length) {
|
|
481
|
+
html += tagSection('Caused by actions', causedBy, 'action');
|
|
482
|
+
}
|
|
483
|
+
if (item.integrity) {
|
|
484
|
+
html += section('Integrity', escHtml(String(item.integrity)));
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if (type === 'action') {
|
|
489
|
+
if (item.produces) {
|
|
490
|
+
html += section('Produces', escHtml(item.produces));
|
|
491
|
+
}
|
|
492
|
+
if (item.causes && item.causes.length) {
|
|
493
|
+
const miles = (graphData.milestones || []).filter(m => item.causes.includes(m.id));
|
|
494
|
+
html += tagSection('Causes milestones', miles.length ? miles : item.causes.map(id => ({ id })), 'milestone');
|
|
495
|
+
}
|
|
496
|
+
if (item.integrity) {
|
|
497
|
+
html += section('Integrity', escHtml(String(item.integrity)));
|
|
498
|
+
}
|
|
499
|
+
if (item.wave != null) {
|
|
500
|
+
html += section('Wave', escHtml(String(item.wave)));
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
$panelBody.innerHTML = html;
|
|
505
|
+
|
|
506
|
+
// Wire tag clicks to jump to that node
|
|
507
|
+
$panelBody.querySelectorAll('.detail-tag').forEach(tag => {
|
|
508
|
+
tag.addEventListener('click', () => {
|
|
509
|
+
const tid = tag.dataset.nodeId;
|
|
510
|
+
const ttype = tag.dataset.nodeType;
|
|
511
|
+
if (tid && ttype) selectNode(tid, ttype);
|
|
512
|
+
});
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Build a detail section with label and text value.
|
|
518
|
+
* @param {string} label
|
|
519
|
+
* @param {string} value
|
|
520
|
+
* @returns {string}
|
|
521
|
+
*/
|
|
522
|
+
function section(label, value) {
|
|
523
|
+
return `
|
|
524
|
+
<div class="detail-section">
|
|
525
|
+
<div class="detail-label">${label}</div>
|
|
526
|
+
<div class="detail-value">${value}</div>
|
|
527
|
+
</div>
|
|
528
|
+
`;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Build a tag list section for linked nodes.
|
|
533
|
+
* @param {string} label
|
|
534
|
+
* @param {Array<{id: string, title?: string, statement?: string}>} items
|
|
535
|
+
* @param {string} type
|
|
536
|
+
* @returns {string}
|
|
537
|
+
*/
|
|
538
|
+
function tagSection(label, items, type) {
|
|
539
|
+
if (!items.length) return '';
|
|
540
|
+
const tags = items.map(item => {
|
|
541
|
+
const name = item.title || item.statement || item.id;
|
|
542
|
+
return `<span class="detail-tag" data-node-id="${item.id}" data-node-type="${type}">${item.id}: ${truncate(name, 30)}</span>`;
|
|
543
|
+
}).join('');
|
|
544
|
+
return `
|
|
545
|
+
<div class="detail-section">
|
|
546
|
+
<div class="detail-label">${label}</div>
|
|
547
|
+
<div class="detail-tag-list">${tags}</div>
|
|
548
|
+
</div>
|
|
549
|
+
`;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Escape HTML special characters.
|
|
554
|
+
* @param {string} str
|
|
555
|
+
* @returns {string}
|
|
556
|
+
*/
|
|
557
|
+
function escHtml(str) {
|
|
558
|
+
return String(str)
|
|
559
|
+
.replace(/&/g, '&')
|
|
560
|
+
.replace(/</g, '<')
|
|
561
|
+
.replace(/>/g, '>')
|
|
562
|
+
.replace(/"/g, '"');
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// ─── Chain panel renderer ─────────────────────────────────────────────────────
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Render the sidebar with the full chain from declarations down to the clicked node.
|
|
569
|
+
* Clicking a milestone shows: parent declaration(s) → the milestone.
|
|
570
|
+
* Clicking an action shows: declaration(s) → parent milestone(s) → the action.
|
|
571
|
+
*/
|
|
572
|
+
function renderPanelChain(item, type) {
|
|
573
|
+
if (!graphData) return;
|
|
574
|
+
const { declarations, milestones, actions } = graphData;
|
|
575
|
+
const sections = [];
|
|
576
|
+
|
|
577
|
+
if (type === 'action') {
|
|
578
|
+
// Parent milestones
|
|
579
|
+
const parentMilestones = milestones.filter(m => (item.causes || []).includes(m.id));
|
|
580
|
+
parentMilestones.forEach(m => {
|
|
581
|
+
// Parent declarations of the milestone
|
|
582
|
+
const parentDecls = declarations.filter(d => (m.realizes || []).includes(d.id));
|
|
583
|
+
parentDecls.forEach(d => sections.push({ item: d, type: 'declaration', role: 'context' }));
|
|
584
|
+
sections.push({ item: m, type: 'milestone', role: 'context' });
|
|
585
|
+
});
|
|
586
|
+
sections.push({ item, type: 'action', role: 'focus' });
|
|
587
|
+
} else if (type === 'milestone') {
|
|
588
|
+
const parentDecls = declarations.filter(d => (item.realizes || []).includes(d.id));
|
|
589
|
+
parentDecls.forEach(d => sections.push({ item: d, type: 'declaration', role: 'context' }));
|
|
590
|
+
sections.push({ item, type: 'milestone', role: 'focus' });
|
|
591
|
+
} else {
|
|
592
|
+
sections.push({ item, type: 'declaration', role: 'focus' });
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const colorMap = { declaration: 'var(--decl-color)', milestone: 'var(--mile-color)', action: 'var(--act-color)' };
|
|
596
|
+
const bgMap = { declaration: 'var(--decl-bg)', milestone: 'var(--mile-bg)', action: 'var(--act-bg)' };
|
|
597
|
+
const borderMap = { declaration: 'var(--decl-border)', milestone: 'var(--mile-border)', action: 'var(--act-border)' };
|
|
598
|
+
|
|
599
|
+
let html = '';
|
|
600
|
+
sections.forEach((s, idx) => {
|
|
601
|
+
const isFocus = s.role === 'focus';
|
|
602
|
+
const title = s.item.title || s.item.statement || s.item.id;
|
|
603
|
+
const status = s.item.status || 'PENDING';
|
|
604
|
+
const isDone = ['DONE','KEPT','HONORED'].includes(status);
|
|
605
|
+
const isBroken = status === 'BROKEN';
|
|
606
|
+
|
|
607
|
+
const cardBg = isFocus ? bgMap[s.type] : 'var(--surface2)';
|
|
608
|
+
const cardBorder = isFocus ? borderMap[s.type] : 'var(--border)';
|
|
609
|
+
const cardOpacity = isFocus ? '1' : '0.7';
|
|
610
|
+
|
|
611
|
+
const badgeStyle = isDone
|
|
612
|
+
? 'background:var(--done-bg);color:var(--done-color);border:1px solid var(--done-border)'
|
|
613
|
+
: isBroken
|
|
614
|
+
? 'background:var(--broken-bg);color:var(--broken-color);border:1px solid var(--broken-border)'
|
|
615
|
+
: `background:${bgMap[s.type]};color:${colorMap[s.type]};border:1px solid ${borderMap[s.type]}`;
|
|
616
|
+
|
|
617
|
+
// Connector line between sections
|
|
618
|
+
if (idx > 0) {
|
|
619
|
+
html += `<div style="display:flex;justify-content:center;margin:2px 0">
|
|
620
|
+
<div style="width:1px;height:20px;background:var(--border)"></div>
|
|
621
|
+
</div>`;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
html += `<div style="
|
|
625
|
+
background:${cardBg};
|
|
626
|
+
border:1px solid ${cardBorder};
|
|
627
|
+
border-radius:8px;
|
|
628
|
+
padding:12px 14px;
|
|
629
|
+
opacity:${cardOpacity};
|
|
630
|
+
cursor:${isFocus ? 'default' : 'pointer'};
|
|
631
|
+
" ${!isFocus ? `onclick="selectNode('${s.item.id}','${s.type}')"` : ''}>
|
|
632
|
+
<div style="font-size:10px;font-weight:700;letter-spacing:0.08em;opacity:0.6;margin-bottom:3px;color:${colorMap[s.type]}">${s.type.toUpperCase()} · ${s.item.id}</div>
|
|
633
|
+
<div style="font-size:13px;font-weight:${isFocus ? '600' : '500'};color:var(--text-bright);line-height:1.35;margin-bottom:8px">${escHtml(title)}</div>
|
|
634
|
+
<span style="${badgeStyle};display:inline-block;padding:2px 9px;border-radius:8px;font-size:10px;font-weight:700;letter-spacing:0.05em;text-transform:uppercase">${status}</span>
|
|
635
|
+
</div>`;
|
|
636
|
+
|
|
637
|
+
// If this is the focus node, show its type-specific details below
|
|
638
|
+
if (isFocus) {
|
|
639
|
+
if (s.type === 'declaration' && s.item.statement) {
|
|
640
|
+
html += `<div style="margin-top:14px">
|
|
641
|
+
<div class="detail-label">Statement</div>
|
|
642
|
+
<div class="detail-value" style="margin-top:5px">${escHtml(s.item.statement)}</div>
|
|
643
|
+
</div>`;
|
|
644
|
+
const realizedBy = milestones.filter(m => (m.realizes || []).includes(s.item.id));
|
|
645
|
+
if (realizedBy.length) {
|
|
646
|
+
html += chainTagSection('Milestones', realizedBy, 'milestone');
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
if (s.type === 'milestone') {
|
|
650
|
+
const causedBy = actions.filter(a => (a.causes || []).includes(s.item.id));
|
|
651
|
+
if (causedBy.length) html += chainTagSection('Actions', causedBy, 'action');
|
|
652
|
+
}
|
|
653
|
+
if (s.type === 'action' && s.item.produces) {
|
|
654
|
+
html += `<div style="margin-top:14px">
|
|
655
|
+
<div class="detail-label">Produces</div>
|
|
656
|
+
<div class="detail-value" style="margin-top:5px">${escHtml(s.item.produces)}</div>
|
|
657
|
+
</div>`;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
$panelBody.innerHTML = html;
|
|
663
|
+
|
|
664
|
+
// Wire tag clicks
|
|
665
|
+
$panelBody.querySelectorAll('[data-chain-id]').forEach(tag => {
|
|
666
|
+
tag.addEventListener('click', () => {
|
|
667
|
+
selectNode(tag.dataset.chainId, tag.dataset.chainType);
|
|
668
|
+
});
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function chainTagSection(label, items, type) {
|
|
673
|
+
const tags = items.map(item => {
|
|
674
|
+
const name = item.title || item.id;
|
|
675
|
+
return `<span class="detail-tag" data-chain-id="${item.id}" data-chain-type="${type}" style="cursor:pointer">${item.id}: ${truncate(name, 28)}</span>`;
|
|
676
|
+
}).join('');
|
|
677
|
+
return `<div style="margin-top:14px">
|
|
678
|
+
<div class="detail-label">${label}</div>
|
|
679
|
+
<div class="detail-tag-list" style="margin-top:6px">${tags}</div>
|
|
680
|
+
</div>`;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// ─── Focus mode — FLIP technique ──────────────────────────────────────────────
|
|
684
|
+
// Exiting nodes: removed from flow instantly (→ flex re-centers), then overlaid
|
|
685
|
+
// at their original positions via position:fixed for the directional slide-out.
|
|
686
|
+
// Subtree nodes: FLIP'd from old positions to new centered positions simultaneously.
|
|
687
|
+
|
|
688
|
+
const $focusHint = document.getElementById('focus-hint');
|
|
689
|
+
const FOCUS_DUR = 380;
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Compute the set of node IDs that belong to a focused subtree.
|
|
693
|
+
* - declaration: itself + all its milestones + all their actions
|
|
694
|
+
* - milestone: itself + its declarations + its actions
|
|
695
|
+
* - action: itself + its parent milestone + that milestone's declaration + all sibling actions
|
|
696
|
+
* @param {string} nodeId
|
|
697
|
+
* @param {string} type
|
|
698
|
+
* @returns {Set<string>}
|
|
699
|
+
*/
|
|
700
|
+
function getFocusSubtree(nodeId, type) {
|
|
701
|
+
if (!graphData) return new Set();
|
|
702
|
+
const { milestones, actions } = graphData;
|
|
703
|
+
const visible = new Set();
|
|
704
|
+
|
|
705
|
+
if (type === 'declaration') {
|
|
706
|
+
visible.add(nodeId);
|
|
707
|
+
milestones.filter(m => (m.realizes || []).includes(nodeId)).forEach(m => {
|
|
708
|
+
visible.add(m.id);
|
|
709
|
+
actions.filter(a => (a.causes || []).includes(m.id)).forEach(a => visible.add(a.id));
|
|
710
|
+
});
|
|
711
|
+
} else if (type === 'milestone') {
|
|
712
|
+
visible.add(nodeId);
|
|
713
|
+
const m = milestones.find(x => x.id === nodeId);
|
|
714
|
+
if (m) {
|
|
715
|
+
(m.realizes || []).forEach(dId => visible.add(dId));
|
|
716
|
+
actions.filter(a => (a.causes || []).includes(nodeId)).forEach(a => visible.add(a.id));
|
|
717
|
+
}
|
|
718
|
+
} else if (type === 'action') {
|
|
719
|
+
visible.add(nodeId);
|
|
720
|
+
const a = actions.find(x => x.id === nodeId);
|
|
721
|
+
if (a) {
|
|
722
|
+
(a.causes || []).forEach(mId => {
|
|
723
|
+
visible.add(mId);
|
|
724
|
+
const m = milestones.find(x => x.id === mId);
|
|
725
|
+
if (m) {
|
|
726
|
+
// parent declarations
|
|
727
|
+
(m.realizes || []).forEach(dId => visible.add(dId));
|
|
728
|
+
// sibling actions (all actions that cause the same milestone)
|
|
729
|
+
actions.filter(sa => (sa.causes || []).includes(mId)).forEach(sa => visible.add(sa.id));
|
|
730
|
+
}
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
return visible;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/** Clear all focus-mode inline styles from a node element. */
|
|
739
|
+
function clearNodeFocusStyles(el) {
|
|
740
|
+
el.style.cssText = ''; // wipe all inline styles at once
|
|
741
|
+
el.classList.remove('focus-exiting', 'focus-active');
|
|
742
|
+
el.dataset.focusDir = '';
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/** @type {Array<{el: HTMLElement, rect: DOMRect, dirLeft: boolean}>} */
|
|
746
|
+
let exitedNodes = [];
|
|
747
|
+
/** @type {ReturnType<typeof setTimeout> | null} Shared cleanup timer — covers both enter and exit cleanups so each cancels the other */
|
|
748
|
+
let focusCleanupTimer = null;
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* Snapshot getBoundingClientRect for a set of node IDs.
|
|
752
|
+
* @param {Set<string>} ids
|
|
753
|
+
* @returns {Map<string, DOMRect>}
|
|
754
|
+
*/
|
|
755
|
+
function snapshotRects(ids) {
|
|
756
|
+
const map = new Map();
|
|
757
|
+
ids.forEach(id => {
|
|
758
|
+
const el = document.querySelector(`[data-node-id="${id}"]`);
|
|
759
|
+
if (el) map.set(id, el.getBoundingClientRect());
|
|
760
|
+
});
|
|
761
|
+
return map;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* Enter focus mode using FLIP:
|
|
766
|
+
* 1. Snapshot subtree node positions (FIRST)
|
|
767
|
+
* 2. Remove exiting nodes from flow + overlay them fixed at their original positions
|
|
768
|
+
* 3. Flex re-centers remaining nodes instantly
|
|
769
|
+
* 4. Snapshot new subtree positions (LAST)
|
|
770
|
+
* 5. INVERT: push subtree nodes back to original positions via transform (no transition)
|
|
771
|
+
* 6. PLAY: animate subtree to new center + animate fixed-overlay exits to slide out
|
|
772
|
+
*/
|
|
773
|
+
function enterFocusMode(nodeId, type) {
|
|
774
|
+
if (!graphData) return;
|
|
775
|
+
// Always cancel any pending cleanup (enter or exit) before starting a new animation
|
|
776
|
+
if (focusCleanupTimer) { clearTimeout(focusCleanupTimer); focusCleanupTimer = null; }
|
|
777
|
+
if (focusNodeId) {
|
|
778
|
+
// Restore everything cleanly before re-entering
|
|
779
|
+
exitedNodes.forEach(({ el }) => {
|
|
780
|
+
el.style.cssText = '';
|
|
781
|
+
el.classList.remove('focus-exiting', 'focus-active');
|
|
782
|
+
});
|
|
783
|
+
document.querySelectorAll('.node.focus-active').forEach(el => el.classList.remove('focus-active'));
|
|
784
|
+
exitedNodes = [];
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
focusNodeId = nodeId;
|
|
788
|
+
const subtree = getFocusSubtree(nodeId, type);
|
|
789
|
+
|
|
790
|
+
// Determine focus center X for directional exits
|
|
791
|
+
const focusEl = document.querySelector(`[data-node-id="${nodeId}"]`);
|
|
792
|
+
if (!focusEl) return;
|
|
793
|
+
const focusCenterX = focusEl.getBoundingClientRect().left + focusEl.getBoundingClientRect().width / 2;
|
|
794
|
+
|
|
795
|
+
// Classify all nodes
|
|
796
|
+
const subtreeEls = new Map(); // id → el
|
|
797
|
+
exitedNodes = [];
|
|
798
|
+
document.querySelectorAll('.node').forEach(el => {
|
|
799
|
+
const id = el.dataset.nodeId;
|
|
800
|
+
if (!id) return;
|
|
801
|
+
el.style.cssText = '';
|
|
802
|
+
el.classList.remove('focus-exiting', 'focus-active');
|
|
803
|
+
if (subtree.has(id)) {
|
|
804
|
+
el.classList.add('focus-active');
|
|
805
|
+
subtreeEls.set(id, el);
|
|
806
|
+
} else {
|
|
807
|
+
const rect = el.getBoundingClientRect();
|
|
808
|
+
exitedNodes.push({ el, rect, dirLeft: (rect.left + rect.width / 2) < focusCenterX });
|
|
809
|
+
}
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
// FIRST: snapshot subtree positions before layout change
|
|
813
|
+
const firstRects = snapshotRects(subtree);
|
|
814
|
+
|
|
815
|
+
// Remove exiting nodes from flow (instantly invisible — opacity handled separately)
|
|
816
|
+
// Pin them fixed at their current viewport positions for the slide-out overlay
|
|
817
|
+
exitedNodes.forEach(({ el, rect, dirLeft }) => {
|
|
818
|
+
el.dataset.focusDir = dirLeft ? 'left' : 'right';
|
|
819
|
+
el.classList.add('focus-exiting');
|
|
820
|
+
el.style.position = 'fixed';
|
|
821
|
+
el.style.left = rect.left + 'px';
|
|
822
|
+
el.style.top = rect.top + 'px';
|
|
823
|
+
el.style.width = rect.width + 'px';
|
|
824
|
+
el.style.height = rect.height + 'px';
|
|
825
|
+
el.style.margin = '0';
|
|
826
|
+
el.style.zIndex = '15';
|
|
827
|
+
el.style.pointerEvents = 'none';
|
|
828
|
+
el.style.opacity = '1';
|
|
829
|
+
el.style.transform = 'none';
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
// Force reflow: flex now sees only subtree nodes → re-centers them
|
|
833
|
+
void document.body.offsetWidth;
|
|
834
|
+
|
|
835
|
+
// LAST: snapshot new positions
|
|
836
|
+
const lastRects = snapshotRects(subtree);
|
|
837
|
+
|
|
838
|
+
// INVERT: push subtree nodes to appear at their old positions (no transition)
|
|
839
|
+
subtreeEls.forEach((el, id) => {
|
|
840
|
+
const first = firstRects.get(id);
|
|
841
|
+
const last = lastRects.get(id);
|
|
842
|
+
if (!first || !last) return;
|
|
843
|
+
const dx = first.left - last.left;
|
|
844
|
+
const dy = first.top - last.top;
|
|
845
|
+
if (Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5) {
|
|
846
|
+
el.style.transition = 'none';
|
|
847
|
+
el.style.transform = `translate(${dx}px, ${dy}px)`;
|
|
848
|
+
}
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
// Force reflow so invert transforms are painted before we play
|
|
852
|
+
void document.body.offsetWidth;
|
|
853
|
+
|
|
854
|
+
const dur = FOCUS_DUR + 'ms';
|
|
855
|
+
const easeIn = 'cubic-bezier(0,0,0.2,1)';
|
|
856
|
+
const easeOut = 'cubic-bezier(0.4,0,1,1)';
|
|
857
|
+
|
|
858
|
+
// PLAY: animate subtree nodes to their new centered positions
|
|
859
|
+
subtreeEls.forEach(el => {
|
|
860
|
+
el.style.transition = `transform ${dur} ${easeIn}`;
|
|
861
|
+
el.style.transform = '';
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
// Clear edges immediately (no transition lag) — redraw only after animation settles
|
|
865
|
+
$edgesSvg.innerHTML = '';
|
|
866
|
+
$edgesSvg.style.opacity = '0';
|
|
867
|
+
|
|
868
|
+
// PLAY: slide + fade exiting nodes out from their fixed positions
|
|
869
|
+
requestAnimationFrame(() => {
|
|
870
|
+
exitedNodes.forEach(({ el, dirLeft }) => {
|
|
871
|
+
el.style.transition = `opacity ${Math.round(FOCUS_DUR * 0.7)}ms ease, transform ${dur} ${easeOut}`;
|
|
872
|
+
el.style.opacity = '0';
|
|
873
|
+
el.style.transform = `translateX(${dirLeft ? -130 : 130}%)`;
|
|
874
|
+
});
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
// After animation: clean up + redraw edges at final positions, then fade back in
|
|
878
|
+
// Stored in focusCleanupTimer so exitFocusMode can cancel it if user exits early
|
|
879
|
+
focusCleanupTimer = setTimeout(() => {
|
|
880
|
+
focusCleanupTimer = null;
|
|
881
|
+
exitedNodes.forEach(({ el }) => {
|
|
882
|
+
el.style.cssText = '';
|
|
883
|
+
el.style.display = 'none';
|
|
884
|
+
el.classList.remove('focus-exiting');
|
|
885
|
+
});
|
|
886
|
+
subtreeEls.forEach(el => { el.style.transition = ''; el.style.transform = ''; });
|
|
887
|
+
drawEdgesForSubtree(subtree);
|
|
888
|
+
$edgesSvg.style.opacity = '1';
|
|
889
|
+
}, FOCUS_DUR + 50);
|
|
890
|
+
|
|
891
|
+
$focusHint.classList.add('visible');
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* Exit focus mode — proper reverse FLIP:
|
|
896
|
+
* 1. Snapshot current visual positions of subtree nodes (may be centered)
|
|
897
|
+
* 2. Restore ALL nodes to normal flow (clear all inline styles)
|
|
898
|
+
* 3. Force layout → nodes at natural positions
|
|
899
|
+
* 4. Snapshot natural positions (LAST)
|
|
900
|
+
* 5. INVERT: push subtree nodes back to where they were + push returned nodes off-screen
|
|
901
|
+
* 6. Force reflow
|
|
902
|
+
* 7. PLAY: animate everything to natural (transform:'')
|
|
903
|
+
*/
|
|
904
|
+
function exitFocusMode() {
|
|
905
|
+
if (!focusNodeId) return;
|
|
906
|
+
const prevSubtree = getFocusSubtree(
|
|
907
|
+
focusNodeId,
|
|
908
|
+
document.querySelector(`[data-node-id="${focusNodeId}"]`)?.dataset.nodeType || 'declaration'
|
|
909
|
+
);
|
|
910
|
+
focusNodeId = null;
|
|
911
|
+
if (focusCleanupTimer) { clearTimeout(focusCleanupTimer); focusCleanupTimer = null; }
|
|
912
|
+
|
|
913
|
+
const dur = FOCUS_DUR + 'ms';
|
|
914
|
+
const easeIn = 'cubic-bezier(0,0,0.2,1)';
|
|
915
|
+
|
|
916
|
+
// FIRST: snapshot current visual positions of subtree nodes (before any style changes)
|
|
917
|
+
const firstRects = snapshotRects(prevSubtree);
|
|
918
|
+
|
|
919
|
+
// Capture dirLeft for each exited node, then clear all inline styles on every node
|
|
920
|
+
const capturedExits = exitedNodes.map(({ el, dirLeft }) => ({ el, dirLeft }));
|
|
921
|
+
exitedNodes = [];
|
|
922
|
+
|
|
923
|
+
document.querySelectorAll('.node').forEach(el => {
|
|
924
|
+
el.style.cssText = '';
|
|
925
|
+
el.classList.remove('focus-exiting', 'focus-active');
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
// Force layout: all nodes now at their natural flex positions
|
|
929
|
+
void document.body.offsetWidth;
|
|
930
|
+
|
|
931
|
+
// LAST: snapshot natural positions of subtree nodes
|
|
932
|
+
const lastRects = snapshotRects(prevSubtree);
|
|
933
|
+
|
|
934
|
+
// Build subtree element map
|
|
935
|
+
const subtreeEls = new Map();
|
|
936
|
+
prevSubtree.forEach(id => {
|
|
937
|
+
const el = document.querySelector(`[data-node-id="${id}"]`);
|
|
938
|
+
if (el) subtreeEls.set(id, el);
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
// INVERT subtree: push nodes back to where they appeared (centered)
|
|
942
|
+
subtreeEls.forEach((el, id) => {
|
|
943
|
+
const first = firstRects.get(id);
|
|
944
|
+
const last = lastRects.get(id);
|
|
945
|
+
if (!first || !last) return;
|
|
946
|
+
const dx = first.left - last.left;
|
|
947
|
+
const dy = first.top - last.top;
|
|
948
|
+
if (Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5) {
|
|
949
|
+
el.style.transition = 'none';
|
|
950
|
+
el.style.transform = `translate(${dx}px, ${dy}px)`;
|
|
951
|
+
}
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
// INVERT returned nodes: push off-screen so they slide in
|
|
955
|
+
capturedExits.forEach(({ el, dirLeft }) => {
|
|
956
|
+
el.style.transition = 'none';
|
|
957
|
+
el.style.opacity = '0';
|
|
958
|
+
el.style.transform = `translateX(${dirLeft ? -130 : 130}%)`;
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
// Force reflow to paint inverted state before PLAY
|
|
962
|
+
void document.body.offsetWidth;
|
|
963
|
+
|
|
964
|
+
// Clear edges immediately
|
|
965
|
+
$edgesSvg.innerHTML = '';
|
|
966
|
+
$edgesSvg.style.opacity = '0';
|
|
967
|
+
|
|
968
|
+
// PLAY: animate all nodes to natural positions (transform:'')
|
|
969
|
+
requestAnimationFrame(() => {
|
|
970
|
+
subtreeEls.forEach(el => {
|
|
971
|
+
el.style.transition = `transform ${dur} ${easeIn}`;
|
|
972
|
+
el.style.transform = '';
|
|
973
|
+
});
|
|
974
|
+
capturedExits.forEach(({ el }) => {
|
|
975
|
+
el.style.transition = `opacity ${Math.round(FOCUS_DUR * 0.8)}ms ease ${Math.round(FOCUS_DUR * 0.1)}ms, transform ${dur} ${easeIn}`;
|
|
976
|
+
el.style.opacity = '1';
|
|
977
|
+
el.style.transform = '';
|
|
978
|
+
});
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
// Cleanup: remove inline styles, redraw edges at settled positions
|
|
982
|
+
focusCleanupTimer = setTimeout(() => {
|
|
983
|
+
document.querySelectorAll('.node').forEach(el => {
|
|
984
|
+
el.style.transition = '';
|
|
985
|
+
el.style.transform = '';
|
|
986
|
+
el.style.opacity = '';
|
|
987
|
+
});
|
|
988
|
+
void document.body.offsetWidth;
|
|
989
|
+
requestAnimationFrame(() => {
|
|
990
|
+
drawEdges();
|
|
991
|
+
$edgesSvg.style.opacity = '1';
|
|
992
|
+
});
|
|
993
|
+
focusCleanupTimer = null;
|
|
994
|
+
}, FOCUS_DUR + 80);
|
|
995
|
+
|
|
996
|
+
$focusHint.classList.remove('visible');
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* Draw edges but dim those outside the given subtree.
|
|
1001
|
+
* @param {Set<string>} subtree
|
|
1002
|
+
*/
|
|
1003
|
+
function drawEdgesForSubtree(subtree) {
|
|
1004
|
+
if (!graphData) return;
|
|
1005
|
+
const { milestones, actions } = graphData;
|
|
1006
|
+
const container = document.getElementById('canvas-container');
|
|
1007
|
+
|
|
1008
|
+
$edgesSvg.setAttribute('width', String(container.scrollWidth));
|
|
1009
|
+
$edgesSvg.setAttribute('height', String(container.scrollHeight));
|
|
1010
|
+
$edgesSvg.innerHTML = '';
|
|
1011
|
+
|
|
1012
|
+
const fragment = document.createDocumentFragment();
|
|
1013
|
+
|
|
1014
|
+
(milestones || []).forEach(m => {
|
|
1015
|
+
const mEl = document.querySelector(`[data-node-id="${m.id}"]`);
|
|
1016
|
+
if (!mEl) return;
|
|
1017
|
+
const mTop = getTopCenter(mEl);
|
|
1018
|
+
|
|
1019
|
+
(m.realizes || []).forEach(dId => {
|
|
1020
|
+
const dEl = document.querySelector(`[data-node-id="${dId}"]`);
|
|
1021
|
+
if (!dEl) return;
|
|
1022
|
+
const dBot = getBottomCenter(dEl);
|
|
1023
|
+
const inSubtree = subtree.has(m.id) && subtree.has(dId);
|
|
1024
|
+
const path = makePath(curvePath(dBot.x, dBot.y, mTop.x, mTop.y), inSubtree);
|
|
1025
|
+
if (!inSubtree) path.classList.add('focus-dim');
|
|
1026
|
+
fragment.appendChild(path);
|
|
1027
|
+
});
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
(actions || []).forEach(a => {
|
|
1031
|
+
const aEl = document.querySelector(`[data-node-id="${a.id}"]`);
|
|
1032
|
+
if (!aEl) return;
|
|
1033
|
+
const aTop = getTopCenter(aEl);
|
|
1034
|
+
|
|
1035
|
+
(a.causes || []).forEach(mId => {
|
|
1036
|
+
const mEl = document.querySelector(`[data-node-id="${mId}"]`);
|
|
1037
|
+
if (!mEl) return;
|
|
1038
|
+
const mBot = getBottomCenter(mEl);
|
|
1039
|
+
const inSubtree = subtree.has(a.id) && subtree.has(mId);
|
|
1040
|
+
const path = makePath(curvePath(mBot.x, mBot.y, aTop.x, aTop.y), inSubtree);
|
|
1041
|
+
if (!inSubtree) path.classList.add('focus-dim');
|
|
1042
|
+
fragment.appendChild(path);
|
|
1043
|
+
});
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
$edgesSvg.appendChild(fragment);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// ─── Event wiring ─────────────────────────────────────────────────────────────
|
|
1050
|
+
|
|
1051
|
+
$refreshBtn.addEventListener('click', () => {
|
|
1052
|
+
loadData();
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
// ESC to exit focus mode
|
|
1056
|
+
document.addEventListener('keydown', (e) => {
|
|
1057
|
+
if (e.key === 'Escape' && focusNodeId) {
|
|
1058
|
+
document.querySelectorAll('.node.selected').forEach(el => el.classList.remove('selected'));
|
|
1059
|
+
selectedNodeId = null;
|
|
1060
|
+
exitFocusMode();
|
|
1061
|
+
if ($panelEmpty) $panelEmpty.style.display = '';
|
|
1062
|
+
}
|
|
1063
|
+
});
|
|
1064
|
+
|
|
1065
|
+
// Click on canvas background to exit focus mode
|
|
1066
|
+
document.getElementById('canvas-wrap').addEventListener('click', (e) => {
|
|
1067
|
+
if (!focusNodeId) return;
|
|
1068
|
+
if (!e.target.closest('.node')) {
|
|
1069
|
+
document.querySelectorAll('.node.selected').forEach(el => el.classList.remove('selected'));
|
|
1070
|
+
selectedNodeId = null;
|
|
1071
|
+
exitFocusMode();
|
|
1072
|
+
if ($panelEmpty) $panelEmpty.style.display = '';
|
|
1073
|
+
}
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1076
|
+
$overlayRetry.addEventListener('click', () => {
|
|
1077
|
+
showLoading();
|
|
1078
|
+
loadData();
|
|
1079
|
+
});
|
|
1080
|
+
|
|
1081
|
+
// Redraw edges on window resize or scroll (layout may shift)
|
|
1082
|
+
window.addEventListener('resize', () => {
|
|
1083
|
+
if (graphData) requestAnimationFrame(() => drawEdges());
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
document.getElementById('canvas-wrap').addEventListener('scroll', () => {
|
|
1087
|
+
if (graphData) requestAnimationFrame(() => drawEdges());
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
// ─── Live updates via Server-Sent Events ─────────────────────────────────────
|
|
1091
|
+
// Server watches .planning/ with fs.watch and pushes a 'change' event.
|
|
1092
|
+
// Client re-renders only when idle (not mid-animation).
|
|
1093
|
+
|
|
1094
|
+
function connectSSE() {
|
|
1095
|
+
const es = new EventSource('/events');
|
|
1096
|
+
es.addEventListener('change', () => {
|
|
1097
|
+
if (focusNodeId || focusCleanupTimer) return; // skip during animation
|
|
1098
|
+
loadData();
|
|
1099
|
+
});
|
|
1100
|
+
es.addEventListener('error', () => {
|
|
1101
|
+
// Connection dropped — reconnect after 3s
|
|
1102
|
+
es.close();
|
|
1103
|
+
setTimeout(connectSSE, 3000);
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
connectSSE();
|
|
1108
|
+
|
|
1109
|
+
// ─── Bootstrap ───────────────────────────────────────────────────────────────
|
|
1110
|
+
|
|
1111
|
+
showLoading();
|
|
1112
|
+
loadData();
|