@fmsim/board 2.0.0-beta.16 → 2.0.0-beta.21

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,215 @@
1
+ /**
2
+ * PlaybackEngine
3
+ *
4
+ * 서버에서 받은 시계열 데이터(snapshot + diff)를 시간순으로 재생한다.
5
+ * snapshot/diff 구분 없이 각 프레임의 data를 그대로 scene 컴포넌트에 바인딩한다.
6
+ *
7
+ * 사용법:
8
+ * const engine = new PlaybackEngine()
9
+ * await engine.load(startTime, endTime)
10
+ * engine.attachScene(scene)
11
+ * engine.play(speed)
12
+ * engine.pause()
13
+ * engine.seekTo(time)
14
+ * engine.stop()
15
+ */
16
+ export class PlaybackEngine {
17
+ constructor() {
18
+ this._state = 'idle';
19
+ this._frames = [];
20
+ this._currentIndex = 0;
21
+ this._speed = 1;
22
+ this._scene = null;
23
+ this._scenarioNames = [];
24
+ this._listeners = new Map();
25
+ }
26
+ // ── 공개 API ──
27
+ get state() { return this._state; }
28
+ get currentIndex() { return this._currentIndex; }
29
+ get totalFrames() { return this._frames.length; }
30
+ get currentTime() { var _a, _b; return (_b = (_a = this._frames[this._currentIndex]) === null || _a === void 0 ? void 0 : _a.time) !== null && _b !== void 0 ? _b : null; }
31
+ get scenarioNames() { return this._scenarioNames; }
32
+ /**
33
+ * 데이터 로드. fetchPlayback을 외부에서 호출하여 결과를 전달한다.
34
+ */
35
+ loadData(scenarios) {
36
+ this._setState('loading');
37
+ this._scenarioNames = scenarios.map(s => s.scenarioName);
38
+ // 모든 시나리오의 프레임을 시간 기준으로 병합
39
+ const timeMap = new Map();
40
+ for (const scenario of scenarios) {
41
+ for (const frame of scenario.snapshots) {
42
+ if (!timeMap.has(frame.time)) {
43
+ timeMap.set(frame.time, new Map());
44
+ }
45
+ timeMap.get(frame.time).set(scenario.scenarioName, frame.data);
46
+ }
47
+ }
48
+ // 시간순 정렬
49
+ const sortedTimes = [...timeMap.keys()].sort();
50
+ this._frames = sortedTimes.map(time => ({
51
+ time,
52
+ data: timeMap.get(time)
53
+ }));
54
+ this._currentIndex = 0;
55
+ this._setState('stopped');
56
+ this._emit('load-complete', {
57
+ totalFrames: this._frames.length,
58
+ scenarios: this._scenarioNames
59
+ });
60
+ }
61
+ attachScene(scene) {
62
+ this._scene = scene;
63
+ }
64
+ detachScene() {
65
+ this._scene = null;
66
+ }
67
+ play(speed) {
68
+ if (this._frames.length === 0)
69
+ return;
70
+ if (speed != null)
71
+ this._speed = speed;
72
+ // 첫 프레임 즉시 적용
73
+ this._applyCurrentFrame();
74
+ this._setState('playing');
75
+ this._startTimer();
76
+ }
77
+ pause() {
78
+ this._stopTimer();
79
+ this._setState('paused');
80
+ }
81
+ resume() {
82
+ if (this._state !== 'paused')
83
+ return;
84
+ this._setState('playing');
85
+ this._startTimer();
86
+ }
87
+ stop() {
88
+ this._stopTimer();
89
+ this._currentIndex = 0;
90
+ this._setState('stopped');
91
+ }
92
+ setSpeed(speed) {
93
+ this._speed = speed;
94
+ if (this._state === 'playing') {
95
+ this._stopTimer();
96
+ this._startTimer();
97
+ }
98
+ }
99
+ /**
100
+ * 특정 시간으로 이동.
101
+ * 해당 시간 이하의 가장 가까운 프레임을 찾고,
102
+ * snapshot부터 그 프레임까지 누적 적용한다.
103
+ */
104
+ seekTo(time) {
105
+ const target = this._formatTime(time);
106
+ let targetIndex = 0;
107
+ for (let i = 0; i < this._frames.length; i++) {
108
+ if (this._frames[i].time <= target) {
109
+ targetIndex = i;
110
+ }
111
+ else {
112
+ break;
113
+ }
114
+ }
115
+ // snapshot(첫 프레임)부터 targetIndex까지 순차 적용
116
+ for (let i = 0; i <= targetIndex; i++) {
117
+ this._applyFrame(i);
118
+ }
119
+ this._currentIndex = targetIndex;
120
+ this._emitFrame();
121
+ // 재생 중이었으면 타이머 재시작
122
+ if (this._state === 'playing') {
123
+ this._stopTimer();
124
+ this._startTimer();
125
+ }
126
+ }
127
+ /**
128
+ * 이벤트 리스너 등록
129
+ */
130
+ on(event, listener) {
131
+ if (!this._listeners.has(event))
132
+ this._listeners.set(event, new Set());
133
+ this._listeners.get(event).add(listener);
134
+ }
135
+ off(event, listener) {
136
+ var _a;
137
+ (_a = this._listeners.get(event)) === null || _a === void 0 ? void 0 : _a.delete(listener);
138
+ }
139
+ dispose() {
140
+ this._stopTimer();
141
+ this._scene = null;
142
+ this._frames = [];
143
+ this._listeners.clear();
144
+ }
145
+ // ── 내부 ──
146
+ _startTimer() {
147
+ this._stopTimer();
148
+ const intervalMs = 1000 / this._speed;
149
+ this._timer = window.setInterval(() => {
150
+ this._currentIndex++;
151
+ if (this._currentIndex >= this._frames.length) {
152
+ // 재생 완료
153
+ this._currentIndex = this._frames.length - 1;
154
+ this._stopTimer();
155
+ this._setState('stopped');
156
+ return;
157
+ }
158
+ this._applyCurrentFrame();
159
+ this._emitFrame();
160
+ }, intervalMs);
161
+ }
162
+ _stopTimer() {
163
+ if (this._timer != null) {
164
+ clearInterval(this._timer);
165
+ this._timer = undefined;
166
+ }
167
+ }
168
+ _applyCurrentFrame() {
169
+ this._applyFrame(this._currentIndex);
170
+ this._emitFrame();
171
+ }
172
+ _applyFrame(index) {
173
+ var _a, _b;
174
+ const frame = this._frames[index];
175
+ if (!frame || !this._scene)
176
+ return;
177
+ for (const [_scenarioName, records] of frame.data) {
178
+ for (const record of records) {
179
+ if (!record.NAME)
180
+ continue;
181
+ const component = (_b = (_a = this._scene.root) === null || _a === void 0 ? void 0 : _a.findById) === null || _b === void 0 ? void 0 : _b.call(_a, record.NAME);
182
+ if (component) {
183
+ component.data = record;
184
+ }
185
+ }
186
+ }
187
+ }
188
+ _emitFrame() {
189
+ const frame = this._frames[this._currentIndex];
190
+ if (frame) {
191
+ this._emit('frame', {
192
+ time: frame.time,
193
+ index: this._currentIndex,
194
+ total: this._frames.length
195
+ });
196
+ }
197
+ }
198
+ _setState(state) {
199
+ this._state = state;
200
+ this._emit('state-change', state);
201
+ }
202
+ _emit(event, detail) {
203
+ const listeners = this._listeners.get(event);
204
+ if (listeners) {
205
+ for (const fn of listeners) {
206
+ fn(detail);
207
+ }
208
+ }
209
+ }
210
+ _formatTime(date) {
211
+ const pad = (n) => String(n).padStart(2, '0');
212
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
213
+ }
214
+ }
215
+ //# sourceMappingURL=playback-engine.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"playback-engine.js","sourceRoot":"","sources":["../../../src/playback/playback-engine.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAsBH,MAAM,OAAO,cAAc;IAA3B;QACU,WAAM,GAAkB,MAAM,CAAA;QAC9B,YAAO,GAAkB,EAAE,CAAA;QAC3B,kBAAa,GAAG,CAAC,CAAA;QAEjB,WAAM,GAAG,CAAC,CAAA;QACV,WAAM,GAAQ,IAAI,CAAA;QAClB,mBAAc,GAAa,EAAE,CAAA;QAC7B,eAAU,GAAG,IAAI,GAAG,EAAyB,CAAA;IA0NvD,CAAC;IAxNC,eAAe;IAEf,IAAI,KAAK,KAAoB,OAAO,IAAI,CAAC,MAAM,CAAA,CAAC,CAAC;IACjD,IAAI,YAAY,KAAa,OAAO,IAAI,CAAC,aAAa,CAAA,CAAC,CAAC;IACxD,IAAI,WAAW,KAAa,OAAO,IAAI,CAAC,OAAO,CAAC,MAAM,CAAA,CAAC,CAAC;IACxD,IAAI,WAAW,iBAAoB,OAAO,MAAA,MAAA,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,aAAa,CAAC,0CAAE,IAAI,mCAAI,IAAI,CAAA,CAAC,CAAC;IAC1F,IAAI,aAAa,KAAe,OAAO,IAAI,CAAC,cAAc,CAAA,CAAC,CAAC;IAE5D;;OAEG;IACH,QAAQ,CAAC,SAA6B;QACpC,IAAI,CAAC,SAAS,CAAC,SAAS,CAAC,CAAA;QACzB,IAAI,CAAC,cAAc,GAAG,SAAS,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,YAAY,CAAC,CAAA;QAExD,2BAA2B;QAC3B,MAAM,OAAO,GAAG,IAAI,GAAG,EAA8B,CAAA;QAErD,KAAK,MAAM,QAAQ,IAAI,SAAS,EAAE,CAAC;YACjC,KAAK,MAAM,KAAK,IAAI,QAAQ,CAAC,SAAS,EAAE,CAAC;gBACvC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC;oBAC7B,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,IAAI,EAAE,IAAI,GAAG,EAAE,CAAC,CAAA;gBACpC,CAAC;gBACD,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,IAAI,CAAE,CAAC,GAAG,CAAC,QAAQ,CAAC,YAAY,EAAE,KAAK,CAAC,IAAI,CAAC,CAAA;YACjE,CAAC;QACH,CAAC;QAED,SAAS;QACT,MAAM,WAAW,GAAG,CAAC,GAAG,OAAO,CAAC,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAA;QAC9C,IAAI,CAAC,OAAO,GAAG,WAAW,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;YACtC,IAAI;YACJ,IAAI,EAAE,OAAO,CAAC,GAAG,CAAC,IAAI,CAAE;SACzB,CAAC,CAAC,CAAA;QAEH,IAAI,CAAC,aAAa,GAAG,CAAC,CAAA;QACtB,IAAI,CAAC,SAAS,CAAC,SAAS,CAAC,CAAA;QAEzB,IAAI,CAAC,KAAK,CAAC,eAAe,EAAE;YAC1B,WAAW,EAAE,IAAI,CAAC,OAAO,CAAC,MAAM;YAChC,SAAS,EAAE,IAAI,CAAC,cAAc;SAC/B,CAAC,CAAA;IACJ,CAAC;IAED,WAAW,CAAC,KAAU;QACpB,IAAI,CAAC,MAAM,GAAG,KAAK,CAAA;IACrB,CAAC;IAED,WAAW;QACT,IAAI,CAAC,MAAM,GAAG,IAAI,CAAA;IACpB,CAAC;IAED,IAAI,CAAC,KAAc;QACjB,IAAI,IAAI,CAAC,OAAO,CAAC,MAAM,KAAK,CAAC;YAAE,OAAM;QAErC,IAAI,KAAK,IAAI,IAAI;YAAE,IAAI,CAAC,MAAM,GAAG,KAAK,CAAA;QAEtC,cAAc;QACd,IAAI,CAAC,kBAAkB,EAAE,CAAA;QACzB,IAAI,CAAC,SAAS,CAAC,SAAS,CAAC,CAAA;QACzB,IAAI,CAAC,WAAW,EAAE,CAAA;IACpB,CAAC;IAED,KAAK;QACH,IAAI,CAAC,UAAU,EAAE,CAAA;QACjB,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,CAAA;IAC1B,CAAC;IAED,MAAM;QACJ,IAAI,IAAI,CAAC,MAAM,KAAK,QAAQ;YAAE,OAAM;QACpC,IAAI,CAAC,SAAS,CAAC,SAAS,CAAC,CAAA;QACzB,IAAI,CAAC,WAAW,EAAE,CAAA;IACpB,CAAC;IAED,IAAI;QACF,IAAI,CAAC,UAAU,EAAE,CAAA;QACjB,IAAI,CAAC,aAAa,GAAG,CAAC,CAAA;QACtB,IAAI,CAAC,SAAS,CAAC,SAAS,CAAC,CAAA;IAC3B,CAAC;IAED,QAAQ,CAAC,KAAa;QACpB,IAAI,CAAC,MAAM,GAAG,KAAK,CAAA;QACnB,IAAI,IAAI,CAAC,MAAM,KAAK,SAAS,EAAE,CAAC;YAC9B,IAAI,CAAC,UAAU,EAAE,CAAA;YACjB,IAAI,CAAC,WAAW,EAAE,CAAA;QACpB,CAAC;IACH,CAAC;IAED;;;;OAIG;IACH,MAAM,CAAC,IAAU;QACf,MAAM,MAAM,GAAG,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,CAAA;QACrC,IAAI,WAAW,GAAG,CAAC,CAAA;QAEnB,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;YAC7C,IAAI,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,IAAI,IAAI,MAAM,EAAE,CAAC;gBACnC,WAAW,GAAG,CAAC,CAAA;YACjB,CAAC;iBAAM,CAAC;gBACN,MAAK;YACP,CAAC;QACH,CAAC;QAED,wCAAwC;QACxC,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,IAAI,WAAW,EAAE,CAAC,EAAE,EAAE,CAAC;YACtC,IAAI,CAAC,WAAW,CAAC,CAAC,CAAC,CAAA;QACrB,CAAC;QAED,IAAI,CAAC,aAAa,GAAG,WAAW,CAAA;QAChC,IAAI,CAAC,UAAU,EAAE,CAAA;QAEjB,mBAAmB;QACnB,IAAI,IAAI,CAAC,MAAM,KAAK,SAAS,EAAE,CAAC;YAC9B,IAAI,CAAC,UAAU,EAAE,CAAA;YACjB,IAAI,CAAC,WAAW,EAAE,CAAA;QACpB,CAAC;IACH,CAAC;IAED;;OAEG;IACH,EAAE,CAAmC,KAAQ,EAAE,QAA6B;QAC1E,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,KAAK,CAAC;YAAE,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,KAAK,EAAE,IAAI,GAAG,EAAE,CAAC,CAAA;QACtE,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,KAAK,CAAE,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAA;IAC3C,CAAC;IAED,GAAG,CAAmC,KAAQ,EAAE,QAA6B;;QAC3E,MAAA,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,KAAK,CAAC,0CAAE,MAAM,CAAC,QAAQ,CAAC,CAAA;IAC9C,CAAC;IAED,OAAO;QACL,IAAI,CAAC,UAAU,EAAE,CAAA;QACjB,IAAI,CAAC,MAAM,GAAG,IAAI,CAAA;QAClB,IAAI,CAAC,OAAO,GAAG,EAAE,CAAA;QACjB,IAAI,CAAC,UAAU,CAAC,KAAK,EAAE,CAAA;IACzB,CAAC;IAED,WAAW;IAEH,WAAW;QACjB,IAAI,CAAC,UAAU,EAAE,CAAA;QACjB,MAAM,UAAU,GAAG,IAAI,GAAG,IAAI,CAAC,MAAM,CAAA;QAErC,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC,WAAW,CAAC,GAAG,EAAE;YACpC,IAAI,CAAC,aAAa,EAAE,CAAA;YAEpB,IAAI,IAAI,CAAC,aAAa,IAAI,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,CAAC;gBAC9C,QAAQ;gBACR,IAAI,CAAC,aAAa,GAAG,IAAI,CAAC,OAAO,CAAC,MAAM,GAAG,CAAC,CAAA;gBAC5C,IAAI,CAAC,UAAU,EAAE,CAAA;gBACjB,IAAI,CAAC,SAAS,CAAC,SAAS,CAAC,CAAA;gBACzB,OAAM;YACR,CAAC;YAED,IAAI,CAAC,kBAAkB,EAAE,CAAA;YACzB,IAAI,CAAC,UAAU,EAAE,CAAA;QACnB,CAAC,EAAE,UAAU,CAAC,CAAA;IAChB,CAAC;IAEO,UAAU;QAChB,IAAI,IAAI,CAAC,MAAM,IAAI,IAAI,EAAE,CAAC;YACxB,aAAa,CAAC,IAAI,CAAC,MAAM,CAAC,CAAA;YAC1B,IAAI,CAAC,MAAM,GAAG,SAAS,CAAA;QACzB,CAAC;IACH,CAAC;IAEO,kBAAkB;QACxB,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,aAAa,CAAC,CAAA;QACpC,IAAI,CAAC,UAAU,EAAE,CAAA;IACnB,CAAC;IAEO,WAAW,CAAC,KAAa;;QAC/B,MAAM,KAAK,GAAG,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,CAAA;QACjC,IAAI,CAAC,KAAK,IAAI,CAAC,IAAI,CAAC,MAAM;YAAE,OAAM;QAElC,KAAK,MAAM,CAAC,aAAa,EAAE,OAAO,CAAC,IAAI,KAAK,CAAC,IAAI,EAAE,CAAC;YAClD,KAAK,MAAM,MAAM,IAAI,OAAO,EAAE,CAAC;gBAC7B,IAAI,CAAC,MAAM,CAAC,IAAI;oBAAE,SAAQ;gBAC1B,MAAM,SAAS,GAAG,MAAA,MAAA,IAAI,CAAC,MAAM,CAAC,IAAI,0CAAE,QAAQ,mDAAG,MAAM,CAAC,IAAI,CAAC,CAAA;gBAC3D,IAAI,SAAS,EAAE,CAAC;oBACd,SAAS,CAAC,IAAI,GAAG,MAAM,CAAA;gBACzB,CAAC;YACH,CAAC;QACH,CAAC;IACH,CAAC;IAEO,UAAU;QAChB,MAAM,KAAK,GAAG,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,aAAa,CAAC,CAAA;QAC9C,IAAI,KAAK,EAAE,CAAC;YACV,IAAI,CAAC,KAAK,CAAC,OAAO,EAAE;gBAClB,IAAI,EAAE,KAAK,CAAC,IAAI;gBAChB,KAAK,EAAE,IAAI,CAAC,aAAa;gBACzB,KAAK,EAAE,IAAI,CAAC,OAAO,CAAC,MAAM;aAC3B,CAAC,CAAA;QACJ,CAAC;IACH,CAAC;IAEO,SAAS,CAAC,KAAoB;QACpC,IAAI,CAAC,MAAM,GAAG,KAAK,CAAA;QACnB,IAAI,CAAC,KAAK,CAAC,cAAc,EAAE,KAAK,CAAC,CAAA;IACnC,CAAC;IAEO,KAAK,CAAmC,KAAQ,EAAE,MAA2B;QACnF,MAAM,SAAS,GAAG,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,KAAK,CAAC,CAAA;QAC5C,IAAI,SAAS,EAAE,CAAC;YACd,KAAK,MAAM,EAAE,IAAI,SAAS,EAAE,CAAC;gBAC1B,EAA0B,CAAC,MAAM,CAAC,CAAA;YACrC,CAAC;QACH,CAAC;IACH,CAAC;IAEO,WAAW,CAAC,IAAU;QAC5B,MAAM,GAAG,GAAG,CAAC,CAAS,EAAE,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,CAAA;QACrD,OAAO,GAAG,IAAI,CAAC,WAAW,EAAE,IAAI,GAAG,CAAC,IAAI,CAAC,QAAQ,EAAE,GAAG,CAAC,CAAC,IAAI,GAAG,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC,IAAI,GAAG,CAAC,IAAI,CAAC,QAAQ,EAAE,CAAC,IAAI,GAAG,CAAC,IAAI,CAAC,UAAU,EAAE,CAAC,IAAI,GAAG,CAAC,IAAI,CAAC,UAAU,EAAE,CAAC,EAAE,CAAA;IAC/J,CAAC;CACF","sourcesContent":["/**\n * PlaybackEngine\n *\n * 서버에서 받은 시계열 데이터(snapshot + diff)를 시간순으로 재생한다.\n * snapshot/diff 구분 없이 각 프레임의 data를 그대로 scene 컴포넌트에 바인딩한다.\n *\n * 사용법:\n * const engine = new PlaybackEngine()\n * await engine.load(startTime, endTime)\n * engine.attachScene(scene)\n * engine.play(speed)\n * engine.pause()\n * engine.seekTo(time)\n * engine.stop()\n */\n\nimport type { PlaybackFrame, PlaybackScenario } from '@fmsim/api'\n\nexport type PlaybackState = 'idle' | 'loading' | 'playing' | 'paused' | 'stopped'\n\nexport interface PlaybackEventMap {\n 'state-change': PlaybackState\n 'frame': { time: string; index: number; total: number }\n 'error': string\n 'load-complete': { totalFrames: number; scenarios: string[] }\n}\n\ntype PlaybackListener<K extends keyof PlaybackEventMap> = (detail: PlaybackEventMap[K]) => void\n\n/** 시간순 정렬된 통합 프레임 */\ninterface MergedFrame {\n time: string\n /** scenario → records */\n data: Map<string, any[]>\n}\n\nexport class PlaybackEngine {\n private _state: PlaybackState = 'idle'\n private _frames: MergedFrame[] = []\n private _currentIndex = 0\n private _timer?: number\n private _speed = 1\n private _scene: any = null\n private _scenarioNames: string[] = []\n private _listeners = new Map<string, Set<Function>>()\n\n // ── 공개 API ──\n\n get state(): PlaybackState { return this._state }\n get currentIndex(): number { return this._currentIndex }\n get totalFrames(): number { return this._frames.length }\n get currentTime(): string | null { return this._frames[this._currentIndex]?.time ?? null }\n get scenarioNames(): string[] { return this._scenarioNames }\n\n /**\n * 데이터 로드. fetchPlayback을 외부에서 호출하여 결과를 전달한다.\n */\n loadData(scenarios: PlaybackScenario[]): void {\n this._setState('loading')\n this._scenarioNames = scenarios.map(s => s.scenarioName)\n\n // 모든 시나리오의 프레임을 시간 기준으로 병합\n const timeMap = new Map<string, Map<string, any[]>>()\n\n for (const scenario of scenarios) {\n for (const frame of scenario.snapshots) {\n if (!timeMap.has(frame.time)) {\n timeMap.set(frame.time, new Map())\n }\n timeMap.get(frame.time)!.set(scenario.scenarioName, frame.data)\n }\n }\n\n // 시간순 정렬\n const sortedTimes = [...timeMap.keys()].sort()\n this._frames = sortedTimes.map(time => ({\n time,\n data: timeMap.get(time)!\n }))\n\n this._currentIndex = 0\n this._setState('stopped')\n\n this._emit('load-complete', {\n totalFrames: this._frames.length,\n scenarios: this._scenarioNames\n })\n }\n\n attachScene(scene: any): void {\n this._scene = scene\n }\n\n detachScene(): void {\n this._scene = null\n }\n\n play(speed?: number): void {\n if (this._frames.length === 0) return\n\n if (speed != null) this._speed = speed\n\n // 첫 프레임 즉시 적용\n this._applyCurrentFrame()\n this._setState('playing')\n this._startTimer()\n }\n\n pause(): void {\n this._stopTimer()\n this._setState('paused')\n }\n\n resume(): void {\n if (this._state !== 'paused') return\n this._setState('playing')\n this._startTimer()\n }\n\n stop(): void {\n this._stopTimer()\n this._currentIndex = 0\n this._setState('stopped')\n }\n\n setSpeed(speed: number): void {\n this._speed = speed\n if (this._state === 'playing') {\n this._stopTimer()\n this._startTimer()\n }\n }\n\n /**\n * 특정 시간으로 이동.\n * 해당 시간 이하의 가장 가까운 프레임을 찾고,\n * snapshot부터 그 프레임까지 누적 적용한다.\n */\n seekTo(time: Date): void {\n const target = this._formatTime(time)\n let targetIndex = 0\n\n for (let i = 0; i < this._frames.length; i++) {\n if (this._frames[i].time <= target) {\n targetIndex = i\n } else {\n break\n }\n }\n\n // snapshot(첫 프레임)부터 targetIndex까지 순차 적용\n for (let i = 0; i <= targetIndex; i++) {\n this._applyFrame(i)\n }\n\n this._currentIndex = targetIndex\n this._emitFrame()\n\n // 재생 중이었으면 타이머 재시작\n if (this._state === 'playing') {\n this._stopTimer()\n this._startTimer()\n }\n }\n\n /**\n * 이벤트 리스너 등록\n */\n on<K extends keyof PlaybackEventMap>(event: K, listener: PlaybackListener<K>): void {\n if (!this._listeners.has(event)) this._listeners.set(event, new Set())\n this._listeners.get(event)!.add(listener)\n }\n\n off<K extends keyof PlaybackEventMap>(event: K, listener: PlaybackListener<K>): void {\n this._listeners.get(event)?.delete(listener)\n }\n\n dispose(): void {\n this._stopTimer()\n this._scene = null\n this._frames = []\n this._listeners.clear()\n }\n\n // ── 내부 ──\n\n private _startTimer(): void {\n this._stopTimer()\n const intervalMs = 1000 / this._speed\n\n this._timer = window.setInterval(() => {\n this._currentIndex++\n\n if (this._currentIndex >= this._frames.length) {\n // 재생 완료\n this._currentIndex = this._frames.length - 1\n this._stopTimer()\n this._setState('stopped')\n return\n }\n\n this._applyCurrentFrame()\n this._emitFrame()\n }, intervalMs)\n }\n\n private _stopTimer(): void {\n if (this._timer != null) {\n clearInterval(this._timer)\n this._timer = undefined\n }\n }\n\n private _applyCurrentFrame(): void {\n this._applyFrame(this._currentIndex)\n this._emitFrame()\n }\n\n private _applyFrame(index: number): void {\n const frame = this._frames[index]\n if (!frame || !this._scene) return\n\n for (const [_scenarioName, records] of frame.data) {\n for (const record of records) {\n if (!record.NAME) continue\n const component = this._scene.root?.findById?.(record.NAME)\n if (component) {\n component.data = record\n }\n }\n }\n }\n\n private _emitFrame(): void {\n const frame = this._frames[this._currentIndex]\n if (frame) {\n this._emit('frame', {\n time: frame.time,\n index: this._currentIndex,\n total: this._frames.length\n })\n }\n }\n\n private _setState(state: PlaybackState): void {\n this._state = state\n this._emit('state-change', state)\n }\n\n private _emit<K extends keyof PlaybackEventMap>(event: K, detail: PlaybackEventMap[K]): void {\n const listeners = this._listeners.get(event)\n if (listeners) {\n for (const fn of listeners) {\n (fn as PlaybackListener<K>)(detail)\n }\n }\n }\n\n private _formatTime(date: Date): string {\n const pad = (n: number) => String(n).padStart(2, '0')\n return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`\n }\n}\n"]}