@quolu/lattice 0.12.14 → 0.12.16
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-cli.mjs +5 -0
- package/src/todo-gantt-html.mjs +50 -14
package/package.json
CHANGED
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,
|
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.v13';
|
|
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
|
|
|
@@ -190,8 +190,12 @@ function renderRelationList(relations, sectionByKey, lookup, emptyText, folds =
|
|
|
190
190
|
}).join('')}</ul>`;
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
+
/** Phase states that are over: nothing is dispatched or judged under them again. */
|
|
194
|
+
const SETTLED_PHASE_STATUS = Object.freeze(['accepted', 'rejected']);
|
|
195
|
+
|
|
193
196
|
function renderPhaseProgress(readModel) {
|
|
194
197
|
const rows = [];
|
|
198
|
+
const settledRows = [];
|
|
195
199
|
for (const member of readModel.members) {
|
|
196
200
|
if (!['lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(member.plan.schema)) continue;
|
|
197
201
|
const phases = new Map(member.snapshot.phases.map((phase) => [phase.phase_id, phase]));
|
|
@@ -200,15 +204,23 @@ function renderPhaseProgress(readModel) {
|
|
|
200
204
|
const states = new Map(member.tasks.map((task) => [task.task_id, task.status]));
|
|
201
205
|
const done = tasks.filter((task) => states.get(task.task_id) === 'done').length;
|
|
202
206
|
const state = phases.get(phase.phase_id);
|
|
203
|
-
|
|
207
|
+
const row = `<li class="phase-progress status-${escapeHtmlAttribute(state.status)}"><header><strong>${escapeHtmlText(phase.title ?? phase.phase_id)}</strong><span>${escapeHtmlText(state.status)}</span></header><p><code>${escapeHtmlText(`${member.plan.plan_key}/${phase.phase_id}`)}</code> — policy <code>${escapeHtmlText(phase.gate_policy)}</code> — ToDo ${done}/${tasks.length}</p><progress max="${tasks.length}" value="${done}">${done}/${tasks.length}</progress></li>`;
|
|
208
|
+
// A settled Phase is history. It stays reachable, but it does not push the
|
|
209
|
+
// live ones off the first screen.
|
|
210
|
+
(SETTLED_PHASE_STATUS.includes(state.status) ? settledRows : rows).push(row);
|
|
204
211
|
}
|
|
205
212
|
}
|
|
206
213
|
const decoupled = readModel.members.some(({ plan }) => plan.schema === 'lattice.todo_plan.v5');
|
|
207
214
|
const guidance = decoupled
|
|
208
215
|
? 'ToDo完了とPhase受理は別です。Phaseは重監査の順序を表し、通常ToDoの開始順はToDo依存だけで決まります。'
|
|
209
216
|
: 'ToDo完了とPhase受理は別です。<code>gate_ready</code>では後続Phaseはまだ解放されません。';
|
|
210
|
-
|
|
211
|
-
|
|
217
|
+
if (rows.length === 0 && settledRows.length === 0) return '';
|
|
218
|
+
const liveList = rows.length === 0
|
|
219
|
+
? '<p class="readiness-note">進行中のPhaseはありません。</p>'
|
|
220
|
+
: `<ol>${rows.join('')}</ol>`;
|
|
221
|
+
const settledList = settledRows.length === 0 ? ''
|
|
222
|
+
: `<details class="phase-settled"><summary>決着済みPhase ${settledRows.length}件</summary><ol>${settledRows.join('')}</ol></details>`;
|
|
223
|
+
return `<section class="phase-overview"><h2>Phase進捗</h2><p>${guidance}</p>${liveList}${settledList}</section>`;
|
|
212
224
|
}
|
|
213
225
|
|
|
214
226
|
function renderRightPane(sections, layout, presentation, readModel) {
|
|
@@ -273,14 +285,21 @@ function renderRightPane(sections, layout, presentation, readModel) {
|
|
|
273
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>`;
|
|
274
286
|
}
|
|
275
287
|
|
|
276
|
-
function renderDiagramLegend(presentation, layout = null) {
|
|
288
|
+
function renderDiagramLegend(presentation, layout = null, expandable = false) {
|
|
277
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('');
|
|
278
290
|
const categoryDetails = categories === '' ? '' : `<details class="category-legend"><summary>カテゴリ説明</summary><dl>${categories}</dl></details>`;
|
|
279
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.
|
|
280
295
|
const foldChip = foldedCount === 0 ? ''
|
|
281
|
-
:
|
|
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>`;
|
|
282
299
|
const foldNote = foldedCount === 0 ? ''
|
|
283
|
-
:
|
|
300
|
+
: expandable
|
|
301
|
+
? '<p class="fold-note">後続に作業中・未着手が残っていない完了工程は図から外しています。生きた工程とその直接の前提工程は必ず描きます。上のバッジを押すと外した工程も含めて描きます。総数・進捗・最長依存鎖は外す前の全工程で数えています。</p>'
|
|
302
|
+
: '<p class="fold-note">後続に作業中・未着手が残っていない完了工程は図から外しています。生きた工程とその直接の前提工程は必ず描きます。外した工程は右の「全工程」から辿れ、図に出すには <code>lattice todo gantt --scope all</code> を実行してください。総数・進捗・最長依存鎖は外す前の全工程で数えています。</p>';
|
|
284
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>`;
|
|
285
304
|
}
|
|
286
305
|
|
|
@@ -344,10 +363,14 @@ body{display:grid;grid-template-rows:minmax(0,1fr);height:100vh;margin:0;backgro
|
|
|
344
363
|
.todo-node .node-title{fill:var(--text-primary);font-size:13.5px;font-weight:400}
|
|
345
364
|
.todo-node .node-title-line{font-size:13.5px;font-weight:400}
|
|
346
365
|
.todo-node .status-mark{fill:var(--text-secondary);font-size:13.5px;font-weight:400}
|
|
347
|
-
.fold-chip{padding:2px 8px;border:1px solid var(--border);border-radius:9999px;background:var(--surface-2);color:var(--text-primary);font
|
|
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}
|
|
348
370
|
.fold-note{flex:1 0 100%;margin:4px 0 0;color:var(--text-secondary);font-weight:400}
|
|
349
371
|
.task-index-folded{margin-top:8px}
|
|
350
|
-
.task-index-folded>summary{cursor:pointer;padding:6px 0;color:var(--text-secondary);font-weight:600}
|
|
372
|
+
.task-index-folded>summary,.phase-settled>summary{cursor:pointer;padding:6px 0;color:var(--text-secondary);font-weight:600}
|
|
373
|
+
.phase-settled>summary:focus-visible{outline:2px solid var(--text-primary);outline-offset:2px}
|
|
351
374
|
.status-in-progress .node-surface{fill:var(--surface-1);stroke:var(--accent);stroke-width:2}
|
|
352
375
|
.status-in-progress .status-mark{fill:var(--accent)}
|
|
353
376
|
.status-in-progress .status-bar{stroke:var(--accent);stroke-width:2;stroke-linecap:round}
|
|
@@ -385,18 +408,23 @@ const CONTROLLER = `
|
|
|
385
408
|
const nodes=[...root.querySelectorAll('[data-node-key]')];
|
|
386
409
|
const edges=[...root.querySelectorAll('[data-edge-id]')];
|
|
387
410
|
const laneChips=[...root.querySelectorAll('.summary-lane[data-lane-key]')];
|
|
388
|
-
const
|
|
411
|
+
const diagrams=[...root.querySelectorAll('[data-diagram]')];
|
|
412
|
+
const expandToggle=root.querySelector('[data-toggle-expanded]');
|
|
413
|
+
const toggleLabel=root.querySelector('[data-toggle-label]');
|
|
389
414
|
const scroller=root.querySelector('[data-diagram-scroll]');
|
|
390
415
|
const zoomOutput=root.querySelector('[data-zoom-output]');
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
let
|
|
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;
|
|
394
420
|
const stacked=()=>window.matchMedia('(max-width:900px)').matches;
|
|
395
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+'%');};
|
|
396
422
|
const finishResize=(event)=>{if(event.pointerId!==resizePointerId)return;if(divider?.hasPointerCapture(event.pointerId))divider.releasePointerCapture(event.pointerId);resizePointerId=null;};
|
|
397
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+'%';}};
|
|
398
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);};
|
|
399
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);};
|
|
400
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;};
|
|
401
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));}};
|
|
402
430
|
const showOverview=()=>{selectedKey=null;syncSelection();showPanel('overview');};
|
|
@@ -407,6 +435,7 @@ const CONTROLLER = `
|
|
|
407
435
|
const overviewButton=event.target.closest('[data-show-overview]');if(overviewButton&&root.contains(overviewButton)){showOverview();return;}
|
|
408
436
|
const selectedButton=event.target.closest('[data-show-selected]');if(selectedButton&&root.contains(selectedButton)){showSelected();return;}
|
|
409
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;}
|
|
410
439
|
const selectButton=event.target.closest('[data-select-node-key]');if(selectButton&&root.contains(selectButton)){select(selectButton.dataset.selectNodeKey);return;}
|
|
411
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;}
|
|
412
441
|
const laneChip=event.target.closest('.summary-lane[data-lane-key]');if(laneChip&&root.contains(laneChip)){toggleLane(laneChip.dataset.laneKey);return;}
|
|
@@ -423,6 +452,7 @@ const CONTROLLER = `
|
|
|
423
452
|
|
|
424
453
|
export function renderTodoGanttHtml({
|
|
425
454
|
readModel, layout, narratives = [], anchorOutcomes = [], presentation = null, metadata = {},
|
|
455
|
+
expandedLayout = null,
|
|
426
456
|
}) {
|
|
427
457
|
if (readModel?.schema !== 'lattice.todo_store_read.v1' || !Array.isArray(readModel.members)) {
|
|
428
458
|
throw new TypeError('readModel must be lattice.todo_store_read.v1');
|
|
@@ -437,13 +467,19 @@ export function renderTodoGanttHtml({
|
|
|
437
467
|
const normalized = normalizeSections(readModel, narratives, anchorOutcomes);
|
|
438
468
|
const displayName = projectDisplayName(readModel, metadata);
|
|
439
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>`;
|
|
440
476
|
const rightPane = renderRightPane(normalized.sections, layout, presentation, readModel);
|
|
441
477
|
const staticData = serializeJsonForScript({
|
|
442
478
|
renderer_version: TODO_GANTT_RENDERER_VERSION,
|
|
443
479
|
metadata,
|
|
444
480
|
presentation,
|
|
445
481
|
});
|
|
446
|
-
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="縦方向を主にスクロール可能な依存工程図">${
|
|
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>`;
|
|
447
483
|
const htmlBytes = Buffer.byteLength(html, 'utf8');
|
|
448
484
|
if (htmlBytes > TODO_GANTT_HTML_MAX_BYTES) {
|
|
449
485
|
throw new TodoGanttRenderError('TODO_SCALE_EXCEEDED', 'todo gantt HTML limit exceeded', {
|