@quolu/lattice 0.12.34 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/lattice-dashboard.mjs +8 -3
- package/package.json +2 -2
- package/sensor/dist/mcp/server-instructions.d.ts +1 -1
- package/sensor/dist/mcp/server-instructions.d.ts.map +1 -1
- package/sensor/dist/mcp/server-instructions.js +20 -0
- package/sensor/dist/mcp/server-instructions.js.map +1 -1
- package/src/cli-help.mjs +12 -0
- package/src/runtime-front-end.mjs +3 -0
- package/src/todo-cli.mjs +441 -14
- package/src/todo-gantt-html.mjs +92 -6
- package/src/todo-gantt-layout.mjs +69 -1
- package/src/todo-gantt-svg.mjs +23 -4
- package/src/todo-independence-contracts.mjs +380 -0
- package/src/todo-independence-guidance.mjs +107 -0
- package/src/todo-independence.mjs +485 -0
- package/src/todo-status.mjs +47 -12
- package/src/todo-store.mjs +132 -0
package/src/todo-gantt-html.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import { createHash } from 'node:crypto';
|
|
|
3
3
|
import { serializeJsonForScript } from './todo-markdown-renderer.mjs';
|
|
4
4
|
import { renderTodoGanttSvg, TODO_GANTT_STATUS_PRESENTATION } from './todo-gantt-svg.mjs';
|
|
5
5
|
|
|
6
|
-
export const TODO_GANTT_RENDERER_VERSION = 'lattice.todo_gantt_renderer.
|
|
6
|
+
export const TODO_GANTT_RENDERER_VERSION = 'lattice.todo_gantt_renderer.v17';
|
|
7
7
|
export const TODO_GANTT_PROSE_MAX_BYTES = 8 * 1024 * 1024;
|
|
8
8
|
export const TODO_GANTT_HTML_MAX_BYTES = 24 * 1024 * 1024;
|
|
9
9
|
|
|
@@ -214,6 +214,74 @@ function renderPhaseProgress(readModel) {
|
|
|
214
214
|
return `<section class="phase-overview"><h2>Phase進捗</h2><p>${guidance}</p>${liveList}${settledList}</section>`;
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
+
/**
|
|
218
|
+
* 図の外が語るための独立性要約を、plan単位の投影から引ける形へ畳む(ADR 0129 Decision 3)。
|
|
219
|
+
*/
|
|
220
|
+
function summarizeIndependence(layout) {
|
|
221
|
+
if (layout.independence === null) return null;
|
|
222
|
+
const byPlan = new Map(layout.independence.plans.map((plan) => [plan.plan_key, plan]));
|
|
223
|
+
return {
|
|
224
|
+
plans: layout.independence.plans,
|
|
225
|
+
byPlan,
|
|
226
|
+
verifiedTaskCount: layout.independence.plans
|
|
227
|
+
.reduce((total, plan) => total + plan.verified_task_count, 0),
|
|
228
|
+
unknownTaskCount: layout.independence.plans
|
|
229
|
+
.reduce((total, plan) => total + plan.unknown_task_ids.length, 0),
|
|
230
|
+
serializePairCount: layout.independence.plans
|
|
231
|
+
.reduce((total, plan) => total + plan.serialize_pairs.length, 0),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** 記録が無いときだけADR 0063の既定をそのまま述べる。 */
|
|
236
|
+
function dispatchBasis(summary) {
|
|
237
|
+
if (summary === null) {
|
|
238
|
+
return 'ready frontier全件が既定です。一部だけを直列着手する場合は理由が必要です。';
|
|
239
|
+
}
|
|
240
|
+
const parts = [`検証済み並列 ${summary.verifiedTaskCount}工程`];
|
|
241
|
+
if (summary.serializePairCount > 0) parts.push(`要直列 ${summary.serializePairCount}組`);
|
|
242
|
+
if (summary.unknownTaskCount > 0) parts.push(`未検査 ${summary.unknownTaskCount}工程`);
|
|
243
|
+
return `${parts.join('、')}。未検査は依存線が無くても並列可の根拠になりません。`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const SEVERABILITY_LABEL = Object.freeze({
|
|
247
|
+
code_seam: 'コードの分割で並列化しうる',
|
|
248
|
+
serial: '共有状態のため直列必須',
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
/** 個別ToDoについて、競合相手と切断可能性を言葉で示す。 */
|
|
252
|
+
function renderIndependenceNote(ref, node, summary) {
|
|
253
|
+
if (summary === null || node === undefined) return '';
|
|
254
|
+
const plan = summary.byPlan.get(ref.plan_key);
|
|
255
|
+
if (plan === undefined) return '';
|
|
256
|
+
const taskId = ref.task_id;
|
|
257
|
+
const state = node.visibility.independence;
|
|
258
|
+
if (state === null) return '';
|
|
259
|
+
if (state === 'verified') {
|
|
260
|
+
return '<p class="readiness-note"><strong>並列可否:</strong> 独立検証済です。記録時点の宣言境界では他のready工程と干渉しません。</p>';
|
|
261
|
+
}
|
|
262
|
+
if (state === 'unknown') {
|
|
263
|
+
return '<p class="readiness-note"><strong>並列可否:</strong> 未検査です。競合が無いのではなく、まだ判定していません。</p>';
|
|
264
|
+
}
|
|
265
|
+
const pairs = [
|
|
266
|
+
...plan.serialize_pairs
|
|
267
|
+
.filter((pair) => pair.task_ids.includes(taskId))
|
|
268
|
+
.map((pair) => ({
|
|
269
|
+
other: pair.task_ids.find((id) => id !== taskId),
|
|
270
|
+
severability: pair.severability,
|
|
271
|
+
detail: pair.detail,
|
|
272
|
+
})),
|
|
273
|
+
...plan.conflicts_with_active
|
|
274
|
+
.filter((entry) => entry.ready_task_id === taskId)
|
|
275
|
+
.map((entry) => ({
|
|
276
|
+
other: `${entry.active_task_id}(作業中)`,
|
|
277
|
+
severability: entry.severability,
|
|
278
|
+
detail: entry.detail,
|
|
279
|
+
})),
|
|
280
|
+
];
|
|
281
|
+
const items = pairs.map((pair) => `<li>${escapeHtmlText(pair.other)} — ${escapeHtmlText(SEVERABILITY_LABEL[pair.severability] ?? pair.severability)}(資源 ${escapeHtmlText(pair.detail)})</li>`).join('');
|
|
282
|
+
return `<p class="readiness-note"><strong>並列可否:</strong> 要直列です。</p><ul class="independence-conflicts">${items}</ul>`;
|
|
283
|
+
}
|
|
284
|
+
|
|
217
285
|
function renderRightPane(sections, layout, presentation, readModel) {
|
|
218
286
|
const lookup = presentationLookup(presentation);
|
|
219
287
|
const sectionByKey = new Map(sections.map((section) => [refKey(section.ref), section]));
|
|
@@ -242,11 +310,17 @@ function renderRightPane(sections, layout, presentation, readModel) {
|
|
|
242
310
|
for (const section of sections) counts[section.state.status] += 1;
|
|
243
311
|
const active = sections.filter((section) => section.state.status === 'in-progress');
|
|
244
312
|
const ready = layout.nodes.filter((node) => node.visibility.next_ready);
|
|
245
|
-
const
|
|
246
|
-
|
|
313
|
+
const independenceSummary = summarizeIndependence(layout);
|
|
314
|
+
const readyHeadline = ready.length > 1
|
|
315
|
+
? `<p class="readiness-note"><strong>同時dispatch推奨:</strong> ${ready.length}工程。${escapeHtmlText(dispatchBasis(independenceSummary))}</p>`
|
|
247
316
|
: ready.length === 1
|
|
248
317
|
? '<p class="readiness-note"><strong>着手候補:</strong> 1工程です。</p>'
|
|
249
318
|
: '<p class="readiness-note">現在のready frontierは空です。</p>';
|
|
319
|
+
// ready件数によらず、記録があるなら内訳を述べる。1件の時だけ黙ると、
|
|
320
|
+
// その1件が未検査でも「候補が1つある」としか伝わらない。
|
|
321
|
+
const independenceNote = independenceSummary === null || ready.length > 1 ? ''
|
|
322
|
+
: `<p class="readiness-note"><strong>並列可否:</strong> ${escapeHtmlText(dispatchBasis(independenceSummary))}</p>`;
|
|
323
|
+
const dispatchSummary = `${readyHeadline}${independenceNote}`;
|
|
250
324
|
const activeLinks = active.length === 0 ? '<p>作業中の工程はありません。</p>'
|
|
251
325
|
: `<ul class="active-list">${active.map((section) => `<li><button type="button" data-select-node-key="${escapeHtmlAttribute(refKey(section.ref))}">${escapeHtmlText(taskReference(section, lookup))} — ${escapeHtmlText(section.task.title)}</button></li>`).join('')}</ul>`;
|
|
252
326
|
const overview = `<section class="right-overview" data-right-panel="overview"><h1>工程を選択してください</h1><p>左の依存工程図から工程を選ぶと、題名・状態・前提・後続を表示します。</p><div class="status-summary"><span>☐ 未着手 ${counts.pending}</span><span>▶ 作業中 ${counts['in-progress']}</span><span>✅ 完了 ${counts.done}</span><span>⛔ ブロック中 ${counts.blocked}</span></div>${dispatchSummary}${renderPhaseProgress(readModel)}<h2>作業中</h2>${activeLinks}</section>`;
|
|
@@ -265,12 +339,13 @@ function renderRightPane(sections, layout, presentation, readModel) {
|
|
|
265
339
|
? `元plan: ${sourceRef}${sourceLine} — 行対応を確認済み`
|
|
266
340
|
: `元plan: ${sourceRef}${sourceLine} — 行対応を確認できないため、本文位置との対応は表示していません`;
|
|
267
341
|
const readiness = node?.visibility.next_ready
|
|
268
|
-
? `<p class="readiness-note">ready frontierの一員です。${ready.length > 1 ? '他のready
|
|
342
|
+
? `<p class="readiness-note">ready frontierの一員です。${ready.length > 1 ? '他のready工程と同時着手できるかは下の並列可否で判断してください。' : '現在の唯一の着手候補です。'}</p>`
|
|
269
343
|
: incoming.get(key).length === 0 ? '<p class="readiness-note">登録済みの前提工程はありません。図だけではdispatch可否を判定しません。</p>' : '';
|
|
344
|
+
const independenceNote = renderIndependenceNote(section.ref, node, independenceSummary);
|
|
270
345
|
// Say it plainly when the reader will not find this ToDo on the diagram.
|
|
271
346
|
const foldedNote = !folds.has(key) ? ''
|
|
272
347
|
: '<p class="fold-note">完走済みのため図には描いていません。図に出すには <code>lattice todo gantt --scope all</code> を実行してください。</p>';
|
|
273
|
-
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>`;
|
|
348
|
+
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}${independenceNote}${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>`;
|
|
274
349
|
}).join('');
|
|
275
350
|
const taskIndex = renderTaskIndex(sections, lookup, folds, planActivity(readModel));
|
|
276
351
|
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>全工程一覧</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に登録された全工程を現在の状態とともに表示しています。planは動いているものを最終活動の新しい順で上に、完走したものを古い順で下にまとめ、plan内は登録順です。</p>${taskIndex}</section></div>`;
|
|
@@ -291,7 +366,13 @@ function renderDiagramLegend(presentation, layout = null, expandable = false) {
|
|
|
291
366
|
: expandable
|
|
292
367
|
? '<p class="fold-note">後続に作業中・未着手が残っていない完了工程は図から外しています。生きた工程とその直接の前提工程は必ず描きます。上のバッジを押すと外した工程も含めて描きます。総数・進捗・最長依存鎖は外す前の全工程で数えています。</p>'
|
|
293
368
|
: '<p class="fold-note">後続に作業中・未着手が残っていない完了工程は図から外しています。生きた工程とその直接の前提工程は必ず描きます。外した工程は右の「全工程」から辿れ、図に出すには <code>lattice todo gantt --scope all</code> を実行してください。総数・進捗・最長依存鎖は外す前の全工程で数えています。</p>';
|
|
294
|
-
|
|
369
|
+
const independenceLegend = layout.independence === null ? ''
|
|
370
|
+
: '<span>∥ 独立検証済</span><span>⛓ 要直列</span><span>? 未検査</span>';
|
|
371
|
+
// 独立性の記録がある間は「全件同時dispatchが既定」と無条件に述べない(ADR 0129 Decision 3)。
|
|
372
|
+
const dispatchSentence = layout.independence === null
|
|
373
|
+
? 'ready frontierは全件同時dispatchが既定です。未登録の資源・host制約によりsubsetだけを選ぶ場合は理由を記録します。'
|
|
374
|
+
: '同時着手できるかはカードの並列可否で判断してください。未検査の工程は依存線が無くても並列可の根拠になりません。';
|
|
375
|
+
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</span>${independenceLegend}<span>太線: 構造上の最長依存鎖</span><span>半円: 非接触の線交差</span><span>黒丸: 論理上の合流</span>${foldChip}${categoryDetails}${foldNote}<p>縦方向は時間ではなく、登録済み依存関係による工程段階です。${dispatchSentence}構造上の最長依存鎖は各工程を同じ重みとして数え、実時間・工数・納期を表しません。</p></div>`;
|
|
295
376
|
}
|
|
296
377
|
|
|
297
378
|
const CSS = `
|
|
@@ -370,6 +451,11 @@ button.fold-chip[aria-expanded="true"]{border-color:var(--text-primary)}
|
|
|
370
451
|
.status-blocked .node-surface{fill:var(--surface-1);stroke:var(--critical);stroke-width:2}
|
|
371
452
|
.status-blocked .status-mark{fill:var(--critical)}
|
|
372
453
|
.next-ready-node .node-surface{stroke:var(--accent);stroke-width:2;stroke-dasharray:4 3}
|
|
454
|
+
/* 独立性は記号と色で示す。枠線はstatusとready frontierが使い切っている(ADR 0129)。 */
|
|
455
|
+
.independence-badge{font-size:10px;font-weight:600;letter-spacing:0.02em}
|
|
456
|
+
.independence-verified .independence-badge{fill:var(--good)}
|
|
457
|
+
.independence-conflict .independence-badge{fill:var(--critical)}
|
|
458
|
+
.independence-unknown .independence-badge{fill:var(--text-secondary)}
|
|
373
459
|
.todo-node:focus .node-surface,.selected-node .node-surface{stroke:var(--text-primary);stroke-width:2.5}
|
|
374
460
|
.dependency-edge .edge-route{fill:none;stroke:var(--text-secondary);stroke-width:1.5;stroke-linejoin:round;opacity:.4}
|
|
375
461
|
.dependency-edge .edge-arrow{fill:var(--text-secondary);opacity:.7}
|
|
@@ -376,6 +376,68 @@ function crossingCount(edges, wave, transversePosition) {
|
|
|
376
376
|
return total;
|
|
377
377
|
}
|
|
378
378
|
|
|
379
|
+
/**
|
|
380
|
+
* plan別の独立性投影を、task refで引ける索引と図の外が語る要約へ畳む(ADR 0129 Decision 4)。
|
|
381
|
+
*
|
|
382
|
+
* 読み出しはasync I/Oとgit実行を伴うためCLI層が行い、ここへは値として渡る。
|
|
383
|
+
* layoutは同期pureのままに保つ。
|
|
384
|
+
*/
|
|
385
|
+
function normalizeIndependence(value, nodesByKey) {
|
|
386
|
+
if (value === null || value === undefined) return { stateByKey: new Map(), summary: null };
|
|
387
|
+
if (!Array.isArray(value)) {
|
|
388
|
+
fail('TODO_LAYOUT_INVALID_INPUT', 'independence must be an array of plan projections');
|
|
389
|
+
}
|
|
390
|
+
const stateByKey = new Map();
|
|
391
|
+
const plans = [];
|
|
392
|
+
for (const projection of value) {
|
|
393
|
+
if (!plain(projection) || typeof projection.plan_key !== 'string'
|
|
394
|
+
|| typeof projection.coverage !== 'string' || !plain(projection.frontier)) {
|
|
395
|
+
fail('TODO_LAYOUT_INVALID_INPUT', 'independence entry must carry plan_key, coverage and frontier');
|
|
396
|
+
}
|
|
397
|
+
const { plan_key: planKey, frontier } = projection;
|
|
398
|
+
const mark = (taskId, state) => {
|
|
399
|
+
const key = refKey({
|
|
400
|
+
project_id: projection.project_id, plan_key: planKey, task_id: taskId,
|
|
401
|
+
});
|
|
402
|
+
// 図に無いtaskへ状態を付けない。layoutのready判定と投影のready判定が食い違えば、
|
|
403
|
+
// 静かに無視するのでなくここで露見させる。
|
|
404
|
+
if (!nodesByKey.has(key)) {
|
|
405
|
+
fail('TODO_LAYOUT_INDEPENDENCE_DRIFT', 'independence references a task absent from layout', {
|
|
406
|
+
plan_key: planKey, task_id: taskId,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
stateByKey.set(key, state);
|
|
410
|
+
};
|
|
411
|
+
for (const group of frontier.parallel_groups ?? []) {
|
|
412
|
+
for (const taskId of group.task_ids) mark(taskId, 'verified');
|
|
413
|
+
}
|
|
414
|
+
for (const pair of frontier.serialize_pairs ?? []) {
|
|
415
|
+
for (const taskId of pair.task_ids) mark(taskId, 'conflict');
|
|
416
|
+
}
|
|
417
|
+
for (const entry of frontier.conflicts_with_active ?? []) mark(entry.ready_task_id, 'conflict');
|
|
418
|
+
for (const entry of frontier.unknown ?? []) mark(entry.task_id, 'unknown');
|
|
419
|
+
plans.push({
|
|
420
|
+
plan_key: planKey,
|
|
421
|
+
coverage: projection.coverage,
|
|
422
|
+
verified_group_count: (frontier.parallel_groups ?? []).length,
|
|
423
|
+
verified_task_count: (frontier.parallel_groups ?? [])
|
|
424
|
+
.reduce((total, group) => total + group.task_ids.length, 0),
|
|
425
|
+
serialize_pairs: (frontier.serialize_pairs ?? []).map((pair) => ({
|
|
426
|
+
task_ids: [...pair.task_ids], type: pair.type, detail: pair.detail,
|
|
427
|
+
kind: pair.kind ?? null, severability: pair.severability ?? 'serial',
|
|
428
|
+
})),
|
|
429
|
+
conflicts_with_active: (frontier.conflicts_with_active ?? []).map((entry) => ({
|
|
430
|
+
ready_task_id: entry.ready_task_id, active_task_id: entry.active_task_id,
|
|
431
|
+
type: entry.type, detail: entry.detail,
|
|
432
|
+
kind: entry.kind ?? null, severability: entry.severability ?? 'serial',
|
|
433
|
+
})),
|
|
434
|
+
unknown_task_ids: (frontier.unknown ?? []).map(({ task_id: taskId }) => taskId).sort(compareText),
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
plans.sort((left, right) => compareText(left.plan_key, right.plan_key));
|
|
438
|
+
return { stateByKey, summary: { plans } };
|
|
439
|
+
}
|
|
440
|
+
|
|
379
441
|
export function layoutTodoGantt(readModel, chainProjection, options = {}) {
|
|
380
442
|
const scope = options.scope ?? 'live';
|
|
381
443
|
if (!TODO_GANTT_SCOPES.includes(scope)) {
|
|
@@ -404,6 +466,7 @@ export function layoutTodoGantt(readModel, chainProjection, options = {}) {
|
|
|
404
466
|
return key;
|
|
405
467
|
}));
|
|
406
468
|
const readyKeys = readyTaskKeys(readModel, full.nodes, full.nodesByKey, fullWaves.incoming);
|
|
469
|
+
const independence = normalizeIndependence(options.independence ?? null, full.nodesByKey);
|
|
407
470
|
|
|
408
471
|
// Only the geometry stage below sees the narrowed graph.
|
|
409
472
|
const projected = scope === 'all'
|
|
@@ -575,6 +638,9 @@ export function layoutTodoGantt(readModel, chainProjection, options = {}) {
|
|
|
575
638
|
longest_dependency_chain: longestNodeKeys.has(node.key),
|
|
576
639
|
active: node.status === 'in-progress', next_ready: readyKeys.has(node.key),
|
|
577
640
|
selected: false,
|
|
641
|
+
// 記録が語らないtaskはnull。「独立と分かっている」と「まだ何も言えない」を
|
|
642
|
+
// 図の上でも同じ顔にしない(ADR 0129 Decision 1)。
|
|
643
|
+
independence: independence.stateByKey.get(node.key) ?? null,
|
|
578
644
|
},
|
|
579
645
|
geometry,
|
|
580
646
|
};
|
|
@@ -699,7 +765,7 @@ export function layoutTodoGantt(readModel, chainProjection, options = {}) {
|
|
|
699
765
|
laneMap.get(key).task_count += 1;
|
|
700
766
|
}
|
|
701
767
|
return {
|
|
702
|
-
schema: 'lattice.todo_gantt_layout.
|
|
768
|
+
schema: 'lattice.todo_gantt_layout.v2',
|
|
703
769
|
assumptions: { logical_time: 'dependency_wave', duration_estimation: false, lane_is_presentation_only: true },
|
|
704
770
|
sweep: { method: 'stable_median', rounds: SWEEP_ROUNDS, tie_break: 'previous_position_then_task_ref' },
|
|
705
771
|
bounds: {
|
|
@@ -709,6 +775,8 @@ export function layoutTodoGantt(readModel, chainProjection, options = {}) {
|
|
|
709
775
|
wave_count: layers.length,
|
|
710
776
|
},
|
|
711
777
|
nodes: projectedNodes,
|
|
778
|
+
// 図の外が語るための投影。カードはバッジで状態だけを示し、相手と理由は右ペインが持つ。
|
|
779
|
+
independence: independence.summary,
|
|
712
780
|
edges: projectedEdges,
|
|
713
781
|
// Every dependency in the plan, before folding contracted any of them away.
|
|
714
782
|
// The diagram draws `edges`; anything that describes a ToDo in words — the
|
package/src/todo-gantt-svg.mjs
CHANGED
|
@@ -14,6 +14,19 @@ export const TODO_GANTT_STATUS_PRESENTATION = Object.freeze({
|
|
|
14
14
|
done: Object.freeze({ mark: '✅', label: '完了' }),
|
|
15
15
|
});
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* 独立性バッジの記号と和名(ADR 0129 Decision 1)。
|
|
19
|
+
*
|
|
20
|
+
* 枠線はstatusとready frontierが使い切っているため、カード内の記号と色で示す。
|
|
21
|
+
* 記録が語らないtaskにはバッジを出さない——「独立と分かっている」と「まだ何も言えない」を
|
|
22
|
+
* 同じ見た目にしない。
|
|
23
|
+
*/
|
|
24
|
+
export const TODO_GANTT_INDEPENDENCE_PRESENTATION = Object.freeze({
|
|
25
|
+
verified: Object.freeze({ mark: '∥', label: '独立検証済', class: 'independence-verified' }),
|
|
26
|
+
conflict: Object.freeze({ mark: '⛓', label: '要直列', class: 'independence-conflict' }),
|
|
27
|
+
unknown: Object.freeze({ mark: '?', label: '未検査', class: 'independence-unknown' }),
|
|
28
|
+
});
|
|
29
|
+
|
|
17
30
|
export function escapeSvgText(value) {
|
|
18
31
|
return String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
|
19
32
|
}
|
|
@@ -145,6 +158,8 @@ function renderNode(node, maps) {
|
|
|
145
158
|
if (node.visibility.active) classes.push('active-node');
|
|
146
159
|
if (node.visibility.next_ready) classes.push('next-ready-node');
|
|
147
160
|
if (node.visibility.selected) classes.push('selected-node');
|
|
161
|
+
const independence = TODO_GANTT_INDEPENDENCE_PRESENTATION[node.visibility.independence] ?? null;
|
|
162
|
+
if (independence !== null) classes.push(independence.class);
|
|
148
163
|
const key = nodeKey(node.ref);
|
|
149
164
|
const nodeLaneKey = laneKey(node.ref.plan_key, node.lane);
|
|
150
165
|
const status = TODO_GANTT_STATUS_PRESENTATION[node.status] ?? { mark: '?', label: '状態不明' };
|
|
@@ -156,15 +171,19 @@ function renderNode(node, maps) {
|
|
|
156
171
|
const spokenReference = taskNumber === undefined
|
|
157
172
|
? `ID ${node.ref.task_id}` : `工程${taskNumber.display_number}`;
|
|
158
173
|
const readyLabel = node.visibility.next_ready ? '。ready frontierの同時dispatch候補' : '';
|
|
174
|
+
const independenceLabel = independence === null ? '' : `。並列可否は${independence.label}`;
|
|
159
175
|
const identity = `正規ID ${node.ref.plan_key}/${node.ref.task_id}`;
|
|
160
|
-
const ariaLabel = `${spokenReference}。${status.label}。${laneLabel}。${node.title}。${identity}${readyLabel}`;
|
|
176
|
+
const ariaLabel = `${spokenReference}。${status.label}。${laneLabel}。${node.title}。${identity}${readyLabel}${independenceLabel}`;
|
|
177
|
+
// カードの右上へ寄せる。node_width/node_heightは変えないので配線規約に影響しない。
|
|
178
|
+
const independenceBadge = independence === null ? ''
|
|
179
|
+
: `<text class="independence-badge" x="${x + width - 10}" y="${y + 20}" text-anchor="end">${escapeSvgText(`${independence.mark} ${independence.label}`)}</text>`;
|
|
161
180
|
const statusBar = node.status === 'in-progress'
|
|
162
181
|
? `<line class="status-bar" x1="${x + 5}" y1="${y + 6}" x2="${x + 5}" y2="${y + height - 6}"></line>` : '';
|
|
163
182
|
const titleLines = wrapLabel(node.title);
|
|
164
183
|
const titleMarkup = titleLines.map((line, index) => `<tspan x="${x + 10}" dy="${index === 0 ? 0 : 17}" class="node-title-line">${escapeSvgText(line)}</tspan>`).join('');
|
|
165
184
|
const taskNumberAttributes = taskNumber === undefined ? ''
|
|
166
185
|
: ` data-task-number="${escapeSvgAttribute(taskNumber.display_number)}" data-task-number-normalized="${escapeSvgAttribute(taskNumber.normalized_number)}" data-task-number-globally-unique="${taskNumber.globally_unique ? 'true' : 'false'}"`;
|
|
167
|
-
return `<g class="${classes.join(' ')}" data-node-key="${escapeSvgAttribute(key)}" data-lane-key="${escapeSvgAttribute(nodeLaneKey)}" data-project-id="${escapeSvgAttribute(node.ref.project_id)}" data-plan-key="${escapeSvgAttribute(node.ref.plan_key)}" data-task-id="${escapeSvgAttribute(node.ref.task_id)}"${taskNumberAttributes} tabindex="0" role="button" aria-selected="${node.visibility.selected ? 'true' : 'false'}" aria-label="${escapeSvgAttribute(ariaLabel)}"><rect class="node-surface" x="${x}" y="${y}" width="${width}" height="${height}" rx="4"></rect>${statusBar}<text class="status-mark" x="${x + 10}" y="${y + 21}">${escapeSvgText(status.mark)}</text><text class="node-meta" x="${x + 34}" y="${y + 20}">${escapeSvgText(`${status.label} · ${visibleReference}`)}</text
|
|
186
|
+
return `<g class="${classes.join(' ')}" data-node-key="${escapeSvgAttribute(key)}" data-lane-key="${escapeSvgAttribute(nodeLaneKey)}" data-project-id="${escapeSvgAttribute(node.ref.project_id)}" data-plan-key="${escapeSvgAttribute(node.ref.plan_key)}" data-task-id="${escapeSvgAttribute(node.ref.task_id)}"${taskNumberAttributes} tabindex="0" role="button" aria-selected="${node.visibility.selected ? 'true' : 'false'}" aria-label="${escapeSvgAttribute(ariaLabel)}"><rect class="node-surface" x="${x}" y="${y}" width="${width}" height="${height}" rx="4"></rect>${statusBar}<text class="status-mark" x="${x + 10}" y="${y + 21}">${escapeSvgText(status.mark)}</text><text class="node-meta" x="${x + 34}" y="${y + 20}">${escapeSvgText(`${status.label} · ${visibleReference}`)}</text>${independenceBadge}<text class="node-title" x="${x + 10}" y="${y + 42}">${titleMarkup}</text><title>${escapeSvgText(`${spokenReference}: ${node.title} — ${status.label} — ${laneLabel} — ${identity}`)}</title></g>`;
|
|
168
187
|
}
|
|
169
188
|
|
|
170
189
|
function summaryLabel(value, maximum = 34) {
|
|
@@ -217,9 +236,9 @@ function renderTodoSummary(layout, maps) {
|
|
|
217
236
|
}
|
|
218
237
|
|
|
219
238
|
export function renderTodoGanttSvg(layout, options = {}) {
|
|
220
|
-
if (layout === null || typeof layout !== 'object' || layout.schema !== 'lattice.todo_gantt_layout.
|
|
239
|
+
if (layout === null || typeof layout !== 'object' || layout.schema !== 'lattice.todo_gantt_layout.v2'
|
|
221
240
|
|| !Array.isArray(layout.nodes) || !Array.isArray(layout.edges)) {
|
|
222
|
-
throw new TypeError('layout must be lattice.todo_gantt_layout.
|
|
241
|
+
throw new TypeError('layout must be lattice.todo_gantt_layout.v2');
|
|
223
242
|
}
|
|
224
243
|
const maps = presentationMaps(options.presentation);
|
|
225
244
|
const summary = renderTodoSummary(layout, maps);
|