@tienne/gestalt 0.88.0 → 0.89.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 (197) hide show
  1. package/CLAUDE.md +6 -2
  2. package/README.ko.md +2 -1
  3. package/README.md +3 -1
  4. package/dist/package.json +2 -1
  5. package/dist/plugin/skills/_shared/proactive-routing.md +1 -0
  6. package/dist/plugin/skills/architecture/SKILL.md +913 -0
  7. package/dist/schemas/architecture-ir.schema.json +596 -0
  8. package/dist/src/architecture/canvas-geometry.d.ts +40 -0
  9. package/dist/src/architecture/canvas-geometry.d.ts.map +1 -0
  10. package/dist/src/architecture/canvas-geometry.js +212 -0
  11. package/dist/src/architecture/canvas-geometry.js.map +1 -0
  12. package/dist/src/architecture/codeowners.d.ts +16 -0
  13. package/dist/src/architecture/codeowners.d.ts.map +1 -0
  14. package/dist/src/architecture/codeowners.js +58 -0
  15. package/dist/src/architecture/codeowners.js.map +1 -0
  16. package/dist/src/architecture/compare-layout.d.ts +27 -0
  17. package/dist/src/architecture/compare-layout.d.ts.map +1 -0
  18. package/dist/src/architecture/compare-layout.js +72 -0
  19. package/dist/src/architecture/compare-layout.js.map +1 -0
  20. package/dist/src/architecture/dataflow-layout.d.ts +29 -0
  21. package/dist/src/architecture/dataflow-layout.d.ts.map +1 -0
  22. package/dist/src/architecture/dataflow-layout.js +229 -0
  23. package/dist/src/architecture/dataflow-layout.js.map +1 -0
  24. package/dist/src/architecture/doc-fresh.d.ts +31 -0
  25. package/dist/src/architecture/doc-fresh.d.ts.map +1 -0
  26. package/dist/src/architecture/doc-fresh.js +84 -0
  27. package/dist/src/architecture/doc-fresh.js.map +1 -0
  28. package/dist/src/architecture/doc-link.d.ts +84 -0
  29. package/dist/src/architecture/doc-link.d.ts.map +1 -0
  30. package/dist/src/architecture/doc-link.js +396 -0
  31. package/dist/src/architecture/doc-link.js.map +1 -0
  32. package/dist/src/architecture/doc-routes.d.ts +39 -0
  33. package/dist/src/architecture/doc-routes.d.ts.map +1 -0
  34. package/dist/src/architecture/doc-routes.js +123 -0
  35. package/dist/src/architecture/doc-routes.js.map +1 -0
  36. package/dist/src/architecture/doc-scan.d.ts +107 -0
  37. package/dist/src/architecture/doc-scan.d.ts.map +1 -0
  38. package/dist/src/architecture/doc-scan.js +617 -0
  39. package/dist/src/architecture/doc-scan.js.map +1 -0
  40. package/dist/src/architecture/doc-stale.d.ts +32 -0
  41. package/dist/src/architecture/doc-stale.d.ts.map +1 -0
  42. package/dist/src/architecture/doc-stale.js +60 -0
  43. package/dist/src/architecture/doc-stale.js.map +1 -0
  44. package/dist/src/architecture/drilldown.d.ts +51 -0
  45. package/dist/src/architecture/drilldown.d.ts.map +1 -0
  46. package/dist/src/architecture/drilldown.js +1160 -0
  47. package/dist/src/architecture/drilldown.js.map +1 -0
  48. package/dist/src/architecture/endpoint-match.d.ts +43 -0
  49. package/dist/src/architecture/endpoint-match.d.ts.map +1 -0
  50. package/dist/src/architecture/endpoint-match.js +133 -0
  51. package/dist/src/architecture/endpoint-match.js.map +1 -0
  52. package/dist/src/architecture/flow-layout.d.ts +79 -0
  53. package/dist/src/architecture/flow-layout.d.ts.map +1 -0
  54. package/dist/src/architecture/flow-layout.js +519 -0
  55. package/dist/src/architecture/flow-layout.js.map +1 -0
  56. package/dist/src/architecture/focus.d.ts +14 -0
  57. package/dist/src/architecture/focus.d.ts.map +1 -0
  58. package/dist/src/architecture/focus.js +107 -0
  59. package/dist/src/architecture/focus.js.map +1 -0
  60. package/dist/src/architecture/global-context.d.ts +23 -0
  61. package/dist/src/architecture/global-context.d.ts.map +1 -0
  62. package/dist/src/architecture/global-context.js +67 -0
  63. package/dist/src/architecture/global-context.js.map +1 -0
  64. package/dist/src/architecture/html-client.d.ts +35 -0
  65. package/dist/src/architecture/html-client.d.ts.map +1 -0
  66. package/dist/src/architecture/html-client.js +1852 -0
  67. package/dist/src/architecture/html-client.js.map +1 -0
  68. package/dist/src/architecture/html-renderer.d.ts +22 -0
  69. package/dist/src/architecture/html-renderer.d.ts.map +1 -0
  70. package/dist/src/architecture/html-renderer.js +1290 -0
  71. package/dist/src/architecture/html-renderer.js.map +1 -0
  72. package/dist/src/architecture/html-theme.d.ts +25 -0
  73. package/dist/src/architecture/html-theme.d.ts.map +1 -0
  74. package/dist/src/architecture/html-theme.js +681 -0
  75. package/dist/src/architecture/html-theme.js.map +1 -0
  76. package/dist/src/architecture/index.d.ts +9 -0
  77. package/dist/src/architecture/index.d.ts.map +1 -0
  78. package/dist/src/architecture/index.js +9 -0
  79. package/dist/src/architecture/index.js.map +1 -0
  80. package/dist/src/architecture/ir-schema.d.ts +1832 -0
  81. package/dist/src/architecture/ir-schema.d.ts.map +1 -0
  82. package/dist/src/architecture/ir-schema.js +343 -0
  83. package/dist/src/architecture/ir-schema.js.map +1 -0
  84. package/dist/src/architecture/kind-text.d.ts +21 -0
  85. package/dist/src/architecture/kind-text.d.ts.map +1 -0
  86. package/dist/src/architecture/kind-text.js +42 -0
  87. package/dist/src/architecture/kind-text.js.map +1 -0
  88. package/dist/src/architecture/layout.d.ts +227 -0
  89. package/dist/src/architecture/layout.d.ts.map +1 -0
  90. package/dist/src/architecture/layout.js +627 -0
  91. package/dist/src/architecture/layout.js.map +1 -0
  92. package/dist/src/architecture/mcp-tool-match.d.ts +49 -0
  93. package/dist/src/architecture/mcp-tool-match.d.ts.map +1 -0
  94. package/dist/src/architecture/mcp-tool-match.js +53 -0
  95. package/dist/src/architecture/mcp-tool-match.js.map +1 -0
  96. package/dist/src/architecture/merge.d.ts +54 -0
  97. package/dist/src/architecture/merge.d.ts.map +1 -0
  98. package/dist/src/architecture/merge.js +681 -0
  99. package/dist/src/architecture/merge.js.map +1 -0
  100. package/dist/src/architecture/micro-app.d.ts +20 -0
  101. package/dist/src/architecture/micro-app.d.ts.map +1 -0
  102. package/dist/src/architecture/micro-app.js +41 -0
  103. package/dist/src/architecture/micro-app.js.map +1 -0
  104. package/dist/src/architecture/name-ref-match.d.ts +43 -0
  105. package/dist/src/architecture/name-ref-match.d.ts.map +1 -0
  106. package/dist/src/architecture/name-ref-match.js +127 -0
  107. package/dist/src/architecture/name-ref-match.js.map +1 -0
  108. package/dist/src/architecture/packs/data.d.ts +109 -0
  109. package/dist/src/architecture/packs/data.d.ts.map +1 -0
  110. package/dist/src/architecture/packs/data.js +64 -0
  111. package/dist/src/architecture/packs/data.js.map +1 -0
  112. package/dist/src/architecture/packs/generic.d.ts +47 -0
  113. package/dist/src/architecture/packs/generic.d.ts.map +1 -0
  114. package/dist/src/architecture/packs/generic.js +35 -0
  115. package/dist/src/architecture/packs/generic.js.map +1 -0
  116. package/dist/src/architecture/packs/harness.d.ts +96 -0
  117. package/dist/src/architecture/packs/harness.d.ts.map +1 -0
  118. package/dist/src/architecture/packs/harness.js +66 -0
  119. package/dist/src/architecture/packs/harness.js.map +1 -0
  120. package/dist/src/architecture/packs/index.d.ts +840 -0
  121. package/dist/src/architecture/packs/index.d.ts.map +1 -0
  122. package/dist/src/architecture/packs/index.js +87 -0
  123. package/dist/src/architecture/packs/index.js.map +1 -0
  124. package/dist/src/architecture/packs/infra.d.ts +91 -0
  125. package/dist/src/architecture/packs/infra.d.ts.map +1 -0
  126. package/dist/src/architecture/packs/infra.js +57 -0
  127. package/dist/src/architecture/packs/infra.js.map +1 -0
  128. package/dist/src/architecture/packs/knowledge.d.ts +72 -0
  129. package/dist/src/architecture/packs/knowledge.d.ts.map +1 -0
  130. package/dist/src/architecture/packs/knowledge.js +58 -0
  131. package/dist/src/architecture/packs/knowledge.js.map +1 -0
  132. package/dist/src/architecture/packs/process.d.ts +87 -0
  133. package/dist/src/architecture/packs/process.d.ts.map +1 -0
  134. package/dist/src/architecture/packs/process.js +52 -0
  135. package/dist/src/architecture/packs/process.js.map +1 -0
  136. package/dist/src/architecture/packs/render-classes.d.ts +11 -0
  137. package/dist/src/architecture/packs/render-classes.d.ts.map +1 -0
  138. package/dist/src/architecture/packs/render-classes.js +92 -0
  139. package/dist/src/architecture/packs/render-classes.js.map +1 -0
  140. package/dist/src/architecture/packs/types.d.ts +66 -0
  141. package/dist/src/architecture/packs/types.d.ts.map +1 -0
  142. package/dist/src/architecture/packs/types.js +18 -0
  143. package/dist/src/architecture/packs/types.js.map +1 -0
  144. package/dist/src/architecture/packs/web-product.d.ts +348 -0
  145. package/dist/src/architecture/packs/web-product.d.ts.map +1 -0
  146. package/dist/src/architecture/packs/web-product.js +226 -0
  147. package/dist/src/architecture/packs/web-product.js.map +1 -0
  148. package/dist/src/architecture/sequence-layout.d.ts +50 -0
  149. package/dist/src/architecture/sequence-layout.d.ts.map +1 -0
  150. package/dist/src/architecture/sequence-layout.js +140 -0
  151. package/dist/src/architecture/sequence-layout.js.map +1 -0
  152. package/dist/src/architecture/service-facts.d.ts +30 -0
  153. package/dist/src/architecture/service-facts.d.ts.map +1 -0
  154. package/dist/src/architecture/service-facts.js +88 -0
  155. package/dist/src/architecture/service-facts.js.map +1 -0
  156. package/dist/src/architecture/store.d.ts +50 -0
  157. package/dist/src/architecture/store.d.ts.map +1 -0
  158. package/dist/src/architecture/store.js +320 -0
  159. package/dist/src/architecture/store.js.map +1 -0
  160. package/dist/src/architecture/types.d.ts +400 -0
  161. package/dist/src/architecture/types.d.ts.map +1 -0
  162. package/dist/src/architecture/types.js +75 -0
  163. package/dist/src/architecture/types.js.map +1 -0
  164. package/dist/src/architecture/validator.d.ts +65 -0
  165. package/dist/src/architecture/validator.d.ts.map +1 -0
  166. package/dist/src/architecture/validator.js +1249 -0
  167. package/dist/src/architecture/validator.js.map +1 -0
  168. package/dist/src/core/errors.d.ts +4 -0
  169. package/dist/src/core/errors.d.ts.map +1 -1
  170. package/dist/src/core/errors.js +8 -0
  171. package/dist/src/core/errors.js.map +1 -1
  172. package/dist/src/mcp/schemas.d.ts +317 -0
  173. package/dist/src/mcp/schemas.d.ts.map +1 -1
  174. package/dist/src/mcp/schemas.js +151 -0
  175. package/dist/src/mcp/schemas.js.map +1 -1
  176. package/dist/src/mcp/server.d.ts.map +1 -1
  177. package/dist/src/mcp/server.js +7 -1
  178. package/dist/src/mcp/server.js.map +1 -1
  179. package/dist/src/mcp/tools/architecture-passthrough.d.ts +9 -0
  180. package/dist/src/mcp/tools/architecture-passthrough.d.ts.map +1 -0
  181. package/dist/src/mcp/tools/architecture-passthrough.js +587 -0
  182. package/dist/src/mcp/tools/architecture-passthrough.js.map +1 -0
  183. package/dist/src/utils/claude-projects.d.ts +19 -0
  184. package/dist/src/utils/claude-projects.d.ts.map +1 -0
  185. package/dist/src/utils/claude-projects.js +157 -0
  186. package/dist/src/utils/claude-projects.js.map +1 -0
  187. package/dist/src/utils/read-only-tools.d.ts +19 -0
  188. package/dist/src/utils/read-only-tools.d.ts.map +1 -0
  189. package/dist/src/utils/read-only-tools.js +132 -0
  190. package/dist/src/utils/read-only-tools.js.map +1 -0
  191. package/package.json +2 -1
  192. package/plugin/.codex-plugin/plugin.json +1 -1
  193. package/plugin/.mcp.json +1 -1
  194. package/plugin/mcp.json +1 -1
  195. package/plugin/skills/_shared/proactive-routing.md +1 -0
  196. package/plugin/skills/architecture/SKILL.md +913 -0
  197. package/schemas/architecture-ir.schema.json +596 -0
@@ -0,0 +1,1852 @@
1
+ import { BAND_LINE_X, BAND_LINE_Y, BAND_TITLE_HALF, BAND_TITLE_X } from './canvas-geometry.js';
2
+ import { FOCUS_SOURCE } from './focus.js';
3
+ import { PRODUCT_COLORS } from './html-theme.js';
4
+ import { FRAME_PAD } from './layout.js';
5
+ // component는 칩 글자를 노드의 displayKind에서, 색과 아이콘은 renderClass에서 가져온다. types.ts의 displayKindOf, chipTextOverride와 같은 규칙이다.
6
+ // 이 조각은 component를 쓸 수 있는 어휘에만 싣는다. 늘 실으면 component가 없는 그림의 바이트까지 바뀐다
7
+ const COMPONENT_DKIND = "n.kind === 'component' ? 'cx_' + (n.renderClass || 'service') : ";
8
+ const COMPONENT_CHIP = "(n.kind === 'component' && n.displayKind) || ";
9
+ const ROOT_FLOWS = "current === 'root' ? flows.filter(function (f) { return !f.service; }) : []";
10
+ // 근거가 섞인 묶음 조각. 그런 묶음이 있는 그림에만 싣는다. 문구는 html-renderer.ts의 inferredNote와 같다
11
+ const MIXED_NOTE = " + (e.inferred !== undefined ? ' (문서 근거만 있는 연결 ' + e.inferred + '개 포함)' : '')";
12
+ const MIXED_PANEL_NOTE = " + (bundle.inferred !== undefined ? ' (문서 근거만 있는 연결 ' + bundle.inferred + '개 포함)' : '')";
13
+ const MIXED_PILL = `
14
+ if (e.inferred !== undefined) pill.lastChild.setAttribute('stroke-dasharray', '3 2');`;
15
+ // 질문 안내 표가 문서로 보내는 말도 검색에 넣는다. 묶음 카드는 아래 레벨 문서의 이름과 말까지 품어서 전체보기에서 찾아도 들어갈 길이 켜진다
16
+ const DOC_HAY = " + (n.doc && n.doc.keywords ? ' ' + n.doc.keywords.join(' ') : '') + docLevelHay(enterMap[n.id])";
17
+ // 문서 정보 조각. 지식 문서 팩을 쓰는 그림에만 싣는다. 공유본은 본문 글자가 가려진 채로 온다
18
+ const DOC_FUNCS = ` var GAP_TEXT = { gap: '빈 곳', unverified: '확인 안 됨' };
19
+ var VIA_TEXT = { 'code-ref': '코드 경로', 'api-path': 'API', 'screen-route': '화면 라우트', session: '세션' };
20
+ function renderCover(list, body) {
21
+ body.appendChild(el('h3', null, list.length ? '설명하는 문서 ' + list.length + '개' : '설명하는 문서 없음'));
22
+ if (!list.length) return;
23
+ var ul = el('ul', 'evidence doc-cover');
24
+ list.forEach(function (x) {
25
+ var li = el('li');
26
+ li.appendChild(el('span', 'badge', VIA_TEXT[x.via] || x.via));
27
+ var notes = [];
28
+ if (!x.verified) notes.push('가리킨 파일 확인 못 함');
29
+ if (x.gaps) notes.push('빈 곳 ' + x.gaps);
30
+ if (x.unverified) notes.push('확인 안 됨 ' + x.unverified);
31
+ if (x.broken) notes.push('깨진 링크 ' + x.broken);
32
+ if (x.staleSince) notes.push('코드가 ' + x.staleSince + '에 바뀜');
33
+ li.appendChild(el('span', 'loc', (x.title ? x.title + ' · ' : '') + x.path + (notes.length ? ' (' + notes.join(', ') + ')' : '')));
34
+ ul.appendChild(li);
35
+ });
36
+ body.appendChild(ul);
37
+ }
38
+ function renderDoc(n, body) {
39
+ var d = n.doc;
40
+ if (!d) {
41
+ if (data.docOverlay) renderCover(data.docOverlay[n.id] || [], body);
42
+ return;
43
+ }
44
+ var facts = el('ul', 'facts');
45
+ factRow(facts, '경로', d.path);
46
+ if (d.updatedAt) factRow(facts, '최종 수정', d.updatedAt);
47
+ if (d.committedAt) factRow(facts, '마지막 커밋', d.committedAt);
48
+ if (d.reads !== undefined) factRow(facts, '읽힌 횟수', String(d.reads));
49
+ if (d.orphan) factRow(facts, '질문 길', '진입 문서에서 안 닿음');
50
+ if (d.aged) factRow(facts, '신선도', '반년 넘게 안 고침');
51
+ if (d.keywords && d.keywords.length) factRow(facts, '찾는 말', d.keywords.join(', '));
52
+ var sc = d.screen;
53
+ if (sc) {
54
+ if (sc.route) factRow(facts, '라우트', sc.route);
55
+ if (sc.screenType) factRow(facts, '화면 종류', sc.screenType);
56
+ if (sc.frame) factRow(facts, '피그마 프레임', sc.frame);
57
+ if (sc.symbolized !== undefined) factRow(facts, '심볼화', sc.symbolized ? '됨' : '안 됨');
58
+ if (sc.hasSpec !== undefined) factRow(facts, '화면 설계 문서', sc.hasSpec ? '있음' : '없음');
59
+ if (sc.synonyms && sc.synonyms.length) factRow(facts, '동의어', sc.synonyms.join(', '));
60
+ }
61
+ if (n.owners && n.owners.length) factRow(facts, '담당', n.owners.join(', '));
62
+ body.appendChild(facts);
63
+ if (sc && sc.thumbnail) {
64
+ var img = el('img', 'doc-thumb');
65
+ img.src = sc.thumbnail;
66
+ img.alt = nameOf(n) + ' 화면';
67
+ body.appendChild(img);
68
+ }
69
+ var mix = d.evidenceMix ? Object.keys(d.evidenceMix).sort() : [];
70
+ if (mix.length) {
71
+ var total = mix.reduce(function (a, k) { return a + d.evidenceMix[k]; }, 0);
72
+ body.appendChild(el('h3', null, '근거 구성 ' + total + '개'));
73
+ var bar = el('div', 'doc-mix');
74
+ var legend = el('ul', 'facts');
75
+ mix.forEach(function (k, i) {
76
+ var seg = el('span', 'mix-' + (i % 6));
77
+ seg.style.width = (d.evidenceMix[k] / total * 100).toFixed(1) + '%';
78
+ seg.title = k + ' ' + d.evidenceMix[k];
79
+ bar.appendChild(seg);
80
+ factRow(legend, k, String(d.evidenceMix[k]));
81
+ });
82
+ body.appendChild(bar);
83
+ body.appendChild(legend);
84
+ }
85
+ var lk = d.links;
86
+ if (lk && lk.total) {
87
+ body.appendChild(el('h3', null, '근거 링크 ' + lk.total + '개'));
88
+ var ll = el('ul', 'facts');
89
+ if (lk.broken) factRow(ll, '깨짐', String(lk.broken));
90
+ if (lk.branchOnly) factRow(ll, '브랜치만 가리킴', String(lk.branchOnly));
91
+ if (lk.pinned) factRow(ll, '커밋 고정', String(lk.pinned));
92
+ if (lk.unchecked) factRow(ll, '확인 못 함', String(lk.unchecked));
93
+ body.appendChild(ll);
94
+ }
95
+ if (d.gaps && d.gaps.length) {
96
+ body.appendChild(el('h3', null, '열린 구멍 ' + d.gaps.length + '개'));
97
+ var gl = el('ul', 'evidence');
98
+ d.gaps.forEach(function (g) {
99
+ var li = el('li');
100
+ li.appendChild(el('span', 'badge doc-' + g.kind, GAP_TEXT[g.kind] || g.kind));
101
+ var who = [g.owner, g.line !== undefined ? g.line + '줄' : ''].filter(Boolean).join(', ');
102
+ li.appendChild(el('span', 'loc', (g.text || '공유본이라 내용을 가렸어요') + (who ? ' (' + who + ')' : '')));
103
+ gl.appendChild(li);
104
+ });
105
+ body.appendChild(gl);
106
+ }
107
+ if (d.routes && d.routes.length) {
108
+ body.appendChild(el('h3', null, '질문 안내 ' + d.routes.length + '줄'));
109
+ var rl = el('ul', 'refs-list doc-routes');
110
+ d.routes.forEach(function (r) {
111
+ var li = el('li');
112
+ var text = r.keywords.join(', ') + ' → ' + (r.target && nodes[r.target] ? label(r.target) : r.targetPath);
113
+ if (r.target && nodes[r.target]) {
114
+ var b = el('button', null, text);
115
+ b.type = 'button';
116
+ b.addEventListener('click', function () { goToNode(r.target); });
117
+ li.appendChild(b);
118
+ } else {
119
+ li.appendChild(el('span', 'loc', text + ' (그림에 없는 문서)'));
120
+ }
121
+ rl.appendChild(li);
122
+ });
123
+ body.appendChild(rl);
124
+ }
125
+ if (d.sections && d.sections.length) {
126
+ body.appendChild(el('h3', null, '절 ' + d.sections.length + '개'));
127
+ var sl = el('ul', 'facts');
128
+ d.sections.forEach(function (t) { factRow(sl, '#', t); });
129
+ body.appendChild(sl);
130
+ }
131
+ }
132
+ var levelHay = {};
133
+ function docLevelHay(id) {
134
+ if (!id || id === current) return '';
135
+ if (levelHay[id] !== undefined) return levelHay[id];
136
+ levelHay[id] = '';
137
+ var s = sectionOf(id);
138
+ var out = '';
139
+ if (s) s.querySelectorAll('.node').forEach(function (c) {
140
+ var n = nodes[c.getAttribute('data-node-id')];
141
+ if (n) out += ' ' + (n.displayName || '') + ' ' + n.label${DOC_HAY};
142
+ });
143
+ return (levelHay[id] = out);
144
+ }
145
+ `;
146
+ // 질문별 그림 조각. 투영이 있는 그림에만 싣는다
147
+ const VIEW_CLICK = `var vm = e.target.closest('.link.seq-m');
148
+ if (vm) { activateMessage(vm); return; }
149
+ `;
150
+ const VIEW_KEY = `var vm = e.target.closest('.link.seq-m');
151
+ if (vm) { e.preventDefault(); activateMessage(vm); return; }
152
+ `;
153
+ const VIEW_FUNCS = ` var messages = data.messages || {};
154
+ // 전체 레벨은 지도 위 카드 줄만큼 내려가 있다. 같은 이름으로 다시 선언해 그 거리를 더한 쪽이 쓰이게 한다
155
+ function cardCenter(c) {
156
+ var s = c.closest('.level');
157
+ return { x: s.offsetLeft + c.offsetLeft + c.offsetWidth / 2, y: s.offsetTop + c.offsetTop + c.offsetHeight / 2 };
158
+ }
159
+ // 순서도를 아래로 내려도 머리 카드는 화면 위에 붙여 둔다. 확대와 이동이 viewport transform 하나라 CSS sticky가 안 먹어서 그 값이 바뀔 때마다 직접 내린다
160
+ function stickHeads() {
161
+ var bar = active && active.querySelector('.seq-sticky');
162
+ if (!bar) return;
163
+ var top = parseFloat(bar.style.top);
164
+ var end = Number(active.getAttribute('data-h')) - top - bar.offsetHeight;
165
+ var dy = Math.min(Math.max(0, -view.y / view.k - top), Math.max(0, end));
166
+ var t = dy > 0 ? 'translateY(' + dy + 'px)' : '';
167
+ bar.style.transform = t;
168
+ bar.classList.toggle('stuck', dy > 0);
169
+ active.querySelectorAll('.node').forEach(function (c) { c.style.transform = t; });
170
+ }
171
+ new MutationObserver(stickHeads).observe(viewport, { attributes: true, attributeFilter: ['style'] });
172
+ function activateMessage(g) {
173
+ var m = messages[g.getAttribute('data-message-id')];
174
+ if (!m) return;
175
+ active.querySelectorAll('.node.selected').forEach(function (x) { x.classList.remove('selected'); });
176
+ lightLink(active, g);
177
+ returnFocus = g;
178
+ selectedId = null;
179
+ syncFocusBtn();
180
+ panel.textContent = '';
181
+ panel.appendChild(panelHead('flow', 'u-flow', m.shape === 'dataflow' ? '데이터 이동' : '주고받기', m.view, m.label));
182
+ var body = el('div', 'dr-body');
183
+ var facts = el('ul', 'facts');
184
+ factRow(facts, '보내는 쪽', label(m.from));
185
+ factRow(facts, '받는 쪽', label(m.to));
186
+ if (m.reply) factRow(facts, '종류', '응답');
187
+ if (m.block) factRow(facts, '묶음', m.branch ? m.block + ', ' + m.branch : m.block);
188
+ factRow(facts, '선', m.lineStyle === 'dashed' ? '점선 (문서나 사람 말로만 확인)' : '실선 (코드나 스펙으로 확인)');
189
+ body.appendChild(facts);
190
+ questionList(body, m.questions);
191
+ refButtons(body, m.from === m.to ? [m.from] : [m.from, m.to], focusCard, function (r) { return 'i-' + dkind(nodes[r]); }, label);
192
+ evidenceList(body, m.evidence);
193
+ panel.appendChild(body);
194
+ openDrawer();
195
+ }
196
+ `;
197
+ const VIEW_POP = ` setupPop('views-btn', 'views');
198
+ var viewsPop = byId('views');
199
+ if (viewsPop) viewsPop.addEventListener('click', function (e) { if (e.target.closest('a')) closePops(false); });
200
+ `;
201
+ export const THEME_STORAGE_KEY = 'gestalt-architecture-theme';
202
+ const HINT_STORAGE_KEY = 'gestalt-architecture-hint';
203
+ // 첫 화면을 그리기 전에 저장한 테마를 걸어야 밝은 화면이 한 번 번쩍이지 않는다
204
+ export const THEME_BOOT_SCRIPT = `(function () {
205
+ try {
206
+ var t = localStorage.getItem('${THEME_STORAGE_KEY}');
207
+ if (t === 'light' || t === 'dark') document.documentElement.setAttribute('data-theme', t);
208
+ } catch (e) {}
209
+ })();`;
210
+ /**
211
+ * 페이지 동작 전부. 화면 이동, 확대와 이동, 서랍, 검색, 팝오버, 두 항목 화면을 맡는다.
212
+ * 문자열은 전부 textContent로만 넣는다. 근거 문자열은 외부 출처에서 온 값이라 HTML로 해석하면 스크립트가 실행될 수 있다.
213
+ * 템플릿 문자열 안이라 정규식과 문자열의 백슬래시는 두 번 적어야 브라우저에 한 번 남는다.
214
+ */
215
+ export function renderClientScript(c) {
216
+ return `
217
+ (function () {
218
+ var doc = document;
219
+ var root = doc.documentElement;
220
+ var data = JSON.parse(doc.getElementById('ir').textContent);
221
+ var KIND_SHORT = ${JSON.stringify(c.kindShort)};
222
+ var HOST_SHORT = ${JSON.stringify(c.hostShort)};
223
+ var FRAME_PAD = ${FRAME_PAD};
224
+ var BAND_LINE_X = ${BAND_LINE_X}, BAND_LINE_Y = ${BAND_LINE_Y}, BAND_TITLE_X = ${BAND_TITLE_X}, BAND_TITLE_HALF = ${BAND_TITLE_HALF};
225
+ var PRODUCT_COLORS = ${PRODUCT_COLORS};
226
+ var MICRO_HOSTS = {};
227
+ (data.microHosts || []).forEach(function (id) { MICRO_HOSTS[id] = true; });
228
+ var KIND_TEXT = ${JSON.stringify(c.kindText)};
229
+ var EVIDENCE_TEXT = ${JSON.stringify(c.evidenceText)};
230
+ var LANE_TITLE = ${JSON.stringify(c.laneTitles)};
231
+ var GUESS = ${JSON.stringify(c.inferredBadge)};
232
+ var PLATFORM_CHIP = ${JSON.stringify(c.platformChip)};
233
+ var PLATFORM_NAME = ${JSON.stringify(c.platformName)};
234
+ var WEB_HOSTING_CHIP = ${JSON.stringify(c.webHostingChip)};
235
+ var WEB_HOSTING_NAME = ${JSON.stringify(c.webHostingName)};
236
+ // 서버의 platformChipText, platformName과 같은 규칙이다
237
+ function chipText(p, f) {
238
+ return p === 'web' && f && f.webHosting ? WEB_HOSTING_CHIP[f.webHosting] : PLATFORM_CHIP[p];
239
+ }
240
+ function platName(p, f) {
241
+ return p === 'web' && f && f.webHosting ? WEB_HOSTING_NAME[f.webHosting] : PLATFORM_NAME[p] || p;
242
+ }
243
+ var BACKWARD = ${JSON.stringify(Object.fromEntries(c.backwardKinds.map((k) => [k, true])))};
244
+ var services = data.services || {};
245
+ var THEME_KEY = '${THEME_STORAGE_KEY}';
246
+ var HINT_KEY = '${HINT_STORAGE_KEY}';
247
+ var SVG_NS = 'http://www.w3.org/2000/svg';
248
+ var nodes = {};
249
+ data.nodes.forEach(function (n) { nodes[n.id] = n; });
250
+ var edges = {};
251
+ data.edges.forEach(function (e) { edges[e.id] = e; });
252
+ var drill = !!data.levels;
253
+ var levels = {};
254
+ (data.levels || [{ id: 'root', kind: 'root', title: '전체', trail: ['root'], edges: [] }]).forEach(function (l) {
255
+ l.edgeById = {};
256
+ l.edges.forEach(function (e) { l.edgeById[e.id] = e; });
257
+ levels[l.id] = l;
258
+ });
259
+ var enterMap = data.enter || {};
260
+ // 흐름 단계는 노드가 아니지만 nodes에 같이 넣는다. 강조와 검색, 질문 이동이 nodes와 카드 id만 보고 돌아서다
261
+ var flows = data.flows || [];
262
+ var flowsBySvc = {};
263
+ var stepFlow = {};
264
+ var transitions = {};
265
+ var stepsByRef = {};
266
+ flows.forEach(function (f) {
267
+ (flowsBySvc[f.service] = flowsBySvc[f.service] || []).push(f);
268
+ var actors = {};
269
+ f.actors.forEach(function (a) { actors[a.id] = a; });
270
+ f.steps.forEach(function (st) {
271
+ nodes[st.id] = { id: st.id, kind: 'flow_step', label: st.label, repo: '', evidence: st.evidence, step: st, actor: actors[st.actor] };
272
+ stepFlow[st.id] = f;
273
+ (st.refs || []).forEach(function (r) { (stepsByRef[r] = stepsByRef[r] || []).push(st.id); });
274
+ });
275
+ f.transitions.forEach(function (t) { transitions[t.id] = t; });
276
+ });
277
+ function isStep(id) { return !!stepFlow[id]; }
278
+ function byId(id) { return doc.getElementById(id); }
279
+ var stage = byId('stage');
280
+ var viewport = byId('viewport');
281
+ var drawer = byId('drawer');
282
+ var panel = byId('panel');
283
+ var crumbs = byId('crumbs');
284
+ var pair = byId('pair');
285
+ var focusSec = byId('focus');
286
+ var focusChip = byId('focus-chip');
287
+ var focusName = byId('focus-name');
288
+ var focusBtn = byId('focus-btn');
289
+ var sheet = byId('sheet');
290
+ var sheetTitle = byId('sheet-title');
291
+ var sheetBody = byId('sheet-body');
292
+ var hint = byId('hint');
293
+ var zoomLevel = byId('zoom-level');
294
+ var search = byId('search');
295
+ var searchCount = byId('search-count');
296
+ var themeBtn = byId('theme-btn');
297
+ var sections = Array.prototype.slice.call(doc.querySelectorAll('.level[data-level-id]'));
298
+ var current = 'root';
299
+ var active = null;
300
+ var anchor = null;
301
+ var selectedId = null;
302
+ var focusId = null;
303
+ var pendingSelect = null;
304
+ var returnFocus = null;
305
+ var ready = false;
306
+ var view = { x: 0, y: 0, k: 1 };
307
+
308
+ ${FOCUS_SOURCE}
309
+ function kindText(k) { return KIND_TEXT[k] || k; }
310
+ // MCP 도구는 IR에선 endpoint지만 칩과 색은 따로 단다. types.ts의 displayKindOf와 같은 규칙이다
311
+ function dkind(n) { return !n ? '' : ${c.components ? COMPONENT_DKIND : ''}n.kind === 'endpoint' && n.protocol === 'mcp' ? 'mcp_tool' : n.kind; }
312
+ function evidenceText(t) { return EVIDENCE_TEXT[t] || t; }
313
+ function el(tag, cls, text) {
314
+ var e = doc.createElement(tag);
315
+ if (cls) e.className = cls;
316
+ if (text !== undefined) e.textContent = text;
317
+ return e;
318
+ }
319
+ function svgEl(tag, attrs) {
320
+ var e = doc.createElementNS(SVG_NS, tag);
321
+ Object.keys(attrs).forEach(function (k) { e.setAttribute(k, attrs[k]); });
322
+ return e;
323
+ }
324
+ function icon(id) {
325
+ var s = svgEl('svg', { viewBox: '0 0 24 24', 'aria-hidden': 'true', focusable: 'false' });
326
+ s.appendChild(svgEl('use', { href: '#' + id }));
327
+ return s;
328
+ }
329
+ function nameOf(n) { return n.displayName || n.label; }
330
+ function label(id) { return nodes[id] ? nameOf(nodes[id]) : id; }
331
+ function store(key, value) { try { localStorage.setItem(key, value); } catch (e) {} }
332
+ function load(key) { try { return localStorage.getItem(key); } catch (e) { return null; } }
333
+
334
+ // 테마. 저장한 값이 없으면 시스템 설정을 따른다
335
+ function systemDark() { return !!(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches); }
336
+ function themeNow() { return root.getAttribute('data-theme') || (systemDark() ? 'dark' : 'light'); }
337
+ function syncTheme() {
338
+ var dark = themeNow() === 'dark';
339
+ var text = dark ? '밝은 화면으로 바꾸기' : '어두운 화면으로 바꾸기';
340
+ themeBtn.setAttribute('data-mode', dark ? 'dark' : 'light');
341
+ themeBtn.setAttribute('aria-label', text);
342
+ themeBtn.title = text;
343
+ }
344
+ themeBtn.addEventListener('click', function () {
345
+ var next = themeNow() === 'dark' ? 'light' : 'dark';
346
+ root.setAttribute('data-theme', next);
347
+ store(THEME_KEY, next);
348
+ syncTheme();
349
+ });
350
+ if (window.matchMedia) {
351
+ var mq = window.matchMedia('(prefers-color-scheme: dark)');
352
+ if (mq.addEventListener) mq.addEventListener('change', syncTheme);
353
+ }
354
+ syncTheme();
355
+
356
+ // 서랍 내용
357
+ function renderEvidence(ev) {
358
+ var li = el('li');
359
+ li.appendChild(el('span', 'badge t-' + ev.type, evidenceText(ev.type)));
360
+ if (ev.location === undefined) {
361
+ li.appendChild(el('span', 'loc', '공유본이라 출처를 가렸어요'));
362
+ if (ev.observedAt) li.appendChild(el('div', 'ev-meta', '조회 시각 ' + ev.observedAt));
363
+ return li;
364
+ }
365
+ if (/^https?:\\/\\//i.test(ev.location)) {
366
+ var a = el('a', 'loc', ev.location);
367
+ a.href = ev.location;
368
+ a.target = '_blank';
369
+ a.rel = 'noopener noreferrer';
370
+ li.appendChild(a);
371
+ } else {
372
+ li.appendChild(el('span', 'loc', ev.location));
373
+ }
374
+ if (ev.command) li.appendChild(el('code', 'cmd', ev.command));
375
+ if (ev.type === 'live' && ev.observedAt) li.appendChild(el('div', 'ev-meta', '조회 시각 ' + ev.observedAt));
376
+ else if (ev.updatedAt) li.appendChild(el('div', 'ev-meta', '수정일 ' + ev.updatedAt));
377
+ if (ev.excerpt) li.appendChild(el('pre', null, ev.excerpt));
378
+ return li;
379
+ }
380
+ function factRow(list, key, value) {
381
+ var li = el('li');
382
+ li.appendChild(el('span', 'env', key));
383
+ li.appendChild(el('span', 'val', value));
384
+ list.appendChild(li);
385
+ }
386
+ // 환경과 계정, 서비스의 도메인과 플랫폼. 그래프에서 끌어낸 사실이라 출처 목록 앞에 둔다
387
+ function renderFacts(n, body) {
388
+ var basics = el('ul', 'facts');
389
+ if (n.environment) factRow(basics, '환경', n.environment);
390
+ if (n.account) factRow(basics, '계정', label(n.account));
391
+ if (n.mcpServer) factRow(basics, 'MCP 서버', n.mcpServer);
392
+ if (n.actions) factRow(basics, 'action', n.actions.join(', '));
393
+ if (basics.childNodes.length) body.appendChild(basics);
394
+ var f = services[n.id];
395
+ if (!f) return;
396
+ if (f.domains.length) {
397
+ body.appendChild(el('h3', null, '도메인 ' + f.domains.length + '개'));
398
+ var dl = el('ul', 'facts');
399
+ f.domains.forEach(function (d) { factRow(dl, d.environment || '환경 모름', d.label); });
400
+ body.appendChild(dl);
401
+ }
402
+ if (!f.platforms.length) return;
403
+ body.appendChild(el('h3', null, '플랫폼'));
404
+ var pl = el('ul', 'evidence plat');
405
+ f.platforms.forEach(function (p) {
406
+ var li = el('li');
407
+ li.appendChild(el('span', 'badge', platName(p, f)));
408
+ var evs = (n.platformEvidence && n.platformEvidence[p]) || [];
409
+ if (p === 'web' && f.servingBuckets.length) {
410
+ li.appendChild(el('span', 'loc', '서빙 버킷: ' + f.servingBuckets.map(label).join(', ')));
411
+ }
412
+ if (p === 'web' && f.servingTargets && f.servingTargets.length) {
413
+ li.appendChild(el('span', 'loc', '서빙 서버: ' + f.servingTargets.map(label).join(', ')));
414
+ }
415
+ if (evs.length) {
416
+ var inner = el('ul', 'evidence');
417
+ evs.forEach(function (ev) { inner.appendChild(renderEvidence(ev)); });
418
+ li.appendChild(inner);
419
+ }
420
+ pl.appendChild(li);
421
+ });
422
+ body.appendChild(pl);
423
+ }
424
+ function panelHead(chipKind, chipIcon, chipText, sub, title) {
425
+ var head = el('div', 'dr-head');
426
+ var chips = el('div', 'chips');
427
+ var chip = el('span', 'chip ' + chipKind);
428
+ chip.appendChild(icon(chipIcon));
429
+ chip.appendChild(doc.createTextNode(chipText));
430
+ chips.appendChild(chip);
431
+ if (sub) chips.appendChild(el('span', 'repo', sub));
432
+ head.appendChild(chips);
433
+ var h = el('h2', null, title);
434
+ h.id = 'drawer-title';
435
+ h.tabIndex = -1;
436
+ head.appendChild(h);
437
+ return head;
438
+ }
439
+ function evidenceList(body, list) {
440
+ body.appendChild(el('h3', null, '출처 ' + list.length + '개'));
441
+ var ul = el('ul', 'evidence');
442
+ list.forEach(function (ev) { ul.appendChild(renderEvidence(ev)); });
443
+ body.appendChild(ul);
444
+ }
445
+ function questionList(body, list) {
446
+ if (!list || !list.length) return;
447
+ body.appendChild(el('h3', null, '확인할 질문 ' + list.length + '개'));
448
+ var ul = el('ul', 'qlist');
449
+ list.forEach(function (q) { ul.appendChild(el('li', 'q-text', q)); });
450
+ body.appendChild(ul);
451
+ }
452
+ // 단계와 기술 그림을 잇는 단추. 같은 서비스 레벨에 카드가 있으면 그쪽을 먼저 연다. 흐름을 보다 온 사람이 서비스 안에서 이어 보게 하려는 것이다
453
+ function goToRef(id, svc) {
454
+ var lv = 'service:' + svc;
455
+ var sec = sectionOf(lv);
456
+ if (sec && cardMap(sec)[id]) {
457
+ if (current === lv) { focusCard(id); return; }
458
+ pendingSelect = id;
459
+ showLevel(lv);
460
+ return;
461
+ }
462
+ goToNode(id);
463
+ }
464
+ function refButtons(body, ids, onClick, iconOf, textOf) {
465
+ var ul = el('ul', 'refs-list');
466
+ ids.forEach(function (id) {
467
+ var li = el('li');
468
+ var b = el('button');
469
+ b.type = 'button';
470
+ b.appendChild(icon(iconOf(id)));
471
+ b.appendChild(doc.createTextNode(textOf(id)));
472
+ b.addEventListener('click', function () { onClick(id); });
473
+ li.appendChild(b);
474
+ ul.appendChild(li);
475
+ });
476
+ body.appendChild(ul);
477
+ }
478
+ function renderStepPanel(id) {
479
+ var n = nodes[id];
480
+ var st = n.step;
481
+ var f = stepFlow[id];
482
+ panel.textContent = '';
483
+ panel.appendChild(panelHead(st.path === 'side' ? 'flow side' : 'flow', 'u-flow', '흐름 단계', f.title, st.label));
484
+ var body = el('div', 'dr-body');
485
+ var facts = el('ul', 'facts');
486
+ if (n.actor) factRow(facts, '누가', n.actor.label);
487
+ if (st.state) {
488
+ var stateName = f.stateLabels && f.stateLabels[st.state];
489
+ factRow(facts, '상태', stateName ? stateName + ' (' + st.state + ')' : st.state);
490
+ }
491
+ factRow(facts, '갈래', st.path === 'side' ? '옆 흐름' : '정상 흐름');
492
+ if (st.terminal) factRow(facts, '다음', '여기서 흐름이 끝나요');
493
+ body.appendChild(facts);
494
+ if (st.description) body.appendChild(el('p', 'desc', st.description));
495
+ questionList(body, st.questions);
496
+ var refs = (st.refs || []).filter(function (r) { return !!nodes[r]; });
497
+ if (refs.length) {
498
+ var web = refs.every(function (r) { return nodes[r].kind === 'screen' || nodes[r].kind === 'endpoint'; });
499
+ body.appendChild(el('h3', null, (web ? '이어진 화면과 API ' : '이어진 카드 ') + refs.length + '개'));
500
+ refButtons(body, refs, function (r) { goToRef(r, f.service); }, function (r) { return 'i-' + dkind(nodes[r]); }, label);
501
+ }
502
+ evidenceList(body, st.evidence);
503
+ panel.appendChild(body);
504
+ }
505
+ function renderTransitionPanel(t) {
506
+ selectedId = null;
507
+ syncFocusBtn();
508
+ panel.textContent = '';
509
+ var title = label(t.from) + ' → ' + label(t.to);
510
+ panel.appendChild(panelHead(t.path === 'side' ? 'flow side' : 'flow', 'u-flow', '상태 전이', t.label || '', title));
511
+ var body = el('div', 'dr-body');
512
+ var facts = el('ul', 'facts');
513
+ var f = stepFlow[t.from];
514
+ if (t.actors && f) {
515
+ factRow(facts, '누가', t.actors.map(function (a) {
516
+ var actor = f.actors.filter(function (x) { return x.id === a; })[0];
517
+ return actor ? actor.label : a;
518
+ }).join(', '));
519
+ }
520
+ factRow(facts, '갈래', t.path === 'side' ? '옆 흐름' : '정상 흐름');
521
+ factRow(facts, '선', t.lineStyle === 'dashed' ? '점선 (문서나 사람 말로만 확인)' : '실선 (코드나 스펙으로 확인)');
522
+ body.appendChild(facts);
523
+ questionList(body, t.questions);
524
+ refButtons(body, [t.from, t.to], focusCard, function () { return 'u-flow'; }, label);
525
+ evidenceList(body, t.evidence);
526
+ panel.appendChild(body);
527
+ openDrawer();
528
+ }
529
+ function renderPanel(id) {
530
+ if (isStep(id)) { renderStepPanel(id); return; }
531
+ var n = nodes[id];
532
+ panel.textContent = '';
533
+ var head = el('div', 'dr-head');
534
+ var chips = el('div', 'chips');
535
+ var chip = el('span', 'chip k-' + dkind(n));
536
+ chip.appendChild(icon('i-' + dkind(n)));
537
+ chip.appendChild(doc.createTextNode(${c.components ? COMPONENT_CHIP : ''}kindText(dkind(n))));
538
+ chips.appendChild(chip);
539
+ chips.appendChild(el('span', 'repo', n.repo));
540
+ head.appendChild(chips);
541
+ var h = el('h2', null, nameOf(n));
542
+ h.id = 'drawer-title';
543
+ h.tabIndex = -1;
544
+ if (n.displayName && n.displayNameInferred) h.appendChild(el('span', 'badge guess', GUESS));
545
+ head.appendChild(h);
546
+ if (n.displayName) head.appendChild(el('div', 'tech', n.label));
547
+ if (n.displayName && n.displayNameInferred) {
548
+ head.appendChild(el('p', 'guess-note', GUESS + ': 문서에 정해진 이름이 없어서 설명 문장을 보고 붙인 이름이에요.'));
549
+ }
550
+ panel.appendChild(head);
551
+ var body = el('div', 'dr-body');
552
+ var target = enterMap[id];
553
+ var canEnter = target && target !== current;
554
+ var canFocus = id !== focusId && !!cardMap(sectionOf(current))[id] && pair.hidden;
555
+ if (canEnter || canFocus) {
556
+ var actions = el('div', 'actions');
557
+ if (canEnter) {
558
+ var b = el('button', 'enter');
559
+ b.type = 'button';
560
+ b.appendChild(doc.createTextNode('상세보기'));
561
+ b.addEventListener('click', function () { showLevel(target); });
562
+ actions.appendChild(b);
563
+ }
564
+ if (canFocus) {
565
+ var f = el('button', 'focus');
566
+ f.type = 'button';
567
+ f.title = '이 항목과 이어진 것만 보기 (F)';
568
+ f.appendChild(icon('u-focus'));
569
+ f.appendChild(doc.createTextNode('포커스'));
570
+ f.addEventListener('click', function () { showFocus(id); });
571
+ actions.appendChild(f);
572
+ }
573
+ body.appendChild(actions);
574
+ }
575
+ if (n.description) body.appendChild(el('p', 'desc', n.description));
576
+ renderFacts(n, body);${c.docs ? '\n renderDoc(n, body);' : ''}
577
+ if (flowsBySvc[id]) {
578
+ body.appendChild(el('h3', null, '흐름 ' + flowsBySvc[id].length + '개'));
579
+ refButtons(body, flowsBySvc[id].map(function (f) { return f.level; }), showLevel, function () { return 'u-flow'; }, function (lv) { return levels[lv].title; });
580
+ }
581
+ if (stepsByRef[id]) {
582
+ body.appendChild(el('h3', null, '이 항목이 나오는 흐름 단계 ' + stepsByRef[id].length + '개'));
583
+ refButtons(body, stepsByRef[id], goToNode, function () { return 'u-flow'; }, function (sid) { return stepFlow[sid].title + ' › ' + label(sid); });
584
+ }
585
+ body.appendChild(el('h3', null, '출처 ' + n.evidence.length + '개'));
586
+ var ul = el('ul', 'evidence');
587
+ n.evidence.forEach(function (ev) { ul.appendChild(renderEvidence(ev)); });
588
+ body.appendChild(ul);
589
+ panel.appendChild(body);
590
+ }
591
+ function drawerOpen() { return drawer.classList.contains('open'); }
592
+ function openDrawer() {
593
+ drawer.classList.add('open');
594
+ drawer.removeAttribute('inert');
595
+ drawer.setAttribute('aria-hidden', 'false');
596
+ }
597
+ function closeDrawer(restore) {
598
+ if (active) active.querySelectorAll('.node.selected').forEach(function (c) { c.classList.remove('selected'); });
599
+ selectedId = null;
600
+ syncFocusBtn();
601
+ restoreLit();
602
+ if (!drawerOpen()) return;
603
+ drawer.classList.remove('open');
604
+ drawer.setAttribute('inert', '');
605
+ drawer.setAttribute('aria-hidden', 'true');
606
+ if (restore && returnFocus && doc.contains(returnFocus)) returnFocus.focus({ preventScroll: true });
607
+ }
608
+ byId('drawer-close').addEventListener('click', function () { closeDrawer(true); });
609
+
610
+ // 확대와 이동. 좌표는 전부 레벨 캔버스 기준이고 viewport 하나에 transform으로 건다
611
+ function clampK(k) { return Math.min(2.5, Math.max(0.2, k)); }
612
+ function applyView() {
613
+ closeProducts(false);
614
+ viewport.style.transform = 'translate(' + view.x + 'px,' + view.y + 'px) scale(' + view.k + ')';
615
+ zoomLevel.textContent = Math.round(view.k * 100) + '%';
616
+ }
617
+ var glideTimer = 0;
618
+ function glide() {
619
+ if (!ready) return;
620
+ viewport.classList.add('glide');
621
+ clearTimeout(glideTimer);
622
+ glideTimer = setTimeout(function () { viewport.classList.remove('glide'); }, 300);
623
+ }
624
+ function stopGlide() { viewport.classList.remove('glide'); }
625
+ function zoomAt(px, py, k) {
626
+ k = clampK(k);
627
+ view.x = px - (px - view.x) * k / view.k;
628
+ view.y = py - (py - view.y) * k / view.k;
629
+ view.k = k;
630
+ applyView();
631
+ }
632
+ function visibleWidth() { return stage.clientWidth - (drawerOpen() ? drawer.offsetWidth : 0); }
633
+ function zoomBy(f) { glide(); zoomAt(visibleWidth() / 2, stage.clientHeight / 2, view.k * f); }
634
+ function dims(sec) { return { w: Number(sec.getAttribute('data-w')) || 1, h: Number(sec.getAttribute('data-h')) || 1 }; }
635
+ function fit(mode) {
636
+ if (!active) return;
637
+ var s = dims(active);
638
+ var W = stage.clientWidth;
639
+ var H = stage.clientHeight - (sheet.hidden ? 0 : sheet.offsetHeight + 16);
640
+ var M = 24;
641
+ var kw = (W - M * 2) / s.w;
642
+ var kh = (H - M * 2) / s.h;
643
+ var k = Math.min(kw, kh, 1);
644
+ var top = false;
645
+ // 세로로 긴 레벨을 한 화면에 다 넣으면 글자가 안 읽힌다. 그때는 폭에 맞추고 위에서부터 보여준다
646
+ // 폰처럼 폭도 좁으면 일부만 보이더라도 읽히는 크기를 지킨다
647
+ if (mode === 'smart' && k < 0.5) { k = Math.max(Math.min(kw, 1), 0.45); top = true; }
648
+ k = clampK(k);
649
+ view.k = k;
650
+ view.x = Math.max(M, (W - s.w * k) / 2);
651
+ view.y = top ? M : Math.max(M, (H - s.h * k) / 2);
652
+ glide();
653
+ applyView();
654
+ }
655
+ function cardCenter(c) { return { x: c.offsetLeft + c.offsetWidth / 2, y: c.offsetTop + c.offsetHeight / 2 }; }
656
+ function centerOn(c, minK) {
657
+ if (minK && view.k < minK) view.k = clampK(minK);
658
+ var p = cardCenter(c);
659
+ view.x = visibleWidth() / 2 - p.x * view.k;
660
+ view.y = stage.clientHeight / 2 - p.y * view.k;
661
+ glide();
662
+ applyView();
663
+ }
664
+ function ensureVisible(c) {
665
+ var W = visibleWidth();
666
+ if (W < 200) return;
667
+ var p = cardCenter(c);
668
+ var sx = view.x + p.x * view.k;
669
+ var sy = view.y + p.y * view.k;
670
+ var M = 60;
671
+ if (sx < M || sx > W - M || sy < M || sy > stage.clientHeight - M) centerOn(c);
672
+ }
673
+ byId('zoom-in').addEventListener('click', function () { zoomBy(1.25); });
674
+ byId('zoom-out').addEventListener('click', function () { zoomBy(0.8); });
675
+ byId('zoom-fit').addEventListener('click', function () { fit('full'); });
676
+ stage.addEventListener('wheel', function (e) {
677
+ e.preventDefault();
678
+ stopGlide();
679
+ var r = stage.getBoundingClientRect();
680
+ if (e.ctrlKey || e.metaKey) {
681
+ var d = Math.max(-60, Math.min(60, e.deltaY));
682
+ zoomAt(e.clientX - r.left, e.clientY - r.top, view.k * Math.exp(-d * 0.008));
683
+ return;
684
+ }
685
+ var unit = e.deltaMode === 1 ? 16 : 1;
686
+ view.x -= (e.shiftKey && !e.deltaX ? e.deltaY : e.deltaX) * unit;
687
+ view.y -= (e.shiftKey && !e.deltaX ? 0 : e.deltaY) * unit;
688
+ applyView();
689
+ }, { passive: false });
690
+ // 포커스가 화면 밖 카드로 가면 브라우저가 stage를 스크롤한다. 스크롤은 되돌리고 화면 이동으로 바꾼다
691
+ stage.addEventListener('scroll', function () { stage.scrollLeft = 0; stage.scrollTop = 0; });
692
+ var drag = null;
693
+ var suppressClick = false;
694
+ stage.addEventListener('pointerdown', function (e) {
695
+ if (e.button !== 0) return;
696
+ stopGlide();
697
+ drag = { x: e.clientX, y: e.clientY, vx: view.x, vy: view.y, moved: false, id: e.pointerId };
698
+ });
699
+ stage.addEventListener('pointermove', function (e) {
700
+ if (!drag || drag.id !== e.pointerId) return;
701
+ var dx = e.clientX - drag.x;
702
+ var dy = e.clientY - drag.y;
703
+ if (!drag.moved) {
704
+ if (Math.abs(dx) + Math.abs(dy) < 4) return;
705
+ drag.moved = true;
706
+ stage.classList.add('panning');
707
+ try { stage.setPointerCapture(e.pointerId); } catch (err) {}
708
+ }
709
+ view.x = drag.vx + dx;
710
+ view.y = drag.vy + dy;
711
+ applyView();
712
+ });
713
+ function endDrag(e) {
714
+ if (!drag || drag.id !== e.pointerId) return;
715
+ if (drag.moved) suppressClick = true;
716
+ drag = null;
717
+ stage.classList.remove('panning');
718
+ }
719
+ stage.addEventListener('pointerup', endDrag);
720
+ stage.addEventListener('pointercancel', endDrag);
721
+
722
+ // 강조. 카드나 선에 올리면 이웃만 남기고 나머지를 흐린다
723
+ function cardMap(c) {
724
+ if (!c) return {};
725
+ if (!c._cards) {
726
+ c._cards = {};
727
+ c.querySelectorAll('.node').forEach(function (n) { c._cards[n.getAttribute('data-node-id')] = n; });
728
+ }
729
+ return c._cards;
730
+ }
731
+ function linkIndex(c) {
732
+ if (!c._links) {
733
+ c._links = {};
734
+ c.querySelectorAll('.link').forEach(function (l) {
735
+ [l.getAttribute('data-from'), l.getAttribute('data-to')].forEach(function (id) {
736
+ (c._links[id] = c._links[id] || []).push(l);
737
+ });
738
+ });
739
+ }
740
+ return c._links;
741
+ }
742
+ function clearLit(c) {
743
+ if (!c) return;
744
+ c.classList.remove('dimmed');
745
+ c.querySelectorAll('.lit').forEach(function (x) { x.classList.remove('lit'); });
746
+ }
747
+ function lightNode(c, id) {
748
+ if (!c) return;
749
+ clearLit(c);
750
+ var cards = cardMap(c);
751
+ c.classList.add('dimmed');
752
+ if (cards[id]) cards[id].classList.add('lit');
753
+ (linkIndex(c)[id] || []).forEach(function (l) {
754
+ l.classList.add('lit');
755
+ var other = l.getAttribute('data-from') === id ? l.getAttribute('data-to') : l.getAttribute('data-from');
756
+ if (cards[other]) cards[other].classList.add('lit');
757
+ });
758
+ }
759
+ function lightLink(c, l) {
760
+ clearLit(c);
761
+ var cards = cardMap(c);
762
+ c.classList.add('dimmed');
763
+ l.classList.add('lit');
764
+ [l.getAttribute('data-from'), l.getAttribute('data-to')].forEach(function (id) {
765
+ if (cards[id]) cards[id].classList.add('lit');
766
+ });
767
+ }
768
+ function restoreLit() {
769
+ if (!active) return;
770
+ if (selectedId && cardMap(active)[selectedId]) lightNode(active, selectedId);
771
+ else clearLit(active);
772
+ }
773
+ function hoverTarget(t) { return t && t.closest ? t.closest('.node, .link') : null; }
774
+ stage.addEventListener('mouseover', function (e) {
775
+ if (drag && drag.moved) return;
776
+ var t = hoverTarget(e.target);
777
+ if (!t || !active || !active.contains(t)) return;
778
+ if (t.classList.contains('node')) lightNode(active, t.getAttribute('data-node-id'));
779
+ else lightLink(active, t);
780
+ });
781
+ stage.addEventListener('mouseout', function (e) {
782
+ var from = hoverTarget(e.target);
783
+ if (!from) return;
784
+ var to = hoverTarget(e.relatedTarget);
785
+ if (!to) restoreLit();
786
+ });
787
+ stage.addEventListener('focusin', function (e) {
788
+ var t = hoverTarget(e.target);
789
+ if (!t || !active || !active.contains(t)) return;
790
+ if (t.classList.contains('node')) {
791
+ lightNode(active, t.getAttribute('data-node-id'));
792
+ ensureVisible(t);
793
+ } else {
794
+ lightLink(active, t);
795
+ }
796
+ });
797
+ stage.addEventListener('focusout', function (e) {
798
+ if (!hoverTarget(e.relatedTarget)) restoreLit();
799
+ });
800
+
801
+ // 선택
802
+ function select(id, focusDrawer) {
803
+ var c = cardMap(active)[id];
804
+ active.querySelectorAll('.node.selected').forEach(function (x) { x.classList.remove('selected'); });
805
+ if (c) c.classList.add('selected');
806
+ selectedId = id;
807
+ syncFocusBtn();
808
+ returnFocus = c || null;
809
+ lightNode(active, id);
810
+ renderPanel(id);
811
+ openDrawer();
812
+ if (c) ensureVisible(c);
813
+ if (focusDrawer) {
814
+ var h = byId('drawer-title');
815
+ if (h) h.focus({ preventScroll: true });
816
+ }
817
+ }
818
+ function focusCard(id) {
819
+ var c = cardMap(active)[id];
820
+ if (!c) return;
821
+ select(id, false);
822
+ centerOn(c, 0.8);
823
+ c.focus({ preventScroll: true });
824
+ }
825
+ function setAnchor(id) {
826
+ active.querySelectorAll('.node.anchor').forEach(function (x) { x.classList.remove('anchor'); });
827
+ anchor = id;
828
+ var c = cardMap(active)[id];
829
+ if (c) c.classList.add('anchor');
830
+ }
831
+ function activateNode(id, ev, viaKeyboard) {
832
+ var pairable = drill && current === 'root' && pair.hidden;
833
+ if (pairable && (ev.shiftKey || ev.metaKey) && anchor && anchor !== id) {
834
+ showPair(anchor, id);
835
+ return;
836
+ }
837
+ if (pairable) setAnchor(id);
838
+ select(id, viaKeyboard);
839
+ }
840
+ function enter(id) {
841
+ var target = enterMap[id];
842
+ if (target && target !== current) showLevel(target);
843
+ }
844
+ function activateBundle(g) {
845
+ var levelId = levelIdOf(active);
846
+ var level = levels[levelId];
847
+ var bundle = level && level.edgeById[g.getAttribute('data-bundle-id')];
848
+ if (!bundle) return;
849
+ if (levelId === 'root') { showPair(bundle.from, bundle.to); return; }
850
+ go('#/bundle/' + enc(levelId) + '/' + enc(bundle.id));
851
+ }
852
+ function activateTransition(g) {
853
+ var t = transitions[g.getAttribute('data-transition-id')];
854
+ if (!t) return;
855
+ active.querySelectorAll('.node.selected').forEach(function (x) { x.classList.remove('selected'); });
856
+ lightLink(active, g);
857
+ returnFocus = g;
858
+ renderTransitionPanel(t);
859
+ }
860
+ ${c.views ? VIEW_FUNCS : ''}${c.docs ? DOC_FUNCS : ''} stage.addEventListener('click', function (e) {
861
+ if (suppressClick) { suppressClick = false; return; }
862
+ var more = e.target.closest('.pb.more');
863
+ if (more) { toggleProducts(more); return; }
864
+ // 흐름이 하나면 배지가 바로 그 흐름으로 간다. 여럿이면 카드를 누른 것처럼 패널을 열어 흐름 목록에서 고르게 한다
865
+ var badge = e.target.closest('.flow-badge');
866
+ if (badge) {
867
+ var fl = flowsBySvc[badge.closest('.node').getAttribute('data-node-id')] || [];
868
+ if (fl.length === 1) { showLevel(fl[0].level); return; }
869
+ }
870
+ ${c.views ? VIEW_CLICK : ''}var card = e.target.closest('.node');
871
+ if (card) { activateNode(card.getAttribute('data-node-id'), e, false); return; }
872
+ var link = e.target.closest('.link.bundle');
873
+ if (link) { activateBundle(link); return; }
874
+ var ft = e.target.closest('.link.flow-t');
875
+ if (ft) { activateTransition(ft); return; }
876
+ if (drawerOpen()) closeDrawer(false);
877
+ });
878
+ stage.addEventListener('dblclick', function (e) {
879
+ if (e.target.closest('.flow-badge, .pb.more')) return;
880
+ var card = e.target.closest('.node');
881
+ if (card) enter(card.getAttribute('data-node-id'));
882
+ });
883
+ stage.addEventListener('keydown', function (e) {
884
+ if (e.key !== 'Enter' && e.key !== ' ') return;
885
+ // +N 버튼은 브라우저가 Enter와 스페이스를 클릭으로 바꿔 준다. 카드 선택으로 새면 안 된다
886
+ if (e.target.closest('.pb.more')) return;
887
+ ${c.views ? VIEW_KEY : ''}var card = e.target.closest('.node');
888
+ if (card) { e.preventDefault(); activateNode(card.getAttribute('data-node-id'), e, true); return; }
889
+ var link = e.target.closest('.link.bundle');
890
+ if (link) { e.preventDefault(); activateBundle(link); return; }
891
+ var ft = e.target.closest('.link.flow-t');
892
+ if (ft) { e.preventDefault(); activateTransition(ft); }
893
+ });
894
+
895
+ // 위치 표시
896
+ function renderCrumbs(items) {
897
+ crumbs.textContent = '';
898
+ if (!drill) { crumbs.hidden = true; return; }
899
+ items.forEach(function (it, i) {
900
+ if (i > 0) crumbs.appendChild(el('span', 'sep', '›'));
901
+ if (i === items.length - 1 || !it.level) {
902
+ var here = el('span', 'here', it.label);
903
+ here.setAttribute('aria-current', 'page');
904
+ crumbs.appendChild(here);
905
+ return;
906
+ }
907
+ var b = el('button', null, it.label);
908
+ b.type = 'button';
909
+ b.addEventListener('click', function () { showLevel(it.level); });
910
+ crumbs.appendChild(b);
911
+ });
912
+ }
913
+ function trailItems(levelId) {
914
+ // 흐름 레벨은 서비스 레벨이 없는 서비스에도 붙을 수 있다. 없는 레벨은 건너뛴다
915
+ return levels[levelId].trail.filter(function (t) { return !!levels[t]; }).map(function (t) { return { label: levels[t].title, level: t }; });
916
+ }
917
+
918
+ // 레벨 이동과 두 항목 화면은 주소의 해시에 싣는다. 그래야 브라우저 뒤로가기와 새로고침, 링크 공유가 그 화면으로 돌아온다
919
+ function enc(s) { return encodeURIComponent(s); }
920
+ function go(hash) {
921
+ hash += envQuery();
922
+ if (location.hash === hash) { route(); return; }
923
+ location.hash = hash;
924
+ }
925
+ function showLevel(id) {
926
+ if (!levels[id]) return;
927
+ go(id === 'root' ? '#/' : '#/level/' + enc(id));
928
+ }
929
+ // 같이 쓰는 카드의 +N 드롭다운. 메뉴 하나를 body에 두고 누른 버튼마다 내용을 바꿔 띄운다
930
+ var productMenu = el('div', 'pmenu');
931
+ productMenu.hidden = true;
932
+ productMenu.setAttribute('role', 'dialog');
933
+ doc.body.appendChild(productMenu);
934
+ var productBtn = null;
935
+ function closeProducts(restore) {
936
+ // 화면 맞춤이 메뉴를 만들기 전에도 불린다
937
+ if (!productMenu || productMenu.hidden) return;
938
+ productMenu.hidden = true;
939
+ if (productBtn) {
940
+ productBtn.setAttribute('aria-expanded', 'false');
941
+ if (restore) productBtn.focus();
942
+ }
943
+ productBtn = null;
944
+ }
945
+ function toggleProducts(btn) {
946
+ if (productBtn === btn) { closeProducts(false); return; }
947
+ closeProducts(false);
948
+ var card = btn.closest('.node');
949
+ var sec = btn.closest('.level');
950
+ // 포커스 화면은 카드 일부만 남으니 전용 카드 수는 원래 레벨에서 센다
951
+ if (sec === focusSec) sec = sectionOf(focusSec.getAttribute('data-focus-level'));
952
+ if (!card || !sec) return;
953
+ var names = JSON.parse(sec.getAttribute('data-regions') || '[]');
954
+ var ids = (card.getAttribute('data-products') || '').split(' ').filter(Boolean).map(Number);
955
+ productMenu.textContent = '';
956
+ productMenu.appendChild(el('h3', '', josa(label(card.getAttribute('data-node-id'))) + ' 같이 쓰는 제품'));
957
+ var list = el('ul', '');
958
+ ids.forEach(function (i) {
959
+ var li = el('li', '');
960
+ li.appendChild(el('span', 'pb p-' + (i % PRODUCT_COLORS), names[i] || ''));
961
+ li.appendChild(doc.createTextNode(names[i] || ''));
962
+ li.appendChild(el('small', '', '전용 카드 ' + sec.querySelectorAll('.node[data-band="' + (i + 1) + '"]').length + '개'));
963
+ list.appendChild(li);
964
+ });
965
+ productMenu.appendChild(list);
966
+ productMenu.setAttribute('aria-label', productMenu.firstChild.textContent);
967
+ productMenu.hidden = false;
968
+ var r = btn.getBoundingClientRect();
969
+ productMenu.style.left = Math.max(8, Math.min(r.left, window.innerWidth - productMenu.offsetWidth - 8)) + 'px';
970
+ productMenu.style.top = (r.bottom + 4) + 'px';
971
+ btn.setAttribute('aria-expanded', 'true');
972
+ productBtn = btn;
973
+ }
974
+ // 받침이 있으면 '을', 없으면 '를'. 끝의 괄호 설명은 건너뛰고 보고, 한글로 안 끝나면 받침을 모르니 '을(를)'
975
+ function josa(word) {
976
+ var stem = word.replace(/\\s*\\([^()]*\\)$/, '') || word;
977
+ var c = stem.charCodeAt(stem.length - 1) - 0xac00;
978
+ if (c < 0 || c > 11171) return word + '을(를)';
979
+ return word + (c % 28 ? '을' : '를');
980
+ }
981
+ function sectionOf(id) {
982
+ for (var i = 0; i < sections.length; i++) if (sections[i].getAttribute('data-level-id') === id) return sections[i];
983
+ return null;
984
+ }
985
+ function renderLevel(id) {
986
+ closeDrawer(false);
987
+ if (active) clearLit(active);
988
+ current = id;
989
+ anchor = null;
990
+ sections.forEach(function (s) { s.hidden = s.getAttribute('data-level-id') !== id; });
991
+ pair.hidden = true;
992
+ clearFocus();
993
+ sheet.hidden = true;
994
+ active = sectionOf(id);
995
+ if (active) active.querySelectorAll('.node.anchor').forEach(function (x) { x.classList.remove('anchor'); });
996
+ renderCrumbs(trailItems(id));
997
+ syncFlowBtn();
998
+ fit('smart');
999
+ runSearch(false);
1000
+ }
1001
+
1002
+ // 두 항목 화면. 쌍마다 미리 그리면 노드 수의 제곱만큼 늘어서 브라우저에서 그린다.
1003
+ // elkjs 없이 열 배치만 쓴다. 열 순서가 곧 요청 흐름이라 자동 배치가 없어도 읽힌다
1004
+ // 경로 중간에는 게이트웨이와 모듈 하나까지만 둔다. 화면에서 출발해 모듈 하나를 지나 클라이언트로 다른 모듈에 닿는 데까지가 한 요청 흐름이고
1005
+ // 그보다 길게 돌아가는 길까지 펼치면 두 노드와 상관없는 엔드포인트가 쏟아진다
1006
+ function pathMembers(a, b) {
1007
+ var rootLevel = levels.root;
1008
+ var out = {};
1009
+ rootLevel.edges.forEach(function (e) { (out[e.from] = out[e.from] || []).push(e); });
1010
+ function collect(s, t) {
1011
+ var picked = {};
1012
+ var maxModules = isKind(s, 'app_module') ? 0 : 1;
1013
+ var seen = {};
1014
+ var path = [];
1015
+ seen[s] = true;
1016
+ function walk(at, modules) {
1017
+ if (at === t) { path.forEach(function (e) { picked[e.id] = e; }); return; }
1018
+ (out[at] || []).forEach(function (e) {
1019
+ var next = e.to;
1020
+ if (seen[next]) return;
1021
+ var m = modules;
1022
+ if (next !== t) {
1023
+ if (isKind(next, 'app_module')) m++;
1024
+ else if (!isKind(next, 'gateway')) return;
1025
+ if (m > maxModules) return;
1026
+ }
1027
+ seen[next] = true;
1028
+ path.push(e);
1029
+ walk(next, m);
1030
+ path.pop();
1031
+ seen[next] = false;
1032
+ });
1033
+ }
1034
+ walk(s, 0);
1035
+ return Object.keys(picked).map(function (k) { return picked[k]; });
1036
+ }
1037
+ var picked = collect(a, b);
1038
+ if (!picked.length) picked = collect(b, a);
1039
+ var ids = {};
1040
+ picked.forEach(function (e) { e.memberEdgeIds.forEach(function (m) { ids[m] = true; }); });
1041
+ return Object.keys(ids).sort();
1042
+ }
1043
+ function isKind(id, kind) { return nodes[id] && nodes[id].kind === kind; }
1044
+ function classify(list) {
1045
+ var clientGw = {};
1046
+ var receivers = {};
1047
+ var fromClient = {};
1048
+ list.forEach(function (e) {
1049
+ if (e.kind !== 'calls' || !isKind(e.from, 'external_service')) return;
1050
+ if (isKind(e.to, 'gateway')) clientGw[e.to] = true;
1051
+ else { fromClient[e.to] = true; receivers[e.to] = true; }
1052
+ });
1053
+ var hops = list.filter(function (e) { return e.kind === 'routes' && isKind(e.from, 'gateway') && isKind(e.to, 'gateway'); });
1054
+ var depth = {};
1055
+ Object.keys(nodes).forEach(function (id) { if (isKind(id, 'gateway')) depth[id] = 0; });
1056
+ // 사슬 단수를 구한다. 고리가 있어도 끝나도록 게이트웨이 수만큼만 돈다
1057
+ for (var round = 0; round < hops.length; round++) {
1058
+ hops.forEach(function (e) {
1059
+ if (depth[e.to] < depth[e.from] + 1) depth[e.to] = depth[e.from] + 1;
1060
+ if (clientGw[e.from]) clientGw[e.to] = true;
1061
+ });
1062
+ }
1063
+ list.forEach(function (e) {
1064
+ if (e.kind === 'routes' && clientGw[e.from]) {
1065
+ if (isKind(e.to, 'app_module')) receivers[e.to] = true;
1066
+ if (isKind(e.to, 'endpoint')) fromClient[e.to] = true;
1067
+ }
1068
+ });
1069
+ list.forEach(function (e) {
1070
+ if (e.kind === 'handles' && fromClient[e.from]) receivers[e.to] = true;
1071
+ });
1072
+ return { clientGw: clientGw, receivers: receivers, depth: depth };
1073
+ }
1074
+ // 열 번호에 틈을 둬서 게이트웨이 사슬이 단마다 한 열씩 끼어든다. 빈 번호는 그릴 때 접힌다
1075
+ // 하네스 카드는 화면보다 앞에 둔다. 클라이언트가 스킬을 싣고 스킬이 에이전트를 띄운 뒤 도구를 부르는 순서다
1076
+ var COL = { client: -300, skill: -200, agent: -100, screen: 0, gateway: 100, endpoint: 200, mcp_tool: 250, app_module: 300, external_service: 400, clientGateway: 500, receiver: 600, db_table: 700 };
1077
+ function columnOf(id, info) {
1078
+ var kind = dkind(nodes[id]);
1079
+ if (kind === 'gateway') return (info.clientGw[id] ? COL.clientGateway : COL.gateway) + (info.depth[id] || 0);
1080
+ if (kind === 'app_module' && info.receivers[id]) return COL.receiver;
1081
+ return COL.hasOwnProperty(kind) ? COL[kind] : 800;
1082
+ }
1083
+ function laneOfColumn(c) {
1084
+ if (c < -200) return 'client';
1085
+ if (c < -100) return 'skill';
1086
+ if (c < 0) return 'agent';
1087
+ if (c < 100) return 'screen';
1088
+ if (c < 200) return 'gateway';
1089
+ if (c < 250) return 'endpoint';
1090
+ if (c < 300) return 'tool';
1091
+ if (c < 400) return 'app_module';
1092
+ if (c < 500) return 'external_service';
1093
+ if (c < 700) return 'external';
1094
+ if (c < 800) return 'db_table';
1095
+ return '';
1096
+ }
1097
+ function cardHeight(id) {
1098
+ var f = services[id];
1099
+ return (nodes[id] && nodes[id].displayName) || (f && (f.prodDomain || f.platforms.length > 0)) ? 60 : 48;
1100
+ }
1101
+ function platformChips(id, parent) {
1102
+ var f = services[id];
1103
+ if (!f) return;
1104
+ f.platforms.forEach(function (p) {
1105
+ var chip = el('span', 'pf pf-' + p);
1106
+ chip.setAttribute('role', 'img');
1107
+ chip.setAttribute('aria-label', platName(p, f));
1108
+ chip.title = platName(p, f);
1109
+ chip.appendChild(icon('p-' + p));
1110
+ chip.appendChild(doc.createTextNode(chipText(p, f)));
1111
+ parent.appendChild(chip);
1112
+ });
1113
+ }
1114
+ function buildCard(id, x, y, w, h) {
1115
+ var n = nodes[id];
1116
+ var kind = dkind(n);
1117
+ var d = el('div', 'node k-' + kind);
1118
+ d.setAttribute('data-node-id', id);
1119
+ d.setAttribute('role', 'button');
1120
+ d.tabIndex = 0;
1121
+ d.style.left = x + 'px';
1122
+ d.style.top = y + 'px';
1123
+ d.style.width = w + 'px';
1124
+ d.style.height = h + 'px';
1125
+ var kc = el('span', 'kc');
1126
+ kc.appendChild(icon('i-' + kind));
1127
+ kc.appendChild(doc.createTextNode(MICRO_HOSTS[id] ? HOST_SHORT : ${c.components ? COMPONENT_CHIP : ''}KIND_SHORT[kind] || kind));
1128
+ d.appendChild(kc);
1129
+ var nm = el('span', 'nm');
1130
+ nm.appendChild(el('span', 't', label(id)));
1131
+ var guess = n && n.displayName && n.displayNameInferred;
1132
+ if (guess) nm.appendChild(el('span', 'guess', GUESS));
1133
+ d.appendChild(nm);
1134
+ var facts = services[id];
1135
+ var sub = null;
1136
+ if (facts && facts.prodDomain) sub = el('span', 'tc dom', facts.prodDomain);
1137
+ else if (n && n.displayName) sub = el('span', 'tc', n.label);
1138
+ if (facts && facts.platforms.length > 0) {
1139
+ var l2 = el('span', 'l2');
1140
+ if (sub) l2.appendChild(sub);
1141
+ platformChips(id, l2);
1142
+ d.appendChild(l2);
1143
+ } else if (sub) d.appendChild(sub);
1144
+ d.title = n && n.displayName ? n.displayName + (guess ? ' (' + GUESS + ')' : '') + '\\n' + n.label : label(id);
1145
+ d.setAttribute('aria-label', label(id) + ', ' + kindText(kind));
1146
+ if (enterMap[id]) {
1147
+ var more = el('span', 'go', '›');
1148
+ more.setAttribute('aria-hidden', 'true');
1149
+ d.appendChild(more);
1150
+ }
1151
+ return d;
1152
+ }
1153
+ // 서버 렌더와 같은 굵기 규칙이다. 건수의 제곱근을 따른다
1154
+ function edgeWidth(count) {
1155
+ return Math.round(Math.min(8, Math.max(1.5, 1.5 + (Math.sqrt(Math.max(count || 1, 1)) - 1) * 1.1)) * 100) / 100;
1156
+ }
1157
+ // 두 항목 화면과 포커스 화면이 같이 쓰는 캔버스. 카드 자리(pos)와 레인을 받아 곡선을 잇고 그린다.
1158
+ // elk 경유점이 없으니 열을 건너뛰는 선은 베지어 하나로 바로 잇는다
1159
+ function drawCanvas(host, o) {
1160
+ var pos = o.pos;
1161
+ var height = o.height;
1162
+ var PAD_BOTTOM = 40;
1163
+ var drawn = o.edges.filter(function (e) { return pos[e.from] && pos[e.to] && e.from !== e.to; });
1164
+ function stacked(e) {
1165
+ var a = pos[e.from], b = pos[e.to];
1166
+ return b.x < a.x + a.w && b.x + b.w > a.x;
1167
+ }
1168
+ function ports(side) {
1169
+ var groups = {};
1170
+ drawn.forEach(function (e) {
1171
+ if (side === 'out' && stacked(e)) return;
1172
+ var key = side === 'out' ? e.from : e.to;
1173
+ (groups[key] = groups[key] || []).push(e);
1174
+ });
1175
+ var res = {};
1176
+ Object.keys(groups).forEach(function (id) {
1177
+ var g = groups[id];
1178
+ var p = pos[id];
1179
+ g.sort(function (a, b) {
1180
+ var oa = pos[side === 'out' ? a.to : a.from], ob = pos[side === 'out' ? b.to : b.from];
1181
+ return (oa.y + oa.h / 2) - (ob.y + ob.h / 2) || oa.x - ob.x || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
1182
+ });
1183
+ var n = g.length;
1184
+ var step = n > 1 ? Math.min(9, (p.h - 28) / (n - 1)) : 0;
1185
+ g.forEach(function (e, i) { res[e.id] = p.y + p.h / 2 + (i - (n - 1) / 2) * step; });
1186
+ });
1187
+ return res;
1188
+ }
1189
+ var outY = ports('out');
1190
+ var inY = ports('in');
1191
+ var paths = drawn.map(function (e) {
1192
+ var a = pos[e.from], b = pos[e.to];
1193
+ var w = edgeWidth(e.count);
1194
+ var len = 7 + w * 0.5, half = 3.5 + w * 0.45;
1195
+ var sx = a.x + a.w, sy = outY[e.id], tx = b.x, ty = inY[e.id], ex = tx - len + 1;
1196
+ var d, mid;
1197
+ if (e.backward && b.x + b.w + 8 <= a.x) {
1198
+ var fx = a.x, fy0 = outY[e.id], tipAt = b.x + b.w, toX = tipAt + len - 1, toY = inY[e.id];
1199
+ var bdx = Math.max(16, (fx - toX) / 2);
1200
+ d = 'M' + fx + ' ' + fy0 + 'C' + (fx - bdx) + ' ' + fy0 + ' ' + (toX + bdx) + ' ' + toY + ' ' + toX + ' ' + toY;
1201
+ return { e: e, d: d, w: w, mid: { x: (fx + toX) / 2, y: (fy0 + toY) / 2 },
1202
+ tip: 'M' + tipAt + ' ' + toY + 'L' + (tipAt + len) + ' ' + (toY - half) + 'L' + (tipAt + len) + ' ' + (toY + half) + 'Z' };
1203
+ }
1204
+ if (stacked(e)) {
1205
+ var fy = a.y + a.h / 2;
1206
+ var bow = Math.min(30, 18 + Math.abs(ty - fy) * 0.2);
1207
+ var ox = Math.min(a.x, ex) - bow;
1208
+ d = 'M' + a.x + ' ' + fy + 'C' + ox + ' ' + fy + ' ' + ox + ' ' + ty + ' ' + ex + ' ' + ty;
1209
+ mid = { x: ox + bow * 0.25, y: (fy + ty) / 2 };
1210
+ } else if (b.x >= sx + 8) {
1211
+ var dx = Math.max(16, (ex - sx) / 2);
1212
+ d = 'M' + sx + ' ' + sy + 'C' + (sx + dx) + ' ' + sy + ' ' + (ex - dx) + ' ' + ty + ' ' + ex + ' ' + ty;
1213
+ mid = { x: (sx + ex) / 2, y: (sy + ty) / 2 };
1214
+ } else {
1215
+ var drop = Math.max(a.y + a.h, b.y + b.h) + 28;
1216
+ height = Math.max(height, drop + PAD_BOTTOM);
1217
+ var back = ex - 36;
1218
+ d = 'M' + sx + ' ' + sy + 'C' + (sx + 36) + ' ' + sy + ' ' + (sx + 36) + ' ' + drop + ' ' + sx + ' ' + drop +
1219
+ 'L' + ex + ' ' + drop + 'C' + back + ' ' + drop + ' ' + back + ' ' + ty + ' ' + ex + ' ' + ty;
1220
+ mid = { x: (sx + ex) / 2, y: drop };
1221
+ }
1222
+ var tip = 'M' + tx + ' ' + ty + 'L' + (tx - len) + ' ' + (ty - half) + 'L' + (tx - len) + ' ' + (ty + half) + 'Z';
1223
+ return { e: e, d: d, tip: tip, mid: mid, w: w };
1224
+ });
1225
+ host.setAttribute('data-w', o.width);
1226
+ host.setAttribute('data-h', height);
1227
+ host.style.width = o.width + 'px';
1228
+ host.style.height = height + 'px';
1229
+ var svg = svgEl('svg', { 'class': 'links', width: o.width, height: height, viewBox: '0 0 ' + o.width + ' ' + height, role: 'group', 'aria-label': o.label });
1230
+ var laneLayer = svgEl('g', { 'class': 'lanes' });
1231
+ o.lanes.forEach(function (l) {
1232
+ laneLayer.appendChild(svgEl('rect', { 'class': 'lane', x: l.x, y: 12, width: l.width, height: height - 24, rx: 14 }));
1233
+ });
1234
+ svg.appendChild(laneLayer);
1235
+ var regionLayer = svgEl('g', { 'class': 'regions' });
1236
+ (o.bands || []).forEach(function (b) {
1237
+ var ly = b.y + BAND_LINE_Y;
1238
+ regionLayer.appendChild(svgEl('line', { 'class': 'band-line', x1: BAND_LINE_X, y1: ly, x2: o.width - BAND_LINE_X, y2: ly }));
1239
+ });
1240
+ svg.appendChild(regionLayer);
1241
+ var frameLayer = svgEl('g', { 'class': 'frames' });
1242
+ (o.frames || []).forEach(function (f) {
1243
+ frameLayer.appendChild(svgEl('rect', { 'class': 'frame', 'data-frame-id': f.id, x: f.x, y: f.y, width: f.width, height: f.height, rx: 14 }));
1244
+ });
1245
+ svg.appendChild(frameLayer);
1246
+ var linkLayer = svgEl('g', { 'class': 'edges' });
1247
+ paths.forEach(function (p) {
1248
+ var e = p.e;
1249
+ var bundle = e.kind === 'bundle';
1250
+ var xa = nodes[e.from] && nodes[e.to] && nodes[e.from].account && nodes[e.to].account && nodes[e.from].account !== nodes[e.to].account;
1251
+ var g = svgEl('g', { 'class': (bundle ? 'link bundle' : 'link e-' + e.kind) + (xa ? ' x-account' : ''), 'data-from': e.from, 'data-to': e.to });
1252
+ var name = bundle ? label(e.from) + ' → ' + label(e.to) + ' ' + e.count + '개'${c.mixedBundles ? MIXED_NOTE : ''} : kindText(e.kind) + ': ' + label(e.from) + ' → ' + label(e.to);
1253
+ if (bundle) {
1254
+ g.setAttribute('data-bundle-id', e.id);
1255
+ g.setAttribute('tabindex', '0');
1256
+ g.setAttribute('role', 'button');
1257
+ g.setAttribute('aria-label', name);
1258
+ }
1259
+ var t = svgEl('title', {});
1260
+ t.textContent = name;
1261
+ g.appendChild(t);
1262
+ g.appendChild(svgEl('path', { 'class': 'hit', d: p.d, 'stroke-width': Math.max(12, p.w + 8) }));
1263
+ var line = svgEl('path', { 'class': 'edge', d: p.d, 'stroke-width': p.w });
1264
+ if (!bundle) line.setAttribute('data-edge-id', e.id);
1265
+ if (e.lineStyle === 'dashed') line.setAttribute('stroke-dasharray', '6 4');
1266
+ g.appendChild(line);
1267
+ g.appendChild(svgEl('path', { 'class': 'tip', d: p.tip }));
1268
+ if (bundle) {
1269
+ var text = String(e.count);
1270
+ var pw = 14 + text.length * 7;
1271
+ var pill = svgEl('g', { 'class': 'pill', transform: 'translate(' + p.mid.x + ',' + p.mid.y + ')' });
1272
+ pill.appendChild(svgEl('rect', { x: -pw / 2, y: -9, width: pw, height: 18, rx: 9 }));${c.mixedBundles ? MIXED_PILL : ''}
1273
+ var pt = svgEl('text', {});
1274
+ pt.textContent = text;
1275
+ pill.appendChild(pt);
1276
+ g.appendChild(pill);
1277
+ }
1278
+ linkLayer.appendChild(g);
1279
+ });
1280
+ svg.appendChild(linkLayer);
1281
+ host.appendChild(svg);
1282
+ o.lanes.forEach(function (l) {
1283
+ if (!l.title) return;
1284
+ var title = el('div', 'lane-title', l.title);
1285
+ title.appendChild(el('span', 'n', String(l.count)));
1286
+ title.style.left = l.x + 'px';
1287
+ title.style.top = '22px';
1288
+ title.style.width = l.width + 'px';
1289
+ host.appendChild(title);
1290
+ });
1291
+ // 서버 렌더와 같은 자리다. 띠 이름은 구분선에 걸쳐 단다
1292
+ (o.bands || []).forEach(function (b) {
1293
+ var bt = el('div', 'band-title', b.band === 0 ? '같이 쓰는 카드' : '');
1294
+ if (b.band > 0) {
1295
+ bt.appendChild(el('i', 'sw p-' + ((b.band - 1) % PRODUCT_COLORS)));
1296
+ bt.appendChild(doc.createTextNode((o.groups[b.band - 1] || '') + ' 전용'));
1297
+ }
1298
+ bt.setAttribute('data-band', b.band);
1299
+ bt.style.left = BAND_TITLE_X + 'px';
1300
+ bt.style.top = (b.y + BAND_LINE_Y - BAND_TITLE_HALF) + 'px';
1301
+ host.appendChild(bt);
1302
+ });
1303
+ Object.keys(pos).sort().forEach(function (id) { host.appendChild(o.card(id, pos[id])); });
1304
+ }
1305
+ function drawColumns(list, host) {
1306
+ var NODE_W = 208, ROW_GAP = 20, COL_GAP = 56, LANE_GAP = 96, PAD_X = 32, PAD_TOP = 60, PAD_BOTTOM = 40, LANE_PAD = 20;
1307
+ var info = classify(list);
1308
+ var ids = {};
1309
+ list.forEach(function (e) { ids[e.from] = true; ids[e.to] = true; });
1310
+ var cols = {};
1311
+ Object.keys(ids).forEach(function (id) {
1312
+ var c = columnOf(id, info);
1313
+ (cols[c] = cols[c] || []).push(id);
1314
+ });
1315
+ var order = Object.keys(cols).map(Number).sort(function (a, b) { return a - b; });
1316
+ var colX = {};
1317
+ var colH = {};
1318
+ var lanes = [];
1319
+ var x = PAD_X + LANE_PAD;
1320
+ var maxH = 0;
1321
+ var prevLane = null;
1322
+ order.forEach(function (c, i) {
1323
+ var lane = laneOfColumn(c);
1324
+ if (i > 0) x += NODE_W + (lane === prevLane ? COL_GAP : LANE_GAP);
1325
+ colX[c] = x;
1326
+ if (lane !== prevLane) lanes.push({ id: lane, left: x, right: x + NODE_W, count: 0 });
1327
+ else lanes[lanes.length - 1].right = x + NODE_W;
1328
+ lanes[lanes.length - 1].count += cols[c].length;
1329
+ cols[c].sort(function (a, b) {
1330
+ var la = label(a), lb = label(b);
1331
+ return la < lb ? -1 : la > lb ? 1 : a < b ? -1 : a > b ? 1 : 0;
1332
+ });
1333
+ var h = 0;
1334
+ cols[c].forEach(function (id, ri) { h += cardHeight(id) + (ri ? ROW_GAP : 0); });
1335
+ colH[c] = h;
1336
+ maxH = Math.max(maxH, h);
1337
+ prevLane = lane;
1338
+ });
1339
+ var pos = {};
1340
+ order.forEach(function (c) {
1341
+ var y = PAD_TOP + (maxH - colH[c]) / 2;
1342
+ cols[c].forEach(function (id) {
1343
+ pos[id] = { x: colX[c], y: y, w: NODE_W, h: cardHeight(id) };
1344
+ y += cardHeight(id) + ROW_GAP;
1345
+ });
1346
+ });
1347
+ drawCanvas(host, {
1348
+ pos: pos,
1349
+ edges: list.map(function (e) { return { id: e.id, from: e.from, to: e.to, kind: e.kind, count: 1, lineStyle: e.lineStyle }; }),
1350
+ lanes: lanes.map(function (l) {
1351
+ return { x: l.left - LANE_PAD, width: l.right - l.left + LANE_PAD * 2, title: LANE_TITLE[l.id] || '', count: l.count };
1352
+ }),
1353
+ width: x + NODE_W + LANE_PAD + PAD_X,
1354
+ height: PAD_TOP + maxH + PAD_BOTTOM,
1355
+ label: '두 항목 사이 연결',
1356
+ card: function (id, p) { return buildCard(id, p.x, p.y, p.w, p.h); },
1357
+ });
1358
+ }
1359
+ function evidenceCell(list) {
1360
+ var td = el('td');
1361
+ if (!list.length) { td.textContent = '-'; return td; }
1362
+ list.forEach(function (ev) {
1363
+ td.appendChild(el('span', 'loc', ev.location === undefined ? evidenceText(ev.type) + ' (공유본이라 가렸어요)' : evidenceText(ev.type) + ' ' + ev.location));
1364
+ });
1365
+ return td;
1366
+ }
1367
+ function drawPair(memberIds, title) {
1368
+ closeDrawer(false);
1369
+ sections.forEach(function (s) { s.hidden = true; });
1370
+ clearFocus();
1371
+ pair.hidden = false;
1372
+ pair.textContent = '';
1373
+ pair._cards = null;
1374
+ pair._links = null;
1375
+ active = pair;
1376
+ var items = trailItems(current);
1377
+ items.push({ label: title });
1378
+ renderCrumbs(items);
1379
+ var list = memberIds.map(function (id) { return edges[id]; }).filter(Boolean);
1380
+ if (!list.length) {
1381
+ pair.setAttribute('data-w', '560');
1382
+ pair.setAttribute('data-h', '240');
1383
+ pair.style.width = '560px';
1384
+ pair.style.height = '240px';
1385
+ pair.appendChild(el('p', 'empty-state', '두 항목은 서로 이어져 있지 않아요.'));
1386
+ sheet.hidden = true;
1387
+ fit('smart');
1388
+ return;
1389
+ }
1390
+ drawColumns(list, pair);
1391
+ sheetTitle.textContent = '세부 연결 ' + list.length + '개';
1392
+ sheetBody.textContent = '';
1393
+ var table = el('table');
1394
+ var head = el('tr');
1395
+ ['연결', '어디서 → 어디로', '출처'].forEach(function (h) { head.appendChild(el('th', null, h)); });
1396
+ table.appendChild(head);
1397
+ list.forEach(function (e) {
1398
+ var tr = el('tr');
1399
+ tr.appendChild(el('td', null, kindText(e.kind) + (e.actions ? ' (' + e.actions.join(', ') + ')' : '')));
1400
+ tr.appendChild(el('td', null, label(e.from) + ' → ' + label(e.to)));
1401
+ tr.appendChild(evidenceCell(e.evidence));
1402
+ table.appendChild(tr);
1403
+ });
1404
+ sheetBody.appendChild(table);
1405
+ sheet.open = false;
1406
+ sheet.hidden = false;
1407
+ fit('smart');
1408
+ runSearch(false);
1409
+ }
1410
+ // 포커스 화면. 레벨에 그려 둔 카드 자리를 그대로 가져와 남는 카드만 레인 안에서 위로 당겨 쌓는다.
1411
+ // 레인 x와 순서를 지켜야 원래 그림에서 보던 자리 감각이 이어진다
1412
+ function levelIdOf(c) {
1413
+ if (!c) return null;
1414
+ return c.getAttribute('data-level-id') || c.getAttribute('data-focus-level');
1415
+ }
1416
+ function levelEdges(levelId) {
1417
+ if (!drill) return data.edges.map(function (e) { return { id: e.id, from: e.from, to: e.to, kind: e.kind, count: 1, lineStyle: e.lineStyle, backward: !!BACKWARD[e.kind] }; });
1418
+ return levels[levelId].edges;
1419
+ }
1420
+ function syncFocusBtn() {
1421
+ focusBtn.hidden = !(selectedId && !isStep(selectedId) && selectedId !== focusId && pair.hidden && cardMap(sectionOf(current))[selectedId]);
1422
+ }
1423
+ function clearFocus() {
1424
+ focusId = null;
1425
+ focusSec.hidden = true;
1426
+ focusSec.textContent = '';
1427
+ focusSec._cards = null;
1428
+ focusSec._links = null;
1429
+ focusSec.removeAttribute('data-focus-level');
1430
+ focusChip.hidden = true;
1431
+ syncFocusBtn();
1432
+ }
1433
+ function showFocus(id) {
1434
+ if (!id) return;
1435
+ go('#/focus/' + enc(current) + '/' + enc(id));
1436
+ }
1437
+ function renderFocus(levelId, nodeId) {
1438
+ if (!levels[levelId]) return false;
1439
+ var src = sectionOf(levelId);
1440
+ if (!src || !cardMap(src)[nodeId]) return false;
1441
+ renderLevel(levelId);
1442
+ var all = levelEdges(levelId);
1443
+ var set = focusSet(all, nodeId);
1444
+ var keep = {};
1445
+ set.nodes.forEach(function (n) { if (n === nodeId || !envHidden(n)) keep[n] = true; });
1446
+ var keptEdge = {};
1447
+ set.edges.forEach(function (e) { keptEdge[e] = true; });
1448
+ var boxes = [];
1449
+ var top = Infinity;
1450
+ src.querySelectorAll('.node').forEach(function (c) {
1451
+ var b = { id: c.getAttribute('data-node-id'), x: parseFloat(c.style.left), y: parseFloat(c.style.top), w: parseFloat(c.style.width), h: parseFloat(c.style.height) };
1452
+ boxes.push(b);
1453
+ top = Math.min(top, b.y);
1454
+ });
1455
+ var names = src.getAttribute('data-regions');
1456
+ var banded = null;
1457
+ if (names) {
1458
+ var bandOf = {};
1459
+ var shareOf = {};
1460
+ src.querySelectorAll('.node[data-band]').forEach(function (c) {
1461
+ var id = c.getAttribute('data-node-id');
1462
+ bandOf[id] = Number(c.getAttribute('data-band'));
1463
+ var ps = c.getAttribute('data-products');
1464
+ if (ps) shareOf[id] = ps.split(' ').length;
1465
+ });
1466
+ banded = focusBandLayout(boxes, keep, top, 24, bandOf, shareOf);
1467
+ }
1468
+ var pos = banded ? banded.pos : focusLayout(boxes, keep, top, 24);
1469
+ var bottom = top;
1470
+ Object.keys(pos).forEach(function (id) { bottom = Math.max(bottom, pos[id].y + pos[id].h); });
1471
+ if (banded && banded.bottom) bottom = Math.max(bottom, banded.bottom);
1472
+ // 남은 카드가 둘 이상인 테두리만 다시 두른다. 한 장만 남으면 무엇을 묶는지 안 보인다
1473
+ var frameCards = {};
1474
+ src.querySelectorAll('.node[data-frame]').forEach(function (c) {
1475
+ var id = c.getAttribute('data-node-id');
1476
+ if (!pos[id]) return;
1477
+ var f = c.getAttribute('data-frame');
1478
+ (frameCards[f] = frameCards[f] || []).push(pos[id]);
1479
+ });
1480
+ var frames = Object.keys(frameCards).sort().filter(function (f) { return frameCards[f].length > 1; }).map(function (f) {
1481
+ var list = frameCards[f];
1482
+ var x0 = Math.min.apply(null, list.map(function (p) { return p.x; })) - FRAME_PAD;
1483
+ var y0 = Math.min.apply(null, list.map(function (p) { return p.y; })) - FRAME_PAD;
1484
+ var x1 = Math.max.apply(null, list.map(function (p) { return p.x + p.w; })) + FRAME_PAD;
1485
+ var y1 = Math.max.apply(null, list.map(function (p) { return p.y + p.h; })) + FRAME_PAD;
1486
+ return { id: f, x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
1487
+ });
1488
+ var rects = src.querySelectorAll('rect.lane');
1489
+ var titles = src.querySelectorAll('.lane-title');
1490
+ var lanes = [];
1491
+ Array.prototype.forEach.call(rects, function (r, i) {
1492
+ var x = parseFloat(r.getAttribute('x'));
1493
+ var w = parseFloat(r.getAttribute('width'));
1494
+ var n = 0;
1495
+ Object.keys(pos).forEach(function (id) { if (pos[id].x >= x && pos[id].x < x + w) n += 1; });
1496
+ if (!n) return;
1497
+ var t = titles[i];
1498
+ lanes.push({ x: x, width: w, title: t ? t.firstChild.textContent : '', count: n });
1499
+ });
1500
+ var cards = cardMap(src);
1501
+ src.hidden = true;
1502
+ focusSec.textContent = '';
1503
+ focusSec._cards = null;
1504
+ focusSec._links = null;
1505
+ focusSec.setAttribute('data-focus-level', levelId);
1506
+ if (names) focusSec.setAttribute('data-regions', names);
1507
+ else focusSec.removeAttribute('data-regions');
1508
+ focusSec.setAttribute('aria-label', label(nodeId) + ' 포커스');
1509
+ drawCanvas(focusSec, {
1510
+ pos: pos,
1511
+ edges: all.filter(function (e) { return keptEdge[e.id]; }),
1512
+ lanes: lanes,
1513
+ bands: banded && banded.bands ? banded.bands : null,
1514
+ groups: names ? JSON.parse(names) : [],
1515
+ frames: frames,
1516
+ width: parseFloat(src.getAttribute('data-w')),
1517
+ height: bottom + 40,
1518
+ label: '이어진 연결: ' + label(nodeId),
1519
+ card: function (id, p) {
1520
+ var c = cards[id].cloneNode(true);
1521
+ c.classList.remove('selected', 'anchor', 'lit', 'hit', 'current');
1522
+ var more = c.querySelector('.pb.more');
1523
+ if (more) more.setAttribute('aria-expanded', 'false');
1524
+ if (id === nodeId) c.classList.add('focus-root');
1525
+ c.style.top = p.y + 'px';
1526
+ return c;
1527
+ },
1528
+ });
1529
+ focusSec.hidden = false;
1530
+ active = focusSec;
1531
+ focusId = nodeId;
1532
+ focusName.textContent = label(nodeId);
1533
+ focusChip.hidden = false;
1534
+ syncFocusBtn();
1535
+ fit('smart');
1536
+ runSearch(false);
1537
+ return true;
1538
+ }
1539
+ function releaseFocus() {
1540
+ if (focusId === null) return false;
1541
+ showLevel(current);
1542
+ return true;
1543
+ }
1544
+ focusBtn.addEventListener('click', function () { showFocus(selectedId); });
1545
+ byId('focus-clear').addEventListener('click', releaseFocus);
1546
+ function showPair(a, b) {
1547
+ go('#/pair/' + enc(a) + '/' + enc(b));
1548
+ }
1549
+ function renderPair(a, b) {
1550
+ if (!nodes[a] || !nodes[b]) return false;
1551
+ renderLevel('root');
1552
+ drawPair(pathMembers(a, b), label(a) + ' ↔ ' + label(b));
1553
+ return true;
1554
+ }
1555
+ function renderBundle(levelId, bundleId) {
1556
+ var level = levels[levelId];
1557
+ var bundle = level && level.edgeById[bundleId];
1558
+ if (!bundle) return false;
1559
+ renderLevel(levelId);
1560
+ drawPair(bundle.memberEdgeIds, label(bundle.from) + ' → ' + label(bundle.to)${c.mixedBundles ? MIXED_PANEL_NOTE : ''});
1561
+ return true;
1562
+ }
1563
+ function route() {
1564
+ var raw = location.hash.charAt(0) === '#' ? location.hash.slice(1) : location.hash;
1565
+ var q = raw.indexOf('?');
1566
+ var query = q >= 0 ? raw.slice(q + 1) : '';
1567
+ if (q >= 0) raw = raw.slice(0, q);
1568
+ setEnv(envFromQuery(query));
1569
+ // 기본값과 같거나 모르는 환경만 적힌 주소는 정리해 둔다. 같은 화면이 주소 두 개로 갈리지 않게 한다
1570
+ if ((q >= 0 ? '?' + query : '') !== envQuery()) history.replaceState(null, '', '#' + raw + envQuery());
1571
+ if (raw.charAt(0) === '/') raw = raw.slice(1);
1572
+ var parts = raw.split('/').map(function (p) {
1573
+ try { return decodeURIComponent(p); } catch (e) { return ''; }
1574
+ });
1575
+ var ok = false;
1576
+ if (drill) {
1577
+ if (parts[0] === 'level' && parts.length === 2 && levels[parts[1]]) { renderLevel(parts[1]); ok = true; }
1578
+ else if (parts[0] === 'pair' && parts.length === 3) ok = renderPair(parts[1], parts[2]);
1579
+ else if (parts[0] === 'bundle' && parts.length === 3) ok = renderBundle(parts[1], parts[2]);
1580
+ }
1581
+ if (parts[0] === 'focus' && parts.length === 3) ok = renderFocus(parts[1], parts[2]);
1582
+ if (!ok) {
1583
+ renderLevel('root');
1584
+ // 모르는 주소는 기록을 남기지 않고 전체로 바꿔 둔다. 남기면 뒤로가기가 같은 자리를 한 번 더 밟는다
1585
+ if (location.hash && location.hash !== '#/' + envQuery()) history.replaceState(null, '', '#/' + envQuery());
1586
+ }
1587
+ if (pendingSelect) {
1588
+ var id = pendingSelect;
1589
+ pendingSelect = null;
1590
+ focusCard(id);
1591
+ }
1592
+ }
1593
+
1594
+ // 검색. 지금 보이는 화면의 카드만 찾는다
1595
+ var hits = [];
1596
+ var hitAt = -1;
1597
+ function runSearch(move) {
1598
+ if (active) {
1599
+ active.classList.remove('searching');
1600
+ active.querySelectorAll('.node.hit').forEach(function (c) { c.classList.remove('hit'); c.classList.remove('current'); });
1601
+ }
1602
+ hits = [];
1603
+ hitAt = -1;
1604
+ var q = search.value.trim().toLowerCase();
1605
+ if (!q || !active) { searchCount.textContent = ''; return; }
1606
+ active.querySelectorAll('.node').forEach(function (c) {
1607
+ var n = nodes[c.getAttribute('data-node-id')];
1608
+ if (!n || c.classList.contains('env-off')) return;
1609
+ var hay = ((n.displayName || '') + ' ' + n.label${c.docs ? DOC_HAY : ''}).toLowerCase();
1610
+ if (hay.indexOf(q) >= 0) hits.push(c);
1611
+ });
1612
+ hits.sort(function (a, b) { return a.offsetLeft - b.offsetLeft || a.offsetTop - b.offsetTop; });
1613
+ active.classList.add('searching');
1614
+ hits.forEach(function (c) { c.classList.add('hit'); });
1615
+ if (!hits.length) { searchCount.textContent = '없음'; return; }
1616
+ if (move) stepSearch(1);
1617
+ else searchCount.textContent = hits.length + '개';
1618
+ }
1619
+ function stepSearch(dir) {
1620
+ if (!hits.length) return;
1621
+ if (hitAt >= 0) hits[hitAt].classList.remove('current');
1622
+ hitAt = (hitAt + dir + hits.length) % hits.length;
1623
+ var c = hits[hitAt];
1624
+ c.classList.add('current');
1625
+ centerOn(c, 0.8);
1626
+ searchCount.textContent = (hitAt + 1) + '/' + hits.length;
1627
+ }
1628
+ search.addEventListener('input', function () { runSearch(true); });
1629
+ search.addEventListener('keydown', function (e) {
1630
+ if (e.key === 'Enter') { e.preventDefault(); stepSearch(e.shiftKey ? -1 : 1); }
1631
+ else if (e.key === 'Escape') { e.stopPropagation(); search.value = ''; runSearch(false); search.blur(); }
1632
+ });
1633
+
1634
+ // 범례와 질문 팝오버
1635
+ var pops = [];
1636
+ function placePop(btn, pop) {
1637
+ var r = btn.getBoundingClientRect();
1638
+ pop.style.top = Math.round(r.bottom + 6) + 'px';
1639
+ var left = Math.min(window.innerWidth - pop.offsetWidth - 8, r.right - pop.offsetWidth);
1640
+ pop.style.left = Math.max(8, Math.round(left)) + 'px';
1641
+ }
1642
+ function anyPop() { return pops.some(function (p) { return !p.pop.hidden; }); }
1643
+ function closePops(restore) {
1644
+ pops.forEach(function (p) {
1645
+ if (p.pop.hidden) return;
1646
+ p.pop.hidden = true;
1647
+ p.btn.setAttribute('aria-expanded', 'false');
1648
+ if (restore) p.btn.focus();
1649
+ });
1650
+ }
1651
+ function setupPop(btnId, popId) {
1652
+ var btn = byId(btnId);
1653
+ var pop = byId(popId);
1654
+ if (!btn || !pop) return;
1655
+ pops.push({ btn: btn, pop: pop });
1656
+ btn.addEventListener('click', function () {
1657
+ var willOpen = pop.hidden;
1658
+ closePops(false);
1659
+ if (!willOpen) return;
1660
+ pop.hidden = false;
1661
+ btn.setAttribute('aria-expanded', 'true');
1662
+ placePop(btn, pop);
1663
+ pop.focus();
1664
+ });
1665
+ }
1666
+ setupPop('legend-btn', 'legend');
1667
+ setupPop('q-btn', 'questions');
1668
+ ${c.views ? VIEW_POP : ''} // 흐름 단추는 서비스 아래 레벨에서만 보인다. 흐름이 하나면 바로 가고 여럿이면 고르게 한다
1669
+ var flowBtn = byId('flow-btn');
1670
+ var flowPop = byId('flow-pop');
1671
+ function flowsHere() {
1672
+ var lv = levels[current];
1673
+ var svc = lv && lv.kind !== 'flow' && lv.trail[1] && lv.trail[1].indexOf('service:') === 0 ? lv.trail[1].slice(8) : null;
1674
+ return svc ? flowsBySvc[svc] || [] : ${c.rootFlows ? ROOT_FLOWS : '[]'};
1675
+ }
1676
+ function syncFlowBtn() {
1677
+ if (flowBtn) flowBtn.hidden = flowsHere().length === 0;
1678
+ }
1679
+ if (flowBtn && flowPop) {
1680
+ pops.push({ btn: flowBtn, pop: flowPop });
1681
+ flowBtn.addEventListener('click', function () {
1682
+ var list = flowsHere();
1683
+ if (list.length === 1) { closePops(false); showLevel(list[0].level); return; }
1684
+ var willOpen = flowPop.hidden;
1685
+ closePops(false);
1686
+ if (!willOpen) return;
1687
+ var ul = flowPop.querySelector('.flow-list');
1688
+ ul.textContent = '';
1689
+ list.forEach(function (f) {
1690
+ var li = el('li');
1691
+ var b = el('button', null, f.title);
1692
+ b.type = 'button';
1693
+ b.addEventListener('click', function () { closePops(false); showLevel(f.level); });
1694
+ li.appendChild(b);
1695
+ ul.appendChild(li);
1696
+ });
1697
+ flowPop.hidden = false;
1698
+ flowBtn.setAttribute('aria-expanded', 'true');
1699
+ placePop(flowBtn, flowPop);
1700
+ flowPop.focus();
1701
+ });
1702
+ }
1703
+ doc.addEventListener('pointerdown', function (e) {
1704
+ if (!productMenu.hidden && !(e.target.closest && e.target.closest('.pmenu, .pb.more'))) closeProducts(false);
1705
+ if (!anyPop()) return;
1706
+ if (e.target.closest && (e.target.closest('.pop') || e.target.closest('[aria-controls]'))) return;
1707
+ closePops(false);
1708
+ });
1709
+ window.addEventListener('resize', function () {
1710
+ pops.forEach(function (p) { if (!p.pop.hidden) placePop(p.btn, p.pop); });
1711
+ });
1712
+ function goToNode(id) {
1713
+ if (!id) return;
1714
+ if (active && cardMap(active)[id]) { focusCard(id); return; }
1715
+ var target = null;
1716
+ sections.forEach(function (s) { if (!target && cardMap(s)[id]) target = s.getAttribute('data-level-id'); });
1717
+ if (!target) return;
1718
+ pendingSelect = id;
1719
+ showLevel(target);
1720
+ }
1721
+ byId('questions').addEventListener('click', function (e) {
1722
+ var b = e.target.closest('button.q');
1723
+ if (!b || b.disabled) return;
1724
+ closePops(false);
1725
+ goToNode(b.getAttribute('data-node-id'));
1726
+ });
1727
+
1728
+ // 처음 쓰는 사람을 위한 안내. 한 번 닫으면 다시 안 띄운다
1729
+ if (load(HINT_KEY) === 'off') hint.hidden = true;
1730
+ byId('hint-close').addEventListener('click', function () {
1731
+ hint.hidden = true;
1732
+ store(HINT_KEY, 'off');
1733
+ });
1734
+
1735
+ doc.addEventListener('keydown', function (e) {
1736
+ if (e.key === 'Escape') {
1737
+ if (!productMenu.hidden) { closeProducts(true); return; }
1738
+ if (anyPop()) { closePops(true); return; }
1739
+ if (drawerOpen()) { closeDrawer(true); return; }
1740
+ releaseFocus();
1741
+ return;
1742
+ }
1743
+ var t = e.target;
1744
+ if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
1745
+ if (e.metaKey || e.ctrlKey || e.altKey) return;
1746
+ if (e.key === '/') { e.preventDefault(); search.focus(); search.select(); }
1747
+ else if (e.key === '+' || e.key === '=') zoomBy(1.25);
1748
+ else if (e.key === '-' || e.key === '_') zoomBy(0.8);
1749
+ else if (e.key === '0') fit('full');
1750
+ else if ((e.key === 'f' || e.key === 'F') && !focusBtn.hidden) showFocus(selectedId);
1751
+ });
1752
+
1753
+ // 환경 고르기. 서버가 그린 자리는 그대로 두고 고르지 않은 환경의 카드와 거기 걸린 선만 숨긴다.
1754
+ // 환경이 prod부터 위에서 아래로 쌓여 있어서 prod만 남겨도 열 중간에 빈 자리가 안 생긴다
1755
+ var envPicker = byId('env-picker');
1756
+ var envOn = {};
1757
+ function envHidden(id) {
1758
+ var n = nodes[id];
1759
+ return !!(envPicker && n && n.environment && !envOn[n.environment]);
1760
+ }
1761
+ function applyEnv(sec) {
1762
+ sec.querySelectorAll('.node[data-env]').forEach(function (c) {
1763
+ c.classList.toggle('env-off', !envOn[c.getAttribute('data-env')]);
1764
+ });
1765
+ sec.querySelectorAll('.link').forEach(function (l) {
1766
+ l.classList.toggle('env-off', envHidden(l.getAttribute('data-from')) || envHidden(l.getAttribute('data-to')));
1767
+ });
1768
+ // 레인 제목 숫자는 보이는 카드만 센다. 다 숨으면 빈 레인도 같이 접는다
1769
+ var cards = sec.querySelectorAll('.node');
1770
+ var titles = sec.querySelectorAll('.lane-title');
1771
+ Array.prototype.forEach.call(sec.querySelectorAll('rect.lane'), function (r, i) {
1772
+ var t = titles[i];
1773
+ var x = parseFloat(r.getAttribute('x'));
1774
+ var w = parseFloat(r.getAttribute('width'));
1775
+ var n = 0;
1776
+ Array.prototype.forEach.call(cards, function (c) {
1777
+ var left = parseFloat(c.style.left);
1778
+ if (left >= x && left < x + w && !c.classList.contains('env-off')) n += 1;
1779
+ });
1780
+ r.classList.toggle('env-off', n === 0);
1781
+ if (!t) return;
1782
+ t.classList.toggle('env-off', n === 0);
1783
+ var count = t.querySelector('.n');
1784
+ if (count) count.textContent = n;
1785
+ });
1786
+ }
1787
+ // 고른 환경은 주소 해시 뒤 ?env=prod,dev로 싣는다. 주소를 보내면 받은 사람도 같은 환경으로 본다.
1788
+ // 처음 켜진 값(prod만)과 같으면 안 붙여서 환경 버튼이 생기기 전 주소와 같게 둔다
1789
+ var envButtons = envPicker ? Array.prototype.slice.call(envPicker.querySelectorAll('button[data-env]')) : [];
1790
+ var envDefault = {};
1791
+ envButtons.forEach(function (b) {
1792
+ var name = b.getAttribute('data-env');
1793
+ envDefault[name] = b.getAttribute('aria-pressed') === 'true';
1794
+ envOn[name] = envDefault[name];
1795
+ });
1796
+ function envNames() { return envButtons.map(function (b) { return b.getAttribute('data-env'); }); }
1797
+ function envQuery() {
1798
+ if (!envButtons.length) return '';
1799
+ var names = envNames();
1800
+ if (names.every(function (n) { return envOn[n] === envDefault[n]; })) return '';
1801
+ return '?env=' + names.filter(function (n) { return envOn[n]; }).map(enc).join(',');
1802
+ }
1803
+ function envFromQuery(query) {
1804
+ var m = /(?:^|&)env=([^&]*)/.exec(query);
1805
+ if (!m) return envDefault;
1806
+ var want = {};
1807
+ var any = false;
1808
+ m[1].split(',').forEach(function (p) {
1809
+ var name;
1810
+ try { name = decodeURIComponent(p); } catch (e) { return; }
1811
+ if (Object.prototype.hasOwnProperty.call(envDefault, name)) { want[name] = true; any = true; }
1812
+ });
1813
+ return any ? want : envDefault;
1814
+ }
1815
+ function setEnv(next) {
1816
+ var changed = false;
1817
+ envButtons.forEach(function (b) {
1818
+ var name = b.getAttribute('data-env');
1819
+ var on = !!next[name];
1820
+ if (envOn[name] !== on) changed = true;
1821
+ envOn[name] = on;
1822
+ b.setAttribute('aria-pressed', String(on));
1823
+ });
1824
+ if (changed) sections.forEach(applyEnv);
1825
+ }
1826
+ if (envPicker) {
1827
+ sections.forEach(applyEnv);
1828
+ envPicker.addEventListener('click', function (e) {
1829
+ var b = e.target.closest ? e.target.closest('button[data-env]') : null;
1830
+ if (!b) return;
1831
+ var env = b.getAttribute('data-env');
1832
+ // 하나는 늘 켜 둔다. 다 끄면 인프라 열이 통째로 비어 무엇을 끈 건지 안 보인다
1833
+ if (envOn[env] && envNames().filter(function (n) { return envOn[n]; }).length === 1) return;
1834
+ var next = {};
1835
+ envNames().forEach(function (n) { next[n] = n === env ? !envOn[n] : envOn[n]; });
1836
+ setEnv(next);
1837
+ // 환경을 바꾼 건 뒤로가기 기록으로 남기지 않는다. 뒤로가기는 레벨을 오간 길만 되짚는 게 덜 헷갈린다
1838
+ var h = location.hash;
1839
+ var at = h.indexOf('?');
1840
+ history.replaceState(null, '', (at >= 0 ? h.slice(0, at) : h || '#/') + envQuery());
1841
+ if (focusId !== null) route();
1842
+ else runSearch(false);
1843
+ });
1844
+ }
1845
+
1846
+ window.addEventListener('hashchange', route);
1847
+ route();
1848
+ ready = true;
1849
+ })();
1850
+ `;
1851
+ }
1852
+ //# sourceMappingURL=html-client.js.map