@milcho0604/velog-mcp 0.4.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 (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.ko.md +366 -0
  3. package/README.md +381 -0
  4. package/dist/auth.d.ts +57 -0
  5. package/dist/auth.js +124 -0
  6. package/dist/auth.js.map +1 -0
  7. package/dist/capabilities.d.ts +50 -0
  8. package/dist/capabilities.js +60 -0
  9. package/dist/capabilities.js.map +1 -0
  10. package/dist/client.d.ts +113 -0
  11. package/dist/client.js +322 -0
  12. package/dist/client.js.map +1 -0
  13. package/dist/format.d.ts +31 -0
  14. package/dist/format.js +66 -0
  15. package/dist/format.js.map +1 -0
  16. package/dist/graphql.d.ts +29 -0
  17. package/dist/graphql.js +82 -0
  18. package/dist/graphql.js.map +1 -0
  19. package/dist/index.d.ts +25 -0
  20. package/dist/index.js +149 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/me.d.ts +23 -0
  23. package/dist/me.js +35 -0
  24. package/dist/me.js.map +1 -0
  25. package/dist/ownership.d.ts +42 -0
  26. package/dist/ownership.js +62 -0
  27. package/dist/ownership.js.map +1 -0
  28. package/dist/plugin-env.d.ts +67 -0
  29. package/dist/plugin-env.js +102 -0
  30. package/dist/plugin-env.js.map +1 -0
  31. package/dist/ratelimit.d.ts +48 -0
  32. package/dist/ratelimit.js +79 -0
  33. package/dist/ratelimit.js.map +1 -0
  34. package/dist/render/chrome.d.ts +77 -0
  35. package/dist/render/chrome.js +287 -0
  36. package/dist/render/chrome.js.map +1 -0
  37. package/dist/render/cover.d.ts +29 -0
  38. package/dist/render/cover.js +195 -0
  39. package/dist/render/cover.js.map +1 -0
  40. package/dist/render/icons.d.ts +22 -0
  41. package/dist/render/icons.js +158 -0
  42. package/dist/render/icons.js.map +1 -0
  43. package/dist/render/index.d.ts +32 -0
  44. package/dist/render/index.js +137 -0
  45. package/dist/render/index.js.map +1 -0
  46. package/dist/render/page.d.ts +89 -0
  47. package/dist/render/page.js +761 -0
  48. package/dist/render/page.js.map +1 -0
  49. package/dist/render/tones.d.ts +30 -0
  50. package/dist/render/tones.js +46 -0
  51. package/dist/render/tones.js.map +1 -0
  52. package/dist/slug.d.ts +42 -0
  53. package/dist/slug.js +79 -0
  54. package/dist/slug.js.map +1 -0
  55. package/dist/tools/discover.d.ts +6 -0
  56. package/dist/tools/discover.js +106 -0
  57. package/dist/tools/discover.js.map +1 -0
  58. package/dist/tools/drafts.d.ts +23 -0
  59. package/dist/tools/drafts.js +227 -0
  60. package/dist/tools/drafts.js.map +1 -0
  61. package/dist/tools/export.d.ts +21 -0
  62. package/dist/tools/export.js +132 -0
  63. package/dist/tools/export.js.map +1 -0
  64. package/dist/tools/images.d.ts +34 -0
  65. package/dist/tools/images.js +556 -0
  66. package/dist/tools/images.js.map +1 -0
  67. package/dist/tools/posts.d.ts +14 -0
  68. package/dist/tools/posts.js +82 -0
  69. package/dist/tools/posts.js.map +1 -0
  70. package/dist/tools/profile-edit.d.ts +15 -0
  71. package/dist/tools/profile-edit.js +216 -0
  72. package/dist/tools/profile-edit.js.map +1 -0
  73. package/dist/tools/profile.d.ts +9 -0
  74. package/dist/tools/profile.js +133 -0
  75. package/dist/tools/profile.js.map +1 -0
  76. package/dist/tools/publish.d.ts +16 -0
  77. package/dist/tools/publish.js +424 -0
  78. package/dist/tools/publish.js.map +1 -0
  79. package/dist/tools/stats.d.ts +32 -0
  80. package/dist/tools/stats.js +154 -0
  81. package/dist/tools/stats.js.map +1 -0
  82. package/dist/types.d.ts +42 -0
  83. package/dist/types.js +3 -0
  84. package/dist/types.js.map +1 -0
  85. package/docs/PRD.md +146 -0
  86. package/docs/api-reference.md +329 -0
  87. package/docs/architecture.md +112 -0
  88. package/docs/decisions/0001-why-build-our-own.md +89 -0
  89. package/docs/decisions/0002-draft-only-write.md +84 -0
  90. package/docs/decisions/0003-token-env-only.md +109 -0
  91. package/docs/decisions/0004-capability-model.md +123 -0
  92. package/docs/decisions/0005-render-in-server.md +117 -0
  93. package/docs/decisions/0006-ship-as-plugin.md +532 -0
  94. package/docs/security.md +384 -0
  95. package/docs/tools.md +404 -0
  96. package/npm-shrinkwrap.json +2345 -0
  97. package/package.json +61 -0
@@ -0,0 +1,761 @@
1
+ /**
2
+ * 다이어그램 HTML 생성.
3
+ *
4
+ * 이 파일의 핵심은 "스타일 규칙을 사람이 기억하는 게 아니라 코드가 강제한다" 는 것이다.
5
+ * 손으로 그릴 때 반복해서 틀렸던 것들을 그대로 옮겨 놨다:
6
+ *
7
+ * - 글자 폭은 **추정하지 않는다.** 전부 브라우저의 getBBox 실측이다.
8
+ * (글자수 × 상수로 잡으면 한글/영문 섞인 라벨에서 칩이 삐져나온다.)
9
+ * - 캔버스 크기를 사람이 정하지 않는다. 그린 뒤 내용 bbox 로 되맞춘다.
10
+ * 잘림 사고가 구조적으로 안 난다.
11
+ * - 선이 노드를 관통하거나 노드 뒤에 숨는 것, 서로 다른 선이 겹치는 것,
12
+ * 노드끼리 겹치는 것을 **자가감사**가 잡아서 보고한다.
13
+ *
14
+ * ★ 페이지 안 스크립트에 대한 보안 원칙
15
+ * 데이터는 `<script type="application/json">` 블록으로만 들어가고, DOM 은 전부
16
+ * createElementNS + textContent 로 만든다. innerHTML 은 한 번도 쓰지 않는다.
17
+ * 따라서 라벨 문자열에 마크업이 들어와도 그냥 글자로 그려진다.
18
+ * 색은 입력 단계(zod)에서 `#rrggbb` 로 좁혀 두었다.
19
+ */
20
+ import { ICONS } from "./icons.js";
21
+ import { DEFAULT_PLANES, TONES, tone } from "./tones.js";
22
+ /**
23
+ * 캔버스 상한. **페이지 안에서** 강제된다 (page 스크립트 참고).
24
+ * 바깥에서 검사하면 이미 브라우저가 표면을 잡은 뒤라 늦는다.
25
+ */
26
+ export const MAX_DIM = 6000;
27
+ export const MAX_AREA = 9_000_000;
28
+ function resolve(spec) {
29
+ const planes = (spec.planes?.length ? spec.planes : DEFAULT_PLANES).map((p) => ({
30
+ key: p.key,
31
+ name: p.name,
32
+ color: p.color,
33
+ dash: p.dash ?? '',
34
+ }));
35
+ const nodes = spec.nodes.map((n, i) => ({
36
+ id: n.id ?? `n${i}`,
37
+ x: n.x,
38
+ y: n.y,
39
+ w: n.w ?? 0, // 0 = 내용에서 실측해 정한다
40
+ h: n.h ?? 0,
41
+ title: n.title,
42
+ sub: n.sub ?? '',
43
+ icon: n.icon && ICONS[n.icon] ? n.icon : '',
44
+ iconColor: tone(n.icon_tone, 'slate').solid,
45
+ tag: n.tag ?? '',
46
+ tagColor: tone(n.tag_tone, 'slate').solid,
47
+ }));
48
+ // 실제 쓰인 아이콘만 싣는다. 안 쓰는 걸 다 넣으면 HTML 이 쓸데없이 커진다.
49
+ const icons = {};
50
+ for (const n of nodes) {
51
+ const prim = ICONS[n.icon];
52
+ if (n.icon && prim)
53
+ icons[n.icon] = prim;
54
+ }
55
+ const groups = (spec.groups ?? []).map((g) => {
56
+ const t = tone(g.tone, 'gray');
57
+ const out = {
58
+ name: g.name,
59
+ sub: g.sub ?? '',
60
+ fill: t.fill,
61
+ stroke: t.stroke,
62
+ members: g.members ?? [],
63
+ };
64
+ if (g.x !== undefined)
65
+ out.x = g.x;
66
+ if (g.y !== undefined)
67
+ out.y = g.y;
68
+ if (g.w !== undefined)
69
+ out.w = g.w;
70
+ if (g.h !== undefined)
71
+ out.h = g.h;
72
+ return out;
73
+ });
74
+ const fallbackPlane = planes[0]?.key ?? 'r';
75
+ const edges = (spec.edges ?? []).map((e) => ({
76
+ plane: e.plane ?? fallbackPlane,
77
+ from: e.from ?? '',
78
+ to: e.to ?? '',
79
+ points: e.points ?? [],
80
+ label: e.label ?? '',
81
+ labelAt: e.label_at ?? null,
82
+ labelAnchor: e.label_anchor ?? 'middle',
83
+ }));
84
+ return {
85
+ title: spec.title,
86
+ subtitle: spec.subtitle ?? '',
87
+ legend: spec.legend ?? true,
88
+ planes,
89
+ groups,
90
+ nodes,
91
+ edges,
92
+ icons,
93
+ maxDim: MAX_DIM,
94
+ maxArea: MAX_AREA,
95
+ };
96
+ }
97
+ /**
98
+ * JSON 을 `<script>` 블록에 안전하게 싣는다.
99
+ * `<` 를 유니코드 이스케이프로 바꾸면 `</script>` 가 만들어질 수 없다.
100
+ * JSON 규격상 `<` 는 파싱하면 다시 `<` 가 되므로 데이터는 그대로다.
101
+ */
102
+ function safeJson(value) {
103
+ return JSON.stringify(value).replace(/</g, '\\u003c');
104
+ }
105
+ const STYLE = `
106
+ html,body { margin:0; padding:0; background:#ffffff; }
107
+ body { font-family:-apple-system,BlinkMacSystemFont,"Apple SD Gothic Neo","Pretendard","Noto Sans KR","Malgun Gothic",sans-serif;
108
+ text-rendering:geometricPrecision; -webkit-font-smoothing:antialiased; }
109
+ #wrap { position:relative; }
110
+ svg { display:block; }
111
+ .hd-title { font-size:21px; font-weight:800; fill:#0f172a; }
112
+ .hd-sub { font-size:12.5px; fill:#64748b; }
113
+ .lg-t { font-size:11.5px; fill:#475569; font-weight:600; }
114
+ .grp-title{ font-size:12.5px; font-weight:700; fill:#1f2937; letter-spacing:.2px; }
115
+ .grp-sub { font-size:11px; fill:#6b7280; }
116
+ .n-title { font-size:13.5px; font-weight:600; fill:#111827; }
117
+ .n-sub { font-size:11px; fill:#6b7280; }
118
+ .tag-t { font-size:10.5px; font-weight:700; fill:#ffffff; letter-spacing:.2px; }
119
+ .e-label { font-size:11px; font-weight:600; paint-order:stroke; stroke:#ffffff; stroke-width:4.5px; stroke-linejoin:round; }
120
+ `;
121
+ // ── 페이지 안에서 도는 스크립트 ──────────────────────────────────────────
122
+ // ★ 이 문자열 안에서는 백틱과 ${ 를 쓰지 않는다. 바깥이 템플릿 리터럴이라
123
+ // 그대로 보간돼 버린다. 문자열은 전부 작은따옴표 + 이어붙이기로 쓴다.
124
+ const SCRIPT = `
125
+ (function(){
126
+ 'use strict';
127
+ try {
128
+ var S = JSON.parse(document.getElementById('spec').textContent);
129
+ var NS = 'http://www.w3.org/2000/svg';
130
+ var svg = document.getElementById('cv');
131
+ var PAD = 34, GAP = 26;
132
+
133
+ function el(n, a, p){
134
+ var e = document.createElementNS(NS, n);
135
+ for (var k in a) { if (a[k] !== null && a[k] !== undefined) e.setAttribute(k, a[k]); }
136
+ (p || svg).appendChild(e);
137
+ return e;
138
+ }
139
+ function txt(x, y, cls, s, anchor, fill, p){
140
+ var t = el('text', {x:x, y:y, 'class':cls, 'text-anchor':anchor||'middle'}, p);
141
+ if (fill) t.setAttribute('fill', fill);
142
+ t.textContent = s;
143
+ return t;
144
+ }
145
+
146
+ // 글자 폭은 전부 여기서 실측한다. 추정값은 쓰지 않는다.
147
+ var scratch = el('g', {visibility:'hidden'});
148
+ function measure(s, cls){
149
+ var t = txt(0, 0, cls, s, 'start', null, scratch);
150
+ var w = t.getBBox().width;
151
+ scratch.removeChild(t);
152
+ return w;
153
+ }
154
+
155
+ function boxOf(t){ var b = t.getBBox(); return {x:b.x, y:b.y, w:b.width, h:b.height}; }
156
+ function boxesOverlap(a, b){
157
+ return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
158
+ }
159
+
160
+ var defs = el('defs', {});
161
+ var PLANE = Object.create(null);
162
+ for (var pi = 0; pi < S.planes.length; pi++) {
163
+ var pl = S.planes[pi];
164
+ PLANE[pl.key] = pl;
165
+ var mk = el('marker', {id:'arr-'+pl.key, viewBox:'0 0 10 10', refX:'9', refY:'5',
166
+ markerWidth:'6.2', markerHeight:'6.2', orient:'auto-start-reverse'}, defs);
167
+ el('path', {d:'M0,0 L10,5 L0,10 z', fill:pl.color}, mk);
168
+ }
169
+ function planeOf(k){ return PLANE[k] || S.planes[0] || {color:'#64748b', dash:''}; }
170
+
171
+ // ── 머리말 (내용 그룹 바깥. 항상 좌상단 고정) ──
172
+ var headerBottom = PAD + 8;
173
+ var headerW = 0;
174
+ txt(PAD, PAD + 14, 'hd-title', S.title, 'start');
175
+ headerW = measure(S.title, 'hd-title');
176
+ headerBottom = PAD + 22;
177
+ if (S.subtitle) {
178
+ txt(PAD, headerBottom + 16, 'hd-sub', S.subtitle, 'start');
179
+ headerW = Math.max(headerW, measure(S.subtitle, 'hd-sub'));
180
+ headerBottom += 22;
181
+ }
182
+ if (S.legend && S.planes.length) {
183
+ var lx = PAD, ly = headerBottom + 22;
184
+ for (var li = 0; li < S.planes.length; li++) {
185
+ var lp = S.planes[li];
186
+ var ln = el('path', {d:'M'+lx+','+ly+' H'+(lx+30), stroke:lp.color, 'stroke-width':2,
187
+ fill:'none', 'marker-end':'url(#arr-'+lp.key+')'});
188
+ if (lp.dash) ln.setAttribute('stroke-dasharray', lp.dash);
189
+ txt(lx + 38, ly + 4, 'lg-t', lp.name, 'start');
190
+ lx += 38 + measure(lp.name, 'lg-t') + 22;
191
+ }
192
+ headerW = Math.max(headerW, lx - PAD - 22);
193
+ headerBottom = ly + 12;
194
+ }
195
+
196
+ // ── 내용은 통째로 한 그룹에 담는다. 다 그린 뒤 위치·캔버스를 되맞추기 위해서다 ──
197
+ var content = el('g', {});
198
+
199
+ // ── 노드 크기 확정 (폭·높이를 안 준 것은 글자 실측으로 정한다) ──
200
+ var NMAP = Object.create(null);
201
+ for (var ni = 0; ni < S.nodes.length; ni++) {
202
+ var n = S.nodes[ni];
203
+ var tw = measure(n.title, 'n-title');
204
+ var sw = n.sub ? measure(n.sub, 'n-sub') : 0;
205
+ n.tagBoxW = n.tag ? measure(n.tag, 'tag-t') + 16 : 0;
206
+ if (!n.w) {
207
+ var need = Math.max(126, tw + 30, sw + 26, n.tagBoxW + 30);
208
+ // 아이콘은 가운데 위, 배지는 오른쪽 위다. 폭이 좁으면 둘이 겹친다.
209
+ // 겹치지 않는 최소 폭: w/2 + 15(아이콘 반폭) + 8(간격) <= w - tagBoxW - 9
210
+ if (n.icon && n.tag) need = Math.max(need, 2 * n.tagBoxW + 64);
211
+ n.w = Math.ceil(need);
212
+ }
213
+ if (!n.h) n.h = n.sub ? 84 : (n.icon ? 76 : 54);
214
+ NMAP[n.id] = n;
215
+ }
216
+
217
+ // ── 그룹 박스: members 를 주면 그 노드들을 감싸도록 자동 계산 ──
218
+ var GPAD = 22;
219
+ for (var gi = 0; gi < S.groups.length; gi++) {
220
+ var g = S.groups[gi];
221
+ if (g.members && g.members.length) {
222
+ var minx = Infinity, miny = Infinity, maxx = -Infinity, maxy = -Infinity;
223
+ for (var mi = 0; mi < g.members.length; mi++) {
224
+ var mn = NMAP[g.members[mi]];
225
+ if (!mn) continue;
226
+ minx = Math.min(minx, mn.x); miny = Math.min(miny, mn.y);
227
+ maxx = Math.max(maxx, mn.x + mn.w); maxy = Math.max(maxy, mn.y + mn.h);
228
+ }
229
+ if (minx !== Infinity) {
230
+ g.x = minx - GPAD; g.y = miny - GPAD;
231
+ g.w = (maxx - minx) + GPAD * 2; g.h = (maxy - miny) + GPAD * 2;
232
+ }
233
+ }
234
+ if (g.x === undefined) continue;
235
+ el('rect', {x:g.x, y:g.y, width:g.w, height:g.h, rx:13, fill:g.fill,
236
+ stroke:g.stroke, 'stroke-width':1.4}, content);
237
+ // 좌상단 타이틀 칩 — 폭은 실측 합산
238
+ var gtw = measure(g.name, 'grp-title');
239
+ var chipW = 13 + gtw + 13;
240
+ if (g.sub) chipW += 9 + measure(g.sub, 'grp-sub');
241
+ el('rect', {x:g.x + 13, y:g.y - 13, width:chipW, height:26, rx:7,
242
+ fill:'#ffffff', stroke:g.stroke, 'stroke-width':1.2}, content);
243
+ txt(g.x + 26, g.y + 4, 'grp-title', g.name, 'start', null, content);
244
+ if (g.sub) txt(g.x + 26 + gtw + 9, g.y + 4, 'grp-sub', g.sub, 'start', null, content);
245
+ }
246
+
247
+ // ── 엣지 경로 계산 ──
248
+ function cx(n){ return n.x + n.w / 2; }
249
+ function cy(n){ return n.y + n.h / 2; }
250
+ function parseEnd(ref){
251
+ // ★ 그 이름의 노드가 실제로 있으면 그게 우선이다.
252
+ // 'svc:right' 라는 id 를 가진 노드가 있는데 이걸 'svc' 의 오른쪽 면으로 읽으면
253
+ // 감사도 깨끗한 채로 **엉뚱한 데 연결된 그림**이 나간다.
254
+ if (NMAP[ref]) return {id:ref, side:''};
255
+ var i = ref.lastIndexOf(':');
256
+ var side = '';
257
+ var id = ref;
258
+ if (i > 0) {
259
+ var maybe = ref.slice(i + 1);
260
+ if (maybe === 'left' || maybe === 'right' || maybe === 'top' || maybe === 'bottom') {
261
+ side = maybe; id = ref.slice(0, i);
262
+ }
263
+ }
264
+ return {id:id, side:side};
265
+ }
266
+ function autoSides(a, b){
267
+ var dx = cx(b) - cx(a), dy = cy(b) - cy(a);
268
+ if (Math.abs(dx) >= Math.abs(dy)) {
269
+ return dx >= 0 ? ['right','left'] : ['left','right'];
270
+ }
271
+ return dy >= 0 ? ['bottom','top'] : ['top','bottom'];
272
+ }
273
+ function laneOffset(i, c, extent){
274
+ if (c <= 1) return 0;
275
+ var step = Math.min(15, Math.max(7, (extent - 30) / (c - 1)));
276
+ return (i - (c - 1) / 2) * step;
277
+ }
278
+ function anchor(n, side, idx, cnt){
279
+ if (side === 'left' || side === 'right') {
280
+ return [side === 'left' ? n.x : n.x + n.w, cy(n) + laneOffset(idx, cnt, n.h)];
281
+ }
282
+ return [cx(n) + laneOffset(idx, cnt, n.w), side === 'top' ? n.y : n.y + n.h];
283
+ }
284
+ function isH(side){ return side === 'left' || side === 'right'; }
285
+ // off = 중간 꺾임선을 옆으로 밀어내는 양. 같은 두 열 사이를 지나는 선들이
286
+ // 전부 같은 중간 좌표를 쓰면 한 줄로 겹친다 — 그걸 벌리는 데 쓴다.
287
+ function route(a, as, b, bs, off){
288
+ var ax = a[0], ay = a[1], bx = b[0], by = b[1];
289
+ var d = off || 0;
290
+ if (isH(as) && isH(bs)) {
291
+ if (Math.abs(ay - by) < 0.6) return [[ax,ay],[bx,by]];
292
+ var mx = (ax + bx) / 2 + d;
293
+ return [[ax,ay],[mx,ay],[mx,by],[bx,by]];
294
+ }
295
+ if (!isH(as) && !isH(bs)) {
296
+ if (Math.abs(ax - bx) < 0.6) return [[ax,ay],[bx,by]];
297
+ var my = (ay + by) / 2 + d;
298
+ return [[ax,ay],[ax,my],[bx,my],[bx,by]];
299
+ }
300
+ if (isH(as)) return [[ax,ay],[bx,ay],[bx,by]];
301
+ return [[ax,ay],[ax,by],[bx,by]];
302
+ }
303
+ function rpath(pts, r){
304
+ r = r || 9;
305
+ var d = 'M' + pts[0][0] + ',' + pts[0][1];
306
+ for (var i = 1; i < pts.length - 1; i++) {
307
+ var p = pts[i-1], c = pts[i], nx = pts[i+1];
308
+ var inLen = Math.hypot(c[0]-p[0], c[1]-p[1]);
309
+ var outLen = Math.hypot(nx[0]-c[0], nx[1]-c[1]);
310
+ var rr = Math.min(r, inLen/2, outLen/2);
311
+ // ★ 축별 Math.sign 으로 r 만큼 물리면 직각에서는 맞지만 **대각선에서는 원래
312
+ // 선을 벗어난다.** points=[[0,0],[100,1],[200,2]] 이면 거의 y=1 인 선이
313
+ // 실제로는 y=-8~10 까지 벌어진다(감사는 원본 선분을 보므로 못 잡는다).
314
+ // 방향 단위벡터로 물려야 어떤 각도에서도 선 위에 남는다.
315
+ var iux = inLen ? (c[0]-p[0]) / inLen : 0, iuy = inLen ? (c[1]-p[1]) / inLen : 0;
316
+ var oux = outLen ? (nx[0]-c[0]) / outLen : 0, ouy = outLen ? (nx[1]-c[1]) / outLen : 0;
317
+ var ix = c[0] - iux * rr, iy = c[1] - iuy * rr;
318
+ var ox = c[0] + oux * rr, oy = c[1] + ouy * rr;
319
+ d += ' L' + ix + ',' + iy + ' Q' + c[0] + ',' + c[1] + ' ' + ox + ',' + oy;
320
+ }
321
+ d += ' L' + pts[pts.length-1][0] + ',' + pts[pts.length-1][1];
322
+ return d;
323
+ }
324
+
325
+ // 같은 노드의 같은 면에서 여러 선이 나가면 등간격으로 벌린다 (팬아웃 버스).
326
+ // ★ 키가 사용자(모델)가 준 노드 id 라서 사전은 전부 Object.create(null) 로 만든다.
327
+ // 보통 객체면 id 가 '__proto__' 일 때 대입이 키가 아니라 프로토타입을 바꿔
328
+ // 조회가 통째로 어긋난다.
329
+ var lanes = Object.create(null);
330
+ var plan = [];
331
+ for (var ei = 0; ei < S.edges.length; ei++) {
332
+ var e = S.edges[ei];
333
+ if (e.points && e.points.length >= 2) { plan.push({e:e, pts:e.points, a:null, b:null}); continue; }
334
+ var fa = parseEnd(e.from), fb = parseEnd(e.to);
335
+ var na = NMAP[fa.id], nb = NMAP[fb.id];
336
+ if (!na || !nb) { plan.push({e:e, pts:null, a:null, b:null, bad:(na?e.to:e.from)}); continue; }
337
+ var sides = autoSides(na, nb);
338
+ var as = fa.side || sides[0], bs = fb.side || sides[1];
339
+ var ka = fa.id + '|' + as, kb = fb.id + '|' + bs;
340
+ lanes[ka] = (lanes[ka] || 0) + 1;
341
+ lanes[kb] = (lanes[kb] || 0) + 1;
342
+ plan.push({e:e, pts:null, a:{n:na, s:as, k:ka}, b:{n:nb, s:bs, k:kb}});
343
+ }
344
+ var seen = Object.create(null);
345
+ var auto = [];
346
+ for (var pj = 0; pj < plan.length; pj++) {
347
+ var it = plan[pj];
348
+ if (it.pts || !it.a) continue;
349
+ seen[it.a.k] = (seen[it.a.k] === undefined) ? 0 : seen[it.a.k] + 1;
350
+ seen[it.b.k] = (seen[it.b.k] === undefined) ? 0 : seen[it.b.k] + 1;
351
+ it.pa = anchor(it.a.n, it.a.s, seen[it.a.k], lanes[it.a.k]);
352
+ it.pb = anchor(it.b.n, it.b.s, seen[it.b.k], lanes[it.b.k]);
353
+ auto.push(it);
354
+ }
355
+
356
+ // ★ 같은 두 열 사이를 지나는 선들은 중간 꺾임 좌표가 전부 같아서 한 줄로 겹친다.
357
+ // 노드 배치가 규칙적일수록(= 보기 좋게 그릴수록) 더 잘 생긴다.
358
+ // 실제로 11노드 그림에서 세로선 3개가 겹쳤다. 버킷별로 등간격으로 벌린다.
359
+ function midKeyOf(it){
360
+ var hA = isH(it.a.s), hB = isH(it.b.s);
361
+ if (hA && hB) {
362
+ if (Math.abs(it.pa[1] - it.pb[1]) < 0.6) return ''; // 일직선 — 꺾임 없음
363
+ return 'h' + Math.round(((it.pa[0] + it.pb[0]) / 2) / 8);
364
+ }
365
+ if (!hA && !hB) {
366
+ if (Math.abs(it.pa[0] - it.pb[0]) < 0.6) return '';
367
+ return 'v' + Math.round(((it.pa[1] + it.pb[1]) / 2) / 8);
368
+ }
369
+ return ''; // ㄴ자 — 중간선이 없다
370
+ }
371
+ var midCount = Object.create(null);
372
+ for (var m1 = 0; m1 < auto.length; m1++) {
373
+ auto[m1].mk = midKeyOf(auto[m1]);
374
+ if (auto[m1].mk) midCount[auto[m1].mk] = (midCount[auto[m1].mk] || 0) + 1;
375
+ }
376
+ var midSeen = Object.create(null);
377
+ for (var m2 = 0; m2 < auto.length; m2++) {
378
+ var im = auto[m2];
379
+ var off = 0;
380
+ if (im.mk) {
381
+ midSeen[im.mk] = (midSeen[im.mk] === undefined) ? 0 : midSeen[im.mk] + 1;
382
+ off = laneOffset(midSeen[im.mk], midCount[im.mk], 200);
383
+ }
384
+ im.pts = route(im.pa, im.a.s, im.pb, im.b.s, off);
385
+ }
386
+
387
+ var badRefs = [];
388
+ for (var pk = 0; pk < plan.length; pk++) {
389
+ var itm = plan[pk];
390
+ if (itm.bad) { badRefs.push(itm.bad); continue; }
391
+ if (!itm.pts) continue;
392
+ var pln = planeOf(itm.e.plane);
393
+ // ★ itm.e.plane 을 그대로 쓰면 없는 key 일 때 marker 를 못 찾아 화살촉이 조용히
394
+ // 사라진다. planeOf() 가 되돌려준 **실재하는** 평면의 key 를 쓴다.
395
+ var pathEl = el('path', {d:rpath(itm.pts), fill:'none', stroke:pln.color,
396
+ 'stroke-width':1.8, 'marker-end':'url(#arr-'+pln.key+')'}, content);
397
+ if (pln.dash) pathEl.setAttribute('stroke-dasharray', pln.dash);
398
+ }
399
+
400
+ // ── 노드 (엣지 위에 올라간다) ──
401
+ function drawIcon(name, cxp, cyp, color){
402
+ var prims = S.icons[name];
403
+ if (!prims) return;
404
+ var size = 30;
405
+ el('rect', {x:cxp - size/2, y:cyp - size/2, width:size, height:size, rx:9, fill:color}, content);
406
+ var s = 0.86;
407
+ var g = el('g', {transform:'translate(' + (cxp - 12*s) + ',' + (cyp - 12*s) + ') scale(' + s + ')',
408
+ fill:'none', stroke:'#ffffff', 'stroke-width':2,
409
+ 'stroke-linecap':'round', 'stroke-linejoin':'round'}, content);
410
+ for (var q = 0; q < prims.length; q++) {
411
+ var pr = prims[q];
412
+ if (pr[0] === 'r') el('rect', {x:pr[1], y:pr[2], width:pr[3], height:pr[4], rx:pr[5]}, g);
413
+ else if (pr[0] === 'c') el('circle', {cx:pr[1], cy:pr[2], r:pr[3]}, g);
414
+ else if (pr[0] === 'e') el('ellipse', {cx:pr[1], cy:pr[2], rx:pr[3], ry:pr[4]}, g);
415
+ else if (pr[0] === 'd') el('circle', {cx:pr[1], cy:pr[2], r:pr[3], fill:'#ffffff', stroke:'none'}, g);
416
+ else if (pr[0] === 'l') el('line', {x1:pr[1], y1:pr[2], x2:pr[3], y2:pr[4]}, g);
417
+ else if (pr[0] === 'p') el('path', {d:pr[1]}, g);
418
+ }
419
+ }
420
+ var compressed = [], nodeTexts = [];
421
+ function fit(t, maxW){
422
+ if (t.getBBox().width > maxW) {
423
+ t.setAttribute('textLength', maxW);
424
+ t.setAttribute('lengthAdjust', 'spacingAndGlyphs');
425
+ compressed.push(t.textContent);
426
+ }
427
+ }
428
+ for (var nj = 0; nj < S.nodes.length; nj++) {
429
+ var nd = S.nodes[nj];
430
+ el('rect', {x:nd.x, y:nd.y, width:nd.w, height:nd.h, rx:11, fill:'#ffffff',
431
+ stroke:'#d1d5db', 'stroke-width':1.3,
432
+ filter:'drop-shadow(0 1.5px 3px rgba(15,23,42,.09))'}, content);
433
+ var ncx = nd.x + nd.w / 2;
434
+ var t1y, iy;
435
+ if (nd.sub) { iy = nd.y + 27; t1y = nd.y + 59; }
436
+ else if (nd.icon) { iy = nd.y + 25; t1y = nd.y + 58; }
437
+ else { t1y = nd.y + nd.h / 2 + 5; iy = 0; }
438
+ if (nd.icon) drawIcon(nd.icon, ncx, iy, nd.iconColor);
439
+ var t1 = txt(ncx, t1y, 'n-title', nd.title, 'middle', null, content);
440
+ fit(t1, nd.w - 18); nodeTexts.push([t1, nd]);
441
+ if (nd.sub) {
442
+ var t2 = txt(ncx, nd.y + 76, 'n-sub', nd.sub, 'middle', null, content);
443
+ fit(t2, nd.w - 16); nodeTexts.push([t2, nd]);
444
+ }
445
+ if (nd.tag) {
446
+ var tgw = nd.tagBoxW || (measure(nd.tag, 'tag-t') + 16);
447
+ var tgx = nd.x + nd.w - tgw - 9, tgy = nd.y + 9;
448
+ el('rect', {x:tgx, y:tgy, width:tgw, height:19, rx:6, fill:nd.tagColor}, content);
449
+ txt(tgx + tgw/2, tgy + 13.5, 'tag-t', nd.tag, 'middle', null, content);
450
+ }
451
+ }
452
+
453
+ // ── 라벨 (맨 위. 흰 테두리로 선 위에서도 읽히게) ──
454
+ // ★ 라벨을 '가장 긴 구간'에만 두면 대개 노드 바로 옆 첫 구간에 붙는다.
455
+ // 거기는 카드와 다른 선이 몰리는 자리라 실제로 라벨끼리 겹쳤다(실물 확인).
456
+ // 꺾임이 있는 경로는 가운데 구간이 비어 있는 게 보통이라 그쪽을 먼저 본다.
457
+ var labelEls = [];
458
+ var placedBoxes = [];
459
+
460
+ function segsOf(pts){
461
+ var out = [];
462
+ for (var i = 0; i < pts.length - 1; i++) out.push([pts[i], pts[i+1]]);
463
+ return out;
464
+ }
465
+ function segLen(sg){ return Math.hypot(sg[1][0]-sg[0][0], sg[1][1]-sg[0][1]); }
466
+
467
+ // ★ 라벨 자리는 한 곳으로 정하지 않는다.
468
+ // '가장 긴 구간' 하나만 쓰면 대개 노드에 붙은 첫 구간에 놓여 카드 위로 올라가고,
469
+ // 같은 노드에서 갈라지는 두 선의 라벨이 서로 겹친다(둘 다 실물로 확인했다).
470
+ // 그래서 후보를 여러 개 만들어 두고, 겹치지 않는 첫 자리를 고른다.
471
+ // 전부 겹치면 첫 후보로 두고 자가감사가 보고한다 — 조용히 넘기지 않는다.
472
+ function labelCandidates(pts){
473
+ var segs = segsOf(pts);
474
+ var order = [];
475
+ if (segs.length >= 3) order.push(segs[Math.floor(segs.length / 2)]);
476
+ var sorted = segs.slice().sort(function(a, b){ return segLen(b) - segLen(a); });
477
+ for (var i = 0; i < sorted.length; i++) order.push(sorted[i]);
478
+
479
+ var out = [];
480
+ for (var j = 0; j < order.length; j++) {
481
+ var sg = order[j];
482
+ if (segLen(sg) < 26) continue;
483
+ var mx = (sg[0][0] + sg[1][0]) / 2, my = (sg[0][1] + sg[1][1]) / 2;
484
+ if (Math.abs(sg[0][1] - sg[1][1]) < 0.6) { // 수평 구간 — 위/아래
485
+ out.push([mx, my - 7, 'middle']);
486
+ out.push([mx, my + 16, 'middle']);
487
+ } else { // 수직 구간 — 왼쪽/오른쪽
488
+ out.push([mx - 8, my, 'end']);
489
+ out.push([mx + 8, my, 'start']);
490
+ }
491
+ }
492
+ if (!out.length) out.push([pts[0][0], pts[0][1] - 7, 'middle']);
493
+ return out;
494
+ }
495
+ function place(t, x, y, anchor){
496
+ t.setAttribute('x', x); t.setAttribute('y', y); t.setAttribute('text-anchor', anchor);
497
+ }
498
+ function labelCollides(box){
499
+ for (var i = 0; i < S.nodes.length; i++) {
500
+ var n = S.nodes[i];
501
+ if (boxesOverlap(box, {x:n.x, y:n.y, w:n.w, h:n.h})) return true;
502
+ }
503
+ for (var j = 0; j < placedBoxes.length; j++) {
504
+ if (boxesOverlap(box, placedBoxes[j])) return true;
505
+ }
506
+ return false;
507
+ }
508
+
509
+ for (var pm = 0; pm < plan.length; pm++) {
510
+ var itl = plan[pm];
511
+ if (!itl.pts || !itl.e.label) continue;
512
+ var t = txt(0, 0, 'e-label', itl.e.label, 'middle', planeOf(itl.e.plane).color, content);
513
+ if (itl.e.labelAt) {
514
+ place(t, itl.e.labelAt[0], itl.e.labelAt[1], itl.e.labelAnchor || 'middle');
515
+ } else {
516
+ var cands = labelCandidates(itl.pts);
517
+ var settled = false;
518
+ for (var ci = 0; ci < cands.length; ci++) {
519
+ place(t, cands[ci][0], cands[ci][1], cands[ci][2]);
520
+ if (!labelCollides(boxOf(t))) { settled = true; break; }
521
+ }
522
+ if (!settled) place(t, cands[0][0], cands[0][1], cands[0][2]);
523
+ }
524
+ placedBoxes.push(boxOf(t));
525
+ labelEls.push([t, itl.e.label]);
526
+ }
527
+
528
+ // ── 캔버스 되맞춤: 그린 결과에 맞춰 크기와 위치를 정한다 (잘림 방지) ──
529
+ svg.removeChild(scratch);
530
+ var bb = content.getBBox();
531
+ var dx = PAD - bb.x, dy = headerBottom + GAP - bb.y;
532
+ content.setAttribute('transform', 'translate(' + dx + ',' + dy + ')');
533
+ var W = Math.ceil(Math.max(bb.width + PAD*2, headerW + PAD*2, 720));
534
+ var H = Math.ceil(headerBottom + GAP + bb.height + PAD);
535
+
536
+ // ★★ 크기를 **설정하기 전에** 본다. 이 순서가 전부다.
537
+ // 예전엔 여기서 그냥 크기를 박고, 상한 검사는 바깥(render/index.ts)에서 DOM 을
538
+ // 받은 뒤에 했다. 그런데 브라우저는 width/height 를 받는 순간 그만한 레이아웃·
539
+ // 페인트 표면을 잡는다 — 실측으로 40068×40150 을 설정했더니 **0.2초 만에 43GB** 를
540
+ // 잡았다(48GB 기계가 그대로 멈췄다). 바깥에서 아무리 빨리 막아도 이미 늦는다.
541
+ // 그래서 캔버스는 여기서 거르고, 넘으면 크기를 아예 설정하지 않는다.
542
+ if (W > S.maxDim || H > S.maxDim || W * H > S.maxArea) {
543
+ document.title = 'AUDIT_TOO_BIG ' + JSON.stringify({w:W, h:H, maxDim:S.maxDim, maxArea:S.maxArea});
544
+ return;
545
+ }
546
+
547
+ svg.setAttribute('width', W); svg.setAttribute('height', H);
548
+ svg.setAttribute('viewBox', '0 0 ' + W + ' ' + H);
549
+ document.getElementById('wrap').style.width = W + 'px';
550
+ document.getElementById('wrap').style.height = H + 'px';
551
+
552
+ // ── 자가감사 ──
553
+ var over = [];
554
+ for (var oi = 0; oi < nodeTexts.length; oi++) {
555
+ var tt = nodeTexts[oi][0], on = nodeTexts[oi][1];
556
+ var b = tt.getBBox();
557
+ if (b.x < on.x + 4) over.push('좌: ' + tt.textContent);
558
+ else if (b.x + b.width > on.x + on.w - 4) over.push('우: ' + tt.textContent);
559
+ else if (b.y + b.height > on.y + on.h - 1) over.push('하: ' + tt.textContent);
560
+ }
561
+ function segs(pts){
562
+ var out = [];
563
+ for (var i = 0; i < pts.length - 1; i++) out.push([pts[i][0], pts[i][1], pts[i+1][0], pts[i+1][1]]);
564
+ return out;
565
+ }
566
+ // ★ 처음엔 직각 선분만 검사했다. 그러면 points 로 직접 준 **대각선**이 노드를
567
+ // 그대로 지나가도 감사를 통과한다. 선분-사각형 교차를 일반적으로 푼다
568
+ // (Liang-Barsky). 가장자리에 붙어 지나가는 건 문제가 아니므로 3px 안쪽으로 좁힌다.
569
+ function hitsRect(s, r){
570
+ var M = 3;
571
+ var minX = r.x + M, maxX = r.x + r.w - M;
572
+ var minY = r.y + M, maxY = r.y + r.h - M;
573
+ if (maxX <= minX || maxY <= minY) return false;
574
+ var dx = s[2] - s[0], dy = s[3] - s[1];
575
+ var p = [-dx, dx, -dy, dy];
576
+ var q = [s[0] - minX, maxX - s[0], s[1] - minY, maxY - s[1]];
577
+ var t0 = 0, t1 = 1;
578
+ for (var i = 0; i < 4; i++) {
579
+ if (Math.abs(p[i]) < 1e-9) { if (q[i] < 0) return false; continue; }
580
+ var t = q[i] / p[i];
581
+ if (p[i] < 0) { if (t > t1) return false; if (t > t0) t0 = t; }
582
+ else { if (t < t0) return false; if (t < t1) t1 = t; }
583
+ }
584
+ return t0 < t1;
585
+ }
586
+ var cross = [];
587
+ for (var ci = 0; ci < plan.length; ci++) {
588
+ var ic = plan[ci];
589
+ if (!ic.pts) continue;
590
+ // ★ 여기도 Object.create(null) 이어야 한다. 보통 객체면 own['constructor'] 가
591
+ // 상속 프로퍼티라 **항상 참**이라, id 가 'constructor' 인 노드는 관통 검사에서
592
+ // 통째로 빠진다. NMAP 등은 고쳤는데 이것만 남아 있었다.
593
+ var own = Object.create(null);
594
+ if (ic.a) { own[ic.a.n.id] = 1; own[ic.b.n.id] = 1; }
595
+ var ss = segs(ic.pts);
596
+ for (var cj = 0; cj < S.nodes.length; cj++) {
597
+ var cn = S.nodes[cj];
598
+ if (own[cn.id]) continue;
599
+ for (var ck = 0; ck < ss.length; ck++) {
600
+ if (hitsRect(ss[ck], cn)) {
601
+ cross.push((ic.e.label || ('선#' + ci)) + ' → 노드 ' + (cn.title || cn.id) + ' 관통');
602
+ ck = ss.length;
603
+ }
604
+ }
605
+ }
606
+ }
607
+ // ★ 처음엔 수평-수평, 수직-수직만 비교했다. 그러면 똑같은 **대각선** 두 개가
608
+ // 완전히 포개져도 통과한다. 각도에 상관없이 '평행하고 같은 직선 위이며 구간이
609
+ // 겹치는가'로 본다.
610
+ // ★ 한쪽 방향만 재면 '짧은 선을 기준으로 보면 겹침, 긴 선을 기준으로 보면 아님'
611
+ // 같은 일이 생긴다(엣지 순서에 따라 답이 달라진다). 양방향으로 본다.
612
+ function collinearOverlap(A, B){
613
+ return collinearOverlapOneWay(A, B) || collinearOverlapOneWay(B, A);
614
+ }
615
+ function collinearOverlapOneWay(A, B){
616
+ var ax = A[2]-A[0], ay = A[3]-A[1];
617
+ var bx = B[2]-B[0], by = B[3]-B[1];
618
+ var la = Math.hypot(ax, ay), lb = Math.hypot(bx, by);
619
+ if (la < 1 || lb < 1) return false;
620
+ // 평행? (단위벡터 외적이 거의 0)
621
+ if (Math.abs((ax*by - ay*bx) / (la*lb)) > 0.02) return false;
622
+ // 같은 직선 위? ★ B 의 **양 끝**을 다 본다. 시작점 하나만 재면 거의 평행한
623
+ // 선분에서 점 순서를 뒤집는 것만으로 답이 달라진다(실측으로 확인된 오탐).
624
+ var ux = ax/la, uy = ay/la;
625
+ var d0 = Math.abs((B[0]-A[0]) * uy - (B[1]-A[1]) * ux);
626
+ var d1 = Math.abs((B[2]-A[0]) * uy - (B[3]-A[1]) * ux);
627
+ if (Math.max(d0, d1) > 3) return false;
628
+ // 구간이 실제로 겹치는가 (A 방향으로 투영)
629
+ function proj(px, py){ return ((px-A[0]) * ax + (py-A[1]) * ay) / la; }
630
+ var a0 = 0, a1 = la;
631
+ var b0 = proj(B[0], B[1]), b1 = proj(B[2], B[3]);
632
+ var lo = Math.max(Math.min(a0,a1), Math.min(b0,b1));
633
+ var hi = Math.min(Math.max(a0,a1), Math.max(b0,b1));
634
+ return hi - lo > 20;
635
+ }
636
+ var overlap = [];
637
+ for (var oa = 0; oa < plan.length; oa++) {
638
+ if (!plan[oa].pts) continue;
639
+ var sa = segs(plan[oa].pts);
640
+ for (var ob = oa + 1; ob < plan.length; ob++) {
641
+ if (!plan[ob].pts) continue;
642
+ var sb = segs(plan[ob].pts);
643
+ var hit = false;
644
+ for (var x1 = 0; x1 < sa.length && !hit; x1++) {
645
+ for (var x2 = 0; x2 < sb.length && !hit; x2++) {
646
+ if (collinearOverlap(sa[x1], sb[x2])) hit = true;
647
+ }
648
+ }
649
+ if (hit) overlap.push((plan[oa].e.label || ('선#'+oa)) + ' ↔ ' + (plan[ob].e.label || ('선#'+ob)));
650
+ }
651
+ }
652
+ var collide = [];
653
+ for (var ka = 0; ka < S.nodes.length; ka++) {
654
+ for (var kb2 = ka + 1; kb2 < S.nodes.length; kb2++) {
655
+ var A2 = S.nodes[ka], B2 = S.nodes[kb2];
656
+ if (A2.x < B2.x + B2.w && B2.x < A2.x + A2.w && A2.y < B2.y + B2.h && B2.y < A2.y + A2.h) {
657
+ collide.push(A2.title + ' ↔ ' + B2.title);
658
+ }
659
+ }
660
+ }
661
+ for (var bi = 0; bi < badRefs.length; bi++) over.push('없는 노드 참조: ' + badRefs[bi]);
662
+
663
+ // 아이콘과 배지가 겹치는지 (폭을 직접 지정한 경우에만 생길 수 있다)
664
+ for (var gi2 = 0; gi2 < S.nodes.length; gi2++) {
665
+ var gn = S.nodes[gi2];
666
+ if (!gn.tag) continue;
667
+ var tagLeft = gn.x + gn.w - (gn.tagBoxW || 0) - 9;
668
+ // 폭을 직접 준 경우 배지가 카드 왼쪽으로 삐져나갈 수 있다. 아이콘이 없으면
669
+ // 아이콘 침범 검사만으로는 이걸 못 잡는다.
670
+ if (tagLeft < gn.x + 6) {
671
+ collide.push(gn.title + ': 배지가 카드를 벗어남 (폭을 넓히세요)');
672
+ continue;
673
+ }
674
+ if (!gn.icon) continue;
675
+ var iconRight = gn.x + gn.w / 2 + 15;
676
+ if (tagLeft < iconRight + 4) collide.push(gn.title + ': 배지가 아이콘을 침범 (폭을 넓히세요)');
677
+ }
678
+
679
+ // 라벨은 선 위에 얹히는 건 괜찮지만 카드 위나 다른 라벨 위에 오면 못 읽는다
680
+ var label = [];
681
+ var boxes = [];
682
+ for (var lb = 0; lb < labelEls.length; lb++) boxes.push(boxOf(labelEls[lb][0]));
683
+ for (var lb2 = 0; lb2 < boxes.length; lb2++) {
684
+ for (var ln = 0; ln < S.nodes.length; ln++) {
685
+ var rn = S.nodes[ln];
686
+ if (boxesOverlap(boxes[lb2], {x:rn.x, y:rn.y, w:rn.w, h:rn.h})) {
687
+ label.push("'" + labelEls[lb2][1] + "' 가 노드 " + rn.title + ' 위에 겹침');
688
+ break;
689
+ }
690
+ }
691
+ for (var lc = lb2 + 1; lc < boxes.length; lc++) {
692
+ if (boxesOverlap(boxes[lb2], boxes[lc])) {
693
+ label.push("'" + labelEls[lb2][1] + "' ↔ '" + labelEls[lc][1] + "' 라벨끼리 겹침");
694
+ }
695
+ }
696
+ }
697
+
698
+ document.title = 'AUDIT ' + JSON.stringify({
699
+ w:W, h:H, over:over, compressed:compressed, cross:cross,
700
+ overlap:overlap, collide:collide, label:label
701
+ });
702
+ } catch (err) {
703
+ // ★ 조용히 죽으면 바깥에서는 '스크립트가 안 돌았다'는 말밖에 못 한다.
704
+ // 실제 예외 메시지를 제목에 실어 원인을 그대로 올려보낸다.
705
+ document.title = 'AUDIT_ERROR ' + ((err && err.message) ? err.message : String(err));
706
+ }
707
+ })();
708
+ `;
709
+ export function buildDiagramHtml(spec) {
710
+ const resolved = resolve(spec);
711
+ return [
712
+ '<!DOCTYPE html>',
713
+ '<html lang="ko"><head><meta charset="utf-8"><title>rendering…</title>',
714
+ `<style>${STYLE}</style>`,
715
+ '</head><body>',
716
+ '<div id="wrap"><svg id="cv" xmlns="http://www.w3.org/2000/svg"></svg></div>',
717
+ `<script type="application/json" id="spec">${safeJson(resolved)}</script>`,
718
+ `<script>${SCRIPT}</script>`,
719
+ '</body></html>',
720
+ ].join('\n');
721
+ }
722
+ /** `--dump-dom` 출력에서 자가감사 결과만 뽑는다. */
723
+ export function parseAudit(dom) {
724
+ const tooBig = /<title>AUDIT_TOO_BIG (\{.*?\})<\/title>/s.exec(dom);
725
+ if (tooBig?.[1]) {
726
+ const size = JSON.parse(tooBig[1]);
727
+ throw new Error(`그림이 너무 큽니다: ${size.w}×${size.h}px ` +
728
+ `(상한 ${MAX_DIM}px / 면적 ${(MAX_AREA / 1_000_000).toFixed(0)}백만px).\n` +
729
+ '좌표 간격을 줄이세요 — 캔버스는 내용 bbox 로 자동 계산되므로 ' +
730
+ '노드를 멀리 떨어뜨릴수록 그대로 커집니다.');
731
+ }
732
+ const failed = /<title>AUDIT_ERROR (.*?)<\/title>/s.exec(dom);
733
+ if (failed?.[1])
734
+ throw new Error(`그림을 그리다 실패했습니다: ${failed[1]}`);
735
+ const match = /<title>AUDIT (\{.*?\})<\/title>/s.exec(dom);
736
+ if (!match?.[1]) {
737
+ throw new Error('렌더 결과를 읽지 못했습니다 — 페이지 스크립트가 끝까지 돌지 않았습니다.\n' +
738
+ '입력 좌표에 숫자가 아닌 값이 섞였을 수 있습니다.');
739
+ }
740
+ // 스크립트가 만든 JSON 이라 형태는 보장되지만, 파싱 실패를 조용히 넘기지는 않는다.
741
+ return JSON.parse(match[1]);
742
+ }
743
+ /** 사람이 읽는 감사 요약. 문제가 없으면 짧게 끝난다. */
744
+ export function formatAudit(a) {
745
+ const lines = [];
746
+ const add = (label, items) => {
747
+ if (items.length)
748
+ lines.push(`- ${label} ${items.length}건: ${items.join(' / ')}`);
749
+ };
750
+ add('글자 삐져나옴', a.over);
751
+ add('자간 압축(노드 폭을 넓히세요)', a.compressed);
752
+ add('선이 노드를 관통', a.cross);
753
+ add('선끼리 겹침', a.overlap);
754
+ add('노드/배지 겹침', a.collide);
755
+ add('라벨 겹침', a.label);
756
+ if (!lines.length)
757
+ return '자가감사 통과 — 삐져나옴·압축·관통·선겹침·라벨겹침 0건';
758
+ return `⚠️ 자가감사에서 걸린 것:\n${lines.join('\n')}`;
759
+ }
760
+ export const TONE_LIST = Object.keys(TONES).join(', ');
761
+ //# sourceMappingURL=page.js.map