@milcho0604/velog-mcp 0.6.1 → 0.7.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.
@@ -0,0 +1,946 @@
1
+ /**
2
+ * 시퀀스 다이어그램 — "누가 누구에게 무엇을 언제" 만 받고 배치는 전부 계산한다.
3
+ *
4
+ * ## 왜 velog_render_diagram 과 도구를 나눴나
5
+ *
6
+ * 구성도는 **좌표계가 자유**다. 모델이 x 와 y 를 정하고 렌더러는 폭과 꺾임만 맞춘다.
7
+ * 시퀀스는 반대다 — 열은 참가자, 행은 시간이라 좌표가 의미를 갖는다.
8
+ * 같은 도구에 얹으면 모델이 매번 y 를 손으로 계산하게 되는데, 메시지 하나만
9
+ * 끼워 넣어도 그 아래 전부가 틀어진다. 실제로 기존 도구로 그려보고 확인한 것이다.
10
+ *
11
+ * 그래서 이 도구는 좌표를 **받지 않는다.** 참가자 목록과 순서 있는 메시지 목록만
12
+ * 받고 열 간격과 행 높이, 활성 막대, 프래그먼트 상자를 전부 실측으로 정한다.
13
+ *
14
+ * ## 겹치지 않는다는 보장이 어디서 오나
15
+ *
16
+ * 글자를 줄여 맞추지 않는다. 반대로 **글자에 맞춰 자리를 넓힌다.**
17
+ * - 라벨이 두 열 사이에 안 들어가면 그 구간의 열 간격을 늘린다
18
+ * - 자기호출 라벨이 옆 열을 침범하면 그 옆 간격을 늘린다
19
+ * - 라벨이 길면 줄바꿈하고, 줄이 늘어난 만큼 그 행을 높인다
20
+ * 그러고 나서 자가감사가 결과를 다시 잰다. 감사는 내 계산이 틀렸을 때 걸리라고
21
+ * 있는 것이지, 통과 도장이 아니다.
22
+ */
23
+ import { ICONS } from "./icons.js";
24
+ import { TONES, tone } from "./tones.js";
25
+ /**
26
+ * 메시지 종류.
27
+ * call — 동기 호출 (실선 + 채운 화살촉)
28
+ * async — 비동기 발행 (실선 + 열린 화살촉)
29
+ * return — 응답 (점선 + 열린 화살촉). 활성 막대를 닫는다
30
+ * note — 선이 아니라 설명 상자. 순서 안에 끼워 넣는다
31
+ */
32
+ export const SEQ_KINDS = ['call', 'async', 'return', 'note'];
33
+ /**
34
+ * 캔버스 상한. page.ts 와 같은 값이고 같은 이유다 — 크기를 **설정하기 전에**
35
+ * 페이지 안에서 거른다. 바깥에서 막으면 이미 브라우저가 표면을 잡은 뒤라 늦는다.
36
+ */
37
+ export const MAX_DIM = 6000;
38
+ export const MAX_AREA = 9_000_000;
39
+ /** 종류별 기본 생김새. 색을 메시지마다 고르게 하지 않는 이유는 tones.ts 주석과 같다. */
40
+ const KIND_STYLE = {
41
+ call: { toneName: 'indigo', dash: '', head: 'solid', name: '호출' },
42
+ async: { toneName: 'teal', dash: '', head: 'open', name: '비동기' },
43
+ return: { toneName: 'slate', dash: '5 4', head: 'open', name: '응답' },
44
+ note: { toneName: 'amber', dash: '', head: 'none', name: '설명' },
45
+ };
46
+ /**
47
+ * 여기서 걸러 **던지는** 것과 자가감사로 넘기는 것을 나눈 기준.
48
+ *
49
+ * 없는 참가자를 가리키거나 프래그먼트 범위가 뒤집힌 건 그려봐야 의미가 없다.
50
+ * 그림 대신 무엇이 틀렸는지 말해주는 편이 낫다. 반대로 '겹쳤다, 삐져나왔다'는
51
+ * 그려보기 전에는 알 수 없으므로 브라우저에서 재고 감사로 올린다.
52
+ */
53
+ function resolve(spec) {
54
+ const participants = spec.participants.map((p, i) => ({
55
+ id: p.id ?? `p${i}`,
56
+ name: p.name,
57
+ sub: p.sub ?? '',
58
+ icon: p.icon && ICONS[p.icon] ? p.icon : '',
59
+ iconColor: tone(p.icon_tone, 'slate').solid,
60
+ tag: p.tag ?? '',
61
+ tagColor: tone(p.tag_tone, 'slate').solid,
62
+ }));
63
+ const index = new Map();
64
+ participants.forEach((p, i) => {
65
+ if (index.has(p.id)) {
66
+ throw new Error(`참가자 id 가 겹칩니다: ${p.id} (생략하면 p0, p1… 이 자동 부여됩니다)`);
67
+ }
68
+ index.set(p.id, i);
69
+ });
70
+ const at = (ref, where) => {
71
+ const found = index.get(ref);
72
+ if (found === undefined) {
73
+ const known = participants.map((p) => p.id).join(', ');
74
+ throw new Error(`${where}: 없는 참가자 '${ref}' 를 가리킵니다. 있는 참가자: ${known}`);
75
+ }
76
+ return found;
77
+ };
78
+ const messages = spec.messages.map((m, i) => {
79
+ const kind = m.kind ?? 'call';
80
+ const a = at(m.from, `메시지 #${i + 1} 의 from`);
81
+ // note 만 to 를 생략할 수 있다. 나머지는 from===to 여야 자기호출이 된다.
82
+ const b = m.to === undefined ? a : at(m.to, `메시지 #${i + 1} 의 to`);
83
+ if (kind !== 'note' && m.to === undefined) {
84
+ throw new Error(`메시지 #${i + 1}: note 가 아니면 to 가 필요합니다 ` +
85
+ '(자기 자신을 부르는 것이라면 from 과 같은 값을 주세요).');
86
+ }
87
+ return { kind, a, b, label: m.label ?? '' };
88
+ });
89
+ const frags = (spec.fragments ?? []).map((f, i) => {
90
+ if (!Number.isInteger(f.from) || !Number.isInteger(f.to)) {
91
+ throw new Error(`프래그먼트 #${i + 1}: from 과 to 는 메시지 번호(정수)여야 합니다.`);
92
+ }
93
+ if (f.from > f.to) {
94
+ throw new Error(`프래그먼트 #${i + 1}: from(${f.from}) 이 to(${f.to}) 보다 뒤입니다.`);
95
+ }
96
+ if (f.from < 0 || f.to >= messages.length) {
97
+ throw new Error(`프래그먼트 #${i + 1}: 범위 ${f.from}~${f.to} 가 메시지 범위(0~${messages.length - 1}) 밖입니다.`);
98
+ }
99
+ const t = tone(f.tone, 'slate');
100
+ return {
101
+ kind: f.kind,
102
+ label: f.label ?? '',
103
+ from: f.from,
104
+ to: f.to,
105
+ fill: t.fill,
106
+ stroke: t.stroke,
107
+ solid: t.solid,
108
+ };
109
+ });
110
+ // ★ 부분 교차는 그릴 수가 없다. 상자 둘이 서로를 반씩 물면 어느 쪽을 안쪽으로
111
+ // 그려도 한쪽 상자가 제 메시지를 못 감싼다. 감사로 잡으면 '그려놓고 실패'라
112
+ // 여기서 막는다.
113
+ for (let i = 0; i < frags.length; i++) {
114
+ for (let j = i + 1; j < frags.length; j++) {
115
+ const x = frags[i];
116
+ const y = frags[j];
117
+ if (!x || !y)
118
+ continue;
119
+ const disjoint = x.to < y.from || y.to < x.from;
120
+ const nested = (x.from <= y.from && y.to <= x.to) || (y.from <= x.from && x.to <= y.to);
121
+ if (!disjoint && !nested) {
122
+ throw new Error(`프래그먼트 ${x.kind}(${x.from}~${x.to}) 와 ${y.kind}(${y.from}~${y.to}) 가 ` +
123
+ '서로 어긋나게 겹칩니다. 완전히 포개거나 완전히 떨어져야 합니다.');
124
+ }
125
+ }
126
+ }
127
+ // 실제로 쓰인 종류만 범례에 싣는다.
128
+ const used = new Set(messages.map((m) => m.kind));
129
+ const kinds = SEQ_KINDS.filter((k) => used.has(k)).map((k) => {
130
+ const style = KIND_STYLE[k];
131
+ return {
132
+ key: k,
133
+ name: style.name,
134
+ color: TONES[style.toneName]?.solid ?? '#475569',
135
+ dash: style.dash,
136
+ head: style.head,
137
+ };
138
+ });
139
+ const icons = {};
140
+ for (const p of participants) {
141
+ const prim = ICONS[p.icon];
142
+ if (p.icon && prim)
143
+ icons[p.icon] = prim;
144
+ }
145
+ return {
146
+ title: spec.title,
147
+ subtitle: spec.subtitle ?? '',
148
+ legend: spec.legend ?? true,
149
+ numbers: spec.numbers ?? true,
150
+ activations: spec.activations ?? true,
151
+ kinds,
152
+ participants,
153
+ messages,
154
+ fragments: frags,
155
+ icons,
156
+ maxDim: MAX_DIM,
157
+ maxArea: MAX_AREA,
158
+ };
159
+ }
160
+ /** page.ts 와 같은 이유 — `<` 를 이스케이프하면 `</script>` 가 만들어질 수 없다. */
161
+ function safeJson(value) {
162
+ return JSON.stringify(value).replace(/</g, '\\u003c');
163
+ }
164
+ const STYLE = `
165
+ html,body { margin:0; padding:0; background:#ffffff; }
166
+ body { font-family:-apple-system,BlinkMacSystemFont,"Apple SD Gothic Neo","Pretendard","Noto Sans KR","Malgun Gothic",sans-serif;
167
+ text-rendering:geometricPrecision; -webkit-font-smoothing:antialiased; }
168
+ #wrap { position:relative; }
169
+ svg { display:block; }
170
+ .hd-title { font-size:21px; font-weight:800; fill:#0f172a; }
171
+ .hd-sub { font-size:12.5px; fill:#64748b; }
172
+ .lg-t { font-size:11.5px; fill:#475569; font-weight:600; }
173
+ .p-title { font-size:13.5px; font-weight:700; fill:#111827; }
174
+ .p-sub { font-size:11px; fill:#6b7280; }
175
+ .tag-t { font-size:10.5px; font-weight:700; fill:#ffffff; letter-spacing:.2px; }
176
+ .m-label { font-size:11.5px; font-weight:600; paint-order:stroke; stroke:#ffffff; stroke-width:4.5px; stroke-linejoin:round; }
177
+ .nt-t { font-size:11px; fill:#7c2d12; }
178
+ .fr-kind { font-size:10.5px; font-weight:800; fill:#ffffff; letter-spacing:.3px; }
179
+ .fr-label { font-size:11px; font-weight:600; fill:#334155; paint-order:stroke; stroke:#ffffff; stroke-width:4px; stroke-linejoin:round; }
180
+ `;
181
+ // ── 페이지 안에서 도는 스크립트 ──────────────────────────────────────────
182
+ // ★ page.ts 와 같은 제약 — 이 문자열 안에서는 백틱과 달러중괄호를 쓰지 않는다.
183
+ // 바깥이 템플릿 리터럴이라 그대로 보간돼 버린다.
184
+ const SCRIPT = `
185
+ (function(){
186
+ 'use strict';
187
+ try {
188
+ var S = JSON.parse(document.getElementById('spec').textContent);
189
+ var NS = 'http://www.w3.org/2000/svg';
190
+ var svg = document.getElementById('cv');
191
+
192
+ var PAD = 34, GAP = 26;
193
+ var LH = 15; // 라벨 줄높이의 **바닥값**. 실제 값은 글자를 재서 정한다
194
+ var WRAP_W = 250; // 라벨을 이 폭에서 접는다 (넘으면 열을 넓힌다)
195
+ var ROW_GAP = 26; // 행 사이
196
+ var CARD_GAP = 36; // 카드끼리 최소 여백
197
+ var MIN_COL = 132; // 열 중심 사이 최소 거리
198
+ var BAR_W = 10, BAR_STEP = 6;
199
+ var SELF_W = 46, SELF_H = 34;
200
+ var HEAD_ROOM = 26; // 카드 아래 첫 행까지
201
+ var NOTE_OFF = 26; // 생명선에서 노트 상자까지
202
+ var FR_HEAD = 30, FR_TAIL = 16, FR_PAD = 26;
203
+
204
+ function el(n, a, p){
205
+ var e = document.createElementNS(NS, n);
206
+ for (var k in a) { if (a[k] !== null && a[k] !== undefined) e.setAttribute(k, a[k]); }
207
+ (p || svg).appendChild(e);
208
+ return e;
209
+ }
210
+ function txt(x, y, cls, s, anchor, fill, p){
211
+ var t = el('text', {x:x, y:y, 'class':cls, 'text-anchor':anchor||'middle'}, p);
212
+ if (fill) t.setAttribute('fill', fill);
213
+ t.textContent = s;
214
+ return t;
215
+ }
216
+
217
+ // 글자 폭은 전부 실측이다. 줄바꿈에서 같은 문자열을 여러 번 재게 되므로 외워둔다.
218
+ var scratch = el('g', {visibility:'hidden'});
219
+ var mcache = Object.create(null);
220
+ function box(s, cls){
221
+ var key = cls + '\\u0000' + s;
222
+ var hit = mcache[key];
223
+ if (hit !== undefined) return hit;
224
+ var t = txt(0, 0, cls, s, 'start', null, scratch);
225
+ var b = t.getBBox();
226
+ scratch.removeChild(t);
227
+ hit = {w:b.width, h:b.height};
228
+ mcache[key] = hit;
229
+ return hit;
230
+ }
231
+ function measure(s, cls){ return box(s, cls).w; }
232
+ // ★ 줄높이를 상수로 고정하면 안 된다. 이모지는 같은 font-size 라도 글리프가 훨씬
233
+ // 높아서, 15px 로 쌓으면 **같은 라벨의 두 줄이 서로 물린다**(자가감사가 잡았다).
234
+ // 눈으로는 멀쩡해 보였다 — 재는 쪽이 맞고 배치가 틀린 경우였다.
235
+ function lineHeightOf(lines, cls){
236
+ var h = 0;
237
+ for (var i = 0; i < lines.length; i++) { var m = box(lines[i], cls).h; if (m > h) h = m; }
238
+ return Math.max(LH, Math.ceil(h) + 3);
239
+ }
240
+ function boxOf(t){ var b = t.getBBox(); return {x:b.x, y:b.y, w:b.width, h:b.height}; }
241
+ function rectsOverlap(a, b){
242
+ 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;
243
+ }
244
+ function pad(r, m){ return {x:r.x - m, y:r.y - m, w:r.w + m*2, h:r.h + m*2}; }
245
+
246
+ // ★ 글자를 눌러 맞추지 않는다. 접고, 그래도 넘치면 자리를 넓힌다.
247
+ // 한국어는 띄어쓰기가 없는 토큰이 흔해서 낱말 단위만으로는 안 접힌다.
248
+ // 그때는 글자 단위로 내려간다. Array.from 을 쓰는 건 이모지를 쪼개지 않기 위해서다.
249
+ function wrapText(s, cls, maxW){
250
+ if (!s) return [];
251
+ var out = [], cur = '';
252
+ var words = s.split(' ');
253
+ for (var wi = 0; wi < words.length; wi++) {
254
+ var word = words[wi];
255
+ if (word === '') continue;
256
+ if (measure(word, cls) > maxW) {
257
+ if (cur) { out.push(cur); cur = ''; }
258
+ var chars = Array.from(word);
259
+ var piece = '';
260
+ for (var ci = 0; ci < chars.length; ci++) {
261
+ var grow = piece + chars[ci];
262
+ if (piece && measure(grow, cls) > maxW) { out.push(piece); piece = chars[ci]; }
263
+ else piece = grow;
264
+ }
265
+ cur = piece;
266
+ continue;
267
+ }
268
+ var cand = cur ? (cur + ' ' + word) : word;
269
+ if (cur && measure(cand, cls) > maxW) { out.push(cur); cur = word; }
270
+ else cur = cand;
271
+ }
272
+ if (cur) out.push(cur);
273
+ return out;
274
+ }
275
+ function widest(lines, cls){
276
+ var w = 0;
277
+ for (var i = 0; i < lines.length; i++) { var m = measure(lines[i], cls); if (m > w) w = m; }
278
+ return w;
279
+ }
280
+
281
+ // ── 화살촉 ──
282
+ // 생명선에는 marker 를 안 붙인다. 기존 구성도 도구가 모든 선에 무조건 붙이는 바람에
283
+ // 생명선 끝에 삼각형이 달렸던 것이 이 도구를 따로 만든 이유 중 하나다.
284
+ var defs = el('defs', {});
285
+ var KIND = Object.create(null);
286
+ for (var ki = 0; ki < S.kinds.length; ki++) {
287
+ var kd = S.kinds[ki];
288
+ KIND[kd.key] = kd;
289
+ if (kd.head === 'none') continue;
290
+ var mk = el('marker', {id:'ar-'+kd.key, viewBox:'0 0 10 10', refX:'8.6', refY:'5',
291
+ markerWidth:'6.2', markerHeight:'6.2', orient:'auto-start-reverse'}, defs);
292
+ if (kd.head === 'open') {
293
+ el('path', {d:'M0.8,0.8 L9,5 L0.8,9.2', fill:'none', stroke:kd.color,
294
+ 'stroke-width':1.7, 'stroke-linecap':'round', 'stroke-linejoin':'round'}, mk);
295
+ } else {
296
+ el('path', {d:'M0,0 L10,5 L0,10 z', fill:kd.color}, mk);
297
+ }
298
+ }
299
+ function kindOf(k){ return KIND[k] || S.kinds[0] || {key:'call', color:'#4f46e5', dash:'', head:'solid'}; }
300
+
301
+ // ── 머리말 ──
302
+ var headerBottom = PAD + 22, headerW = 0;
303
+ txt(PAD, PAD + 14, 'hd-title', S.title, 'start');
304
+ headerW = measure(S.title, 'hd-title');
305
+ if (S.subtitle) {
306
+ txt(PAD, headerBottom + 16, 'hd-sub', S.subtitle, 'start');
307
+ headerW = Math.max(headerW, measure(S.subtitle, 'hd-sub'));
308
+ headerBottom += 22;
309
+ }
310
+ if (S.legend && S.kinds.length) {
311
+ var lx = PAD, ly = headerBottom + 22;
312
+ for (var li = 0; li < S.kinds.length; li++) {
313
+ var lp = S.kinds[li];
314
+ if (lp.head === 'none') {
315
+ el('rect', {x:lx, y:ly - 7, width:28, height:14, rx:4, fill:'#fffbeb',
316
+ stroke:lp.color, 'stroke-width':1.2});
317
+ } else {
318
+ var ln = el('path', {d:'M'+lx+','+ly+' H'+(lx+30), stroke:lp.color, 'stroke-width':2,
319
+ fill:'none', 'marker-end':'url(#ar-'+lp.key+')'});
320
+ if (lp.dash) ln.setAttribute('stroke-dasharray', lp.dash);
321
+ }
322
+ txt(lx + 38, ly + 4, 'lg-t', lp.name, 'start');
323
+ lx += 38 + measure(lp.name, 'lg-t') + 22;
324
+ }
325
+ headerW = Math.max(headerW, lx - PAD - 22);
326
+ headerBottom = ly + 12;
327
+ }
328
+ // 생명선 범례는 종류가 아니라 그림의 뼈대라 항상 같은 자리에 둔다.
329
+ var content = el('g', {});
330
+
331
+ // ── ① 참가자 카드 크기 (글자 실측) ──
332
+ var P = S.participants;
333
+ var CARD_H = 54;
334
+ for (var i = 0; i < P.length; i++) {
335
+ var p = P[i];
336
+ var tw = measure(p.name, 'p-title');
337
+ var sw = p.sub ? measure(p.sub, 'p-sub') : 0;
338
+ p.tagW = p.tag ? measure(p.tag, 'tag-t') + 16 : 0;
339
+ var need = Math.max(118, tw + 30, sw + 26, p.tagW + 30);
340
+ // 아이콘은 가운데 위, 배지는 오른쪽 위다. 좁으면 둘이 겹친다.
341
+ if (p.icon && p.tag) need = Math.max(need, 2 * p.tagW + 64);
342
+ // ★ 아이콘이 없으면 제목이 카드 한가운데로 올라온다 — 배지와 같은 높이가 된다.
343
+ // 그때는 **가운데 정렬한 제목의 오른쪽 끝**이 배지 밑으로 들어가지 않아야 한다.
344
+ // (실물 확인: 배지만 있고 아이콘이 없는 카드에서 배지가 이름을 덮었다.)
345
+ if (p.tag && !p.icon) need = Math.max(need, Math.max(tw, sw) + 2 * p.tagW + 34);
346
+ p.w = Math.ceil(need);
347
+ p.contentH = (p.icon ? 36 : 0) + 18 + (p.sub ? 16 : 0);
348
+ CARD_H = Math.max(CARD_H, p.contentH + 26);
349
+ }
350
+ // 높이를 하나로 맞춰야 생명선이 같은 줄에서 시작한다.
351
+ for (i = 0; i < P.length; i++) P[i].h = CARD_H;
352
+
353
+ // ── ② 라벨 접기 ──
354
+ var M = S.messages;
355
+ var seq = 0;
356
+ for (var mi = 0; mi < M.length; mi++) {
357
+ var m = M[mi];
358
+ m.note = (m.kind === 'note');
359
+ m.self = (!m.note && m.a === m.b);
360
+ var body = m.label;
361
+ if (S.numbers && !m.note) {
362
+ seq++;
363
+ body = body ? (seq + '. ' + body) : String(seq);
364
+ }
365
+ m.cls = m.note ? 'nt-t' : 'm-label';
366
+ m.lines = wrapText(body, m.cls, WRAP_W);
367
+ m.raw = body;
368
+ m.tw = widest(m.lines, m.cls);
369
+ m.lh = lineHeightOf(m.lines, m.cls);
370
+ m.th = m.lines.length * m.lh;
371
+ }
372
+
373
+ // ── ③ 열 간격: 라벨이 들어갈 만큼 넓힌다 ──
374
+ var gaps = [];
375
+ for (i = 0; i < P.length - 1; i++) {
376
+ gaps.push(Math.max(MIN_COL, P[i].w / 2 + P[i+1].w / 2 + CARD_GAP));
377
+ }
378
+ function widenOne(i, need){ if (i >= 0 && i < gaps.length && gaps[i] < need) gaps[i] = need; }
379
+
380
+ for (mi = 0; mi < M.length; mi++) {
381
+ var mm = M[mi];
382
+ if (mm.note && mm.a === mm.b) {
383
+ // ★ 한 열짜리 노트는 생명선 **오른쪽**에 붙인다. 열 위에 얹으면 그 참가자의
384
+ // 활성 막대를 덮어 막대가 끊겨 보인다(실물 확인). 대신 어디에 붙은
385
+ // 설명인지 모르게 되므로 생명선에서 상자까지 짧은 연결선을 긋는다.
386
+ if (mm.a < P.length - 1) {
387
+ widenOne(mm.a, NOTE_OFF + mm.tw + 28 + 16 + P[mm.a+1].w / 2);
388
+ }
389
+ } else if (mm.self) {
390
+ // 자기호출은 고리와 라벨이 오른쪽으로 나간다. 마지막 열이면 캔버스가 늘어난다.
391
+ if (mm.a < P.length - 1) {
392
+ widenOne(mm.a, SELF_W + 10 + mm.tw + 16 + P[mm.a+1].w / 2);
393
+ }
394
+ }
395
+ }
396
+ // 여러 열에 걸친 것은 짧은 것부터 처리한다. 긴 것은 앞서 벌어진 폭을 물려받는다.
397
+ var spans = [];
398
+ for (mi = 0; mi < M.length; mi++) {
399
+ var ms = M[mi];
400
+ if (ms.a === ms.b) continue;
401
+ spans.push({lo:Math.min(ms.a, ms.b), hi:Math.max(ms.a, ms.b), need:ms.tw + (ms.note ? 34 : 30)});
402
+ }
403
+ spans.sort(function(x, y){ return (x.hi - x.lo) - (y.hi - y.lo); });
404
+ for (var si = 0; si < spans.length; si++) {
405
+ var sp = spans[si];
406
+ var cur = 0;
407
+ for (i = sp.lo; i < sp.hi; i++) cur += gaps[i];
408
+ if (cur < sp.need) {
409
+ var add = (sp.need - cur) / (sp.hi - sp.lo);
410
+ for (i = sp.lo; i < sp.hi; i++) gaps[i] += add;
411
+ }
412
+ }
413
+ var CX = [0];
414
+ for (i = 0; i < gaps.length; i++) CX.push(CX[i] + gaps[i]);
415
+ for (i = 0; i < P.length; i++) {
416
+ P[i].cx = CX[i];
417
+ P[i].x = CX[i] - P[i].w / 2;
418
+ P[i].y = 0;
419
+ }
420
+
421
+ // ── ④ 프래그먼트 깊이 (겹친 만큼 안쪽으로 들어간다) ──
422
+ var FR = S.fragments;
423
+ for (var fi = 0; fi < FR.length; fi++) {
424
+ var f = FR[fi], depth = 0;
425
+ for (var fj = 0; fj < FR.length; fj++) {
426
+ if (fj === fi) continue;
427
+ var g = FR[fj];
428
+ var contains = (g.from <= f.from && g.to >= f.to);
429
+ var same = (g.from === f.from && g.to === f.to);
430
+ if (contains && (!same || fj < fi)) depth++;
431
+ }
432
+ f.depth = depth;
433
+ }
434
+ var padTop = [], padBot = [];
435
+ for (mi = 0; mi < M.length; mi++) { padTop.push(0); padBot.push(0); }
436
+ for (fi = 0; fi < FR.length; fi++) { padTop[FR[fi].from] += FR_HEAD; padBot[FR[fi].to] += FR_TAIL; }
437
+
438
+ // ── ⑤ 행 배치 ──
439
+ var cursor = CARD_H + HEAD_ROOM;
440
+ for (mi = 0; mi < M.length; mi++) {
441
+ var mr = M[mi];
442
+ cursor += padTop[mi];
443
+ mr.top = cursor;
444
+ if (mr.note) {
445
+ mr.h = mr.th + 18;
446
+ mr.boxY = cursor;
447
+ } else if (mr.self) {
448
+ mr.h = Math.max(SELF_H + 20, mr.th + 16);
449
+ mr.y1 = cursor + (mr.h - SELF_H) / 2;
450
+ mr.y2 = mr.y1 + SELF_H;
451
+ } else {
452
+ mr.h = mr.th + 12;
453
+ mr.ay = cursor + mr.h;
454
+ }
455
+ cursor += mr.h + padBot[mi] + ROW_GAP;
456
+ }
457
+ var BOTTOM = cursor - ROW_GAP + 20;
458
+
459
+ // ── ⑥ 활성 막대: 호출/응답 짝에서 뽑는다 ──
460
+ var ACT = [], stack = [], lastY = [];
461
+ for (i = 0; i < P.length; i++) { ACT.push([]); stack.push([]); lastY.push(CARD_H + 20); }
462
+ if (S.activations) {
463
+ for (mi = 0; mi < M.length; mi++) {
464
+ var ma = M[mi];
465
+ if (ma.note) continue;
466
+ var yy = ma.self ? ma.y2 : ma.ay;
467
+ if (lastY[ma.a] < yy) lastY[ma.a] = yy;
468
+ if (lastY[ma.b] < yy) lastY[ma.b] = yy;
469
+ if (ma.self) continue;
470
+ if (ma.kind === 'return') {
471
+ var st = stack[ma.a];
472
+ if (st.length) { var open = st.pop(); open.y2 = ma.ay; }
473
+ } else {
474
+ var bar = {y1:ma.ay, y2:-1, depth:stack[ma.b].length};
475
+ ACT[ma.b].push(bar);
476
+ stack[ma.b].push(bar);
477
+ }
478
+ }
479
+ // 안 닫힌 막대는 그 참가자가 마지막으로 관여한 지점까지 끈다.
480
+ for (i = 0; i < P.length; i++) {
481
+ for (var q = 0; q < ACT[i].length; q++) {
482
+ if (ACT[i][q].y2 < 0) ACT[i][q].y2 = Math.max(lastY[i], ACT[i][q].y1 + 20);
483
+ }
484
+ }
485
+ }
486
+ // 화살표는 생명선 한가운데가 아니라 막대 가장자리에서 끊는다.
487
+ function barEdge(pi, y, side){
488
+ var best = -1;
489
+ for (var b = 0; b < ACT[pi].length; b++) {
490
+ var bar = ACT[pi][b];
491
+ if (y >= bar.y1 - 0.5 && y <= bar.y2 + 0.5 && bar.depth > best) best = bar.depth;
492
+ }
493
+ if (best < 0) return 0;
494
+ return best * BAR_STEP + side * (BAR_W / 2);
495
+ }
496
+
497
+ // ── ⑥-b 메시지가 실제로 닿는 x 범위 ──
498
+ // ★ 그리기 전에 확정한다. 프래그먼트를 '전체 열'이 아니라 '자기 메시지가 닿는
499
+ // 곳'만 감싸게 하려면 상자를 그리는 시점에 이 값이 이미 있어야 한다.
500
+ for (mi = 0; mi < M.length; mi++) {
501
+ var mx = M[mi];
502
+ if (mx.note) {
503
+ var bw0 = mx.tw + 28;
504
+ if (mx.a === mx.b) {
505
+ mx.box = {x:P[mx.a].cx + NOTE_OFF, y:mx.boxY, w:bw0, h:mx.h};
506
+ mx.lo = P[mx.a].cx - BAR_W;
507
+ mx.hi = mx.box.x + bw0;
508
+ } else {
509
+ // ★ from~to 를 가리킨다고 해놓고 가운데에만 떠 있으면 어디 얘기인지 모른다.
510
+ // 글자가 짧아도 상자는 그 구간을 실제로 덮는다.
511
+ var nlo = P[Math.min(mx.a,mx.b)].cx, nhi = P[Math.max(mx.a,mx.b)].cx;
512
+ bw0 = Math.max(bw0, nhi - nlo + 40);
513
+ mx.box = {x:(nlo + nhi) / 2 - bw0 / 2, y:mx.boxY, w:bw0, h:mx.h};
514
+ mx.lo = Math.min(mx.box.x, nlo);
515
+ mx.hi = Math.max(mx.box.x + bw0, nhi);
516
+ }
517
+ } else if (mx.self) {
518
+ var se1 = barEdge(mx.a, mx.y1, 1), se2 = barEdge(mx.a, mx.y2, 1);
519
+ mx.x0 = P[mx.a].cx + se1;
520
+ mx.x1 = P[mx.a].cx + se2;
521
+ mx.far = P[mx.a].cx + Math.max(se1, se2) + SELF_W;
522
+ mx.lo = P[mx.a].cx - BAR_W;
523
+ mx.hi = mx.far;
524
+ } else {
525
+ var mdir = mx.b > mx.a ? 1 : -1;
526
+ mx.x0 = P[mx.a].cx + barEdge(mx.a, mx.ay, mdir);
527
+ mx.x1 = P[mx.b].cx + barEdge(mx.b, mx.ay, -mdir);
528
+ mx.lo = Math.min(mx.x0, mx.x1, P[mx.a].cx, P[mx.b].cx) - BAR_W;
529
+ mx.hi = Math.max(mx.x0, mx.x1, P[mx.a].cx, P[mx.b].cx) + BAR_W;
530
+ }
531
+ }
532
+
533
+ // ── ⑦ 그리기 (프래그먼트 → 생명선 → 막대 → 화살표 → 노트 → 카드 → 라벨) ──
534
+ var startOff = Object.create(null), endOff = Object.create(null);
535
+ var byStart = FR.slice().sort(function(a, b){ return (a.from - b.from) || (a.depth - b.depth); });
536
+ for (fi = 0; fi < byStart.length; fi++) {
537
+ var fs = byStart[fi], ks = 'i' + fs.from;
538
+ fs.headOff = startOff[ks] || 0;
539
+ startOff[ks] = fs.headOff + FR_HEAD;
540
+ }
541
+ var byEnd = FR.slice().sort(function(a, b){ return (a.to - b.to) || (b.depth - a.depth); });
542
+ for (fi = 0; fi < byEnd.length; fi++) {
543
+ var fe = byEnd[fi], ke = 'i' + fe.to;
544
+ fe.tailOff = endOff[ke] || 0;
545
+ endOff[ke] = fe.tailOff + FR_TAIL;
546
+ }
547
+ var chipEls = [];
548
+ // ★ 상자는 전체 열이 아니라 **자기 메시지가 실제로 닿는 범위**만 감싼다.
549
+ // 전체를 감싸면 loop 하나가 관계없는 열까지 묶은 것처럼 읽힌다(실물 확인).
550
+ for (fi = 0; fi < FR.length; fi++) {
551
+ var fr = FR[fi];
552
+ var flo = Infinity, fhi = -Infinity;
553
+ for (var fk = fr.from; fk <= fr.to; fk++) {
554
+ if (M[fk].lo < flo) flo = M[fk].lo;
555
+ if (M[fk].hi > fhi) fhi = M[fk].hi;
556
+ }
557
+ fr.x = flo - FR_PAD;
558
+ fr.x2 = fhi + FR_PAD;
559
+ // 종류 칩과 조건 라벨이 상자보다 넓으면 글자가 밖으로 나간다. 미리 넓혀 둔다.
560
+ var chipNeed = measure(fr.kind, 'fr-kind') + 18 + (fr.label ? measure(fr.label, 'fr-label') + 9 : 0) + 14;
561
+ if (fr.x2 - fr.x < chipNeed) fr.x2 = fr.x + chipNeed;
562
+ fr.y = M[fr.from].top - padTop[fr.from] + fr.headOff;
563
+ fr.y2 = M[fr.to].top + M[fr.to].h + 16 + fr.tailOff;
564
+ FR[fi] = fr;
565
+ }
566
+ // 겹친 상자는 안쪽이 확실히 안에 들어가게 바깥을 넓힌다. 깊은 것부터 확정한다.
567
+ var byDepth = FR.slice().sort(function(a, b){ return b.depth - a.depth; });
568
+ for (fi = 0; fi < byDepth.length; fi++) {
569
+ var kid = byDepth[fi];
570
+ for (fj = 0; fj < FR.length; fj++) {
571
+ var par = FR[fj];
572
+ if (par === kid) continue;
573
+ if (par.from <= kid.from && par.to >= kid.to && par.depth < kid.depth) {
574
+ if (par.x > kid.x - 13) par.x = kid.x - 13;
575
+ if (par.x2 < kid.x2 + 13) par.x2 = kid.x2 + 13;
576
+ }
577
+ }
578
+ }
579
+ for (fi = 0; fi < FR.length; fi++) {
580
+ fr = FR[fi];
581
+ el('rect', {x:fr.x, y:fr.y, width:fr.x2 - fr.x, height:fr.y2 - fr.y, rx:11,
582
+ fill:fr.fill, stroke:fr.stroke, 'stroke-width':1.3,
583
+ 'fill-opacity':0.55}, content);
584
+ }
585
+ for (i = 0; i < P.length; i++) {
586
+ el('path', {d:'M'+P[i].cx+','+(CARD_H + 8)+' V'+BOTTOM, stroke:'#cbd5e1',
587
+ 'stroke-width':1.4, fill:'none', 'stroke-dasharray':'5 5'}, content);
588
+ }
589
+ for (i = 0; i < P.length; i++) {
590
+ for (q = 0; q < ACT[i].length; q++) {
591
+ var ab = ACT[i][q];
592
+ el('rect', {x:P[i].cx + ab.depth * BAR_STEP - BAR_W / 2, y:ab.y1,
593
+ width:BAR_W, height:Math.max(20, ab.y2 - ab.y1), rx:3,
594
+ fill:'#ffffff', stroke:'#94a3b8', 'stroke-width':1.2}, content);
595
+ }
596
+ }
597
+ for (mi = 0; mi < M.length; mi++) {
598
+ var md = M[mi];
599
+ if (md.note) continue;
600
+ var kk = kindOf(md.kind);
601
+ var d;
602
+ if (md.self) {
603
+ var r = 8, far = md.far;
604
+ d = 'M' + md.x0 + ',' + md.y1 +
605
+ ' H' + (far - r) + ' Q' + far + ',' + md.y1 + ' ' + far + ',' + (md.y1 + r) +
606
+ ' V' + (md.y2 - r) + ' Q' + far + ',' + md.y2 + ' ' + (far - r) + ',' + md.y2 +
607
+ ' H' + md.x1;
608
+ } else {
609
+ d = 'M' + md.x0 + ',' + md.ay + ' H' + md.x1;
610
+ }
611
+ var pe = el('path', {d:d, fill:'none', stroke:kk.color, 'stroke-width':1.8,
612
+ 'marker-end':'url(#ar-'+kk.key+')'}, content);
613
+ if (kk.dash) pe.setAttribute('stroke-dasharray', kk.dash);
614
+ }
615
+ var noteBoxes = [], noteTexts = [];
616
+ for (mi = 0; mi < M.length; mi++) {
617
+ var mn = M[mi];
618
+ if (!mn.note) continue;
619
+ var bw = mn.box.w, bx = mn.box.x;
620
+ if (mn.a === mn.b) {
621
+ el('path', {d:'M'+P[mn.a].cx+','+(mn.boxY + mn.h / 2)+' H'+bx,
622
+ stroke:'#f59e0b', 'stroke-width':1.2, fill:'none',
623
+ 'stroke-dasharray':'3 3'}, content);
624
+ }
625
+ el('rect', {x:bx, y:mn.boxY, width:bw, height:mn.h, rx:8,
626
+ fill:'#fffbeb', stroke:'#f59e0b', 'stroke-width':1.2}, content);
627
+ el('path', {d:'M'+(bx+bw-14)+','+mn.boxY+' L'+(bx+bw)+','+(mn.boxY+14),
628
+ stroke:'#f59e0b', 'stroke-width':1.2, fill:'none'}, content);
629
+ for (var nl = 0; nl < mn.lines.length; nl++) {
630
+ var nt = txt(bx + 14, mn.boxY + 12 + mn.lh * 0.8 + nl * mn.lh, 'nt-t', mn.lines[nl], 'start', null, content);
631
+ noteTexts.push([nt, mn.box, mn.lines[nl]]);
632
+ }
633
+ noteBoxes.push([mn.box, mn.raw, {lo:Math.min(mn.a,mn.b), hi:Math.max(mn.a,mn.b)}]);
634
+ }
635
+ function drawIcon(name, cxp, cyp, color){
636
+ var prims = S.icons[name];
637
+ if (!prims) return;
638
+ var size = 30;
639
+ el('rect', {x:cxp - size/2, y:cyp - size/2, width:size, height:size, rx:9, fill:color}, content);
640
+ var s = 0.86;
641
+ var g = el('g', {transform:'translate(' + (cxp - 12*s) + ',' + (cyp - 12*s) + ') scale(' + s + ')',
642
+ fill:'none', stroke:'#ffffff', 'stroke-width':2,
643
+ 'stroke-linecap':'round', 'stroke-linejoin':'round'}, content);
644
+ for (var z = 0; z < prims.length; z++) {
645
+ var pr = prims[z];
646
+ if (pr[0] === 'r') el('rect', {x:pr[1], y:pr[2], width:pr[3], height:pr[4], rx:pr[5]}, g);
647
+ else if (pr[0] === 'c') el('circle', {cx:pr[1], cy:pr[2], r:pr[3]}, g);
648
+ else if (pr[0] === 'e') el('ellipse', {cx:pr[1], cy:pr[2], rx:pr[3], ry:pr[4]}, g);
649
+ else if (pr[0] === 'd') el('circle', {cx:pr[1], cy:pr[2], r:pr[3], fill:'#ffffff', stroke:'none'}, g);
650
+ else if (pr[0] === 'l') el('line', {x1:pr[1], y1:pr[2], x2:pr[3], y2:pr[4]}, g);
651
+ else if (pr[0] === 'p') el('path', {d:pr[1]}, g);
652
+ }
653
+ }
654
+ var cardTexts = [], tagBoxes = [];
655
+ for (i = 0; i < P.length; i++) {
656
+ var pc = P[i];
657
+ el('rect', {x:pc.x, y:pc.y, width:pc.w, height:pc.h, rx:11, fill:'#ffffff',
658
+ stroke:'#d1d5db', 'stroke-width':1.3,
659
+ filter:'drop-shadow(0 1.5px 3px rgba(15,23,42,.09))'}, content);
660
+ var top = pc.y + (pc.h - pc.contentH) / 2;
661
+ if (pc.icon) drawIcon(pc.icon, pc.cx, top + 15, pc.iconColor);
662
+ var baseY = top + (pc.icon ? 36 : 0) + 14;
663
+ cardTexts.push([txt(pc.cx, baseY, 'p-title', pc.name, 'middle', null, content), pc]);
664
+ if (pc.sub) cardTexts.push([txt(pc.cx, baseY + 16, 'p-sub', pc.sub, 'middle', null, content), pc]);
665
+ if (pc.tag) {
666
+ var tgx = pc.x + pc.w - pc.tagW - 9;
667
+ el('rect', {x:tgx, y:pc.y + 9, width:pc.tagW, height:19, rx:6, fill:pc.tagColor}, content);
668
+ txt(tgx + pc.tagW / 2, pc.y + 22.5, 'tag-t', pc.tag, 'middle', null, content);
669
+ tagBoxes.push([{x:tgx, y:pc.y + 9, w:pc.tagW, h:19}, pc]);
670
+ }
671
+ }
672
+ var labelEls = [];
673
+ for (mi = 0; mi < M.length; mi++) {
674
+ var ml = M[mi];
675
+ if (ml.note || !ml.lines.length) continue;
676
+ var kc = kindOf(ml.kind);
677
+ var lx2, ly2, anch;
678
+ if (ml.self) {
679
+ lx2 = ml.far + 10;
680
+ ly2 = (ml.y1 + ml.y2) / 2 - (ml.lines.length - 1) * ml.lh / 2 + 4;
681
+ anch = 'start';
682
+ } else {
683
+ lx2 = (ml.x0 + ml.x1) / 2;
684
+ ly2 = ml.ay - 9 - (ml.lines.length - 1) * ml.lh;
685
+ anch = 'middle';
686
+ }
687
+ for (var lj = 0; lj < ml.lines.length; lj++) {
688
+ var le = txt(lx2, ly2 + lj * ml.lh, 'm-label', ml.lines[lj], anch, kc.color, content);
689
+ labelEls.push([le, ml.raw, ml]);
690
+ }
691
+ }
692
+ // ★ 프래그먼트 칩은 **맨 마지막**에 그린다. 상자와 같이 그렸더니 그 위를 지나는
693
+ // 활성 막대와 화살표가 칩을 덮어 종류 글자가 반쯤 사라졌다(실물 확인).
694
+ // 상자는 배경이고 칩은 이름표다 — 이름표가 가려지면 상자를 못 읽는다.
695
+ for (fi = 0; fi < FR.length; fi++) {
696
+ var fc = FR[fi];
697
+ var kw = measure(fc.kind, 'fr-kind') + 18;
698
+ el('rect', {x:fc.x, y:fc.y, width:kw, height:20, rx:6, fill:fc.solid}, content);
699
+ var kt = txt(fc.x + kw / 2, fc.y + 14, 'fr-kind', fc.kind, 'middle', null, content);
700
+ chipEls.push([kt, {x:fc.x, y:fc.y, w:kw, h:20}, fc.kind]);
701
+ fc.chipBox = {x:fc.x, y:fc.y, w:kw, h:20};
702
+ if (fc.label) {
703
+ var lt = txt(fc.x + kw + 9, fc.y + 14, 'fr-label', fc.label, 'start', null, content);
704
+ fc.labelBox = boxOf(lt);
705
+ }
706
+ }
707
+
708
+ // ── ⑧ 캔버스 되맞춤 ──
709
+ svg.removeChild(scratch);
710
+ var bb = content.getBBox();
711
+ var dx = PAD - bb.x, dy = headerBottom + GAP - bb.y;
712
+ content.setAttribute('transform', 'translate(' + dx + ',' + dy + ')');
713
+ var W = Math.ceil(Math.max(bb.width + PAD*2, headerW + PAD*2, 720));
714
+ var H = Math.ceil(headerBottom + GAP + bb.height + PAD);
715
+
716
+ // ★★ page.ts 와 같은 이유로 **설정하기 전에** 거른다. 크기를 박는 순간 브라우저가
717
+ // 그만한 표면을 잡는다 — 바깥에서 막으면 늦는다.
718
+ if (W > S.maxDim || H > S.maxDim || W * H > S.maxArea) {
719
+ document.title = 'AUDIT_TOO_BIG ' + JSON.stringify({w:W, h:H, maxDim:S.maxDim, maxArea:S.maxArea});
720
+ return;
721
+ }
722
+ svg.setAttribute('width', W); svg.setAttribute('height', H);
723
+ svg.setAttribute('viewBox', '0 0 ' + W + ' ' + H);
724
+ document.getElementById('wrap').style.width = W + 'px';
725
+ document.getElementById('wrap').style.height = H + 'px';
726
+
727
+ // ── ⑨ 자가감사 ──
728
+ // 여기서 재는 것은 전부 '내 배치 계산이 맞았는가'다. 통과가 곧 좋은 그림이라는
729
+ // 뜻은 아니다 — 기하만 본다.
730
+ var over = [], collide = [], label = [], cross = [], frame = [];
731
+
732
+ for (var ci2 = 0; ci2 < cardTexts.length; ci2++) {
733
+ var ct = cardTexts[ci2][0], cp = cardTexts[ci2][1];
734
+ var cb = boxOf(ct);
735
+ if (cb.x < cp.x + 4 || cb.x + cb.w > cp.x + cp.w - 4) over.push('카드 ' + cp.name + ': ' + ct.textContent);
736
+ else if (cb.y + cb.h > cp.y + cp.h - 1) over.push('카드 ' + cp.name + ' 아래로: ' + ct.textContent);
737
+ }
738
+ for (var ni2 = 0; ni2 < noteTexts.length; ni2++) {
739
+ var ntt = noteTexts[ni2][0], ntb = noteTexts[ni2][1];
740
+ var nb = boxOf(ntt);
741
+ if (nb.x < ntb.x + 3 || nb.x + nb.w > ntb.x + ntb.w - 3 || nb.y + nb.h > ntb.y + ntb.h - 1) {
742
+ over.push('노트 밖으로: ' + ntt.textContent);
743
+ }
744
+ }
745
+ for (var pi2 = 0; pi2 < chipEls.length; pi2++) {
746
+ var cte = chipEls[pi2][0], ctb = chipEls[pi2][1];
747
+ var cbb = boxOf(cte);
748
+ if (cbb.x < ctb.x + 2 || cbb.x + cbb.w > ctb.x + ctb.w - 2) over.push('프래그먼트 칩: ' + chipEls[pi2][2]);
749
+ }
750
+
751
+ // ★ 배지가 이름을 덮는 결함은 '글자가 카드 밖으로 나갔나'로는 안 잡힌다 —
752
+ // 글자는 카드 안에 얌전히 있고 배지가 그 위에 얹힐 뿐이다. 따로 재야 한다.
753
+ for (var tg = 0; tg < tagBoxes.length; tg++) {
754
+ for (var tc = 0; tc < cardTexts.length; tc++) {
755
+ if (cardTexts[tc][1] !== tagBoxes[tg][1]) continue;
756
+ if (rectsOverlap(tagBoxes[tg][0], boxOf(cardTexts[tc][0]))) {
757
+ collide.push(tagBoxes[tg][1].name + ': 배지가 글자를 덮음 (카드를 넓혀야 한다)');
758
+ }
759
+ }
760
+ }
761
+ for (i = 0; i < P.length; i++) {
762
+ for (var j2 = i + 1; j2 < P.length; j2++) {
763
+ if (rectsOverlap({x:P[i].x, y:P[i].y, w:P[i].w, h:P[i].h},
764
+ {x:P[j2].x, y:P[j2].y, w:P[j2].w, h:P[j2].h})) {
765
+ collide.push('카드 ' + P[i].name + ' ↔ ' + P[j2].name);
766
+ }
767
+ }
768
+ }
769
+ for (i = 0; i < P.length; i++) {
770
+ for (q = 0; q < ACT[i].length; q++) {
771
+ for (var q2 = q + 1; q2 < ACT[i].length; q2++) {
772
+ var b1 = ACT[i][q], b2 = ACT[i][q2];
773
+ if (b1.depth === b2.depth && b1.y1 < b2.y2 && b2.y1 < b1.y2) {
774
+ collide.push('활성막대가 같은 자리에 둘: ' + P[i].name);
775
+ }
776
+ }
777
+ }
778
+ }
779
+ for (var nb1 = 0; nb1 < noteBoxes.length; nb1++) {
780
+ for (i = 0; i < P.length; i++) {
781
+ if (rectsOverlap(noteBoxes[nb1][0], {x:P[i].x, y:P[i].y, w:P[i].w, h:P[i].h})) {
782
+ collide.push('노트가 카드 ' + P[i].name + ' 위에 겹침');
783
+ }
784
+ }
785
+ for (var nb2 = nb1 + 1; nb2 < noteBoxes.length; nb2++) {
786
+ if (rectsOverlap(noteBoxes[nb1][0], noteBoxes[nb2][0])) collide.push('노트끼리 겹침');
787
+ }
788
+ var own = noteBoxes[nb1][2];
789
+ for (i = 0; i < P.length; i++) {
790
+ if (i >= own.lo && i <= own.hi) continue;
791
+ var nbx = noteBoxes[nb1][0];
792
+ if (P[i].cx >= nbx.x && P[i].cx <= nbx.x + nbx.w) {
793
+ collide.push('노트가 ' + P[i].name + ' 생명선을 덮음');
794
+ }
795
+ }
796
+ }
797
+
798
+ var lboxes = [];
799
+ for (var lb = 0; lb < labelEls.length; lb++) lboxes.push(boxOf(labelEls[lb][0]));
800
+
801
+ // ★★ 이 두 검사는 변이시험에서 **구멍이 드러나 뒤늦게 넣은 것**이다.
802
+ // 열 넓히기와 행 높이 계산을 일부러 망가뜨렸는데 나머지 항목이 전부 통과했다.
803
+ // 라벨이 다른 걸 덮지는 않으면서 제 구간만 벗어나는 경우가 있었기 때문이다.
804
+ // '아무것도 안 걸렸다'는 곧 '맞다'가 아니다 — 재는 항목이 없으면 안 걸린다.
805
+ for (var lx3 = 0; lx3 < lboxes.length; lx3++) {
806
+ var lm = labelEls[lx3][2], lbx = lboxes[lx3];
807
+ var loX, hiX;
808
+ if (lm.self) {
809
+ loX = P[lm.a].cx;
810
+ hiX = (lm.a < P.length - 1) ? (P[lm.a+1].x - 4) : Infinity;
811
+ } else {
812
+ loX = Math.min(P[lm.a].cx, P[lm.b].cx) - 6;
813
+ hiX = Math.max(P[lm.a].cx, P[lm.b].cx) + 6;
814
+ }
815
+ if (lbx.x < loX || lbx.x + lbx.w > hiX) {
816
+ label.push("'" + labelEls[lx3][1] + "' 가 제 구간 밖으로 나감 (열을 더 넓혀야 한다)");
817
+ }
818
+ if (lbx.y < lm.top - 3 || lbx.y + lbx.h > lm.top + lm.h + 3) {
819
+ label.push("'" + labelEls[lx3][1] + "' 가 제 행을 벗어남 (행 높이가 모자란다)");
820
+ }
821
+ }
822
+ for (var lb2 = 0; lb2 < lboxes.length; lb2++) {
823
+ for (i = 0; i < P.length; i++) {
824
+ if (rectsOverlap(lboxes[lb2], {x:P[i].x, y:P[i].y, w:P[i].w, h:P[i].h})) {
825
+ label.push("'" + labelEls[lb2][1] + "' 가 카드 " + P[i].name + ' 를 가림');
826
+ break;
827
+ }
828
+ }
829
+ for (var nb3 = 0; nb3 < noteBoxes.length; nb3++) {
830
+ if (rectsOverlap(lboxes[lb2], noteBoxes[nb3][0])) {
831
+ label.push("'" + labelEls[lb2][1] + "' 가 노트를 가림");
832
+ break;
833
+ }
834
+ }
835
+ for (var lc = lb2 + 1; lc < lboxes.length; lc++) {
836
+ // 같은 메시지의 여러 줄은 겹치지 않게 이미 쌓아 놓았다. 그래도 재는 건
837
+ // 줄높이를 잘못 잡으면 여기서 걸려야 하기 때문이다.
838
+ if (rectsOverlap(lboxes[lb2], lboxes[lc])) {
839
+ label.push("'" + labelEls[lb2][1] + "' ↔ '" + labelEls[lc][1] + "'");
840
+ }
841
+ }
842
+ }
843
+
844
+ for (mi = 0; mi < M.length; mi++) {
845
+ var mc = M[mi];
846
+ if (mc.note) continue;
847
+ var ys = mc.self ? [mc.y1, mc.y2] : [mc.ay];
848
+ var xlo = Math.min(mc.x0, mc.x1), xhi = Math.max(mc.x0, mc.x1);
849
+ if (mc.self) { xlo = Math.min(xlo, mc.far); xhi = Math.max(xhi, mc.far); }
850
+ for (var yi = 0; yi < ys.length; yi++) {
851
+ var seg = {x:xlo, y:ys[yi] - 1, w:xhi - xlo, h:2};
852
+ for (i = 0; i < P.length; i++) {
853
+ if (rectsOverlap(seg, {x:P[i].x, y:P[i].y, w:P[i].w, h:P[i].h})) {
854
+ cross.push("'" + mc.raw + "' 가 카드 " + P[i].name + ' 를 관통');
855
+ }
856
+ }
857
+ for (var nb4 = 0; nb4 < noteBoxes.length; nb4++) {
858
+ if (rectsOverlap(seg, noteBoxes[nb4][0])) cross.push("'" + mc.raw + "' 가 노트를 관통");
859
+ }
860
+ }
861
+ }
862
+
863
+ for (fi = 0; fi < FR.length; fi++) {
864
+ var fchk = FR[fi];
865
+ for (var k2 = fchk.from; k2 <= fchk.to; k2++) {
866
+ var im = M[k2];
867
+ if (im.top < fchk.y + 2) frame.push(fchk.kind + ' 상자가 메시지 ' + k2 + '번 위쪽을 못 덮음');
868
+ if (im.top + im.h > fchk.y2 - 4) frame.push(fchk.kind + ' 상자가 메시지 ' + k2 + '번 아래쪽을 못 덮음');
869
+ if (im.lo < fchk.x || im.hi > fchk.x2) {
870
+ frame.push(fchk.kind + ' 상자가 메시지 ' + k2 + '번 좌우를 못 덮음');
871
+ }
872
+ }
873
+ for (var lb3 = 0; lb3 < lboxes.length; lb3++) {
874
+ if (fchk.labelBox && rectsOverlap(fchk.labelBox, lboxes[lb3])) {
875
+ label.push("프래그먼트 '" + fchk.label + "' 가 라벨 '" + labelEls[lb3][1] + "' 와 겹침");
876
+ }
877
+ if (fchk.chipBox && rectsOverlap(fchk.chipBox, lboxes[lb3])) {
878
+ label.push("프래그먼트 칩 '" + fchk.kind + "' 가 라벨 '" + labelEls[lb3][1] + "' 와 겹침");
879
+ }
880
+ }
881
+ if (fchk.labelBox && (fchk.labelBox.x + fchk.labelBox.w > fchk.x2 - 6)) {
882
+ over.push("프래그먼트 조건 '" + fchk.label + "' 가 상자 밖으로 나감");
883
+ }
884
+ for (var nb5 = 0; nb5 < noteBoxes.length; nb5++) {
885
+ if (fchk.chipBox && rectsOverlap(fchk.chipBox, noteBoxes[nb5][0])) {
886
+ label.push("프래그먼트 칩 '" + fchk.kind + "' 가 노트와 겹침");
887
+ }
888
+ }
889
+ }
890
+
891
+ document.title = 'AUDIT ' + JSON.stringify({
892
+ w:W, h:H, over:over, collide:collide, label:label, cross:cross, frame:frame
893
+ });
894
+ } catch (err) {
895
+ document.title = 'AUDIT_ERROR ' + ((err && err.message) ? err.message : String(err));
896
+ }
897
+ })();
898
+ `;
899
+ export function buildSequenceHtml(spec) {
900
+ const resolved = resolve(spec);
901
+ return [
902
+ '<!DOCTYPE html>',
903
+ '<html lang="ko"><head><meta charset="utf-8"><title>rendering…</title>',
904
+ `<style>${STYLE}</style>`,
905
+ '</head><body>',
906
+ '<div id="wrap"><svg id="cv" xmlns="http://www.w3.org/2000/svg"></svg></div>',
907
+ `<script type="application/json" id="spec">${safeJson(resolved)}</script>`,
908
+ `<script>${SCRIPT}</script>`,
909
+ '</body></html>',
910
+ ].join('\n');
911
+ }
912
+ /** `--dump-dom` 출력에서 자가감사 결과만 뽑는다. */
913
+ export function parseSequenceAudit(dom) {
914
+ const tooBig = /<title>AUDIT_TOO_BIG (\{.*?\})<\/title>/s.exec(dom);
915
+ if (tooBig?.[1]) {
916
+ const size = JSON.parse(tooBig[1]);
917
+ throw new Error(`그림이 너무 큽니다: ${size.w}×${size.h}px ` +
918
+ `(상한 ${MAX_DIM}px / 면적 ${(MAX_AREA / 1_000_000).toFixed(0)}백만px).\n` +
919
+ '참가자를 줄이거나 메시지를 나누세요 — 라벨이 길수록 열이 벌어집니다.');
920
+ }
921
+ const failed = /<title>AUDIT_ERROR (.*?)<\/title>/s.exec(dom);
922
+ if (failed?.[1])
923
+ throw new Error(`그림을 그리다 실패했습니다: ${failed[1]}`);
924
+ const match = /<title>AUDIT (\{.*?\})<\/title>/s.exec(dom);
925
+ if (!match?.[1]) {
926
+ throw new Error('렌더 결과를 읽지 못했습니다 — 페이지 스크립트가 끝까지 돌지 않았습니다.');
927
+ }
928
+ return JSON.parse(match[1]);
929
+ }
930
+ /** 사람이 읽는 감사 요약. */
931
+ export function formatSequenceAudit(a) {
932
+ const lines = [];
933
+ const add = (label, items) => {
934
+ if (items.length)
935
+ lines.push(`- ${label} ${items.length}건: ${items.join(' / ')}`);
936
+ };
937
+ add('글자 삐져나옴', a.over);
938
+ add('카드/막대/노트 겹침', a.collide);
939
+ add('라벨 겹침', a.label);
940
+ add('화살표 관통', a.cross);
941
+ add('프래그먼트 범위', a.frame);
942
+ if (!lines.length)
943
+ return '자가감사 통과 — 삐져나옴, 겹침, 관통, 프래그먼트 범위 0건';
944
+ return `⚠️ 자가감사에서 걸린 것:\n${lines.join('\n')}`;
945
+ }
946
+ //# sourceMappingURL=sequence.js.map