@quolu/lattice 0.12.12 → 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-dashboard-registry.mjs +17 -1
- package/src/todo-gantt-html.mjs +29 -20
- package/src/todo-gantt-layout.mjs +20 -33
- package/src/todo-gantt-live.mjs +7 -1
- package/src/todo-gantt-scope.mjs +27 -229
- package/src/todo-gantt-svg.mjs +16 -17
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.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,23 +126,23 @@ 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
|
-
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
|
|
140
|
-
//
|
|
141
|
-
const selectKey =
|
|
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.
|
|
141
|
+
const selectKey = key;
|
|
142
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>`;
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
-
function renderTaskIndex(sections, lookup, folds = new
|
|
145
|
+
function renderTaskIndex(sections, lookup, folds = new Set()) {
|
|
146
146
|
const plans = [];
|
|
147
147
|
for (const section of sections) {
|
|
148
148
|
let plan = plans.at(-1);
|
|
@@ -156,9 +156,9 @@ function renderTaskIndex(sections, lookup, folds = new Map()) {
|
|
|
156
156
|
const drawn = plan.tasks.filter((section) => !folds.has(refKey(section.ref)));
|
|
157
157
|
const folded = plan.tasks.filter((section) => folds.has(refKey(section.ref)));
|
|
158
158
|
const drawnList = drawn.length === 0 ? ''
|
|
159
|
-
: `<ol class="task-index-list">${drawn.map((section) => renderTaskIndexEntry(section, lookup
|
|
159
|
+
: `<ol class="task-index-list">${drawn.map((section) => renderTaskIndexEntry(section, lookup)).join('')}</ol>`;
|
|
160
160
|
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
|
|
161
|
+
: `<details class="task-index-folded"><summary>完走済みとして畳んだ工程 ${folded.length}件</summary><ol class="task-index-list">${folded.map((section) => renderTaskIndexEntry(section, lookup)).join('')}</ol></details>`;
|
|
162
162
|
return `<section class="task-index-plan"><h2><code>${escapeHtmlText(plan.planKey)}</code></h2>${drawnList}${foldedList}</section>`;
|
|
163
163
|
}).join('');
|
|
164
164
|
}
|
|
@@ -175,14 +175,18 @@ function taskReference(section, lookup) {
|
|
|
175
175
|
return number === undefined ? `ID ${section.task.task_id}` : `工程 ${number.display_number}`;
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
-
function renderRelationList(relations, sectionByKey, lookup, emptyText) {
|
|
178
|
+
function renderRelationList(relations, sectionByKey, lookup, emptyText, folds = new Set()) {
|
|
179
179
|
if (relations.length === 0) return `<p class="relation-empty">${escapeHtmlText(emptyText)}</p>`;
|
|
180
180
|
return `<ul class="relation-list">${relations.map((relation) => {
|
|
181
|
-
const
|
|
181
|
+
const targetKey = refKey(relation.ref);
|
|
182
|
+
const target = sectionByKey.get(targetKey);
|
|
182
183
|
if (target === undefined) return '';
|
|
183
184
|
const join = relation.joinIds.length === 0 ? ''
|
|
184
185
|
: `<span class="relation-kind">合流条件: ${escapeHtmlText(relation.joinIds.join(', '))}</span>`;
|
|
185
|
-
|
|
186
|
+
// Say which ones the diagram does not draw, so the reader stops looking.
|
|
187
|
+
const reference = folds.has(targetKey)
|
|
188
|
+
? `${taskReference(target, lookup)}(図では非表示)` : taskReference(target, lookup);
|
|
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>`;
|
|
186
190
|
}).join('')}</ul>`;
|
|
187
191
|
}
|
|
188
192
|
|
|
@@ -211,6 +215,7 @@ function renderRightPane(sections, layout, presentation, readModel) {
|
|
|
211
215
|
const lookup = presentationLookup(presentation);
|
|
212
216
|
const sectionByKey = new Map(sections.map((section) => [refKey(section.ref), section]));
|
|
213
217
|
const nodeByKey = new Map(layout.nodes.map((node) => [refKey(node.ref), node]));
|
|
218
|
+
const folds = foldIndex(layout);
|
|
214
219
|
const incoming = new Map(sections.map((section) => [refKey(section.ref), []]));
|
|
215
220
|
const outgoing = new Map(sections.map((section) => [refKey(section.ref), []]));
|
|
216
221
|
const addRelation = (relations, ownerKey, ref, joinIds) => {
|
|
@@ -223,7 +228,10 @@ function renderRightPane(sections, layout, presentation, readModel) {
|
|
|
223
228
|
}
|
|
224
229
|
entry.joinIds = [...new Set([...entry.joinIds, ...joinIds])].sort();
|
|
225
230
|
};
|
|
226
|
-
|
|
231
|
+
// Premises and successors come from the FULL graph. `layout.edges` is the
|
|
232
|
+
// drawn graph, where a fold unit's interior dependencies have been contracted
|
|
233
|
+
// away — reading those here would tell a folded ToDo it has no premises.
|
|
234
|
+
for (const edge of layout.full_edges ?? layout.edges) {
|
|
227
235
|
addRelation(incoming, refKey(edge.to), edge.from, edge.join_ids);
|
|
228
236
|
addRelation(outgoing, refKey(edge.from), edge.to, edge.join_ids);
|
|
229
237
|
}
|
|
@@ -256,9 +264,12 @@ function renderRightPane(sections, layout, presentation, readModel) {
|
|
|
256
264
|
const readiness = node?.visibility.next_ready
|
|
257
265
|
? `<p class="readiness-note">ready frontierの一員です。${ready.length > 1 ? '他のready工程と同時dispatchするのが既定です。subsetだけを選ぶ場合は理由を記録してください。' : '現在の唯一の着手候補です。'}</p>`
|
|
258
266
|
: incoming.get(key).length === 0 ? '<p class="readiness-note">登録済みの前提工程はありません。図だけではdispatch可否を判定しません。</p>' : '';
|
|
259
|
-
|
|
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>';
|
|
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>`;
|
|
260
271
|
}).join('');
|
|
261
|
-
const taskIndex = renderTaskIndex(sections, lookup,
|
|
272
|
+
const taskIndex = renderTaskIndex(sections, lookup, folds);
|
|
262
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>`;
|
|
263
274
|
}
|
|
264
275
|
|
|
@@ -267,9 +278,9 @@ function renderDiagramLegend(presentation, layout = null) {
|
|
|
267
278
|
const categoryDetails = categories === '' ? '' : `<details class="category-legend"><summary>カテゴリ説明</summary><dl>${categories}</dl></details>`;
|
|
268
279
|
const foldedCount = layout?.scope?.folded_task_count ?? 0;
|
|
269
280
|
const foldChip = foldedCount === 0 ? ''
|
|
270
|
-
: `<span class="fold-chip"
|
|
281
|
+
: `<span class="fold-chip">完走済み ${foldedCount}件を非表示</span>`;
|
|
271
282
|
const foldNote = foldedCount === 0 ? ''
|
|
272
|
-
:
|
|
283
|
+
: '<p class="fold-note">後続に作業中・未着手が残っていない完了工程は図から外しています。生きた工程とその直接の前提工程は必ず描きます。外した工程は右の「全工程」から辿れ、図に出すには <code>lattice todo gantt --scope all</code> を実行してください。総数・進捗・最長依存鎖は外す前の全工程で数えています。</p>';
|
|
273
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>`;
|
|
274
285
|
}
|
|
275
286
|
|
|
@@ -333,8 +344,6 @@ body{display:grid;grid-template-rows:minmax(0,1fr);height:100vh;margin:0;backgro
|
|
|
333
344
|
.todo-node .node-title{fill:var(--text-primary);font-size:13.5px;font-weight:400}
|
|
334
345
|
.todo-node .node-title-line{font-size:13.5px;font-weight:400}
|
|
335
346
|
.todo-node .status-mark{fill:var(--text-secondary);font-size:13.5px;font-weight:400}
|
|
336
|
-
.folded-node .node-surface{fill:var(--surface-2);stroke:var(--border);stroke-width:2;stroke-dasharray:none}
|
|
337
|
-
.folded-node .node-title,.folded-node .node-meta{fill:var(--text-secondary)}
|
|
338
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}
|
|
339
348
|
.fold-note{flex:1 0 100%;margin:4px 0 0;color:var(--text-secondary);font-weight:400}
|
|
340
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
|
});
|
|
@@ -619,6 +608,16 @@ export function layoutTodoGantt(readModel, chainProjection, options = {}) {
|
|
|
619
608
|
},
|
|
620
609
|
nodes: projectedNodes,
|
|
621
610
|
edges: projectedEdges,
|
|
611
|
+
// Every dependency in the plan, before folding contracted any of them away.
|
|
612
|
+
// The diagram draws `edges`; anything that describes a ToDo in words — the
|
|
613
|
+
// premises and successors in the right pane — reads this instead, so a
|
|
614
|
+
// folded ToDo keeps telling the truth about what it depended on.
|
|
615
|
+
full_edges: full.edges.map((edge) => ({
|
|
616
|
+
from: { ...full.nodesByKey.get(edge.from).ref },
|
|
617
|
+
to: { ...full.nodesByKey.get(edge.to).ref },
|
|
618
|
+
kinds: [...edge.kinds].sort(compareText),
|
|
619
|
+
join_ids: [...edge.joinIdentities.values()].map(({ join_id }) => join_id).sort(compareText),
|
|
620
|
+
})),
|
|
622
621
|
connectors: junctionConnectors,
|
|
623
622
|
groups: {
|
|
624
623
|
plans: [...planMap.entries()].map(([plan_key, task_count]) => ({ plan_key, task_count })),
|
|
@@ -626,26 +625,14 @@ export function layoutTodoGantt(readModel, chainProjection, options = {}) {
|
|
|
626
625
|
},
|
|
627
626
|
scope: {
|
|
628
627
|
requested: scope,
|
|
629
|
-
folded_task_count: projected.
|
|
630
|
-
fold_node_count: projected.folds.length,
|
|
631
|
-
per_wave_refinement: projected.refined,
|
|
632
|
-
folds: projected.folds.map((entry) => ({
|
|
633
|
-
ref: { ...entry.ref },
|
|
634
|
-
task_count: entry.task_count,
|
|
635
|
-
lanes: [...entry.lanes],
|
|
636
|
-
longest_chain_task_count: entry.longest_chain_task_count,
|
|
637
|
-
})),
|
|
628
|
+
folded_task_count: projected.foldedKeys.size,
|
|
638
629
|
},
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
)
|
|
645
|
-
.map(({ task, fold }) => ({
|
|
646
|
-
task: { project_id: task[0], plan_key: task[1], task_id: task[2] },
|
|
647
|
-
fold: { project_id: fold[0], plan_key: fold[1], task_id: fold[2] },
|
|
648
|
-
})),
|
|
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),
|
|
649
636
|
metrics: {
|
|
650
637
|
crossing_count: crossingCount(edges, wave, transversePosition),
|
|
651
638
|
visible_node_count: visibleKeys.size,
|
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
|
@@ -9,49 +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
|
-
*
|
|
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.
|
|
16
|
+
*
|
|
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.
|
|
17
22
|
*
|
|
18
23
|
* This module is pure graph math over the layout's internal node/edge shape.
|
|
19
24
|
* It must run AFTER dependency waves, the longest dependency chain and the
|
|
20
25
|
* ready frontier have been computed on the FULL graph — those numbers describe
|
|
21
|
-
* the real plan, and measuring them on a
|
|
26
|
+
* the real plan, and measuring them on a narrowed graph would make them lie.
|
|
22
27
|
*/
|
|
23
28
|
|
|
24
29
|
export const TODO_GANTT_SCOPES = Object.freeze(['live', 'all']);
|
|
25
30
|
|
|
26
31
|
/**
|
|
27
32
|
* Hops of completed predecessors kept in front of live work. 1 = keep the
|
|
28
|
-
* direct premises of live ToDos,
|
|
33
|
+
* direct premises of live ToDos, drop everything upstream of them.
|
|
29
34
|
*/
|
|
30
35
|
export const DEFAULT_FOLD_DISTANCE = 1;
|
|
31
36
|
|
|
32
|
-
/**
|
|
33
|
-
* `task_id` values in the store match /^[0-9A-Za-z][0-9A-Za-z._-]{0,127}$/, so
|
|
34
|
-
* a leading '~' cannot collide with a real ToDo.
|
|
35
|
-
*/
|
|
36
|
-
const FOLD_TASK_ID_PREFIX = '~folded';
|
|
37
|
-
|
|
38
|
-
export class TodoGanttScopeError extends Error {
|
|
39
|
-
constructor(code, message, detail = null) {
|
|
40
|
-
super(message);
|
|
41
|
-
this.name = 'TodoGanttScopeError';
|
|
42
|
-
this.code = code;
|
|
43
|
-
this.detail = detail;
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
37
|
function compareText(left, right) {
|
|
48
38
|
return left < right ? -1 : left > right ? 1 : 0;
|
|
49
39
|
}
|
|
50
40
|
|
|
51
|
-
export function isFoldNodeRef(ref) {
|
|
52
|
-
return typeof ref?.task_id === 'string' && ref.task_id.startsWith(`${FOLD_TASK_ID_PREFIX}:`);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
41
|
/**
|
|
56
42
|
* Forward distance from each node to the nearest live (non-done) node, over the
|
|
57
43
|
* dependency DAG. A live node is at distance 0; a node with no live descendant
|
|
@@ -82,215 +68,27 @@ function distanceToLive(nodes, edges, wave) {
|
|
|
82
68
|
return distance;
|
|
83
69
|
}
|
|
84
70
|
|
|
85
|
-
function foldableKeys(nodes, distance, foldDistance) {
|
|
86
|
-
return new Set(nodes
|
|
87
|
-
.filter((node) => node.status === 'done' && distance.get(node.key) > foldDistance)
|
|
88
|
-
.map(({ key }) => key));
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Group foldable nodes into fold units.
|
|
93
|
-
*
|
|
94
|
-
* `byComponent` groups each weakly connected component of the foldable subgraph
|
|
95
|
-
* into one unit — the compact projection, one node per finished branch.
|
|
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.
|
|
100
|
-
*/
|
|
101
|
-
function groupFoldable(nodes, edges, foldable, wave, splitByWave) {
|
|
102
|
-
const parent = new Map([...foldable].map((key) => [key, key]));
|
|
103
|
-
const find = (key) => {
|
|
104
|
-
let root = key;
|
|
105
|
-
while (parent.get(root) !== root) root = parent.get(root);
|
|
106
|
-
let cursor = key;
|
|
107
|
-
while (parent.get(cursor) !== root) {
|
|
108
|
-
const next = parent.get(cursor);
|
|
109
|
-
parent.set(cursor, root);
|
|
110
|
-
cursor = next;
|
|
111
|
-
}
|
|
112
|
-
return root;
|
|
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);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
const unitByNode = new Map();
|
|
131
|
-
const members = new Map();
|
|
132
|
-
for (const key of [...foldable].sort(compareText)) {
|
|
133
|
-
const unitKey = splitByWave
|
|
134
|
-
? JSON.stringify([find(key), wave.get(key)])
|
|
135
|
-
: JSON.stringify([find(key), null]);
|
|
136
|
-
unitByNode.set(key, unitKey);
|
|
137
|
-
if (!members.has(unitKey)) members.set(unitKey, []);
|
|
138
|
-
members.get(unitKey).push(key);
|
|
139
|
-
}
|
|
140
|
-
return { unitByNode, members };
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
function dominantLane(memberNodes) {
|
|
144
|
-
const counts = new Map();
|
|
145
|
-
for (const node of memberNodes) counts.set(node.lane, (counts.get(node.lane) ?? 0) + 1);
|
|
146
|
-
return [...counts.entries()]
|
|
147
|
-
.sort(([leftLane, leftCount], [rightLane, rightCount]) => rightCount - leftCount
|
|
148
|
-
|| compareText(leftLane, rightLane))[0][0];
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function buildSummaryNodes(nodes, members, longestChainKeys) {
|
|
152
|
-
const nodeByKey = new Map(nodes.map((node) => [node.key, node]));
|
|
153
|
-
// Order fold units by their first member so synthetic ids are stable across runs.
|
|
154
|
-
const ordered = [...members.entries()]
|
|
155
|
-
.map(([unitKey, memberKeys]) => ({ unitKey, memberKeys: [...memberKeys].sort(compareText) }))
|
|
156
|
-
.sort((left, right) => compareText(left.memberKeys[0], right.memberKeys[0]));
|
|
157
|
-
|
|
158
|
-
const summaryByUnit = new Map();
|
|
159
|
-
ordered.forEach((unit, index) => {
|
|
160
|
-
const memberNodes = unit.memberKeys.map((key) => nodeByKey.get(key));
|
|
161
|
-
const { plan_key: planKey, project_id: projectId } = memberNodes[0].ref;
|
|
162
|
-
const ref = { project_id: projectId, plan_key: planKey, task_id: `${FOLD_TASK_ID_PREFIX}:${index}` };
|
|
163
|
-
const key = JSON.stringify([ref.project_id, ref.plan_key, ref.task_id]);
|
|
164
|
-
const lanes = [...new Set(memberNodes.map((node) => node.lane))].sort(compareText);
|
|
165
|
-
summaryByUnit.set(unit.unitKey, {
|
|
166
|
-
key,
|
|
167
|
-
ref,
|
|
168
|
-
title: `完了済み ${memberNodes.length}件`,
|
|
169
|
-
lane: dominantLane(memberNodes),
|
|
170
|
-
status: 'done',
|
|
171
|
-
plan_schema: memberNodes[0].plan_schema,
|
|
172
|
-
phase_id: null,
|
|
173
|
-
phase_status: null,
|
|
174
|
-
phase_ready: false,
|
|
175
|
-
fold: {
|
|
176
|
-
task_count: memberNodes.length,
|
|
177
|
-
lanes,
|
|
178
|
-
longest_chain_task_count: memberNodes
|
|
179
|
-
.filter((node) => longestChainKeys.has(node.key)).length,
|
|
180
|
-
task_refs: memberNodes.map((node) => ({ ...node.ref })),
|
|
181
|
-
},
|
|
182
|
-
});
|
|
183
|
-
});
|
|
184
|
-
return summaryByUnit;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
function contract(nodes, edges, unitByNode, summaryByUnit) {
|
|
188
|
-
const mapKey = (key) => {
|
|
189
|
-
const unitKey = unitByNode.get(key);
|
|
190
|
-
return unitKey === undefined ? key : summaryByUnit.get(unitKey).key;
|
|
191
|
-
};
|
|
192
|
-
const keptNodes = nodes.filter((node) => !unitByNode.has(node.key));
|
|
193
|
-
const summaries = [...summaryByUnit.values()];
|
|
194
|
-
const resultNodes = [...keptNodes, ...summaries]
|
|
195
|
-
.sort((left, right) => compareText(left.key, right.key));
|
|
196
|
-
|
|
197
|
-
const contracted = new Map();
|
|
198
|
-
for (const edge of edges) {
|
|
199
|
-
const from = mapKey(edge.from);
|
|
200
|
-
const to = mapKey(edge.to);
|
|
201
|
-
if (from === to) continue; // interior of a fold unit
|
|
202
|
-
const key = JSON.stringify([from, to]);
|
|
203
|
-
let merged = contracted.get(key);
|
|
204
|
-
if (merged === undefined) {
|
|
205
|
-
// A rewired edge is an aggregate. Join identity describes how several
|
|
206
|
-
// premises meet at one ToDo; carrying it onto an aggregate edge would
|
|
207
|
-
// draw a junction marker for a join whose members are no longer on
|
|
208
|
-
// screen, so aggregates keep only the dependency kinds.
|
|
209
|
-
merged = { key, from, to, kinds: new Set(), joinIdentities: new Map(), aggregated: false };
|
|
210
|
-
contracted.set(key, merged);
|
|
211
|
-
}
|
|
212
|
-
for (const kind of edge.kinds) merged.kinds.add(kind);
|
|
213
|
-
if (from === edge.from && to === edge.to) {
|
|
214
|
-
for (const [identityKey, identity] of edge.joinIdentities) {
|
|
215
|
-
merged.joinIdentities.set(identityKey, identity);
|
|
216
|
-
}
|
|
217
|
-
} else {
|
|
218
|
-
merged.aggregated = true;
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
for (const edge of contracted.values()) {
|
|
222
|
-
if (edge.aggregated) edge.joinIdentities = new Map();
|
|
223
|
-
}
|
|
224
|
-
const resultEdges = [...contracted.values()].sort((left, right) => compareText(left.key, right.key));
|
|
225
|
-
return { nodes: resultNodes, edges: resultEdges };
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
/** Kahn peel; returns true when the graph is acyclic. */
|
|
229
|
-
function isAcyclic(nodes, edges) {
|
|
230
|
-
const indegree = new Map(nodes.map(({ key }) => [key, 0]));
|
|
231
|
-
const outgoing = new Map(nodes.map(({ key }) => [key, []]));
|
|
232
|
-
for (const edge of edges) {
|
|
233
|
-
outgoing.get(edge.from).push(edge.to);
|
|
234
|
-
indegree.set(edge.to, indegree.get(edge.to) + 1);
|
|
235
|
-
}
|
|
236
|
-
const ready = nodes.filter(({ key }) => indegree.get(key) === 0).map(({ key }) => key);
|
|
237
|
-
let visited = 0;
|
|
238
|
-
while (ready.length > 0) {
|
|
239
|
-
const current = ready.pop();
|
|
240
|
-
visited += 1;
|
|
241
|
-
for (const next of outgoing.get(current)) {
|
|
242
|
-
indegree.set(next, indegree.get(next) - 1);
|
|
243
|
-
if (indegree.get(next) === 0) ready.push(next);
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
return visited === nodes.length;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
71
|
/**
|
|
250
72
|
* Project the full dependency graph onto the `live` scope.
|
|
251
73
|
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
* completed ToDo kept as the direct premise of live work and `f1`/`f2` belong
|
|
255
|
-
* to the same fold unit. Contracting that unit would produce
|
|
256
|
-
* `summary -> s -> summary`. So the contraction is verified, and on a cycle the
|
|
257
|
-
* grouping is refined to (component, wave) — provably acyclic, because every
|
|
258
|
-
* dependency edge strictly increases the wave.
|
|
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.
|
|
259
76
|
*
|
|
260
|
-
* @returns {{nodes: Array, edges: Array,
|
|
77
|
+
* @returns {{nodes: Array, edges: Array, foldedKeys: Set<string>}}
|
|
261
78
|
*/
|
|
262
79
|
export function projectTodoGanttScope({
|
|
263
|
-
nodes, edges, wave,
|
|
80
|
+
nodes, edges, wave, foldDistance = DEFAULT_FOLD_DISTANCE,
|
|
264
81
|
}) {
|
|
265
82
|
const distance = distanceToLive(nodes, edges, wave);
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
grouping = groupFoldable(nodes, edges, foldable, wave, true);
|
|
278
|
-
summaryByUnit = buildSummaryNodes(nodes, grouping.members, longestChainKeys);
|
|
279
|
-
contracted = contract(nodes, edges, grouping.unitByNode, summaryByUnit);
|
|
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
|
-
}
|
|
286
|
-
|
|
287
|
-
const foldedByKey = new Map();
|
|
288
|
-
for (const [nodeKey, unitKey] of grouping.unitByNode) {
|
|
289
|
-
foldedByKey.set(nodeKey, summaryByUnit.get(unitKey).key);
|
|
290
|
-
}
|
|
291
|
-
const folds = [...summaryByUnit.values()]
|
|
292
|
-
.sort((left, right) => compareText(left.key, right.key))
|
|
293
|
-
.map((summary) => ({ ref: { ...summary.ref }, ...summary.fold }));
|
|
294
|
-
|
|
295
|
-
return { nodes: contracted.nodes, edges: contracted.edges, foldedByKey, folds, refined };
|
|
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 };
|
|
87
|
+
return {
|
|
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,
|
|
93
|
+
};
|
|
296
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
|