@fmsim/board 2.0.0-beta.101 → 2.0.0-beta.102

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,226 @@
1
+ /*
2
+ * SceneMonitorRenderer — fmsim 커스텀 로딩 오버레이 표현.
3
+ *
4
+ * things-scene 의 LoadingOverlay 렌더러 시임(LoadingOverlayRenderer)을 구현해,
5
+ * 로딩 단계(phaseHistory)를 Network / CPU 카테고리로 묶어 소요시간을 보여준다.
6
+ * 코어(수명주기·데이터·M키·자동표시·타이머)는 things-scene 이 그대로 담당하고,
7
+ * 이 클래스는 표현(DOM)만 담당한다 — 코어를 fork 하지 않는다.
8
+ *
9
+ * this._loadingOverlay.renderer = new SceneMonitorRenderer()
10
+ */
11
+ /** 로딩 phase → 표시 라벨. */
12
+ const PHASE_LABEL = {
13
+ fetch: 'Fetching model',
14
+ assets: 'Loading assets',
15
+ data: 'Waiting for data',
16
+ parse: 'Parsing model',
17
+ compile: 'Building components',
18
+ render: 'Rendering',
19
+ init: 'Initializing'
20
+ };
21
+ /* 인라인 SVG 아이콘 — stroke=currentColor 라 감싸는 span 색(카테고리색)으로 틴팅된다. */
22
+ const ICON_NETWORK = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">'
23
+ + '<circle cx="12" cy="12" r="9"/><line x1="3" y1="12" x2="21" y2="12"/>'
24
+ + '<path d="M12 3a15.3 15.3 0 0 1 4 9 15.3 15.3 0 0 1-4 9 15.3 15.3 0 0 1-4-9 15.3 15.3 0 0 1 4-9z"/></svg>';
25
+ /** CPU = microchip(칩) — 둥근 사각 + 내부 사각 + 8핀(원본 이미지 형태). */
26
+ const ICON_CPU = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">'
27
+ + '<rect x="4" y="4" width="16" height="16" rx="2" ry="2"/><rect x="9" y="9" width="6" height="6"/>'
28
+ + '<line x1="9" y1="1" x2="9" y2="4"/><line x1="15" y1="1" x2="15" y2="4"/>'
29
+ + '<line x1="9" y1="20" x2="9" y2="23"/><line x1="15" y1="20" x2="15" y2="23"/>'
30
+ + '<line x1="20" y1="9" x2="23" y2="9"/><line x1="20" y1="14" x2="23" y2="14"/>'
31
+ + '<line x1="1" y1="9" x2="4" y2="9"/><line x1="1" y1="14" x2="4" y2="14"/></svg>';
32
+ /** 카테고리 정의(표시 순서). Network=파랑, CPU=주황(아이콘도 카테고리색으로 틴팅). */
33
+ const CATEGORIES = [
34
+ { key: 'network', label: 'Network', icon: ICON_NETWORK, color: '#4a9eff', phases: ['fetch', 'assets', 'data'] },
35
+ { key: 'cpu', label: 'CPU', icon: ICON_CPU, color: '#ff9f40', phases: ['parse', 'compile', 'render', 'init'] }
36
+ ];
37
+ function fmt(ms) {
38
+ return `${(ms / 1000).toFixed(1)}s`;
39
+ }
40
+ const DIR_META = {
41
+ leading: { word: 'LEAD', arrow: '◂', color: '#3ddc84' },
42
+ lagging: { word: 'LAG', arrow: '▸', color: '#ff5a5a' },
43
+ ontrack: { word: 'SYNC', arrow: '≈', color: '#aab0b6' }
44
+ };
45
+ const LAG_LEVEL_COLORS = { warn: '#ff4040', critical: '#ff1f1f' };
46
+ const MIN_LAG_RADIUS_MS = 1000;
47
+ function fmtLag(ms) {
48
+ const a = Math.abs(ms);
49
+ return a < 1000 ? `${Math.round(a)}ms` : `${(a / 1000).toFixed(1)}s`;
50
+ }
51
+ /** 정확값(ms, 천단위 구분) — 반올림 손실 없이 그대로. */
52
+ function fmtMs(ms) {
53
+ return `${Math.round(ms).toLocaleString()}ms`;
54
+ }
55
+ /** 부호 있는 정확한 차이(drift) — +뒤처짐 / −앞섬 / ±동일. 부호는 반올림 ms 기준. */
56
+ function fmtDrift(ms) {
57
+ const r = Math.round(ms);
58
+ const sign = r > 0 ? '+' : r < 0 ? '−' : '±';
59
+ return `${sign}${Math.abs(r).toLocaleString()}ms`;
60
+ }
61
+ /** drift 부호(반올림 ms)로 삼분 — LAG(>0)/LEAD(<0)/SYNC(=0). deadzone 없음. */
62
+ function lagDirection(s) {
63
+ const d = Math.round(s.drift);
64
+ return d > 0 ? 'lagging' : d < 0 ? 'leading' : 'ontrack';
65
+ }
66
+ /** 방향색 — lagging 은 level(warn/critical)로 더 진한 적색, leading 녹색, ontrack 회색. */
67
+ function dirColor(direction, level) {
68
+ if (direction === 'lagging')
69
+ return LAG_LEVEL_COLORS[level] || DIR_META.lagging.color;
70
+ return DIR_META[direction].color;
71
+ }
72
+ function lagGauge(s) {
73
+ const { current, baseline, min, max } = s;
74
+ const clamp = (x, lo, hi) => (x < lo ? lo : x > hi ? hi : x);
75
+ const R = Math.max(baseline - min, max - baseline, MIN_LAG_RADIUS_MS);
76
+ const pos = (v) => 50 + 50 * clamp((v - baseline) / R, -1, 1);
77
+ return { curPct: pos(current), minPct: pos(min), maxPct: pos(max) };
78
+ }
79
+ function lagFill(pct) {
80
+ return pct >= 50
81
+ ? `left:50%;width:${(pct - 50).toFixed(2)}%`
82
+ : `left:${pct.toFixed(2)}%;width:${(50 - pct).toFixed(2)}%`;
83
+ }
84
+ /** 방향 배지(pill) — 색·단어·화살표 + 정확한 차이(부호 있는 drift)로 Lag/Lead 를 즉시 구분. */
85
+ function dirPill(direction, drift, color) {
86
+ const m = DIR_META[direction];
87
+ const diff = fmtDrift(drift);
88
+ const label = direction === 'lagging' ? `${m.word} ${diff} ${m.arrow}` : `${m.arrow} ${m.word} ${diff}`;
89
+ return `<span style="display:inline-flex;align-items:center;padding:2px 9px;border-radius:10px;`
90
+ + `background:${color}22;color:${color};border:1px solid ${color}66;`
91
+ + `font-size:11px;font-weight:700;letter-spacing:0.3px;white-space:nowrap">${label}</span>`;
92
+ }
93
+ /** Data Lag 섹션 HTML — baseline 중심 게이지 + 방향 배지 + min/max 눈금 + cur 점. */
94
+ function lagSectionHtml(s) {
95
+ const direction = lagDirection(s);
96
+ const { curPct, minPct, maxPct } = lagGauge(s);
97
+ const color = dirColor(direction, s.level);
98
+ const tick = (p) => `<div style="position:absolute;left:${p}%;top:-1px;width:1px;height:8px;background:rgba(255,255,255,0.28);transform:translateX(-50%)"></div>`;
99
+ return `<div style="display:flex;align-items:center;gap:8px;padding:7px 0 4px;border-top:1px solid rgba(255,255,255,0.07);margin-top:5px">`
100
+ + `<span style="color:rgba(232,234,237,0.6);font-size:12px;flex:1">Data Lag</span>`
101
+ + `<span style="color:rgba(232,234,237,0.5);font-size:11px">cur ${fmtMs(s.current)}</span>`
102
+ + dirPill(direction, s.drift, color)
103
+ + `</div>`
104
+ + `<div style="position:relative;height:6px;background:rgba(255,255,255,0.08);border-radius:3px;margin:3px 0 3px">`
105
+ + `<div style="position:absolute;left:50%;top:-3px;width:1px;height:12px;background:rgba(255,255,255,0.45);transform:translateX(-50%)"></div>`
106
+ + tick(minPct) + tick(maxPct)
107
+ + `<div style="position:absolute;top:50%;height:3px;transform:translateY(-50%);border-radius:2px;background:${color};opacity:0.55;${lagFill(curPct)}"></div>`
108
+ + `<div style="position:absolute;top:50%;width:9px;height:9px;border-radius:50%;background:${color};box-shadow:0 0 0 2px rgba(0,0,0,0.35);transform:translate(-50%,-50%);left:${curPct}%"></div>`
109
+ + `</div>`
110
+ + `<div style="display:flex;align-items:center;font-size:10px;color:rgba(232,234,237,0.4)">`
111
+ + `<span style="flex:1">◂ ahead · min ${fmtLag(s.min)}</span>`
112
+ + `<span style="flex:1;text-align:center;color:rgba(232,234,237,0.3)">base ${fmtLag(s.baseline)} · n ${s.sampleCount}</span>`
113
+ + `<span style="flex:1;text-align:right">max ${fmtLag(s.max)} · behind ▸</span>`
114
+ + `</div>`;
115
+ }
116
+ export class SceneMonitorRenderer {
117
+ build(container, host) {
118
+ const card = document.createElement('div');
119
+ card.style.cssText = [
120
+ 'position:absolute',
121
+ 'top:50%',
122
+ 'left:50%',
123
+ 'transform:translate(-50%,-50%)',
124
+ 'display:flex',
125
+ 'flex-direction:column',
126
+ 'gap:6px',
127
+ 'min-width:340px',
128
+ 'max-width:460px',
129
+ 'padding:14px 18px',
130
+ 'background:#16191d',
131
+ 'border:1px solid rgba(255,255,255,0.08)',
132
+ 'border-radius:10px',
133
+ 'box-shadow:0 8px 28px rgba(0,0,0,0.45)',
134
+ 'pointer-events:auto',
135
+ 'user-select:none',
136
+ 'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif',
137
+ 'color:#e8eaed'
138
+ ].join(';');
139
+ // 헤더: "Scene Monitor" + X
140
+ const header = document.createElement('div');
141
+ header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding-bottom:6px;border-bottom:1px solid rgba(255,255,255,0.07)';
142
+ const title = document.createElement('span');
143
+ title.style.cssText = 'font-weight:700;font-size:15px;color:#f1f3f5';
144
+ title.textContent = 'Scene Monitor';
145
+ this._titleEl = title;
146
+ header.appendChild(title);
147
+ const close = document.createElement('button');
148
+ close.textContent = '✕';
149
+ close.title = 'Close';
150
+ close.style.cssText = 'background:none;border:none;color:rgba(255,255,255,0.55);cursor:pointer;font-size:15px;line-height:1;padding:2px 4px;border-radius:4px';
151
+ close.addEventListener('mouseenter', () => { close.style.color = 'rgba(255,255,255,0.95)'; });
152
+ close.addEventListener('mouseleave', () => { close.style.color = 'rgba(255,255,255,0.55)'; });
153
+ close.addEventListener('click', () => host.hide());
154
+ header.appendChild(close);
155
+ card.appendChild(header);
156
+ // 본문: 카테고리별 그룹(매 tick 재구성 — 소량이라 저렴)
157
+ const body = document.createElement('div');
158
+ body.style.cssText = 'display:flex;flex-direction:column;gap:2px;padding-top:4px';
159
+ this._bodyEl = body;
160
+ card.appendChild(body);
161
+ // 하부 Data Lag 섹션 — state.lag 있을 때만 채워짐(없으면 숨김).
162
+ const lag = document.createElement('div');
163
+ lag.style.display = 'none';
164
+ this._lagEl = lag;
165
+ card.appendChild(lag);
166
+ container.appendChild(card);
167
+ this._card = card;
168
+ }
169
+ update(state) {
170
+ var _a, _b;
171
+ if (this._titleEl) {
172
+ this._titleEl.textContent = state.isComplete ? 'Scene Monitor' : 'Scene Monitor';
173
+ }
174
+ if (!this._bodyEl)
175
+ return;
176
+ // phase → duration(ms). 완료 phase 는 duration, 진행 중은 경과.
177
+ const now = performance.now();
178
+ const durByPhase = new Map();
179
+ for (const r of state.phaseHistory) {
180
+ if (r.phase === 'complete')
181
+ continue;
182
+ const d = r.duration !== undefined ? r.duration : Math.max(0, now - r.timestamp);
183
+ durByPhase.set(r.phase, ((_a = durByPhase.get(r.phase)) !== null && _a !== void 0 ? _a : 0) + d);
184
+ }
185
+ let html = '';
186
+ for (const cat of CATEGORIES) {
187
+ const present = cat.phases.filter(p => durByPhase.has(p));
188
+ if (present.length === 0)
189
+ continue;
190
+ const total = present.reduce((s, p) => { var _a; return s + ((_a = durByPhase.get(p)) !== null && _a !== void 0 ? _a : 0); }, 0);
191
+ // 카테고리 헤더
192
+ html += `<div style="display:flex;align-items:center;gap:6px;padding:5px 0 2px">`
193
+ + `<span style="display:inline-flex;align-items:center;color:${cat.color}">${cat.icon}</span>`
194
+ + `<span style="flex:1;font-weight:600;font-size:13px;color:${cat.color}">${cat.label}</span>`
195
+ + `<span style="font-weight:700;font-size:13px;color:${cat.color}">${fmt(total)}</span>`
196
+ + `</div>`;
197
+ // 하위 단계
198
+ for (const p of present) {
199
+ const label = PHASE_LABEL[p] || p;
200
+ html += `<div style="display:flex;align-items:center;padding:1px 0 1px 22px">`
201
+ + `<span style="flex:1;font-size:12.5px;color:rgba(232,234,237,0.82)">${label}</span>`
202
+ + `<span style="font-size:12.5px;color:rgba(232,234,237,0.55)">${fmt((_b = durByPhase.get(p)) !== null && _b !== void 0 ? _b : 0)}</span>`
203
+ + `</div>`;
204
+ }
205
+ }
206
+ this._bodyEl.innerHTML = html;
207
+ // 하부 Data Lag — 표본 있으면 표시, 없으면 숨김.
208
+ if (this._lagEl) {
209
+ if (state.lag) {
210
+ this._lagEl.style.display = '';
211
+ this._lagEl.innerHTML = lagSectionHtml(state.lag);
212
+ }
213
+ else {
214
+ this._lagEl.style.display = 'none';
215
+ this._lagEl.innerHTML = '';
216
+ }
217
+ }
218
+ }
219
+ destroy() {
220
+ this._card = undefined;
221
+ this._titleEl = undefined;
222
+ this._bodyEl = undefined;
223
+ this._lagEl = undefined;
224
+ }
225
+ }
226
+ //# sourceMappingURL=scene-monitor-renderer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scene-monitor-renderer.js","sourceRoot":"","sources":["../../src/scene-monitor-renderer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,wBAAwB;AACxB,MAAM,WAAW,GAA2B;IAC1C,KAAK,EAAE,gBAAgB;IACvB,MAAM,EAAE,gBAAgB;IACxB,IAAI,EAAE,kBAAkB;IACxB,KAAK,EAAE,eAAe;IACtB,OAAO,EAAE,qBAAqB;IAC9B,MAAM,EAAE,WAAW;IACnB,IAAI,EAAE,cAAc;CACrB,CAAA;AAUD,mEAAmE;AACnE,MAAM,YAAY,GAChB,oJAAoJ;MAClJ,uEAAuE;MACvE,0GAA0G,CAAA;AAC9G,0DAA0D;AAC1D,MAAM,QAAQ,GACZ,oJAAoJ;MAClJ,kGAAkG;MAClG,0EAA0E;MAC1E,8EAA8E;MAC9E,8EAA8E;MAC9E,gFAAgF,CAAA;AAEpF,2DAA2D;AAC3D,MAAM,UAAU,GAAe;IAC7B,EAAE,GAAG,EAAE,SAAS,EAAE,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,YAAY,EAAE,KAAK,EAAE,SAAS,EAAE,MAAM,EAAE,CAAC,OAAO,EAAE,QAAQ,EAAE,MAAM,CAAC,EAAE;IAC/G,EAAE,GAAG,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,QAAQ,EAAE,KAAK,EAAE,SAAS,EAAE,MAAM,EAAE,CAAC,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,CAAC,EAAE;CAC/G,CAAA;AAED,SAAS,GAAG,CAAC,EAAU;IACrB,OAAO,GAAG,CAAC,EAAE,GAAG,IAAI,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,CAAA;AACrC,CAAC;AASD,MAAM,QAAQ,GAAyE;IACrF,OAAO,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,GAAG,EAAE,KAAK,EAAE,SAAS,EAAE;IACvD,OAAO,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,EAAE,GAAG,EAAE,KAAK,EAAE,SAAS,EAAE;IACtD,OAAO,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,GAAG,EAAE,KAAK,EAAE,SAAS,EAAE;CACxD,CAAA;AACD,MAAM,gBAAgB,GAA2B,EAAE,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,SAAS,EAAE,CAAA;AACzF,MAAM,iBAAiB,GAAG,IAAI,CAAA;AAE9B,SAAS,MAAM,CAAC,EAAU;IACxB,MAAM,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,CAAA;IACtB,OAAO,CAAC,GAAG,IAAI,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,CAAA;AACtE,CAAC;AAED,uCAAuC;AACvC,SAAS,KAAK,CAAC,EAAU;IACvB,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC,cAAc,EAAE,IAAI,CAAA;AAC/C,CAAC;AAED,6DAA6D;AAC7D,SAAS,QAAQ,CAAC,EAAU;IAC1B,MAAM,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,CAAA;IACxB,MAAM,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,CAAA;IAC5C,OAAO,GAAG,IAAI,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,cAAc,EAAE,IAAI,CAAA;AACnD,CAAC;AAED,qEAAqE;AACrE,SAAS,YAAY,CAAC,CAAW;IAC/B,MAAM,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,CAAA;IAC7B,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,SAAS,CAAA;AAC1D,CAAC;AAED,6EAA6E;AAC7E,SAAS,QAAQ,CAAC,SAAuB,EAAE,KAAa;IACtD,IAAI,SAAS,KAAK,SAAS;QAAE,OAAO,gBAAgB,CAAC,KAAK,CAAC,IAAI,QAAQ,CAAC,OAAO,CAAC,KAAK,CAAA;IACrF,OAAO,QAAQ,CAAC,SAAS,CAAC,CAAC,KAAK,CAAA;AAClC,CAAC;AAED,SAAS,QAAQ,CAAC,CAAW;IAC3B,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,CAAA;IACzC,MAAM,KAAK,GAAG,CAAC,CAAS,EAAE,EAAU,EAAE,EAAU,EAAE,EAAE,CAAC,CAAC,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;IACpF,MAAM,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,QAAQ,GAAG,GAAG,EAAE,GAAG,GAAG,QAAQ,EAAE,iBAAiB,CAAC,CAAA;IACrE,MAAM,GAAG,GAAG,CAAC,CAAS,EAAE,EAAE,CAAC,EAAE,GAAG,EAAE,GAAG,KAAK,CAAC,CAAC,CAAC,GAAG,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,CAAA;IACrE,OAAO,EAAE,MAAM,EAAE,GAAG,CAAC,OAAO,CAAC,EAAE,MAAM,EAAE,GAAG,CAAC,GAAG,CAAC,EAAE,MAAM,EAAE,GAAG,CAAC,GAAG,CAAC,EAAE,CAAA;AACrE,CAAC;AAED,SAAS,OAAO,CAAC,GAAW;IAC1B,OAAO,GAAG,IAAI,EAAE;QACd,CAAC,CAAC,kBAAkB,CAAC,GAAG,GAAG,EAAE,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG;QAC5C,CAAC,CAAC,QAAQ,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,WAAW,CAAC,EAAE,GAAG,GAAG,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,CAAA;AAC/D,CAAC;AAED,sEAAsE;AACtE,SAAS,OAAO,CAAC,SAAuB,EAAE,KAAa,EAAE,KAAa;IACpE,MAAM,CAAC,GAAG,QAAQ,CAAC,SAAS,CAAC,CAAA;IAC7B,MAAM,IAAI,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAA;IAC5B,MAAM,KAAK,GAAG,SAAS,KAAK,SAAS,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,IAAI,IAAI,IAAI,CAAC,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,KAAK,IAAI,CAAC,CAAC,IAAI,IAAI,IAAI,EAAE,CAAA;IACvG,OAAO,yFAAyF;UAC5F,cAAc,KAAK,YAAY,KAAK,qBAAqB,KAAK,KAAK;UACnE,2EAA2E,KAAK,SAAS,CAAA;AAC/F,CAAC;AAED,uEAAuE;AACvE,SAAS,cAAc,CAAC,CAAW;IACjC,MAAM,SAAS,GAAG,YAAY,CAAC,CAAC,CAAC,CAAA;IACjC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,QAAQ,CAAC,CAAC,CAAC,CAAA;IAC9C,MAAM,KAAK,GAAG,QAAQ,CAAC,SAAS,EAAE,CAAC,CAAC,KAAK,CAAC,CAAA;IAC1C,MAAM,IAAI,GAAG,CAAC,CAAS,EAAE,EAAE,CACzB,sCAAsC,CAAC,sGAAsG,CAAA;IAC/I,OAAO,oIAAoI;UACrI,iFAAiF;UACjF,gEAAgE,KAAK,CAAC,CAAC,CAAC,OAAO,CAAC,SAAS;UACzF,OAAO,CAAC,SAAS,EAAE,CAAC,CAAC,KAAK,EAAE,KAAK,CAAC;UAClC,QAAQ;UACV,iHAAiH;UAC/G,4IAA4I;UAC5I,IAAI,CAAC,MAAM,CAAC,GAAG,IAAI,CAAC,MAAM,CAAC;UAC3B,4GAA4G,KAAK,iBAAiB,OAAO,CAAC,MAAM,CAAC,UAAU;UAC3J,2FAA2F,KAAK,8EAA8E,MAAM,WAAW;UAC/L,QAAQ;UACV,0FAA0F;UACxF,sCAAsC,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,SAAS;UAC5D,2EAA2E,MAAM,CAAC,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC,WAAW,SAAS;UAC3H,6CAA6C,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,oBAAoB;UAC9E,QAAQ,CAAA;AAChB,CAAC;AAED,MAAM,OAAO,oBAAoB;IAM/B,KAAK,CAAC,SAAsB,EAAE,IAAwB;QACpD,MAAM,IAAI,GAAG,QAAQ,CAAC,aAAa,CAAC,KAAK,CAAC,CAAA;QAC1C,IAAI,CAAC,KAAK,CAAC,OAAO,GAAG;YACnB,mBAAmB;YACnB,SAAS;YACT,UAAU;YACV,gCAAgC;YAChC,cAAc;YACd,uBAAuB;YACvB,SAAS;YACT,iBAAiB;YACjB,iBAAiB;YACjB,mBAAmB;YACnB,oBAAoB;YACpB,yCAAyC;YACzC,oBAAoB;YACpB,wCAAwC;YACxC,qBAAqB;YACrB,kBAAkB;YAClB,2EAA2E;YAC3E,eAAe;SAChB,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA;QAEX,0BAA0B;QAC1B,MAAM,MAAM,GAAG,QAAQ,CAAC,aAAa,CAAC,KAAK,CAAC,CAAA;QAC5C,MAAM,CAAC,KAAK,CAAC,OAAO,GAAG,iIAAiI,CAAA;QAExJ,MAAM,KAAK,GAAG,QAAQ,CAAC,aAAa,CAAC,MAAM,CAAC,CAAA;QAC5C,KAAK,CAAC,KAAK,CAAC,OAAO,GAAG,8CAA8C,CAAA;QACpE,KAAK,CAAC,WAAW,GAAG,eAAe,CAAA;QACnC,IAAI,CAAC,QAAQ,GAAG,KAAK,CAAA;QACrB,MAAM,CAAC,WAAW,CAAC,KAAK,CAAC,CAAA;QAEzB,MAAM,KAAK,GAAG,QAAQ,CAAC,aAAa,CAAC,QAAQ,CAAC,CAAA;QAC9C,KAAK,CAAC,WAAW,GAAG,GAAG,CAAA;QACvB,KAAK,CAAC,KAAK,GAAG,OAAO,CAAA;QACrB,KAAK,CAAC,KAAK,CAAC,OAAO,GAAG,wIAAwI,CAAA;QAC9J,KAAK,CAAC,gBAAgB,CAAC,YAAY,EAAE,GAAG,EAAE,GAAG,KAAK,CAAC,KAAK,CAAC,KAAK,GAAG,wBAAwB,CAAA,CAAC,CAAC,CAAC,CAAA;QAC5F,KAAK,CAAC,gBAAgB,CAAC,YAAY,EAAE,GAAG,EAAE,GAAG,KAAK,CAAC,KAAK,CAAC,KAAK,GAAG,wBAAwB,CAAA,CAAC,CAAC,CAAC,CAAA;QAC5F,KAAK,CAAC,gBAAgB,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,CAAA;QAClD,MAAM,CAAC,WAAW,CAAC,KAAK,CAAC,CAAA;QAEzB,IAAI,CAAC,WAAW,CAAC,MAAM,CAAC,CAAA;QAExB,qCAAqC;QACrC,MAAM,IAAI,GAAG,QAAQ,CAAC,aAAa,CAAC,KAAK,CAAC,CAAA;QAC1C,IAAI,CAAC,KAAK,CAAC,OAAO,GAAG,4DAA4D,CAAA;QACjF,IAAI,CAAC,OAAO,GAAG,IAAI,CAAA;QACnB,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,CAAA;QAEtB,gDAAgD;QAChD,MAAM,GAAG,GAAG,QAAQ,CAAC,aAAa,CAAC,KAAK,CAAC,CAAA;QACzC,GAAG,CAAC,KAAK,CAAC,OAAO,GAAG,MAAM,CAAA;QAC1B,IAAI,CAAC,MAAM,GAAG,GAAG,CAAA;QACjB,IAAI,CAAC,WAAW,CAAC,GAAG,CAAC,CAAA;QAErB,SAAS,CAAC,WAAW,CAAC,IAAI,CAAC,CAAA;QAC3B,IAAI,CAAC,KAAK,GAAG,IAAI,CAAA;IACnB,CAAC;IAED,MAAM,CAAC,KAA0B;;QAC/B,IAAI,IAAI,CAAC,QAAQ,EAAE,CAAC;YAClB,IAAI,CAAC,QAAQ,CAAC,WAAW,GAAG,KAAK,CAAC,UAAU,CAAC,CAAC,CAAC,eAAe,CAAC,CAAC,CAAC,eAAe,CAAA;QAClF,CAAC;QACD,IAAI,CAAC,IAAI,CAAC,OAAO;YAAE,OAAM;QAEzB,uDAAuD;QACvD,MAAM,GAAG,GAAG,WAAW,CAAC,GAAG,EAAE,CAAA;QAC7B,MAAM,UAAU,GAAG,IAAI,GAAG,EAAkB,CAAA;QAC5C,KAAK,MAAM,CAAC,IAAI,KAAK,CAAC,YAAY,EAAE,CAAC;YACnC,IAAI,CAAC,CAAC,KAAK,KAAK,UAAU;gBAAE,SAAQ;YACpC,MAAM,CAAC,GAAG,CAAC,CAAC,QAAQ,KAAK,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,GAAG,GAAG,CAAC,CAAC,SAAS,CAAC,CAAA;YAChF,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,KAAK,EAAE,CAAC,MAAA,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,mCAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAA;QAC7D,CAAC;QAED,IAAI,IAAI,GAAG,EAAE,CAAA;QACb,KAAK,MAAM,GAAG,IAAI,UAAU,EAAE,CAAC;YAC7B,MAAM,OAAO,GAAG,GAAG,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAA;YACzD,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC;gBAAE,SAAQ;YAElC,MAAM,KAAK,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,WAAC,OAAA,CAAC,GAAG,CAAC,MAAA,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,mCAAI,CAAC,CAAC,CAAA,EAAA,EAAE,CAAC,CAAC,CAAA;YAEvE,UAAU;YACV,IAAI,IAAI,yEAAyE;kBAC7E,6DAA6D,GAAG,CAAC,KAAK,KAAK,GAAG,CAAC,IAAI,SAAS;kBAC5F,4DAA4D,GAAG,CAAC,KAAK,KAAK,GAAG,CAAC,KAAK,SAAS;kBAC5F,qDAAqD,GAAG,CAAC,KAAK,KAAK,GAAG,CAAC,KAAK,CAAC,SAAS;kBACtF,QAAQ,CAAA;YAEZ,QAAQ;YACR,KAAK,MAAM,CAAC,IAAI,OAAO,EAAE,CAAC;gBACxB,MAAM,KAAK,GAAG,WAAW,CAAC,CAAC,CAAC,IAAI,CAAC,CAAA;gBACjC,IAAI,IAAI,sEAAsE;sBAC1E,sEAAsE,KAAK,SAAS;sBACpF,+DAA+D,GAAG,CAAC,MAAA,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,mCAAI,CAAC,CAAC,SAAS;sBACnG,QAAQ,CAAA;YACd,CAAC;QACH,CAAC;QAED,IAAI,CAAC,OAAO,CAAC,SAAS,GAAG,IAAI,CAAA;QAE7B,mCAAmC;QACnC,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC;YAChB,IAAI,KAAK,CAAC,GAAG,EAAE,CAAC;gBACd,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,OAAO,GAAG,EAAE,CAAA;gBAC9B,IAAI,CAAC,MAAM,CAAC,SAAS,GAAG,cAAc,CAAC,KAAK,CAAC,GAAG,CAAC,CAAA;YACnD,CAAC;iBAAM,CAAC;gBACN,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,OAAO,GAAG,MAAM,CAAA;gBAClC,IAAI,CAAC,MAAM,CAAC,SAAS,GAAG,EAAE,CAAA;YAC5B,CAAC;QACH,CAAC;IACH,CAAC;IAED,OAAO;QACL,IAAI,CAAC,KAAK,GAAG,SAAS,CAAA;QACtB,IAAI,CAAC,QAAQ,GAAG,SAAS,CAAA;QACzB,IAAI,CAAC,OAAO,GAAG,SAAS,CAAA;QACxB,IAAI,CAAC,MAAM,GAAG,SAAS,CAAA;IACzB,CAAC;CACF","sourcesContent":["/*\n * SceneMonitorRenderer — fmsim 커스텀 로딩 오버레이 표현.\n *\n * things-scene 의 LoadingOverlay 렌더러 시임(LoadingOverlayRenderer)을 구현해,\n * 로딩 단계(phaseHistory)를 Network / CPU 카테고리로 묶어 소요시간을 보여준다.\n * 코어(수명주기·데이터·M키·자동표시·타이머)는 things-scene 이 그대로 담당하고,\n * 이 클래스는 표현(DOM)만 담당한다 — 코어를 fork 하지 않는다.\n *\n * this._loadingOverlay.renderer = new SceneMonitorRenderer()\n */\n\nimport type { LoadingOverlayRenderer, LoadingOverlayState, LoadingOverlayHost, LagStats } from '@hatiolab/things-scene'\n\n/** 로딩 phase → 표시 라벨. */\nconst PHASE_LABEL: Record<string, string> = {\n fetch: 'Fetching model',\n assets: 'Loading assets',\n data: 'Waiting for data',\n parse: 'Parsing model',\n compile: 'Building components',\n render: 'Rendering',\n init: 'Initializing'\n}\n\ninterface Category {\n key: string\n label: string\n icon: string\n color: string\n phases: string[]\n}\n\n/* 인라인 SVG 아이콘 — stroke=currentColor 라 감싸는 span 색(카테고리색)으로 틴팅된다. */\nconst ICON_NETWORK =\n '<svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">'\n + '<circle cx=\"12\" cy=\"12\" r=\"9\"/><line x1=\"3\" y1=\"12\" x2=\"21\" y2=\"12\"/>'\n + '<path d=\"M12 3a15.3 15.3 0 0 1 4 9 15.3 15.3 0 0 1-4 9 15.3 15.3 0 0 1-4-9 15.3 15.3 0 0 1 4-9z\"/></svg>'\n/** CPU = microchip(칩) — 둥근 사각 + 내부 사각 + 8핀(원본 이미지 형태). */\nconst ICON_CPU =\n '<svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">'\n + '<rect x=\"4\" y=\"4\" width=\"16\" height=\"16\" rx=\"2\" ry=\"2\"/><rect x=\"9\" y=\"9\" width=\"6\" height=\"6\"/>'\n + '<line x1=\"9\" y1=\"1\" x2=\"9\" y2=\"4\"/><line x1=\"15\" y1=\"1\" x2=\"15\" y2=\"4\"/>'\n + '<line x1=\"9\" y1=\"20\" x2=\"9\" y2=\"23\"/><line x1=\"15\" y1=\"20\" x2=\"15\" y2=\"23\"/>'\n + '<line x1=\"20\" y1=\"9\" x2=\"23\" y2=\"9\"/><line x1=\"20\" y1=\"14\" x2=\"23\" y2=\"14\"/>'\n + '<line x1=\"1\" y1=\"9\" x2=\"4\" y2=\"9\"/><line x1=\"1\" y1=\"14\" x2=\"4\" y2=\"14\"/></svg>'\n\n/** 카테고리 정의(표시 순서). Network=파랑, CPU=주황(아이콘도 카테고리색으로 틴팅). */\nconst CATEGORIES: Category[] = [\n { key: 'network', label: 'Network', icon: ICON_NETWORK, color: '#4a9eff', phases: ['fetch', 'assets', 'data'] },\n { key: 'cpu', label: 'CPU', icon: ICON_CPU, color: '#ff9f40', phases: ['parse', 'compile', 'render', 'init'] }\n]\n\nfunction fmt(ms: number): string {\n return `${(ms / 1000).toFixed(1)}s`\n}\n\n// ── Data Lag 게이지(하부 — state.lag 있을 때만 표시) ──\n// baseline(초기 평균) 대비 현재 지연을 상태 + 부호 있는 정확한 차이로 표시한다:\n// LAG = 기준보다 지연(drift>0, 뒤처짐) → 적색(▸, level 로 진해짐)\n// LEAD = 기준보다 신선(drift<0, 따라잡음) → 녹색(◂)\n// SYNC = 기준과 동일(drift≈0, ±0ms) → 중립 회색(≈) ※ deadzone 없음, 반올림 0ms 일 때만\n// 배지 예: \"LAG +1,234ms ▸\". knob·채움·배지가 방향색을 공유.\ntype LagDirection = 'leading' | 'lagging' | 'ontrack'\nconst DIR_META: Record<LagDirection, { word: string; arrow: string; color: string }> = {\n leading: { word: 'LEAD', arrow: '◂', color: '#3ddc84' },\n lagging: { word: 'LAG', arrow: '▸', color: '#ff5a5a' },\n ontrack: { word: 'SYNC', arrow: '≈', color: '#aab0b6' }\n}\nconst LAG_LEVEL_COLORS: Record<string, string> = { warn: '#ff4040', critical: '#ff1f1f' }\nconst MIN_LAG_RADIUS_MS = 1000\n\nfunction fmtLag(ms: number): string {\n const a = Math.abs(ms)\n return a < 1000 ? `${Math.round(a)}ms` : `${(a / 1000).toFixed(1)}s`\n}\n\n/** 정확값(ms, 천단위 구분) — 반올림 손실 없이 그대로. */\nfunction fmtMs(ms: number): string {\n return `${Math.round(ms).toLocaleString()}ms`\n}\n\n/** 부호 있는 정확한 차이(drift) — +뒤처짐 / −앞섬 / ±동일. 부호는 반올림 ms 기준. */\nfunction fmtDrift(ms: number): string {\n const r = Math.round(ms)\n const sign = r > 0 ? '+' : r < 0 ? '−' : '±'\n return `${sign}${Math.abs(r).toLocaleString()}ms`\n}\n\n/** drift 부호(반올림 ms)로 삼분 — LAG(>0)/LEAD(<0)/SYNC(=0). deadzone 없음. */\nfunction lagDirection(s: LagStats): LagDirection {\n const d = Math.round(s.drift)\n return d > 0 ? 'lagging' : d < 0 ? 'leading' : 'ontrack'\n}\n\n/** 방향색 — lagging 은 level(warn/critical)로 더 진한 적색, leading 녹색, ontrack 회색. */\nfunction dirColor(direction: LagDirection, level: string): string {\n if (direction === 'lagging') return LAG_LEVEL_COLORS[level] || DIR_META.lagging.color\n return DIR_META[direction].color\n}\n\nfunction lagGauge(s: LagStats): { curPct: number; minPct: number; maxPct: number } {\n const { current, baseline, min, max } = s\n const clamp = (x: number, lo: number, hi: number) => (x < lo ? lo : x > hi ? hi : x)\n const R = Math.max(baseline - min, max - baseline, MIN_LAG_RADIUS_MS)\n const pos = (v: number) => 50 + 50 * clamp((v - baseline) / R, -1, 1)\n return { curPct: pos(current), minPct: pos(min), maxPct: pos(max) }\n}\n\nfunction lagFill(pct: number): string {\n return pct >= 50\n ? `left:50%;width:${(pct - 50).toFixed(2)}%`\n : `left:${pct.toFixed(2)}%;width:${(50 - pct).toFixed(2)}%`\n}\n\n/** 방향 배지(pill) — 색·단어·화살표 + 정확한 차이(부호 있는 drift)로 Lag/Lead 를 즉시 구분. */\nfunction dirPill(direction: LagDirection, drift: number, color: string): string {\n const m = DIR_META[direction]\n const diff = fmtDrift(drift)\n const label = direction === 'lagging' ? `${m.word} ${diff} ${m.arrow}` : `${m.arrow} ${m.word} ${diff}`\n return `<span style=\"display:inline-flex;align-items:center;padding:2px 9px;border-radius:10px;`\n + `background:${color}22;color:${color};border:1px solid ${color}66;`\n + `font-size:11px;font-weight:700;letter-spacing:0.3px;white-space:nowrap\">${label}</span>`\n}\n\n/** Data Lag 섹션 HTML — baseline 중심 게이지 + 방향 배지 + min/max 눈금 + cur 점. */\nfunction lagSectionHtml(s: LagStats): string {\n const direction = lagDirection(s)\n const { curPct, minPct, maxPct } = lagGauge(s)\n const color = dirColor(direction, s.level)\n const tick = (p: number) =>\n `<div style=\"position:absolute;left:${p}%;top:-1px;width:1px;height:8px;background:rgba(255,255,255,0.28);transform:translateX(-50%)\"></div>`\n return `<div style=\"display:flex;align-items:center;gap:8px;padding:7px 0 4px;border-top:1px solid rgba(255,255,255,0.07);margin-top:5px\">`\n + `<span style=\"color:rgba(232,234,237,0.6);font-size:12px;flex:1\">Data Lag</span>`\n + `<span style=\"color:rgba(232,234,237,0.5);font-size:11px\">cur ${fmtMs(s.current)}</span>`\n + dirPill(direction, s.drift, color)\n + `</div>`\n + `<div style=\"position:relative;height:6px;background:rgba(255,255,255,0.08);border-radius:3px;margin:3px 0 3px\">`\n + `<div style=\"position:absolute;left:50%;top:-3px;width:1px;height:12px;background:rgba(255,255,255,0.45);transform:translateX(-50%)\"></div>`\n + tick(minPct) + tick(maxPct)\n + `<div style=\"position:absolute;top:50%;height:3px;transform:translateY(-50%);border-radius:2px;background:${color};opacity:0.55;${lagFill(curPct)}\"></div>`\n + `<div style=\"position:absolute;top:50%;width:9px;height:9px;border-radius:50%;background:${color};box-shadow:0 0 0 2px rgba(0,0,0,0.35);transform:translate(-50%,-50%);left:${curPct}%\"></div>`\n + `</div>`\n + `<div style=\"display:flex;align-items:center;font-size:10px;color:rgba(232,234,237,0.4)\">`\n + `<span style=\"flex:1\">◂ ahead · min ${fmtLag(s.min)}</span>`\n + `<span style=\"flex:1;text-align:center;color:rgba(232,234,237,0.3)\">base ${fmtLag(s.baseline)} · n ${s.sampleCount}</span>`\n + `<span style=\"flex:1;text-align:right\">max ${fmtLag(s.max)} · behind ▸</span>`\n + `</div>`\n}\n\nexport class SceneMonitorRenderer implements LoadingOverlayRenderer {\n private _card?: HTMLElement\n private _titleEl?: HTMLElement\n private _bodyEl?: HTMLElement\n private _lagEl?: HTMLElement\n\n build(container: HTMLElement, host: LoadingOverlayHost): void {\n const card = document.createElement('div')\n card.style.cssText = [\n 'position:absolute',\n 'top:50%',\n 'left:50%',\n 'transform:translate(-50%,-50%)',\n 'display:flex',\n 'flex-direction:column',\n 'gap:6px',\n 'min-width:340px',\n 'max-width:460px',\n 'padding:14px 18px',\n 'background:#16191d',\n 'border:1px solid rgba(255,255,255,0.08)',\n 'border-radius:10px',\n 'box-shadow:0 8px 28px rgba(0,0,0,0.45)',\n 'pointer-events:auto',\n 'user-select:none',\n 'font-family:-apple-system,BlinkMacSystemFont,\"Segoe UI\",Roboto,sans-serif',\n 'color:#e8eaed'\n ].join(';')\n\n // 헤더: \"Scene Monitor\" + X\n const header = document.createElement('div')\n header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding-bottom:6px;border-bottom:1px solid rgba(255,255,255,0.07)'\n\n const title = document.createElement('span')\n title.style.cssText = 'font-weight:700;font-size:15px;color:#f1f3f5'\n title.textContent = 'Scene Monitor'\n this._titleEl = title\n header.appendChild(title)\n\n const close = document.createElement('button')\n close.textContent = '✕'\n close.title = 'Close'\n close.style.cssText = 'background:none;border:none;color:rgba(255,255,255,0.55);cursor:pointer;font-size:15px;line-height:1;padding:2px 4px;border-radius:4px'\n close.addEventListener('mouseenter', () => { close.style.color = 'rgba(255,255,255,0.95)' })\n close.addEventListener('mouseleave', () => { close.style.color = 'rgba(255,255,255,0.55)' })\n close.addEventListener('click', () => host.hide())\n header.appendChild(close)\n\n card.appendChild(header)\n\n // 본문: 카테고리별 그룹(매 tick 재구성 — 소량이라 저렴)\n const body = document.createElement('div')\n body.style.cssText = 'display:flex;flex-direction:column;gap:2px;padding-top:4px'\n this._bodyEl = body\n card.appendChild(body)\n\n // 하부 Data Lag 섹션 — state.lag 있을 때만 채워짐(없으면 숨김).\n const lag = document.createElement('div')\n lag.style.display = 'none'\n this._lagEl = lag\n card.appendChild(lag)\n\n container.appendChild(card)\n this._card = card\n }\n\n update(state: LoadingOverlayState): void {\n if (this._titleEl) {\n this._titleEl.textContent = state.isComplete ? 'Scene Monitor' : 'Scene Monitor'\n }\n if (!this._bodyEl) return\n\n // phase → duration(ms). 완료 phase 는 duration, 진행 중은 경과.\n const now = performance.now()\n const durByPhase = new Map<string, number>()\n for (const r of state.phaseHistory) {\n if (r.phase === 'complete') continue\n const d = r.duration !== undefined ? r.duration : Math.max(0, now - r.timestamp)\n durByPhase.set(r.phase, (durByPhase.get(r.phase) ?? 0) + d)\n }\n\n let html = ''\n for (const cat of CATEGORIES) {\n const present = cat.phases.filter(p => durByPhase.has(p))\n if (present.length === 0) continue\n\n const total = present.reduce((s, p) => s + (durByPhase.get(p) ?? 0), 0)\n\n // 카테고리 헤더\n html += `<div style=\"display:flex;align-items:center;gap:6px;padding:5px 0 2px\">`\n + `<span style=\"display:inline-flex;align-items:center;color:${cat.color}\">${cat.icon}</span>`\n + `<span style=\"flex:1;font-weight:600;font-size:13px;color:${cat.color}\">${cat.label}</span>`\n + `<span style=\"font-weight:700;font-size:13px;color:${cat.color}\">${fmt(total)}</span>`\n + `</div>`\n\n // 하위 단계\n for (const p of present) {\n const label = PHASE_LABEL[p] || p\n html += `<div style=\"display:flex;align-items:center;padding:1px 0 1px 22px\">`\n + `<span style=\"flex:1;font-size:12.5px;color:rgba(232,234,237,0.82)\">${label}</span>`\n + `<span style=\"font-size:12.5px;color:rgba(232,234,237,0.55)\">${fmt(durByPhase.get(p) ?? 0)}</span>`\n + `</div>`\n }\n }\n\n this._bodyEl.innerHTML = html\n\n // 하부 Data Lag — 표본 있으면 표시, 없으면 숨김.\n if (this._lagEl) {\n if (state.lag) {\n this._lagEl.style.display = ''\n this._lagEl.innerHTML = lagSectionHtml(state.lag)\n } else {\n this._lagEl.style.display = 'none'\n this._lagEl.innerHTML = ''\n }\n }\n }\n\n destroy(): void {\n this._card = undefined\n this._titleEl = undefined\n this._bodyEl = undefined\n this._lagEl = undefined\n }\n}\n"]}