@tienne/gestalt 0.87.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 (280) hide show
  1. package/CLAUDE.md +12 -4
  2. package/README.ko.md +2 -1
  3. package/README.md +3 -1
  4. package/dist/bin/gestalt-hook.d.ts +5 -0
  5. package/dist/bin/gestalt-hook.d.ts.map +1 -0
  6. package/dist/bin/gestalt-hook.js +51 -0
  7. package/dist/bin/gestalt-hook.js.map +1 -0
  8. package/dist/package.json +5 -3
  9. package/dist/plugin/skills/_shared/proactive-routing.md +1 -0
  10. package/dist/plugin/skills/architecture/SKILL.md +913 -0
  11. package/dist/plugin/skills/blast-radius/SKILL.md +11 -1
  12. package/dist/plugin/skills/diff-radius/SKILL.md +11 -0
  13. package/dist/schemas/architecture-ir.schema.json +596 -0
  14. package/dist/schemas/gestalt.schema.json +19 -0
  15. package/dist/src/architecture/canvas-geometry.d.ts +40 -0
  16. package/dist/src/architecture/canvas-geometry.d.ts.map +1 -0
  17. package/dist/src/architecture/canvas-geometry.js +212 -0
  18. package/dist/src/architecture/canvas-geometry.js.map +1 -0
  19. package/dist/src/architecture/codeowners.d.ts +16 -0
  20. package/dist/src/architecture/codeowners.d.ts.map +1 -0
  21. package/dist/src/architecture/codeowners.js +58 -0
  22. package/dist/src/architecture/codeowners.js.map +1 -0
  23. package/dist/src/architecture/compare-layout.d.ts +27 -0
  24. package/dist/src/architecture/compare-layout.d.ts.map +1 -0
  25. package/dist/src/architecture/compare-layout.js +72 -0
  26. package/dist/src/architecture/compare-layout.js.map +1 -0
  27. package/dist/src/architecture/dataflow-layout.d.ts +29 -0
  28. package/dist/src/architecture/dataflow-layout.d.ts.map +1 -0
  29. package/dist/src/architecture/dataflow-layout.js +229 -0
  30. package/dist/src/architecture/dataflow-layout.js.map +1 -0
  31. package/dist/src/architecture/doc-fresh.d.ts +31 -0
  32. package/dist/src/architecture/doc-fresh.d.ts.map +1 -0
  33. package/dist/src/architecture/doc-fresh.js +84 -0
  34. package/dist/src/architecture/doc-fresh.js.map +1 -0
  35. package/dist/src/architecture/doc-link.d.ts +84 -0
  36. package/dist/src/architecture/doc-link.d.ts.map +1 -0
  37. package/dist/src/architecture/doc-link.js +396 -0
  38. package/dist/src/architecture/doc-link.js.map +1 -0
  39. package/dist/src/architecture/doc-routes.d.ts +39 -0
  40. package/dist/src/architecture/doc-routes.d.ts.map +1 -0
  41. package/dist/src/architecture/doc-routes.js +123 -0
  42. package/dist/src/architecture/doc-routes.js.map +1 -0
  43. package/dist/src/architecture/doc-scan.d.ts +107 -0
  44. package/dist/src/architecture/doc-scan.d.ts.map +1 -0
  45. package/dist/src/architecture/doc-scan.js +617 -0
  46. package/dist/src/architecture/doc-scan.js.map +1 -0
  47. package/dist/src/architecture/doc-stale.d.ts +32 -0
  48. package/dist/src/architecture/doc-stale.d.ts.map +1 -0
  49. package/dist/src/architecture/doc-stale.js +60 -0
  50. package/dist/src/architecture/doc-stale.js.map +1 -0
  51. package/dist/src/architecture/drilldown.d.ts +51 -0
  52. package/dist/src/architecture/drilldown.d.ts.map +1 -0
  53. package/dist/src/architecture/drilldown.js +1160 -0
  54. package/dist/src/architecture/drilldown.js.map +1 -0
  55. package/dist/src/architecture/endpoint-match.d.ts +43 -0
  56. package/dist/src/architecture/endpoint-match.d.ts.map +1 -0
  57. package/dist/src/architecture/endpoint-match.js +133 -0
  58. package/dist/src/architecture/endpoint-match.js.map +1 -0
  59. package/dist/src/architecture/flow-layout.d.ts +79 -0
  60. package/dist/src/architecture/flow-layout.d.ts.map +1 -0
  61. package/dist/src/architecture/flow-layout.js +519 -0
  62. package/dist/src/architecture/flow-layout.js.map +1 -0
  63. package/dist/src/architecture/focus.d.ts +14 -0
  64. package/dist/src/architecture/focus.d.ts.map +1 -0
  65. package/dist/src/architecture/focus.js +107 -0
  66. package/dist/src/architecture/focus.js.map +1 -0
  67. package/dist/src/architecture/global-context.d.ts +23 -0
  68. package/dist/src/architecture/global-context.d.ts.map +1 -0
  69. package/dist/src/architecture/global-context.js +67 -0
  70. package/dist/src/architecture/global-context.js.map +1 -0
  71. package/dist/src/architecture/html-client.d.ts +35 -0
  72. package/dist/src/architecture/html-client.d.ts.map +1 -0
  73. package/dist/src/architecture/html-client.js +1852 -0
  74. package/dist/src/architecture/html-client.js.map +1 -0
  75. package/dist/src/architecture/html-renderer.d.ts +22 -0
  76. package/dist/src/architecture/html-renderer.d.ts.map +1 -0
  77. package/dist/src/architecture/html-renderer.js +1290 -0
  78. package/dist/src/architecture/html-renderer.js.map +1 -0
  79. package/dist/src/architecture/html-theme.d.ts +25 -0
  80. package/dist/src/architecture/html-theme.d.ts.map +1 -0
  81. package/dist/src/architecture/html-theme.js +681 -0
  82. package/dist/src/architecture/html-theme.js.map +1 -0
  83. package/dist/src/architecture/index.d.ts +9 -0
  84. package/dist/src/architecture/index.d.ts.map +1 -0
  85. package/dist/src/architecture/index.js +9 -0
  86. package/dist/src/architecture/index.js.map +1 -0
  87. package/dist/src/architecture/ir-schema.d.ts +1832 -0
  88. package/dist/src/architecture/ir-schema.d.ts.map +1 -0
  89. package/dist/src/architecture/ir-schema.js +343 -0
  90. package/dist/src/architecture/ir-schema.js.map +1 -0
  91. package/dist/src/architecture/kind-text.d.ts +21 -0
  92. package/dist/src/architecture/kind-text.d.ts.map +1 -0
  93. package/dist/src/architecture/kind-text.js +42 -0
  94. package/dist/src/architecture/kind-text.js.map +1 -0
  95. package/dist/src/architecture/layout.d.ts +227 -0
  96. package/dist/src/architecture/layout.d.ts.map +1 -0
  97. package/dist/src/architecture/layout.js +627 -0
  98. package/dist/src/architecture/layout.js.map +1 -0
  99. package/dist/src/architecture/mcp-tool-match.d.ts +49 -0
  100. package/dist/src/architecture/mcp-tool-match.d.ts.map +1 -0
  101. package/dist/src/architecture/mcp-tool-match.js +53 -0
  102. package/dist/src/architecture/mcp-tool-match.js.map +1 -0
  103. package/dist/src/architecture/merge.d.ts +54 -0
  104. package/dist/src/architecture/merge.d.ts.map +1 -0
  105. package/dist/src/architecture/merge.js +681 -0
  106. package/dist/src/architecture/merge.js.map +1 -0
  107. package/dist/src/architecture/micro-app.d.ts +20 -0
  108. package/dist/src/architecture/micro-app.d.ts.map +1 -0
  109. package/dist/src/architecture/micro-app.js +41 -0
  110. package/dist/src/architecture/micro-app.js.map +1 -0
  111. package/dist/src/architecture/name-ref-match.d.ts +43 -0
  112. package/dist/src/architecture/name-ref-match.d.ts.map +1 -0
  113. package/dist/src/architecture/name-ref-match.js +127 -0
  114. package/dist/src/architecture/name-ref-match.js.map +1 -0
  115. package/dist/src/architecture/packs/data.d.ts +109 -0
  116. package/dist/src/architecture/packs/data.d.ts.map +1 -0
  117. package/dist/src/architecture/packs/data.js +64 -0
  118. package/dist/src/architecture/packs/data.js.map +1 -0
  119. package/dist/src/architecture/packs/generic.d.ts +47 -0
  120. package/dist/src/architecture/packs/generic.d.ts.map +1 -0
  121. package/dist/src/architecture/packs/generic.js +35 -0
  122. package/dist/src/architecture/packs/generic.js.map +1 -0
  123. package/dist/src/architecture/packs/harness.d.ts +96 -0
  124. package/dist/src/architecture/packs/harness.d.ts.map +1 -0
  125. package/dist/src/architecture/packs/harness.js +66 -0
  126. package/dist/src/architecture/packs/harness.js.map +1 -0
  127. package/dist/src/architecture/packs/index.d.ts +840 -0
  128. package/dist/src/architecture/packs/index.d.ts.map +1 -0
  129. package/dist/src/architecture/packs/index.js +87 -0
  130. package/dist/src/architecture/packs/index.js.map +1 -0
  131. package/dist/src/architecture/packs/infra.d.ts +91 -0
  132. package/dist/src/architecture/packs/infra.d.ts.map +1 -0
  133. package/dist/src/architecture/packs/infra.js +57 -0
  134. package/dist/src/architecture/packs/infra.js.map +1 -0
  135. package/dist/src/architecture/packs/knowledge.d.ts +72 -0
  136. package/dist/src/architecture/packs/knowledge.d.ts.map +1 -0
  137. package/dist/src/architecture/packs/knowledge.js +58 -0
  138. package/dist/src/architecture/packs/knowledge.js.map +1 -0
  139. package/dist/src/architecture/packs/process.d.ts +87 -0
  140. package/dist/src/architecture/packs/process.d.ts.map +1 -0
  141. package/dist/src/architecture/packs/process.js +52 -0
  142. package/dist/src/architecture/packs/process.js.map +1 -0
  143. package/dist/src/architecture/packs/render-classes.d.ts +11 -0
  144. package/dist/src/architecture/packs/render-classes.d.ts.map +1 -0
  145. package/dist/src/architecture/packs/render-classes.js +92 -0
  146. package/dist/src/architecture/packs/render-classes.js.map +1 -0
  147. package/dist/src/architecture/packs/types.d.ts +66 -0
  148. package/dist/src/architecture/packs/types.d.ts.map +1 -0
  149. package/dist/src/architecture/packs/types.js +18 -0
  150. package/dist/src/architecture/packs/types.js.map +1 -0
  151. package/dist/src/architecture/packs/web-product.d.ts +348 -0
  152. package/dist/src/architecture/packs/web-product.d.ts.map +1 -0
  153. package/dist/src/architecture/packs/web-product.js +226 -0
  154. package/dist/src/architecture/packs/web-product.js.map +1 -0
  155. package/dist/src/architecture/sequence-layout.d.ts +50 -0
  156. package/dist/src/architecture/sequence-layout.d.ts.map +1 -0
  157. package/dist/src/architecture/sequence-layout.js +140 -0
  158. package/dist/src/architecture/sequence-layout.js.map +1 -0
  159. package/dist/src/architecture/service-facts.d.ts +30 -0
  160. package/dist/src/architecture/service-facts.d.ts.map +1 -0
  161. package/dist/src/architecture/service-facts.js +88 -0
  162. package/dist/src/architecture/service-facts.js.map +1 -0
  163. package/dist/src/architecture/store.d.ts +50 -0
  164. package/dist/src/architecture/store.d.ts.map +1 -0
  165. package/dist/src/architecture/store.js +320 -0
  166. package/dist/src/architecture/store.js.map +1 -0
  167. package/dist/src/architecture/types.d.ts +400 -0
  168. package/dist/src/architecture/types.d.ts.map +1 -0
  169. package/dist/src/architecture/types.js +75 -0
  170. package/dist/src/architecture/types.js.map +1 -0
  171. package/dist/src/architecture/validator.d.ts +65 -0
  172. package/dist/src/architecture/validator.d.ts.map +1 -0
  173. package/dist/src/architecture/validator.js +1249 -0
  174. package/dist/src/architecture/validator.js.map +1 -0
  175. package/dist/src/code-graph/cochange.d.ts +40 -0
  176. package/dist/src/code-graph/cochange.d.ts.map +1 -1
  177. package/dist/src/code-graph/cochange.js +88 -3
  178. package/dist/src/code-graph/cochange.js.map +1 -1
  179. package/dist/src/code-graph/engine.d.ts +35 -1
  180. package/dist/src/code-graph/engine.d.ts.map +1 -1
  181. package/dist/src/code-graph/engine.js +300 -55
  182. package/dist/src/code-graph/engine.js.map +1 -1
  183. package/dist/src/code-graph/freshness.d.ts +39 -0
  184. package/dist/src/code-graph/freshness.d.ts.map +1 -0
  185. package/dist/src/code-graph/freshness.js +93 -0
  186. package/dist/src/code-graph/freshness.js.map +1 -0
  187. package/dist/src/code-graph/hooks/background.d.ts +18 -0
  188. package/dist/src/code-graph/hooks/background.d.ts.map +1 -0
  189. package/dist/src/code-graph/hooks/background.js +55 -0
  190. package/dist/src/code-graph/hooks/background.js.map +1 -0
  191. package/dist/src/code-graph/hooks/pointers.d.ts +66 -0
  192. package/dist/src/code-graph/hooks/pointers.d.ts.map +1 -0
  193. package/dist/src/code-graph/hooks/pointers.js +292 -0
  194. package/dist/src/code-graph/hooks/pointers.js.map +1 -0
  195. package/dist/src/code-graph/hooks/rank.d.ts +36 -0
  196. package/dist/src/code-graph/hooks/rank.d.ts.map +1 -0
  197. package/dist/src/code-graph/hooks/rank.js +212 -0
  198. package/dist/src/code-graph/hooks/rank.js.map +1 -0
  199. package/dist/src/code-graph/hooks/run.d.ts +37 -0
  200. package/dist/src/code-graph/hooks/run.d.ts.map +1 -0
  201. package/dist/src/code-graph/hooks/run.js +324 -0
  202. package/dist/src/code-graph/hooks/run.js.map +1 -0
  203. package/dist/src/code-graph/hooks/settings.d.ts +11 -0
  204. package/dist/src/code-graph/hooks/settings.d.ts.map +1 -0
  205. package/dist/src/code-graph/hooks/settings.js +25 -0
  206. package/dist/src/code-graph/hooks/settings.js.map +1 -0
  207. package/dist/src/code-graph/hooks/state.d.ts +20 -0
  208. package/dist/src/code-graph/hooks/state.d.ts.map +1 -0
  209. package/dist/src/code-graph/hooks/state.js +73 -0
  210. package/dist/src/code-graph/hooks/state.js.map +1 -0
  211. package/dist/src/code-graph/index.d.ts +6 -2
  212. package/dist/src/code-graph/index.d.ts.map +1 -1
  213. package/dist/src/code-graph/index.js +3 -1
  214. package/dist/src/code-graph/index.js.map +1 -1
  215. package/dist/src/code-graph/ko-text.d.ts +39 -0
  216. package/dist/src/code-graph/ko-text.d.ts.map +1 -0
  217. package/dist/src/code-graph/ko-text.js +223 -0
  218. package/dist/src/code-graph/ko-text.js.map +1 -0
  219. package/dist/src/code-graph/lock.d.ts +30 -0
  220. package/dist/src/code-graph/lock.d.ts.map +1 -0
  221. package/dist/src/code-graph/lock.js +140 -0
  222. package/dist/src/code-graph/lock.js.map +1 -0
  223. package/dist/src/code-graph/plugins/typescript-skeleton.d.ts +7 -0
  224. package/dist/src/code-graph/plugins/typescript-skeleton.d.ts.map +1 -0
  225. package/dist/src/code-graph/plugins/typescript-skeleton.js +157 -0
  226. package/dist/src/code-graph/plugins/typescript-skeleton.js.map +1 -0
  227. package/dist/src/code-graph/plugins/typescript.d.ts.map +1 -1
  228. package/dist/src/code-graph/plugins/typescript.js +58 -0
  229. package/dist/src/code-graph/plugins/typescript.js.map +1 -1
  230. package/dist/src/code-graph/storage.d.ts +85 -0
  231. package/dist/src/code-graph/storage.d.ts.map +1 -1
  232. package/dist/src/code-graph/storage.js +318 -3
  233. package/dist/src/code-graph/storage.js.map +1 -1
  234. package/dist/src/code-graph/types.d.ts +66 -0
  235. package/dist/src/code-graph/types.d.ts.map +1 -1
  236. package/dist/src/core/config.d.ts +33 -0
  237. package/dist/src/core/config.d.ts.map +1 -1
  238. package/dist/src/core/config.js +14 -0
  239. package/dist/src/core/config.js.map +1 -1
  240. package/dist/src/core/errors.d.ts +4 -0
  241. package/dist/src/core/errors.d.ts.map +1 -1
  242. package/dist/src/core/errors.js +8 -0
  243. package/dist/src/core/errors.js.map +1 -1
  244. package/dist/src/core/hooks-env.d.ts +9 -0
  245. package/dist/src/core/hooks-env.d.ts.map +1 -0
  246. package/dist/src/core/hooks-env.js +16 -0
  247. package/dist/src/core/hooks-env.js.map +1 -0
  248. package/dist/src/mcp/schemas.d.ts +326 -3
  249. package/dist/src/mcp/schemas.d.ts.map +1 -1
  250. package/dist/src/mcp/schemas.js +170 -2
  251. package/dist/src/mcp/schemas.js.map +1 -1
  252. package/dist/src/mcp/server.d.ts.map +1 -1
  253. package/dist/src/mcp/server.js +28 -4
  254. package/dist/src/mcp/server.js.map +1 -1
  255. package/dist/src/mcp/tools/architecture-passthrough.d.ts +9 -0
  256. package/dist/src/mcp/tools/architecture-passthrough.d.ts.map +1 -0
  257. package/dist/src/mcp/tools/architecture-passthrough.js +587 -0
  258. package/dist/src/mcp/tools/architecture-passthrough.js.map +1 -0
  259. package/dist/src/mcp/tools/code-graph-passthrough.d.ts +6 -1
  260. package/dist/src/mcp/tools/code-graph-passthrough.d.ts.map +1 -1
  261. package/dist/src/mcp/tools/code-graph-passthrough.js +95 -6
  262. package/dist/src/mcp/tools/code-graph-passthrough.js.map +1 -1
  263. package/dist/src/utils/claude-projects.d.ts +19 -0
  264. package/dist/src/utils/claude-projects.d.ts.map +1 -0
  265. package/dist/src/utils/claude-projects.js +157 -0
  266. package/dist/src/utils/claude-projects.js.map +1 -0
  267. package/dist/src/utils/read-only-tools.d.ts +19 -0
  268. package/dist/src/utils/read-only-tools.d.ts.map +1 -0
  269. package/dist/src/utils/read-only-tools.js +132 -0
  270. package/dist/src/utils/read-only-tools.js.map +1 -0
  271. package/package.json +5 -3
  272. package/plugin/.codex-plugin/plugin.json +1 -1
  273. package/plugin/.mcp.json +1 -1
  274. package/plugin/mcp.json +1 -1
  275. package/plugin/skills/_shared/proactive-routing.md +1 -0
  276. package/plugin/skills/architecture/SKILL.md +913 -0
  277. package/plugin/skills/blast-radius/SKILL.md +11 -1
  278. package/plugin/skills/diff-radius/SKILL.md +11 -0
  279. package/schemas/architecture-ir.schema.json +596 -0
  280. package/schemas/gestalt.schema.json +19 -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