@quolu/lattice 0.12.13 → 0.12.14
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-gantt-html.mjs +15 -55
- package/src/todo-gantt-layout.mjs +10 -33
- package/src/todo-gantt-scope.mjs +24 -243
- package/src/todo-gantt-svg.mjs +16 -17
package/package.json
CHANGED
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.v11';
|
|
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
|
|
|
@@ -126,9 +126,9 @@ function normalizeSections(readModel, narratives, anchorOutcomes) {
|
|
|
126
126
|
return { sections: result, proseBytes };
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
/**
|
|
129
|
+
/** Keys of the ToDos the diagram does not draw, empty when nothing was folded. */
|
|
130
130
|
function foldIndex(layout) {
|
|
131
|
-
return new
|
|
131
|
+
return new Set((layout?.folded ?? []).map((ref) => refKey(ref)));
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
function renderTaskIndexEntry(section, lookup) {
|
|
@@ -136,15 +136,13 @@ function renderTaskIndexEntry(section, lookup) {
|
|
|
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
|
|
140
|
-
//
|
|
141
|
-
// on the diagram would open nothing: a fold node is not a ToDo, so no detail
|
|
142
|
-
// panel carries its key.
|
|
139
|
+
// A ToDo the diagram does not draw keeps its row here — the index is the
|
|
140
|
+
// complete list — and it selects its own detail, which exists either way.
|
|
143
141
|
const selectKey = key;
|
|
144
142
|
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>`;
|
|
145
143
|
}
|
|
146
144
|
|
|
147
|
-
function renderTaskIndex(sections, lookup, folds = new
|
|
145
|
+
function renderTaskIndex(sections, lookup, folds = new Set()) {
|
|
148
146
|
const plans = [];
|
|
149
147
|
for (const section of sections) {
|
|
150
148
|
let plan = plans.at(-1);
|
|
@@ -165,26 +163,6 @@ function renderTaskIndex(sections, lookup, folds = new Map()) {
|
|
|
165
163
|
}).join('');
|
|
166
164
|
}
|
|
167
165
|
|
|
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
|
-
|
|
188
166
|
function presentationLookup(presentation) {
|
|
189
167
|
return {
|
|
190
168
|
lanes: new Map((presentation?.lanes ?? []).map((lane) => [JSON.stringify([lane.plan_key, lane.lane]), lane])),
|
|
@@ -197,7 +175,7 @@ function taskReference(section, lookup) {
|
|
|
197
175
|
return number === undefined ? `ID ${section.task.task_id}` : `工程 ${number.display_number}`;
|
|
198
176
|
}
|
|
199
177
|
|
|
200
|
-
function renderRelationList(relations, sectionByKey, lookup, emptyText, folds = new
|
|
178
|
+
function renderRelationList(relations, sectionByKey, lookup, emptyText, folds = new Set()) {
|
|
201
179
|
if (relations.length === 0) return `<p class="relation-empty">${escapeHtmlText(emptyText)}</p>`;
|
|
202
180
|
return `<ul class="relation-list">${relations.map((relation) => {
|
|
203
181
|
const targetKey = refKey(relation.ref);
|
|
@@ -205,10 +183,9 @@ function renderRelationList(relations, sectionByKey, lookup, emptyText, folds =
|
|
|
205
183
|
if (target === undefined) return '';
|
|
206
184
|
const join = relation.joinIds.length === 0 ? ''
|
|
207
185
|
: `<span class="relation-kind">合流条件: ${escapeHtmlText(relation.joinIds.join(', '))}</span>`;
|
|
208
|
-
//
|
|
209
|
-
// why they cannot find this box on screen.
|
|
186
|
+
// Say which ones the diagram does not draw, so the reader stops looking.
|
|
210
187
|
const reference = folds.has(targetKey)
|
|
211
|
-
?
|
|
188
|
+
? `${taskReference(target, lookup)}(図では非表示)` : taskReference(target, lookup);
|
|
212
189
|
return `<li><button type="button" data-select-node-key="${escapeHtmlAttribute(targetKey)}"><strong>${escapeHtmlText(reference)}</strong><span>${escapeHtmlText(target.task.title)}</span></button>${join}</li>`;
|
|
213
190
|
}).join('')}</ul>`;
|
|
214
191
|
}
|
|
@@ -287,25 +264,13 @@ function renderRightPane(sections, layout, presentation, readModel) {
|
|
|
287
264
|
const readiness = node?.visibility.next_ready
|
|
288
265
|
? `<p class="readiness-note">ready frontierの一員です。${ready.length > 1 ? '他のready工程と同時dispatchするのが既定です。subsetだけを選ぶ場合は理由を記録してください。' : '現在の唯一の着手候補です。'}</p>`
|
|
289
266
|
: incoming.get(key).length === 0 ? '<p class="readiness-note">登録済みの前提工程はありません。図だけではdispatch可否を判定しません。</p>' : '';
|
|
290
|
-
//
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
const foldedNote = foldKey === undefined ? ''
|
|
294
|
-
: `<p class="fold-note">この工程は図の上では ▣ 畳み込みノードにまとめられています。<button type="button" class="fold-return" data-select-node-key="${escapeHtmlAttribute(foldKey)}">畳み込みノードを開く</button></p>`;
|
|
267
|
+
// Say it plainly when the reader will not find this ToDo on the diagram.
|
|
268
|
+
const foldedNote = !folds.has(key) ? ''
|
|
269
|
+
: '<p class="fold-note">完走済みのため図には描いていません。図に出すには <code>lattice todo gantt --scope all</code> を実行してください。</p>';
|
|
295
270
|
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>`;
|
|
296
271
|
}).join('');
|
|
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
272
|
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}
|
|
273
|
+
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}</div><section class="task-index" data-right-panel="task-index" hidden><h1>全工程</h1><p>Latticeに登録された全工程を、現在の状態とともに登録順で表示しています。</p>${taskIndex}</section></div>`;
|
|
309
274
|
}
|
|
310
275
|
|
|
311
276
|
function renderDiagramLegend(presentation, layout = null) {
|
|
@@ -313,9 +278,9 @@ function renderDiagramLegend(presentation, layout = null) {
|
|
|
313
278
|
const categoryDetails = categories === '' ? '' : `<details class="category-legend"><summary>カテゴリ説明</summary><dl>${categories}</dl></details>`;
|
|
314
279
|
const foldedCount = layout?.scope?.folded_task_count ?? 0;
|
|
315
280
|
const foldChip = foldedCount === 0 ? ''
|
|
316
|
-
: `<span class="fold-chip"
|
|
281
|
+
: `<span class="fold-chip">完走済み ${foldedCount}件を非表示</span>`;
|
|
317
282
|
const foldNote = foldedCount === 0 ? ''
|
|
318
|
-
:
|
|
283
|
+
: '<p class="fold-note">後続に作業中・未着手が残っていない完了工程は図から外しています。生きた工程とその直接の前提工程は必ず描きます。外した工程は右の「全工程」から辿れ、図に出すには <code>lattice todo gantt --scope all</code> を実行してください。総数・進捗・最長依存鎖は外す前の全工程で数えています。</p>';
|
|
319
284
|
return `<div class="diagram-legend" aria-label="工程図の凡例"><span>${statusMarkup('pending', ' 未着手')}</span><span>${statusMarkup('in-progress', ' 作業中')}</span><span>${statusMarkup('done', ' 完了')}</span><span>${statusMarkup('blocked', ' ブロック中')}</span><span>破線枠: ready frontier(同時dispatch推奨)</span><span>太線: 構造上の最長依存鎖</span><span>半円: 非接触の線交差</span><span>黒丸: 論理上の合流</span>${foldChip}${categoryDetails}${foldNote}<p>縦方向は時間ではなく、登録済み依存関係による工程段階です。ready frontierは全件同時dispatchが既定です。未登録の資源・host制約によりsubsetだけを選ぶ場合は理由を記録します。構造上の最長依存鎖は各工程を同じ重みとして数え、実時間・工数・納期を表しません。</p></div>`;
|
|
320
285
|
}
|
|
321
286
|
|
|
@@ -373,17 +338,12 @@ body{display:grid;grid-template-rows:minmax(0,1fr);height:100vh;margin:0;backgro
|
|
|
373
338
|
.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}
|
|
374
339
|
.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)}
|
|
375
340
|
.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}
|
|
379
341
|
.todo-gantt text{font-family:system-ui,-apple-system,"Hiragino Sans","Yu Gothic UI",sans-serif;pointer-events:none}
|
|
380
342
|
.todo-node .node-surface{fill:var(--surface-2);stroke:var(--border);stroke-width:1}
|
|
381
343
|
.todo-node .node-meta{fill:var(--text-secondary);font-size:12px;font-weight:500}
|
|
382
344
|
.todo-node .node-title{fill:var(--text-primary);font-size:13.5px;font-weight:400}
|
|
383
345
|
.todo-node .node-title-line{font-size:13.5px;font-weight:400}
|
|
384
346
|
.todo-node .status-mark{fill:var(--text-secondary);font-size:13.5px;font-weight:400}
|
|
385
|
-
.folded-node .node-surface{fill:var(--surface-2);stroke:var(--border);stroke-width:2;stroke-dasharray:none}
|
|
386
|
-
.folded-node .node-title,.folded-node .node-meta{fill:var(--text-secondary)}
|
|
387
347
|
.fold-chip{padding:2px 8px;border:1px solid var(--border);border-radius:9999px;background:var(--surface-2);color:var(--text-primary);font-weight:650}
|
|
388
348
|
.fold-note{flex:1 0 100%;margin:4px 0 0;color:var(--text-secondary);font-weight:400}
|
|
389
349
|
.task-index-folded{margin-top:8px}
|
|
@@ -403,10 +403,8 @@ 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,
|
|
407
|
-
: projectTodoGanttScope({
|
|
408
|
-
nodes: full.nodes, edges: full.edges, wave: fullWaves.wave, longestChainKeys: longestNodeKeys,
|
|
409
|
-
});
|
|
406
|
+
? { nodes: full.nodes, edges: full.edges, foldedKeys: new Set() }
|
|
407
|
+
: projectTodoGanttScope({ nodes: full.nodes, edges: full.edges, wave: fullWaves.wave });
|
|
410
408
|
const nodes = projected.nodes;
|
|
411
409
|
const edges = projected.edges;
|
|
412
410
|
const nodesByKey = new Map(nodes.map((node) => [node.key, node]));
|
|
@@ -477,19 +475,10 @@ export function layoutTodoGantt(readModel, chainProjection, options = {}) {
|
|
|
477
475
|
ref: { ...node.ref }, title: node.title, lane: node.lane, status: node.status,
|
|
478
476
|
wave: wave.get(node.key), row: transversePosition.get(node.key), visible,
|
|
479
477
|
visibility: {
|
|
480
|
-
|
|
481
|
-
// marking of the ToDos it replaced rather than carrying one of its own.
|
|
482
|
-
longest_dependency_chain: node.fold === undefined
|
|
483
|
-
? longestNodeKeys.has(node.key) : node.fold.longest_chain_task_count > 0,
|
|
478
|
+
longest_dependency_chain: longestNodeKeys.has(node.key),
|
|
484
479
|
active: node.status === 'in-progress', next_ready: readyKeys.has(node.key),
|
|
485
480
|
selected: false,
|
|
486
481
|
},
|
|
487
|
-
fold: node.fold === undefined ? null : {
|
|
488
|
-
task_count: node.fold.task_count,
|
|
489
|
-
lanes: [...node.fold.lanes],
|
|
490
|
-
longest_chain_task_count: node.fold.longest_chain_task_count,
|
|
491
|
-
task_refs: node.fold.task_refs.map((entry) => ({ ...entry })),
|
|
492
|
-
},
|
|
493
482
|
geometry,
|
|
494
483
|
};
|
|
495
484
|
});
|
|
@@ -636,26 +625,14 @@ export function layoutTodoGantt(readModel, chainProjection, options = {}) {
|
|
|
636
625
|
},
|
|
637
626
|
scope: {
|
|
638
627
|
requested: scope,
|
|
639
|
-
folded_task_count: projected.
|
|
640
|
-
fold_node_count: projected.folds.length,
|
|
641
|
-
grouping: projected.grouping,
|
|
642
|
-
folds: projected.folds.map((entry) => ({
|
|
643
|
-
ref: { ...entry.ref },
|
|
644
|
-
task_count: entry.task_count,
|
|
645
|
-
lanes: [...entry.lanes],
|
|
646
|
-
longest_chain_task_count: entry.longest_chain_task_count,
|
|
647
|
-
})),
|
|
628
|
+
folded_task_count: projected.foldedKeys.size,
|
|
648
629
|
},
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
)
|
|
655
|
-
.map(({ task, fold }) => ({
|
|
656
|
-
task: { project_id: task[0], plan_key: task[1], task_id: task[2] },
|
|
657
|
-
fold: { project_id: fold[0], plan_key: fold[1], task_id: fold[2] },
|
|
658
|
-
})),
|
|
630
|
+
// The ToDos the diagram no longer draws. They stay in the task index and in
|
|
631
|
+
// every count, so the reader can still reach them by name.
|
|
632
|
+
folded: [...projected.foldedKeys]
|
|
633
|
+
.map((taskKey) => JSON.parse(taskKey))
|
|
634
|
+
.map(([project_id, plan_key, task_id]) => ({ project_id, plan_key, task_id }))
|
|
635
|
+
.sort(compareRefs),
|
|
659
636
|
metrics: {
|
|
660
637
|
crossing_count: crossingCount(edges, wave, transversePosition),
|
|
661
638
|
visible_node_count: visibleKeys.size,
|
package/src/todo-gantt-scope.mjs
CHANGED
|
@@ -9,55 +9,35 @@
|
|
|
9
9
|
* removed from the store; the diagram is a projection, and this module is
|
|
10
10
|
* where that projection narrows.
|
|
11
11
|
*
|
|
12
|
-
* The rule is "
|
|
13
|
-
* to any live work is history, and history
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* the context for what is dispatchable right now.
|
|
12
|
+
* The rule is "drop the dead branches": a completed ToDo that no longer leads
|
|
13
|
+
* to any live work is history, and history leaves the diagram entirely.
|
|
14
|
+
* Completed ToDos that are still the direct premise of live work stay visible,
|
|
15
|
+
* because they are the context for what is dispatchable right now.
|
|
17
16
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
17
|
+
* Summarising history into placeholder nodes was tried and is worse than
|
|
18
|
+
* drawing nothing: a summary box still occupies a column, so the diagram stays
|
|
19
|
+
* as wide as the finished plans ever made it while saying nothing a reader
|
|
20
|
+
* acts on. The count belongs in the legend and the ToDos themselves belong in
|
|
21
|
+
* the task index; neither needs floor space in the graph.
|
|
23
22
|
*
|
|
24
23
|
* This module is pure graph math over the layout's internal node/edge shape.
|
|
25
24
|
* It must run AFTER dependency waves, the longest dependency chain and the
|
|
26
25
|
* ready frontier have been computed on the FULL graph — those numbers describe
|
|
27
|
-
* the real plan, and measuring them on a
|
|
26
|
+
* the real plan, and measuring them on a narrowed graph would make them lie.
|
|
28
27
|
*/
|
|
29
28
|
|
|
30
29
|
export const TODO_GANTT_SCOPES = Object.freeze(['live', 'all']);
|
|
31
30
|
|
|
32
31
|
/**
|
|
33
32
|
* Hops of completed predecessors kept in front of live work. 1 = keep the
|
|
34
|
-
* direct premises of live ToDos,
|
|
33
|
+
* direct premises of live ToDos, drop everything upstream of them.
|
|
35
34
|
*/
|
|
36
35
|
export const DEFAULT_FOLD_DISTANCE = 1;
|
|
37
36
|
|
|
38
|
-
/**
|
|
39
|
-
* `task_id` values in the store match /^[0-9A-Za-z][0-9A-Za-z._-]{0,127}$/, so
|
|
40
|
-
* a leading '~' cannot collide with a real ToDo.
|
|
41
|
-
*/
|
|
42
|
-
const FOLD_TASK_ID_PREFIX = '~folded';
|
|
43
|
-
|
|
44
|
-
export class TodoGanttScopeError extends Error {
|
|
45
|
-
constructor(code, message, detail = null) {
|
|
46
|
-
super(message);
|
|
47
|
-
this.name = 'TodoGanttScopeError';
|
|
48
|
-
this.code = code;
|
|
49
|
-
this.detail = detail;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
37
|
function compareText(left, right) {
|
|
54
38
|
return left < right ? -1 : left > right ? 1 : 0;
|
|
55
39
|
}
|
|
56
40
|
|
|
57
|
-
export function isFoldNodeRef(ref) {
|
|
58
|
-
return typeof ref?.task_id === 'string' && ref.task_id.startsWith(`${FOLD_TASK_ID_PREFIX}:`);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
41
|
/**
|
|
62
42
|
* Forward distance from each node to the nearest live (non-done) node, over the
|
|
63
43
|
* dependency DAG. A live node is at distance 0; a node with no live descendant
|
|
@@ -88,226 +68,27 @@ function distanceToLive(nodes, edges, wave) {
|
|
|
88
68
|
return distance;
|
|
89
69
|
}
|
|
90
70
|
|
|
91
|
-
function foldableKeys(nodes, distance, foldDistance) {
|
|
92
|
-
return new Set(nodes
|
|
93
|
-
.filter((node) => node.status === 'done' && distance.get(node.key) > foldDistance)
|
|
94
|
-
.map(({ key }) => key));
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/**
|
|
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.
|
|
116
|
-
*
|
|
117
|
-
* Edges always increase the wave (`assignWaves` is a longest-path layering), so
|
|
118
|
-
* visiting nodes in ascending wave order settles every predecessor first.
|
|
119
|
-
*/
|
|
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;
|
|
133
|
-
}
|
|
134
|
-
depth.set(node.key, best);
|
|
135
|
-
}
|
|
136
|
-
return depth;
|
|
137
|
-
}
|
|
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));
|
|
147
|
-
const unitByNode = new Map();
|
|
148
|
-
const members = new Map();
|
|
149
|
-
for (const key of [...foldable].sort(compareText)) {
|
|
150
|
-
const unitKey = JSON.stringify([nodeByKey.get(key).ref.plan_key, axisOf(key)]);
|
|
151
|
-
unitByNode.set(key, unitKey);
|
|
152
|
-
if (!members.has(unitKey)) members.set(unitKey, []);
|
|
153
|
-
members.get(unitKey).push(key);
|
|
154
|
-
}
|
|
155
|
-
return { unitByNode, members };
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
function dominantLane(memberNodes) {
|
|
159
|
-
const counts = new Map();
|
|
160
|
-
for (const node of memberNodes) counts.set(node.lane, (counts.get(node.lane) ?? 0) + 1);
|
|
161
|
-
return [...counts.entries()]
|
|
162
|
-
.sort(([leftLane, leftCount], [rightLane, rightCount]) => rightCount - leftCount
|
|
163
|
-
|| compareText(leftLane, rightLane))[0][0];
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
function buildSummaryNodes(nodes, members, longestChainKeys) {
|
|
167
|
-
const nodeByKey = new Map(nodes.map((node) => [node.key, node]));
|
|
168
|
-
// Order fold units by their first member so synthetic ids are stable across runs.
|
|
169
|
-
const ordered = [...members.entries()]
|
|
170
|
-
.map(([unitKey, memberKeys]) => ({ unitKey, memberKeys: [...memberKeys].sort(compareText) }))
|
|
171
|
-
.sort((left, right) => compareText(left.memberKeys[0], right.memberKeys[0]));
|
|
172
|
-
|
|
173
|
-
const summaryByUnit = new Map();
|
|
174
|
-
ordered.forEach((unit, index) => {
|
|
175
|
-
const memberNodes = unit.memberKeys.map((key) => nodeByKey.get(key));
|
|
176
|
-
const { plan_key: planKey, project_id: projectId } = memberNodes[0].ref;
|
|
177
|
-
const ref = { project_id: projectId, plan_key: planKey, task_id: `${FOLD_TASK_ID_PREFIX}:${index}` };
|
|
178
|
-
const key = JSON.stringify([ref.project_id, ref.plan_key, ref.task_id]);
|
|
179
|
-
const lanes = [...new Set(memberNodes.map((node) => node.lane))].sort(compareText);
|
|
180
|
-
summaryByUnit.set(unit.unitKey, {
|
|
181
|
-
key,
|
|
182
|
-
ref,
|
|
183
|
-
title: `完了済み ${memberNodes.length}件`,
|
|
184
|
-
lane: dominantLane(memberNodes),
|
|
185
|
-
status: 'done',
|
|
186
|
-
plan_schema: memberNodes[0].plan_schema,
|
|
187
|
-
phase_id: null,
|
|
188
|
-
phase_status: null,
|
|
189
|
-
phase_ready: false,
|
|
190
|
-
fold: {
|
|
191
|
-
task_count: memberNodes.length,
|
|
192
|
-
lanes,
|
|
193
|
-
longest_chain_task_count: memberNodes
|
|
194
|
-
.filter((node) => longestChainKeys.has(node.key)).length,
|
|
195
|
-
task_refs: memberNodes.map((node) => ({ ...node.ref })),
|
|
196
|
-
},
|
|
197
|
-
});
|
|
198
|
-
});
|
|
199
|
-
return summaryByUnit;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
function contract(nodes, edges, unitByNode, summaryByUnit) {
|
|
203
|
-
const mapKey = (key) => {
|
|
204
|
-
const unitKey = unitByNode.get(key);
|
|
205
|
-
return unitKey === undefined ? key : summaryByUnit.get(unitKey).key;
|
|
206
|
-
};
|
|
207
|
-
const keptNodes = nodes.filter((node) => !unitByNode.has(node.key));
|
|
208
|
-
const summaries = [...summaryByUnit.values()];
|
|
209
|
-
const resultNodes = [...keptNodes, ...summaries]
|
|
210
|
-
.sort((left, right) => compareText(left.key, right.key));
|
|
211
|
-
|
|
212
|
-
const contracted = new Map();
|
|
213
|
-
for (const edge of edges) {
|
|
214
|
-
const from = mapKey(edge.from);
|
|
215
|
-
const to = mapKey(edge.to);
|
|
216
|
-
if (from === to) continue; // interior of a fold unit
|
|
217
|
-
const key = JSON.stringify([from, to]);
|
|
218
|
-
let merged = contracted.get(key);
|
|
219
|
-
if (merged === undefined) {
|
|
220
|
-
// A rewired edge is an aggregate. Join identity describes how several
|
|
221
|
-
// premises meet at one ToDo; carrying it onto an aggregate edge would
|
|
222
|
-
// draw a junction marker for a join whose members are no longer on
|
|
223
|
-
// screen, so aggregates keep only the dependency kinds.
|
|
224
|
-
merged = { key, from, to, kinds: new Set(), joinIdentities: new Map(), aggregated: false };
|
|
225
|
-
contracted.set(key, merged);
|
|
226
|
-
}
|
|
227
|
-
for (const kind of edge.kinds) merged.kinds.add(kind);
|
|
228
|
-
if (from === edge.from && to === edge.to) {
|
|
229
|
-
for (const [identityKey, identity] of edge.joinIdentities) {
|
|
230
|
-
merged.joinIdentities.set(identityKey, identity);
|
|
231
|
-
}
|
|
232
|
-
} else {
|
|
233
|
-
merged.aggregated = true;
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
for (const edge of contracted.values()) {
|
|
237
|
-
if (edge.aggregated) edge.joinIdentities = new Map();
|
|
238
|
-
}
|
|
239
|
-
const resultEdges = [...contracted.values()].sort((left, right) => compareText(left.key, right.key));
|
|
240
|
-
return { nodes: resultNodes, edges: resultEdges };
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/** Kahn peel; returns true when the graph is acyclic. */
|
|
244
|
-
function isAcyclic(nodes, edges) {
|
|
245
|
-
const indegree = new Map(nodes.map(({ key }) => [key, 0]));
|
|
246
|
-
const outgoing = new Map(nodes.map(({ key }) => [key, []]));
|
|
247
|
-
for (const edge of edges) {
|
|
248
|
-
outgoing.get(edge.from).push(edge.to);
|
|
249
|
-
indegree.set(edge.to, indegree.get(edge.to) + 1);
|
|
250
|
-
}
|
|
251
|
-
const ready = nodes.filter(({ key }) => indegree.get(key) === 0).map(({ key }) => key);
|
|
252
|
-
let visited = 0;
|
|
253
|
-
while (ready.length > 0) {
|
|
254
|
-
const current = ready.pop();
|
|
255
|
-
visited += 1;
|
|
256
|
-
for (const next of outgoing.get(current)) {
|
|
257
|
-
indegree.set(next, indegree.get(next) - 1);
|
|
258
|
-
if (indegree.get(next) === 0) ready.push(next);
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
return visited === nodes.length;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
71
|
/**
|
|
265
72
|
* Project the full dependency graph onto the `live` scope.
|
|
266
73
|
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
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.
|
|
74
|
+
* Removing nodes from a DAG cannot create a cycle, so the narrowed graph needs
|
|
75
|
+
* no verification: what is left is a subgraph of what was already acyclic.
|
|
273
76
|
*
|
|
274
|
-
* @returns {{nodes: Array, edges: Array,
|
|
77
|
+
* @returns {{nodes: Array, edges: Array, foldedKeys: Set<string>}}
|
|
275
78
|
*/
|
|
276
79
|
export function projectTodoGanttScope({
|
|
277
|
-
nodes, edges, wave,
|
|
80
|
+
nodes, edges, wave, foldDistance = DEFAULT_FOLD_DISTANCE,
|
|
278
81
|
}) {
|
|
279
82
|
const distance = distanceToLive(nodes, edges, wave);
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
}
|
|
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;
|
|
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;
|
|
301
|
-
|
|
302
|
-
const foldedByKey = new Map();
|
|
303
|
-
for (const [nodeKey, unitKey] of grouping.unitByNode) {
|
|
304
|
-
foldedByKey.set(nodeKey, summaryByUnit.get(unitKey).key);
|
|
305
|
-
}
|
|
306
|
-
const folds = [...summaryByUnit.values()]
|
|
307
|
-
.sort((left, right) => compareText(left.key, right.key))
|
|
308
|
-
.map((summary) => ({ ref: { ...summary.ref }, ...summary.fold }));
|
|
309
|
-
|
|
83
|
+
const foldedKeys = new Set(nodes
|
|
84
|
+
.filter((node) => node.status === 'done' && distance.get(node.key) > foldDistance)
|
|
85
|
+
.map(({ key }) => key));
|
|
86
|
+
if (foldedKeys.size === 0) return { nodes, edges, foldedKeys };
|
|
310
87
|
return {
|
|
311
|
-
nodes:
|
|
88
|
+
nodes: nodes.filter((node) => !foldedKeys.has(node.key)),
|
|
89
|
+
// An edge with a dropped endpoint goes with it. The kept node keeps its
|
|
90
|
+
// real premises in the right pane, which reads the full graph.
|
|
91
|
+
edges: edges.filter((edge) => !foldedKeys.has(edge.from) && !foldedKeys.has(edge.to)),
|
|
92
|
+
foldedKeys,
|
|
312
93
|
};
|
|
313
94
|
}
|
package/src/todo-gantt-svg.mjs
CHANGED
|
@@ -140,31 +140,23 @@ function presentationMaps(presentation) {
|
|
|
140
140
|
function renderNode(node, maps) {
|
|
141
141
|
if (!node.visible || node.geometry === null) return '';
|
|
142
142
|
const { x, y, width, height } = node.geometry;
|
|
143
|
-
const fold = node.fold ?? null;
|
|
144
143
|
const classes = ['todo-node', STATUS_CLASSES[node.status] ?? 'status-unknown'];
|
|
145
|
-
if (fold !== null) classes.push('folded-node');
|
|
146
144
|
if (node.visibility.longest_dependency_chain) classes.push('longest-chain-node');
|
|
147
145
|
if (node.visibility.active) classes.push('active-node');
|
|
148
146
|
if (node.visibility.next_ready) classes.push('next-ready-node');
|
|
149
147
|
if (node.visibility.selected) classes.push('selected-node');
|
|
150
148
|
const key = nodeKey(node.ref);
|
|
151
149
|
const nodeLaneKey = laneKey(node.ref.plan_key, node.lane);
|
|
152
|
-
const status =
|
|
153
|
-
? TODO_GANTT_STATUS_PRESENTATION[node.status] ?? { mark: '?', label: '状態不明' }
|
|
154
|
-
: { mark: '▣', label: '完走済み(畳み込み)' };
|
|
150
|
+
const status = TODO_GANTT_STATUS_PRESENTATION[node.status] ?? { mark: '?', label: '状態不明' };
|
|
155
151
|
const lane = maps.lanes.get(nodeLaneKey);
|
|
156
|
-
const laneLabel =
|
|
157
|
-
? (lane === undefined ? node.lane : `${node.lane}、${lane.name}`)
|
|
158
|
-
: fold.lanes.join('、');
|
|
152
|
+
const laneLabel = lane === undefined ? node.lane : `${node.lane}、${lane.name}`;
|
|
159
153
|
const taskNumber = maps.taskNumbers.get(key);
|
|
160
|
-
const visibleReference =
|
|
161
|
-
|
|
162
|
-
const spokenReference =
|
|
163
|
-
|
|
154
|
+
const visibleReference = taskNumber === undefined
|
|
155
|
+
? `ID ${node.ref.task_id}` : `工程 ${taskNumber.display_number}`;
|
|
156
|
+
const spokenReference = taskNumber === undefined
|
|
157
|
+
? `ID ${node.ref.task_id}` : `工程${taskNumber.display_number}`;
|
|
164
158
|
const readyLabel = node.visibility.next_ready ? '。ready frontierの同時dispatch候補' : '';
|
|
165
|
-
const identity =
|
|
166
|
-
? `正規ID ${node.ref.plan_key}/${node.ref.task_id}`
|
|
167
|
-
: `${node.ref.plan_key}の完走済み${fold.task_count}工程をまとめたノード。全件を描くには --scope all`;
|
|
159
|
+
const identity = `正規ID ${node.ref.plan_key}/${node.ref.task_id}`;
|
|
168
160
|
const ariaLabel = `${spokenReference}。${status.label}。${laneLabel}。${node.title}。${identity}${readyLabel}`;
|
|
169
161
|
const statusBar = node.status === 'in-progress'
|
|
170
162
|
? `<line class="status-bar" x1="${x + 5}" y1="${y + 6}" x2="${x + 5}" y2="${y + height - 6}"></line>` : '';
|
|
@@ -186,8 +178,15 @@ function summaryChipWidth(label, minimum = 116) {
|
|
|
186
178
|
function renderTodoSummary(layout, maps) {
|
|
187
179
|
const markup = [];
|
|
188
180
|
let groupX = 16;
|
|
189
|
-
for
|
|
190
|
-
|
|
181
|
+
// The band is a header for the diagram, so it covers what the diagram draws.
|
|
182
|
+
// Keeping a chip for every lane of every finished plan stretched the canvas to
|
|
183
|
+
// several times the width of the graph itself, all of it empty columns.
|
|
184
|
+
// The chips still count every ToDo in the lane, folded ones included.
|
|
185
|
+
const drawnPlans = new Set(layout.nodes.map((node) => node.ref.plan_key));
|
|
186
|
+
const drawnLanes = new Set(layout.nodes.map((node) => laneKey(node.ref.plan_key, node.lane)));
|
|
187
|
+
for (const plan of layout.groups.plans.filter(({ plan_key }) => drawnPlans.has(plan_key))) {
|
|
188
|
+
const lanes = layout.groups.lanes.filter((lane) => lane.plan_key === plan.plan_key
|
|
189
|
+
&& drawnLanes.has(laneKey(lane.plan_key, lane.lane)));
|
|
191
190
|
const laneChips = lanes.map((lane) => {
|
|
192
191
|
const metadata = maps.lanes.get(laneKey(lane.plan_key, lane.lane));
|
|
193
192
|
const fullLabel = metadata === undefined
|