@operato/board 10.0.0-beta.7 → 10.0.0-beta.8

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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,15 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [10.0.0-beta.8](https://github.com/hatiolab/operato/compare/v10.0.0-beta.7...v10.0.0-beta.8) (2026-03-13)
7
+
8
+
9
+ ### :rocket: New Features
10
+
11
+ * **board:** add playback feature for historical data replay ([e8a3a73](https://github.com/hatiolab/operato/commit/e8a3a730648c2b5ec17244b157e1ce59b728b524))
12
+
13
+
14
+
6
15
  ## [10.0.0-beta.7](https://github.com/hatiolab/operato/compare/v10.0.0-beta.6...v10.0.0-beta.7) (2026-03-09)
7
16
 
8
17
 
@@ -0,0 +1,74 @@
1
+ import { Component, DataSubscriptionProvider } from '@hatiolab/things-scene';
2
+ export type PlaybackState = 'idle' | 'playing' | 'paused' | 'stopped';
3
+ export interface PlaybackStatus {
4
+ state: PlaybackState;
5
+ currentTime: string;
6
+ speed: number;
7
+ }
8
+ export interface PlaybackConfig {
9
+ /** 플레이백 가능한 시간 범위 */
10
+ timeRange?: {
11
+ from: Date;
12
+ to: Date;
13
+ };
14
+ }
15
+ /**
16
+ * PlaybackProvider
17
+ *
18
+ * 백엔드 플레이백 서비스와 point-to-point 구독으로 통신하는 DataSubscriptionProvider.
19
+ * 백엔드가 요청된 시점/배속으로 스냅샷을 실시간처럼 푸시하면,
20
+ * 이를 받아서 등록된 컴포넌트에 전달한다.
21
+ *
22
+ * 실시간 DataSubscriptionProviderImpl과 동일한 인터페이스를 구현하므로
23
+ * Scene 입장에서는 실시간/플레이백을 구분하지 않는다.
24
+ */
25
+ export declare class PlaybackProvider implements DataSubscriptionProvider {
26
+ private _components;
27
+ private _subscription;
28
+ private _state;
29
+ private _speed;
30
+ private _currentTime;
31
+ private _onStatusChange?;
32
+ constructor(onStatusChange?: (status: PlaybackStatus) => void);
33
+ get state(): PlaybackState;
34
+ get speed(): number;
35
+ get currentTime(): string;
36
+ /**
37
+ * DataSubscriptionProvider.subscribe 구현
38
+ * 컴포넌트를 tag별로 등록한다. 실제 데이터는 백엔드 구독을 통해 수신된다.
39
+ */
40
+ subscribe(tag: string, component: Component): Promise<{
41
+ unsubscribe: () => void;
42
+ }>;
43
+ /**
44
+ * 플레이백 시작 — 백엔드에 point-to-point 구독을 개시한다.
45
+ */
46
+ start(fromTime: Date, speed?: number): Promise<void>;
47
+ /**
48
+ * 일시정지
49
+ */
50
+ pause(): Promise<void>;
51
+ /**
52
+ * 재개
53
+ */
54
+ resume(): Promise<void>;
55
+ /**
56
+ * 특정 시점으로 이동
57
+ */
58
+ seek(toTime: Date): Promise<void>;
59
+ /**
60
+ * 배속 변경
61
+ */
62
+ setSpeed(speed: number): Promise<void>;
63
+ /**
64
+ * 플레이백 중지 및 구독 해제
65
+ */
66
+ stop(): void;
67
+ /**
68
+ * DataSubscriptionProvider.dispose 구현
69
+ */
70
+ dispose(): void;
71
+ private _distributeSnapshot;
72
+ private _sendCommand;
73
+ private _notifyStatus;
74
+ }
@@ -0,0 +1,191 @@
1
+ import gql from 'graphql-tag';
2
+ import { subscribe } from '@operato/graphql';
3
+ /**
4
+ * PlaybackProvider
5
+ *
6
+ * 백엔드 플레이백 서비스와 point-to-point 구독으로 통신하는 DataSubscriptionProvider.
7
+ * 백엔드가 요청된 시점/배속으로 스냅샷을 실시간처럼 푸시하면,
8
+ * 이를 받아서 등록된 컴포넌트에 전달한다.
9
+ *
10
+ * 실시간 DataSubscriptionProviderImpl과 동일한 인터페이스를 구현하므로
11
+ * Scene 입장에서는 실시간/플레이백을 구분하지 않는다.
12
+ */
13
+ export class PlaybackProvider {
14
+ constructor(onStatusChange) {
15
+ this._components = new Map();
16
+ this._subscription = null;
17
+ this._state = 'idle';
18
+ this._speed = 1;
19
+ this._currentTime = '';
20
+ this._onStatusChange = onStatusChange;
21
+ }
22
+ get state() {
23
+ return this._state;
24
+ }
25
+ get speed() {
26
+ return this._speed;
27
+ }
28
+ get currentTime() {
29
+ return this._currentTime;
30
+ }
31
+ /**
32
+ * DataSubscriptionProvider.subscribe 구현
33
+ * 컴포넌트를 tag별로 등록한다. 실제 데이터는 백엔드 구독을 통해 수신된다.
34
+ */
35
+ async subscribe(tag, component) {
36
+ if (!this._components.has(tag)) {
37
+ this._components.set(tag, new Set());
38
+ }
39
+ this._components.get(tag).add(component);
40
+ return {
41
+ unsubscribe: () => {
42
+ const components = this._components.get(tag);
43
+ if (components) {
44
+ components.delete(component);
45
+ if (components.size === 0) {
46
+ this._components.delete(tag);
47
+ }
48
+ }
49
+ }
50
+ };
51
+ }
52
+ /**
53
+ * 플레이백 시작 — 백엔드에 point-to-point 구독을 개시한다.
54
+ */
55
+ async start(fromTime, speed = 1) {
56
+ var _a;
57
+ // 기존 구독이 있으면 정리
58
+ (_a = this._subscription) === null || _a === void 0 ? void 0 : _a.unsubscribe();
59
+ this._speed = speed;
60
+ this._state = 'playing';
61
+ this._subscription = await subscribe({
62
+ query: gql `
63
+ subscription Playback($fromTime: String!, $speed: Float!) {
64
+ playback(fromTime: $fromTime, speed: $speed) {
65
+ timestamp
66
+ data
67
+ }
68
+ }
69
+ `,
70
+ variables: {
71
+ fromTime: fromTime.toISOString(),
72
+ speed
73
+ }
74
+ }, {
75
+ next: ({ data }) => {
76
+ if (!(data === null || data === void 0 ? void 0 : data.playback))
77
+ return;
78
+ const { timestamp, data: snapshotData } = data.playback;
79
+ this._currentTime = timestamp;
80
+ // 스냅샷 데이터를 tag별로 분배
81
+ this._distributeSnapshot(snapshotData);
82
+ this._notifyStatus();
83
+ },
84
+ error: (err) => {
85
+ console.error('[PlaybackProvider] subscription error:', err);
86
+ this._state = 'stopped';
87
+ this._notifyStatus();
88
+ },
89
+ complete: () => {
90
+ this._state = 'stopped';
91
+ this._notifyStatus();
92
+ }
93
+ });
94
+ this._notifyStatus();
95
+ }
96
+ /**
97
+ * 일시정지
98
+ */
99
+ async pause() {
100
+ if (this._state !== 'playing')
101
+ return;
102
+ this._state = 'paused';
103
+ // 백엔드에 pause 명령 전송 (mutation)
104
+ await this._sendCommand('pause');
105
+ this._notifyStatus();
106
+ }
107
+ /**
108
+ * 재개
109
+ */
110
+ async resume() {
111
+ if (this._state !== 'paused')
112
+ return;
113
+ this._state = 'playing';
114
+ await this._sendCommand('resume');
115
+ this._notifyStatus();
116
+ }
117
+ /**
118
+ * 특정 시점으로 이동
119
+ */
120
+ async seek(toTime) {
121
+ await this._sendCommand('seek', { toTime: toTime.toISOString() });
122
+ this._currentTime = toTime.toISOString();
123
+ this._notifyStatus();
124
+ }
125
+ /**
126
+ * 배속 변경
127
+ */
128
+ async setSpeed(speed) {
129
+ this._speed = speed;
130
+ await this._sendCommand('speed', { speed });
131
+ this._notifyStatus();
132
+ }
133
+ /**
134
+ * 플레이백 중지 및 구독 해제
135
+ */
136
+ stop() {
137
+ var _a;
138
+ (_a = this._subscription) === null || _a === void 0 ? void 0 : _a.unsubscribe();
139
+ this._subscription = null;
140
+ this._state = 'stopped';
141
+ this._notifyStatus();
142
+ }
143
+ /**
144
+ * DataSubscriptionProvider.dispose 구현
145
+ */
146
+ dispose() {
147
+ this.stop();
148
+ this._components.clear();
149
+ }
150
+ _distributeSnapshot(snapshotData) {
151
+ if (!snapshotData || typeof snapshotData !== 'object')
152
+ return;
153
+ // snapshotData: { tag1: value1, tag2: value2, ... }
154
+ for (const [tag, value] of Object.entries(snapshotData)) {
155
+ const components = this._components.get(tag);
156
+ if (components) {
157
+ for (const component of components) {
158
+ component.data = value;
159
+ }
160
+ }
161
+ }
162
+ }
163
+ async _sendCommand(command, params = {}) {
164
+ try {
165
+ const { client } = await import('@operato/graphql');
166
+ await client.mutate({
167
+ mutation: gql `
168
+ mutation PlaybackControl($command: String!, $params: String) {
169
+ playbackControl(command: $command, params: $params)
170
+ }
171
+ `,
172
+ variables: {
173
+ command,
174
+ params: JSON.stringify(params)
175
+ }
176
+ });
177
+ }
178
+ catch (e) {
179
+ console.error('[PlaybackProvider] command failed:', command, e);
180
+ }
181
+ }
182
+ _notifyStatus() {
183
+ var _a;
184
+ (_a = this._onStatusChange) === null || _a === void 0 ? void 0 : _a.call(this, {
185
+ state: this._state,
186
+ currentTime: this._currentTime,
187
+ speed: this._speed
188
+ });
189
+ }
190
+ }
191
+ //# sourceMappingURL=playback-subscription.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"playback-subscription.js","sourceRoot":"","sources":["../../../src/graphql/playback-subscription.ts"],"names":[],"mappings":"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","sourcesContent":["import { Component, DataSubscriptionProvider } from '@hatiolab/things-scene'\n\nimport gql from 'graphql-tag'\nimport { subscribe } from '@operato/graphql'\n\nexport type PlaybackState = 'idle' | 'playing' | 'paused' | 'stopped'\n\nexport interface PlaybackStatus {\n state: PlaybackState\n currentTime: string\n speed: number\n}\n\nexport interface PlaybackConfig {\n /** 플레이백 가능한 시간 범위 */\n timeRange?: { from: Date; to: Date }\n}\n\n/**\n * PlaybackProvider\n *\n * 백엔드 플레이백 서비스와 point-to-point 구독으로 통신하는 DataSubscriptionProvider.\n * 백엔드가 요청된 시점/배속으로 스냅샷을 실시간처럼 푸시하면,\n * 이를 받아서 등록된 컴포넌트에 전달한다.\n *\n * 실시간 DataSubscriptionProviderImpl과 동일한 인터페이스를 구현하므로\n * Scene 입장에서는 실시간/플레이백을 구분하지 않는다.\n */\nexport class PlaybackProvider implements DataSubscriptionProvider {\n private _components: Map<string, Set<Component>> = new Map()\n private _subscription: { unsubscribe: () => void } | null = null\n private _state: PlaybackState = 'idle'\n private _speed: number = 1\n private _currentTime: string = ''\n private _onStatusChange?: (status: PlaybackStatus) => void\n\n constructor(onStatusChange?: (status: PlaybackStatus) => void) {\n this._onStatusChange = onStatusChange\n }\n\n get state() {\n return this._state\n }\n\n get speed() {\n return this._speed\n }\n\n get currentTime() {\n return this._currentTime\n }\n\n /**\n * DataSubscriptionProvider.subscribe 구현\n * 컴포넌트를 tag별로 등록한다. 실제 데이터는 백엔드 구독을 통해 수신된다.\n */\n async subscribe(tag: string, component: Component) {\n if (!this._components.has(tag)) {\n this._components.set(tag, new Set())\n }\n this._components.get(tag)!.add(component)\n\n return {\n unsubscribe: () => {\n const components = this._components.get(tag)\n if (components) {\n components.delete(component)\n if (components.size === 0) {\n this._components.delete(tag)\n }\n }\n }\n }\n }\n\n /**\n * 플레이백 시작 — 백엔드에 point-to-point 구독을 개시한다.\n */\n async start(fromTime: Date, speed: number = 1) {\n // 기존 구독이 있으면 정리\n this._subscription?.unsubscribe()\n\n this._speed = speed\n this._state = 'playing'\n\n this._subscription = await subscribe(\n {\n query: gql`\n subscription Playback($fromTime: String!, $speed: Float!) {\n playback(fromTime: $fromTime, speed: $speed) {\n timestamp\n data\n }\n }\n `,\n variables: {\n fromTime: fromTime.toISOString(),\n speed\n }\n },\n {\n next: ({ data }: { data: any }) => {\n if (!data?.playback) return\n\n const { timestamp, data: snapshotData } = data.playback\n this._currentTime = timestamp\n\n // 스냅샷 데이터를 tag별로 분배\n this._distributeSnapshot(snapshotData)\n\n this._notifyStatus()\n },\n error: (err: any) => {\n console.error('[PlaybackProvider] subscription error:', err)\n this._state = 'stopped'\n this._notifyStatus()\n },\n complete: () => {\n this._state = 'stopped'\n this._notifyStatus()\n }\n }\n )\n\n this._notifyStatus()\n }\n\n /**\n * 일시정지\n */\n async pause() {\n if (this._state !== 'playing') return\n\n this._state = 'paused'\n\n // 백엔드에 pause 명령 전송 (mutation)\n await this._sendCommand('pause')\n this._notifyStatus()\n }\n\n /**\n * 재개\n */\n async resume() {\n if (this._state !== 'paused') return\n\n this._state = 'playing'\n\n await this._sendCommand('resume')\n this._notifyStatus()\n }\n\n /**\n * 특정 시점으로 이동\n */\n async seek(toTime: Date) {\n await this._sendCommand('seek', { toTime: toTime.toISOString() })\n this._currentTime = toTime.toISOString()\n this._notifyStatus()\n }\n\n /**\n * 배속 변경\n */\n async setSpeed(speed: number) {\n this._speed = speed\n await this._sendCommand('speed', { speed })\n this._notifyStatus()\n }\n\n /**\n * 플레이백 중지 및 구독 해제\n */\n stop() {\n this._subscription?.unsubscribe()\n this._subscription = null\n this._state = 'stopped'\n this._notifyStatus()\n }\n\n /**\n * DataSubscriptionProvider.dispose 구현\n */\n dispose() {\n this.stop()\n this._components.clear()\n }\n\n private _distributeSnapshot(snapshotData: any) {\n if (!snapshotData || typeof snapshotData !== 'object') return\n\n // snapshotData: { tag1: value1, tag2: value2, ... }\n for (const [tag, value] of Object.entries(snapshotData)) {\n const components = this._components.get(tag)\n if (components) {\n for (const component of components) {\n component.data = value\n }\n }\n }\n }\n\n private async _sendCommand(command: string, params: Record<string, any> = {}) {\n try {\n const { client } = await import('@operato/graphql')\n await client.mutate({\n mutation: gql`\n mutation PlaybackControl($command: String!, $params: String) {\n playbackControl(command: $command, params: $params)\n }\n `,\n variables: {\n command,\n params: JSON.stringify(params)\n }\n })\n } catch (e) {\n console.error('[PlaybackProvider] command failed:', command, e)\n }\n }\n\n private _notifyStatus() {\n this._onStatusChange?.({\n state: this._state,\n currentTime: this._currentTime,\n speed: this._speed\n })\n }\n}\n"]}
@@ -2,6 +2,7 @@ import '@material/web/icon/icon.js';
2
2
  import '@material/web/fab/fab.js';
3
3
  import { LitElement, PropertyValues } from 'lit';
4
4
  import { Component, ReferenceProvider } from '@hatiolab/things-scene';
5
+ import './ox-playback-controls.js';
5
6
  export declare class BoardViewer extends LitElement {
6
7
  static styles: import("lit").CSSResult[];
7
8
  baseUrl: string;
@@ -12,6 +13,17 @@ export declare class BoardViewer extends LitElement {
12
13
  history: boolean;
13
14
  hideFullscreen: boolean;
14
15
  hideNavigation: boolean;
16
+ playbackEnabled: boolean;
17
+ playbackTimeRange?: {
18
+ from: Date;
19
+ to: Date;
20
+ };
21
+ private _playbackState;
22
+ private _playbackActive;
23
+ private _playbackSpeed;
24
+ private _playbackCurrentTime;
25
+ private _playbackProvider;
26
+ private _savedRealProvider;
15
27
  _scene: any;
16
28
  _forward: {
17
29
  id: string;
@@ -29,6 +41,7 @@ export declare class BoardViewer extends LitElement {
29
41
  _prev: HTMLElement;
30
42
  _next: HTMLElement;
31
43
  _fullscreen: HTMLElement;
44
+ _fabGroup: HTMLElement;
32
45
  render(): import("lit-html").TemplateResult<1>;
33
46
  private resizeHandler;
34
47
  connectedCallback(): void;
@@ -61,6 +74,35 @@ export declare class BoardViewer extends LitElement {
61
74
  onExportData(filename: string, value: string | number | object, component: Component): Promise<void>;
62
75
  onImportData(_: string, value: string | number | object, component: Component): Promise<void>;
63
76
  onClickEvent(e: MouseEvent, hint: any): void;
77
+ /**
78
+ * 외부에서 플레이백을 활성화한다.
79
+ * playback-enabled 속성을 설정하면 컨트롤바가 나타나고,
80
+ * 이 메서드로 시간 범위 등 상세 설정을 할 수 있다.
81
+ */
82
+ enablePlayback(config?: {
83
+ timeRange?: {
84
+ from: Date;
85
+ to: Date;
86
+ };
87
+ }): void;
88
+ disablePlayback(): void;
89
+ private _onTogglePlaybackPanel;
90
+ private _onPlaybackStart;
91
+ private _onPlaybackPause;
92
+ private _onPlaybackResume;
93
+ private _onPlaybackStop;
94
+ private _onPlaybackSeek;
95
+ private _onPlaybackSpeed;
96
+ private _startPlayback;
97
+ private _stopPlayback;
98
+ /**
99
+ * 모든 tag 컴포넌트의 기존 구독을 해제한다.
100
+ */
101
+ private _unsubscribeAll;
102
+ /**
103
+ * 모든 tag 컴포넌트를 현재 provider로 재구독한다.
104
+ */
105
+ private _resubscribeAll;
64
106
  hidePopup(): void;
65
107
  getSceneData(): any;
66
108
  getSceneValues(): any;
@@ -2,13 +2,15 @@ import { __decorate } from "tslib";
2
2
  import '@material/web/icon/icon.js';
3
3
  import '@material/web/fab/fab.js';
4
4
  import { css, html, LitElement } from 'lit';
5
- import { customElement, property, query } from 'lit/decorators.js';
5
+ import { customElement, property, query, state } from 'lit/decorators.js';
6
6
  import * as XLSX from 'xlsx';
7
7
  import { create, SCENE_MODE } from '@hatiolab/things-scene';
8
8
  import { isIOS, togglefullscreen } from '@operato/utils';
9
9
  import { ScrollbarStyles } from '@operato/styles';
10
10
  import { BoardDataStorage } from './data-storage/data-storage.js';
11
11
  import { DataSubscriptionProviderImpl } from './graphql/data-subscription.js';
12
+ import { PlaybackProvider } from './graphql/playback-subscription.js';
13
+ import './ox-playback-controls.js';
12
14
  import { runScenario, startScenario } from './graphql/scenario.js';
13
15
  import { fetchPlayGroupByName } from './graphql/play-group.js';
14
16
  function objectToQueryString(obj) {
@@ -36,6 +38,13 @@ let BoardViewer = class BoardViewer extends LitElement {
36
38
  this.history = false;
37
39
  this.hideFullscreen = false;
38
40
  this.hideNavigation = false;
41
+ this.playbackEnabled = false;
42
+ this._playbackState = 'idle';
43
+ this._playbackActive = false;
44
+ this._playbackSpeed = 1;
45
+ this._playbackCurrentTime = '';
46
+ this._playbackProvider = null;
47
+ this._savedRealProvider = null;
39
48
  this._scene = null;
40
49
  this._forward = [];
41
50
  this._backward = [];
@@ -45,19 +54,6 @@ let BoardViewer = class BoardViewer extends LitElement {
45
54
  };
46
55
  }
47
56
  render() {
48
- var fullscreen = !isIOS() && !this.hideFullscreen
49
- ? html `
50
- <md-fab
51
- id="fullscreen"
52
- @click=${(e) => this.onTapFullscreen()}
53
- @mouseover=${(e) => this.transientShowButtons(true)}
54
- @mouseout=${(e) => this.transientShowButtons()}
55
- title="fullscreen"
56
- >
57
- <md-icon slot="icon">${document.fullscreenElement ? 'fullscreen_exit' : 'fullscreen'}</md-icon>
58
- </md-fab>
59
- `
60
- : html ``;
61
57
  var prev = !this.hideNavigation
62
58
  ? html `
63
59
  <md-icon
@@ -92,7 +88,49 @@ let BoardViewer = class BoardViewer extends LitElement {
92
88
  ></div>
93
89
 
94
90
  <slot></slot>
95
- ${next} ${fullscreen}
91
+ ${next}
92
+
93
+ <div
94
+ class="fab-group"
95
+ @mouseover=${(e) => this.transientShowButtons(true)}
96
+ @mouseout=${(e) => this.transientShowButtons()}
97
+ >
98
+ ${!isIOS() && !this.hideFullscreen
99
+ ? html `
100
+ <md-fab id="fullscreen" @click=${() => this.onTapFullscreen()} title="fullscreen">
101
+ <md-icon slot="icon">${document.fullscreenElement ? 'fullscreen_exit' : 'fullscreen'}</md-icon>
102
+ </md-fab>
103
+ `
104
+ : html ``}
105
+ ${this.playbackEnabled
106
+ ? html `
107
+ <md-fab
108
+ id="playback"
109
+ @click=${() => this._onTogglePlaybackPanel()}
110
+ title="playback"
111
+ >
112
+ <md-icon slot="icon">${this._playbackActive ? 'stop' : 'history'}</md-icon>
113
+ </md-fab>
114
+ `
115
+ : html ``}
116
+ </div>
117
+
118
+ ${this._playbackActive
119
+ ? html `
120
+ <ox-playback-controls
121
+ .playbackState=${this._playbackState}
122
+ .speed=${this._playbackSpeed}
123
+ .currentTime=${this._playbackCurrentTime}
124
+ .timeRange=${this.playbackTimeRange || { from: new Date(Date.now() - 3600000), to: new Date() }}
125
+ @playback-start=${this._onPlaybackStart}
126
+ @playback-pause=${this._onPlaybackPause}
127
+ @playback-resume=${this._onPlaybackResume}
128
+ @playback-stop=${this._onPlaybackStop}
129
+ @playback-seek=${this._onPlaybackSeek}
130
+ @playback-speed=${this._onPlaybackSpeed}
131
+ ></ox-playback-controls>
132
+ `
133
+ : html ``}
96
134
  `;
97
135
  }
98
136
  connectedCallback() {
@@ -156,6 +194,13 @@ let BoardViewer = class BoardViewer extends LitElement {
156
194
  this.setupScene({ id: this.board.id, scene: this._scene });
157
195
  }
158
196
  closeScene() {
197
+ // 플레이백 중이면 정리
198
+ if (this._playbackProvider) {
199
+ this._playbackProvider.dispose();
200
+ this._playbackProvider = null;
201
+ this._savedRealProvider = null;
202
+ this._playbackState = 'idle';
203
+ }
159
204
  if (this._scene) {
160
205
  this.unbindSceneEvents(this._scene);
161
206
  this._scene.target = null;
@@ -285,7 +330,8 @@ let BoardViewer = class BoardViewer extends LitElement {
285
330
  transientShowButtons(stop) {
286
331
  var buttons = [];
287
332
  !this.hideNavigation && buttons.push(this._next, this._prev);
288
- !this.hideFullscreen && buttons.push(this._fullscreen);
333
+ if (this._fabGroup)
334
+ buttons.push(this._fabGroup);
289
335
  if (buttons.length == 0) {
290
336
  return;
291
337
  }
@@ -308,7 +354,7 @@ let BoardViewer = class BoardViewer extends LitElement {
308
354
  }
309
355
  this._forward.length <= 0 ? this._next.setAttribute('hidden', '') : this._next.removeAttribute('hidden');
310
356
  this._backward.length <= 0 ? this._prev.setAttribute('hidden', '') : this._prev.removeAttribute('hidden');
311
- this._fullscreen && this._fullscreen.removeAttribute('hidden');
357
+ this._fabGroup && this._fabGroup.removeAttribute('hidden');
312
358
  this._fade_animations.forEach(animation => {
313
359
  animation.cancel();
314
360
  if (stop)
@@ -452,6 +498,122 @@ let BoardViewer = class BoardViewer extends LitElement {
452
498
  // clickComponent 이벤트만 발생시킨다.
453
499
  window.dispatchEvent(new CustomEvent('clickComponent', { detail: component }));
454
500
  }
501
+ /* playback */
502
+ /**
503
+ * 외부에서 플레이백을 활성화한다.
504
+ * playback-enabled 속성을 설정하면 컨트롤바가 나타나고,
505
+ * 이 메서드로 시간 범위 등 상세 설정을 할 수 있다.
506
+ */
507
+ enablePlayback(config) {
508
+ this.playbackEnabled = true;
509
+ if (config === null || config === void 0 ? void 0 : config.timeRange) {
510
+ this.playbackTimeRange = config.timeRange;
511
+ }
512
+ }
513
+ disablePlayback() {
514
+ this._stopPlayback();
515
+ this._playbackActive = false;
516
+ this.playbackEnabled = false;
517
+ }
518
+ _onTogglePlaybackPanel() {
519
+ if (this._playbackActive) {
520
+ // 패널 닫기 — 재생 중이면 중지
521
+ this._stopPlayback();
522
+ this._playbackActive = false;
523
+ }
524
+ else {
525
+ this._playbackActive = true;
526
+ }
527
+ }
528
+ async _onPlaybackStart(e) {
529
+ const { fromTime, speed } = e.detail;
530
+ await this._startPlayback(fromTime, speed);
531
+ }
532
+ async _onPlaybackPause() {
533
+ var _a;
534
+ await ((_a = this._playbackProvider) === null || _a === void 0 ? void 0 : _a.pause());
535
+ }
536
+ async _onPlaybackResume() {
537
+ var _a;
538
+ await ((_a = this._playbackProvider) === null || _a === void 0 ? void 0 : _a.resume());
539
+ }
540
+ _onPlaybackStop() {
541
+ this._stopPlayback();
542
+ this._playbackActive = false;
543
+ }
544
+ async _onPlaybackSeek(e) {
545
+ var _a;
546
+ await ((_a = this._playbackProvider) === null || _a === void 0 ? void 0 : _a.seek(e.detail.toTime));
547
+ }
548
+ async _onPlaybackSpeed(e) {
549
+ var _a;
550
+ await ((_a = this._playbackProvider) === null || _a === void 0 ? void 0 : _a.setSpeed(e.detail.speed));
551
+ }
552
+ async _startPlayback(fromTime, speed) {
553
+ if (!this._scene)
554
+ return;
555
+ const rootContainer = this._scene.rootContainer;
556
+ // 실시간 provider 보관 및 구독 해제
557
+ if (!this._savedRealProvider) {
558
+ this._savedRealProvider = rootContainer.app.dataSubscriptionProvider;
559
+ await this._unsubscribeAll(rootContainer);
560
+ }
561
+ // PlaybackProvider 생성
562
+ this._playbackProvider = new PlaybackProvider((status) => {
563
+ this._playbackState = status.state;
564
+ this._playbackSpeed = status.speed;
565
+ this._playbackCurrentTime = status.currentTime;
566
+ });
567
+ // provider 교체 및 재구독
568
+ rootContainer.app.dataSubscriptionProvider = this._playbackProvider;
569
+ await this._resubscribeAll(rootContainer);
570
+ // 플레이백 시작
571
+ await this._playbackProvider.start(fromTime, speed);
572
+ }
573
+ async _stopPlayback() {
574
+ if (!this._scene || !this._playbackProvider)
575
+ return;
576
+ const rootContainer = this._scene.rootContainer;
577
+ // 플레이백 구독 해제
578
+ this._playbackProvider.dispose();
579
+ await this._unsubscribeAll(rootContainer);
580
+ // 실시간 provider 복귀
581
+ if (this._savedRealProvider) {
582
+ rootContainer.app.dataSubscriptionProvider = this._savedRealProvider;
583
+ await this._resubscribeAll(rootContainer);
584
+ this._savedRealProvider = null;
585
+ }
586
+ this._playbackProvider = null;
587
+ this._playbackState = 'idle';
588
+ this._playbackCurrentTime = '';
589
+ this._playbackSpeed = 1;
590
+ }
591
+ /**
592
+ * 모든 tag 컴포넌트의 기존 구독을 해제한다.
593
+ */
594
+ async _unsubscribeAll(rootContainer) {
595
+ const promises = [];
596
+ rootContainer.model_layer.traverse((component) => {
597
+ var _a;
598
+ if ((_a = component.model) === null || _a === void 0 ? void 0 : _a.tag) {
599
+ promises.push(rootContainer.unsubscribe(component.model.tag, component));
600
+ }
601
+ });
602
+ await Promise.all(promises);
603
+ }
604
+ /**
605
+ * 모든 tag 컴포넌트를 현재 provider로 재구독한다.
606
+ */
607
+ async _resubscribeAll(rootContainer) {
608
+ const promises = [];
609
+ rootContainer.model_layer.traverse((component) => {
610
+ var _a;
611
+ if ((_a = component.model) === null || _a === void 0 ? void 0 : _a.tag) {
612
+ promises.push(rootContainer.subscribe(component.model.tag, component));
613
+ }
614
+ });
615
+ await Promise.all(promises);
616
+ }
455
617
  hidePopup() {
456
618
  if (this.popup) {
457
619
  this.removeChild(this.popup);
@@ -584,11 +746,19 @@ BoardViewer.styles = [
584
746
  z-index: 1000;
585
747
  }
586
748
 
587
- #fullscreen {
749
+ .fab-group {
588
750
  position: absolute;
589
751
  bottom: 15px;
590
752
  right: 16px;
591
753
  z-index: 1000;
754
+ display: flex;
755
+ flex-direction: column-reverse;
756
+ gap: 8px;
757
+ }
758
+
759
+ .fab-group md-fab {
760
+ --md-fab-container-width: 48px;
761
+ --md-fab-container-height: 48px;
592
762
  }
593
763
 
594
764
  [hidden] {
@@ -627,6 +797,24 @@ __decorate([
627
797
  __decorate([
628
798
  property({ type: Boolean, reflect: true, attribute: 'hide-navigation' })
629
799
  ], BoardViewer.prototype, "hideNavigation", void 0);
800
+ __decorate([
801
+ property({ type: Boolean, reflect: true, attribute: 'playback-enabled' })
802
+ ], BoardViewer.prototype, "playbackEnabled", void 0);
803
+ __decorate([
804
+ property({ type: Object, attribute: 'playback-time-range' })
805
+ ], BoardViewer.prototype, "playbackTimeRange", void 0);
806
+ __decorate([
807
+ state()
808
+ ], BoardViewer.prototype, "_playbackState", void 0);
809
+ __decorate([
810
+ state()
811
+ ], BoardViewer.prototype, "_playbackActive", void 0);
812
+ __decorate([
813
+ state()
814
+ ], BoardViewer.prototype, "_playbackSpeed", void 0);
815
+ __decorate([
816
+ state()
817
+ ], BoardViewer.prototype, "_playbackCurrentTime", void 0);
630
818
  __decorate([
631
819
  query('#target')
632
820
  ], BoardViewer.prototype, "_target", void 0);
@@ -639,6 +827,9 @@ __decorate([
639
827
  __decorate([
640
828
  query('#fullscreen')
641
829
  ], BoardViewer.prototype, "_fullscreen", void 0);
830
+ __decorate([
831
+ query('.fab-group')
832
+ ], BoardViewer.prototype, "_fabGroup", void 0);
642
833
  BoardViewer = __decorate([
643
834
  customElement('ox-board-viewer')
644
835
  ], BoardViewer);