@quolu/lattice 0.12.15 → 0.12.17

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.15",
3
+ "version": "0.12.17",
4
4
  "description": "Lattice — phase-aware TODO graph compiler and conflict-aware orchestration runtime",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/todo-cli.mjs CHANGED
@@ -653,6 +653,10 @@ export async function renderTodoGanttForProject({
653
653
  const topology = mergedTopology(store);
654
654
  const chain = projectTodoChainV1(topology);
655
655
  const layout = layoutTodoGantt(store, chain, { scope });
656
+ // When the diagram hides history, the page also carries the full diagram so
657
+ // the reader can bring it back in place. Nothing is hidden under `all`.
658
+ const expandedLayout = layout.scope.folded_task_count === 0
659
+ ? null : layoutTodoGantt(store, chain, { scope: 'all' });
656
660
  const narrative = await loadNarratives(store, repoRoot);
657
661
  const anchorOutcomes = verifyNarrativeAnchors({
658
662
  readModel: store,
@@ -685,6 +689,7 @@ export async function renderTodoGanttForProject({
685
689
  const rendered = renderTodoGanttHtml({
686
690
  readModel: store,
687
691
  layout,
692
+ expandedLayout,
688
693
  narratives: narrative.narratives,
689
694
  anchorOutcomes,
690
695
  presentation,
@@ -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.v12';
9
+ export const TODO_GANTT_RENDERER_VERSION = 'lattice.todo_gantt_renderer.v14';
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
 
@@ -285,14 +285,21 @@ function renderRightPane(sections, layout, presentation, readModel) {
285
285
  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>`;
286
286
  }
287
287
 
288
- function renderDiagramLegend(presentation, layout = null) {
288
+ function renderDiagramLegend(presentation, layout = null, expandable = false) {
289
289
  const categories = (presentation?.lanes ?? []).map((lane) => `<div class="category-entry"><dt><code>${escapeHtmlText(lane.lane)}</code> — ${escapeHtmlText(lane.name)}</dt><dd>${escapeHtmlText(lane.description)}</dd></div>`).join('');
290
290
  const categoryDetails = categories === '' ? '' : `<details class="category-legend"><summary>カテゴリ説明</summary><dl>${categories}</dl></details>`;
291
291
  const foldedCount = layout?.scope?.folded_task_count ?? 0;
292
+ // The badge says what is missing from the diagram, so it is also the control
293
+ // that brings it back — a reader who notices the count is exactly the reader
294
+ // who wants to see it.
292
295
  const foldChip = foldedCount === 0 ? ''
293
- : `<span class="fold-chip">完走済み ${foldedCount}件を非表示</span>`;
296
+ : expandable
297
+ ? `<button type="button" class="fold-chip" data-toggle-expanded aria-expanded="false"><span data-toggle-label data-collapsed-label="完走済み ${foldedCount}件を非表示(押すと表示)" data-expanded-label="完走済み ${foldedCount}件を表示中(押すと非表示)">完走済み ${foldedCount}件を非表示(押すと表示)</span></button>`
298
+ : `<span class="fold-chip">完走済み ${foldedCount}件を非表示</span>`;
294
299
  const foldNote = foldedCount === 0 ? ''
295
- : '<p class="fold-note">後続に作業中・未着手が残っていない完了工程は図から外しています。生きた工程とその直接の前提工程は必ず描きます。外した工程は右の「全工程」から辿れ、図に出すには <code>lattice todo gantt --scope all</code> を実行してください。総数・進捗・最長依存鎖は外す前の全工程で数えています。</p>';
300
+ : expandable
301
+ ? '<p class="fold-note">後続に作業中・未着手が残っていない完了工程は図から外しています。生きた工程とその直接の前提工程は必ず描きます。上のバッジを押すと外した工程も含めて描きます。総数・進捗・最長依存鎖は外す前の全工程で数えています。</p>'
302
+ : '<p class="fold-note">後続に作業中・未着手が残っていない完了工程は図から外しています。生きた工程とその直接の前提工程は必ず描きます。外した工程は右の「全工程」から辿れ、図に出すには <code>lattice todo gantt --scope all</code> を実行してください。総数・進捗・最長依存鎖は外す前の全工程で数えています。</p>';
296
303
  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>`;
297
304
  }
298
305
 
@@ -356,7 +363,10 @@ body{display:grid;grid-template-rows:minmax(0,1fr);height:100vh;margin:0;backgro
356
363
  .todo-node .node-title{fill:var(--text-primary);font-size:13.5px;font-weight:400}
357
364
  .todo-node .node-title-line{font-size:13.5px;font-weight:400}
358
365
  .todo-node .status-mark{fill:var(--text-secondary);font-size:13.5px;font-weight:400}
359
- .fold-chip{padding:2px 8px;border:1px solid var(--border);border-radius:9999px;background:var(--surface-2);color:var(--text-primary);font-weight:650}
366
+ .fold-chip{padding:2px 8px;border:1px solid var(--border);border-radius:9999px;background:var(--surface-2);color:var(--text-primary);font:650 13.5px/1.6 system-ui,-apple-system,"Hiragino Sans","Yu Gothic UI",sans-serif}
367
+ button.fold-chip{cursor:pointer}button.fold-chip:focus-visible{outline:2px solid var(--text-primary);outline-offset:2px}
368
+ button.fold-chip[aria-expanded="true"]{border-color:var(--text-primary)}
369
+ [data-diagram][hidden]{display:none}
360
370
  .fold-note{flex:1 0 100%;margin:4px 0 0;color:var(--text-secondary);font-weight:400}
361
371
  .task-index-folded{margin-top:8px}
362
372
  .task-index-folded>summary,.phase-settled>summary{cursor:pointer;padding:6px 0;color:var(--text-secondary);font-weight:600}
@@ -398,18 +408,23 @@ const CONTROLLER = `
398
408
  const nodes=[...root.querySelectorAll('[data-node-key]')];
399
409
  const edges=[...root.querySelectorAll('[data-edge-id]')];
400
410
  const laneChips=[...root.querySelectorAll('.summary-lane[data-lane-key]')];
401
- const svg=root.querySelector('[data-gantt-svg]');
411
+ const diagrams=[...root.querySelectorAll('[data-diagram]')];
412
+ const expandToggle=root.querySelector('[data-toggle-expanded]');
413
+ const toggleLabel=root.querySelector('[data-toggle-label]');
402
414
  const scroller=root.querySelector('[data-diagram-scroll]');
403
415
  const zoomOutput=root.querySelector('[data-zoom-output]');
404
- const baseWidth=Number(svg?.dataset.svgWidth??0);
405
- const baseHeight=Number(svg?.dataset.svgHeight??0);
406
- let zoom=1;let activeLaneKey=null;let selectedKey=null;let resizePointerId=null;
416
+ let svg=root.querySelector('[data-diagram]:not([hidden]) [data-gantt-svg]')??root.querySelector('[data-gantt-svg]');
417
+ let baseWidth=Number(svg?.dataset.svgWidth??0);
418
+ let baseHeight=Number(svg?.dataset.svgHeight??0);
419
+ let zoom=1;let activeLaneKey=null;let selectedKey=null;let resizePointerId=null;let expanded=false;
407
420
  const stacked=()=>window.matchMedia('(max-width:900px)').matches;
408
421
  const setSplit=(clientX)=>{if(!shell||stacked())return;const bounds=shell.getBoundingClientRect();if(bounds.width<=0)return;const percent=Math.max(30,Math.min(75,(clientX-bounds.left)/bounds.width*100));shell.style.setProperty('--split',percent+'%');};
409
422
  const finishResize=(event)=>{if(event.pointerId!==resizePointerId)return;if(divider?.hasPointerCapture(event.pointerId))divider.releasePointerCapture(event.pointerId);resizePointerId=null;};
410
423
  const setZoom=(value,minimum=.2)=>{if(!svg||!Number.isFinite(value)||value<=0)return;zoom=Math.max(minimum,Math.min(2,value));svg.setAttribute('width',String(Math.max(1,Math.round(baseWidth*zoom))));svg.setAttribute('height',String(Math.max(1,Math.round(baseHeight*zoom))));if(zoomOutput){const percent=zoom>=.1?String(Math.round(zoom*100)):String(Number((zoom*100).toFixed(1)));zoomOutput.textContent=percent+'%';}};
411
424
  const applyLane=(key)=>{activeLaneKey=key;for(const chip of laneChips)chip.setAttribute('aria-pressed',String(chip.dataset.laneKey===key));for(const node of nodes)node.classList.toggle('lane-dimmed',key!==null&&node.dataset.laneKey!==key);for(const edge of edges)edge.classList.toggle('lane-dimmed',key!==null&&edge.dataset.fromLaneKey!==key&&edge.dataset.toLaneKey!==key);};
412
425
  const toggleLane=(key)=>applyLane(activeLaneKey===key?null:key);
426
+ // Both diagrams ship in the page; the badge picks which one is on screen.
427
+ const setExpanded=(next)=>{if(diagrams.length<2)return;expanded=next;for(const diagram of diagrams)diagram.hidden=(diagram.dataset.diagram==='expanded')!==expanded;svg=diagrams.find(diagram=>!diagram.hidden)?.querySelector('[data-gantt-svg]')??svg;baseWidth=Number(svg?.dataset.svgWidth??0);baseHeight=Number(svg?.dataset.svgHeight??0);if(expandToggle){expandToggle.setAttribute('aria-expanded',String(expanded));}if(toggleLabel){toggleLabel.textContent=expanded?toggleLabel.dataset.expandedLabel:toggleLabel.dataset.collapsedLabel;}setZoom(zoom);applyLane(activeLaneKey);syncSelection();scroller?.scrollTo(0,0);};
413
428
  const showPanel=(name)=>{if(overviewPanel)overviewPanel.hidden=name!=='overview';if(detailsPanel)detailsPanel.hidden=name!=='details';if(taskIndexPanel)taskIndexPanel.hidden=name!=='task-index';if(selectedReturnButton)selectedReturnButton.hidden=name!=='task-index'||selectedKey===null;root.dataset.viewState=name;};
414
429
  const syncSelection=()=>{for(const detail of detailPanels)detail.hidden=detail.dataset.detailKey!==selectedKey;for(const node of nodes){const selected=node.dataset.nodeKey===selectedKey;node.setAttribute('aria-selected',String(selected));node.classList.toggle('selected-node',selected);}for(const edge of edges){const selected=selectedKey!==null;edge.classList.toggle('selected-incident-edge',selected&&(edge.dataset.fromNodeKey===selectedKey||edge.dataset.toNodeKey===selectedKey));}};
415
430
  const showOverview=()=>{selectedKey=null;syncSelection();showPanel('overview');};
@@ -420,6 +435,7 @@ const CONTROLLER = `
420
435
  const overviewButton=event.target.closest('[data-show-overview]');if(overviewButton&&root.contains(overviewButton)){showOverview();return;}
421
436
  const selectedButton=event.target.closest('[data-show-selected]');if(selectedButton&&root.contains(selectedButton)){showSelected();return;}
422
437
  const taskIndexButton=event.target.closest('[data-show-task-index]');if(taskIndexButton&&root.contains(taskIndexButton)){showTaskIndex();return;}
438
+ const expandButton=event.target.closest('[data-toggle-expanded]');if(expandButton&&root.contains(expandButton)){setExpanded(!expanded);return;}
423
439
  const selectButton=event.target.closest('[data-select-node-key]');if(selectButton&&root.contains(selectButton)){select(selectButton.dataset.selectNodeKey);return;}
424
440
  const zoomButton=event.target.closest('[data-zoom-action]');if(zoomButton&&root.contains(zoomButton)){const action=zoomButton.dataset.zoomAction;if(action==='in')setZoom(zoom<1&&zoom*1.25>=1?1:zoom*1.25,.001);else if(action==='out')setZoom(zoom>1&&zoom/1.25<=1?1:zoom/1.25,.001);else if(action==='reset')setZoom(1);else if(action==='fit'&&scroller){setZoom(Math.min(1,(scroller.clientWidth-16)/baseWidth),.001);scroller.scrollTo(0,0);}return;}
425
441
  const laneChip=event.target.closest('.summary-lane[data-lane-key]');if(laneChip&&root.contains(laneChip)){toggleLane(laneChip.dataset.laneKey);return;}
@@ -436,6 +452,7 @@ const CONTROLLER = `
436
452
 
437
453
  export function renderTodoGanttHtml({
438
454
  readModel, layout, narratives = [], anchorOutcomes = [], presentation = null, metadata = {},
455
+ expandedLayout = null,
439
456
  }) {
440
457
  if (readModel?.schema !== 'lattice.todo_store_read.v1' || !Array.isArray(readModel.members)) {
441
458
  throw new TypeError('readModel must be lattice.todo_store_read.v1');
@@ -450,13 +467,19 @@ export function renderTodoGanttHtml({
450
467
  const normalized = normalizeSections(readModel, narratives, anchorOutcomes);
451
468
  const displayName = projectDisplayName(readModel, metadata);
452
469
  const svg = renderTodoGanttSvg(layout, { presentation });
470
+ // The expanded diagram travels with the page so the badge can bring the
471
+ // history back without a round trip. A file:// artifact has nowhere to ask.
472
+ const expandedSvg = expandedLayout === null ? '' : renderTodoGanttSvg(expandedLayout, { presentation });
473
+ const diagrams = expandedSvg === ''
474
+ ? `<div data-diagram="live">${svg}</div>`
475
+ : `<div data-diagram="live">${svg}</div><div data-diagram="expanded" hidden>${expandedSvg}</div>`;
453
476
  const rightPane = renderRightPane(normalized.sections, layout, presentation, readModel);
454
477
  const staticData = serializeJsonForScript({
455
478
  renderer_version: TODO_GANTT_RENDERER_VERSION,
456
479
  metadata,
457
480
  presentation,
458
481
  });
459
- const html = `<!doctype html><html lang="ja"><head><meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Lattice — ${escapeHtmlText(displayName)} 依存工程図</title><style>${CSS}</style></head><body data-gantt-root data-view-state="overview"><main class="shell"><section class="gantt-pane" aria-label="${escapeHtmlAttribute(displayName)} 依存工程図"><div class="diagram-toolbar" role="group" aria-label="図のズーム"><strong class="project-heading">${escapeHtmlText(displayName)} 依存工程図</strong><button type="button" data-zoom-action="out" aria-label="縮小">−</button><button type="button" data-zoom-action="reset">等倍</button><button type="button" data-zoom-action="in" aria-label="拡大">+</button><button type="button" data-zoom-action="fit">全体表示</button><output class="zoom-readout" data-zoom-output aria-live="polite">100%</output><span class="diagram-note">縦=依存段階(時間ではない)</span></div>${renderDiagramLegend(presentation, layout)}<div class="diagram-scroll" data-diagram-scroll tabindex="0" aria-label="縦方向を主にスクロール可能な依存工程図">${svg}</div></section><div class="pane-divider" data-pane-divider aria-hidden="true"></div><aside class="narrative-pane" aria-label="選択工程の詳細と全工程一覧">${rightPane}</aside></main><script type="application/json" id="todo-gantt-data">${staticData}</script><script>${CONTROLLER}</script></body></html>`;
482
+ const html = `<!doctype html><html lang="ja"><head><meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Lattice — ${escapeHtmlText(displayName)} 依存工程図</title><style>${CSS}</style></head><body data-gantt-root data-view-state="overview"><main class="shell"><section class="gantt-pane" aria-label="${escapeHtmlAttribute(displayName)} 依存工程図"><div class="diagram-toolbar" role="group" aria-label="図のズーム"><strong class="project-heading">${escapeHtmlText(displayName)} 依存工程図</strong><button type="button" data-zoom-action="out" aria-label="縮小">−</button><button type="button" data-zoom-action="reset">等倍</button><button type="button" data-zoom-action="in" aria-label="拡大">+</button><button type="button" data-zoom-action="fit">全体表示</button><output class="zoom-readout" data-zoom-output aria-live="polite">100%</output><span class="diagram-note">縦=依存段階(時間ではない)</span></div>${renderDiagramLegend(presentation, layout, expandedSvg !== '')}<div class="diagram-scroll" data-diagram-scroll tabindex="0" aria-label="縦方向を主にスクロール可能な依存工程図">${diagrams}</div></section><div class="pane-divider" data-pane-divider aria-hidden="true"></div><aside class="narrative-pane" aria-label="選択工程の詳細と全工程一覧">${rightPane}</aside></main><script type="application/json" id="todo-gantt-data">${staticData}</script><script>${CONTROLLER}</script></body></html>`;
460
483
  const htmlBytes = Buffer.byteLength(html, 'utf8');
461
484
  if (htmlBytes > TODO_GANTT_HTML_MAX_BYTES) {
462
485
  throw new TodoGanttRenderError('TODO_SCALE_EXCEEDED', 'todo gantt HTML limit exceeded', {
@@ -4,6 +4,9 @@ const TASK_LIMIT = 2_000;
4
4
  const EDGE_LIMIT = 8_000;
5
5
  const SWEEP_ROUNDS = 4;
6
6
 
7
+ /** Columns for a stage made only of ToDos with no registered dependency. */
8
+ const LOOSE_COLUMNS_MINIMUM = 8;
9
+
7
10
  const GEOMETRY = Object.freeze({
8
11
  left: 16,
9
12
  top: 16,
@@ -11,6 +14,7 @@ const GEOMETRY = Object.freeze({
11
14
  lane_gap: 296,
12
15
  node_width: 272,
13
16
  node_height: 68,
17
+ stage_row_gap: 12,
14
18
  route_inset: 8,
15
19
  route_spacing: 12,
16
20
  });
@@ -410,9 +414,27 @@ export function layoutTodoGantt(readModel, chainProjection, options = {}) {
410
414
  const nodesByKey = new Map(nodes.map((node) => [node.key, node]));
411
415
  const { incoming, outgoing, wave } = assignWaves(nodes, nodesByKey, edges);
412
416
  const layers = orderLayers(nodes, wave, incoming, outgoing);
417
+ // A ToDo with no registered dependency has nothing to line up with. Laying
418
+ // every one of them out in a single row makes the canvas as wide as the plan
419
+ // is old — real stores have hundreds — so they wrap into a block underneath
420
+ // the wired ToDos of their stage. Nodes that carry edges keep the top row:
421
+ // edge routing assumes both endpoints of a stage share one baseline.
422
+ const wired = new Set();
423
+ for (const edge of edges) { wired.add(edge.from); wired.add(edge.to); }
413
424
  const transversePosition = new Map();
425
+ const stageRow = new Map();
426
+ const stageRowCount = [];
414
427
  for (const layer of layers) {
415
- for (let index = 0; index < layer.length; index += 1) transversePosition.set(layer[index], index);
428
+ const connected = layer.filter((key) => wired.has(key));
429
+ const loose = layer.filter((key) => !wired.has(key));
430
+ connected.forEach((key, index) => { transversePosition.set(key, index); stageRow.set(key, 0); });
431
+ const columns = Math.max(LOOSE_COLUMNS_MINIMUM, connected.length);
432
+ const firstLooseRow = connected.length === 0 ? 0 : 1;
433
+ loose.forEach((key, index) => {
434
+ transversePosition.set(key, index % columns);
435
+ stageRow.set(key, firstLooseRow + Math.floor(index / columns));
436
+ });
437
+ stageRowCount.push(Math.max(0, ...layer.map((key) => stageRow.get(key))) + 1);
416
438
  }
417
439
  const visibleKeys = new Set(nodes.map(({ key }) => key));
418
440
  const displayBranches = edges.flatMap((edge, semanticIndex) => {
@@ -458,15 +480,25 @@ export function layoutTodoGantt(readModel, chainProjection, options = {}) {
458
480
  const nodeWidth = Math.max(GEOMETRY.node_width,
459
481
  GEOMETRY.route_inset * 2 + (maximumPortTraffic + 1) * GEOMETRY.route_spacing);
460
482
  const laneGap = nodeWidth + 24;
461
- const waveGap = GEOMETRY.node_height + Math.max(36,
462
- (maximumGapOccupancy + 1) * GEOMETRY.route_spacing);
483
+ // Each routing band is sized for the edges that actually cross it. One
484
+ // crowded band used to set the spacing for every stage in the diagram, so a
485
+ // long plan paid the width of its busiest junction on all of its stages.
486
+ const gapBand = (gap) => Math.max(36, ((gapGroups.get(gap)?.length ?? 0)
487
+ + (connectorGroupKeysByGap.get(gap)?.size ?? 0) + 1) * GEOMETRY.route_spacing);
488
+ const stageHeight = (index) => stageRowCount[index] * GEOMETRY.node_height
489
+ + (stageRowCount[index] - 1) * GEOMETRY.stage_row_gap;
490
+ const stageTop = [GEOMETRY.top];
491
+ for (let index = 0; index + 1 < layers.length; index += 1) {
492
+ stageTop.push(stageTop[index] + stageHeight(index) + gapBand(index));
493
+ }
463
494
 
464
495
  const coordinates = new Map();
465
496
  const projectedNodes = nodes.map((node) => {
466
497
  const visible = visibleKeys.has(node.key);
467
498
  const geometry = visible ? {
468
499
  x: GEOMETRY.left + transversePosition.get(node.key) * laneGap,
469
- y: GEOMETRY.top + wave.get(node.key) * waveGap,
500
+ y: stageTop[wave.get(node.key)]
501
+ + stageRow.get(node.key) * (GEOMETRY.node_height + GEOMETRY.stage_row_gap),
470
502
  width: nodeWidth,
471
503
  height: GEOMETRY.node_height,
472
504
  } : null;
@@ -486,10 +518,11 @@ export function layoutTodoGantt(readModel, chainProjection, options = {}) {
486
518
  const gapPosition = (edgeKey, waveIndex) => {
487
519
  const keys = gapGroups.get(waveIndex);
488
520
  const index = keys.indexOf(edgeKey);
489
- return GEOMETRY.top + waveIndex * waveGap + GEOMETRY.node_height
521
+ return stageTop[waveIndex] + stageHeight(waveIndex)
490
522
  + GEOMETRY.route_spacing * (index + 1);
491
523
  };
492
- const maximumLayerWidth = Math.max(...layers.map((layer) => layer.length));
524
+ const maximumLayerWidth = Math.max(1,
525
+ ...[...transversePosition.values()].map((position) => position + 1));
493
526
  const routeRight = GEOMETRY.left + (maximumLayerWidth - 1) * laneGap + nodeWidth + 12;
494
527
  const branchCounts = new Map();
495
528
  for (const branch of displayBranches) {
@@ -551,7 +584,7 @@ export function layoutTodoGantt(readModel, chainProjection, options = {}) {
551
584
  connectorGapRanks.set(targetGap, groupRank + 1);
552
585
  const primaryTargetPortX = group[0].route.at(-1)[0];
553
586
  const junction = [primaryTargetPortX,
554
- GEOMETRY.top + targetGap * waveGap + GEOMETRY.node_height
587
+ stageTop[targetGap] + stageHeight(targetGap)
555
588
  + GEOMETRY.route_spacing * ((gapGroups.get(targetGap)?.length ?? 0) + groupRank + 1)];
556
589
  const contacts = [];
557
590
  for (const [index, edge] of group.entries()) {
@@ -602,8 +635,8 @@ export function layoutTodoGantt(readModel, chainProjection, options = {}) {
602
635
  sweep: { method: 'stable_median', rounds: SWEEP_ROUNDS, tie_break: 'previous_position_then_task_ref' },
603
636
  bounds: {
604
637
  width: nodes.length === 0 ? 0 : routeMaximumX + GEOMETRY.left,
605
- height: nodes.length === 0 ? 0 : GEOMETRY.top * 2
606
- + (layers.length - 1) * waveGap + GEOMETRY.node_height,
638
+ height: nodes.length === 0 ? 0
639
+ : GEOMETRY.top + stageTop.at(-1) + stageHeight(layers.length - 1),
607
640
  wave_count: layers.length,
608
641
  },
609
642
  nodes: projectedNodes,