@fmsim/board 2.0.0-beta.60 → 2.0.0-beta.61

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.
@@ -15,7 +15,9 @@ const ONE_HOUR = 3600000;
15
15
  * @fires playback-pause - 일시정지 요청
16
16
  * @fires playback-resume - 재개 요청
17
17
  * @fires playback-stop - 중지 요청
18
- * @fires playback-seek - 시크 요청 { toTime }
18
+ * @fires playback-seek - 드래그 릴리즈 시 시크 요청 { toTime }
19
+ * @fires playback-seek-start - 드래그 첫 input 시 즉시 발행 (엔진 타이머·애니메이션 정지 신호, detail 없음)
20
+ * @fires playback-seek-preview - 드래그 중 디바운스된 스크러빙 요청 { toTime }
19
21
  * @fires playback-speed - 배속 변경 요청 { speed }
20
22
  */
21
23
  let PlaybackControls = PlaybackControls_1 = class PlaybackControls extends LitElement {
@@ -33,7 +35,7 @@ let PlaybackControls = PlaybackControls_1 = class PlaybackControls extends LitEl
33
35
  this._startTime = PlaybackControls_1._floorToHour(new Date(Date.now() - ONE_HOUR));
34
36
  this._seeking = false;
35
37
  this._seekPreviewTime = null;
36
- this._speeds = [1, 1.5, 2];
38
+ this._speeds = [1, 2, 4];
37
39
  }
38
40
  get _endTime() {
39
41
  return new Date(this._startTime.getTime() + ONE_HOUR);
@@ -51,14 +53,16 @@ let PlaybackControls = PlaybackControls_1 = class PlaybackControls extends LitEl
51
53
  }
52
54
  disconnectedCallback() {
53
55
  super.disconnectedCallback();
54
- this._stopTimer();
56
+ this._cancelPreviewDebounce();
55
57
  }
56
58
  updated(changes) {
57
59
  if (changes.has('timeRange') && this.timeRange) {
58
60
  this._startTime = PlaybackControls_1._floorToHour(this.timeRange.from);
59
61
  }
62
+ // 엔진 프레임(currentTime 프로프 변경)이 유일한 시간 소스.
63
+ // drag 중에는 _seeking 가 true 이므로 엔진 업데이트 무시 (스크러빙 중 UI 튐 방지).
60
64
  if (changes.has('currentTime') && this.currentTime && !this._seeking) {
61
- this._updateSeekPosition();
65
+ this._syncFromEngine();
62
66
  }
63
67
  }
64
68
  render() {
@@ -151,16 +155,23 @@ let PlaybackControls = PlaybackControls_1 = class PlaybackControls extends LitEl
151
155
  const pad = (n) => String(n).padStart(2, '0');
152
156
  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
153
157
  }
154
- _updateSeekPosition() {
158
+ /**
159
+ * 엔진 프레임(currentTime 프로프) 을 유일한 시간 소스로 사용하여
160
+ * _currentPlayTime 및 _seekValue 동기화.
161
+ */
162
+ _syncFromEngine() {
155
163
  if (!this.currentTime || this._seeking)
156
164
  return;
157
- const current = new Date(this.currentTime).getTime();
165
+ const currentMs = new Date(this.currentTime).getTime();
166
+ if (!Number.isFinite(currentMs))
167
+ return;
168
+ this._currentPlayTime = new Date(currentMs);
158
169
  const from = this._startTime.getTime();
159
170
  const to = this._endTime.getTime();
160
171
  const range = to - from;
161
172
  if (range <= 0)
162
173
  return;
163
- this._seekValue = Math.max(0, Math.min(1000, Math.round(((current - from) / range) * 1000)));
174
+ this._seekValue = Math.max(0, Math.min(1000, Math.round(((currentMs - from) / range) * 1000)));
164
175
  }
165
176
  _onDateChange(e) {
166
177
  const value = e.target.value;
@@ -193,7 +204,6 @@ let PlaybackControls = PlaybackControls_1 = class PlaybackControls extends LitEl
193
204
  this._startTime = newStart;
194
205
  this._seekValue = 0;
195
206
  this._currentPlayTime = new Date(newStart);
196
- this._stopTimer();
197
207
  if (wasPlaying) {
198
208
  // play 중: 새 시간에서 자동 재시작
199
209
  this._onStart();
@@ -205,15 +215,18 @@ let PlaybackControls = PlaybackControls_1 = class PlaybackControls extends LitEl
205
215
  bubbles: true,
206
216
  composed: true
207
217
  }));
208
- this._updateSeekFromPlayTime();
209
218
  }
210
219
  }
211
220
  _onStart() {
212
- this._currentPlayTime = new Date(this._startTime);
221
+ // 현재 seekbar 위치에서 재생 시작 (_seekValue 가 유일한 슬라이더 위치 소스)
222
+ const ratio = this._seekValue / 1000;
223
+ const from = this._startTime.getTime();
224
+ const to = this._endTime.getTime();
225
+ const fromTime = new Date(from + (to - from) * ratio);
226
+ this._currentPlayTime = fromTime;
213
227
  this.playbackState = 'playing';
214
- this._startTimer();
215
228
  this.dispatchEvent(new CustomEvent('playback-start', {
216
- detail: { fromTime: this._startTime, speed: this.speed },
229
+ detail: { fromTime, speed: this.speed },
217
230
  bubbles: true,
218
231
  composed: true
219
232
  }));
@@ -221,33 +234,44 @@ let PlaybackControls = PlaybackControls_1 = class PlaybackControls extends LitEl
221
234
  _onTogglePlayPause() {
222
235
  if (this.playbackState === 'playing') {
223
236
  this.playbackState = 'paused';
224
- this._stopTimer();
225
237
  this.dispatchEvent(new CustomEvent('playback-pause', { bubbles: true, composed: true }));
226
238
  }
227
239
  else {
228
240
  this.playbackState = 'playing';
229
- this._startTimer();
230
241
  this.dispatchEvent(new CustomEvent('playback-resume', { bubbles: true, composed: true }));
231
242
  }
232
243
  }
233
244
  _onStop() {
234
- this._stopTimer();
245
+ this._cancelPreviewDebounce();
235
246
  this._currentPlayTime = null;
236
247
  this._seekValue = 0;
237
248
  this.playbackState = 'idle';
238
249
  this.dispatchEvent(new CustomEvent('playback-stop', { bubbles: true, composed: true }));
239
250
  }
240
251
  _onSeekInput(e) {
252
+ // 드래그 시작(첫 input) 탐지: 엔진 타이머 정지 신호 즉시 발행
253
+ const justStartedDrag = !this._seeking;
241
254
  this._seeking = true;
242
255
  this._seekValue = Number(e.target.value);
243
256
  // 드래그 중 preview 시간 표시 (실제 seek은 하지 않음)
244
257
  const ratio = this._seekValue / 1000;
245
258
  const from = this._startTime.getTime();
246
259
  const to = this._endTime.getTime();
247
- this._seekPreviewTime = new Date(from + (to - from) * ratio);
260
+ const previewTime = new Date(from + (to - from) * ratio);
261
+ this._seekPreviewTime = previewTime;
262
+ if (justStartedDrag) {
263
+ // 즉시 이벤트 — 재생 타이머 정지해서 scene 이 엔진 시간으로 갱신되는 것을 막음.
264
+ this.dispatchEvent(new CustomEvent('playback-seek-start', {
265
+ bubbles: true,
266
+ composed: true
267
+ }));
268
+ }
269
+ // 디바운스된 playback-seek-preview 이벤트 — 드래그 중 snapshot 스크러빙
270
+ this._schedulePreview(previewTime);
248
271
  }
249
272
  _onSeekChange(e) {
250
273
  this._seeking = false;
274
+ this._cancelPreviewDebounce();
251
275
  const ratio = Number(e.target.value) / 1000;
252
276
  const from = this._startTime.getTime();
253
277
  const to = this._endTime.getTime();
@@ -259,10 +283,22 @@ let PlaybackControls = PlaybackControls_1 = class PlaybackControls extends LitEl
259
283
  bubbles: true,
260
284
  composed: true
261
285
  }));
262
- // 재생 중이었으면 새 시점에서 타이머 계속
263
- if (this.playbackState === 'playing') {
264
- this._stopTimer();
265
- this._startTimer();
286
+ }
287
+ _schedulePreview(previewTime) {
288
+ this._cancelPreviewDebounce();
289
+ this._previewDebounce = window.setTimeout(() => {
290
+ this._previewDebounce = undefined;
291
+ this.dispatchEvent(new CustomEvent('playback-seek-preview', {
292
+ detail: { toTime: previewTime },
293
+ bubbles: true,
294
+ composed: true
295
+ }));
296
+ }, PlaybackControls_1.PREVIEW_DEBOUNCE_MS);
297
+ }
298
+ _cancelPreviewDebounce() {
299
+ if (this._previewDebounce != null) {
300
+ clearTimeout(this._previewDebounce);
301
+ this._previewDebounce = undefined;
266
302
  }
267
303
  }
268
304
  _renderBufferBars() {
@@ -288,50 +324,6 @@ let PlaybackControls = PlaybackControls_1 = class PlaybackControls extends LitEl
288
324
  bubbles: true,
289
325
  composed: true
290
326
  }));
291
- // 재생 중이면 타이머 재시작 (새 속도 반영)
292
- if (this.playbackState === 'playing') {
293
- this._stopTimer();
294
- this._startTimer();
295
- }
296
- }
297
- // --- 타이머: 초 단위 진행 ---
298
- _startTimer() {
299
- this._stopTimer();
300
- const intervalMs = 1000 / this.speed;
301
- this._timer = window.setInterval(() => {
302
- if (!this._currentPlayTime)
303
- return;
304
- const next = new Date(this._currentPlayTime.getTime() + 1000);
305
- // 종료 시점 초과 시 정지
306
- if (next.getTime() > this._endTime.getTime()) {
307
- this._onStop();
308
- return;
309
- }
310
- this._currentPlayTime = next;
311
- if (!this._seeking)
312
- this._updateSeekFromPlayTime();
313
- this.dispatchEvent(new CustomEvent('playback-tick', {
314
- detail: { currentTime: this._currentPlayTime },
315
- bubbles: true,
316
- composed: true
317
- }));
318
- }, intervalMs);
319
- }
320
- _stopTimer() {
321
- if (this._timer) {
322
- clearInterval(this._timer);
323
- this._timer = undefined;
324
- }
325
- }
326
- _updateSeekFromPlayTime() {
327
- if (!this._currentPlayTime)
328
- return;
329
- const from = this._startTime.getTime();
330
- const to = this._endTime.getTime();
331
- const range = to - from;
332
- if (range <= 0)
333
- return;
334
- this._seekValue = Math.max(0, Math.min(1000, Math.round(((this._currentPlayTime.getTime() - from) / range) * 1000)));
335
327
  }
336
328
  };
337
329
  PlaybackControls.styles = css `
@@ -557,6 +549,7 @@ PlaybackControls.styles = css `
557
549
  text-align: center;
558
550
  }
559
551
  `;
552
+ PlaybackControls.PREVIEW_DEBOUNCE_MS = 200;
560
553
  __decorate([
561
554
  property({ type: String })
562
555
  ], PlaybackControls.prototype, "playbackState", void 0);
@@ -1 +1 @@
1
- {"version":3,"file":"ox-playback-controls.js","sourceRoot":"","sources":["../../src/ox-playback-controls.ts"],"names":[],"mappings":"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","sourcesContent":["import '@material/web/icon/icon.js'\nimport '@material/web/iconbutton/icon-button.js'\n\nimport { css, html, LitElement } from 'lit'\nimport { customElement, property, state } from 'lit/decorators.js'\n\ntype PlaybackState = 'idle' | 'playing' | 'paused' | 'stopped'\n\nconst ONE_HOUR = 3600000\n\n/**\n * 플레이백 컨트롤 오버레이 바 (2행 구조)\n *\n * 1행: 시작 시점 설정 (datetime + ±1h 버튼) + 배속 선택\n * 2행: 재생 제어 (play/pause/stop) + 1시간 범위 시크바 + 닫기\n *\n * @fires playback-start - 플레이백 시작 요청 { fromTime, speed }\n * @fires playback-pause - 일시정지 요청\n * @fires playback-resume - 재개 요청\n * @fires playback-stop - 중지 요청\n * @fires playback-seek - 시크 요청 { toTime }\n * @fires playback-speed - 배속 변경 요청 { speed }\n */\n@customElement('ox-playback-controls')\nexport class PlaybackControls extends LitElement {\n static styles = css`\n :host {\n display: flex;\n justify-content: center;\n position: absolute;\n bottom: 0;\n left: 0;\n right: 0;\n z-index: 1001;\n pointer-events: none;\n }\n\n .control-bar {\n display: flex;\n flex-direction: column;\n gap: 6px;\n padding: 10px 16px;\n margin: 12px;\n max-width: 560px;\n width: 100%;\n background: rgba(0, 0, 0, 0.75);\n backdrop-filter: blur(8px);\n border-radius: 12px;\n color: #fff;\n pointer-events: auto;\n font-size: 13px;\n box-sizing: border-box;\n }\n\n /* 1행: 시점 설정 + 배속 */\n .row-time {\n display: flex;\n align-items: center;\n gap: 6px;\n }\n\n md-icon-button {\n --md-icon-button-icon-color: #fff;\n --md-icon-button-hover-icon-color: #fff;\n --md-icon-button-pressed-icon-color: #fff;\n --md-icon-button-focus-icon-color: #fff;\n --md-icon-button-state-layer-color: rgba(255, 255, 255, 0.2);\n }\n\n md-icon-button:hover {\n background: rgba(255, 255, 255, 0.15);\n border-radius: 50%;\n }\n\n .row-time md-icon-button {\n --md-icon-button-icon-size: 18px;\n --md-icon-button-container-width: 32px;\n --md-icon-button-container-height: 32px;\n }\n\n .date-input {\n background: rgba(255, 255, 255, 0.15);\n border: 1px solid rgba(255, 255, 255, 0.3);\n border-radius: 6px;\n color: #fff;\n font-size: 13px;\n padding: 4px 8px;\n outline: none;\n color-scheme: dark;\n width: 130px;\n }\n\n .date-input:focus {\n border-color: rgba(255, 255, 255, 0.6);\n }\n\n .hour-select {\n background: rgba(255, 255, 255, 0.15);\n border: 1px solid rgba(255, 255, 255, 0.3);\n border-radius: 6px;\n color: #fff;\n font-size: 13px;\n padding: 4px 6px;\n outline: none;\n color-scheme: dark;\n cursor: pointer;\n }\n\n .hour-select:focus {\n border-color: rgba(255, 255, 255, 0.6);\n }\n\n .time-label {\n font-size: 12px;\n opacity: 0.7;\n white-space: nowrap;\n }\n\n .spacer {\n flex: 1;\n }\n\n .speed-group {\n display: flex;\n gap: 2px;\n }\n\n .speed-btn {\n padding: 4px 8px;\n border: 1px solid rgba(255, 255, 255, 0.3);\n border-radius: 4px;\n background: transparent;\n color: #fff;\n font-size: 12px;\n cursor: pointer;\n white-space: nowrap;\n }\n\n .speed-btn:hover {\n background: rgba(255, 255, 255, 0.15);\n }\n\n .speed-btn.active {\n background: rgba(255, 255, 255, 0.3);\n border-color: #fff;\n }\n\n /* 2행: 재생 제어 + 시크바 */\n .row-seek {\n display: flex;\n align-items: center;\n gap: 8px;\n }\n\n .row-seek md-icon-button {\n --md-icon-button-icon-size: 20px;\n --md-icon-button-container-width: 36px;\n --md-icon-button-container-height: 36px;\n }\n\n .row-seek md-icon-button.primary {\n --md-icon-button-icon-size: 24px;\n --md-icon-button-container-width: 40px;\n --md-icon-button-container-height: 40px;\n }\n\n .row-seek md-icon {\n font-variation-settings: 'FILL' 1;\n }\n\n .timeline {\n flex: 1;\n display: flex;\n flex-direction: column;\n gap: 2px;\n min-width: 0;\n }\n\n .seek-track {\n position: relative;\n width: 100%;\n height: 12px;\n }\n\n .seek-rail {\n position: absolute;\n top: 4px;\n left: 0;\n right: 0;\n height: 4px;\n background: rgba(255, 255, 255, 0.15);\n border-radius: 2px;\n }\n\n .buffer-bar {\n position: absolute;\n top: 4px;\n height: 4px;\n background: rgba(255, 255, 255, 0.4);\n border-radius: 2px;\n pointer-events: none;\n z-index: 1;\n }\n\n .seek-bar {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 12px;\n -webkit-appearance: none;\n appearance: none;\n background: transparent;\n outline: none;\n cursor: pointer;\n z-index: 2;\n margin: 0;\n }\n\n .seek-bar::-webkit-slider-runnable-track {\n height: 12px;\n background: transparent;\n }\n\n .seek-bar::-webkit-slider-thumb {\n -webkit-appearance: none;\n width: 12px;\n height: 12px;\n border-radius: 50%;\n background: #fff;\n cursor: pointer;\n }\n\n .seek-labels {\n display: flex;\n justify-content: space-between;\n font-size: 11px;\n opacity: 0.8;\n }\n\n .current-time {\n font-size: 12px;\n opacity: 0.9;\n white-space: nowrap;\n min-width: 60px;\n text-align: center;\n }\n `\n\n @property({ type: String }) playbackState: PlaybackState = 'idle'\n @property({ type: Number }) speed: number = 1\n @property({ type: String }) currentTime: string = ''\n @property({ type: Array }) bufferedRanges: { from: number; to: number }[] = []\n\n /** 외부에서 전달받은 초기 시간 범위 (from만 사용, to는 from+1h로 자동 계산) */\n @property({ type: Object }) timeRange: { from: Date; to: Date } = {\n from: new Date(Date.now() - ONE_HOUR),\n to: new Date()\n }\n\n @state() private _startTime: Date = PlaybackControls._floorToHour(new Date(Date.now() - ONE_HOUR))\n @state() declare private _seekValue: number\n @state() declare private _currentPlayTime: Date | null\n @state() private _seeking = false\n @state() private _seekPreviewTime: Date | null = null\n\n private _speeds = [1, 1.5, 2]\n private _timer?: number\n\n private get _endTime(): Date {\n return new Date(this._startTime.getTime() + ONE_HOUR)\n }\n\n private static _floorToHour(date: Date): Date {\n const d = new Date(date)\n d.setMinutes(0, 0, 0)\n return d\n }\n\n connectedCallback() {\n super.connectedCallback()\n this._startTime = PlaybackControls._floorToHour(this.timeRange.from)\n this._seekValue = 0\n this._currentPlayTime = null\n }\n\n disconnectedCallback() {\n super.disconnectedCallback()\n this._stopTimer()\n }\n\n updated(changes: Map<string, any>) {\n if (changes.has('timeRange') && this.timeRange) {\n this._startTime = PlaybackControls._floorToHour(this.timeRange.from)\n }\n if (changes.has('currentTime') && this.currentTime && !this._seeking) {\n this._updateSeekPosition()\n }\n }\n\n render() {\n const isPlaying = this.playbackState === 'playing'\n const isPaused = this.playbackState === 'paused'\n const isActive = isPlaying || isPaused\n\n return html`\n <div class=\"control-bar\">\n <!-- 1행: 시점 설정 + 배속 -->\n <div class=\"row-time\">\n <md-icon-button @click=${this._shiftTime(-1)} title=\"-1h\">\n <md-icon>chevron_left</md-icon>\n </md-icon-button>\n\n <input\n type=\"date\"\n class=\"date-input\"\n .value=${this._toDateString(this._startTime)}\n @change=${this._onDateChange}\n />\n\n <select class=\"hour-select\" .value=${String(this._startTime.getHours())} @change=${this._onHourChange}>\n ${Array.from({ length: 24 }, (_, i) => html`\n <option value=${i} ?selected=${this._startTime.getHours() === i}>\n ${String(i).padStart(2, '0')}:00\n </option>\n `)}\n </select>\n\n <md-icon-button @click=${this._shiftTime(1)} title=\"+1h\">\n <md-icon>chevron_right</md-icon>\n </md-icon-button>\n\n <span class=\"time-label\">~ ${String(this._endTime.getHours()).padStart(2, '0')}:00</span>\n\n <span class=\"spacer\"></span>\n\n <div class=\"speed-group\">\n ${this._speeds.map(\n s => html`\n <button class=\"speed-btn ${this.speed === s ? 'active' : ''}\" @click=${() => this._onSpeedChange(s)}>\n ×${s}\n </button>\n `\n )}\n </div>\n </div>\n\n <!-- 2행: 재생 제어 + 시크바 + 닫기 -->\n <div class=\"row-seek\">\n <md-icon-button class=\"primary\" @click=${isActive ? this._onTogglePlayPause : this._onStart}>\n <md-icon>${isPlaying ? 'pause' : 'play_arrow'}</md-icon>\n </md-icon-button>\n\n <div class=\"timeline\">\n <div class=\"seek-track\">\n <div class=\"seek-rail\"></div>\n ${this._renderBufferBars()}\n <input\n type=\"range\"\n class=\"seek-bar\"\n min=\"0\"\n max=\"1000\"\n .value=${String(this._seekValue)}\n @input=${this._onSeekInput}\n @change=${this._onSeekChange}\n />\n </div>\n <div class=\"seek-labels\">\n <span>${this._formatTime(this._startTime)}</span>\n <span>${this._formatTime(this._endTime)}</span>\n </div>\n </div>\n\n <span class=\"current-time\">\n ${this._seeking && this._seekPreviewTime\n ? this._formatTime(this._seekPreviewTime)\n : this._currentPlayTime ? this._formatTime(this._currentPlayTime) : '--:--:--'}\n </span>\n\n <md-icon-button @click=${this._onStop}>\n <md-icon>close</md-icon>\n </md-icon-button>\n </div>\n </div>\n `\n }\n\n private _formatTime(date: Date): string {\n return date.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })\n }\n\n private _toDateString(date: Date): string {\n const pad = (n: number) => String(n).padStart(2, '0')\n return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`\n }\n\n private _updateSeekPosition() {\n if (!this.currentTime || this._seeking) return\n const current = new Date(this.currentTime).getTime()\n const from = this._startTime.getTime()\n const to = this._endTime.getTime()\n const range = to - from\n if (range <= 0) return\n this._seekValue = Math.max(0, Math.min(1000, Math.round(((current - from) / range) * 1000)))\n }\n\n private _onDateChange(e: Event) {\n const value = (e.target as HTMLInputElement).value\n if (!value) return\n const [year, month, day] = value.split('-').map(Number)\n const newStart = new Date(this._startTime)\n newStart.setFullYear(year, month - 1, day)\n this._shiftStartTime(newStart)\n }\n\n private _onHourChange(e: Event) {\n const hour = Number((e.target as HTMLSelectElement).value)\n const newStart = new Date(this._startTime)\n newStart.setHours(hour, 0, 0, 0)\n this._shiftStartTime(newStart)\n }\n\n private _shiftTime(hours: number) {\n return () => {\n const newStart = new Date(this._startTime.getTime() + hours * ONE_HOUR)\n this._shiftStartTime(newStart)\n }\n }\n\n /**\n * 시작 시간 변경 시: 재생/일시정지 중이면 새 시간에서 재시작.\n * 정지 상태면 시간만 변경.\n */\n private _shiftStartTime(newStart: Date) {\n const wasPlaying = this.playbackState === 'playing'\n const wasPaused = this.playbackState === 'paused'\n\n this._startTime = newStart\n this._seekValue = 0\n this._currentPlayTime = new Date(newStart)\n this._stopTimer()\n\n if (wasPlaying) {\n // play 중: 새 시간에서 자동 재시작\n this._onStart()\n } else if (wasPaused) {\n // pause 중: 엔진에 새 시간 전달 + 데이터 fetch, 하지만 자동 재생 안 함\n this.dispatchEvent(\n new CustomEvent('playback-seek', {\n detail: { toTime: new Date(newStart) },\n bubbles: true,\n composed: true\n })\n )\n this._updateSeekFromPlayTime()\n }\n }\n\n private _onStart() {\n this._currentPlayTime = new Date(this._startTime)\n this.playbackState = 'playing'\n this._startTimer()\n\n this.dispatchEvent(\n new CustomEvent('playback-start', {\n detail: { fromTime: this._startTime, speed: this.speed },\n bubbles: true,\n composed: true\n })\n )\n }\n\n private _onTogglePlayPause() {\n if (this.playbackState === 'playing') {\n this.playbackState = 'paused'\n this._stopTimer()\n this.dispatchEvent(new CustomEvent('playback-pause', { bubbles: true, composed: true }))\n } else {\n this.playbackState = 'playing'\n this._startTimer()\n this.dispatchEvent(new CustomEvent('playback-resume', { bubbles: true, composed: true }))\n }\n }\n\n private _onStop() {\n this._stopTimer()\n this._currentPlayTime = null\n this._seekValue = 0\n this.playbackState = 'idle'\n this.dispatchEvent(new CustomEvent('playback-stop', { bubbles: true, composed: true }))\n }\n\n private _onSeekInput(e: Event) {\n this._seeking = true\n this._seekValue = Number((e.target as HTMLInputElement).value)\n\n // 드래그 중 preview 시간 표시 (실제 seek은 하지 않음)\n const ratio = this._seekValue / 1000\n const from = this._startTime.getTime()\n const to = this._endTime.getTime()\n this._seekPreviewTime = new Date(from + (to - from) * ratio)\n }\n\n private _onSeekChange(e: Event) {\n this._seeking = false\n const ratio = Number((e.target as HTMLInputElement).value) / 1000\n const from = this._startTime.getTime()\n const to = this._endTime.getTime()\n const targetTime = new Date(from + (to - from) * ratio)\n\n this._seekPreviewTime = null\n this._currentPlayTime = targetTime\n\n this.dispatchEvent(\n new CustomEvent('playback-seek', {\n detail: { toTime: targetTime },\n bubbles: true,\n composed: true\n })\n )\n\n // 재생 중이었으면 새 시점에서 타이머 계속\n if (this.playbackState === 'playing') {\n this._stopTimer()\n this._startTimer()\n }\n }\n\n private _renderBufferBars() {\n if (!this.bufferedRanges?.length) return ''\n\n const from = this._startTime.getTime()\n const to = this._endTime.getTime()\n const range = to - from\n if (range <= 0) return ''\n\n return this.bufferedRanges.map(r => {\n const left = Math.max(0, (r.from - from) / range) * 100\n const right = Math.min(1, (r.to - from) / range) * 100\n const width = right - left\n return html`<div class=\"buffer-bar\" style=\"left:${left}%;width:${width}%\"></div>`\n })\n }\n\n private _onSpeedChange(speed: number) {\n this.speed = speed\n\n this.dispatchEvent(\n new CustomEvent('playback-speed', {\n detail: { speed },\n bubbles: true,\n composed: true\n })\n )\n\n // 재생 중이면 타이머 재시작 (새 속도 반영)\n if (this.playbackState === 'playing') {\n this._stopTimer()\n this._startTimer()\n }\n }\n\n // --- 타이머: 초 단위 진행 ---\n\n private _startTimer() {\n this._stopTimer()\n\n const intervalMs = 1000 / this.speed\n this._timer = window.setInterval(() => {\n if (!this._currentPlayTime) return\n\n const next = new Date(this._currentPlayTime.getTime() + 1000)\n\n // 종료 시점 초과 시 정지\n if (next.getTime() > this._endTime.getTime()) {\n this._onStop()\n return\n }\n\n this._currentPlayTime = next\n if (!this._seeking) this._updateSeekFromPlayTime()\n\n this.dispatchEvent(\n new CustomEvent('playback-tick', {\n detail: { currentTime: this._currentPlayTime },\n bubbles: true,\n composed: true\n })\n )\n }, intervalMs)\n }\n\n private _stopTimer() {\n if (this._timer) {\n clearInterval(this._timer)\n this._timer = undefined\n }\n }\n\n private _updateSeekFromPlayTime() {\n if (!this._currentPlayTime) return\n const from = this._startTime.getTime()\n const to = this._endTime.getTime()\n const range = to - from\n if (range <= 0) return\n this._seekValue = Math.max(0, Math.min(1000, Math.round(((this._currentPlayTime.getTime() - from) / range) * 1000)))\n }\n}\n"]}
1
+ {"version":3,"file":"ox-playback-controls.js","sourceRoot":"","sources":["../../src/ox-playback-controls.ts"],"names":[],"mappings":"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","sourcesContent":["import '@material/web/icon/icon.js'\nimport '@material/web/iconbutton/icon-button.js'\n\nimport { css, html, LitElement } from 'lit'\nimport { customElement, property, state } from 'lit/decorators.js'\n\ntype PlaybackState = 'idle' | 'playing' | 'paused' | 'stopped'\n\nconst ONE_HOUR = 3600000\n\n/**\n * 플레이백 컨트롤 오버레이 바 (2행 구조)\n *\n * 1행: 시작 시점 설정 (datetime + ±1h 버튼) + 배속 선택\n * 2행: 재생 제어 (play/pause/stop) + 1시간 범위 시크바 + 닫기\n *\n * @fires playback-start - 플레이백 시작 요청 { fromTime, speed }\n * @fires playback-pause - 일시정지 요청\n * @fires playback-resume - 재개 요청\n * @fires playback-stop - 중지 요청\n * @fires playback-seek - 드래그 릴리즈 시 시크 요청 { toTime }\n * @fires playback-seek-start - 드래그 첫 input 시 즉시 발행 (엔진 타이머·애니메이션 정지 신호, detail 없음)\n * @fires playback-seek-preview - 드래그 중 디바운스된 스크러빙 요청 { toTime }\n * @fires playback-speed - 배속 변경 요청 { speed }\n */\n@customElement('ox-playback-controls')\nexport class PlaybackControls extends LitElement {\n static styles = css`\n :host {\n display: flex;\n justify-content: center;\n position: absolute;\n bottom: 0;\n left: 0;\n right: 0;\n z-index: 1001;\n pointer-events: none;\n }\n\n .control-bar {\n display: flex;\n flex-direction: column;\n gap: 6px;\n padding: 10px 16px;\n margin: 12px;\n max-width: 560px;\n width: 100%;\n background: rgba(0, 0, 0, 0.75);\n backdrop-filter: blur(8px);\n border-radius: 12px;\n color: #fff;\n pointer-events: auto;\n font-size: 13px;\n box-sizing: border-box;\n }\n\n /* 1행: 시점 설정 + 배속 */\n .row-time {\n display: flex;\n align-items: center;\n gap: 6px;\n }\n\n md-icon-button {\n --md-icon-button-icon-color: #fff;\n --md-icon-button-hover-icon-color: #fff;\n --md-icon-button-pressed-icon-color: #fff;\n --md-icon-button-focus-icon-color: #fff;\n --md-icon-button-state-layer-color: rgba(255, 255, 255, 0.2);\n }\n\n md-icon-button:hover {\n background: rgba(255, 255, 255, 0.15);\n border-radius: 50%;\n }\n\n .row-time md-icon-button {\n --md-icon-button-icon-size: 18px;\n --md-icon-button-container-width: 32px;\n --md-icon-button-container-height: 32px;\n }\n\n .date-input {\n background: rgba(255, 255, 255, 0.15);\n border: 1px solid rgba(255, 255, 255, 0.3);\n border-radius: 6px;\n color: #fff;\n font-size: 13px;\n padding: 4px 8px;\n outline: none;\n color-scheme: dark;\n width: 130px;\n }\n\n .date-input:focus {\n border-color: rgba(255, 255, 255, 0.6);\n }\n\n .hour-select {\n background: rgba(255, 255, 255, 0.15);\n border: 1px solid rgba(255, 255, 255, 0.3);\n border-radius: 6px;\n color: #fff;\n font-size: 13px;\n padding: 4px 6px;\n outline: none;\n color-scheme: dark;\n cursor: pointer;\n }\n\n .hour-select:focus {\n border-color: rgba(255, 255, 255, 0.6);\n }\n\n .time-label {\n font-size: 12px;\n opacity: 0.7;\n white-space: nowrap;\n }\n\n .spacer {\n flex: 1;\n }\n\n .speed-group {\n display: flex;\n gap: 2px;\n }\n\n .speed-btn {\n padding: 4px 8px;\n border: 1px solid rgba(255, 255, 255, 0.3);\n border-radius: 4px;\n background: transparent;\n color: #fff;\n font-size: 12px;\n cursor: pointer;\n white-space: nowrap;\n }\n\n .speed-btn:hover {\n background: rgba(255, 255, 255, 0.15);\n }\n\n .speed-btn.active {\n background: rgba(255, 255, 255, 0.3);\n border-color: #fff;\n }\n\n /* 2행: 재생 제어 + 시크바 */\n .row-seek {\n display: flex;\n align-items: center;\n gap: 8px;\n }\n\n .row-seek md-icon-button {\n --md-icon-button-icon-size: 20px;\n --md-icon-button-container-width: 36px;\n --md-icon-button-container-height: 36px;\n }\n\n .row-seek md-icon-button.primary {\n --md-icon-button-icon-size: 24px;\n --md-icon-button-container-width: 40px;\n --md-icon-button-container-height: 40px;\n }\n\n .row-seek md-icon {\n font-variation-settings: 'FILL' 1;\n }\n\n .timeline {\n flex: 1;\n display: flex;\n flex-direction: column;\n gap: 2px;\n min-width: 0;\n }\n\n .seek-track {\n position: relative;\n width: 100%;\n height: 12px;\n }\n\n .seek-rail {\n position: absolute;\n top: 4px;\n left: 0;\n right: 0;\n height: 4px;\n background: rgba(255, 255, 255, 0.15);\n border-radius: 2px;\n }\n\n .buffer-bar {\n position: absolute;\n top: 4px;\n height: 4px;\n background: rgba(255, 255, 255, 0.4);\n border-radius: 2px;\n pointer-events: none;\n z-index: 1;\n }\n\n .seek-bar {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 12px;\n -webkit-appearance: none;\n appearance: none;\n background: transparent;\n outline: none;\n cursor: pointer;\n z-index: 2;\n margin: 0;\n }\n\n .seek-bar::-webkit-slider-runnable-track {\n height: 12px;\n background: transparent;\n }\n\n .seek-bar::-webkit-slider-thumb {\n -webkit-appearance: none;\n width: 12px;\n height: 12px;\n border-radius: 50%;\n background: #fff;\n cursor: pointer;\n }\n\n .seek-labels {\n display: flex;\n justify-content: space-between;\n font-size: 11px;\n opacity: 0.8;\n }\n\n .current-time {\n font-size: 12px;\n opacity: 0.9;\n white-space: nowrap;\n min-width: 60px;\n text-align: center;\n }\n `\n\n @property({ type: String }) playbackState: PlaybackState = 'idle'\n @property({ type: Number }) speed: number = 1\n @property({ type: String }) currentTime: string = ''\n @property({ type: Array }) bufferedRanges: { from: number; to: number }[] = []\n\n /** 외부에서 전달받은 초기 시간 범위 (from만 사용, to는 from+1h로 자동 계산) */\n @property({ type: Object }) timeRange: { from: Date; to: Date } = {\n from: new Date(Date.now() - ONE_HOUR),\n to: new Date()\n }\n\n @state() private _startTime: Date = PlaybackControls._floorToHour(new Date(Date.now() - ONE_HOUR))\n @state() declare private _seekValue: number\n @state() declare private _currentPlayTime: Date | null\n @state() private _seeking = false\n @state() private _seekPreviewTime: Date | null = null\n\n private _speeds = [1, 2, 4]\n /** 드래그 중 playback-seek-preview 이벤트 디바운스 타이머 */\n private _previewDebounce?: number\n\n private get _endTime(): Date {\n return new Date(this._startTime.getTime() + ONE_HOUR)\n }\n\n private static _floorToHour(date: Date): Date {\n const d = new Date(date)\n d.setMinutes(0, 0, 0)\n return d\n }\n\n connectedCallback() {\n super.connectedCallback()\n this._startTime = PlaybackControls._floorToHour(this.timeRange.from)\n this._seekValue = 0\n this._currentPlayTime = null\n }\n\n disconnectedCallback() {\n super.disconnectedCallback()\n this._cancelPreviewDebounce()\n }\n\n updated(changes: Map<string, any>) {\n if (changes.has('timeRange') && this.timeRange) {\n this._startTime = PlaybackControls._floorToHour(this.timeRange.from)\n }\n // 엔진 프레임(currentTime 프로프 변경)이 유일한 시간 소스.\n // drag 중에는 _seeking 가 true 이므로 엔진 업데이트 무시 (스크러빙 중 UI 튐 방지).\n if (changes.has('currentTime') && this.currentTime && !this._seeking) {\n this._syncFromEngine()\n }\n }\n\n render() {\n const isPlaying = this.playbackState === 'playing'\n const isPaused = this.playbackState === 'paused'\n const isActive = isPlaying || isPaused\n\n return html`\n <div class=\"control-bar\">\n <!-- 1행: 시점 설정 + 배속 -->\n <div class=\"row-time\">\n <md-icon-button @click=${this._shiftTime(-1)} title=\"-1h\">\n <md-icon>chevron_left</md-icon>\n </md-icon-button>\n\n <input\n type=\"date\"\n class=\"date-input\"\n .value=${this._toDateString(this._startTime)}\n @change=${this._onDateChange}\n />\n\n <select class=\"hour-select\" .value=${String(this._startTime.getHours())} @change=${this._onHourChange}>\n ${Array.from({ length: 24 }, (_, i) => html`\n <option value=${i} ?selected=${this._startTime.getHours() === i}>\n ${String(i).padStart(2, '0')}:00\n </option>\n `)}\n </select>\n\n <md-icon-button @click=${this._shiftTime(1)} title=\"+1h\">\n <md-icon>chevron_right</md-icon>\n </md-icon-button>\n\n <span class=\"time-label\">~ ${String(this._endTime.getHours()).padStart(2, '0')}:00</span>\n\n <span class=\"spacer\"></span>\n\n <div class=\"speed-group\">\n ${this._speeds.map(\n s => html`\n <button class=\"speed-btn ${this.speed === s ? 'active' : ''}\" @click=${() => this._onSpeedChange(s)}>\n ×${s}\n </button>\n `\n )}\n </div>\n </div>\n\n <!-- 2행: 재생 제어 + 시크바 + 닫기 -->\n <div class=\"row-seek\">\n <md-icon-button class=\"primary\" @click=${isActive ? this._onTogglePlayPause : this._onStart}>\n <md-icon>${isPlaying ? 'pause' : 'play_arrow'}</md-icon>\n </md-icon-button>\n\n <div class=\"timeline\">\n <div class=\"seek-track\">\n <div class=\"seek-rail\"></div>\n ${this._renderBufferBars()}\n <input\n type=\"range\"\n class=\"seek-bar\"\n min=\"0\"\n max=\"1000\"\n .value=${String(this._seekValue)}\n @input=${this._onSeekInput}\n @change=${this._onSeekChange}\n />\n </div>\n <div class=\"seek-labels\">\n <span>${this._formatTime(this._startTime)}</span>\n <span>${this._formatTime(this._endTime)}</span>\n </div>\n </div>\n\n <span class=\"current-time\">\n ${this._seeking && this._seekPreviewTime\n ? this._formatTime(this._seekPreviewTime)\n : this._currentPlayTime ? this._formatTime(this._currentPlayTime) : '--:--:--'}\n </span>\n\n <md-icon-button @click=${this._onStop}>\n <md-icon>close</md-icon>\n </md-icon-button>\n </div>\n </div>\n `\n }\n\n private _formatTime(date: Date): string {\n return date.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })\n }\n\n private _toDateString(date: Date): string {\n const pad = (n: number) => String(n).padStart(2, '0')\n return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`\n }\n\n /**\n * 엔진 프레임(currentTime 프로프) 을 유일한 시간 소스로 사용하여\n * _currentPlayTime 및 _seekValue 동기화.\n */\n private _syncFromEngine() {\n if (!this.currentTime || this._seeking) return\n const currentMs = new Date(this.currentTime).getTime()\n if (!Number.isFinite(currentMs)) return\n\n this._currentPlayTime = new Date(currentMs)\n\n const from = this._startTime.getTime()\n const to = this._endTime.getTime()\n const range = to - from\n if (range <= 0) return\n this._seekValue = Math.max(0, Math.min(1000, Math.round(((currentMs - from) / range) * 1000)))\n }\n\n private _onDateChange(e: Event) {\n const value = (e.target as HTMLInputElement).value\n if (!value) return\n const [year, month, day] = value.split('-').map(Number)\n const newStart = new Date(this._startTime)\n newStart.setFullYear(year, month - 1, day)\n this._shiftStartTime(newStart)\n }\n\n private _onHourChange(e: Event) {\n const hour = Number((e.target as HTMLSelectElement).value)\n const newStart = new Date(this._startTime)\n newStart.setHours(hour, 0, 0, 0)\n this._shiftStartTime(newStart)\n }\n\n private _shiftTime(hours: number) {\n return () => {\n const newStart = new Date(this._startTime.getTime() + hours * ONE_HOUR)\n this._shiftStartTime(newStart)\n }\n }\n\n /**\n * 시작 시간 변경 시: 재생/일시정지 중이면 새 시간에서 재시작.\n * 정지 상태면 시간만 변경.\n */\n private _shiftStartTime(newStart: Date) {\n const wasPlaying = this.playbackState === 'playing'\n const wasPaused = this.playbackState === 'paused'\n\n this._startTime = newStart\n this._seekValue = 0\n this._currentPlayTime = new Date(newStart)\n\n if (wasPlaying) {\n // play 중: 새 시간에서 자동 재시작\n this._onStart()\n } else if (wasPaused) {\n // pause 중: 엔진에 새 시간 전달 + 데이터 fetch, 하지만 자동 재생 안 함\n this.dispatchEvent(\n new CustomEvent('playback-seek', {\n detail: { toTime: new Date(newStart) },\n bubbles: true,\n composed: true\n })\n )\n }\n }\n\n private _onStart() {\n // 현재 seekbar 위치에서 재생 시작 (_seekValue 가 유일한 슬라이더 위치 소스)\n const ratio = this._seekValue / 1000\n const from = this._startTime.getTime()\n const to = this._endTime.getTime()\n const fromTime = new Date(from + (to - from) * ratio)\n\n this._currentPlayTime = fromTime\n this.playbackState = 'playing'\n\n this.dispatchEvent(\n new CustomEvent('playback-start', {\n detail: { fromTime, speed: this.speed },\n bubbles: true,\n composed: true\n })\n )\n }\n\n private _onTogglePlayPause() {\n if (this.playbackState === 'playing') {\n this.playbackState = 'paused'\n this.dispatchEvent(new CustomEvent('playback-pause', { bubbles: true, composed: true }))\n } else {\n this.playbackState = 'playing'\n this.dispatchEvent(new CustomEvent('playback-resume', { bubbles: true, composed: true }))\n }\n }\n\n private _onStop() {\n this._cancelPreviewDebounce()\n this._currentPlayTime = null\n this._seekValue = 0\n this.playbackState = 'idle'\n this.dispatchEvent(new CustomEvent('playback-stop', { bubbles: true, composed: true }))\n }\n\n private _onSeekInput(e: Event) {\n // 드래그 시작(첫 input) 탐지: 엔진 타이머 정지 신호 즉시 발행\n const justStartedDrag = !this._seeking\n this._seeking = true\n this._seekValue = Number((e.target as HTMLInputElement).value)\n\n // 드래그 중 preview 시간 표시 (실제 seek은 하지 않음)\n const ratio = this._seekValue / 1000\n const from = this._startTime.getTime()\n const to = this._endTime.getTime()\n const previewTime = new Date(from + (to - from) * ratio)\n this._seekPreviewTime = previewTime\n\n if (justStartedDrag) {\n // 즉시 이벤트 — 재생 타이머 정지해서 scene 이 엔진 시간으로 갱신되는 것을 막음.\n this.dispatchEvent(\n new CustomEvent('playback-seek-start', {\n bubbles: true,\n composed: true\n })\n )\n }\n\n // 디바운스된 playback-seek-preview 이벤트 — 드래그 중 snapshot 스크러빙\n this._schedulePreview(previewTime)\n }\n\n private _onSeekChange(e: Event) {\n this._seeking = false\n this._cancelPreviewDebounce()\n\n const ratio = Number((e.target as HTMLInputElement).value) / 1000\n const from = this._startTime.getTime()\n const to = this._endTime.getTime()\n const targetTime = new Date(from + (to - from) * ratio)\n\n this._seekPreviewTime = null\n this._currentPlayTime = targetTime\n\n this.dispatchEvent(\n new CustomEvent('playback-seek', {\n detail: { toTime: targetTime },\n bubbles: true,\n composed: true\n })\n )\n }\n\n private static readonly PREVIEW_DEBOUNCE_MS = 200\n\n private _schedulePreview(previewTime: Date) {\n this._cancelPreviewDebounce()\n this._previewDebounce = window.setTimeout(() => {\n this._previewDebounce = undefined\n this.dispatchEvent(\n new CustomEvent('playback-seek-preview', {\n detail: { toTime: previewTime },\n bubbles: true,\n composed: true\n })\n )\n }, PlaybackControls.PREVIEW_DEBOUNCE_MS)\n }\n\n private _cancelPreviewDebounce() {\n if (this._previewDebounce != null) {\n clearTimeout(this._previewDebounce)\n this._previewDebounce = undefined\n }\n }\n\n private _renderBufferBars() {\n if (!this.bufferedRanges?.length) return ''\n\n const from = this._startTime.getTime()\n const to = this._endTime.getTime()\n const range = to - from\n if (range <= 0) return ''\n\n return this.bufferedRanges.map(r => {\n const left = Math.max(0, (r.from - from) / range) * 100\n const right = Math.min(1, (r.to - from) / range) * 100\n const width = right - left\n return html`<div class=\"buffer-bar\" style=\"left:${left}%;width:${width}%\"></div>`\n })\n }\n\n private _onSpeedChange(speed: number) {\n this.speed = speed\n\n this.dispatchEvent(\n new CustomEvent('playback-speed', {\n detail: { speed },\n bubbles: true,\n composed: true\n })\n )\n }\n}\n"]}
@@ -3,7 +3,9 @@
3
3
  *
4
4
  * - 10분 단위로 데이터를 fetch하여 버퍼에 적재
5
5
  * - 남은 데이터 1분 이하 시 다음 10분 prefetch
6
- * - seek 시 기존 버퍼 전체 폐기 후 fresh fetch
6
+ * - seek 시 기존 버퍼 폐기 후 fresh fetch (보드 리셋 없음 — snapshot 이 모든 data 덮어씀)
7
+ * - seekPreview: count=1 snapshot 만 요청해 scene 에 적용 (드래그 스크러빙)
8
+ * - pauseForSeek / seekTo 가 scene.suppressAnimations 를 ON/OFF 토글
7
9
  * - setInterval 기반 프레임 진행 (배속 반영)
8
10
  */
9
11
  import type { PlaybackScenario } from '@fmsim/api';
@@ -48,8 +50,10 @@ export declare class PlaybackEngine {
48
50
  get scenarioNames(): string[];
49
51
  /**
50
52
  * fetch 함수 주입 (페이지에서 제공)
53
+ * - 2-arg 호출: 청크 프리페치 (includeSnapshot 기본 true)
54
+ * - 4-arg 호출: 프리뷰용 snapshot-only (count=1)
51
55
  */
52
- setFetchFunction(fn: (startTime: string, endTime: string) => Promise<any>): void;
56
+ setFetchFunction(fn: (startTime: string, endTime?: string, includeSnapshot?: boolean, count?: number) => Promise<any>): void;
53
57
  /**
54
58
  * 청크 기반 시작: fromTime에서 10분 분량을 가져와 재생 시작
55
59
  */
@@ -66,15 +70,22 @@ export declare class PlaybackEngine {
66
70
  stop(): void;
67
71
  setSpeed(speed: number): void;
68
72
  /**
69
- * seek — 기존 버퍼 전체 폐기 + 보드 상태 리셋 + 새 위치에서 fresh fetch
73
+ * seek — 기존 버퍼 폐기 + 새 위치에서 snapshot+delta fresh fetch.
74
+ * 보드 상태 리셋 없음 — snapshot 이 모든 컴포넌트 data 를 덮어쓰므로 불필요.
75
+ * (드래그로 들어온 경우 pauseForSeek 에서 이미 suppressAnimations = true)
70
76
  */
71
77
  seekTo(time: Date): Promise<void>;
72
78
  /**
73
- * Scene 상태 리셋:
74
- * 1) carrier 등 동적 컴포넌트 정리 (carrierManager.dispose)
75
- * 2) 모든 컴포넌트의 data를 null로 초기화 (이전 위치 잔상 제거)
79
+ * 드래그 시작: 재생 타이머 정지 + 애니메이션 억제 ON.
80
+ * 논리 상태(_state)는 유지 — seekTo 가 상태 기반으로 타이머 복원.
76
81
  */
77
- private _resetScene;
82
+ pauseForSeek(): void;
83
+ /**
84
+ * seekPreview — 드래그 중 호출. 해당 시간의 snapshot 1프레임만 요청해서
85
+ * 현재 보드에 적용. 버퍼/상태 변경 없음.
86
+ * (pauseForSeek 에서 이미 suppressAnimations = true 가 걸려 있음)
87
+ */
88
+ seekPreview(time: Date): Promise<void>;
78
89
  on<K extends keyof PlaybackEventMap>(event: K, listener: PlaybackListener<K>): void;
79
90
  off<K extends keyof PlaybackEventMap>(event: K, listener: PlaybackListener<K>): void;
80
91
  dispose(): void;
@@ -3,7 +3,9 @@
3
3
  *
4
4
  * - 10분 단위로 데이터를 fetch하여 버퍼에 적재
5
5
  * - 남은 데이터 1분 이하 시 다음 10분 prefetch
6
- * - seek 시 기존 버퍼 전체 폐기 후 fresh fetch
6
+ * - seek 시 기존 버퍼 폐기 후 fresh fetch (보드 리셋 없음 — snapshot 이 모든 data 덮어씀)
7
+ * - seekPreview: count=1 snapshot 만 요청해 scene 에 적용 (드래그 스크러빙)
8
+ * - pauseForSeek / seekTo 가 scene.suppressAnimations 를 ON/OFF 토글
7
9
  * - setInterval 기반 프레임 진행 (배속 반영)
8
10
  */
9
11
  const CHUNK_DURATION = 10 * 60 * 1000; // 10분
@@ -28,6 +30,8 @@ export class PlaybackEngine {
28
30
  get scenarioNames() { return this._scenarioNames; }
29
31
  /**
30
32
  * fetch 함수 주입 (페이지에서 제공)
33
+ * - 2-arg 호출: 청크 프리페치 (includeSnapshot 기본 true)
34
+ * - 4-arg 호출: 프리뷰용 snapshot-only (count=1)
31
35
  */
32
36
  setFetchFunction(fn) {
33
37
  this._fetchFn = fn;
@@ -99,6 +103,7 @@ export class PlaybackEngine {
99
103
  this._frames = [];
100
104
  this._bufferedEnd = 0;
101
105
  this._prefetching = false;
106
+ this._speed = 1; // 다음 세션이 기본 1x 로 시작하도록 리셋
102
107
  this._setState('stopped');
103
108
  }
104
109
  setSpeed(speed) {
@@ -109,7 +114,9 @@ export class PlaybackEngine {
109
114
  }
110
115
  }
111
116
  /**
112
- * seek — 기존 버퍼 전체 폐기 + 보드 상태 리셋 + 새 위치에서 fresh fetch
117
+ * seek — 기존 버퍼 폐기 + 새 위치에서 snapshot+delta fresh fetch.
118
+ * 보드 상태 리셋 없음 — snapshot 이 모든 컴포넌트 data 를 덮어쓰므로 불필요.
119
+ * (드래그로 들어온 경우 pauseForSeek 에서 이미 suppressAnimations = true)
113
120
  */
114
121
  async seekTo(time) {
115
122
  const wasPlaying = this._state === 'playing';
@@ -119,8 +126,6 @@ export class PlaybackEngine {
119
126
  this._currentIndex = 0;
120
127
  this._bufferedEnd = 0;
121
128
  this._prefetching = false;
122
- // 보드 상태 리셋 — 이전 위치의 carrier 등 동적 컴포넌트 정리
123
- await this._resetScene();
124
129
  if (this._fetchFn && this._totalRange) {
125
130
  this._setState('loading');
126
131
  const chunkEnd = Math.min(time.getTime() + CHUNK_DURATION, this._totalRange.to);
@@ -151,42 +156,58 @@ export class PlaybackEngine {
151
156
  else if (this._frames.length > 0) {
152
157
  this._setState('paused');
153
158
  }
159
+ // 드래그 종료 — 애니메이션 억제 OFF
160
+ if (this._scene)
161
+ this._scene.suppressAnimations = false;
154
162
  }
155
163
  /**
156
- * Scene 상태 리셋:
157
- * 1) carrier 등 동적 컴포넌트 정리 (carrierManager.dispose)
158
- * 2) 모든 컴포넌트의 data를 null로 초기화 (이전 위치 잔상 제거)
164
+ * 드래그 시작: 재생 타이머 정지 + 애니메이션 억제 ON.
165
+ * 논리 상태(_state)는 유지 — seekTo 가 상태 기반으로 타이머 복원.
159
166
  */
160
- async _resetScene() {
167
+ pauseForSeek() {
168
+ this._stopTimer();
169
+ if (this._scene)
170
+ this._scene.suppressAnimations = true;
171
+ }
172
+ /**
173
+ * seekPreview — 드래그 중 호출. 해당 시간의 snapshot 1프레임만 요청해서
174
+ * 현재 보드에 적용. 버퍼/상태 변경 없음.
175
+ * (pauseForSeek 에서 이미 suppressAnimations = true 가 걸려 있음)
176
+ */
177
+ async seekPreview(time) {
161
178
  var _a, _b, _c;
162
- const root = (_a = this._scene) === null || _a === void 0 ? void 0 : _a.root;
163
- if (!root)
179
+ if (!this._fetchFn || !this._scene)
164
180
  return;
165
- // 모든 컴포넌트의 data 및 위치 캐시 초기화 (차량 lastWaypoint 등)
166
- if (typeof root.traverse === 'function') {
167
- root.traverse((c) => {
168
- if (!c || c === root)
169
- return;
170
- if ('data' in c) {
171
- try {
172
- c.data = null;
181
+ try {
182
+ // count=1 로 해당 시점의 snapshot 한 프레임만 요청
183
+ const result = await this._fetchFn(this._formatTime(time), undefined, true, // includeSnapshot
184
+ 1 // count
185
+ );
186
+ // await 동안 scene 이 detach 된 경우 무시 (stop/dispose)
187
+ if (!this._scene)
188
+ return;
189
+ if (!(result === null || result === void 0 ? void 0 : result.status) || !result.playback)
190
+ return;
191
+ const scenarios = result.playback.scenarios;
192
+ if (!(scenarios === null || scenarios === void 0 ? void 0 : scenarios.length))
193
+ return;
194
+ for (const scenario of scenarios) {
195
+ const frame = (_a = scenario.snapshots) === null || _a === void 0 ? void 0 : _a[0];
196
+ if (!(frame === null || frame === void 0 ? void 0 : frame.data))
197
+ continue;
198
+ for (const record of frame.data) {
199
+ if (!(record === null || record === void 0 ? void 0 : record.NAME))
200
+ continue;
201
+ const components = ((_c = (_b = this._scene) === null || _b === void 0 ? void 0 : _b.findAllById) === null || _c === void 0 ? void 0 : _c.call(_b, record.NAME)) || [];
202
+ for (const component of components) {
203
+ component.data = record;
173
204
  }
174
- catch (_a) { }
175
205
  }
176
- // 차량 등 위치 캐시 초기화
177
- if ('lastWaypoint' in c)
178
- c.lastWaypoint = undefined;
179
- });
180
- }
181
- if ((_b = root.carrierManager) === null || _b === void 0 ? void 0 : _b.dispose) {
182
- root.carrierManager.dispose();
206
+ }
183
207
  }
184
- // 진행 중인 reparent 트랜지션 정리 (캐리어 이전 위치 잔상 방지)
185
- if ((_c = root.transitionEffect) === null || _c === void 0 ? void 0 : _c.dispose) {
186
- root.transitionEffect.dispose();
208
+ catch (e) {
209
+ console.error('[PlaybackEngine] seekPreview error:', e);
187
210
  }
188
- // 진행 중인 transition/animation이 완전히 끝나고, 캐리어 오브젝트가 완전히 제거되기를 기다림
189
- await new Promise(resolve => setTimeout(resolve, 1000));
190
211
  }
191
212
  on(event, listener) {
192
213
  if (!this._listeners.has(event))
@@ -199,6 +220,8 @@ export class PlaybackEngine {
199
220
  }
200
221
  dispose() {
201
222
  this._stopTimer();
223
+ if (this._scene)
224
+ this._scene.suppressAnimations = false;
202
225
  this._scene = null;
203
226
  this._frames = [];
204
227
  this._listeners.clear();
@@ -209,6 +232,9 @@ export class PlaybackEngine {
209
232
  return;
210
233
  try {
211
234
  const result = await this._fetchFn(this._formatTime(new Date(fromMs)), this._formatTime(new Date(toMs)));
235
+ // stop 후 응답이 돌아온 경우 무시 (잔여 diff 프레임 방지)
236
+ if (this._state === 'stopped')
237
+ return;
212
238
  if ((result === null || result === void 0 ? void 0 : result.status) && result.playback) {
213
239
  this._appendScenarios(result.playback.scenarios);
214
240
  this._bufferedEnd = Math.max(this._bufferedEnd, toMs);
@@ -311,6 +337,7 @@ export class PlaybackEngine {
311
337
  }
312
338
  _applyFrame(index) {
313
339
  var _a, _b;
340
+ // detach 후(=stop 경로) scene 이 null 이면 no-op
314
341
  const frame = this._frames[index];
315
342
  if (!frame || !this._scene)
316
343
  return;