@quolu/lattice 0.12.10 → 0.12.12
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 +2 -2
- package/src/bridge-address.mjs +107 -0
- package/src/bridge-cli.mjs +78 -8
- package/src/bridge-launch-agent.mjs +12 -0
- package/src/bridge-registrar.mjs +102 -0
- package/src/bridge-server.mjs +53 -5
- package/src/cli-help.mjs +8 -3
- package/src/todo-cli.mjs +39 -12
- package/src/todo-gantt-html.mjs +42 -13
- package/src/todo-gantt-layout.mjs +68 -14
- package/src/todo-gantt-scope.mjs +296 -0
- package/src/todo-gantt-svg.mjs +17 -6
- package/src/todo-store.mjs +6 -5
package/src/todo-cli.mjs
CHANGED
|
@@ -20,6 +20,7 @@ import { projectTodoChainV1 } from './todo-chain.mjs';
|
|
|
20
20
|
import { ensureTodoDashboardActivity } from './todo-dashboard-registry.mjs';
|
|
21
21
|
import { resolveProjectIdentity } from './project-identity.mjs';
|
|
22
22
|
import { layoutTodoGantt } from './todo-gantt-layout.mjs';
|
|
23
|
+
import { TODO_GANTT_SCOPES } from './todo-gantt-scope.mjs';
|
|
23
24
|
import { loadTodoGanttPresentation } from './todo-gantt-presentation.mjs';
|
|
24
25
|
import { startTodoGanttLiveServer } from './todo-gantt-live.mjs';
|
|
25
26
|
import {
|
|
@@ -55,6 +56,7 @@ const CLI_ERROR_SCHEMA = 'lattice.cli_error.v2';
|
|
|
55
56
|
const DEFAULT_GANTT_REF = '.lattice/generated/gantt.html';
|
|
56
57
|
const GANTT_DESCRIPTOR_SUFFIX = '.status.json';
|
|
57
58
|
const MAX_GANTT_DESCRIPTOR_BYTES = 65_536;
|
|
59
|
+
const DEFAULT_GANTT_SCOPE = 'live';
|
|
58
60
|
const MAX_MIGRATION_INPUT_BYTES = 8_388_608;
|
|
59
61
|
const ACTOR_ENV_KEYS = Object.freeze([
|
|
60
62
|
'LATTICE_TODO_ACTOR_HOST',
|
|
@@ -596,15 +598,19 @@ function ganttDescriptorRef(outputRef) {
|
|
|
596
598
|
return `${outputRef}${GANTT_DESCRIPTOR_SUFFIX}`;
|
|
597
599
|
}
|
|
598
600
|
|
|
601
|
+
// v2 records the scope the artifact was drawn at. Without it `gantt status`
|
|
602
|
+
// would re-render at the default scope and report a `--scope all` artifact as
|
|
603
|
+
// stale even though nothing in the store had moved.
|
|
599
604
|
function validateGanttArtifactDescriptor(value) {
|
|
600
605
|
return exactRecord(value, [
|
|
601
606
|
'schema', 'project_id', 'output_ref', 'manifest_digest', 'renderer_version',
|
|
602
|
-
'html_digest', 'artifact_digest',
|
|
603
|
-
]) && value.schema === 'lattice.todo_gantt_artifact.
|
|
607
|
+
'scope', 'html_digest', 'artifact_digest',
|
|
608
|
+
]) && value.schema === 'lattice.todo_gantt_artifact.v2'
|
|
604
609
|
&& isTodoIdentifier(value.project_id) && isTodoRef(value.output_ref)
|
|
605
610
|
&& isTodoDigest(value.manifest_digest)
|
|
606
611
|
&& typeof value.renderer_version === 'string'
|
|
607
612
|
&& /^lattice\.todo_gantt_renderer\.v[1-9][0-9]*$/u.test(value.renderer_version)
|
|
613
|
+
&& TODO_GANTT_SCOPES.includes(value.scope)
|
|
608
614
|
&& isTodoDigest(value.html_digest) && isTodoDigest(value.artifact_digest)
|
|
609
615
|
&& value.artifact_digest === todoSelfDigest(value, 'artifact_digest');
|
|
610
616
|
}
|
|
@@ -636,6 +642,7 @@ function parseGanttDescriptor(bytes, descriptorRef) {
|
|
|
636
642
|
|
|
637
643
|
export async function renderTodoGanttForProject({
|
|
638
644
|
repoRoot, stable = false, displayName = null, env = process.env, readModel = null,
|
|
645
|
+
scope = DEFAULT_GANTT_SCOPE,
|
|
639
646
|
}) {
|
|
640
647
|
const store = readModel
|
|
641
648
|
?? (stable ? await readTodoStoreStable({ repoRoot }) : await readTodoStore({ repoRoot }));
|
|
@@ -645,7 +652,7 @@ export async function renderTodoGanttForProject({
|
|
|
645
652
|
const presentation = await loadTodoGanttPresentation({ repoRoot, readModel: store });
|
|
646
653
|
const topology = mergedTopology(store);
|
|
647
654
|
const chain = projectTodoChainV1(topology);
|
|
648
|
-
const layout = layoutTodoGantt(store, chain);
|
|
655
|
+
const layout = layoutTodoGantt(store, chain, { scope });
|
|
649
656
|
const narrative = await loadNarratives(store, repoRoot);
|
|
650
657
|
const anchorOutcomes = verifyNarrativeAnchors({
|
|
651
658
|
readModel: store,
|
|
@@ -673,6 +680,7 @@ export async function renderTodoGanttForProject({
|
|
|
673
680
|
layout_digest: digestTodoArtifact(layout),
|
|
674
681
|
renderer_version: TODO_GANTT_RENDERER_VERSION,
|
|
675
682
|
project_display_name: identity.displayName,
|
|
683
|
+
folded_task_count: layout.scope.folded_task_count,
|
|
676
684
|
};
|
|
677
685
|
const rendered = renderTodoGanttHtml({
|
|
678
686
|
readModel: store,
|
|
@@ -685,12 +693,14 @@ export async function renderTodoGanttForProject({
|
|
|
685
693
|
return { store, metadata, memberBindings, rendered };
|
|
686
694
|
}
|
|
687
695
|
|
|
688
|
-
async function gantt({ repoRoot, outputRef, env }) {
|
|
689
|
-
const { store, metadata, memberBindings, rendered } = await renderTodoGanttForProject({
|
|
696
|
+
async function gantt({ repoRoot, outputRef, env, scope = DEFAULT_GANTT_SCOPE }) {
|
|
697
|
+
const { store, metadata, memberBindings, rendered } = await renderTodoGanttForProject({
|
|
698
|
+
repoRoot, env, scope,
|
|
699
|
+
});
|
|
690
700
|
await atomicWriteOutput(repoRoot, outputRef, rendered.html);
|
|
691
|
-
const descriptor = { schema: 'lattice.todo_gantt_artifact.
|
|
701
|
+
const descriptor = { schema: 'lattice.todo_gantt_artifact.v2', project_id: store.project_id,
|
|
692
702
|
output_ref: outputRef, manifest_digest: metadata.manifest_digest,
|
|
693
|
-
renderer_version: TODO_GANTT_RENDERER_VERSION, html_digest: rendered.html_digest,
|
|
703
|
+
renderer_version: TODO_GANTT_RENDERER_VERSION, scope, html_digest: rendered.html_digest,
|
|
694
704
|
artifact_digest: '' };
|
|
695
705
|
descriptor.artifact_digest = todoSelfDigest(descriptor, 'artifact_digest');
|
|
696
706
|
await atomicWriteOutput(repoRoot, ganttDescriptorRef(outputRef),
|
|
@@ -699,6 +709,8 @@ async function gantt({ repoRoot, outputRef, env }) {
|
|
|
699
709
|
schema: 'lattice.todo_gantt_result.v1',
|
|
700
710
|
project_id: store.project_id,
|
|
701
711
|
output_ref: outputRef,
|
|
712
|
+
scope,
|
|
713
|
+
folded_task_count: metadata.folded_task_count,
|
|
702
714
|
manifest_digest: metadata.manifest_digest,
|
|
703
715
|
member_bindings: memberBindings,
|
|
704
716
|
narrative_bindings_digest: metadata.narrative_bindings_digest,
|
|
@@ -727,7 +739,7 @@ async function ganttStatus({ repoRoot, outputRef, env }) {
|
|
|
727
739
|
const result = { schema: 'lattice.todo_gantt_status_result.v1', project_id: store.project_id,
|
|
728
740
|
output_ref: outputRef, descriptor_ref: descriptorRef, artifact_status: 'missing',
|
|
729
741
|
current_manifest_digest: store.manifest.manifest_digest, artifact_manifest_digest: null,
|
|
730
|
-
html_digest: null, renderer_version: null, result_digest: '' };
|
|
742
|
+
html_digest: null, renderer_version: null, scope: null, result_digest: '' };
|
|
731
743
|
result.result_digest = todoSelfDigest(result, 'result_digest');
|
|
732
744
|
return result;
|
|
733
745
|
}
|
|
@@ -737,7 +749,9 @@ async function ganttStatus({ repoRoot, outputRef, env }) {
|
|
|
737
749
|
throw new TodoStoreError('GANTT_ARTIFACT_INVALID', 'artifact_digest_mismatch', undefined,
|
|
738
750
|
{ output_ref: outputRef, descriptor_ref: descriptorRef });
|
|
739
751
|
}
|
|
740
|
-
|
|
752
|
+
// Re-render at the artifact's own scope: comparing a `--scope all` artifact
|
|
753
|
+
// against a default-scope render would report a false `stale`.
|
|
754
|
+
const current = await renderTodoGanttForProject({ repoRoot, env, scope: descriptor.scope });
|
|
741
755
|
if (descriptor.project_id !== current.store.project_id) {
|
|
742
756
|
throw new TodoStoreError('GANTT_ARTIFACT_INVALID', 'artifact_project_mismatch', undefined,
|
|
743
757
|
{ output_ref: outputRef });
|
|
@@ -749,12 +763,12 @@ async function ganttStatus({ repoRoot, outputRef, env }) {
|
|
|
749
763
|
project_id: current.store.project_id, output_ref: outputRef, descriptor_ref: descriptorRef,
|
|
750
764
|
artifact_status: artifactStatus, current_manifest_digest: current.metadata.manifest_digest,
|
|
751
765
|
artifact_manifest_digest: descriptor.manifest_digest, html_digest: descriptor.html_digest,
|
|
752
|
-
renderer_version: descriptor.renderer_version, result_digest: '' };
|
|
766
|
+
renderer_version: descriptor.renderer_version, scope: descriptor.scope, result_digest: '' };
|
|
753
767
|
result.result_digest = todoSelfDigest(result, 'result_digest');
|
|
754
768
|
return result;
|
|
755
769
|
}
|
|
756
770
|
|
|
757
|
-
async function serveGantt({ repoRoot, port, stdout, env }) {
|
|
771
|
+
async function serveGantt({ repoRoot, port, stdout, env, scope = DEFAULT_GANTT_SCOPE }) {
|
|
758
772
|
const initialStore = await readTodoStoreStable({ repoRoot });
|
|
759
773
|
const identity = await resolveProjectIdentity({ repoRoot, projectId: initialStore.project_id, env });
|
|
760
774
|
const live = await startTodoGanttLiveServer({
|
|
@@ -763,7 +777,7 @@ async function serveGantt({ repoRoot, port, stdout, env }) {
|
|
|
763
777
|
port,
|
|
764
778
|
render: async () => {
|
|
765
779
|
const { rendered, metadata } = await renderTodoGanttForProject({
|
|
766
|
-
repoRoot, stable: true, displayName: identity.displayName,
|
|
780
|
+
repoRoot, stable: true, displayName: identity.displayName, scope,
|
|
767
781
|
});
|
|
768
782
|
return { html: rendered.html, head_digest: metadata.manifest_digest };
|
|
769
783
|
},
|
|
@@ -919,9 +933,15 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
|
|
|
919
933
|
action = (repoRoot) => rebuildSnapshot({ repoRoot, planKey: argv[3] });
|
|
920
934
|
} else if (argv.length === 1 && argv[0] === 'gantt') {
|
|
921
935
|
action = (repoRoot) => gantt({ repoRoot, outputRef: DEFAULT_GANTT_REF, env });
|
|
936
|
+
} else if (argv.length === 3 && argv[0] === 'gantt' && argv[1] === '--scope'
|
|
937
|
+
&& TODO_GANTT_SCOPES.includes(argv[2])) {
|
|
938
|
+
action = (repoRoot) => gantt({ repoRoot, outputRef: DEFAULT_GANTT_REF, env, scope: argv[2] });
|
|
922
939
|
} else if (argv.length === 3 && argv[0] === 'gantt' && argv[1] === '--out'
|
|
923
940
|
&& isTodoRef(argv[2])) {
|
|
924
941
|
action = (repoRoot) => gantt({ repoRoot, outputRef: argv[2], env });
|
|
942
|
+
} else if (argv.length === 5 && argv[0] === 'gantt' && argv[1] === '--out'
|
|
943
|
+
&& isTodoRef(argv[2]) && argv[3] === '--scope' && TODO_GANTT_SCOPES.includes(argv[4])) {
|
|
944
|
+
action = (repoRoot) => gantt({ repoRoot, outputRef: argv[2], env, scope: argv[4] });
|
|
925
945
|
} else if (argv.length === 2 && argv[0] === 'gantt' && argv[1] === 'status') {
|
|
926
946
|
action = (repoRoot) => ganttStatus({ repoRoot, outputRef: DEFAULT_GANTT_REF, env });
|
|
927
947
|
} else if (argv.length === 4 && argv[0] === 'gantt' && argv[1] === 'status'
|
|
@@ -931,6 +951,13 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
|
|
|
931
951
|
&& argv[2] === '--port' && /^(?:0|[1-9][0-9]{0,4})$/u.test(argv[3])
|
|
932
952
|
&& Number(argv[3]) <= 65_535) {
|
|
933
953
|
action = (repoRoot) => serveGantt({ repoRoot, port: Number(argv[3]), stdout, env });
|
|
954
|
+
} else if (argv.length === 6 && argv[0] === 'gantt' && argv[1] === 'serve'
|
|
955
|
+
&& argv[2] === '--port' && /^(?:0|[1-9][0-9]{0,4})$/u.test(argv[3])
|
|
956
|
+
&& Number(argv[3]) <= 65_535 && argv[4] === '--scope'
|
|
957
|
+
&& TODO_GANTT_SCOPES.includes(argv[5])) {
|
|
958
|
+
action = (repoRoot) => serveGantt({
|
|
959
|
+
repoRoot, port: Number(argv[3]), stdout, env, scope: argv[5],
|
|
960
|
+
});
|
|
934
961
|
} else if (argv.length === 3 && argv[0] === 'migrate' && argv[1] === '--input'
|
|
935
962
|
&& isTodoRef(argv[2])) {
|
|
936
963
|
action = (repoRoot) => migrate({ repoRoot, inputRef: argv[2] });
|
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.v9';
|
|
10
10
|
export const TODO_GANTT_PROSE_MAX_BYTES = 8 * 1024 * 1024;
|
|
11
11
|
export const TODO_GANTT_HTML_MAX_BYTES = 24 * 1024 * 1024;
|
|
12
12
|
|
|
@@ -126,7 +126,23 @@ function normalizeSections(readModel, narratives, anchorOutcomes) {
|
|
|
126
126
|
return { sections: result, proseBytes };
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
|
|
129
|
+
/** task node key -> the fold node standing in for it, empty when nothing folded. */
|
|
130
|
+
function foldIndex(layout) {
|
|
131
|
+
return new Map((layout?.folded ?? []).map((entry) => [refKey(entry.task), refKey(entry.fold)]));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function renderTaskIndexEntry(section, lookup, folds) {
|
|
135
|
+
const key = refKey(section.ref);
|
|
136
|
+
const status = DOCUMENT_STATUS[section.state.status] ?? { mark: '?', label: '状態不明' };
|
|
137
|
+
const blockedReason = section.state.status === 'blocked'
|
|
138
|
+
? `<span class="task-index-blocked-reason">— ${escapeHtmlText(section.state.blocked_reason ?? '理由未記録')}</span>` : '';
|
|
139
|
+
// A folded ToDo keeps its row here — the index is the complete list — but
|
|
140
|
+
// selecting it points at the fold node that actually stands on the diagram.
|
|
141
|
+
const selectKey = folds.get(key) ?? key;
|
|
142
|
+
return `<li><button type="button" data-select-node-key="${escapeHtmlAttribute(selectKey)}"><span class="task-index-status status-${escapeHtmlAttribute(section.state.status)}" role="img" aria-label="${escapeHtmlAttribute(status.label)}">${escapeHtmlText(status.mark)}</span><span class="task-index-reference">${escapeHtmlText(taskReference(section, lookup))}</span><strong>${escapeHtmlText(section.task.title)}</strong>${blockedReason}</button></li>`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function renderTaskIndex(sections, lookup, folds = new Map()) {
|
|
130
146
|
const plans = [];
|
|
131
147
|
for (const section of sections) {
|
|
132
148
|
let plan = plans.at(-1);
|
|
@@ -136,13 +152,15 @@ function renderTaskIndex(sections, lookup) {
|
|
|
136
152
|
}
|
|
137
153
|
plan.tasks.push(section);
|
|
138
154
|
}
|
|
139
|
-
return plans.map((plan) =>
|
|
140
|
-
const
|
|
141
|
-
const
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
155
|
+
return plans.map((plan) => {
|
|
156
|
+
const drawn = plan.tasks.filter((section) => !folds.has(refKey(section.ref)));
|
|
157
|
+
const folded = plan.tasks.filter((section) => folds.has(refKey(section.ref)));
|
|
158
|
+
const drawnList = drawn.length === 0 ? ''
|
|
159
|
+
: `<ol class="task-index-list">${drawn.map((section) => renderTaskIndexEntry(section, lookup, folds)).join('')}</ol>`;
|
|
160
|
+
const foldedList = folded.length === 0 ? ''
|
|
161
|
+
: `<details class="task-index-folded"><summary>完走済みとして畳んだ工程 ${folded.length}件</summary><ol class="task-index-list">${folded.map((section) => renderTaskIndexEntry(section, lookup, folds)).join('')}</ol></details>`;
|
|
162
|
+
return `<section class="task-index-plan"><h2><code>${escapeHtmlText(plan.planKey)}</code></h2>${drawnList}${foldedList}</section>`;
|
|
163
|
+
}).join('');
|
|
146
164
|
}
|
|
147
165
|
|
|
148
166
|
function presentationLookup(presentation) {
|
|
@@ -240,14 +258,19 @@ function renderRightPane(sections, layout, presentation, readModel) {
|
|
|
240
258
|
: incoming.get(key).length === 0 ? '<p class="readiness-note">登録済みの前提工程はありません。図だけではdispatch可否を判定しません。</p>' : '';
|
|
241
259
|
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}<section><h2>前提工程</h2>${renderRelationList(incoming.get(key), sectionByKey, lookup, '登録済みの前提工程はありません。')}</section><section><h2>後続工程</h2>${renderRelationList(outgoing.get(key), sectionByKey, lookup, '登録済みの後続工程はありません。')}</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>`;
|
|
242
260
|
}).join('');
|
|
243
|
-
const taskIndex = renderTaskIndex(sections, lookup);
|
|
261
|
+
const taskIndex = renderTaskIndex(sections, lookup, foldIndex(layout));
|
|
244
262
|
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>`;
|
|
245
263
|
}
|
|
246
264
|
|
|
247
|
-
function renderDiagramLegend(presentation) {
|
|
265
|
+
function renderDiagramLegend(presentation, layout = null) {
|
|
248
266
|
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('');
|
|
249
267
|
const categoryDetails = categories === '' ? '' : `<details class="category-legend"><summary>カテゴリ説明</summary><dl>${categories}</dl></details>`;
|
|
250
|
-
|
|
268
|
+
const foldedCount = layout?.scope?.folded_task_count ?? 0;
|
|
269
|
+
const foldChip = foldedCount === 0 ? ''
|
|
270
|
+
: `<span class="fold-chip">▣ 完走済み ${foldedCount}件を畳んで表示中</span>`;
|
|
271
|
+
const foldNote = foldedCount === 0 ? ''
|
|
272
|
+
: `<p class="fold-note">後続に作業中・未着手が残っていない完了工程を、${layout.scope.fold_node_count}個の畳み込みノードへまとめています。生きた工程とその直接の前提工程は必ず展開したままです。全件を描くには <code>lattice todo gantt --scope all</code> を実行してください。総数・進捗・最長依存鎖は畳み込み前の全工程で数えています。</p>`;
|
|
273
|
+
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>`;
|
|
251
274
|
}
|
|
252
275
|
|
|
253
276
|
const CSS = `
|
|
@@ -310,6 +333,12 @@ body{display:grid;grid-template-rows:minmax(0,1fr);height:100vh;margin:0;backgro
|
|
|
310
333
|
.todo-node .node-title{fill:var(--text-primary);font-size:13.5px;font-weight:400}
|
|
311
334
|
.todo-node .node-title-line{font-size:13.5px;font-weight:400}
|
|
312
335
|
.todo-node .status-mark{fill:var(--text-secondary);font-size:13.5px;font-weight:400}
|
|
336
|
+
.folded-node .node-surface{fill:var(--surface-2);stroke:var(--border);stroke-width:2;stroke-dasharray:none}
|
|
337
|
+
.folded-node .node-title,.folded-node .node-meta{fill:var(--text-secondary)}
|
|
338
|
+
.fold-chip{padding:2px 8px;border:1px solid var(--border);border-radius:9999px;background:var(--surface-2);color:var(--text-primary);font-weight:650}
|
|
339
|
+
.fold-note{flex:1 0 100%;margin:4px 0 0;color:var(--text-secondary);font-weight:400}
|
|
340
|
+
.task-index-folded{margin-top:8px}
|
|
341
|
+
.task-index-folded>summary{cursor:pointer;padding:6px 0;color:var(--text-secondary);font-weight:600}
|
|
313
342
|
.status-in-progress .node-surface{fill:var(--surface-1);stroke:var(--accent);stroke-width:2}
|
|
314
343
|
.status-in-progress .status-mark{fill:var(--accent)}
|
|
315
344
|
.status-in-progress .status-bar{stroke:var(--accent);stroke-width:2;stroke-linecap:round}
|
|
@@ -405,7 +434,7 @@ export function renderTodoGanttHtml({
|
|
|
405
434
|
metadata,
|
|
406
435
|
presentation,
|
|
407
436
|
});
|
|
408
|
-
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)}<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>`;
|
|
437
|
+
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>`;
|
|
409
438
|
const htmlBytes = Buffer.byteLength(html, 'utf8');
|
|
410
439
|
if (htmlBytes > TODO_GANTT_HTML_MAX_BYTES) {
|
|
411
440
|
throw new TodoGanttRenderError('TODO_SCALE_EXCEEDED', 'todo gantt HTML limit exceeded', {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { TODO_GANTT_SCOPES, projectTodoGanttScope } from './todo-gantt-scope.mjs';
|
|
2
|
+
|
|
1
3
|
const TASK_LIMIT = 2_000;
|
|
2
4
|
const EDGE_LIMIT = 8_000;
|
|
3
5
|
const SWEEP_ROUNDS = 4;
|
|
@@ -370,18 +372,21 @@ function crossingCount(edges, wave, transversePosition) {
|
|
|
370
372
|
return total;
|
|
371
373
|
}
|
|
372
374
|
|
|
373
|
-
export function layoutTodoGantt(readModel, chainProjection) {
|
|
374
|
-
const
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
const transversePosition = new Map();
|
|
378
|
-
for (const layer of layers) {
|
|
379
|
-
for (let index = 0; index < layer.length; index += 1) transversePosition.set(layer[index], index);
|
|
375
|
+
export function layoutTodoGantt(readModel, chainProjection, options = {}) {
|
|
376
|
+
const scope = options.scope ?? 'live';
|
|
377
|
+
if (!TODO_GANTT_SCOPES.includes(scope)) {
|
|
378
|
+
fail('TODO_LAYOUT_INVALID_INPUT', `scope must be one of ${TODO_GANTT_SCOPES.join(', ')}`);
|
|
380
379
|
}
|
|
381
380
|
|
|
381
|
+
// Every structural number below is measured on the FULL graph: the dependency
|
|
382
|
+
// waves, the longest dependency chain and the ready frontier describe the real
|
|
383
|
+
// plan. Folding first and measuring second would make all three lie.
|
|
384
|
+
const full = normalizeInput(readModel, chainProjection);
|
|
385
|
+
const fullWaves = assignWaves(full.nodes, full.nodesByKey, full.edges);
|
|
386
|
+
|
|
382
387
|
const longestNodeKeys = new Set(chainProjection.longest_chain_node_refs.map((ref, index) => {
|
|
383
388
|
const key = refKey(refOf(ref, `longest_chain_node_refs[${index}]`));
|
|
384
|
-
if (!nodesByKey.has(key)) fail('TODO_LAYOUT_INVALID_INPUT', 'chain projection references an absent task');
|
|
389
|
+
if (!full.nodesByKey.has(key)) fail('TODO_LAYOUT_INVALID_INPUT', 'chain projection references an absent task');
|
|
385
390
|
return key;
|
|
386
391
|
}));
|
|
387
392
|
const longestEdgeKeys = new Set(chainProjection.longest_chain_edges.map((edge, index) => {
|
|
@@ -389,12 +394,28 @@ export function layoutTodoGantt(readModel, chainProjection) {
|
|
|
389
394
|
const from = refKey(refOf(edge.from, `longest_chain_edges[${index}].from`));
|
|
390
395
|
const to = refKey(refOf(edge.to, `longest_chain_edges[${index}].to`));
|
|
391
396
|
const key = JSON.stringify([from, to]);
|
|
392
|
-
if (!edges.some((candidate) => candidate.key === key)) {
|
|
397
|
+
if (!full.edges.some((candidate) => candidate.key === key)) {
|
|
393
398
|
fail('TODO_LAYOUT_INVALID_INPUT', 'chain projection edge is absent from the read model');
|
|
394
399
|
}
|
|
395
400
|
return key;
|
|
396
401
|
}));
|
|
397
|
-
const readyKeys = readyTaskKeys(readModel, nodes, nodesByKey, incoming);
|
|
402
|
+
const readyKeys = readyTaskKeys(readModel, full.nodes, full.nodesByKey, fullWaves.incoming);
|
|
403
|
+
|
|
404
|
+
// Only the geometry stage below sees the narrowed graph.
|
|
405
|
+
const projected = scope === 'all'
|
|
406
|
+
? { nodes: full.nodes, edges: full.edges, foldedByKey: new Map(), folds: [], refined: false }
|
|
407
|
+
: projectTodoGanttScope({
|
|
408
|
+
nodes: full.nodes, edges: full.edges, wave: fullWaves.wave, longestChainKeys: longestNodeKeys,
|
|
409
|
+
});
|
|
410
|
+
const nodes = projected.nodes;
|
|
411
|
+
const edges = projected.edges;
|
|
412
|
+
const nodesByKey = new Map(nodes.map((node) => [node.key, node]));
|
|
413
|
+
const { incoming, outgoing, wave } = assignWaves(nodes, nodesByKey, edges);
|
|
414
|
+
const layers = orderLayers(nodes, wave, incoming, outgoing);
|
|
415
|
+
const transversePosition = new Map();
|
|
416
|
+
for (const layer of layers) {
|
|
417
|
+
for (let index = 0; index < layer.length; index += 1) transversePosition.set(layer[index], index);
|
|
418
|
+
}
|
|
398
419
|
const visibleKeys = new Set(nodes.map(({ key }) => key));
|
|
399
420
|
const displayBranches = edges.flatMap((edge, semanticIndex) => {
|
|
400
421
|
const identities = [...edge.joinIdentities.entries()]
|
|
@@ -456,10 +477,19 @@ export function layoutTodoGantt(readModel, chainProjection) {
|
|
|
456
477
|
ref: { ...node.ref }, title: node.title, lane: node.lane, status: node.status,
|
|
457
478
|
wave: wave.get(node.key), row: transversePosition.get(node.key), visible,
|
|
458
479
|
visibility: {
|
|
459
|
-
|
|
480
|
+
// A fold node stands in for a finished branch, so it inherits the chain
|
|
481
|
+
// marking of the ToDos it replaced rather than carrying one of its own.
|
|
482
|
+
longest_dependency_chain: node.fold === undefined
|
|
483
|
+
? longestNodeKeys.has(node.key) : node.fold.longest_chain_task_count > 0,
|
|
460
484
|
active: node.status === 'in-progress', next_ready: readyKeys.has(node.key),
|
|
461
485
|
selected: false,
|
|
462
486
|
},
|
|
487
|
+
fold: node.fold === undefined ? null : {
|
|
488
|
+
task_count: node.fold.task_count,
|
|
489
|
+
lanes: [...node.fold.lanes],
|
|
490
|
+
longest_chain_task_count: node.fold.longest_chain_task_count,
|
|
491
|
+
task_refs: node.fold.task_refs.map((entry) => ({ ...entry })),
|
|
492
|
+
},
|
|
463
493
|
geometry,
|
|
464
494
|
};
|
|
465
495
|
});
|
|
@@ -566,9 +596,11 @@ export function layoutTodoGantt(readModel, chainProjection) {
|
|
|
566
596
|
for (const [x] of connector.route) routeMaximumX = Math.max(routeMaximumX, x);
|
|
567
597
|
}
|
|
568
598
|
|
|
599
|
+
// Counts stay honest: the summary chips report every ToDo in the plan, not
|
|
600
|
+
// only the ones the narrowed diagram happens to draw.
|
|
569
601
|
const planMap = new Map();
|
|
570
602
|
const laneMap = new Map();
|
|
571
|
-
for (const node of nodes) {
|
|
603
|
+
for (const node of full.nodes) {
|
|
572
604
|
if (!planMap.has(node.ref.plan_key)) planMap.set(node.ref.plan_key, 0);
|
|
573
605
|
planMap.set(node.ref.plan_key, planMap.get(node.ref.plan_key) + 1);
|
|
574
606
|
const key = groupKey(node.ref.plan_key, node.lane);
|
|
@@ -592,12 +624,34 @@ export function layoutTodoGantt(readModel, chainProjection) {
|
|
|
592
624
|
plans: [...planMap.entries()].map(([plan_key, task_count]) => ({ plan_key, task_count })),
|
|
593
625
|
lanes: [...laneMap.values()],
|
|
594
626
|
},
|
|
627
|
+
scope: {
|
|
628
|
+
requested: scope,
|
|
629
|
+
folded_task_count: projected.foldedByKey.size,
|
|
630
|
+
fold_node_count: projected.folds.length,
|
|
631
|
+
per_wave_refinement: projected.refined,
|
|
632
|
+
folds: projected.folds.map((entry) => ({
|
|
633
|
+
ref: { ...entry.ref },
|
|
634
|
+
task_count: entry.task_count,
|
|
635
|
+
lanes: [...entry.lanes],
|
|
636
|
+
longest_chain_task_count: entry.longest_chain_task_count,
|
|
637
|
+
})),
|
|
638
|
+
},
|
|
639
|
+
folded: [...projected.foldedByKey.entries()]
|
|
640
|
+
.map(([taskKey, foldKey]) => ({ task: JSON.parse(taskKey), fold: JSON.parse(foldKey) }))
|
|
641
|
+
.sort((left, right) => compareRefs(
|
|
642
|
+
{ project_id: left.task[0], plan_key: left.task[1], task_id: left.task[2] },
|
|
643
|
+
{ project_id: right.task[0], plan_key: right.task[1], task_id: right.task[2] },
|
|
644
|
+
))
|
|
645
|
+
.map(({ task, fold }) => ({
|
|
646
|
+
task: { project_id: task[0], plan_key: task[1], task_id: task[2] },
|
|
647
|
+
fold: { project_id: fold[0], plan_key: fold[1], task_id: fold[2] },
|
|
648
|
+
})),
|
|
595
649
|
metrics: {
|
|
596
650
|
crossing_count: crossingCount(edges, wave, transversePosition),
|
|
597
651
|
visible_node_count: visibleKeys.size,
|
|
598
652
|
visible_edge_count: projectedEdges.filter(({ visible }) => visible).length,
|
|
599
|
-
task_count: nodes.length,
|
|
600
|
-
edge_count: edges.length,
|
|
653
|
+
task_count: full.nodes.length,
|
|
654
|
+
edge_count: full.edges.length,
|
|
601
655
|
},
|
|
602
656
|
};
|
|
603
657
|
}
|