@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.
@@ -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.v16';
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 dispatchSummary = ready.length > 1
246
- ? `<p class="readiness-note"><strong>同時dispatch推奨:</strong> ${ready.length}工程。ready frontier全件が既定です。一部だけを直列着手する場合は理由が必要です。</p>`
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工程と同時dispatchするのが既定です。subsetだけを選ぶ場合は理由を記録してください。' : '現在の唯一の着手候補です。'}</p>`
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
- 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>`;
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.v1',
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
@@ -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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
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><text class="node-title" x="${x + 10}" y="${y + 42}">${titleMarkup}</text><title>${escapeSvgText(`${spokenReference}: ${node.title} — ${status.label} — ${laneLabel} — ${identity}`)}</title></g>`;
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.v1'
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.v1');
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);