@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quolu/lattice",
3
- "version": "0.12.13",
3
+ "version": "0.12.14",
4
4
  "description": "Lattice — phase-aware TODO graph compiler and conflict-aware orchestration runtime",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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.v10';
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
- /** task node key -> the fold node standing in for it, empty when nothing folded. */
129
+ /** Keys of the ToDos the diagram does not draw, empty when nothing was folded. */
130
130
  function foldIndex(layout) {
131
- return new Map((layout?.folded ?? []).map((entry) => [refKey(entry.task), refKey(entry.fold)]));
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 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.
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 Map()) {
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 Map()) {
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
- // Mark the ones the diagram no longer draws separately, so the reader knows
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
- ? `▣ ${taskReference(target, lookup)}` : taskReference(target, lookup);
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
- // 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>`;
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}${foldDetails}</div><section class="task-index" data-right-panel="task-index" hidden><h1>全工程</h1><p>Latticeに登録された全工程を、現在の状態とともに登録順で表示しています。</p>${taskIndex}</section></div>`;
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">▣ 完走済み ${foldedCount}件を畳んで表示中</span>`;
281
+ : `<span class="fold-chip">完走済み ${foldedCount}件を非表示</span>`;
317
282
  const foldNote = foldedCount === 0 ? ''
318
- : `<p class="fold-note">後続に作業中・未着手が残っていない完了工程を、${layout.scope.fold_node_count}個の畳み込みノードへまとめています。生きた工程とその直接の前提工程は必ず展開したままです。全件を描くには <code>lattice todo gantt --scope all</code> を実行してください。総数・進捗・最長依存鎖は畳み込み前の全工程で数えています。</p>`;
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, foldedByKey: new Map(), folds: [], grouping: null }
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
- // A fold node stands in for a finished branch, so it inherits the chain
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.foldedByKey.size,
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
- folded: [...projected.foldedByKey.entries()]
650
- .map(([taskKey, foldKey]) => ({ task: JSON.parse(taskKey), fold: JSON.parse(foldKey) }))
651
- .sort((left, right) => compareRefs(
652
- { project_id: left.task[0], plan_key: left.task[1], task_id: left.task[2] },
653
- { project_id: right.task[0], plan_key: right.task[1], task_id: right.task[2] },
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,
@@ -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 "fold the dead branches": a completed ToDo that no longer leads
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.
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
- * 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
+ * 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 folded graph would make them lie.
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, fold everything upstream of them.
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
- * 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.
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, foldedByKey: Map, folds: Array, grouping: string}}
77
+ * @returns {{nodes: Array, edges: Array, foldedKeys: Set<string>}}
275
78
  */
276
79
  export function projectTodoGanttScope({
277
- nodes, edges, wave, longestChainKeys = new Set(), foldDistance = DEFAULT_FOLD_DISTANCE,
80
+ nodes, edges, wave, foldDistance = DEFAULT_FOLD_DISTANCE,
278
81
  }) {
279
82
  const distance = distanceToLive(nodes, edges, wave);
280
- const foldable = foldableKeys(nodes, distance, foldDistance);
281
- if (foldable.size === 0) {
282
- return { nodes, edges, foldedByKey: new Map(), folds: [], grouping: null };
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: contracted.nodes, edges: contracted.edges, foldedByKey, folds, grouping: selected.strategy,
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
  }
@@ -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 = fold === null
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 = fold === null
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 = fold !== null ? `${fold.task_count}工程`
161
- : taskNumber === undefined ? `ID ${node.ref.task_id}` : `工程 ${taskNumber.display_number}`;
162
- const spokenReference = fold !== null ? `${fold.task_count}工程の畳み込み`
163
- : taskNumber === undefined ? `ID ${node.ref.task_id}` : `工程${taskNumber.display_number}`;
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 = fold === null
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 (const plan of layout.groups.plans) {
190
- const lanes = layout.groups.lanes.filter((lane) => lane.plan_key === plan.plan_key);
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