@quolu/lattice 0.12.12 → 0.12.13
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 +1 -1
- package/src/todo-dashboard-registry.mjs +17 -1
- package/src/todo-gantt-html.mjs +63 -14
- package/src/todo-gantt-layout.mjs +12 -2
- package/src/todo-gantt-live.mjs +7 -1
- package/src/todo-gantt-scope.mjs +81 -64
package/package.json
CHANGED
|
@@ -6,6 +6,16 @@ import {
|
|
|
6
6
|
} from 'node:fs/promises';
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
|
|
9
|
+
import packageJson from '../package.json' with { type: 'json' };
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The version of the code in THIS process. A daemon loads its modules once at
|
|
13
|
+
* startup and keeps serving them, so installing a new package does not change
|
|
14
|
+
* what the running daemon serves. The daemon reports the version it started
|
|
15
|
+
* with; anything else is code that has already been replaced on disk.
|
|
16
|
+
*/
|
|
17
|
+
export const TODO_DASHBOARD_CODE_VERSION = packageJson.version;
|
|
18
|
+
|
|
9
19
|
const REGISTRY_SCHEMA = 'lattice.todo_dashboard_registry.v1';
|
|
10
20
|
const DAEMON_SCHEMA = 'lattice.todo_dashboard_daemon.v1';
|
|
11
21
|
const DEFAULT_PORT = 0;
|
|
@@ -182,7 +192,13 @@ async function daemonAttestation(descriptor, { timeoutMs = 2_000 } = {}) {
|
|
|
182
192
|
if (body?.schema !== 'lattice.todo_dashboard_health.v1' || body.pid !== descriptor.pid
|
|
183
193
|
|| !Array.isArray(body.project_ids)) return null;
|
|
184
194
|
const keys = Object.keys(body).sort().join(',');
|
|
185
|
-
|
|
195
|
+
// 'legacy' means "alive, but serving code we have already replaced" — the
|
|
196
|
+
// caller starts a replacement and stops it. A daemon that predates the
|
|
197
|
+
// version field, or one still running an older package, is exactly that.
|
|
198
|
+
if (keys === 'pid,port,project_ids,schema,version' && body.port === descriptor.port) {
|
|
199
|
+
return body.version === TODO_DASHBOARD_CODE_VERSION ? 'current' : 'legacy';
|
|
200
|
+
}
|
|
201
|
+
if (keys === 'pid,port,project_ids,schema' && body.port === descriptor.port) return 'legacy';
|
|
186
202
|
if (keys === 'pid,project_ids,schema') return 'legacy';
|
|
187
203
|
return null;
|
|
188
204
|
} catch { return null; }
|
package/src/todo-gantt-html.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
} from './todo-markdown-renderer.mjs';
|
|
7
7
|
import { renderTodoGanttSvg, TODO_GANTT_STATUS_PRESENTATION } from './todo-gantt-svg.mjs';
|
|
8
8
|
|
|
9
|
-
export const TODO_GANTT_RENDERER_VERSION = 'lattice.todo_gantt_renderer.
|
|
9
|
+
export const TODO_GANTT_RENDERER_VERSION = 'lattice.todo_gantt_renderer.v10';
|
|
10
10
|
export const TODO_GANTT_PROSE_MAX_BYTES = 8 * 1024 * 1024;
|
|
11
11
|
export const TODO_GANTT_HTML_MAX_BYTES = 24 * 1024 * 1024;
|
|
12
12
|
|
|
@@ -131,14 +131,16 @@ function foldIndex(layout) {
|
|
|
131
131
|
return new Map((layout?.folded ?? []).map((entry) => [refKey(entry.task), refKey(entry.fold)]));
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
-
function renderTaskIndexEntry(section, lookup
|
|
134
|
+
function renderTaskIndexEntry(section, lookup) {
|
|
135
135
|
const key = refKey(section.ref);
|
|
136
136
|
const status = DOCUMENT_STATUS[section.state.status] ?? { mark: '?', label: '状態不明' };
|
|
137
137
|
const blockedReason = section.state.status === 'blocked'
|
|
138
138
|
? `<span class="task-index-blocked-reason">— ${escapeHtmlText(section.state.blocked_reason ?? '理由未記録')}</span>` : '';
|
|
139
|
-
// A folded ToDo keeps its row here — the index is the complete list —
|
|
140
|
-
//
|
|
141
|
-
|
|
139
|
+
// A folded ToDo keeps its row here — the index is the complete list — and it
|
|
140
|
+
// selects its OWN detail. Pointing the row at the fold node standing in for it
|
|
141
|
+
// on the diagram would open nothing: a fold node is not a ToDo, so no detail
|
|
142
|
+
// panel carries its key.
|
|
143
|
+
const selectKey = key;
|
|
142
144
|
return `<li><button type="button" data-select-node-key="${escapeHtmlAttribute(selectKey)}"><span class="task-index-status status-${escapeHtmlAttribute(section.state.status)}" role="img" aria-label="${escapeHtmlAttribute(status.label)}">${escapeHtmlText(status.mark)}</span><span class="task-index-reference">${escapeHtmlText(taskReference(section, lookup))}</span><strong>${escapeHtmlText(section.task.title)}</strong>${blockedReason}</button></li>`;
|
|
143
145
|
}
|
|
144
146
|
|
|
@@ -156,13 +158,33 @@ function renderTaskIndex(sections, lookup, folds = new Map()) {
|
|
|
156
158
|
const drawn = plan.tasks.filter((section) => !folds.has(refKey(section.ref)));
|
|
157
159
|
const folded = plan.tasks.filter((section) => folds.has(refKey(section.ref)));
|
|
158
160
|
const drawnList = drawn.length === 0 ? ''
|
|
159
|
-
: `<ol class="task-index-list">${drawn.map((section) => renderTaskIndexEntry(section, lookup
|
|
161
|
+
: `<ol class="task-index-list">${drawn.map((section) => renderTaskIndexEntry(section, lookup)).join('')}</ol>`;
|
|
160
162
|
const foldedList = folded.length === 0 ? ''
|
|
161
|
-
: `<details class="task-index-folded"><summary>完走済みとして畳んだ工程 ${folded.length}件</summary><ol class="task-index-list">${folded.map((section) => renderTaskIndexEntry(section, lookup
|
|
163
|
+
: `<details class="task-index-folded"><summary>完走済みとして畳んだ工程 ${folded.length}件</summary><ol class="task-index-list">${folded.map((section) => renderTaskIndexEntry(section, lookup)).join('')}</ol></details>`;
|
|
162
164
|
return `<section class="task-index-plan"><h2><code>${escapeHtmlText(plan.planKey)}</code></h2>${drawnList}${foldedList}</section>`;
|
|
163
165
|
}).join('');
|
|
164
166
|
}
|
|
165
167
|
|
|
168
|
+
/**
|
|
169
|
+
* Detail panel for a fold node.
|
|
170
|
+
*
|
|
171
|
+
* A fold node is the only thing standing on the diagram for the history it
|
|
172
|
+
* summarises, so it must open like any other node — otherwise clicking the
|
|
173
|
+
* folded part of the plan does nothing and the folded ToDos become unreachable.
|
|
174
|
+
*/
|
|
175
|
+
function renderFoldDetail(fold, members, lookup) {
|
|
176
|
+
const laneLabels = fold.lanes.map((lane) => {
|
|
177
|
+
const entry = lookup.lanes.get(JSON.stringify([fold.ref.plan_key, lane]));
|
|
178
|
+
return entry === undefined ? lane : `${lane} — ${entry.name}`;
|
|
179
|
+
});
|
|
180
|
+
const chain = fold.longest_chain_task_count === 0 ? ''
|
|
181
|
+
: `<p><strong>構造上の最長依存鎖:</strong> このうち${fold.longest_chain_task_count}工程が乗っています。</p>`;
|
|
182
|
+
const memberList = members.length === 0
|
|
183
|
+
? '<p class="relation-empty">構成工程を復元できませんでした。</p>'
|
|
184
|
+
: `<ol class="task-index-list">${members.map((section) => renderTaskIndexEntry(section, lookup)).join('')}</ol>`;
|
|
185
|
+
return `<article class="task-detail fold-detail" data-detail-key="${escapeHtmlAttribute(refKey(fold.ref))}" hidden><header><span class="detail-status status-done">▣ 完走済み(畳み込み)</span><span class="detail-reference">${escapeHtmlText(`${fold.task_count}工程`)}</span></header><h1>完了済み ${escapeHtmlText(String(fold.task_count))}件</h1><p><strong>plan:</strong> <code>${escapeHtmlText(fold.ref.plan_key)}</code></p><p class="detail-category"><strong>カテゴリ:</strong> ${escapeHtmlText(laneLabels.join('、'))}</p>${chain}<p class="fold-note">後続に作業中・未着手の工程が残っていないため、まとめて1個のノードとして描いています。図に全件を描くには <code>lattice todo gantt --scope all</code> を実行してください。</p><section><h2>含まれる工程 ${escapeHtmlText(String(members.length))}件</h2>${memberList}</section></article>`;
|
|
186
|
+
}
|
|
187
|
+
|
|
166
188
|
function presentationLookup(presentation) {
|
|
167
189
|
return {
|
|
168
190
|
lanes: new Map((presentation?.lanes ?? []).map((lane) => [JSON.stringify([lane.plan_key, lane.lane]), lane])),
|
|
@@ -175,14 +197,19 @@ function taskReference(section, lookup) {
|
|
|
175
197
|
return number === undefined ? `ID ${section.task.task_id}` : `工程 ${number.display_number}`;
|
|
176
198
|
}
|
|
177
199
|
|
|
178
|
-
function renderRelationList(relations, sectionByKey, lookup, emptyText) {
|
|
200
|
+
function renderRelationList(relations, sectionByKey, lookup, emptyText, folds = new Map()) {
|
|
179
201
|
if (relations.length === 0) return `<p class="relation-empty">${escapeHtmlText(emptyText)}</p>`;
|
|
180
202
|
return `<ul class="relation-list">${relations.map((relation) => {
|
|
181
|
-
const
|
|
203
|
+
const targetKey = refKey(relation.ref);
|
|
204
|
+
const target = sectionByKey.get(targetKey);
|
|
182
205
|
if (target === undefined) return '';
|
|
183
206
|
const join = relation.joinIds.length === 0 ? ''
|
|
184
207
|
: `<span class="relation-kind">合流条件: ${escapeHtmlText(relation.joinIds.join(', '))}</span>`;
|
|
185
|
-
|
|
208
|
+
// Mark the ones the diagram no longer draws separately, so the reader knows
|
|
209
|
+
// why they cannot find this box on screen.
|
|
210
|
+
const reference = folds.has(targetKey)
|
|
211
|
+
? `▣ ${taskReference(target, lookup)}` : taskReference(target, lookup);
|
|
212
|
+
return `<li><button type="button" data-select-node-key="${escapeHtmlAttribute(targetKey)}"><strong>${escapeHtmlText(reference)}</strong><span>${escapeHtmlText(target.task.title)}</span></button>${join}</li>`;
|
|
186
213
|
}).join('')}</ul>`;
|
|
187
214
|
}
|
|
188
215
|
|
|
@@ -211,6 +238,7 @@ function renderRightPane(sections, layout, presentation, readModel) {
|
|
|
211
238
|
const lookup = presentationLookup(presentation);
|
|
212
239
|
const sectionByKey = new Map(sections.map((section) => [refKey(section.ref), section]));
|
|
213
240
|
const nodeByKey = new Map(layout.nodes.map((node) => [refKey(node.ref), node]));
|
|
241
|
+
const folds = foldIndex(layout);
|
|
214
242
|
const incoming = new Map(sections.map((section) => [refKey(section.ref), []]));
|
|
215
243
|
const outgoing = new Map(sections.map((section) => [refKey(section.ref), []]));
|
|
216
244
|
const addRelation = (relations, ownerKey, ref, joinIds) => {
|
|
@@ -223,7 +251,10 @@ function renderRightPane(sections, layout, presentation, readModel) {
|
|
|
223
251
|
}
|
|
224
252
|
entry.joinIds = [...new Set([...entry.joinIds, ...joinIds])].sort();
|
|
225
253
|
};
|
|
226
|
-
|
|
254
|
+
// Premises and successors come from the FULL graph. `layout.edges` is the
|
|
255
|
+
// drawn graph, where a fold unit's interior dependencies have been contracted
|
|
256
|
+
// away — reading those here would tell a folded ToDo it has no premises.
|
|
257
|
+
for (const edge of layout.full_edges ?? layout.edges) {
|
|
227
258
|
addRelation(incoming, refKey(edge.to), edge.from, edge.join_ids);
|
|
228
259
|
addRelation(outgoing, refKey(edge.from), edge.to, edge.join_ids);
|
|
229
260
|
}
|
|
@@ -256,10 +287,25 @@ function renderRightPane(sections, layout, presentation, readModel) {
|
|
|
256
287
|
const readiness = node?.visibility.next_ready
|
|
257
288
|
? `<p class="readiness-note">ready frontierの一員です。${ready.length > 1 ? '他のready工程と同時dispatchするのが既定です。subsetだけを選ぶ場合は理由を記録してください。' : '現在の唯一の着手候補です。'}</p>`
|
|
258
289
|
: incoming.get(key).length === 0 ? '<p class="readiness-note">登録済みの前提工程はありません。図だけではdispatch可否を判定しません。</p>' : '';
|
|
259
|
-
|
|
290
|
+
// A folded ToDo has no box of its own on the diagram. Say which fold node
|
|
291
|
+
// stands in for it, and make that node one click away.
|
|
292
|
+
const foldKey = folds.get(key);
|
|
293
|
+
const foldedNote = foldKey === undefined ? ''
|
|
294
|
+
: `<p class="fold-note">この工程は図の上では ▣ 畳み込みノードにまとめられています。<button type="button" class="fold-return" data-select-node-key="${escapeHtmlAttribute(foldKey)}">畳み込みノードを開く</button></p>`;
|
|
295
|
+
return `<article class="task-detail" data-detail-key="${escapeHtmlAttribute(key)}" hidden><header><span class="detail-status status-${escapeHtmlAttribute(section.state.status)}">${escapeHtmlText(status.mark)} ${escapeHtmlText(status.label)}</span><span class="detail-reference">${escapeHtmlText(taskReference(section, lookup))}</span></header><h1>${escapeHtmlText(section.task.title)}</h1><p class="detail-category"><strong>カテゴリ:</strong> ${escapeHtmlText(category)}</p>${categoryDescription}<p><strong>正規ID:</strong> <code>${escapeHtmlText(`${section.ref.plan_key}/${section.task.task_id}`)}</code></p>${blockedReason}${readiness}${foldedNote}<section><h2>前提工程</h2>${renderRelationList(incoming.get(key), sectionByKey, lookup, '登録済みの前提工程はありません。', folds)}</section><section><h2>後続工程</h2>${renderRelationList(outgoing.get(key), sectionByKey, lookup, '登録済みの後続工程はありません。', folds)}</section><p class="anchor-status">${escapeHtmlText(anchorText)}</p><details class="task-diagnostics"><summary>開発者向け診断</summary><dl><dt>canonical ref</dt><dd><code>${escapeHtmlText(`${section.ref.project_id}/${section.ref.plan_key}/${section.task.task_id}`)}</code></dd><dt>anchor</dt><dd>${escapeHtmlText(section.anchorOutcome.anchored ? 'verified' : section.anchorOutcome.reason)}</dd></dl></details></article>`;
|
|
260
296
|
}).join('');
|
|
261
|
-
const
|
|
262
|
-
|
|
297
|
+
const membersByFold = new Map();
|
|
298
|
+
for (const [taskKey, foldKey] of folds) {
|
|
299
|
+
const section = sectionByKey.get(taskKey);
|
|
300
|
+
if (section === undefined) continue;
|
|
301
|
+
if (!membersByFold.has(foldKey)) membersByFold.set(foldKey, []);
|
|
302
|
+
membersByFold.get(foldKey).push(section);
|
|
303
|
+
}
|
|
304
|
+
const foldDetails = (layout.scope?.folds ?? [])
|
|
305
|
+
.map((fold) => renderFoldDetail(fold, membersByFold.get(refKey(fold.ref)) ?? [], lookup))
|
|
306
|
+
.join('');
|
|
307
|
+
const taskIndex = renderTaskIndex(sections, lookup, folds);
|
|
308
|
+
return `<div class="right-toolbar"><button type="button" data-show-overview>概要</button><button type="button" data-show-selected hidden>選択工程へ戻る</button><button type="button" data-show-task-index>元Markdown全文</button></div><div class="right-content">${overview}<div data-right-panel="details" hidden>${details}${foldDetails}</div><section class="task-index" data-right-panel="task-index" hidden><h1>全工程</h1><p>Latticeに登録された全工程を、現在の状態とともに登録順で表示しています。</p>${taskIndex}</section></div>`;
|
|
263
309
|
}
|
|
264
310
|
|
|
265
311
|
function renderDiagramLegend(presentation, layout = null) {
|
|
@@ -327,6 +373,9 @@ body{display:grid;grid-template-rows:minmax(0,1fr);height:100vh;margin:0;backgro
|
|
|
327
373
|
.task-index-list{margin:0;padding:0;list-style:none}.task-index-list li+li{margin-top:8px}.task-index-list button{display:grid;width:100%;grid-template-columns:1.5rem auto minmax(0,1fr);gap:4px 8px;align-items:baseline}
|
|
328
374
|
.task-index-status{grid-row:1 / span 2;color:var(--text-secondary);font-size:13.5px;text-align:center}.task-index-status.status-in-progress{color:var(--accent)}.task-index-status.status-done{color:var(--good)}.task-index-status.status-blocked{color:var(--critical)}
|
|
329
375
|
.task-index-reference{color:var(--text-secondary);font-size:12px;white-space:nowrap}.task-index-list strong{font-size:13.5px;font-weight:600;overflow-wrap:anywhere}.task-index-blocked-reason{grid-column:2 / -1;color:var(--text-secondary);font-size:12px;overflow-wrap:anywhere}
|
|
376
|
+
.fold-detail>section{margin-top:16px}.fold-detail h2{margin:0 0 12px;font-size:16px;font-weight:600}
|
|
377
|
+
.fold-return{margin-left:8px;padding:2px 8px;border:1px solid var(--border);border-radius:4px;background:var(--surface-2);color:var(--text-primary);font:500 12px/1.6 system-ui,-apple-system,"Hiragino Sans","Yu Gothic UI",sans-serif;cursor:pointer}
|
|
378
|
+
.fold-return:focus-visible{outline:2px solid var(--text-primary);outline-offset:2px}
|
|
330
379
|
.todo-gantt text{font-family:system-ui,-apple-system,"Hiragino Sans","Yu Gothic UI",sans-serif;pointer-events:none}
|
|
331
380
|
.todo-node .node-surface{fill:var(--surface-2);stroke:var(--border);stroke-width:1}
|
|
332
381
|
.todo-node .node-meta{fill:var(--text-secondary);font-size:12px;font-weight:500}
|
|
@@ -403,7 +403,7 @@ export function layoutTodoGantt(readModel, chainProjection, options = {}) {
|
|
|
403
403
|
|
|
404
404
|
// Only the geometry stage below sees the narrowed graph.
|
|
405
405
|
const projected = scope === 'all'
|
|
406
|
-
? { nodes: full.nodes, edges: full.edges, foldedByKey: new Map(), folds: [],
|
|
406
|
+
? { nodes: full.nodes, edges: full.edges, foldedByKey: new Map(), folds: [], grouping: null }
|
|
407
407
|
: projectTodoGanttScope({
|
|
408
408
|
nodes: full.nodes, edges: full.edges, wave: fullWaves.wave, longestChainKeys: longestNodeKeys,
|
|
409
409
|
});
|
|
@@ -619,6 +619,16 @@ export function layoutTodoGantt(readModel, chainProjection, options = {}) {
|
|
|
619
619
|
},
|
|
620
620
|
nodes: projectedNodes,
|
|
621
621
|
edges: projectedEdges,
|
|
622
|
+
// Every dependency in the plan, before folding contracted any of them away.
|
|
623
|
+
// The diagram draws `edges`; anything that describes a ToDo in words — the
|
|
624
|
+
// premises and successors in the right pane — reads this instead, so a
|
|
625
|
+
// folded ToDo keeps telling the truth about what it depended on.
|
|
626
|
+
full_edges: full.edges.map((edge) => ({
|
|
627
|
+
from: { ...full.nodesByKey.get(edge.from).ref },
|
|
628
|
+
to: { ...full.nodesByKey.get(edge.to).ref },
|
|
629
|
+
kinds: [...edge.kinds].sort(compareText),
|
|
630
|
+
join_ids: [...edge.joinIdentities.values()].map(({ join_id }) => join_id).sort(compareText),
|
|
631
|
+
})),
|
|
622
632
|
connectors: junctionConnectors,
|
|
623
633
|
groups: {
|
|
624
634
|
plans: [...planMap.entries()].map(([plan_key, task_count]) => ({ plan_key, task_count })),
|
|
@@ -628,7 +638,7 @@ export function layoutTodoGantt(readModel, chainProjection, options = {}) {
|
|
|
628
638
|
requested: scope,
|
|
629
639
|
folded_task_count: projected.foldedByKey.size,
|
|
630
640
|
fold_node_count: projected.folds.length,
|
|
631
|
-
|
|
641
|
+
grouping: projected.grouping,
|
|
632
642
|
folds: projected.folds.map((entry) => ({
|
|
633
643
|
ref: { ...entry.ref },
|
|
634
644
|
task_count: entry.task_count,
|
package/src/todo-gantt-live.mjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
2
|
|
|
3
|
+
import { TODO_DASHBOARD_CODE_VERSION } from './todo-dashboard-registry.mjs';
|
|
4
|
+
|
|
3
5
|
const LOOPBACK = '127.0.0.1';
|
|
4
6
|
const POLL_MS = 500;
|
|
5
7
|
const HTTP_ERROR_SCHEMA = 'lattice.todo_gantt_http_error.v1';
|
|
@@ -136,8 +138,12 @@ export async function startTodoGanttDashboardServer({ registry, port = 0, redire
|
|
|
136
138
|
if (url.pathname === '/__lattice/health') {
|
|
137
139
|
response.writeHead(200, { 'content-type': 'application/json; charset=utf-8',
|
|
138
140
|
'cache-control': 'no-store', 'x-content-type-options': 'nosniff' });
|
|
141
|
+
// `version` is the package this process loaded at startup, not the one
|
|
142
|
+
// installed on disk. That difference is the whole point: it is how a
|
|
143
|
+
// caller learns the daemon is serving code that has been superseded.
|
|
139
144
|
response.end(`${JSON.stringify({ schema: 'lattice.todo_dashboard_health.v1', pid: process.pid,
|
|
140
|
-
port: actualPort, project_ids: registry.list().map(({ projectId }) => projectId)
|
|
145
|
+
port: actualPort, project_ids: registry.list().map(({ projectId }) => projectId),
|
|
146
|
+
version: TODO_DASHBOARD_CODE_VERSION })}\n`);
|
|
141
147
|
return;
|
|
142
148
|
}
|
|
143
149
|
if (url.pathname === '/' || url.pathname === '/projects/') {
|
package/src/todo-gantt-scope.mjs
CHANGED
|
@@ -10,10 +10,16 @@
|
|
|
10
10
|
* where that projection narrows.
|
|
11
11
|
*
|
|
12
12
|
* The rule is "fold the dead branches": a completed ToDo that no longer leads
|
|
13
|
-
* to any live work is history, and history collapses into
|
|
14
|
-
*
|
|
15
|
-
* live work stay visible, because they are
|
|
16
|
-
* right now.
|
|
13
|
+
* to any live work is history, and history collapses into as few labelled nodes
|
|
14
|
+
* as the graph allows — one per plan when nothing forbids it. Completed ToDos
|
|
15
|
+
* that are still the direct premise of live work stay visible, because they are
|
|
16
|
+
* the context for what is dispatchable right now.
|
|
17
|
+
*
|
|
18
|
+
* Grouping by connectivity was tried first and does not compress real plans: a
|
|
19
|
+
* store whose finished ToDos rarely declare dependencies on each other yields
|
|
20
|
+
* one fold unit per ToDo, which draws the same number of boxes as no folding at
|
|
21
|
+
* all while hiding every title. History is grouped by the plan it belongs to,
|
|
22
|
+
* not by whether its members happen to be wired together.
|
|
17
23
|
*
|
|
18
24
|
* This module is pure graph math over the layout's internal node/edge shape.
|
|
19
25
|
* It must run AFTER dependency waves, the longest dependency chain and the
|
|
@@ -89,50 +95,59 @@ function foldableKeys(nodes, distance, foldDistance) {
|
|
|
89
95
|
}
|
|
90
96
|
|
|
91
97
|
/**
|
|
92
|
-
*
|
|
98
|
+
* Grouping strategies, coarsest first. The projection takes the first one whose
|
|
99
|
+
* contraction stays acyclic.
|
|
100
|
+
*
|
|
101
|
+
* - `plan`: one fold unit per plan. All history of a finished plan becomes one
|
|
102
|
+
* node. This is the target shape and it is what a reader wants to see.
|
|
103
|
+
* - `plan_stage`: additionally split per kept-node depth. A cycle can only close
|
|
104
|
+
* through a node kept on screen, and that axis is exactly what `keptDepth`
|
|
105
|
+
* measures, so this is the smallest refinement that removes the usual cause.
|
|
106
|
+
* - `plan_wave`: additionally split per dependency wave. Provably acyclic —
|
|
107
|
+
* every dependency edge strictly increases the wave, so a contracted edge
|
|
108
|
+
* between two units always increases it too and no cycle can exist.
|
|
109
|
+
*/
|
|
110
|
+
const GROUPING_LADDER = Object.freeze(['plan', 'plan_stage', 'plan_wave']);
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Number of kept (non-foldable) nodes lying before each node on the longest
|
|
114
|
+
* path. Crossing a kept node strictly increases it; edges between two foldable
|
|
115
|
+
* nodes leave it unchanged.
|
|
93
116
|
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
* `byComponentAndWave` additionally splits each component per dependency wave.
|
|
97
|
-
* The latter is always acyclic once contracted (every dependency edge strictly
|
|
98
|
-
* increases the wave, so contracted edges do too), which makes it the
|
|
99
|
-
* guaranteed-safe refinement when contraction would otherwise close a cycle.
|
|
117
|
+
* Edges always increase the wave (`assignWaves` is a longest-path layering), so
|
|
118
|
+
* visiting nodes in ascending wave order settles every predecessor first.
|
|
100
119
|
*/
|
|
101
|
-
function
|
|
102
|
-
const
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
120
|
+
function keptDepth(nodes, edges, foldable, wave) {
|
|
121
|
+
const incoming = new Map(nodes.map(({ key }) => [key, []]));
|
|
122
|
+
for (const edge of edges) incoming.get(edge.to).push(edge.from);
|
|
123
|
+
const depth = new Map();
|
|
124
|
+
const ordered = [...nodes].sort((left, right) => wave.get(left.key) - wave.get(right.key)
|
|
125
|
+
|| compareText(left.key, right.key));
|
|
126
|
+
for (const node of ordered) {
|
|
127
|
+
let best = 0;
|
|
128
|
+
for (const predecessor of incoming.get(node.key)) {
|
|
129
|
+
const settled = depth.get(predecessor);
|
|
130
|
+
if (settled === undefined) continue;
|
|
131
|
+
const candidate = settled + (foldable.has(predecessor) ? 0 : 1);
|
|
132
|
+
if (candidate > best) best = candidate;
|
|
111
133
|
}
|
|
112
|
-
|
|
113
|
-
};
|
|
114
|
-
const union = (left, right) => {
|
|
115
|
-
const leftRoot = find(left);
|
|
116
|
-
const rightRoot = find(right);
|
|
117
|
-
if (leftRoot === rightRoot) return;
|
|
118
|
-
if (compareText(leftRoot, rightRoot) <= 0) parent.set(rightRoot, leftRoot);
|
|
119
|
-
else parent.set(leftRoot, rightRoot);
|
|
120
|
-
};
|
|
121
|
-
const nodeByKey = new Map(nodes.map((node) => [node.key, node]));
|
|
122
|
-
for (const edge of edges) {
|
|
123
|
-
if (!foldable.has(edge.from) || !foldable.has(edge.to)) continue;
|
|
124
|
-
// A fold unit never spans plans: the diagram groups by plan and a unit that
|
|
125
|
-
// straddled two plans would have no honest lane to sit in.
|
|
126
|
-
if (nodeByKey.get(edge.from).ref.plan_key !== nodeByKey.get(edge.to).ref.plan_key) continue;
|
|
127
|
-
union(edge.from, edge.to);
|
|
134
|
+
depth.set(node.key, best);
|
|
128
135
|
}
|
|
136
|
+
return depth;
|
|
137
|
+
}
|
|
129
138
|
|
|
139
|
+
/** Group foldable nodes into fold units under one of `GROUPING_LADDER`. */
|
|
140
|
+
function groupFoldable(nodes, foldable, strategy, axes) {
|
|
141
|
+
const nodeByKey = new Map(nodes.map((node) => [node.key, node]));
|
|
142
|
+
// A fold unit never spans plans: the diagram groups by plan and a unit that
|
|
143
|
+
// straddled two plans would have no honest lane to sit in.
|
|
144
|
+
const axisOf = (key) => (strategy === 'plan' ? null
|
|
145
|
+
: strategy === 'plan_stage' ? axes.stage.get(key)
|
|
146
|
+
: axes.wave.get(key));
|
|
130
147
|
const unitByNode = new Map();
|
|
131
148
|
const members = new Map();
|
|
132
149
|
for (const key of [...foldable].sort(compareText)) {
|
|
133
|
-
const unitKey =
|
|
134
|
-
? JSON.stringify([find(key), wave.get(key)])
|
|
135
|
-
: JSON.stringify([find(key), null]);
|
|
150
|
+
const unitKey = JSON.stringify([nodeByKey.get(key).ref.plan_key, axisOf(key)]);
|
|
136
151
|
unitByNode.set(key, unitKey);
|
|
137
152
|
if (!members.has(unitKey)) members.set(unitKey, []);
|
|
138
153
|
members.get(unitKey).push(key);
|
|
@@ -249,15 +264,14 @@ function isAcyclic(nodes, edges) {
|
|
|
249
264
|
/**
|
|
250
265
|
* Project the full dependency graph onto the `live` scope.
|
|
251
266
|
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
* dependency edge strictly increases the wave.
|
|
267
|
+
* Contraction can close a cycle: with a fold distance of 1 the shape
|
|
268
|
+
* `f1 -> s -> f2` is reachable, where `s` is a completed ToDo kept as the direct
|
|
269
|
+
* premise of live work and `f1`/`f2` land in the same fold unit. Contracting
|
|
270
|
+
* that unit would produce `summary -> s -> summary`. So each candidate grouping
|
|
271
|
+
* is verified and the coarsest acyclic one wins. The last rung is provably
|
|
272
|
+
* acyclic, so the throw below is a backstop, never a routine outcome.
|
|
259
273
|
*
|
|
260
|
-
* @returns {{nodes: Array, edges: Array, foldedByKey: Map, folds: Array,
|
|
274
|
+
* @returns {{nodes: Array, edges: Array, foldedByKey: Map, folds: Array, grouping: string}}
|
|
261
275
|
*/
|
|
262
276
|
export function projectTodoGanttScope({
|
|
263
277
|
nodes, edges, wave, longestChainKeys = new Set(), foldDistance = DEFAULT_FOLD_DISTANCE,
|
|
@@ -265,24 +279,25 @@ export function projectTodoGanttScope({
|
|
|
265
279
|
const distance = distanceToLive(nodes, edges, wave);
|
|
266
280
|
const foldable = foldableKeys(nodes, distance, foldDistance);
|
|
267
281
|
if (foldable.size === 0) {
|
|
268
|
-
return { nodes, edges, foldedByKey: new Map(), folds: [],
|
|
282
|
+
return { nodes, edges, foldedByKey: new Map(), folds: [], grouping: null };
|
|
269
283
|
}
|
|
270
284
|
|
|
271
|
-
|
|
272
|
-
let
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
if (!isAcyclic(contracted.nodes, contracted.edges)) {
|
|
281
|
-
throw new TodoGanttScopeError('TODO_SCOPE_CONTRACTION_CYCLIC',
|
|
282
|
-
'todo gantt scope contraction produced a cycle after per-wave refinement',
|
|
283
|
-
{ fold_unit_count: grouping.members.size });
|
|
284
|
-
}
|
|
285
|
+
const axes = { wave, stage: keptDepth(nodes, edges, foldable, wave) };
|
|
286
|
+
let selected = null;
|
|
287
|
+
for (const strategy of GROUPING_LADDER) {
|
|
288
|
+
const grouping = groupFoldable(nodes, foldable, strategy, axes);
|
|
289
|
+
const summaryByUnit = buildSummaryNodes(nodes, grouping.members, longestChainKeys);
|
|
290
|
+
const contracted = contract(nodes, edges, grouping.unitByNode, summaryByUnit);
|
|
291
|
+
if (!isAcyclic(contracted.nodes, contracted.edges)) continue;
|
|
292
|
+
selected = { strategy, grouping, summaryByUnit, contracted };
|
|
293
|
+
break;
|
|
285
294
|
}
|
|
295
|
+
if (selected === null) {
|
|
296
|
+
throw new TodoGanttScopeError('TODO_SCOPE_CONTRACTION_CYCLIC',
|
|
297
|
+
'todo gantt scope contraction produced a cycle under every grouping',
|
|
298
|
+
{ attempted_groupings: [...GROUPING_LADDER] });
|
|
299
|
+
}
|
|
300
|
+
const { grouping, summaryByUnit, contracted } = selected;
|
|
286
301
|
|
|
287
302
|
const foldedByKey = new Map();
|
|
288
303
|
for (const [nodeKey, unitKey] of grouping.unitByNode) {
|
|
@@ -292,5 +307,7 @@ export function projectTodoGanttScope({
|
|
|
292
307
|
.sort((left, right) => compareText(left.key, right.key))
|
|
293
308
|
.map((summary) => ({ ref: { ...summary.ref }, ...summary.fold }));
|
|
294
309
|
|
|
295
|
-
return {
|
|
310
|
+
return {
|
|
311
|
+
nodes: contracted.nodes, edges: contracted.edges, foldedByKey, folds, grouping: selected.strategy,
|
|
312
|
+
};
|
|
296
313
|
}
|