@kaydash9999/banker-plugins 0.21.0 → 0.23.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.
Files changed (129) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +69 -0
  4. package/PRIVACY.md +8 -3
  5. package/README.md +78 -15
  6. package/bin/banker.js +154 -32
  7. package/bin/lib/codex-hooks.mjs +123 -20
  8. package/bin/lib/codex-names.mjs +4 -0
  9. package/bin/lib/codex-routes.mjs +204 -0
  10. package/bin/lib/command-skill.cjs +50 -0
  11. package/bin/lib/telemetry-config.mjs +2 -1
  12. package/codex/command-skills/front-qa/SKILL.md +56 -0
  13. package/codex/command-skills/setup/SKILL.md +87 -0
  14. package/codex/hooks.json +29 -0
  15. package/codex/manifest.json +18 -15
  16. package/codex/skills/graceful-pause/SKILL.md +2 -2
  17. package/codex/skills/progress/scripts/progress.mjs +44 -14
  18. package/codex/skills/teamwork/SKILL.md +137 -0
  19. package/codex/skills/teamwork/scripts/teamwork.mjs +957 -0
  20. package/codex/transform-matrix.md +5 -3
  21. package/commands/setup.md +2 -2
  22. package/hooks/obsidize-hook.mjs +75 -32
  23. package/hooks/telemetry-count-codex.mjs +303 -35
  24. package/hooks/update-fetch.mjs +2 -1
  25. package/hooks/update-notify.mjs +146 -13
  26. package/package.json +3 -1
  27. package/skills/3d-intro-build/SKILL.md +101 -20
  28. package/skills/3d-intro-build/references/NOTICE.md +9 -2
  29. package/skills/3d-intro-build/references/assemble.mjs +113 -22
  30. package/skills/3d-intro-build/references/azure-adapter.mjs +110 -24
  31. package/skills/3d-intro-build/references/clip-post.mjs +443 -0
  32. package/skills/3d-intro-build/references/dive-mode.mjs +299 -0
  33. package/skills/3d-intro-build/references/intro-fixes.css +11 -0
  34. package/skills/3d-intro-build/references/prompts.md +51 -4
  35. package/skills/3d-intro-build/references/scrub-engine.js +1 -0
  36. package/skills/3d-intro-build/references/step-nav.css +29 -0
  37. package/skills/3d-intro-build/references/step-nav.js +277 -106
  38. package/skills/3d-intro-build/references/verify-intro.mjs +387 -32
  39. package/skills/3d-intro-build/references/verify.md +6 -0
  40. package/skills/3d-intro-build/references/video-pool.mjs +142 -12
  41. package/skills/3d-intro-setup/SKILL.md +20 -7
  42. package/skills/3d-intro-setup/references/azure-adapter.mjs +110 -24
  43. package/skills/3d-intro-setup/references/video-pool.mjs +142 -12
  44. package/skills/all-in-one/SKILL.md +125 -86
  45. package/skills/anti-drm/SKILL.md +1 -1
  46. package/skills/anti-drm/scripts/instruction-toggle.mjs +57 -4
  47. package/skills/arch-diagram/build_pptx_template.py +47 -28
  48. package/skills/audit-web-page/SKILL.md +1 -1
  49. package/skills/audit-web-page/harness.py +0 -1
  50. package/skills/lineage/lineage.py +1435 -533
  51. package/skills/motion-graphic-make/SKILL.md +1 -1
  52. package/skills/obsidizer/SKILL.md +20 -5
  53. package/skills/omc-patch/SKILL.md +1 -1
  54. package/skills/payload-mon/SKILL.md +2 -2
  55. package/skills/play-qa/SKILL.md +7 -5
  56. package/skills/progress-mon/README.md +162 -0
  57. package/skills/progress-mon/SKILL.md +165 -0
  58. package/skills/progress-mon/assets/fixture/README.md +1 -0
  59. package/skills/progress-mon/assets/fixture/arch.json +33 -0
  60. package/skills/progress-mon/assets/fixture/commits.json +7 -0
  61. package/skills/progress-mon/assets/fixture/config.json +30 -0
  62. package/skills/progress-mon/assets/fixture/events.jsonl +7 -0
  63. package/skills/progress-mon/assets/fixture/flow.json +44 -0
  64. package/skills/progress-mon/assets/fixture/items.json +14 -0
  65. package/skills/progress-mon/assets/fixture/req-spec.json +31 -0
  66. package/skills/progress-mon/assets/fixture/reqs.json +11 -0
  67. package/skills/progress-mon/assets/fixture/variants/arch-auto/arch.json +58 -0
  68. package/skills/progress-mon/assets/fixture/variants/check-auto/arch.json +15 -0
  69. package/skills/progress-mon/assets/fixture/variants/check-overlap/arch.json +14 -0
  70. package/skills/progress-mon/assets/fixture/variants/history-manual/config.json +13 -0
  71. package/skills/progress-mon/assets/fixture/variants/history-manual/items.json +7 -0
  72. package/skills/progress-mon/assets/fixture/variants/workflow-wide/flow.json +35 -0
  73. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a01.jsonl +1 -0
  74. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a02.jsonl +1 -0
  75. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a03.jsonl +1 -0
  76. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a04.jsonl +1 -0
  77. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/journal.jsonl +7 -0
  78. package/skills/progress-mon/references/arch.md +280 -0
  79. package/skills/progress-mon/references/authoring.md +199 -0
  80. package/skills/progress-mon/references/config.md +115 -0
  81. package/skills/progress-mon/references/flow.md +75 -0
  82. package/skills/progress-mon/references/inventory.md +58 -0
  83. package/skills/progress-mon/references/items.md +74 -0
  84. package/skills/progress-mon/references/req-spec.md +74 -0
  85. package/skills/progress-mon/references/reqs.md +30 -0
  86. package/skills/progress-mon/scripts/index.js +31 -0
  87. package/skills/progress-mon/scripts/pm-check.mjs +526 -0
  88. package/skills/progress-mon/scripts/pm-config.mjs +341 -0
  89. package/skills/progress-mon/scripts/pm-data.mjs +394 -0
  90. package/skills/progress-mon/scripts/pm-html.mjs +77 -0
  91. package/skills/progress-mon/scripts/pm-memory.mjs +308 -0
  92. package/skills/progress-mon/scripts/pm-render.mjs +1644 -0
  93. package/skills/progress-mon/scripts/pm-server.mjs +735 -0
  94. package/skills/progress-mon/scripts/pm-sources.mjs +501 -0
  95. package/skills/progress-mon/scripts/progress-mon.mjs +167 -0
  96. package/skills/ralph-qa/SKILL.md +105 -16
  97. package/skills/ralph-qa/references/gate-evidence.mjs +457 -0
  98. package/skills/ralph-qa/references/payload-scan.mjs +19 -2
  99. package/skills/ralph-qa/references/run-folder.mjs +299 -0
  100. package/skills/ralph-qa/references/seat-payload.mjs +60 -13
  101. package/skills/ralph-qa/references/seat-run.mjs +159 -155
  102. package/skills/remains/SKILL.md +2 -1
  103. package/skills/remains/scripts/boxes.mjs +6 -2
  104. package/skills/rfp-author/scripts/md2docx.py +63 -34
  105. package/skills/setup-bypass-permissions/SKILL.md +2 -2
  106. package/skills/setup-bypass-permissions/scripts/codex-config.mjs +19 -8
  107. package/skills/setup-bypass-permissions/scripts/fallback/bypass-permissions.py +66 -15
  108. package/skills/setup-mcp/SKILL.md +1 -1
  109. package/skills/setup-omc-hud/SKILL.md +2 -1
  110. package/skills/setup-omc-hud/assets/codex-config.mjs +19 -8
  111. package/skills/smart-compact/SKILL.md +1 -1
  112. package/skills/smart-compact/scripts/codex-gate.mjs +2 -1
  113. package/skills/theme-selection/SKILL.md +1 -1
  114. package/skills/tone-compact/SKILL.md +1 -1
  115. package/skills/tone-compact/scripts/instruction-toggle.mjs +57 -4
  116. package/skills/ultra-chain/SKILL.md +34 -16
  117. package/skills/ultra-chain/references/chain-ledger.mjs +111 -3
  118. package/skills/ultra-init/SKILL.md +30 -22
  119. package/skills/ultra-ui-qa/SKILL.md +1 -1
  120. package/skills/ultraqa/NOTICE.md +34 -0
  121. package/skills/ultraqa/SKILL.md +196 -0
  122. package/skills/ultraqa/references/gate-evidence.mjs +457 -0
  123. package/skills/ultraqa/references/run-folder.mjs +299 -0
  124. package/skills/ultraqa/references/tmux-drive.md +52 -0
  125. package/skills/ultraqa/references/uqa-run.mjs +339 -0
  126. package/skills/update-banker/SKILL.md +32 -7
  127. package/skills/vertical-pptx/SKILL.md +1 -1
  128. package/skills/vertical-pptx/build_a4p.js +20 -2
  129. package/skills/vertical-pptx/build_a4p.py +560 -359
@@ -0,0 +1,1644 @@
1
+ // pm-render.mjs: 4탭(진척도, 구현요건, workflow, 구성도)과 단일 HTML 스냅숏, workflow 배치와 구성도 자동 배치.
2
+ // 옛 진척도 서버의 클래스 이름과 DOM 구조를 그대로 둔다. 저장소 고유 값은 config.json 에서 읽는다.
3
+ // 시각은 now 인자로만 받는다. CLI 하위 명령 export 도 여기서 맡는다.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { esc, color, finite, textW, wrap, FAVICON, CSS, TABS, tabsHtml } from './pm-html.mjs';
7
+ import { configOrFail, loadConfig, UsageError, DEFAULT_REQ_RULE, STORYLESS_REQ_RULE } from './pm-config.mjs';
8
+ import { collect, parseTime, agentJudge } from './pm-sources.mjs';
9
+
10
+ const two = (n) => String(n).padStart(2, '0');
11
+ const hm = (ms) => (ms == null ? '-' : Math.floor(ms / 3600e3) + ':' + two(Math.floor((ms % 3600e3) / 60000)));
12
+ // 표시는 실행 환경 시간대(현지).
13
+ const fmt = (ms) => {
14
+ if (!ms) return '-';
15
+ const d = new Date(ms);
16
+ return two(d.getMonth() + 1) + '-' + two(d.getDate()) + ' ' + two(d.getHours()) + ':' + two(d.getMinutes());
17
+ };
18
+ const span = (a, b) => (a && b && b >= a ? b - a : undefined);
19
+ const nowText = (now) => new Date(now).toLocaleString('ko-KR', { hour12: false });
20
+ const arr = (v) => (Array.isArray(v) ? v : []);
21
+ // 문장 끝 온점(뒤에 공백)에서 줄을 바꾼다. 판 번호(5.7.12) 같은 점은 그대로 둔다.
22
+ const brk = (t) => esc(t).replace(/\.\s+/g, '.<br>');
23
+ // 이미 만든 HTML 문단에 같은 규칙. 문단 안 태그는 속성 없는 <code> 뿐이라 태그 안에 ". " 가 없다.
24
+ const sent = (h) => h.replace(/\.\s+(?=\S)/g, '.<br>');
25
+ // 자료 없음 안내. 스키마 문서는 스킬 기준 경로.
26
+ const emptyNotice = (...docs) => `<div class="meta empty">자료 없음. 만드는 법: ${docs.map((d) => `<code>${esc(d)}</code>`).join(', ')}</div>`;
27
+ const tabName = (k) => TABS.find((t) => t[0] === k)[1];
28
+
29
+ // ---------- 진척도 ----------
30
+
31
+ const LABEL = { done: '완료', doing: '진행 중', todo: '예정' };
32
+
33
+ // 스토리 상태 판정 함수 묶음. 커밋(제목 판정 또는 storyCommits)이 있으면 완료, 없으면 storyStates, 없으면 진행 중 에이전트.
34
+ function storyJudge(m, config) {
35
+ // 에이전트 라벨은 한 묶음으로 판정(사용자 정규식의 시간 상한이 요청마다 하나)
36
+ const judged = agentJudge(m.agents.map((a) => a.label), m.ids, config.agentLabelPattern);
37
+ const busy = new Map();
38
+ const hide = new Set();
39
+ m.agents.forEach((a, i) => {
40
+ if (judged[i].id) busy.set(judged[i].id, a);
41
+ if (judged[i].hidden) hide.add(a.label);
42
+ });
43
+ const ss = (id) => {
44
+ const v = m.storyStates[id];
45
+ return v && typeof v === 'object' && ['done', 'doing', 'todo'].includes(v.status) ? v : null;
46
+ };
47
+ const state = (id) => (m.commits[id] ? 'done' : ss(id) ? ss(id).status : busy.has(id) ? 'doing' : 'todo');
48
+ return { busy, ss, state, hidden: (label) => hide.has(label) };
49
+ }
50
+
51
+ function historyBody(m, config, now) {
52
+ const j = storyJudge(m, config);
53
+ const rows = { done: [], doing: [], todo: [] };
54
+ // live: 실행 중 정보(에이전트, 멈춤). notes 비고 앞에 붙인다. dur: 소요시간(ms).
55
+ const add = (status, t, kind, text, o = {}) => {
56
+ const n = (o.key && m.notes[o.key]) || {};
57
+ const note = [o.live, o.note || n.note].filter(Boolean).join(', ');
58
+ (rows[status] || rows.doing).push({ t, dur: o.dur, kind, text, hash: o.hash || '', note, issue: o.issue || n.issue || '' });
59
+ };
60
+ for (const { id, label } of m.stages) {
61
+ const s = m.stageState[id];
62
+ const key = 'stage:' + id;
63
+ if (!s) add('todo', 0, '단계', label, { key });
64
+ else if (s.s === 'done') add('done', s.t, '단계', label, { key, dur: span(s.start, s.t) });
65
+ else add('doing', s.t, '단계', label, { key, dur: span(s.start, now), live: s.s === 'stopped' ? '멈춤' : '' });
66
+ }
67
+ for (const { id, name } of m.stories) {
68
+ const c = m.commits[id];
69
+ const st = j.ss(id);
70
+ const text = id + ' ' + name;
71
+ const a = j.busy.get(id);
72
+ if (c) add('done', c.t, '스토리', text, { key: id, hash: c.h, dur: span(m.starts[id], c.t) });
73
+ else if (st && st.status === 'done') {
74
+ const t = parseTime(st.t);
75
+ add('done', t, '스토리', text, { key: id, dur: span(m.starts[id], t) });
76
+ } else if (st && st.status === 'doing') {
77
+ const t = parseTime(st.t) || (a ? a.since : 0);
78
+ add('doing', t, '스토리', text, { key: id, dur: span(m.starts[id] || t, now), live: a ? a.label : '' });
79
+ } else if (!st && a) add('doing', a.since, '스토리', text, { key: id, dur: span(m.starts[id] || a.since, now), live: a.label });
80
+ else add('todo', 0, '스토리', text, { key: id });
81
+ }
82
+ for (const a of m.agents) if (!j.hidden(a.label)) add('doing', a.since, '에이전트', a.label, { dur: span(a.since, now) });
83
+ // 기타 항목은 s(시작 시각)가 있을 때만 소요시간을 낸다. hash(item --commit)는 앞 7자리.
84
+ for (const x of m.items) {
85
+ const t = parseTime(x.t);
86
+ const s0 = parseTime(x.s);
87
+ const hash = typeof x.hash === 'string' ? x.hash.slice(0, 7) : '';
88
+ add(x.status, t, '기타', x.text, { note: x.note, issue: x.issue, hash, dur: span(s0, x.status === 'doing' ? now : t) });
89
+ }
90
+ rows.done.sort((a, b) => a.t - b.t);
91
+ rows.doing.sort((a, b) => a.t - b.t);
92
+ // 진척률: 스토리가 있으면 스토리, 없으면 단계와 기타 항목(에이전트 행은 넣지 않음).
93
+ const total = m.stories.length;
94
+ const nDone = m.stories.filter((s) => j.state(s.id) === 'done').length;
95
+ const work = [...rows.done, ...rows.doing, ...rows.todo].filter((r) => r.kind !== '에이전트');
96
+ const iTotal = work.length;
97
+ const iDone = rows.done.filter((r) => r.kind !== '에이전트').length;
98
+ const [unit, a, b] = total ? ['스토리', nDone, total] : ['항목', iDone, iTotal];
99
+ const pct = b ? Math.round((a / b) * 100) : 0;
100
+ let seq = 0;
101
+ const section = (k) =>
102
+ `<tr class="sec ${k}"><td colspan="8">${LABEL[k]} ${rows[k].length}건</td></tr>` +
103
+ rows[k].map((r) => `<tr class="${k}"><td class="q">${++seq}</td><td class="t">${fmt(r.t)}</td><td class="d">${hm(r.dur)}</td><td><span class="b ${k}">${LABEL[k]}</span></td><td><small>${esc(r.kind)}</small> ${esc(r.text)}</td><td class="h">${esc(r.hash)}</td><td class="p">${brk(r.issue)}</td><td class="n">${brk(r.note)}</td></tr>`).join('');
104
+ // 표에 그릴 행이 하나도 없을 때만 기록이 없다는 안내 한 줄과 기록 명령(승인 목업 문구).
105
+ const empty = !m.stages.length && !rows.done.length && !rows.doing.length && !rows.todo.length;
106
+ const head = b
107
+ ? `<div>${unit} ${a}/${b} 완료 (${pct}%)</div><div class="bar"><i style="width:${pct}%"></i></div>`
108
+ : empty
109
+ ? '<div class="meta">단계와 스토리 기록 없음. ultra-chain 실행 기록(events.jsonl)이 없고 config.json 의 stories 가 비어 있음. stage, story, item 명령으로 기록</div>'
110
+ : '';
111
+ // 소요시간이 모두 "-" 면 칸이 좁아 머리글이 한 글자씩 꺾인다. 그때만 머리글을 꺾지 않는다(줄은 <br> 두 줄 그대로).
112
+ const durTh = [...rows.done, ...rows.doing, ...rows.todo].some((r) => r.dur != null) ? '<th>소요<br>시간</th>' : '<th style="white-space:nowrap">소요<br>시간</th>';
113
+ // 스토리와 단계가 모두 없는 프로젝트는 내용 칸이 좁아질 수 있어 한글 낱말 가운데에서 꺾지 않는다(공백에서만).
114
+ const keep = !m.stories.length && !m.stages.length ? ' style="word-break:keep-all"' : '';
115
+ return `${head}
116
+ <table${keep}><thead><tr><th>순번</th><th>시각</th>${durTh}<th>상태</th><th>내용</th><th>commit Hash<br>(앞 7자리)</th><th>문제점</th><th>비고</th></tr></thead><tbody>
117
+ ${section('done')}
118
+ ${section('doing')}
119
+ ${section('todo')}
120
+ </tbody></table>`;
121
+ }
122
+
123
+ // ---------- 구현요건 ----------
124
+
125
+ const REQ_LABEL = { done: '완료', part: '일부', doing: '진행 중', todo: '미구현', unv: '미검증', excl: '대상 제외', none: '명세 없음' };
126
+
127
+ // 요구(묶음) 상태. 대상 제외 명세는 넣지 않고 모두 대상 제외면 대상 제외. 차례: 모두 완료면 완료, 진행 중이 있으면 진행 중,
128
+ // 완료나 일부가 있으면 일부, 모두 미검증이나 명세 없음이고 미검증이 하나라도 있으면 미검증, 모두 명세 없음이면 명세 없음, 그 밖 미구현.
129
+ export function groupKind(ks) {
130
+ const kin = ks.filter((k) => k !== 'excl');
131
+ if (!kin.length) return 'excl';
132
+ if (kin.every((k) => k === 'done')) return 'done';
133
+ if (kin.includes('doing')) return 'doing';
134
+ if (kin.some((k) => k === 'done' || k === 'part')) return 'part';
135
+ if (kin.every((k) => k === 'none')) return 'none';
136
+ if (kin.every((k) => k === 'unv' || k === 'none')) return 'unv';
137
+ return 'todo';
138
+ }
139
+
140
+ // 판정 순서(계획 4.4절): 명세 state, 근거 AC 의 담당 스토리, 스토리 없는 AC 는 미검증, AC 없음은 명세 없음.
141
+ function reqBody(m, config) {
142
+ if (!m.reqSpec.value) return emptyNotice('references/req-spec.md', 'references/reqs.md');
143
+ const j = storyJudge(m, config);
144
+ const spec = m.reqSpec.value;
145
+ const users = arr(spec.users);
146
+ const acStories = new Map(arr(m.reqs.value && m.reqs.value.acs).map((a) => [a && a.id, a && a.stories]));
147
+ const judge = (stories) => {
148
+ const left = stories.filter((s) => j.state(s) !== 'done');
149
+ return { k: !left.length ? 'done' : left.some((s) => j.state(s) === 'doing') ? 'doing' : left.length < stories.length ? 'part' : 'todo', left };
150
+ };
151
+ const cnt = { done: 0, part: 0, doing: 0, todo: 0, unv: 0, excl: 0, none: 0 };
152
+ // 스토리가 없는 프로젝트(설정과 story 명령 모두 없음)는 빈 담당 스토리 칸과 빈 스토리 표를 그리지 않는다.
153
+ const specStories = Object.entries(spec.stories && typeof spec.stories === 'object' ? spec.stories : {});
154
+ const noStories = !m.stories.length;
155
+ const stCol = !noStories || users.some((u) => arr(u.specs).some((x) => arr(x.acs).some((a) => arr(acStories.get(a)).length)));
156
+ let nSpec = 0;
157
+ const body = users
158
+ .map((u, ui) => {
159
+ const ks = [];
160
+ const rows = arr(u.specs)
161
+ .map((x, si) => {
162
+ const acs = arr(x.acs);
163
+ const stories = [...new Set(acs.flatMap((a) => arr(acStories.get(a))))];
164
+ let r;
165
+ if (x.state && REQ_LABEL[x.state]) r = { k: x.state, left: [] };
166
+ else if (acs.length && stories.length) r = judge(stories);
167
+ else if (acs.length) r = { k: 'unv', left: [], why: '담당 스토리 없음' };
168
+ else r = { k: 'none', left: [] };
169
+ const { k, left } = r;
170
+ ks.push(k);
171
+ cnt[k]++;
172
+ nSpec++;
173
+ const st = stories.map((s) => `<span class="s${j.state(s) === 'done' ? ' ok' : ''}">${esc(s)}</span>`).join('');
174
+ const note = [r.why || (left.length && k !== 'todo' ? '남은 스토리 ' + left.join(', ') : ''), x.note].filter(Boolean).join('. ');
175
+ return `<tr><td class="q">${ui + 1}.${si + 1}</td><td>${brk(x.text)}</td><td class="h">${esc(acs.join(', '))}</td>${stCol ? `<td class="st">${st}</td>` : ''}<td><span class="b ${k === 'none' ? 'miss' : k}">${REQ_LABEL[k]}</span></td><td class="n">${esc(note)}</td></tr>`;
176
+ })
177
+ .join('');
178
+ const uk = groupKind(ks);
179
+ return `<tr class="sec"><td class="q">${ui + 1}</td><td colspan="${stCol ? 3 : 2}">${brk(u.text)} <small>${esc(u.source)}</small></td><td><span class="b ${uk === 'none' ? 'miss' : uk}">${REQ_LABEL[uk]}</span></td><td></td></tr>${rows}`;
180
+ })
181
+ .join('');
182
+ // 대상 제외 명세는 완료율의 분모에서 뺀다.
183
+ const pct = Math.round((cnt.done / (nSpec - cnt.excl || 1)) * 100);
184
+ // 담당 스토리 칸이 없을 때 판정 문구가 기본(스토리 기준) 그대로면 state 판정 문구로 바꾼다.
185
+ // 담당 스토리 칸이 없으면 상태 칸이 좁아 "구현 여부" 가 꺾이므로 그 머리글은 꺾지 않는다(아래 머리 줄).
186
+ const rule = !stCol && config.text.reqRule === DEFAULT_REQ_RULE ? STORYLESS_REQ_RULE : config.text.reqRule;
187
+ const stories = specStories
188
+ .map(([id, v]) => {
189
+ const [name, what] = Array.isArray(v) ? v : [v, ''];
190
+ const k = j.state(id);
191
+ return `<tr><td class="st"><span class="s${k === 'done' ? ' ok' : ''}">${esc(id)}</span></td><td>${esc(name)}</td><td>${esc(what)}</td><td><span class="b ${k}">${REQ_LABEL[k]}</span></td></tr>`;
192
+ })
193
+ .join('');
194
+ return `<div>요구 ${users.length}건, 기능 명세 ${nSpec}건: 완료 ${cnt.done}, 일부 ${cnt.part}, 진행 중 ${cnt.doing}, 미구현 ${cnt.todo}, 미검증 ${cnt.unv}, 대상 제외 ${cnt.excl}, 명세 없음 ${cnt.none} (대상 제외를 뺀 완료 ${pct}%)</div><div class="bar"><i style="width:${pct}%"></i></div>
195
+ <div class="meta">${sent(`${esc(rule)} ${esc(spec.excluded || '')}`)}</div>
196
+ <table><thead><tr><th>순번</th><th>요구 / 기능 명세</th><th>근거 AC</th>${stCol ? '<th>담당 스토리</th><th>구현 여부</th>' : '<th style="white-space:nowrap">구현 여부</th>'}<th>비고</th></tr></thead><tbody>${body}</tbody></table>${noStories && !specStories.length ? '' : `
197
+ <h2>스토리</h2>
198
+ <table class="sty"><thead><tr><th>스토리</th><th>이름</th><th>내용</th><th>상태</th></tr></thead><tbody>${stories}</tbody></table>`}`;
199
+ }
200
+
201
+ // ---------- workflow ----------
202
+ // 흐름별 SVG. 상자 id(<흐름>-<단계>)와 data-scene 은 모션 그래픽으로 옮길 때 장면과 상자를 잇는 열쇠다.
203
+ // FPAD: 그림 좌우 여백(줄 바꿈 연결선이 지나는 자리), FOUT: 그 연결선이 상자 옆면에서 나가는 거리.
204
+ export const FLOW = { FW: 176, FH: 92, FGX: 38, FGY: 104, FPER: 5, FTOP: 34, FSTEP_S: 0.9, FPAD: 40, FOUT: 28 };
205
+ const { FW, FH, FGX, FGY, FPER, FTOP, FSTEP_S, FPAD, FOUT } = FLOW;
206
+ const PLANNED_TAG = '예정';
207
+
208
+ // 두 사각형이 가로와 세로 모두 1 px 이상 겹치는 양. 변이 닿기만 하면 0.
209
+ const overlap = (a0, a1, b0, b1) => Math.min(a1, b1) - Math.max(a0, b0);
210
+
211
+ // 단계 이름(.lb, 14px 굵게) 폭 추정. textW 의 글자 구분(0x2e7f 넘는 한글 등과 그 밖)을 14px 굵은 글꼴 실측에 맞춰 나눔.
212
+ // 브라우저 실측(Chromium, 맑은 고딕 굵게 14px): 한글 14.0, 공백 4.9, M W m w 12.9~14.1, 그 밖 대문자 11.2 이하,
213
+ // i l f r 과 문장 부호 5.8 이하, 그 밖 소문자와 숫자 8.8 이하. 이 저장소 이름 33개에서 실측보다 작게 잡은 것 0.
214
+ const nameW = (t) =>
215
+ [...String(t || '')].reduce((a, ch) => a + (ch === ' ' ? 5 : ch.charCodeAt(0) > 0x2e7f || /[MWmw@%&]/.test(ch) ? 14 : /[A-Z]/.test(ch) ? 11 : /[ijlIft.,:;|!()[\]{}/\-r]/.test(ch) ? 6 : 8.8), 0);
216
+
217
+ // 단계 이름 한 줄이 상자 안에 들어가는지. 이름은 x + 32 에서 시작해 상자 테두리(폭 2) 안쪽 176 - 2 까지.
218
+ // 예정 단계는 오른쪽 "예정" 표기(끝 176 - 8, 폭 textW) 앞 4 px 까지. 옛 서버는 줄바꿈하지 않으므로 정말 넘칠 때만 두 줄.
219
+ export const nameFits = (s, planned) => 32 + nameW(s) <= (planned ? FW - 8 - textW(PLANNED_TAG) - 4 : FW - 2);
220
+
221
+ // 단계 이름 줄. 한 줄에 들어가지 않으면 두 줄.
222
+ export function labelLines(label, planned) {
223
+ const t = String(label ?? '');
224
+ if (nameFits(t, planned)) return [t];
225
+ const words = t.split(' ');
226
+ let first = words[0];
227
+ let i = 1;
228
+ while (i < words.length && nameFits(first + ' ' + words[i], planned)) first += ' ' + words[i++];
229
+ const rest = words.slice(i).join(' ');
230
+ return rest ? [first, rest] : [t];
231
+ }
232
+
233
+ // 흐름 하나의 배치(렌더와 check 가 같이 씀). 옛 공식(한 줄 5상자 지그재그)에 두 가지만 더함:
234
+ // 분기 표가 아래 줄 상자나 외부 표와 1 px 이상 겹치면 그 줄 간격을 필요한 만큼 늘림, 넘치는 이름은 두 줄
235
+ // (설명은 한 줄 내리고 3줄 그대로. 설명이 3줄이면 그 상자 높이를 15 px 늘리고 다음 줄도 그만큼 내림).
236
+ export function flowLayout(l) {
237
+ const steps = arr(l.steps);
238
+ const branches = arr(l.branches);
239
+ const externals = arr(l.externals);
240
+ const n = steps.length;
241
+ const rows = Math.ceil(n / FPER);
242
+ const byId = new Map(steps.map((s, i) => [s.id, i]));
243
+ const brText = (b) => {
244
+ const j = byId.get(b.to);
245
+ return String(b.when ?? '') + (j !== undefined ? ` (${j + 1}번)` : '');
246
+ };
247
+ // 줄 바꿈 연결선은 이 흐름에서 가장 긴 분기 표의 오른쪽 끝보다 바깥을 지난다.
248
+ const out = Math.max(FOUT, ...branches.map((b) => 20 + textW(brText(b)) + 16 - FW + 10));
249
+ const pad = Math.max(FPAD, out + 12);
250
+ const cells = steps.map((s, i) => {
251
+ const r = Math.floor(i / FPER);
252
+ const c = i % FPER;
253
+ const col = r % 2 ? FPER - 1 - c : c;
254
+ return { x: pad + col * (FW + FGX), r, c };
255
+ });
256
+ // 분기 표(상자 아래, 상자마다 차례 k). y 는 줄 위에서의 거리.
257
+ const brCount = new Map();
258
+ const brs = [];
259
+ for (const b of branches) {
260
+ const i = byId.get(b.from);
261
+ if (i === undefined) continue;
262
+ const k = brCount.get(i) || 0;
263
+ brCount.set(i, k + 1);
264
+ const text = brText(b);
265
+ brs.push({ b, i, k, text, w: textW(text) + 16 });
266
+ }
267
+ // 외부 표(상자 위). 한 상자에 여럿이면 옆으로.
268
+ const extX = new Map();
269
+ const exts = [];
270
+ for (const e of externals) {
271
+ const i = byId.get(e.linkTo);
272
+ if (i === undefined) continue;
273
+ const x = extX.get(i) ?? cells[i].x + 6;
274
+ const w = textW(e.label) + 16;
275
+ extX.set(i, x + w + 6);
276
+ exts.push({ e, i, x, w });
277
+ }
278
+ const names = steps.map((s) => labelLines(s.label, s.planned));
279
+ // 이름이 두 줄이면 설명 첫 줄을 한 줄(15 px) 내린다. 설명은 3줄까지 그대로 두고, 내린 설명이 3줄이면 상자를 15 px 늘린다.
280
+ const downs = names.map((nm) => (nm.length > 1 ? 15 : 0));
281
+ const descs = steps.map((s) => wrap(s.detail, FW - 20).slice(0, 3));
282
+ const hs = descs.map((d, i) => FH + (downs[i] && d.length > 2 ? 15 : 0));
283
+ const rowH = Array.from({ length: rows }, (_, r) => Math.max(FH, ...hs.filter((_, i) => cells[i].r === r)));
284
+ // 줄 위치. 기본 간격 FGY(앞 줄의 가장 높은 상자 아래부터), 겹치면 늘림.
285
+ const rowTop = [];
286
+ for (let r = 0; r < rows; r++) {
287
+ if (!r) {
288
+ rowTop.push(FTOP);
289
+ continue;
290
+ }
291
+ const top = rowTop[r - 1];
292
+ const H = rowH[r - 1];
293
+ let gap = FGY;
294
+ for (const q of brs) {
295
+ if (cells[q.i].r !== r - 1) continue;
296
+ const x0 = cells[q.i].x + 20;
297
+ const bottom = top + hs[q.i] + 10 + q.k * 24 + 20;
298
+ for (let i = 0; i < n; i++) {
299
+ if (cells[i].r !== r || overlap(x0, x0 + q.w, cells[i].x, cells[i].x + FW) < 1) continue;
300
+ if (bottom - (top + H + gap) >= 1) gap = bottom - top - H;
301
+ }
302
+ for (const x of exts) {
303
+ if (cells[x.i].r !== r || overlap(x0, x0 + q.w, x.x, x.x + x.w) < 1) continue;
304
+ if (bottom - (top + H + gap - 26) >= 1) gap = bottom - top - H + 26;
305
+ }
306
+ }
307
+ rowTop.push(top + H + gap);
308
+ }
309
+ const pos = cells.map((c) => ({ x: c.x, y: rowTop[c.r], r: c.r, c: c.c }));
310
+ const maxBr = Math.max(0, ...brCount.values());
311
+ const h = rows ? rowTop[rows - 1] + rowH[rows - 1] + 14 + maxBr * 24 : FTOP + 14;
312
+ const cols = Math.min(n, FPER);
313
+ const w = Math.max(0, 2 * pad + cols * FW + (cols - 1) * FGX);
314
+ const branchesAt = brs.map((q) => {
315
+ const p = pos[q.i];
316
+ const y = p.y + hs[q.i] + 10 + q.k * 24;
317
+ return { ...q, sx: p.x + 10, x: p.x + 20, y, cy: y + 10, y0: q.k ? y + 10 - 24 : p.y + hs[q.i] };
318
+ });
319
+ const externalsAt = exts.map((q) => ({ ...q, y: pos[q.i].y - 26 }));
320
+ return { n, rows, out, pos, rowTop, names, downs, descs, hs, branches: branchesAt, externals: externalsAt, w, h };
321
+ }
322
+
323
+ function laneSvg(l, li, scenesOf) {
324
+ const L = flowLayout(l);
325
+ const steps = arr(l.steps);
326
+ const lc = color(l.color, '#455a64');
327
+ const { n, pos, out } = L;
328
+ const cycle = n * FSTEP_S + 1.5;
329
+ const hi = Math.max(2, Math.round((FSTEP_S / cycle) * 100));
330
+ const parts = [];
331
+ // 연결선: 같은 줄은 옆으로. 줄이 바뀌면 줄 끝 상자의 바깥 옆면에서 나가 그림 가장자리를 따라 내려와
332
+ // 다음 줄 상자의 같은 옆면으로 들어간다. alone 단계는 앞 단계와 잇지 않는다(다른 진입점).
333
+ for (let i = 0; i + 1 < n; i++) {
334
+ if (steps[i + 1].alone) continue;
335
+ const a = pos[i];
336
+ const b = pos[i + 1];
337
+ const ltr = a.r % 2 === 0;
338
+ if (a.r === b.r) {
339
+ const x1 = ltr ? a.x + FW : a.x;
340
+ const x2 = ltr ? b.x - 4 : b.x + FW + 4;
341
+ const y = a.y + FH / 2;
342
+ parts.push(`<line class="fl" x1="${x1}" y1="${y}" x2="${x2}" y2="${y}" stroke="${lc}" marker-end="url(#ah${li})"/>`);
343
+ } else {
344
+ const ya = a.y + FH / 2;
345
+ const yb = b.y + FH / 2;
346
+ const pts = ltr
347
+ ? [[a.x + FW, ya], [a.x + FW + out, ya], [a.x + FW + out, yb], [b.x + FW + 4, yb]]
348
+ : [[a.x, ya], [a.x - out, ya], [a.x - out, yb], [b.x - 4, yb]];
349
+ parts.push(`<polyline class="fl" points="${pts.map((q) => q.join(',')).join(' ')}" fill="none" stroke="${lc}" marker-end="url(#ah${li})"/>`);
350
+ }
351
+ }
352
+ steps.forEach((s, i) => {
353
+ const p = pos[i];
354
+ const sc = scenesOf(l.id, s.id);
355
+ const name = L.names[i];
356
+ const down = L.downs[i];
357
+ const refs = arr(s.refs);
358
+ parts.push(`<g class="n${s.planned ? ' pl' : ''}" id="${esc(l.id + '-' + s.id)}" data-scene="${esc(sc.join(','))}" style="animation:fp${li} ${cycle}s ${(i * FSTEP_S).toFixed(1)}s infinite">
359
+ <title>${esc(s.actor + ': ' + s.detail + (refs.length ? ' (' + refs.join(', ') + ')' : ''))}</title>
360
+ <rect x="${p.x}" y="${p.y}" width="${FW}" height="${L.hs[i]}" rx="10" fill="#fff" stroke="${lc}" stroke-width="2"${s.planned ? ' stroke-dasharray="6 4"' : ''}/>
361
+ <circle cx="${p.x + 16}" cy="${p.y + 16}" r="10" fill="${lc}"/><text x="${p.x + 16}" y="${p.y + 20}" class="no">${i + 1}</text>
362
+ <text x="${p.x + 32}" y="${p.y + 21}" class="lb">${esc(name[0])}</text>${name[1] !== undefined ? `<text x="${p.x + 32}" y="${p.y + 37}" class="lb l2">${esc(name[1])}</text>` : ''}
363
+ <text x="${p.x + FW - 8}" y="${p.y + 21}" class="ac">${s.planned ? PLANNED_TAG : ''}</text>
364
+ ${L.descs[i].map((t, k) => `<text x="${p.x + 10}" y="${p.y + 42 + down + k * 15}" class="dt">${esc(t)}</text>`).join('')}
365
+ <text x="${p.x + 10}" y="${p.y + L.hs[i] - 7}" class="who">${esc(s.actor)}</text></g>`);
366
+ });
367
+ // 외부 구성요소: 연결된 상자 위에 회색 표. 예정 변경은 주황 표.
368
+ for (const { e, i, x, w } of L.externals) {
369
+ const p = pos[i];
370
+ const y = p.y - 26;
371
+ const [fill, line] = e.planned ? ['#fff4e0', '#b26a00'] : ['#eef0f3', '#999'];
372
+ parts.push(`<g class="ex" id="${esc(l.id + '-' + e.id)}"><title>${esc(e.note)}</title><line x1="${x + w / 2}" y1="${y + 18}" x2="${x + w / 2}" y2="${p.y}" stroke="${line}" stroke-dasharray="3 3"/><rect x="${x}" y="${y}" width="${w}" height="18" rx="9" fill="${fill}" stroke="${line}" stroke-dasharray="3 3"/><text x="${x + w / 2}" y="${y + 13}" class="et">${esc(e.label)}</text></g>`);
373
+ }
374
+ // 분기: 상자 아래 빨간 표. 상자 왼쪽 아래의 세로선 하나에서 표마다 가지를 친다. note 는 마우스를 올리면 보인다.
375
+ for (const { b, text, sx, x, y, cy, y0, w } of L.branches) {
376
+ parts.push(`<g class="br"><title>${esc(b.note || b.when)}</title><polyline points="${sx},${y0} ${sx},${cy} ${x},${cy}" fill="none" stroke="#c62828" stroke-dasharray="3 3"/><rect x="${x}" y="${y}" width="${w}" height="20" rx="10" fill="#fdecea" stroke="#c62828"/><text x="${x + 8}" y="${y + 14}" class="bt">${esc(text)}</text></g>`);
377
+ }
378
+ return `<style>@keyframes fp${li}{0%{filter:none}${Math.round(hi / 2)}%{filter:drop-shadow(0 0 7px ${lc})}${hi}%,100%{filter:none}}</style>
379
+ <svg class="flow" viewBox="0 0 ${L.w} ${L.h}" width="${L.w}" height="${L.h}" role="img" aria-label="${esc(l.title)}"><defs><marker id="ah${li}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0L10 5L0 10z" fill="${lc}"/></marker></defs>${parts.join('')}</svg>`;
380
+ }
381
+
382
+ function flowBody(m) {
383
+ if (!m.flow.value) return emptyNotice('references/flow.md');
384
+ const f = m.flow.value;
385
+ const lanes = arr(f.lanes);
386
+ const scenes = arr(f.scenes);
387
+ const scenesOf = (lane, id) => scenes.filter((s) => s.lane === lane && arr(s.focus).includes(id)).map((s) => s.n);
388
+ const body = lanes
389
+ .map((l, li) => `<h2><span class="dot" style="background:${color(l.color, '#455a64')}"></span>${esc(l.title)} <small>${arr(l.steps).length}단계</small></h2>${l.note ? `<div class="meta">${brk(l.note)}</div>` : ''}<div class="fwrap">${laneSvg(l, li, scenesOf)}</div>`)
390
+ .join('');
391
+ const total = scenes.reduce((a, s) => a + (finite(s.sec) ? s.sec : 0), 0);
392
+ const laneTitle = new Map(lanes.map((l) => [l.id, l.title]));
393
+ const stepLabel = (lane, id) => {
394
+ const l = lanes.find((x) => x.id === lane);
395
+ const i = l ? arr(l.steps).findIndex((s) => s.id === id) : -1;
396
+ return i < 0 ? id : `${i + 1} ${l.steps[i].label}`;
397
+ };
398
+ const rows = scenes.map((s) => `<tr><td class="q">${esc(s.n)}</td><td class="d">${esc(s.sec)}초</td><td>${esc(laneTitle.get(s.lane) || s.lane || '전체')}</td><td>${esc(s.caption)}</td><td class="n">${esc(arr(s.focus).map((id) => stepLabel(s.lane, id)).join(', '))}</td></tr>`).join('');
399
+ return `<div class="meta">${sent(`${esc(f.note || '')} 실행 순서를 그린 화면. 구성요소 사이의 연결은 구성도 탭. 자료: <code>flow.json</code>. 상자에 마우스를 올리면 행위자, 근거 파일이 보임. 점선 상자는 예정(아직 구현 안 됨), 빨간 표는 분기, 회색 표는 외부 구성요소, 주황 표는 예정 변경. 표에 마우스를 올리면 근거가 보임.`)}</div>
400
+ ${body}
401
+ <h2>장면 대본 <small>모션 그래픽 준비, 합계 ${total}초</small></h2>
402
+ <table><thead><tr><th>장면</th><th>길이</th><th>흐름</th><th>화면 내용</th><th>강조 단계</th></tr></thead><tbody>${rows}</tbody></table>`;
403
+ }
404
+
405
+ // ---------- 구성도 ----------
406
+ // 영역(zones), 구성요소(nodes), 연결(edges). 연결 종류: api(요청이 흐르는 길, 움직이는 굵은 선),
407
+ // control(실행, 호출), data(파일, 설정 읽기와 쓰기, 점선).
408
+ const ACOL = { api: '#e65100', control: '#455a64', data: '#8d8d8d' };
409
+ // 자동 배치 치수: 바깥 여백, 영역 머리, 영역 안 여백, 상자 사이 가로와 세로, 영역 사이(위아래, 옆), 상자 최소 크기,
410
+ // 띠 폭 상한 후보, 넓은 후보(가로세로 비를 못 맞출 때만), 가로세로 비 하한과 그 하한을 적용하는 상자 수, WARN 하나의 값(px),
411
+ // 배치 고르기에서 선 길이 1 px 의 값, 엇갈림 하나의 값, 붐비는 이름표(상자에서 LBOX 안, 영역 이름이나 테두리를 가림, 다른 선이 LGAP 안) 하나의 값,
412
+ // 선을 그어 보는 후보 수, 상자 순서 후보에서 옮기는 상자의 다른 영역 연결 수, 두 끝이 속하지 않는 영역 안을 지나는 선 1 px 의 값,
413
+ // 좁은 나란한 선 쌍과 테두리 붙은 긴 선분 하나의 값(routeSum).
414
+ // 연결 길 찾기 값은 아래 ROUTE.
415
+ const AUTO = { PAD: 20, HEAD: 32, IN: 16, GX: 60, GY: 40, ZGAP: 72, ZGX: 48, NW: 160, NH: 64, WIDTHS: [1200, 1400, 1560], WIDE: 1760, RATIO: 1.2, MANY: 20, WARN_PX: 60, LEN: 0.1, CROSS: 10, CROWD: 30, TRY: 3, HUB: 4, FOREIGN: 0.3, TIGHT: 20 };
416
+
417
+ // 상자 a 의 가장자리에서 점 (bx, by) 쪽으로 나가는 점. off 는 가장자리를 따라 옮기는 거리.
418
+ function exitPt(a, bx, by, off = 0) {
419
+ const ax = a.x + a.w / 2;
420
+ const ay = a.y + a.h / 2;
421
+ const dx = bx - ax;
422
+ const dy = by - ay;
423
+ if (Math.abs(dx) * a.h >= Math.abs(dy) * a.w) return [dx > 0 ? a.x + a.w : a.x, ay + off];
424
+ return [ax + off, dy > 0 ? a.y + a.h : a.y];
425
+ }
426
+
427
+ // 상자 a 에서 b 까지 선의 점. via 는 꺾는 점.
428
+ function pathOf(a, b, via, off, offTo) {
429
+ const first = via[0] || [b.x + b.w / 2, b.y + b.h / 2];
430
+ const last = via[via.length - 1] || [a.x + a.w / 2, a.y + a.h / 2];
431
+ return [exitPt(a, first[0], first[1], off), ...via, exitPt(b, last[0], last[1], offTo)];
432
+ }
433
+
434
+ // 선분의 둘레 사각형이 상자 안쪽(가장자리 1 px 안쪽)과 겹치는지. 가로나 세로 선분이면 상자를 지나는지와 같다.
435
+ const axisHits = (p, q, r) => Math.max(p[0], q[0]) > r.x + 1 && Math.min(p[0], q[0]) < r.x + r.w - 1 && Math.max(p[1], q[1]) > r.y + 1 && Math.min(p[1], q[1]) < r.y + r.h - 1;
436
+
437
+ // 선분이 상자 안을 지나는지(가장자리 1 px 안쪽, 표본점 19개). check 의 판정. 둘레 사각형이 겹치지 않으면 표본점도 들어가지 않는다.
438
+ function segHits(p, q, r) {
439
+ if (!axisHits(p, q, r)) return false;
440
+ for (let k = 1; k < 20; k++) {
441
+ const x = p[0] + ((q[0] - p[0]) * k) / 20;
442
+ const y = p[1] + ((q[1] - p[1]) * k) / 20;
443
+ if (x > r.x + 1 && x < r.x + r.w - 1 && y > r.y + 1 && y < r.y + r.h - 1) return true;
444
+ }
445
+ return false;
446
+ }
447
+
448
+ const RECT = ['x', 'y', 'w', 'h'];
449
+ const optNum = (v) => v === undefined || v === null || finite(v);
450
+ const okVia = (v) => v === undefined || v === null || (Array.isArray(v) && v.every((p) => Array.isArray(p) && p.length === 2 && finite(p[0]) && finite(p[1])));
451
+
452
+ // 좌표를 쓸 수 없는 첫 키 경로. 모두 맞으면 null(계획 4.5절 키 집합).
453
+ export function missingCoord(f) {
454
+ for (const k of ['width', 'height']) if (!finite(f[k])) return k;
455
+ for (const [list, name] of [[arr(f.zones), 'zones'], [arr(f.nodes), 'nodes']]) {
456
+ for (let i = 0; i < list.length; i++) for (const k of RECT) if (!finite(list[i] && list[i][k])) return `${name}[${i}].${k}`;
457
+ }
458
+ const edges = arr(f.edges);
459
+ for (let i = 0; i < edges.length; i++) {
460
+ const e = edges[i] || {};
461
+ if (!okVia(e.via)) return `edges[${i}].via`;
462
+ for (const k of ['lx', 'ly', 'off', 'offTo']) if (!optNum(e[k])) return `edges[${i}].${k}`;
463
+ }
464
+ return null;
465
+ }
466
+
467
+ // 자동 배치의 상자 크기: 이름, 설명 줄(가장 긴 낱말이 한 줄에 들어가는 폭), 칩 줄이 들어가는 폭과 높이.
468
+ function nodeNeed(n, w, prefix) {
469
+ const sub = wrap(n.detail, w - 16).slice(0, n.lines || 2);
470
+ let cx = 8;
471
+ let cy = 26 + sub.length * 14 + 4;
472
+ let chips = false;
473
+ let wide = 0;
474
+ for (const t of arr(n.items)) {
475
+ const cw = textW(t) + 12;
476
+ wide = Math.max(wide, cw + 14);
477
+ if (cx + cw > w - 6) {
478
+ cx = 8;
479
+ cy += 19;
480
+ }
481
+ cx += cw + 4;
482
+ chips = true;
483
+ }
484
+ const textBottom = sub.length ? 34 + (sub.length - 1) * 14 + 4 : 22;
485
+ const tag = n.planned ? textW(prefix) + 8 : 0;
486
+ const word = Math.max(0, ...String(n.detail || '').split(' ').map((t) => Math.ceil(textW(t) * 1.1) + 16));
487
+ return { h: Math.max(AUTO.NH, Math.max(textBottom, chips ? cy + 16 : 0) + 10), w: Math.max(Math.ceil(textW(n.label) * 1.3) + 16 + tag, wide, word) };
488
+ }
489
+
490
+ // 영역 제목 띠: 렌더가 영역 이름을 쓰는 자리(왼쪽 위 + (12, 5), 굵은 13px 글 폭 x 1.3, 높이 18). 길 찾기의 장애물.
491
+ const titleBand = (z, x, y) => ({ x: x + 12, y: y + 5, w: textW(z.label) * 1.3, h: 18 });
492
+
493
+ // 자동 배치 후보 하나. 영역은 groups 순서대로 띠(가로 줄)에 왼쪽부터 채우고, 띠 폭이 maxW 를 넘으면 다음 띠.
494
+ // 영역 안 상자는 rows 줄 이하가 되는 가장 적은 칸 수로 행 우선(영역 폭이 maxW 를 넘으면 칸을 줄임). snake 면 짝수 번째 띠(둘째, 넷째)를 오른쪽부터 채운다.
495
+ // 띠는 모두 가장 넓은 띠 폭에 맞춰 영역 폭을 똑같이 늘리고, 영역 높이는 그 띠의 가장 높은 영역에 맞춘다.
496
+ function autoPlace(groups, rows, maxW, snake, gapOf = () => AUTO.ZGX, bandGap = () => AUTO.ZGAP) {
497
+ const { PAD, HEAD, IN, GX, GY } = AUTO;
498
+ const gs = groups.map((g) => {
499
+ const n = g.list.length;
500
+ // 칸 수는 영역 폭이 띠 폭 상한 maxW 안에 들도록 줄인다(줄이 rows 보다 많아짐). 그림 폭은 maxW + 2 x PAD 이하.
501
+ const gx = g.gx || GX;
502
+ const c = Math.max(1, Math.min(Math.ceil(n / rows), Math.floor((maxW - 2 * IN + gx) / (g.nw + gx))));
503
+ const r = Math.max(1, Math.ceil(n / c));
504
+ const gw = c * g.nw + (c - 1) * gx;
505
+ return { ...g, gx, c, r, gw, w: Math.max(2 * IN + gw, g.z ? Math.ceil(textW(g.z.label) * 1.3) + 24 : 0), h: HEAD + r * g.nh + (r - 1) * GY + IN };
506
+ });
507
+ const bands = [];
508
+ let cur = null;
509
+ for (const g of gs) {
510
+ // 영역 없는 상자 묶음은 맨 아래 띠에 혼자. 옆 영역 사이 틈은 gapOf(왼쪽 묶음, 오른쪽 묶음).
511
+ if (!cur || !g.z || cur.w + gapOf(cur.list.at(-1), g) + g.w > maxW) bands.push((cur = { list: [], gaps: [], w: 0 }));
512
+ const gap = cur.list.length ? gapOf(cur.list.at(-1), g) : 0;
513
+ cur.gaps.push(gap);
514
+ cur.list.push(g);
515
+ cur.w += gap + g.w;
516
+ if (!g.z) cur = null;
517
+ }
518
+ const W = Math.max(0, ...bands.map((b) => b.w));
519
+ const zone = new Map();
520
+ const node = new Map();
521
+ const titles = [];
522
+ let y = PAD;
523
+ bands.forEach((b, bi) => {
524
+ const k = b.list.length;
525
+ const extra = W - b.w;
526
+ const H = Math.max(...b.list.map((g) => g.h));
527
+ let x = PAD;
528
+ b.list.forEach((g, gi) => {
529
+ x += b.gaps[gi];
530
+ const w = g.w + Math.floor(extra / k) + (gi === k - 1 ? extra % k : 0);
531
+ const zx = snake && bi % 2 ? 2 * PAD + W - x - w : x;
532
+ if (g.z) {
533
+ zone.set(g.z.id, { x: zx, y, w, h: H });
534
+ titles.push(titleBand(g.z, zx, y));
535
+ }
536
+ const x0 = zx + Math.floor((w - g.gw) / 2);
537
+ g.list.forEach((n, i) => node.set(n.id, { x: x0 + (i % g.c) * (g.nw + g.gx), y: y + HEAD + Math.floor(i / g.c) * (g.nh + GY), w: g.nw, h: g.nh }));
538
+ x += w;
539
+ });
540
+ y += H + (bi + 1 < bands.length ? bandGap(bi) : 0);
541
+ });
542
+ return { auto: true, width: 2 * PAD + W, height: bands.length ? y + PAD : 2 * PAD, zone, node, titles, sig: bands.map((b) => b.list.map((g) => g.c).join(',')).join('/') };
543
+ }
544
+
545
+ // 자동 배치 연결의 길 찾기 값(px 또는 px 로 친 값).
546
+ // M: 격자점이 상자 테두리에서 떨어질 거리. SNAP: 이보다 가까운 격자선은 하나로. BEND: 꺾기 하나. CROSS: 다른 선과 엇갈림 하나.
547
+ // SHARE: 다른 선이 이미 지나는 통로를 같이 쓸 때 px 당 더하는 비율(쓰는 선 하나마다). OVER: 통로에 들어갈 나란한 선 수를 넘길 때 px 당 비율.
548
+ // TRACK: 같은 통로 나란한 선 사이. NEAR: 다른 묶음 선과 이보다 가까우면 합쳐 다시 벌림(TRACK 과 같아 따로 놓인 나란한 선도 TRACK 간격으로). KEEP: 나란한 선과 상자 사이(모자라면 HARD 까지).
549
+ // ZK: 선과 나란한 영역 테두리, 그림 가장자리 사이(늘 지킴).
550
+ // CORNER: 끝 자리와 옆면 모서리 사이. OFFW: 옆면 가운데에서 떨어진 자리 px 당 값. PORT: 같은 옆면을 이미 쓰는 연결 하나마다(같은 자리면 LINE 더).
551
+ // ARROW: 끝 선분 최소 길이(화살표). LPAD: 이름표 자리를 찾을 때 상자와 둘 여유. LGAP: 이름표와 다른 선, 영역 테두리 사이 여유.
552
+ // LROW: 이름표 있는 연결의 나란한 가로 선분 사이(이름표 높이 16 + LGAP, 이름표가 위아래로 들어감).
553
+ // ZONE: 두 끝이 속하지 않는 영역 안을 지날 때 px 당 더하는 비율. RIM: 그림 가장자리 여백이나 영역 머리 줄을 따라 달릴 때 px 당 더하는 비율.
554
+ // ZL: 이름표 있는 선과 LONG 넘는 선분이 나란한 영역 테두리에서 먼저 지킬 거리(모자라면 ZK). LBOX: 이름표와 상자 사이 여유.
555
+ const ROUTE = { M: 8, SNAP: 6, BEND: 40, CROSS: 30, SHARE: 0.5, TRACK: 12, NEAR: 12, KEEP: 10, HARD: 3, ZK: 6, OVER: 4, CORNER: 10, OFFW: 0.5, PORT: 12, LINE: 30, ARROW: 10, LPAD: 6, LGAP: 4, LROW: 20, ZONE: 1, RIM: 0.5, ZL: 12, LONG: 100, LBOX: 5 };
556
+ const BACK = [1, 0, 3, 2];
557
+
558
+ // 길 찾기 격자. 가로세로 선: 상자 가운데 선(옆면, 위아래 가운데에서 나가는 자리)과 통로 가운데(상자 옆 틈, 상자 아래 틈,
559
+ // 같은 행 이웃 상자 사이 틈, 영역 사이 틈, 영역 안 상자 묶음 둘레의 넓은 빈 곳, 그림 가장자리 여백). SNAP 보다 가까운 선은 하나만 둔다(상자 가운데 선이 먼저).
560
+ // 통로 가운데 선이 그림 가장자리 여백 가운데보다 바깥이면 버린다. 영역 테두리에 가까운 선도 남긴다(다른 띠 영역의 테두리에 가까워 이웃 상자 사이 틈 가운데 선이
561
+ // 사라지는 일이 없게). 그 테두리를 따라 가는 것은 아래 noH, noV 가 막는다.
562
+ // 영역 제목 띠를 지나는 가로선과 세로선이 없으면 띠 가운데에 하나씩 더한다.
563
+ // 격자점은 상자와 제목 띠(장애물)를 M 만큼 키운 사각형 밖일 때만 쓴다. 장애물마다 그 안을 지나는 격자선이 있으므로 이웃 격자점 둘이 모두
564
+ // 쓸 수 있으면 그 사이 선분은 장애물을 지나지 않는다.
565
+ // 영역 테두리에서 ZK 보다 가까운 격자선(상자 가운데 선 등)은 그 테두리를 따라 가지 못한다(noH, noV: 그 점에서 오른쪽, 아래로 가는 선분). 가로지르기는 된다.
566
+ // 돌려줌의 walls: 통로 폭과 나란한 선 자리를 정할 때 보는 것(상자, 제목 띠, 두께 0 의 영역 테두리 네 변).
567
+ function routeGrid(L) {
568
+ const { PAD, GX, GY, ZGAP, ZGX } = AUTO;
569
+ const { M, SNAP, ZK } = ROUTE;
570
+ const boxes = [...L.node.values()];
571
+ const titles = L.titles || [];
572
+ const zs = [...L.zone.values()];
573
+ const borders = zs.flatMap((z) => [{ x: z.x, y: z.y, w: z.w, h: 0 }, { x: z.x, y: z.y + z.h, w: z.w, h: 0 }, { x: z.x, y: z.y, w: 0, h: z.h }, { x: z.x + z.w, y: z.y, w: 0, h: z.h }]);
574
+ const cx = boxes.map((r) => r.x + r.w / 2);
575
+ const cy = boxes.map((r) => r.y + r.h / 2);
576
+ const xs = [PAD / 2, L.width - PAD / 2];
577
+ const ys = [PAD / 2, L.height - PAD / 2];
578
+ for (const r of boxes) xs.push(r.x - GX / 2, r.x + r.w + GX / 2), ys.push(r.y + r.h + GY / 2);
579
+ // 같은 행 이웃 상자 사이 틈 가운데(영역마다 상자 사이 가로 간격이 다름).
580
+ for (const r of boxes) {
581
+ const o = boxes.filter((b) => b.y === r.y && b.x > r.x).sort((p, q) => p.x - q.x)[0];
582
+ if (o && o.x - r.x - r.w <= 2 * GX) xs.push((r.x + r.w + o.x) / 2);
583
+ }
584
+ for (const z of L.zone.values()) {
585
+ // 같은 띠 오른쪽 이웃 영역과의 틈 가운데(틈이 ZGX 보다 넓을 때 이름표가 들어가는 자리).
586
+ const right = zs.filter((o) => o.y === z.y && o.x > z.x).sort((p, q) => p.x - q.x)[0];
587
+ if (right) xs.push((z.x + z.w + right.x) / 2);
588
+ xs.push(z.x - ZGX / 2, z.x + z.w + ZGX / 2);
589
+ ys.push(z.y - ZGAP / 2, z.y + z.h + ZGAP / 2);
590
+ const inn = boxes.filter((r) => r.x >= z.x && r.x + r.w <= z.x + z.w && r.y >= z.y && r.y + r.h <= z.y + z.h);
591
+ if (!inn.length) continue;
592
+ const x0 = Math.min(...inn.map((r) => r.x));
593
+ const x1 = Math.max(...inn.map((r) => r.x + r.w));
594
+ const y1 = Math.max(...inn.map((r) => r.y + r.h));
595
+ if (x0 - z.x >= 3 * M) xs.push((z.x + x0) / 2);
596
+ if (z.x + z.w - x1 >= 3 * M) xs.push((x1 + z.x + z.w) / 2);
597
+ if (z.y + z.h - y1 >= 3 * M) ys.push((y1 + z.y + z.h) / 2);
598
+ }
599
+ const lines = (centers, more, max, bands) => {
600
+ const keep = [];
601
+ for (const v of [...new Set(centers)].sort((a, b) => a - b)) if (!keep.length || v - keep.at(-1) >= SNAP) keep.push(v);
602
+ const out = [...keep];
603
+ for (const v of [...new Set(more)].sort((a, b) => a - b)) if (v >= PAD / 2 && v <= max - PAD / 2 && !out.some((k) => Math.abs(k - v) < SNAP)) out.push(v);
604
+ for (const [b0, b1] of bands) if (!out.some((v) => v > b0 && v < b1)) out.push((b0 + b1) / 2);
605
+ return out.sort((a, b) => a - b);
606
+ };
607
+ const X = lines(cx, xs, L.width, titles.map((t) => [t.x, t.x + t.w]));
608
+ const Y = lines(cy, ys, L.height, titles.map((t) => [t.y, t.y + t.h]));
609
+ const nx = X.length;
610
+ const ok = new Uint8Array(nx * Y.length).fill(1);
611
+ for (const r of [...boxes, ...titles]) {
612
+ for (let j = 0; j < Y.length; j++) {
613
+ if (Y[j] <= r.y - M || Y[j] >= r.y + r.h + M) continue;
614
+ for (let i = 0; i < nx; i++) if (X[i] > r.x - M && X[i] < r.x + r.w + M) ok[j * nx + i] = 0;
615
+ }
616
+ }
617
+ const noH = new Uint8Array(nx * Y.length);
618
+ const noV = new Uint8Array(nx * Y.length);
619
+ const near = (v, a, b) => Math.abs(v - a) < ZK || Math.abs(v - b) < ZK;
620
+ for (const z of zs) {
621
+ for (let j = 0; j < Y.length; j++) if (near(Y[j], z.y, z.y + z.h)) for (let i = 0; i + 1 < nx; i++) if (X[i + 1] > z.x && X[i] < z.x + z.w) noH[j * nx + i] = 1;
622
+ for (let i = 0; i < nx; i++) if (near(X[i], z.x, z.x + z.w)) for (let j = 0; j + 1 < Y.length; j++) if (Y[j + 1] > z.y && Y[j] < z.y + z.h) noV[j * nx + i] = 1;
623
+ }
624
+ return { X, Y, nx, ny: Y.length, ok, noH, noV, boxes, walls: [...boxes, ...titles, ...borders], titles, borders };
625
+ }
626
+
627
+ // 끝(상자나 영역)에서 나가는 자리. 옆면을 지나는 격자선마다(모서리에서 CORNER 안쪽). 상자는 옆면 가운데(가장 가까운 격자선)도 넣고,
628
+ // 가운데에서 떨어진 만큼 값(OFFW)을 더한다. 자리에서 바깥으로 곧게 가서 만나는 첫 격자점까지가 첫 선분.
629
+ // 그 점을 다른 상자가 막으면 그 자리는 쓰지 않는다. 돌려줌 [{ p: [x, y], d: 바깥 방향(0 +x, 1 -x, 2 +y, 3 -y), n: 격자점, len: 값 }].
630
+ function routePorts(G, r, isZone) {
631
+ const { X, Y, nx, ok } = G;
632
+ const { M, SNAP, CORNER } = ROUTE;
633
+ const out = [];
634
+ const seen = new Set();
635
+ const add = (px, py, d, mid) => {
636
+ const horiz = d < 2;
637
+ const line = horiz ? X : Y;
638
+ const cross = horiz ? Y : X;
639
+ const want = horiz ? py : px;
640
+ let j = -1;
641
+ cross.forEach((v, k) => {
642
+ if (Math.abs(v - want) < SNAP && (j < 0 || Math.abs(v - want) < Math.abs(cross[j] - want))) j = k;
643
+ });
644
+ if (j < 0 || seen.has(d * 1e6 + j)) return;
645
+ seen.add(d * 1e6 + j);
646
+ const p = horiz ? [px, cross[j]] : [cross[j], py];
647
+ const at = horiz ? px : py;
648
+ const step = d % 2 ? -1 : 1;
649
+ let i = step > 0 ? line.findIndex((v) => v > at) : line.findLastIndex((v) => v < at);
650
+ for (; i >= 0 && i < line.length; i += step) {
651
+ const n = horiz ? j * nx + i : i * nx + j;
652
+ // 첫 선분은 화살표가 들어가게 ARROW 이상. 자기 상자를 M 만큼 키운 띠 안이거나 ARROW 보다 가까우면 더 나가 본다.
653
+ // 다른 상자가 막으면 이 자리는 버린다.
654
+ const far = Math.abs(line[i] - at);
655
+ if (ok[n] && far >= ROUTE.ARROW) return void out.push({ p, d, n, len: far + (mid === undefined ? 0 : Math.abs(cross[j] - mid) * ROUTE.OFFW) });
656
+ if (!ok[n] && (isZone || far >= M)) return;
657
+ }
658
+ };
659
+ if (!isZone) {
660
+ for (const [d, at, cross, s0, len] of [[0, r.x + r.w, Y, r.y, r.h], [1, r.x, Y, r.y, r.h], [2, r.y + r.h, X, r.x, r.w], [3, r.y, X, r.x, r.w]]) {
661
+ const mid = s0 + len / 2;
662
+ for (const v of [mid, ...cross.filter((v) => v >= s0 + CORNER && v <= s0 + len - CORNER)]) (d < 2 ? add(at, v, d, mid) : add(v, at, d, mid));
663
+ }
664
+ return out;
665
+ }
666
+ const free = (x, y) => !G.boxes.some((b) => x > b.x - M && x < b.x + b.w + M && y > b.y - M && y < b.y + b.h + M);
667
+ for (const x of X) {
668
+ if (x < r.x + CORNER || x > r.x + r.w - CORNER) continue;
669
+ if (free(x, r.y + r.h)) add(x, r.y + r.h, 2);
670
+ if (free(x, r.y)) add(x, r.y, 3);
671
+ }
672
+ for (const y of Y) {
673
+ if (y < r.y + CORNER || y > r.y + r.h - CORNER) continue;
674
+ if (free(r.x + r.w, y)) add(r.x + r.w, y, 0);
675
+ if (free(r.x, y)) add(r.x, y, 1);
676
+ }
677
+ return out;
678
+ }
679
+
680
+ // 자동 배치 연결의 길(렌더와 check 가 같이 씀). 돌려줌 Map(연결 → 점 목록). 모든 선분은 가로나 세로이고, 두 끝이 아닌 상자는 지나지 않는다.
681
+ // 1) 짧은 연결부터 하나씩 격자 위 가장 값이 작은 길(A*). 값: 길이 + 꺾기 x BEND + 다른 선과 엇갈림 x CROSS
682
+ // + 이미 다른 선이 지나는 통로 길이 x SHARE x 그 선 수 + 나란한 선 자리가 찬 통로 길이 x OVER + 같은 옆면(자리)을 쓰는 연결 수 x PORT(LINE)
683
+ // + 두 끝이 속하지 않는 영역 안 길이 x ZONE + 그림 가장자리 여백과 영역 머리 줄을 따라 가는 길이 x RIM.
684
+ // 같은 값이면 먼저 넣은 길(결과가 늘 같음).
685
+ // 이름표가 있는 연결은 이름표가 상자와 겹치지 않고 놓이는 곧은 구간을 지나는 길만 고른다(그런 길이 없으면 조건 없이).
686
+ // 2) 같은 방향 선분이 겹치거나 가까우면 나란한 선(TRACK 간격)으로 벌린다(routeTracks).
687
+ // 길을 찾지 못하면(막힌 끝) 한 번 꺾은 선. 시험은 직접 부른다.
688
+ export function autoRoutes(L, edges) {
689
+ const G = routeGrid(L);
690
+ const { X, Y, nx, ny, ok, noH, noV, boxes } = G;
691
+ const { LGAP } = ROUTE;
692
+ const N = nx * ny;
693
+ const S = N * 8;
694
+ const passH = new Uint8Array(N);
695
+ const passV = new Uint8Array(N);
696
+ // 통로 칸: 이웃 격자점 사이 선분마다 양옆에서 가장 가까운 벽(상자, 제목 띠, 영역 테두리) 면 사이(같은 구간, 같은 두 면이면 격자선이 달라도 같은 통로).
697
+ // 통로마다 지나는 선 수(use)와 들어갈 나란한 선 수(cap: 폭에서 KEEP 두 번을 뺀 폭 / TRACK + 1).
698
+ const xy = (n) => [X[n % nx], Y[Math.floor(n / nx)]];
699
+ const corH = new Int32Array(N).fill(-1);
700
+ const corV = new Int32Array(N).fill(-1);
701
+ const corId = new Map();
702
+ const use = [];
703
+ const cap = [];
704
+ for (const [cor, step, along, max] of [[corH, 1, X, L.height], [corV, nx, Y, L.width]]) {
705
+ const h = step === 1;
706
+ // 구간(이웃 격자선 사이)마다 겹치는 상자의 가로지르는 쪽 범위 [d0, d1].
707
+ const spans = along.slice(1).map((v, i) => G.walls.filter((b) => (h ? b.x + b.w > along[i] + 0.5 && b.x < v - 0.5 : b.y + b.h > along[i] + 0.5 && b.y < v - 0.5)).map((b) => (h ? [b.y, b.y + b.h] : [b.x, b.x + b.w])));
708
+ for (let n = 0; n < N; n++) {
709
+ const m = n + step;
710
+ if (!ok[n] || m >= N || !ok[m] || (h && m % nx === 0)) continue;
711
+ const [i0, c] = h ? [n % nx, Y[Math.floor(n / nx)]] : [Math.floor(n / nx), X[n % nx]];
712
+ let lo = 0;
713
+ let hi = max;
714
+ for (const [d0, d1] of spans[i0]) {
715
+ if (d1 <= c) lo = Math.max(lo, d1);
716
+ else if (d0 >= c) hi = Math.min(hi, d0);
717
+ }
718
+ const key = `${step}:${i0}:${lo}:${hi}`;
719
+ if (!corId.has(key)) {
720
+ corId.set(key, use.length);
721
+ use.push(0);
722
+ cap.push(Math.max(1, Math.floor((hi - lo - 2 * ROUTE.KEEP) / ROUTE.TRACK) + 1));
723
+ }
724
+ cor[n] = corId.get(key);
725
+ }
726
+ }
727
+ // 격자 선분(이웃 격자점 사이)마다 가운데가 든 영역 번호(zoneH, zoneV, 없으면 -1)와 가장자리 띠인지(nearH, nearV).
728
+ // 가장자리 띠: 그림 가장자리에서 PAD 안(영역 바깥 여백), 가로 선분은 영역 머리(영역 위 테두리에서 HEAD 안, 영역 이름 줄)도.
729
+ const zs = [...L.zone.values()];
730
+ const inZone = (x, y) => zs.findIndex((z) => x > z.x && x < z.x + z.w && y > z.y && y < z.y + z.h);
731
+ const zoneH = new Int16Array(N).fill(-1);
732
+ const zoneV = new Int16Array(N).fill(-1);
733
+ const nearH = new Uint8Array(N);
734
+ const nearV = new Uint8Array(N);
735
+ for (let n = 0; n < N; n++) {
736
+ const [x, y] = xy(n);
737
+ if (n % nx + 1 < nx) {
738
+ const mx = (x + X[n % nx + 1]) / 2;
739
+ zoneH[n] = inZone(mx, y);
740
+ nearH[n] = y < AUTO.PAD || y > L.height - AUTO.PAD || zs.some((z) => mx > z.x && mx < z.x + z.w && y > z.y && y < z.y + AUTO.HEAD) ? 1 : 0;
741
+ }
742
+ if (n + nx < N) {
743
+ const my = (y + Y[Math.floor(n / nx) + 1]) / 2;
744
+ zoneV[n] = inZone(x, my);
745
+ nearV[n] = x < AUTO.PAD || x > L.width - AUTO.PAD ? 1 : 0;
746
+ }
747
+ }
748
+ // 끝(상자, 영역) 옆면마다, 그 옆면의 자리(격자선)마다 이미 쓰는 연결 수.
749
+ const sideUse = new Map();
750
+ const sideKeys = (p) => [p.d, `${p.d}:${p.d < 2 ? p.p[1] : p.p[0]}`];
751
+ const sideCost = (r, p) => {
752
+ const u = sideUse.get(r);
753
+ if (!u) return 0;
754
+ const [side, line] = sideKeys(p);
755
+ return (u.get(side) || 0) * ROUTE.PORT + (u.get(line) || 0) * ROUTE.LINE;
756
+ };
757
+ const sideAdd = (r, p) => {
758
+ if (!sideUse.has(r)) sideUse.set(r, new Map());
759
+ const u = sideUse.get(r);
760
+ for (const k of sideKeys(p)) u.set(k, (u.get(k) || 0) + 1);
761
+ };
762
+ const dist = new Float64Array(S);
763
+ const prev = new Int32Array(S);
764
+ const mark = new Uint32Array(S);
765
+ let stamp = 0;
766
+ const next = (n, d) => {
767
+ const i = n % nx;
768
+ if (d === 0) return i + 1 < nx ? n + 1 : -1;
769
+ if (d === 1) return i > 0 ? n - 1 : -1;
770
+ if (d === 2) return n + nx < N ? n + nx : -1;
771
+ return n - nx >= 0 ? n - nx : -1;
772
+ };
773
+ // 상태 = (격자점 x 4 + 방향) x 2 + 이름표 자리를 지났는지. 끝 자리 도착은 상태 S + 자리 번호.
774
+ const search = (a, b, starts, goals, lw, own) => {
775
+ const need = lw > 0;
776
+ const fitMemo = new Map();
777
+ // 격자점 n 을 방향 d 로 곧게 지날 때 이름표(가운데가 n, 그림 안으로 민 자리)가 상자와 겹치지 않고 영역 테두리에서 LGAP 떨어지는지.
778
+ // 선을 나란히 벌리면 선분과 수직으로 움직이므로 상자는 그 방향만 LPAD 여유, 선분 방향은 1 px.
779
+ const fits = (n, d) => {
780
+ const key = n * 2 + (d < 2 ? 1 : 0);
781
+ if (fitMemo.has(key)) return fitMemo.get(key);
782
+ const [x0, y0] = xy(n);
783
+ const x = Math.min(Math.max(x0, lw / 2 + 1), L.width - lw / 2 - 1);
784
+ const [px, py] = d < 2 ? [1, ROUTE.LPAD] : [ROUTE.LPAD, 1];
785
+ const r = { x: x - lw / 2 - px, y: y0 - 8 - py, w: lw + 2 * px, h: 16 + 2 * py };
786
+ const rb = { x: x - lw / 2 - LGAP, y: y0 - 8 - LGAP, w: lw + 2 * LGAP, h: 16 + 2 * LGAP };
787
+ const v = !boxes.some((q) => hits(r, q, 0)) && !G.borders.some((q) => q.x + q.w > rb.x && q.x < rb.x + rb.w && q.y + q.h > rb.y && q.y < rb.y + rb.h);
788
+ fitMemo.set(key, v);
789
+ return v;
790
+ };
791
+ const h = (n) => {
792
+ const [x, y] = xy(n);
793
+ return Math.max(0, b.x - x, x - b.x - b.w) + Math.max(0, b.y - y, y - b.y - b.h);
794
+ };
795
+ const goalAt = new Map();
796
+ goals.forEach((g, k) => goalAt.set(g.n, [...(goalAt.get(g.n) || []), k]));
797
+ stamp++;
798
+ // 힙: [f, 넣은 차례, 상태, g]. f 가 같으면 먼저 넣은 것.
799
+ const heap = [];
800
+ let seq = 0;
801
+ const less = (p, q) => p[0] < q[0] || (p[0] === q[0] && p[1] < q[1]);
802
+ const push = (f, s, g) => {
803
+ const v = [f, seq++, s, g];
804
+ let k = heap.push(v) - 1;
805
+ while (k) {
806
+ const pk = (k - 1) >> 1;
807
+ if (!less(v, heap[pk])) break;
808
+ heap[k] = heap[pk];
809
+ k = pk;
810
+ }
811
+ heap[k] = v;
812
+ };
813
+ const pop = () => {
814
+ const top = heap[0];
815
+ const last = heap.pop();
816
+ if (heap.length) {
817
+ let k = 0;
818
+ for (;;) {
819
+ const l = 2 * k + 1;
820
+ if (l >= heap.length) break;
821
+ const c = l + 1 < heap.length && less(heap[l + 1], heap[l]) ? l + 1 : l;
822
+ if (!less(heap[c], last)) break;
823
+ heap[k] = heap[c];
824
+ k = c;
825
+ }
826
+ heap[k] = last;
827
+ }
828
+ return top;
829
+ };
830
+ const relax = (s, g, from) => {
831
+ if (mark[s] === stamp && dist[s] <= g) return false;
832
+ mark[s] = stamp;
833
+ dist[s] = g;
834
+ prev[s] = from;
835
+ push(g + h(s >> 3), s, g);
836
+ return true;
837
+ };
838
+ const startOf = new Map();
839
+ for (const p of starts) if (relax((p.n * 4 + p.d) * 2 + (need ? 0 : 1), p.len + sideCost(a, p), -1)) startOf.set((p.n * 4 + p.d) * 2 + (need ? 0 : 1), p);
840
+ const goalG = new Map();
841
+ while (heap.length) {
842
+ const [, , s, g] = pop();
843
+ if (s >= S) {
844
+ const states = [];
845
+ for (let t = goalG.get(s).from; t >= 0; t = prev[t]) states.push(t);
846
+ return { states: states.reverse(), goal: goals[s - S], startOf };
847
+ }
848
+ if (dist[s] !== g || mark[s] !== stamp) continue;
849
+ const n = s >> 3;
850
+ const d = (s >> 1) & 3;
851
+ const f = s & 1;
852
+ for (const k of goalAt.get(n) || []) {
853
+ const q = goals[k];
854
+ const straight = BACK[q.d] === d;
855
+ // 끝 자리 쪽에서 오는 길(되돌아 겹치는 선)은 쓰지 않는다.
856
+ if (q.d === d || (!f && !(straight && fits(n, d)))) continue;
857
+ const gg = g + q.len + sideCost(b, q) + (straight ? 0 : ROUTE.BEND);
858
+ const gs = S + k;
859
+ if (!goalG.has(gs) || goalG.get(gs).g > gg) {
860
+ goalG.set(gs, { g: gg, from: s });
861
+ push(gg, gs, gg);
862
+ }
863
+ }
864
+ for (let nd = 0; nd < 4; nd++) {
865
+ if (nd === BACK[d]) continue;
866
+ const m = next(n, nd);
867
+ const horiz = nd < 2;
868
+ if (m < 0 || !ok[m] || (horiz ? noH : noV)[Math.min(n, m)]) continue;
869
+ const len = horiz ? Math.abs(X[m % nx] - X[n % nx]) : Math.abs(Y[Math.floor(m / nx)] - Y[Math.floor(n / nx)]);
870
+ const k = Math.min(n, m);
871
+ const c = (horiz ? corH : corV)[k];
872
+ const zi = (horiz ? zoneH : zoneV)[k];
873
+ const extra = (zi >= 0 && !own.includes(zi) ? ROUTE.ZONE : 0) + ((horiz ? nearH : nearV)[k] ? ROUTE.RIM : 0);
874
+ const cost = len * (1 + extra + ROUTE.SHARE * use[c] + (use[c] >= cap[c] ? ROUTE.OVER : 0)) + (nd === d ? 0 : ROUTE.BEND) + ((horiz ? passV[m] : passH[m]) ? ROUTE.CROSS : 0);
875
+ const nf = f || (nd === d && fits(n, d)) ? 1 : 0;
876
+ relax((m * 4 + nd) * 2 + nf, g + cost, s);
877
+ }
878
+ }
879
+ return null;
880
+ };
881
+ const list = [];
882
+ edges.forEach((e, i) => {
883
+ const za = !L.node.has(e.from);
884
+ const zb = !L.node.has(e.to);
885
+ const a = za ? L.zone.get(e.from) : L.node.get(e.from);
886
+ const b = zb ? L.zone.get(e.to) : L.node.get(e.to);
887
+ if (!a || !b) return;
888
+ const d = Math.abs(a.x + a.w / 2 - b.x - b.w / 2) + Math.abs(a.y + a.h / 2 - b.y - b.h / 2);
889
+ list.push({ e, i, a, b, za, zb, d });
890
+ });
891
+ list.sort((p, q) => p.d - q.d || p.i - q.i);
892
+ const found = [];
893
+ for (const it of list) {
894
+ const { a, b } = it;
895
+ let pts = null;
896
+ if (a !== b) {
897
+ const starts = routePorts(G, a, it.za);
898
+ const goals = routePorts(G, b, it.zb);
899
+ const lw = it.e.label ? textW(it.e.label) + 10 : 0;
900
+ // 두 끝이 속한 영역(영역 끝은 그 영역, 상자는 가운데가 든 영역).
901
+ const own = [[a, it.za], [b, it.zb]].map(([r, z]) => (z ? zs.indexOf(r) : inZone(r.x + r.w / 2, r.y + r.h / 2)));
902
+ const got = (lw && search(a, b, starts, goals, lw, own)) || search(a, b, starts, goals, 0, own);
903
+ if (got) {
904
+ const { states } = got;
905
+ const nodes = states.map((s) => s >> 3);
906
+ for (let k = 0; k < nodes.length; k++) {
907
+ const n = nodes[k];
908
+ if (k) {
909
+ const p = nodes[k - 1];
910
+ use[(Math.abs(n - p) === 1 ? corH : corV)[Math.min(n, p)]]++;
911
+ }
912
+ for (const t of [states[k], states[k + 1]]) if (t !== undefined) ((t >> 1) & 3) < 2 ? (passH[n] = 1) : (passV[n] = 1);
913
+ }
914
+ const sp = got.startOf.get(states[0]);
915
+ sideAdd(a, sp);
916
+ sideAdd(b, got.goal);
917
+ pts = [sp.p, ...nodes.map(xy), got.goal.p];
918
+ }
919
+ }
920
+ if (!pts) pts = pathOf(a, b, [[b.x + b.w / 2, a.y + a.h / 2]], 0, 0);
921
+ // 같은 직선 위 가운데 점은 뺀다.
922
+ pts = pts.filter((p, k) => {
923
+ if (!k || k === pts.length - 1) return true;
924
+ const [o, q] = [pts[k - 1], pts[k + 1]];
925
+ return !((o[0] === p[0] && p[0] === q[0]) || (o[1] === p[1] && p[1] === q[1]));
926
+ });
927
+ found.push({ e: it.e, i: it.i, pts, a, b });
928
+ }
929
+ found.sort((p, q) => p.i - q.i);
930
+ routeTracks(L, G, found);
931
+ return new Map(found.map((r) => [r.e, r.pts]));
932
+ }
933
+
934
+ // 나란한 선 벌리기. 같은 방향 선분 가운데 구간이 겹치거나 TRACK 보다 가까운 것끼리 묶어 묶음마다 나란한 자리(TRACK 간격)를 정한다.
935
+ // 처음 묶음은 같은 격자선 위 선분. 벌린 뒤 다른 묶음의 선분과 NEAR 보다 가까워지면 두 묶음을 합쳐 다시 정한다.
936
+ // 선분 하나뿐인 묶음도 자리 범위 안으로 옮긴다(격자선이 상자에서 KEEP 안이거나 영역 테두리에서 ZL 안이면).
937
+ // 묶음 안 차례: 원래 줄 좌표, 다음에 먼저 끝나는 쪽 끝에서 꺾는 방향(위나 왼쪽으로 꺾으면 앞 자리), 늦게 시작하는 쪽 끝에서 오는 방향, 연결 차례.
938
+ // 자리 범위와 간격은 아래 bounds, place.
939
+ function routeTracks(L, G, routes) {
940
+ const { TRACK, KEEP, HARD, CORNER, ZK } = ROUTE;
941
+ const { boxes } = G;
942
+ const walls = [...boxes, ...G.titles];
943
+ const all = [];
944
+ for (const r of routes) {
945
+ r.pts = r.pts.map((p) => [...p]);
946
+ const P = r.pts;
947
+ for (let k = 0; k + 1 < P.length; k++) {
948
+ const h = P[k][1] === P[k + 1][1];
949
+ if (!h && P[k][0] !== P[k + 1][0]) continue;
950
+ const ax = h ? 0 : 1;
951
+ const c = P[k][1 - ax];
952
+ const [u, v] = [P[k][ax], P[k + 1][ax]];
953
+ // 선분 끝에서 꺾는 방향: 이웃 점이 이 줄의 어느 쪽인지. 끝 자리(상자, 영역)는 0.
954
+ const side = (q) => (q ? Math.sign(q[1 - ax] - c) : 0);
955
+ const [sU, sV] = [side(P[k - 1]), side(P[k + 2])];
956
+ all.push({ r, k, h, c, pos: c, lo: Math.min(u, v), hi: Math.max(u, v), sLo: u <= v ? sU : sV, sHi: u <= v ? sV : sU, first: k === 0, last: k + 2 === P.length });
957
+ }
958
+ }
959
+ const cmp = (a, b) => {
960
+ if (a.c !== b.c) return a.c - b.c;
961
+ let v = 0;
962
+ if (a.hi < b.hi - 0.5) v += a.sHi;
963
+ else if (b.hi < a.hi - 0.5) v -= b.sHi;
964
+ else v += a.sHi - b.sHi;
965
+ if (a.lo > b.lo + 0.5) v += a.sLo;
966
+ else if (b.lo > a.lo + 0.5) v -= b.sLo;
967
+ else v += a.sLo - b.sLo;
968
+ return v || a.r.i - b.r.i || a.k - b.k;
969
+ };
970
+ const near = (a, b) => a.lo < b.hi + TRACK && b.lo < a.hi + TRACK;
971
+ // 처음 묶음: 같은 줄에서 구간이 이어지는 선분.
972
+ const groups = [];
973
+ const byLine = new Map();
974
+ for (const s of all) {
975
+ const key = (s.h ? 'h' : 'v') + s.c;
976
+ if (!byLine.has(key)) byLine.set(key, []);
977
+ byLine.get(key).push(s);
978
+ }
979
+ for (const segs of byLine.values()) {
980
+ segs.sort((a, b) => a.lo - b.lo || a.r.i - b.r.i || a.k - b.k);
981
+ let hi = -Infinity;
982
+ for (const s of segs) {
983
+ if (s.lo >= hi + TRACK) groups.push({ segs: [] });
984
+ s.g = groups.at(-1);
985
+ s.g.segs.push(s);
986
+ hi = Math.max(hi, s.hi);
987
+ }
988
+ }
989
+ // 선분 하나의 자리 범위: 그 선분 구간과 1 px 넘게 겹치는 상자와 제목 띠에서 keep 안쪽(끝 선분이 이어지는 끝 상자나 영역은 ARROW 이상),
990
+ // 나란한 영역 테두리와 그림 가장자리에서 ZK 안쪽, 끝 자리가 상자나 영역 옆면이면 모서리에서 CORNER 안쪽.
991
+ const bounds = (s, keep, zk) => {
992
+ const ax = s.h ? 1 : 0;
993
+ let lo = ZK;
994
+ let hi = (s.h ? L.height : L.width) - ZK;
995
+ const ends = [s.k === 1 ? s.r.a : null, s.k + 3 === s.r.pts.length ? s.r.b : null];
996
+ const lim = (b, k) => {
997
+ const [b0, b1, d0, d1] = s.h ? [b.x, b.x + b.w, b.y, b.y + b.h] : [b.y, b.y + b.h, b.x, b.x + b.w];
998
+ if (b1 <= s.lo + 0.5 || b0 >= s.hi - 0.5) return;
999
+ if (d1 <= s.c + 0.5) lo = Math.max(lo, d1 + k);
1000
+ else if (d0 >= s.c - 0.5) hi = Math.min(hi, d0 - k);
1001
+ };
1002
+ // 이름표 있는 가로 선분은 먼저(zk) 상자에서 이름표 반 높이 + LBOX 이상(이름표가 상자에서 LBOX 떨어짐).
1003
+ const k0 = zk && s.h && s.r.e.label ? Math.max(keep, 8 + ROUTE.LBOX) : keep;
1004
+ for (const b of [...walls, ...ends.filter((e) => e && !boxes.includes(e))]) lim(b, ends.includes(b) ? Math.max(k0, ROUTE.ARROW) : k0);
1005
+ for (const b of G.borders) lim(b, zk && (s.r.e.label || s.hi - s.lo > ROUTE.LONG) ? ROUTE.ZL : ZK);
1006
+ for (const [on, box] of [[s.first, s.r.a], [s.last, s.r.b]]) {
1007
+ if (!on) continue;
1008
+ const [b0, b1] = ax ? [box.y, box.y + box.h] : [box.x, box.x + box.w];
1009
+ lo = Math.max(lo, b0 + CORNER);
1010
+ hi = Math.min(hi, b1 - CORNER);
1011
+ }
1012
+ return [lo, hi];
1013
+ };
1014
+ // 묶음 자리: 차례대로 간격 gap 이상, 선분마다 자리 범위 안. 원래 줄 가운데에 가깝게 놓고(앞으로 밀기, 뒤로 당기기)
1015
+ // 범위를 못 맞추면 아래 차례로 푼다: 이름표 있는 연결의 가로 선분끼리 이웃이면 LROW(이름표 높이 + LGAP), 나머지 TRACK 에 상자 거리 KEEP,
1016
+ // 간격 TRACK 에 상자 거리 KEEP, 간격 TRACK - 1..6 에 상자 거리 KEEP, 간격 TRACK..6 에 상자 거리 6, 간격 5..4 에 KEEP, 6, 마지막으로 간격 TRACK..HARD 에 상자 거리 HARD.
1017
+ // 단계마다 먼저 이름표 있는 선과 LONG 넘는 선분을 나란한 영역 테두리에서 ZL, 이름표 있는 가로 선분을 상자에서 8 + LBOX 떨어뜨려 보고, 안 되면 ZK 와 위 상자 거리로.
1018
+ // 끝내 못 맞추면 원래 줄에 둔다.
1019
+ const place = (g) => {
1020
+ const segs = g.segs.sort(cmp);
1021
+ const n = segs.length;
1022
+ if (!n) return;
1023
+ const c0 = Math.min(...segs.map((s) => s.c));
1024
+ const c1 = Math.max(...segs.map((s) => s.c));
1025
+ const lab = segs.map((s) => Boolean(s.h && s.r.e.label));
1026
+ const tiers = [[KEEP, TRACK, TRACK, ROUTE.LROW], [KEEP, TRACK, TRACK, 0], [KEEP, TRACK - 1, 6, 0], [6, TRACK, 6, 0], [KEEP, 5, 4, 0], [6, 5, 4, 0], [HARD, TRACK, HARD, 0]];
1027
+ for (const [keep, g0, g1, lg, zk] of tiers.flatMap((t) => [[...t, true], [...t, false]])) {
1028
+ if (lg && !lab.some((v, t) => v && lab[t - 1])) continue;
1029
+ const bs = segs.map((s) => bounds(s, keep, zk));
1030
+ for (let gap = g0; gap >= g1; gap--) {
1031
+ const at = (t) => (lg && lab[t - 1] && lab[t] ? Math.max(gap, lg) : gap);
1032
+ const off = [0];
1033
+ for (let t = 1; t < n; t++) off.push(off[t - 1] + at(t));
1034
+ const p = segs.map((_, t) => (c0 + c1) / 2 + off[t] - off[n - 1] / 2);
1035
+ for (let t = 0; t < n; t++) p[t] = Math.max(p[t], bs[t][0], t ? p[t - 1] + at(t) : -Infinity);
1036
+ for (let t = n - 1; t >= 0; t--) p[t] = Math.min(p[t], bs[t][1], t < n - 1 ? p[t + 1] - at(t + 1) : Infinity);
1037
+ if (p.every((v, t) => v >= bs[t][0] - 1e-9)) return void segs.forEach((s, t) => (s.pos = p[t]));
1038
+ }
1039
+ }
1040
+ segs.forEach((s) => (s.pos = s.c));
1041
+ };
1042
+ groups.forEach(place);
1043
+ // 다른 묶음과 가까워진 선분이 있으면 두 묶음을 합쳐 다시 정한다. 묶음 수가 줄 때만 되풀이하므로 끝난다.
1044
+ for (let round = 0; round < all.length; round++) {
1045
+ let merged = false;
1046
+ for (const h of [true, false]) {
1047
+ const segs = all.filter((s) => s.h === h).sort((a, b) => a.pos - b.pos || a.r.i - b.r.i || a.k - b.k);
1048
+ for (let i = 0; i < segs.length; i++) {
1049
+ for (let j = i + 1; j < segs.length && segs[j].pos - segs[i].pos < ROUTE.NEAR; j++) {
1050
+ const [a, b] = [segs[i], segs[j]];
1051
+ if (a.g === b.g || a.r === b.r || !near(a, b)) continue;
1052
+ const keep = a.g;
1053
+ const gone = b.g;
1054
+ for (const s of gone.segs) {
1055
+ s.g = keep;
1056
+ keep.segs.push(s);
1057
+ }
1058
+ gone.segs = [];
1059
+ place(keep);
1060
+ merged = true;
1061
+ }
1062
+ }
1063
+ }
1064
+ if (!merged) break;
1065
+ }
1066
+ for (const s of all) {
1067
+ const ax = s.h ? 1 : 0;
1068
+ s.r.pts[s.k][ax] = s.pos;
1069
+ s.r.pts[s.k + 1][ax] = s.pos;
1070
+ }
1071
+ }
1072
+
1073
+ // 자동 배치 선의 길이 합, 엇갈림 수(다른 연결의 가로 선분과 세로 선분이 두 선분 안쪽에서 만남), 두 끝이 속하지 않는 영역 안 길이,
1074
+ // 좁은 나란한 선 쌍 수, 테두리 붙은 긴 선분 수. 배치 고르기 값.
1075
+ function routeSum(routes, L) {
1076
+ let len = 0;
1077
+ let foreign = 0;
1078
+ const hs = [];
1079
+ const vs = [];
1080
+ let id = 0;
1081
+ const zs = [...L.zone.values()];
1082
+ const inZone = (r) => zs.find((z) => r.x >= z.x && r.y >= z.y && r.x + r.w <= z.x + z.w && r.y + r.h <= z.y + z.h);
1083
+ // 구간 [a0, a1](순서 무관)과 [b0, b1] 의 겹친 길이.
1084
+ const ov = (a0, a1, b0, b1) => Math.max(0, Math.min(Math.max(a0, a1), b1) - Math.max(Math.min(a0, a1), b0));
1085
+ for (const [e, pts] of routes) {
1086
+ id++;
1087
+ const own = [e.from, e.to].map((k) => L.zone.get(k) || (L.node.has(k) ? inZone(L.node.get(k)) : null));
1088
+ for (let k = 1; k < pts.length; k++) {
1089
+ const [p, q] = [pts[k - 1], pts[k]];
1090
+ len += Math.abs(q[0] - p[0]) + Math.abs(q[1] - p[1]);
1091
+ for (const z of zs) {
1092
+ if (own.includes(z)) continue;
1093
+ if (p[1] === q[1] && p[1] > z.y && p[1] < z.y + z.h) foreign += ov(p[0], q[0], z.x, z.x + z.w);
1094
+ else if (p[0] === q[0] && p[0] > z.x && p[0] < z.x + z.w) foreign += ov(p[1], q[1], z.y, z.y + z.h);
1095
+ }
1096
+ if (p[1] === q[1]) hs.push([id, p[1], Math.min(p[0], q[0]), Math.max(p[0], q[0])]);
1097
+ else if (p[0] === q[0]) vs.push([id, p[0], Math.min(p[1], q[1]), Math.max(p[1], q[1])]);
1098
+ }
1099
+ }
1100
+ let cross = 0;
1101
+ for (const h of hs) for (const v of vs) if (h[0] !== v[0] && v[1] > h[2] && v[1] < h[3] && h[1] > v[2] && h[1] < v[3]) cross++;
1102
+ // 좁은 나란한 선: 다른 연결의 같은 방향 선분이 20 px 넘게 함께 달리며 TRACK 보다 가까운 쌍.
1103
+ // 테두리 붙은 긴 선: LONG 넘게 나란한 영역 테두리에서 ZL 안을 달리는 선분.
1104
+ let tight = 0;
1105
+ for (const list of [hs, vs]) {
1106
+ for (let i = 0; i < list.length; i++) for (let j = i + 1; j < list.length; j++) {
1107
+ const [a, b] = [list[i], list[j]];
1108
+ if (a[0] !== b[0] && Math.abs(a[1] - b[1]) < ROUTE.TRACK && Math.min(a[3], b[3]) - Math.max(a[2], b[2]) > 20) tight++;
1109
+ }
1110
+ }
1111
+ let hug = 0;
1112
+ for (const [list, h] of [[hs, true], [vs, false]]) {
1113
+ for (const [, c, lo, hi] of list) {
1114
+ for (const z of zs) {
1115
+ const [b0, b1, l0, l1] = h ? [z.x, z.x + z.w, z.y, z.y + z.h] : [z.y, z.y + z.h, z.x, z.x + z.w];
1116
+ if (Math.min(hi, b1) - Math.max(lo, b0) > ROUTE.LONG && (Math.abs(c - l0) < ROUTE.ZL || Math.abs(c - l1) < ROUTE.ZL)) hug++;
1117
+ }
1118
+ }
1119
+ }
1120
+ return { len, cross, foreign, tight, hug };
1121
+ }
1122
+
1123
+ // 자동 배치 선이 지나는 틈 가운데 나란한 선이 들어가지 않는 틈의 필요한 폭. 같은 띠 옆 영역 사이 틈(키: 두 영역 id)은 그 틈을 지나는 세로 선분,
1124
+ // 띠 사이 틈(키: 'band' + 위 띠 번호)은 가로 선분을 구간이 이어지는 묶음(나란한 선 벌리기와 같은 묶음)으로 나눠 가장 큰 묶음의 선 수 n 으로
1125
+ // (n - 1) x TRACK + 2 x ZL. 지금 틈보다 넓을 때만 넣는다.
1126
+ function roomNeed(L, routes, key) {
1127
+ const need = new Map();
1128
+ const segs = [...routes.values()].flatMap((pts) => pts.slice(1).map((q, k) => [pts[k], q]));
1129
+ // 가로(h)나 세로 선분 가운데 줄 좌표가 (a0, a1) 안이고 구간이 (b0, b1) 와 겹치는 것의 가장 큰 묶음 크기.
1130
+ const most = (h, a0, a1, b0 = -Infinity, b1 = Infinity) => {
1131
+ const on = segs
1132
+ .filter(([p, q]) => (h ? p[1] === q[1] : p[0] === q[0]) && (h ? p[1] : p[0]) > a0 && (h ? p[1] : p[0]) < a1)
1133
+ .map(([p, q]) => (h ? [Math.min(p[0], q[0]), Math.max(p[0], q[0])] : [Math.min(p[1], q[1]), Math.max(p[1], q[1])]))
1134
+ .filter(([l, u]) => u > b0 && l < b1)
1135
+ .sort((p, q) => p[0] - q[0]);
1136
+ let n = 0;
1137
+ let k = 0;
1138
+ let hi = -Infinity;
1139
+ for (const [l, u] of on) {
1140
+ k = l < hi + ROUTE.TRACK ? k + 1 : 1;
1141
+ hi = k === 1 ? u : Math.max(hi, u);
1142
+ n = Math.max(n, k);
1143
+ }
1144
+ return n;
1145
+ };
1146
+ const room = (n) => (n - 1) * ROUTE.TRACK + 2 * ROUTE.ZL;
1147
+ const zs = [...L.zone.entries()];
1148
+ for (const [id, z] of zs) {
1149
+ const right = zs.filter(([, o]) => o.y === z.y && o.x > z.x).sort((p, q) => p[1].x - q[1].x)[0];
1150
+ if (!right) continue;
1151
+ const [x0, x1] = [z.x + z.w, right[1].x];
1152
+ const r = room(most(false, x0, x1, z.y, z.y + z.h));
1153
+ if (r > x1 - x0) need.set(key(id, right[0]), r);
1154
+ }
1155
+ const ys = [...new Set(zs.map(([, z]) => z.y))].sort((a, b) => a - b);
1156
+ ys.slice(1).forEach((y1, bi) => {
1157
+ const y0 = Math.max(...zs.filter(([, z]) => z.y === ys[bi]).map(([, z]) => z.y + z.h));
1158
+ const r = room(most(true, y0, y1));
1159
+ if (r > y1 - y0) need.set('band' + bi, r);
1160
+ });
1161
+ return need;
1162
+ }
1163
+
1164
+ // 구성도 배치(렌더와 check 가 같이 씀). 좌표가 모두 맞으면 그대로, 하나라도 빠지거나 틀리면 그림 전체 자동 배치:
1165
+ // 후보(영역과 상자 순서, 영역 안 줄 수 상한, 띠 폭 상한, 띠 방향)마다 autoPlace 로 놓고, 아래 한계가 작은 TRY 개만 autoRoutes 로 그어 하나를 고른다.
1166
+ // 고르는 기준(앞이 같을 때만 다음): 상자가 MANY 개 이상이면 가로세로 비 RATIO 이상, 값(높이 + WARN 수 x WARN_PX + 선 길이 합 x LEN
1167
+ // + 엇갈림 수 x CROSS + 붐비는 이름표 수 x CROWD + 다른 영역 안 길이 x FOREIGN + (좁은 나란한 선 쌍 + 테두리 붙은 긴 선분) x TIGHT)이 가장 작음, 먼저 만든 후보.
1168
+ // 고른 후보는 나란한 선이 모자란 틈(roomNeed)과 이름표가 상자에 붙는 같은 행 이웃 상자 사이를 넓혀 한 번 더 놓고, 값이 더 작으면 바꾼다.
1169
+ // WIDTHS 후보로 비를 맞추지 못하면 WIDE 후보도 보고 같은 기준으로 더 나은 쪽.
1170
+ // 그림 폭은 띠 폭 상한 + 2 x PAD 이하(WIDTHS 는 1600, WIDE 는 1800 이하). 연결 선택 키(via, lx, ly, off, offTo)는 무시한다.
1171
+ // 같은 자료 객체면 다시 계산하지 않는다(서버는 바뀌지 않은 파일의 파싱 결과를 다시 씀).
1172
+ const layoutMemo = new WeakMap();
1173
+ export function archLayout(f, prefix = '예정') {
1174
+ if (!f || typeof f !== 'object') return layoutOf(f, prefix);
1175
+ const memo = layoutMemo.get(f) || new Map();
1176
+ layoutMemo.set(f, memo);
1177
+ if (!memo.has(prefix)) memo.set(prefix, layoutOf(f, prefix));
1178
+ return memo.get(prefix);
1179
+ }
1180
+
1181
+ function layoutOf(f, prefix) {
1182
+ const zones = arr(f.zones);
1183
+ const nodes = arr(f.nodes);
1184
+ const edges = arr(f.edges);
1185
+ const missing = missingCoord(f);
1186
+ if (!missing) {
1187
+ const zone = new Map();
1188
+ const node = new Map();
1189
+ for (const z of zones) zone.set(z.id, { x: z.x, y: z.y, w: z.w, h: z.h });
1190
+ for (const n of nodes) node.set(n.id, { x: n.x, y: n.y, w: n.w, h: n.h });
1191
+ return { auto: false, missing: null, width: f.width, height: f.height, zone, node, edge: (e) => e };
1192
+ }
1193
+ // 영역마다 상자 크기를 가장 크게 필요한 상자에 맞춘다.
1194
+ const sized = (list) => {
1195
+ const nw = Math.max(AUTO.NW, ...list.map((n) => nodeNeed(n, AUTO.NW, prefix).w));
1196
+ return { list, nw, nh: Math.max(AUTO.NH, ...list.map((n) => nodeNeed(n, nw, prefix).h)) };
1197
+ };
1198
+ const ids = new Set(zones.map((z) => z.id));
1199
+ const groups = zones.map((z) => ({ z, ...sized(nodes.filter((n) => n.zone === z.id)) }));
1200
+ const loose = nodes.filter((n) => !ids.has(n.zone));
1201
+ if (loose.length) groups.push({ z: null, ...sized(loose) });
1202
+ const maxN = Math.max(1, ...groups.map((g) => g.list.length));
1203
+ // 연결 끝의 영역 id(상자면 그 상자의 영역, 영역 없는 상자는 null).
1204
+ const zoneOfNode = new Map(nodes.map((n) => [n.id, ids.has(n.zone) ? n.zone : null]));
1205
+ const zid = (id) => (zoneOfNode.has(id) ? zoneOfNode.get(id) : ids.has(id) ? id : null);
1206
+ // 같은 띠 옆 영역 사이 틈: 두 영역을 잇는 이름표 있는 연결이 있으면 가장 넓은 이름표 + 2 x LGAP + 8(ZGX 이상).
1207
+ // 이름표가 영역 테두리를 덮지 않고 틈에 들어간다.
1208
+ const pairKey = (a, b) => (a < b ? [a, b] : [b, a]).join('|');
1209
+ const gapNeed = new Map();
1210
+ for (const e of edges) {
1211
+ const [a, b] = [zid(e && e.from), zid(e && e.to)];
1212
+ if (!a || !b || a === b || !e.label) continue;
1213
+ const k = pairKey(a, b);
1214
+ gapNeed.set(k, Math.max(gapNeed.get(k) || 0, Math.ceil(textW(e.label) + 10) + 2 * ROUTE.LGAP + 8));
1215
+ }
1216
+ const gapOf = (p, q) => Math.max(AUTO.ZGX, (p.z && q.z && gapNeed.get(pairKey(p.z.id, q.z.id))) || 0);
1217
+ // 영역 순서 후보: 배열 순서, 연결이 많은 영역끼리 이웃하게 이은 순서. 둘째는 첫 영역에서 시작해 마지막에 놓은 영역과
1218
+ // 연결이 가장 많은 영역을 다음에 놓는다(같으면 이미 놓은 영역 전체와 연결 수, 다음에 배열 순서). 영역 없는 상자 묶음은 늘 맨 끝.
1219
+ const zg = groups.filter((g) => g.z);
1220
+ const link = new Map();
1221
+ for (const e of edges) {
1222
+ const [a, b] = [zid(e && e.from), zid(e && e.to)];
1223
+ if (a && b && a !== b) link.set(pairKey(a, b), (link.get(pairKey(a, b)) || 0) + 1);
1224
+ }
1225
+ const w = (p, q) => link.get(pairKey(p.z.id, q.z.id)) || 0;
1226
+ const chain = zg.slice(0, 1);
1227
+ const left = zg.slice(1);
1228
+ while (left.length) {
1229
+ let k = 0;
1230
+ const score = (g) => [w(chain.at(-1), g), chain.reduce((t, c) => t + w(c, g), 0)];
1231
+ for (let j = 1; j < left.length; j++) {
1232
+ const [a, b] = [score(left[j]), score(left[k])];
1233
+ if (a[0] > b[0] || (a[0] === b[0] && a[1] > b[1])) k = j;
1234
+ }
1235
+ chain.push(left.splice(k, 1)[0]);
1236
+ }
1237
+ // 상자 순서 후보: 영역마다 다른 영역과 잇는 연결이 HUB 개 이상 몰린 상자(값: 뒤 영역 연결 수 - 앞 영역 연결 수, 영역 없는 상자는 맨 뒤)를
1238
+ // 값이 HUB 이상이면 맨 뒤 칸(아래 행, 오른쪽), -HUB 이하면 맨 앞 칸으로. 나머지는 배열 순서 그대로. 연결이 많은 상자의 선이
1239
+ // 같은 영역의 다른 상자를 돌아가지 않고 바로 이웃 영역으로 간다.
1240
+ const sortNodes = (order) => {
1241
+ const at = new Map(order.map((g, i) => [g.z ? g.z.id : null, i]));
1242
+ return order.map((g, gi) => {
1243
+ const val = new Map(g.list.map((n) => [n.id, 0]));
1244
+ for (const e of edges) {
1245
+ for (const [me, other] of [[e && e.from, e && e.to], [e && e.to, e && e.from]]) {
1246
+ if (!val.has(me) || val.has(other)) continue;
1247
+ if (!zoneOfNode.has(other) && !ids.has(other)) continue;
1248
+ const oz = zid(other);
1249
+ const oi = at.has(oz) ? at.get(oz) : -1;
1250
+ if (oi >= 0 && oi !== gi) val.set(me, val.get(me) + (oi > gi ? 1 : -1));
1251
+ }
1252
+ }
1253
+ const side = (n) => (Math.abs(val.get(n.id)) >= AUTO.HUB ? Math.sign(val.get(n.id)) : 0);
1254
+ const list = g.list.map((n, i) => [n, i]).sort((p, q) => side(p[0]) - side(q[0]) || p[1] - q[1]).map((x) => x[0]);
1255
+ return list.every((n, i) => n === g.list[i]) ? g : { ...g, list };
1256
+ });
1257
+ };
1258
+ const orders = [];
1259
+ const seen = new Set();
1260
+ for (const o of [groups, [...chain, ...groups.filter((g) => !g.z)]]) {
1261
+ for (const v of [o, sortNodes(o)]) {
1262
+ const sig = v.map((g) => g.list.map((n) => n.id).join(',')).join('/');
1263
+ if (!seen.has(sig)) seen.add(sig), orders.push(v);
1264
+ }
1265
+ }
1266
+ // 후보 값 = base + WARN 수 x WARN_PX + 선 길이 합 x LEN + 엇갈림 수 x CROSS + 붐비는 이름표 수 x CROWD + FOREIGN, TIGHT 몫(위). base 는 높이, 상자 MANY 개 이상이고 가로세로 비가
1267
+ // RATIO 아래면 모자란 폭(RATIO x 높이 - 폭)을 더한다(비 하한에 가까운 후보). 길을 긋기 전에 아래 한계(base + 두 끝 사각형 사이
1268
+ // 가로세로 거리 합 x LEN. 선은 그 거리보다 짧을 수 없음)로 후보를 줄 세워 작은 TRY 개만 긋는다. 그 가운데 앞서 고른 후보 값보다
1269
+ // 아래 한계가 큰 후보는 긋지 않는다.
1270
+ const base = (L) => L.height + (nodes.length >= AUTO.MANY ? Math.max(0, AUTO.RATIO * L.height - L.width) : 0);
1271
+ const ends = edges.filter((e) => e && e.from !== e.to).map((e) => [e.from, e.to]);
1272
+ const lower = (L) => {
1273
+ let d = 0;
1274
+ for (const [a, b] of ends) {
1275
+ const p = L.node.get(a) || L.zone.get(a);
1276
+ const q = L.node.get(b) || L.zone.get(b);
1277
+ if (p && q) d += Math.max(0, q.x - p.x - p.w, p.x - q.x - q.w) + Math.max(0, q.y - p.y - p.h, p.y - q.y - q.h);
1278
+ }
1279
+ return base(L) + d * AUTO.LEN;
1280
+ };
1281
+ // 후보 하나에 선을 긋고 값을 잰다.
1282
+ const draw = (L) => {
1283
+ const routes = autoRoutes(L, edges);
1284
+ L.edge = (e) => {
1285
+ const pts = routes.get(e);
1286
+ return { ...e, via: pts && pts.length > 2 ? pts.slice(1, -1) : undefined, lx: undefined, ly: undefined, off: 0, offTo: undefined, pts };
1287
+ };
1288
+ const { len, cross, foreign, tight, hug } = routeSum(routes, L);
1289
+ const es = archEdges(f, L);
1290
+ const crowd = es.filter((g) => g.crowd).length;
1291
+ const cost = base(L) + archFlaws(f, L, es).length * AUTO.WARN_PX + len * AUTO.LEN + cross * AUTO.CROSS + crowd * AUTO.CROWD + foreign * AUTO.FOREIGN + (tight + hug) * AUTO.TIGHT;
1292
+ return { routes, es, cost };
1293
+ };
1294
+ const pick = (widths) => {
1295
+ const cands = [];
1296
+ const tried = new Set();
1297
+ orders.forEach((order, oi) => {
1298
+ for (const maxW of widths) {
1299
+ for (let rows = 1; rows <= maxN; rows++) {
1300
+ for (const snake of [false, true]) {
1301
+ const L = autoPlace(order, rows, maxW, snake, gapOf);
1302
+ const key = oi + ':' + L.sig + (snake && L.sig.includes('/') ? '/s' : '');
1303
+ if (tried.has(key)) continue;
1304
+ tried.add(key);
1305
+ cands.push({ L, args: [order, rows, maxW, snake], i: cands.length, bad: nodes.length >= AUTO.MANY && L.width / L.height < AUTO.RATIO ? 1 : 0, lb: lower(L) });
1306
+ }
1307
+ }
1308
+ }
1309
+ });
1310
+ // 아래 한계가 작은 차례(같으면 먼저 만든 후보). 비 하한을 맞춘 후보가 있으면 못 맞춘 후보는 긋지 않는다.
1311
+ cands.sort((p, q) => p.bad - q.bad || p.lb - q.lb || p.i - q.i);
1312
+ let best = null;
1313
+ for (const c of cands.slice(0, AUTO.TRY)) {
1314
+ if (best && (c.bad > best.bad || c.lb > best.cost)) continue;
1315
+ const d = draw(c.L);
1316
+ if (!best || c.bad < best.bad || (c.bad === best.bad && (d.cost < best.cost || (d.cost === best.cost && c.i < best.i)))) best = { ...c, ...d };
1317
+ }
1318
+ // 고른 후보에서 나란한 선이 들어가지 않는 틈을 넓혀 다시 놓고 그어 본다. 값이 작고 비 하한을 지키면 바꾼다.
1319
+ const need = roomNeed(best.L, best.routes, pairKey);
1320
+ // 같은 영역 같은 행 이웃 상자를 곧게 잇는 선의 이름표가 상자에서 LBOX 안이면 그 영역 상자 사이 가로 간격을 이름표 + 2 x LBOX + 2 로(GX + 16 이하일 때만).
1321
+ for (const g of best.es) {
1322
+ const [p, q] = g.pts;
1323
+ const z = zoneOfNode.get(g.e.from);
1324
+ const w = Math.ceil(g.lw) + 2 * ROUTE.LBOX + 2;
1325
+ if (!g.e.label || g.pts.length !== 2 || p[1] !== q[1] || !z || z !== zoneOfNode.get(g.e.to) || w > AUTO.GX + 16 || Math.abs(q[0] - p[0]) >= w) continue;
1326
+ need.set('gx:' + z, Math.max(need.get('gx:' + z) || 0, w));
1327
+ }
1328
+ if (need.size) {
1329
+ const [order, ...rest] = best.args;
1330
+ const L = autoPlace(order.map((g) => (g.z && need.has('gx:' + g.z.id) ? { ...g, gx: need.get('gx:' + g.z.id) } : g)), ...rest, (p, q) => Math.max(gapOf(p, q), (p.z && q.z && need.get(pairKey(p.z.id, q.z.id))) || 0), (bi) => Math.max(AUTO.ZGAP, need.get('band' + bi) || 0));
1331
+ const bad = nodes.length >= AUTO.MANY && L.width / L.height < AUTO.RATIO ? 1 : 0;
1332
+ const d = draw(L);
1333
+ if (bad <= best.bad && d.cost < best.cost) best = { ...best, L, bad, ...d };
1334
+ }
1335
+ return best;
1336
+ };
1337
+ let best = pick(AUTO.WIDTHS);
1338
+ if (best.bad) {
1339
+ const wide = pick([AUTO.WIDE]);
1340
+ if (wide.bad < best.bad || (wide.bad === best.bad && wide.cost < best.cost)) best = wide;
1341
+ }
1342
+ const { L } = best;
1343
+ return { auto: true, missing, width: L.width, height: L.height, zone: L.zone, node: L.node, edge: L.edge };
1344
+ }
1345
+
1346
+ // 이름표 자리 후보(선분 위 비율). 가운데부터 시작 쪽으로, 다음에 끝 쪽으로.
1347
+ const LABEL_TS = [0.5, 0.45, 0.4, 0.35, 0.3, 0.25, 0.2, 0.15, 0.1, 0.55, 0.6, 0.65, 0.7, 0.75, 0.8, 0.85, 0.9];
1348
+ const hits = (a, b, gap) => overlap(a.x, a.x + a.w, b.x, b.x + b.w) > gap && overlap(a.y, a.y + a.h, b.y, b.y + b.h) > gap;
1349
+ const seg = (s) => Math.hypot(s[1][0] - s[0][0], s[1][1] - s[0][1]);
1350
+
1351
+ // 연결선 점과 이름표 자리(렌더와 check 가 같이 씀). 끝을 찾지 못한 연결은 뺀다.
1352
+ // 좌표 사용이면 옛 규칙(lx, ly 또는 가장 긴 선분 가운데). 자동 배치면 선을 모두 그은 뒤 이름표를 연결 차례로 놓는다:
1353
+ // 그 연결의 선분을 긴 차례로 보며 상자와 1 px 이상 겹치지 않고, 상자에서 LBOX 이상, 앞서 놓은 이름표와 4 px 이상 떨어지고, 영역 이름과 영역 테두리를
1354
+ // 가리지 않고, 다른 연결의 선과 LGAP 이상 떨어진 첫 후보. 없으면 (덮는 상자 수, LBOX 안 상자 수, 가까운 이름표 수, 가리는 영역 이름과 테두리 수,
1355
+ // LGAP 안의 다른 선 수)가 가장 작은 첫 후보.
1356
+ export function archEdges(f, L) {
1357
+ const out = [];
1358
+ const segsOf = new Map();
1359
+ for (const e0 of arr(f.edges)) {
1360
+ const e = L.edge(e0);
1361
+ // 연결 끝은 구성요소나 영역(zone) id. 상자를 먼저 찾는다.
1362
+ const a = L.node.get(e.from) || L.zone.get(e.from);
1363
+ const b = L.node.get(e.to) || L.zone.get(e.to);
1364
+ if (!a || !b) continue;
1365
+ const pts = L.auto && e.pts ? e.pts : pathOf(a, b, e.via || [], e.off || 0, e.offTo ?? e.off ?? 0);
1366
+ // 가장 긴 선분이 앞. 길이가 같으면 선 차례(안정 정렬).
1367
+ const segs = pts.slice(1).map((q, i) => [pts[i], q]).sort((s, t) => seg(t) - seg(s));
1368
+ const [p, q] = segs[0];
1369
+ const lw = textW(e.label) + 10;
1370
+ const col = color(e.color, '') || ACOL[e.kind] || ACOL.control;
1371
+ const g = { e, e0, id: e.id || `${e.from}-${e.to}`, pts, col, lx: e.lx ?? (p[0] + q[0]) / 2, ly: e.ly ?? (p[1] + q[1]) / 2, lw };
1372
+ segsOf.set(g, segs);
1373
+ out.push(g);
1374
+ }
1375
+ if (!L.auto) return out;
1376
+ const placed = [];
1377
+ const boxes = [...L.node.values()];
1378
+ // 선분이 이름표 사각형 안을 지나는지(가장자리 1 px 안쪽).
1379
+ const under = (s, r) => Math.max(s[0][0], s[1][0]) > r.x + 1 && Math.min(s[0][0], s[1][0]) < r.x + r.w - 1 && Math.max(s[0][1], s[1][1]) > r.y + 1 && Math.min(s[0][1], s[1][1]) < r.y + r.h - 1;
1380
+ // 후보 자리: 선분을 긴 차례로, 선분마다 LABEL_TS 17곳. 상자와 겹치지 않는 곳이 없으면 선분마다 가운데부터 4 px 간격으로 더.
1381
+ // 그림 밖으로 나가는 자리는 그림 안(테두리 선 굵기까지 1 px 안쪽)으로 민다(가장자리 통로를 지나는 선).
1382
+ // 자리마다 덮는 상자 수, 가리는 영역 이름(굵은 13px, 글 폭 x 1.3)과 LGAP 안의 영역 테두리 수, LGAP 안의 다른 연결 선 수를 미리 센다.
1383
+ const zoneNames = arr(f.zones).filter((z) => L.zone.has(z.id)).map((z) => titleBand(z, L.zone.get(z.id).x, L.zone.get(z.id).y));
1384
+ // 영역 테두리 네 변(선분). 이름표가 덮지 않게 다른 선처럼 센다.
1385
+ const borders = [...L.zone.values()].flatMap((z) => [[[z.x, z.y], [z.x + z.w, z.y]], [[z.x, z.y + z.h], [z.x + z.w, z.y + z.h]], [[z.x, z.y], [z.x, z.y + z.h]], [[z.x + z.w, z.y], [z.x + z.w, z.y + z.h]]]);
1386
+ const { LGAP, LBOX } = ROUTE;
1387
+ const fine = (len) => Array.from({ length: Math.max(0, Math.floor(len / 8) - 1) }, (_, k) => 0.5 + ((k % 2 ? -1 : 1) * Math.ceil(k / 2) * 4) / len);
1388
+ const spots = new Map();
1389
+ for (const g of out) {
1390
+ if (!g.e.label) continue;
1391
+ const { lw } = g;
1392
+ // 이 연결의 선분 둘레(이름표 폭 절반 + 9 px 바깥까지)와 닿는 다른 선분만 본다.
1393
+ const own = segsOf.get(g).flat();
1394
+ const [bx0, bx1, by0, by1] = [Math.min(...own.map((q) => q[0])) - lw / 2 - 9, Math.max(...own.map((q) => q[0])) + lw / 2 + 9, Math.min(...own.map((q) => q[1])) - 17, Math.max(...own.map((q) => q[1])) + 17];
1395
+ const others = out.filter((o) => o !== g).flatMap((o) => segsOf.get(o)).filter((q) => Math.max(q[0][0], q[1][0]) > bx0 && Math.min(q[0][0], q[1][0]) < bx1 && Math.max(q[0][1], q[1][1]) > by0 && Math.min(q[0][1], q[1][1]) < by1);
1396
+ const list = [];
1397
+ for (const ts of [() => LABEL_TS, (s) => fine(seg(s))]) {
1398
+ for (const s of segsOf.get(g)) {
1399
+ const [s0, s1] = s;
1400
+ for (const t of ts(s)) {
1401
+ const x = Math.min(Math.max(s0[0] + (s1[0] - s0[0]) * t, lw / 2 + 1), L.width - lw / 2 - 1);
1402
+ const y = Math.min(Math.max(s0[1] + (s1[1] - s0[1]) * t, 9), L.height - 9);
1403
+ const r = { x: x - lw / 2, y: y - 8, w: lw, h: 16 };
1404
+ const near = { x: r.x - LGAP, y: r.y - LGAP, w: r.w + 2 * LGAP, h: r.h + 2 * LGAP };
1405
+ const pad = { x: r.x - LBOX, y: r.y - LBOX, w: r.w + 2 * LBOX, h: r.h + 2 * LBOX };
1406
+ list.push({ x, y, r, box: boxes.filter((n) => hits(r, n, 0)).length, boxNear: boxes.filter((n) => hits(pad, n, 0)).length, zone: zoneNames.filter((z) => hits(r, z, 0)).length + borders.filter((q) => under(q, near)).length, line: others.filter((q) => under(q, near)).length });
1407
+ }
1408
+ }
1409
+ if (list.some((c) => !c.box)) break;
1410
+ }
1411
+ spots.set(g, list);
1412
+ }
1413
+ // 상자와 겹치지 않는 자리가 적은 이름표부터 놓는다(같으면 연결 차례). 자리 점수: 덮는 상자 수, LBOX 안 상자 수, 4 px 안쪽 이름표 수,
1414
+ // 가리는 영역 이름과 테두리 수, LGAP 안의 다른 선 수. 모두 0 인 첫 자리, 없으면 점수가 가장 작은 첫 자리.
1415
+ const free = (g) => spots.get(g).filter((c) => !c.box).length;
1416
+ const order = [...spots.keys()].map((g, i) => [g, free(g), i]).sort((p, q) => p[1] - q[1] || p[2] - q[2]);
1417
+ for (const [g] of order) {
1418
+ let best = null;
1419
+ for (const c of spots.get(g)) {
1420
+ const sc = [c.box, c.boxNear, placed.filter((o) => hits(c.r, o, -4)).length, c.zone, c.line];
1421
+ if (!best || (sc[0] - best.sc[0] || sc[1] - best.sc[1] || sc[2] - best.sc[2] || sc[3] - best.sc[3] || sc[4] - best.sc[4]) < 0) best = { sc, c };
1422
+ if (!best.sc.some(Boolean)) break;
1423
+ }
1424
+ [g.lx, g.ly] = [best.c.x, best.c.y];
1425
+ // 상자에서 LBOX 안이거나 영역 이름이나 테두리를 가리거나 다른 선이 LGAP 안에 있는 이름표(배치 고르기 값).
1426
+ g.crowd = best.sc[1] + best.sc[3] + best.sc[4] > 0;
1427
+ placed.push(best.c.r);
1428
+ }
1429
+ return out;
1430
+ }
1431
+
1432
+ // 연결의 기하 문제(check 와 자동 배치 고르기가 같이 씀): 선이 지나는 상자, 그림 밖 이름표, 이름표가 덮는 상자.
1433
+ // 돌려줌: [{ g: archEdges 한 줄, kind: 'cross' | 'out' | 'cover', id: 상자 id }]. 연결 차례, 그 안에서 위 종류 차례.
1434
+ export function archFlaws(f, L, es = archEdges(f, L)) {
1435
+ const nodes = arr(f.nodes);
1436
+ const out = [];
1437
+ const hit = (a, b) => overlap(a.x, a.x + a.w, b.x, b.x + b.w) >= 1 && overlap(a.y, a.y + a.h, b.y, b.y + b.h) >= 1;
1438
+ for (const g of es) {
1439
+ const a = L.node.get(g.e.from) || L.zone.get(g.e.from);
1440
+ const b = L.node.get(g.e.to) || L.zone.get(g.e.to);
1441
+ const crossed = new Set();
1442
+ for (let k = 0; k + 1 < g.pts.length; k++) {
1443
+ for (const n of nodes) {
1444
+ const box = L.node.get(n.id);
1445
+ if (box === a || box === b || crossed.has(n.id)) continue;
1446
+ if (segHits(g.pts[k], g.pts[k + 1], box)) crossed.add(n.id);
1447
+ }
1448
+ }
1449
+ for (const id of crossed) out.push({ g, kind: 'cross', id });
1450
+ if (g.e.label) {
1451
+ const lab = { x: g.lx - g.lw / 2, y: g.ly - 8, w: g.lw, h: 16 };
1452
+ if (lab.x < 0 || lab.x + lab.w > L.width) out.push({ g, kind: 'out' });
1453
+ for (const n of nodes) if (hit(lab, L.node.get(n.id))) out.push({ g, kind: 'cover', id: n.id });
1454
+ }
1455
+ }
1456
+ return out;
1457
+ }
1458
+
1459
+ function archBody(m, config) {
1460
+ if (!m.arch.value) return emptyNotice('references/arch.md');
1461
+ const f = m.arch.value;
1462
+ const prefix = config.text.plannedPrefix;
1463
+ const L = archLayout(f, prefix);
1464
+ const zones = arr(f.zones);
1465
+ const nodes = arr(f.nodes);
1466
+ const edgesIn = arr(f.edges);
1467
+ const scenes = arr(f.scenes);
1468
+ const zoneD = new Map(zones.map((z) => [z.id, z]));
1469
+ const sceneOf = (id) => scenes.filter((s) => arr(s.focus).includes(id)).map((s) => s.n);
1470
+ const main = arr(f.main);
1471
+ const cycle = main.length * FSTEP_S + 1.5;
1472
+ const hi = Math.max(2, Math.round((FSTEP_S / cycle) * 100));
1473
+ const zoneColor = (z) => color(z && z.color, '#555555');
1474
+ const parts = [];
1475
+ for (const z of zones) {
1476
+ const r = L.zone.get(z.id);
1477
+ const zc = zoneColor(z);
1478
+ parts.push(`<g class="zn" id="z-${esc(z.id)}"><rect x="${r.x}" y="${r.y}" width="${r.w}" height="${r.h}" rx="14" fill="${zc}14" stroke="${zc}" stroke-width="1.5"${z.dashed ? ' stroke-dasharray="7 5"' : ''}/><text x="${r.x + 12}" y="${r.y + 19}" class="zl" fill="${zc}">${esc(z.label)}</text></g>`);
1479
+ }
1480
+ const cols = new Set();
1481
+ const edges = [];
1482
+ const labels = [];
1483
+ for (const { e, id, pts, col, lx, ly, lw } of archEdges(f, L)) {
1484
+ cols.add(col);
1485
+ // 선과 이름표를 따로 그린다. 이름표는 모든 선과 상자 뒤(SVG 는 나중에 그린 것이 앞)에 그려 선에 가리지 않는다.
1486
+ // 마우스를 올리면 보이는 글: 설명, 없으면 이름표, 둘 다 없으면 근거만(근거도 없으면 빈 글).
1487
+ const refs = arr(e.refs);
1488
+ const said = e.detail || e.label || '';
1489
+ const tip = `<title>${esc(said + (refs.length ? (said ? ' (' : '(') + refs.join(', ') + ')' : ''))}</title>`;
1490
+ if (e.label) labels.push(`<g class="el-tag" id="e-${esc(id)}" data-scene="${esc(sceneOf(id).join(','))}">${tip}<rect x="${lx - lw / 2}" y="${ly - 8}" width="${lw}" height="16" rx="8" fill="#fff" stroke="${col}" stroke-width="0.8"/><text x="${lx}" y="${ly + 3.5}" class="el" text-anchor="middle">${esc(e.label)}</text></g>`);
1491
+ edges.push(`<g class="eg" id="e-${esc(id)}-line" data-scene="${esc(sceneOf(id).join(','))}">${tip}
1492
+ <polyline points="${pts.map((p) => p.join(',')).join(' ')}" fill="none" stroke="${col}" class="${esc(e.kind || 'control')}" marker-end="url(#am${col.slice(1)})"${e.both ? ` marker-start="url(#am${col.slice(1)})"` : ''}/>
1493
+ </g>`);
1494
+ }
1495
+ for (const n of nodes) {
1496
+ const r = L.node.get(n.id);
1497
+ const col = color(n.color, '') || zoneColor(zoneD.get(n.zone));
1498
+ const k = main.indexOf(n.id);
1499
+ const anim = k >= 0 ? ` style="animation:ap ${cycle}s ${(k * FSTEP_S).toFixed(1)}s infinite"` : '';
1500
+ const sub = wrap(n.detail, r.w - 16).slice(0, n.lines || 2);
1501
+ let cx = r.x + 8;
1502
+ let cy = r.y + 26 + sub.length * 14 + 4;
1503
+ const chips = arr(n.items)
1504
+ .map((t) => {
1505
+ const w = textW(t) + 12;
1506
+ if (cx + w > r.x + r.w - 6) {
1507
+ cx = r.x + 8;
1508
+ cy += 19;
1509
+ }
1510
+ // 예정 접두로 시작하는 칩은 주황(아직 구현 안 됨).
1511
+ const planned = Boolean(prefix) && String(t).startsWith(prefix);
1512
+ const [cf, cs] = planned ? ['#fff4e0', '#b26a00'] : [col + '1f', col];
1513
+ const s = `<rect x="${cx}" y="${cy}" width="${w}" height="16" rx="8" fill="${cf}" stroke="${cs}" stroke-width="0.6"${planned ? ' stroke-dasharray="3 2"' : ''}/><text x="${cx + 6}" y="${cy + 11.5}" class="ck">${esc(t)}</text>`;
1514
+ cx += w + 4;
1515
+ return s;
1516
+ })
1517
+ .join('');
1518
+ parts.push(`<g class="nd" id="n-${esc(n.id)}" data-scene="${esc(sceneOf(n.id).join(','))}"${anim}><title>${esc([n.kind, n.detail, n.path, arr(n.refs).join(', ')].filter(Boolean).join(' | '))}</title>
1519
+ <rect x="${r.x}" y="${r.y}" width="${r.w}" height="${r.h}" rx="9" fill="#fff" stroke="${col}" stroke-width="${k >= 0 ? 2.4 : 1.6}"${n.planned ? ' stroke-dasharray="6 4"' : ''}/>
1520
+ <text x="${r.x + 8}" y="${r.y + 18}" class="nl">${esc(n.label)}</text>${n.planned ? `<text x="${r.x + r.w - 6}" y="${r.y + 16}" class="tag">${PLANNED_TAG}</text>` : ''}
1521
+ ${sub.map((t, i) => `<text x="${r.x + 8}" y="${r.y + 34 + i * 14}" class="ns">${esc(t)}</text>`).join('')}${chips}</g>`);
1522
+ }
1523
+ const markers = [...cols].map((c) => `<marker id="am${c.slice(1)}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0L10 5L0 10z" fill="${c}"/></marker>`).join('');
1524
+ const total = scenes.reduce((a, s) => a + (finite(s.sec) ? s.sec : 0), 0);
1525
+ const nodeD = new Map(nodes.map((n) => [n.id, n]));
1526
+ const label = (id) => (nodeD.get(id) || {}).label || (edgesIn.find((e) => (e.id || `${e.from}-${e.to}`) === id) || {}).label || id;
1527
+ const rows = scenes.map((s) => `<tr><td class="q">${esc(s.n)}</td><td class="d">${esc(s.sec)}초</td><td>${esc(s.caption)}</td><td class="n">${esc(arr(s.focus).map(label).join(', '))}</td></tr>`).join('');
1528
+ const legend = arr(f.legend).map((t) => `<li>${esc(t)}</li>`).join('');
1529
+ return `<div class="meta">${sent(`${esc(f.note || '')} 자료: <code>arch.json</code>. 주황 굵은 선은 요청이 흐르는 길, 회색 실선은 실행과 호출, 점선은 파일과 설정 읽기, 점선 상자는 예정. 상자와 선에 마우스를 올리면 근거가 보임.`)}</div>
1530
+ <style>@keyframes ap{0%{filter:none}${Math.round(hi / 2)}%{filter:drop-shadow(0 0 8px #e65100)}${hi}%,100%{filter:none}}</style>
1531
+ <div class="fwrap"><svg class="arch" viewBox="0 0 ${L.width} ${L.height}" width="${L.width}" height="${L.height}" role="img" aria-label="${esc(config.shortName)} 구성도"><defs>${markers}</defs>${parts.slice(0, zones.length).join('')}${edges.join('')}${parts.slice(zones.length).join('')}${labels.join('')}</svg></div>
1532
+ ${legend ? `<h2>읽는 법</h2><ul class="meta">${legend}</ul>` : ''}
1533
+ <h2>장면 대본 <small>모션 그래픽 준비, 합계 ${total}초</small></h2>
1534
+ <table><thead><tr><th>장면</th><th>길이</th><th>화면 내용</th><th>강조 구성요소</th></tr></thead><tbody>${rows}</tbody></table>`;
1535
+ }
1536
+
1537
+ // ---------- 화면 ----------
1538
+
1539
+ // 탭 key. workflow 탭의 옛 주소(?tab=flow)도 받는다. 모르는 값은 진척도.
1540
+ export const tabKey = (tab) => (tab === 'flow' ? 'workflow' : TABS.some((t) => t[0] === tab) ? tab : 'history');
1541
+
1542
+ function tabBody(m, config, k, now) {
1543
+ if (k === 'arch') return archBody(m, config);
1544
+ if (k === 'workflow') return flowBody(m);
1545
+ if (k === 'req') return reqBody(m, config);
1546
+ return historyBody(m, config, now);
1547
+ }
1548
+
1549
+ // 모은 자료(m)로 탭 하나의 HTML. workflow 와 구성도는 움직임이 있고 자주 바뀌지 않아 자동 갱신하지 않는다.
1550
+ export function pageFrom(m, config, tab, now) {
1551
+ const k = tabKey(tab);
1552
+ const refresh = k === 'history' || k === 'req' ? `<meta http-equiv="refresh" content="${config.refreshSec};url=/?tab=${k}">` : '';
1553
+ return `<!doctype html><html lang="ko"><head><meta charset="utf-8">${refresh}
1554
+ <title>${esc(config.shortName)} ${tabName(k)}</title><link rel="icon" href="${FAVICON}"><style>
1555
+ ${CSS}
1556
+ </style></head><body>
1557
+ <h1>${esc(config.title)}</h1>
1558
+ <div class="meta">갱신 ${esc(nowText(now))} | ${config.refreshSec}초마다 자동 갱신 | HEAD <code>${esc(m.head)}</code></div>
1559
+ ${tabsHtml(k)}
1560
+ ${tabBody(m, config, k, now)}</body></html>`;
1561
+ }
1562
+
1563
+ // 단일 HTML 스냅숏: 네 탭을 한 파일에. 서버, 자동 갱신, 외부 자원 없이 file:// 로 열린다.
1564
+ // 탭 전환은 주소의 #history, #req, #workflow, #arch 와 짧은 인라인 스크립트. 스크립트가 꺼져 있으면 네 탭을 모두 보인다.
1565
+ export function snapshotFrom(m, config, now) {
1566
+ const tabs = TABS.map(([k, name]) => `<a href="#${k}" data-tab="${k}">${esc(name)}</a>`).join('');
1567
+ const secs = TABS.map(([k, name]) => `<section id="tab-${k}" class="tab"><h2 class="tt">${esc(name)}</h2>\n\n${tabBody(m, config, k, now)}</section>`).join('\n');
1568
+ return `<!doctype html><html lang="ko"><head><meta charset="utf-8">
1569
+ <title>${esc(config.shortName)} 진척도 스냅숏</title><link rel="icon" href="${FAVICON}"><style>
1570
+ ${CSS}
1571
+ </style>
1572
+ <style>.js .tab{display:none}.js .tab.cur{display:block}.js h2.tt{display:none}h2.tt{border-top:2px solid #ccc;padding-top:10px}</style></head><body>
1573
+ <h1>${esc(config.title)}</h1>
1574
+ <div class="meta">스냅숏 ${esc(nowText(now))} | 서버 없이 여는 단일 파일(자동 갱신 없음) | HEAD <code>${esc(m.head)}</code></div>
1575
+ <div class="tabs">${tabs}</div>
1576
+ ${secs}
1577
+ <script>(function(){var d=document.documentElement;d.className+=' js';var ks=${JSON.stringify(TABS.map((t) => t[0]))};
1578
+ function show(){var h=location.hash.slice(1),k=ks.indexOf(h)<0?ks[0]:h;ks.forEach(function(x){document.getElementById("tab-"+x).className='tab'+(x===k?' cur':'');var a=document.querySelector('.tabs a[data-tab="'+x+'"]');a.className=x===k?'on':'';});scrollTo(0,0);}
1579
+ addEventListener('hashchange',show);show();})();</script>
1580
+ </body></html>`;
1581
+ }
1582
+
1583
+ // 서버가 요청마다 부른다. 자료는 요청마다 다시 읽는다(바뀌지 않은 파일은 캐시).
1584
+ export function renderTab(ctx, config, tab, now) {
1585
+ return pageFrom(collect(ctx, config, now), config, tab, now);
1586
+ }
1587
+
1588
+ // config.json 형식 오류 화면. 고치면 다음 자동 갱신(기본 주기)에 화면이 돌아온다.
1589
+ export function configErrorPage(errors, refreshSec) {
1590
+ const rows = errors.map((e) => `<li><code>${esc(e.path)}</code> ${esc(e.msg)}</li>`).join('');
1591
+ return `<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta http-equiv="refresh" content="${refreshSec}">
1592
+ <title>config.json 형식 오류</title><link rel="icon" href="${FAVICON}"><style>
1593
+ ${CSS}
1594
+ </style></head><body>
1595
+ <h1>config.json 형식 오류</h1>
1596
+ <div class="meta">고친 뒤 ${refreshSec}초 안에 화면이 돌아옴. check 로 확인</div>
1597
+ <ul>${rows}</ul></body></html>`;
1598
+ }
1599
+
1600
+ // 서버의 렌더 함수. config.json 을 요청마다 보고 (mtime, 크기, ctime)이 바뀌었을 때만 다시 읽는다.
1601
+ // 그래서 stories, commitRegex, title 같은 값은 서버를 다시 띄우지 않아도 다음 응답에 반영된다(포트와 주소만 다시 띄워야 함).
1602
+ export function liveRender(ctx, initial) {
1603
+ const file = path.join(ctx.dataDir, 'config.json');
1604
+ const keyOf = () => {
1605
+ try {
1606
+ const st = fs.statSync(file);
1607
+ return `${st.mtimeMs}:${st.size}:${st.ctimeMs}`;
1608
+ } catch (e) {
1609
+ return e.code || 'error';
1610
+ }
1611
+ };
1612
+ let key = null; // 첫 요청에서 다시 읽는다(서버가 처음 읽은 뒤 키를 재기 전에 바뀐 config 를 놓치지 않게)
1613
+ let cur = { config: initial, errors: [] };
1614
+ return (tab, now) => {
1615
+ const k = keyOf();
1616
+ if (k !== key) {
1617
+ key = k;
1618
+ cur = loadConfig(ctx);
1619
+ }
1620
+ if (cur.errors.length) return configErrorPage(cur.errors, initial.refreshSec);
1621
+ return renderTab(ctx, cur.config, tab, now);
1622
+ };
1623
+ }
1624
+
1625
+ // CLI: export <file> [--now <ISO>]. 자료 폴더를 만들지 않는다.
1626
+ export async function command(cmd, ctx, o, io) {
1627
+ if (cmd !== 'export') throw new UsageError(`${cmd} 는 pm-render.mjs 가 맡지 않음`);
1628
+ const file = o._[1];
1629
+ if (!file || o._.length > 2) throw new UsageError('사용: export <file> [--now <ISO>]');
1630
+ let now = Date.now();
1631
+ if (o.now !== undefined) {
1632
+ now = Date.parse(o.now);
1633
+ if (!Number.isFinite(now)) throw new UsageError(`--now 는 ISO 8601 시각: ${o.now}`);
1634
+ }
1635
+ const config = configOrFail(ctx);
1636
+ const out = path.resolve(file);
1637
+ try {
1638
+ fs.writeFileSync(out, snapshotFrom(collect(ctx, config, now), config, now));
1639
+ } catch (e) {
1640
+ throw new UsageError(`쓰기 실패(${e.code || e.message}): ${file}`);
1641
+ }
1642
+ io.out(`내보냄: ${out}`);
1643
+ return 0;
1644
+ }