@fmsim/board 2.0.0-beta.81 → 2.0.0-beta.83

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.
@@ -9,11 +9,18 @@ import './ox-playback-controls.js';
9
9
  import { css, html, LitElement, nothing } from 'lit';
10
10
  import { customElement, property, query, state } from 'lit/decorators.js';
11
11
  import { PlaybackEngine } from './playback/playback-engine.js';
12
+ import { CarrierTrackingStore } from './playback/carrier-tracking-store.js';
12
13
  import { create, LoadTracker, LoadingOverlay, SCENE_MODE } from '@hatiolab/things-scene';
13
14
  import { isIOS, fullscreen, exitfullscreen, togglefullscreen } from '@operato/utils';
14
15
  import { ScrollbarStyles } from '@operato/styles';
15
16
  import { DataStorage } from './data-storage/data-storage.js';
16
17
  import { runScenario, startScenario, DataSubscriptionProviderImpl, isRestfulAvailable } from '@fmsim/api';
18
+ // FAB 아이콘 — packages/board/icons/fab/ 의 외부 SVG asset 로 분리.
19
+ // 기존엔 inline SVG 였으나 (a) 가독성 향상, (b) 디자이너 단독 수정, (c) 캐시 활용 목적.
20
+ const ICON_FAB_PLAYBACK_PLAY = new URL('../../icons/fab/playback-play.svg', import.meta.url).href;
21
+ const ICON_FAB_PLAYBACK_STOP = new URL('../../icons/fab/playback-stop.svg', import.meta.url).href;
22
+ const ICON_FAB_MODE_2D = new URL('../../icons/fab/mode-2d.svg', import.meta.url).href;
23
+ const ICON_FAB_MODE_3D = new URL('../../icons/fab/mode-3d.svg', import.meta.url).href;
17
24
  let BoardViewer = BoardViewer_1 = class BoardViewer extends LitElement {
18
25
  constructor() {
19
26
  var _a;
@@ -45,16 +52,16 @@ let BoardViewer = BoardViewer_1 = class BoardViewer extends LitElement {
45
52
  };
46
53
  // 모니터링에서 Movement Data를 보여준다.
47
54
  this.fmbShowMovementHandler = (e) => {
48
- var _a, _b;
55
+ var _a, _b, _c;
49
56
  // 플레이백 중에는 외부 모니터링이 보내는 실시간 반송 정보를 그리지 않는다 —
50
57
  // 사용자가 플레이백 시점의 *과거 상태* 를 보고 있는데 실시간 화살표가 겹치면
51
58
  // 혼란을 줌. (백엔드 상세 정보 요청은 ox-board-component-info.ts:225 에서
52
59
  // 같은 playbackMode 가드로 이미 차단됨.)
53
- if ((_a = this._scene) === null || _a === void 0 ? void 0 : _a.playbackMode) {
60
+ if ((_b = (_a = this._scene) === null || _a === void 0 ? void 0 : _a.rootContainer) === null || _b === void 0 ? void 0 : _b.playbackMode) {
54
61
  return;
55
62
  }
56
63
  const movement = e.detail.value;
57
- const movementLayer = (_b = this._scene) === null || _b === void 0 ? void 0 : _b.findById(`movement-layer`);
64
+ const movementLayer = (_c = this._scene) === null || _c === void 0 ? void 0 : _c.findById(`movement-layer`);
58
65
  if (!movementLayer) {
59
66
  return;
60
67
  }
@@ -198,6 +205,19 @@ let BoardViewer = BoardViewer_1 = class BoardViewer extends LitElement {
198
205
  };
199
206
  // ── Data Subscription ──
200
207
  this._dataSubscriptionProvider = isRestfulAvailable() ? undefined : new DataSubscriptionProviderImpl();
208
+ /**
209
+ * 영구추적 fallback 데이터 캐시 — 플레이백 윈도우 × carrierName 단위로
210
+ * carrier 위치 이력을 보관. carrier 가 scene 에 직접 안 보일 때
211
+ * Tracker fallback 콜백이 lookupAt 으로 머신/유닛을 찾아 반환.
212
+ */
213
+ this._carrierTrackingStore = new CarrierTrackingStore();
214
+ /**
215
+ * fallback 결과(target 컴포넌트) dedupe — 직전과 동일한 target 일 땐 로그 skip
216
+ * + deco-arrow 라이프사이클 (target 변경 시 이전 끄고 새로 부착).
217
+ */
218
+ this._lastFallbackTarget = null;
219
+ /** _resolveCarrierFallback 마지막 호출 timestamp — carrier 발견 감지용. */
220
+ this._lastFallbackResolvedAt = 0;
201
221
  }
202
222
  get _is3dMode() {
203
223
  var _a, _b, _c;
@@ -231,139 +251,21 @@ let BoardViewer = BoardViewer_1 = class BoardViewer extends LitElement {
231
251
  class="sub-fab"
232
252
  @click=${() => { this._fabExpanded = false; this.onTapPlayback(); }}
233
253
  title=${this._playbackActive ? 'stop playback' : 'start playback'}
234
- >${this._playbackActive
235
- ? html `<svg slot="icon" width="48" height="48" viewBox="0 0 100 100" style="display: block;">
236
- <circle cx="50" cy="50" r="50" fill="#F9EBEA" />
237
- <g transform="translate(-5, 0)">
238
- <path
239
- d="M 52,22 A 28,28 0 1,1 30,40"
240
- fill="none"
241
- stroke="#F87171"
242
- stroke-width="5"
243
- stroke-linecap="round"
244
- />
245
- <path d="M 52,12 L 37,24 L 55,33 Z" fill="#F87171" />
246
- </g>
247
- <rect
248
- x="36"
249
- y="36"
250
- width="28"
251
- height="28"
252
- rx="3"
253
- fill="#F87171"
254
- stroke="#F87171"
255
- stroke-width="3"
256
- stroke-linejoin="round"
257
- />
258
- <text
259
- x="50"
260
- y="53.5"
261
- font-family="Verdana, sans-serif"
262
- font-size="9"
263
- font-weight="900"
264
- fill="#ffffff"
265
- text-anchor="middle"
266
- >OFF</text>
267
- </svg>`
268
- : html `<svg slot="icon" width="48" height="48" viewBox="0 0 100 100" style="display: block;">
269
- <circle cx="50" cy="50" r="50" fill="#F9EBEA" />
270
- <g transform="translate(-5, 0)">
271
- <path
272
- d="M 52,22 A 28,28 0 1,1 30,40"
273
- fill="none"
274
- stroke="#F87171"
275
- stroke-width="5"
276
- stroke-linecap="round"
277
- />
278
- <path d="M 52,12 L 37,24 L 55,33 Z" fill="#F87171" />
279
- </g>
280
- <path
281
- d="M 42,35 L 65,50 L 42,65 Z"
282
- fill="#F87171"
283
- stroke="#F87171"
284
- stroke-width="3"
285
- stroke-linejoin="round"
286
- />
287
- <text
288
- x="49.5"
289
- y="53"
290
- font-family="Verdana, sans-serif"
291
- font-size="9"
292
- font-weight="900"
293
- fill="#ffffff"
294
- text-anchor="middle"
295
- >RE:</text>
296
- </svg>`}</md-fab>
254
+ ><span slot="icon" class="fab-icon-wrap"><img
255
+ class="fab-icon"
256
+ src=${this._playbackActive ? ICON_FAB_PLAYBACK_STOP : ICON_FAB_PLAYBACK_PLAY}
257
+ alt=${this._playbackActive ? 'stop playback' : 'start playback'}
258
+ /></span></md-fab>
297
259
 
298
260
  <md-fab
299
261
  class="sub-fab"
300
262
  @click=${() => { this._fabExpanded = false; this.onTapToggle3D(); }}
301
263
  title=${this._is3dMode ? '2D mode' : '3D mode'}
302
- >${this._is3dMode
303
- ? html `<svg slot="icon" width="48" height="48" viewBox="0 0 100 100" style="display: block;">
304
- <circle cx="50" cy="50" r="50" fill="#F9EBEA" />
305
- <g transform="rotate(55, 50, 50)">
306
- <path
307
- d="M 72,32 A 30,30 0 0,0 38,28"
308
- fill="none"
309
- stroke="#F87171"
310
- stroke-width="5"
311
- stroke-linecap="round"
312
- />
313
- <path d="M 38,20 L 22,30 L 38,40 Z" fill="#F87171" />
314
- </g>
315
- <g transform="rotate(235, 50, 50)">
316
- <path
317
- d="M 72,32 A 30,30 0 0,0 38,28"
318
- fill="none"
319
- stroke="#F87171"
320
- stroke-width="5"
321
- stroke-linecap="round"
322
- />
323
- <path d="M 38,20 L 22,30 L 38,40 Z" fill="#F87171" />
324
- </g>
325
- <text
326
- x="50"
327
- y="57.5"
328
- font-family="Verdana, sans-serif"
329
- font-size="20"
330
- font-weight="900"
331
- fill="#F87171"
332
- text-anchor="middle"
333
- >2D</text>
334
- </svg>`
335
- : html `<svg slot="icon" width="48" height="48" viewBox="0 0 100 100" style="display: block;">
336
- <circle cx="50" cy="50" r="50" fill="#F9EBEA" />
337
- <g transform="rotate(55, 50, 50)">
338
- <path
339
- d="M 72,32 A 30,30 0 0,0 38,28"
340
- fill="none"
341
- stroke="#F87171"
342
- stroke-width="5"
343
- stroke-linecap="round"
344
- />
345
- <path d="M 38,20 L 22,30 L 38,40 Z" fill="#F87171" />
346
- </g>
347
- <g transform="rotate(235, 50, 50)">
348
- <path
349
- d="M 72,32 A 30,30 0 0,0 38,28"
350
- fill="none"
351
- stroke="#F87171"
352
- stroke-width="5"
353
- stroke-linecap="round"
354
- />
355
- <path d="M 38,20 L 22,30 L 38,40 Z" fill="#F87171" />
356
- </g>
357
- <text
358
- x="50"
359
- y="57.5"
360
- font-family="Verdana, sans-serif"
361
- font-size="20"
362
- font-weight="900"
363
- fill="#F87171"
364
- text-anchor="middle"
365
- >3D</text>
366
- </svg>`}</md-fab>
264
+ ><span slot="icon" class="fab-icon-wrap"><img
265
+ class="fab-icon"
266
+ src=${this._is3dMode ? ICON_FAB_MODE_2D : ICON_FAB_MODE_3D}
267
+ alt=${this._is3dMode ? '2D mode' : '3D mode'}
268
+ /></span></md-fab>
367
269
  </div>
368
270
  `;
369
271
  var prev = !this.hideNavigation
@@ -472,6 +374,10 @@ let BoardViewer = BoardViewer_1 = class BoardViewer extends LitElement {
472
374
  (_e = this._dataSubscriptionProvider) === null || _e === void 0 ? void 0 : _e.resume();
473
375
  this._playbackActive = false;
474
376
  }
377
+ this._playbackWindow = undefined;
378
+ this._carrierTrackingStore.clear();
379
+ this._clearFallbackArrow();
380
+ this._stopFallbackArrowMonitor();
475
381
  if (this._playbackEngine) {
476
382
  this._playbackEngine.dispose();
477
383
  this._playbackEngine = undefined;
@@ -635,7 +541,203 @@ let BoardViewer = BoardViewer_1 = class BoardViewer extends LitElement {
635
541
  // VIEW 모드 SearchOverlay 디폴트 = 영구 추적 (things-scene 자동 처리).
636
542
  // application 은 trackerOptions (color/fallback 등) 또는 onSearchSubmit/onSearchClose
637
543
  // hook (디폴트 override) 만 제공.
638
- this._scene.trackerOptions = { color: BoardViewer_1.TRACKING_COLOR };
544
+ //
545
+ // fallback: 원본 carrier 컴포넌트가 scene 에 없을 때 호출 (Tracker 1s polling).
546
+ // 플레이백 중이면 carrier-location history 에서 현재 시각의 머신/유닛을 lookup 해
547
+ // 해당 컴포넌트를 highlight 대상으로 반환. 플레이백 비활성 / 시간 정보 없음 /
548
+ // NA / 보드에 머신 없음 → null 반환해서 highlight 없이 추적만 유지.
549
+ this._scene.trackerOptions = {
550
+ color: BoardViewer_1.TRACKING_COLOR,
551
+ fallback: (_id, scene) => this._resolveCarrierFallback(scene)
552
+ };
553
+ // SearchOverlay submit/close hook — carrier-location store 의 충전/정리 트리거.
554
+ // 디폴트 동작 (tracker.start/stop) 은 그대로 위임 (return undefined).
555
+ this._scene.onSearchSubmit = (detail) => {
556
+ this._onCarrierTrackingSubmit(detail === null || detail === void 0 ? void 0 : detail.query);
557
+ };
558
+ this._scene.onSearchClose = () => {
559
+ this._carrierTrackingStore.clear();
560
+ };
561
+ }
562
+ /**
563
+ * Tracker fallback resolver — 플레이백 시각의 carrier 위치를 컴포넌트로 변환.
564
+ * NA / 정보 없음 / 머신 미존재 → null (Tracker 가 highlight 없이 추적만 유지).
565
+ *
566
+ * 매 1초 polling 마다 호출되며, 결과 target 이 직전과 동일하면 로그 skip.
567
+ */
568
+ _resolveCarrierFallback(scene) {
569
+ var _a, _b, _c, _d;
570
+ if (!this._playbackActive)
571
+ return null;
572
+ const currentTime = (_a = this._playbackEngine) === null || _a === void 0 ? void 0 : _a.currentTime;
573
+ if (!currentTime)
574
+ return null;
575
+ const timeMs = Date.parse(currentTime.replace(' ', 'T'));
576
+ if (!Number.isFinite(timeMs))
577
+ return null;
578
+ const carrier = (_d = (_c = (_b = this._scene) === null || _b === void 0 ? void 0 : _b.tracker) === null || _c === void 0 ? void 0 : _c.trackingId) !== null && _d !== void 0 ? _d : '?';
579
+ const loc = this._carrierTrackingStore.lookupAt(timeMs);
580
+ this._lastFallbackResolvedAt = Date.now();
581
+ const logIfChanged = (target, msg) => {
582
+ if (target === this._lastFallbackTarget)
583
+ return;
584
+ // 이전 target 의 bouncing arrow 해제 + 새 target 에 부착 (가시성 강화).
585
+ // deco-border 는 things-scene Tracker 가 처리하므로 우리는 deco-arrow 만 관리.
586
+ this._setFallbackArrow(this._lastFallbackTarget, null);
587
+ this._setFallbackArrow(null, target);
588
+ this._lastFallbackTarget = target;
589
+ console.log(msg);
590
+ };
591
+ if (!loc) {
592
+ logIfChanged(null, `[CarrierTracking] fallback: history miss (carrier=${carrier}, t=${currentTime})`);
593
+ return null;
594
+ }
595
+ if (loc.machineName === 'NA') {
596
+ logIfChanged(null, `[CarrierTracking] fallback: NA (carrier=${carrier}, t=${currentTime})`);
597
+ return null;
598
+ }
599
+ // unitName 우선 (더 정밀), 없으면 machineName 으로 폴백.
600
+ // 같은 id 컴포넌트가 여러 개일 수 있어 (예: 머신 본체 + 부착된 게이지) findAllById
601
+ // 로 후보 모두 가져온 뒤, mcs-mouse-event-handler 의 클릭 dispatch 와 동일한
602
+ // 패턴으로 머신 본체로 수렴:
603
+ // - 'isLVComponent' 후보 선택
604
+ // - parentObject 가 있으면 그쪽으로 escalate (게이지·바 → 머신 본체)
605
+ // - 없으면 자기 자신 사용
606
+ const resolveLVById = (id) => {
607
+ var _a, _b;
608
+ const candidates = (((_a = scene === null || scene === void 0 ? void 0 : scene.findAllById) === null || _a === void 0 ? void 0 : _a.call(scene, id)) || []);
609
+ const c = candidates.find(c => 'isLVComponent' in c);
610
+ if (!c)
611
+ return null;
612
+ return (_b = c.parentObject) !== null && _b !== void 0 ? _b : c;
613
+ };
614
+ let target = null;
615
+ let hitVia = null;
616
+ if (loc.unitName) {
617
+ const t = resolveLVById(loc.unitName);
618
+ if (t) {
619
+ target = t;
620
+ hitVia = 'unit';
621
+ }
622
+ }
623
+ if (!target && loc.machineName) {
624
+ const t = resolveLVById(loc.machineName);
625
+ if (t) {
626
+ target = t;
627
+ hitVia = 'machine';
628
+ }
629
+ }
630
+ if (hitVia === 'unit') {
631
+ logIfChanged(target, `[CarrierTracking] fallback applied via unit=${loc.unitName} ` +
632
+ `(machine=${loc.machineName} not tried) (carrier=${carrier}, t=${currentTime})`);
633
+ }
634
+ else if (hitVia === 'machine') {
635
+ logIfChanged(target, `[CarrierTracking] fallback applied via machine=${loc.machineName} ` +
636
+ `(unit=${loc.unitName} miss) (carrier=${carrier}, t=${currentTime})`);
637
+ }
638
+ else {
639
+ logIfChanged(target, `[CarrierTracking] fallback: scene 미존재 — unit=${loc.unitName} (miss), ` +
640
+ `machine=${loc.machineName} (miss) (carrier=${carrier}, t=${currentTime})`);
641
+ }
642
+ return target;
643
+ }
644
+ /**
645
+ * deco-arrow 라이프사이클 — 이전 target 끄고 새 target 에 bouncing arrow 부착.
646
+ * 둘 다 null 가능 (해제만, 부착만).
647
+ */
648
+ _setFallbackArrow(off, on) {
649
+ var _a, _b, _c, _d;
650
+ if (off) {
651
+ try {
652
+ (_b = (_a = off).trigger) === null || _b === void 0 ? void 0 : _b.call(_a, 'deco-arrow-off');
653
+ }
654
+ catch (_e) { }
655
+ }
656
+ if (on) {
657
+ try {
658
+ ;
659
+ (_d = (_c = on).trigger) === null || _d === void 0 ? void 0 : _d.call(_c, 'deco-arrow', {
660
+ animation: true,
661
+ repeat: true,
662
+ size: 0.75,
663
+ // TRACKING_COLOR(#00aaff7f) 의 색조는 유지하되 알파(7f) 만 제거해 불투명.
664
+ color: BoardViewer_1.TRACKING_COLOR.slice(0, 7),
665
+ duration: 5000
666
+ });
667
+ }
668
+ catch (_f) { }
669
+ }
670
+ }
671
+ /**
672
+ * 1초 polling — 화살표 부착 대상을 결정.
673
+ * 1) 플레이백 종료 / 추적 ID 없음 → 화살표 해제
674
+ * 2) carrier 가 scene 에 직접 존재 → **carrier 자체에 화살표 부착**
675
+ * (things-scene Tracker 가 carrier 발견 시 fallback 콜백 호출을 멈춰
676
+ * resolver 경로로는 우리가 모름. monitor 에서 직접 책임)
677
+ * 3) carrier 없음 + resolver 가 3초 이상 호출 안 됨 → 화살표 해제
678
+ * (그 외엔 resolver 의 logIfChanged 가 fallback target 으로 부착 중)
679
+ */
680
+ _startFallbackArrowMonitor() {
681
+ if (this._fallbackArrowMonitor != null)
682
+ return;
683
+ this._fallbackArrowMonitor = window.setInterval(() => {
684
+ var _a, _b, _c, _d, _e;
685
+ // 1) 플레이백 종료 / 추적 ID 해제
686
+ const trackingId = (_b = (_a = this._scene) === null || _a === void 0 ? void 0 : _a.tracker) === null || _b === void 0 ? void 0 : _b.trackingId;
687
+ if (!this._playbackActive || !trackingId) {
688
+ this._clearFallbackArrow();
689
+ return;
690
+ }
691
+ // 2) carrier 가 scene 에 직접 존재 — carrier(또는 parentObject)에 화살표 부착
692
+ const carrierComp = (((_d = (_c = this._scene) === null || _c === void 0 ? void 0 : _c.findAllById) === null || _d === void 0 ? void 0 : _d.call(_c, trackingId)) || [])
693
+ .find(c => 'isLVComponent' in c);
694
+ if (carrierComp) {
695
+ const arrowTarget = (_e = carrierComp.parentObject) !== null && _e !== void 0 ? _e : carrierComp;
696
+ if (arrowTarget !== this._lastFallbackTarget) {
697
+ this._setFallbackArrow(this._lastFallbackTarget, null);
698
+ this._setFallbackArrow(null, arrowTarget);
699
+ this._lastFallbackTarget = arrowTarget;
700
+ }
701
+ return;
702
+ }
703
+ // 3) resolver 가 일정 시간 호출되지 않음 — Tracker 가 추적 중단했을 가능성
704
+ if (this._lastFallbackTarget && Date.now() - this._lastFallbackResolvedAt > 3000) {
705
+ this._clearFallbackArrow();
706
+ }
707
+ }, 1000);
708
+ }
709
+ _stopFallbackArrowMonitor() {
710
+ if (this._fallbackArrowMonitor != null) {
711
+ clearInterval(this._fallbackArrowMonitor);
712
+ this._fallbackArrowMonitor = undefined;
713
+ }
714
+ }
715
+ /** 부착된 deco-arrow 강제 해제 + dedupe 상태 초기화. */
716
+ _clearFallbackArrow() {
717
+ if (this._lastFallbackTarget) {
718
+ this._setFallbackArrow(this._lastFallbackTarget, null);
719
+ this._lastFallbackTarget = null;
720
+ }
721
+ }
722
+ /**
723
+ * onSearchSubmit hook — 영구추적 carrierName 이 설정되는 순간.
724
+ * 플레이백 윈도우가 잡혀 있으면 즉시 store 충전. 윈도우가 없으면
725
+ * 플레이백 시작 시 (_onPlaybackStart) 에 충전.
726
+ */
727
+ _onCarrierTrackingSubmit(carrierName) {
728
+ this._clearFallbackArrow(); // 새 carrier — 기존 화살표/dedupe 정리
729
+ if (!carrierName) {
730
+ this._carrierTrackingStore.clear();
731
+ return;
732
+ }
733
+ const win = this._playbackWindow;
734
+ if (this._playbackActive && win) {
735
+ this._carrierTrackingStore.load(carrierName, win.from, win.to);
736
+ }
737
+ else {
738
+ // 플레이백 비활성 — 윈도우 정해질 때까지 대기. 캐시는 비워둠.
739
+ this._carrierTrackingStore.clear();
740
+ }
639
741
  }
640
742
  unbindSceneEvents(scene) {
641
743
  scene.off('run', this.onRunBoard, this);
@@ -727,7 +829,7 @@ let BoardViewer = BoardViewer_1 = class BoardViewer extends LitElement {
727
829
  }
728
830
  }
729
831
  onTapPlayback() {
730
- var _a;
832
+ var _a, _b;
731
833
  if (this._playbackActive) {
732
834
  this._onPlaybackStop();
733
835
  }
@@ -740,6 +842,11 @@ let BoardViewer = BoardViewer_1 = class BoardViewer extends LitElement {
740
842
  // 2) 라이브 데이터 초기화 (깨끗한 상태에서 playback 시작)
741
843
  this._resetSceneData();
742
844
  this._playbackActive = true;
845
+ // playbackMode 를 "라이브 아님" 의미로 통일 — 준비 단계부터 set 해서
846
+ // FAB 클릭 직후 ~ Play 누르기 전 사이의 갭에서도 가드 (mouse handler,
847
+ // movement layer, restful subscription) 가 작동하도록.
848
+ if ((_b = this._scene) === null || _b === void 0 ? void 0 : _b.rootContainer)
849
+ this._scene.rootContainer.playbackMode = true;
743
850
  if (!this._playbackEngine) {
744
851
  this._playbackEngine = new PlaybackEngine();
745
852
  this._playbackEngine.on('frame', ({ time }) => {
@@ -779,6 +886,7 @@ let BoardViewer = BoardViewer_1 = class BoardViewer extends LitElement {
779
886
  }
780
887
  }
781
888
  async _onPlaybackStart(e) {
889
+ var _a, _b, _c;
782
890
  const { fromTime, toTime, speed } = e.detail;
783
891
  if (!this._playbackEngine)
784
892
  return;
@@ -788,8 +896,19 @@ let BoardViewer = BoardViewer_1 = class BoardViewer extends LitElement {
788
896
  // the requested window. Falling back to `+1h` only if toTime was
789
897
  // omitted (legacy callers).
790
898
  const endTime = toTime instanceof Date ? toTime : new Date(fromTime.getTime() + 3600000);
791
- if (this._scene)
792
- this._scene.playbackMode = true;
899
+ // playbackMode 는 onTapPlayback (준비 단계) 에서 이미 set — idempotent
900
+ // 재설정으로 안전망 유지.
901
+ if ((_a = this._scene) === null || _a === void 0 ? void 0 : _a.rootContainer)
902
+ this._scene.rootContainer.playbackMode = true;
903
+ // 플레이백 윈도우 기록 + 영구추적 활성 시 carrier history 충전.
904
+ // 윈도우 변경 (재시작) 시에도 같은 경로 — store.load 가 key 비교로
905
+ // 동일 (carrierName, from, to) 이면 no-op.
906
+ this._playbackWindow = { from: fromTime, to: endTime };
907
+ const carrierName = (_c = (_b = this._scene) === null || _b === void 0 ? void 0 : _b.tracker) === null || _c === void 0 ? void 0 : _c.trackingId;
908
+ if (carrierName) {
909
+ this._carrierTrackingStore.load(carrierName, fromTime, endTime);
910
+ }
911
+ this._startFallbackArrowMonitor();
793
912
  try {
794
913
  await this._playbackEngine.startChunked(fromTime, endTime, speed);
795
914
  }
@@ -812,9 +931,14 @@ let BoardViewer = BoardViewer_1 = class BoardViewer extends LitElement {
812
931
  (_a = this._playbackEngine) === null || _a === void 0 ? void 0 : _a.detachScene();
813
932
  (_b = this._playbackEngine) === null || _b === void 0 ? void 0 : _b.stop();
814
933
  this._playbackActive = false;
934
+ this._playbackWindow = undefined;
935
+ this._carrierTrackingStore.clear();
936
+ this._clearFallbackArrow();
937
+ this._stopFallbackArrowMonitor();
815
938
  if (scene) {
816
939
  scene.suppressAnimations = true;
817
- scene.playbackMode = false;
940
+ if (scene.rootContainer)
941
+ scene.rootContainer.playbackMode = false;
818
942
  }
819
943
  // UI 신호 + fullscreen 해제
820
944
  this.dispatchEvent(new CustomEvent('playback-toggle', {
@@ -1040,8 +1164,11 @@ let BoardViewer = BoardViewer_1 = class BoardViewer extends LitElement {
1040
1164
  this.popup = popup;
1041
1165
  }
1042
1166
  if (this.popup) {
1043
- this.popup.component = component;
1167
+ // playbackMode 를 component 보다 먼저 set — BoardComponentInfo 의 updated()
1168
+ // 가 component 변경을 보는 시점에 playbackMode 가 반드시 최신값이어야
1169
+ // detailInfo() 백엔드 호출 가드가 일관 동작.
1044
1170
  this.popup.playbackMode = this._playbackActive;
1171
+ this.popup.component = component;
1045
1172
  }
1046
1173
  }
1047
1174
  hidePopup() {
@@ -1131,19 +1258,46 @@ BoardViewer.styles = [
1131
1258
  }
1132
1259
 
1133
1260
  [fab-group] md-fab.sub-fab {
1134
- --md-fab-container-width: 40px;
1135
- --md-fab-container-height: 40px;
1136
- --md-icon-size: 20px;
1261
+ --md-fab-container-width: 48px;
1262
+ --md-fab-container-height: 48px;
1263
+ /* fab 표면 색 = image 의 자체 원형 배경 (#F9EBEA) 과 일치.
1264
+ * 이렇게 하면 md-fab 의 내부 padding 영역도 같은 색이라
1265
+ * 시각적으로 사라짐 — 세 버튼 모두 visible 48px 컨테이너 통일. */
1266
+ --md-fab-container-color: #F9EBEA;
1267
+ --md-fab-icon-color: #F87171;
1268
+ --md-icon-size: 36px;
1137
1269
  transform: scale(0);
1138
1270
  opacity: 0;
1139
1271
  pointer-events: none;
1140
1272
  }
1141
1273
 
1142
- /* SVG icon 은 fab 전체 크기로 — 자체 원이 fab 표면을 대체. */
1274
+ /* visible 영역은 컨테이너(48px) 그대로 — fullscreen 버튼과 동일.
1275
+ * img 자체는 48px 박스이고 transform scale 로 내부 그래픽만 확대.
1276
+ * wrap 의 overflow:hidden + border-radius:50% 가 fab 원형 밖으로
1277
+ * 넘치는 영역을 잘라내어 외형 크기는 변하지 않음. */
1278
+ [fab-group] md-fab.sub-fab .fab-icon-wrap {
1279
+ width: 48px;
1280
+ height: 48px;
1281
+ display: block;
1282
+ overflow: hidden;
1283
+ border-radius: 50%;
1284
+ }
1285
+
1286
+ [fab-group] md-fab.sub-fab .fab-icon-wrap img.fab-icon {
1287
+ width: 48px;
1288
+ height: 48px;
1289
+ display: block;
1290
+ transform: scale(1.15);
1291
+ transform-origin: center;
1292
+ }
1293
+
1294
+ /* legacy inline svg 경로 — 동일하게 box 48 유지 + scale 로 그래픽만 확대. */
1143
1295
  [fab-group] md-fab.sub-fab svg[slot="icon"] {
1144
- width: 40px;
1145
- height: 40px;
1296
+ width: 48px;
1297
+ height: 48px;
1146
1298
  display: block;
1299
+ transform: scale(1.15);
1300
+ transform-origin: center;
1147
1301
  }
1148
1302
 
1149
1303
  [fab-group][expanded] md-fab.sub-fab {