@fmsim/board 2.0.0-beta.62 → 2.0.0-beta.64
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/custom-elements.json +1742 -1647
- package/dist/src/component/restful-input-attachment-selector.d.ts +1 -1
- package/dist/src/modeller/property-sidebar/abstract-property.js +10 -2
- package/dist/src/modeller/property-sidebar/abstract-property.js.map +1 -1
- package/dist/src/ox-board-viewer.js +8 -3
- package/dist/src/ox-board-viewer.js.map +1 -1
- package/dist/src/ox-playback-controls.d.ts +87 -12
- package/dist/src/ox-playback-controls.js +427 -96
- package/dist/src/ox-playback-controls.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
|
@@ -4,14 +4,41 @@ import '@material/web/icon/icon.js';
|
|
|
4
4
|
import '@material/web/iconbutton/icon-button.js';
|
|
5
5
|
import { css, html, LitElement } from 'lit';
|
|
6
6
|
import { customElement, property, state } from 'lit/decorators.js';
|
|
7
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Window-length presets shown in the range dropdown. Values are durations
|
|
9
|
+
* in milliseconds; `'Range'` is the sentinel for "user picks both bounds
|
|
10
|
+
* directly". The fixed presets cap at 2H — same as the explicit Range
|
|
11
|
+
* mode max gap — so any preset switch produces a window already inside
|
|
12
|
+
* the allowed envelope.
|
|
13
|
+
*/
|
|
14
|
+
const RANGE_DURATIONS = {
|
|
15
|
+
'10m': 10 * 60 * 1000,
|
|
16
|
+
'30m': 30 * 60 * 1000,
|
|
17
|
+
'1H': 60 * 60 * 1000,
|
|
18
|
+
'2H': 2 * 60 * 60 * 1000
|
|
19
|
+
};
|
|
20
|
+
const ONE_HOUR = RANGE_DURATIONS['1H'];
|
|
21
|
+
const MAX_GAP_MS = RANGE_DURATIONS['2H']; // 2H upper bound on (to − from)
|
|
22
|
+
const MIN_GAP_MS = RANGE_DURATIONS['10m']; // 10m lower bound (prevents reversal / zero-window)
|
|
8
23
|
/**
|
|
9
24
|
* 플레이백 컨트롤 오버레이 바 (2행 구조)
|
|
10
25
|
*
|
|
11
|
-
* 1
|
|
12
|
-
*
|
|
26
|
+
* 1행 (config): Range 선택 (10m / 30m / 1H / 2H / Range) + ←/→ shift
|
|
27
|
+
* + FROM/TO datetime 입력 (date 캘린더 + time 키인) + Apply 버튼
|
|
28
|
+
* + 배속 (×1 ×2 ×4)
|
|
29
|
+
*
|
|
30
|
+
* - Fixed presets: TO 가 anchor (←/→ 로 shift), FROM = TO − range.
|
|
31
|
+
* 입력 disabled. 초기 TO = 현재시간.
|
|
32
|
+
* - Range mode: 양쪽 직접 입력. 2H cap, 10m min, future clamp,
|
|
33
|
+
* 마지막 편집한 쪽이 anchor.
|
|
34
|
+
* - 상단 변경은 PENDING tier 만 갱신 — Apply 누르기 전에는 하단
|
|
35
|
+
* 슬라이더/엔진에 영향 없음.
|
|
13
36
|
*
|
|
14
|
-
*
|
|
37
|
+
* 2행 (control): play / pause / 시크바 (적용된 range 만큼 span) /
|
|
38
|
+
* 현재 재생 시각 / 닫기.
|
|
39
|
+
*
|
|
40
|
+
* @fires playback-start - 플레이백 시작 요청 { fromTime, toTime, speed }
|
|
41
|
+
* toTime 은 window END — 엔진이 chunked fetch 의 상한으로 사용.
|
|
15
42
|
* @fires playback-pause - 일시정지 요청
|
|
16
43
|
* @fires playback-resume - 재개 요청
|
|
17
44
|
* @fires playback-stop - 중지 요청
|
|
@@ -32,7 +59,37 @@ let PlaybackControls = PlaybackControls_1 = class PlaybackControls extends LitEl
|
|
|
32
59
|
from: new Date(Date.now() - ONE_HOUR),
|
|
33
60
|
to: new Date()
|
|
34
61
|
};
|
|
35
|
-
|
|
62
|
+
/**
|
|
63
|
+
* Two-tier state model:
|
|
64
|
+
*
|
|
65
|
+
* PENDING (`_pendingRange`, `_pendingFrom`, `_pendingTo`)
|
|
66
|
+
* The upper bar's draft. User edits these via the range dropdown,
|
|
67
|
+
* ←/→ shift, or direct date/time inputs (Range mode only). The
|
|
68
|
+
* lower seekbar is NOT driven by these — they only become live
|
|
69
|
+
* when the user clicks "적용".
|
|
70
|
+
*
|
|
71
|
+
* APPLIED (`_appliedFrom`, `_appliedTo`)
|
|
72
|
+
* The window the seekbar visualizes and the engine fetches data
|
|
73
|
+
* for. Updated only by `_onApply()`, which mirrors pending values
|
|
74
|
+
* here and dispatches a `playback-start` to refresh the engine
|
|
75
|
+
* with the new window (when playing/paused).
|
|
76
|
+
*
|
|
77
|
+
* Splitting these prevents mid-edit churn: typing into the time input
|
|
78
|
+
* doesn't refetch on every keystroke, and the slider doesn't jiggle
|
|
79
|
+
* while the user picks a date.
|
|
80
|
+
*/
|
|
81
|
+
this._pendingRange = '1H';
|
|
82
|
+
this._pendingFrom = new Date(Date.now() - ONE_HOUR);
|
|
83
|
+
this._pendingTo = new Date();
|
|
84
|
+
/**
|
|
85
|
+
* Which side did the user touch most recently in Range mode? Anchors
|
|
86
|
+
* the auto-adjust when the resulting gap violates min/max — the
|
|
87
|
+
* unedited side is what gets pushed to a legal value, so the user's
|
|
88
|
+
* latest intent is preserved.
|
|
89
|
+
*/
|
|
90
|
+
this._lastEdited = 'to';
|
|
91
|
+
this._appliedFrom = new Date(Date.now() - ONE_HOUR);
|
|
92
|
+
this._appliedTo = new Date();
|
|
36
93
|
this._seeking = false;
|
|
37
94
|
this._seekPreviewTime = null;
|
|
38
95
|
this._speeds = [1, 2, 4];
|
|
@@ -41,17 +98,18 @@ let PlaybackControls = PlaybackControls_1 = class PlaybackControls extends LitEl
|
|
|
41
98
|
/** 가장 최근에 실제 emit 한 preview 시각 (dedupe 용) */
|
|
42
99
|
this._lastEmittedPreviewTime = null;
|
|
43
100
|
}
|
|
44
|
-
get _endTime() {
|
|
45
|
-
return new Date(this._startTime.getTime() + ONE_HOUR);
|
|
46
|
-
}
|
|
47
|
-
static _floorToHour(date) {
|
|
48
|
-
const d = new Date(date);
|
|
49
|
-
d.setMinutes(0, 0, 0);
|
|
50
|
-
return d;
|
|
51
|
-
}
|
|
52
101
|
connectedCallback() {
|
|
53
102
|
super.connectedCallback();
|
|
54
|
-
|
|
103
|
+
// Initialize both pending and applied from the incoming timeRange.
|
|
104
|
+
// Default range = 1H (matches the previous fixed-window behavior),
|
|
105
|
+
// so the seekbar covers a 1H window from the start.
|
|
106
|
+
const initFrom = new Date(this.timeRange.from);
|
|
107
|
+
const initTo = new Date(this.timeRange.to);
|
|
108
|
+
this._appliedFrom = initFrom;
|
|
109
|
+
this._appliedTo = initTo;
|
|
110
|
+
this._pendingFrom = new Date(initFrom);
|
|
111
|
+
this._pendingTo = new Date(initTo);
|
|
112
|
+
this._pendingRange = this._inferRangeKey(initTo.getTime() - initFrom.getTime());
|
|
55
113
|
this._seekValue = 0;
|
|
56
114
|
this._currentPlayTime = null;
|
|
57
115
|
}
|
|
@@ -61,7 +119,12 @@ let PlaybackControls = PlaybackControls_1 = class PlaybackControls extends LitEl
|
|
|
61
119
|
}
|
|
62
120
|
updated(changes) {
|
|
63
121
|
if (changes.has('timeRange') && this.timeRange) {
|
|
64
|
-
|
|
122
|
+
// Resync both tiers from the externally-supplied window.
|
|
123
|
+
this._appliedFrom = new Date(this.timeRange.from);
|
|
124
|
+
this._appliedTo = new Date(this.timeRange.to);
|
|
125
|
+
this._pendingFrom = new Date(this._appliedFrom);
|
|
126
|
+
this._pendingTo = new Date(this._appliedTo);
|
|
127
|
+
this._pendingRange = this._inferRangeKey(this._appliedTo.getTime() - this._appliedFrom.getTime());
|
|
65
128
|
}
|
|
66
129
|
// 엔진 프레임(currentTime 프로프 변경)이 유일한 시간 소스.
|
|
67
130
|
// drag 중에는 _seeking 가 true 이므로 엔진 업데이트 무시 (스크러빙 중 UI 튐 방지).
|
|
@@ -69,51 +132,84 @@ let PlaybackControls = PlaybackControls_1 = class PlaybackControls extends LitEl
|
|
|
69
132
|
this._syncFromEngine();
|
|
70
133
|
}
|
|
71
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* Map a millisecond gap to the matching preset key, or `'Range'` if
|
|
137
|
+
* the gap doesn't match any preset (custom window already in effect).
|
|
138
|
+
*/
|
|
139
|
+
_inferRangeKey(gapMs) {
|
|
140
|
+
for (const k of ['10m', '30m', '1H', '2H']) {
|
|
141
|
+
if (RANGE_DURATIONS[k] === gapMs)
|
|
142
|
+
return k;
|
|
143
|
+
}
|
|
144
|
+
return 'Range';
|
|
145
|
+
}
|
|
72
146
|
render() {
|
|
73
147
|
const isPlaying = this.playbackState === 'playing';
|
|
74
148
|
const isPaused = this.playbackState === 'paused';
|
|
75
149
|
const isActive = isPlaying || isPaused;
|
|
150
|
+
const isRangeMode = this._pendingRange === 'Range';
|
|
151
|
+
const isDirty = this._pendingFrom.getTime() !== this._appliedFrom.getTime() ||
|
|
152
|
+
this._pendingTo.getTime() !== this._appliedTo.getTime();
|
|
76
153
|
return html `
|
|
77
154
|
<div class="control-bar">
|
|
78
|
-
<!-- 1
|
|
155
|
+
<!-- 1행 (config): Range 선택 + ←/→ shift + datetime 입력 + Apply -->
|
|
79
156
|
<div class="row-time">
|
|
80
|
-
<
|
|
157
|
+
<select class="range-select" .value=${this._pendingRange} @change=${this._onRangeChange}>
|
|
158
|
+
${['10m', '30m', '1H', '2H', 'Range'].map(k => html `<option value=${k} ?selected=${this._pendingRange === k}>${k}</option>`)}
|
|
159
|
+
</select>
|
|
160
|
+
|
|
161
|
+
<button
|
|
162
|
+
class="shift-btn"
|
|
163
|
+
@click=${this._shiftPending(-1)}
|
|
164
|
+
?disabled=${isRangeMode}
|
|
165
|
+
title="이전 ${this._pendingRange}"
|
|
166
|
+
>
|
|
81
167
|
<md-icon>chevron_left</md-icon>
|
|
82
|
-
</
|
|
168
|
+
</button>
|
|
169
|
+
<button
|
|
170
|
+
class="shift-btn"
|
|
171
|
+
@click=${this._shiftPending(1)}
|
|
172
|
+
?disabled=${isRangeMode}
|
|
173
|
+
title="다음 ${this._pendingRange}"
|
|
174
|
+
>
|
|
175
|
+
<md-icon>chevron_right</md-icon>
|
|
176
|
+
</button>
|
|
83
177
|
|
|
178
|
+
<!-- FROM datetime — single compact widget (date via calendar
|
|
179
|
+
popup, time via key-in, browser-native). In Fixed mode
|
|
180
|
+
disabled (derived from to − range); in Range mode editable. -->
|
|
84
181
|
<input
|
|
85
|
-
type="
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
182
|
+
type="datetime-local"
|
|
183
|
+
step="1"
|
|
184
|
+
lang="ko-KR"
|
|
185
|
+
class="datetime-input"
|
|
186
|
+
.value=${this._toDatetimeLocalString(this._pendingFrom)}
|
|
187
|
+
?disabled=${!isRangeMode}
|
|
188
|
+
@change=${(e) => this._onPendingDatetimeChange('from', e)}
|
|
89
189
|
/>
|
|
90
190
|
|
|
91
|
-
<
|
|
92
|
-
${Array.from({ length: 24 }, (_, i) => html `
|
|
93
|
-
<option value=${i} ?selected=${this._startTime.getHours() === i}>
|
|
94
|
-
${String(i).padStart(2, '0')}:00
|
|
95
|
-
</option>
|
|
96
|
-
`)}
|
|
97
|
-
</select>
|
|
191
|
+
<span class="time-label">~</span>
|
|
98
192
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
193
|
+
<!-- TO datetime. In Fixed mode disabled (←/→ moves it).
|
|
194
|
+
In Range mode editable. -->
|
|
195
|
+
<input
|
|
196
|
+
type="datetime-local"
|
|
197
|
+
step="1"
|
|
198
|
+
lang="ko-KR"
|
|
199
|
+
class="datetime-input"
|
|
200
|
+
.value=${this._toDatetimeLocalString(this._pendingTo)}
|
|
201
|
+
?disabled=${!isRangeMode}
|
|
202
|
+
@change=${(e) => this._onPendingDatetimeChange('to', e)}
|
|
203
|
+
/>
|
|
102
204
|
|
|
103
|
-
<
|
|
205
|
+
<button class="apply-btn ${isDirty ? 'dirty' : ''}" ?disabled=${!isDirty} @click=${this._onApply}>
|
|
206
|
+
적용
|
|
207
|
+
</button>
|
|
104
208
|
|
|
105
209
|
<span class="spacer"></span>
|
|
106
|
-
|
|
107
|
-
<div class="speed-group">
|
|
108
|
-
${this._speeds.map(s => html `
|
|
109
|
-
<button class="speed-btn ${this.speed === s ? 'active' : ''}" @click=${() => this._onSpeedChange(s)}>
|
|
110
|
-
×${s}
|
|
111
|
-
</button>
|
|
112
|
-
`)}
|
|
113
|
-
</div>
|
|
114
210
|
</div>
|
|
115
211
|
|
|
116
|
-
<!-- 2행: 재생 제어 + 시크바 + 닫기 -->
|
|
212
|
+
<!-- 2행: 재생 제어 + 시크바 + 배속 + 닫기 -->
|
|
117
213
|
<div class="row-seek">
|
|
118
214
|
<md-icon-button class="primary" @click=${isActive ? this._onTogglePlayPause : this._onStart}>
|
|
119
215
|
<md-icon>${isPlaying ? 'pause' : 'play_arrow'}</md-icon>
|
|
@@ -134,8 +230,8 @@ let PlaybackControls = PlaybackControls_1 = class PlaybackControls extends LitEl
|
|
|
134
230
|
/>
|
|
135
231
|
</div>
|
|
136
232
|
<div class="seek-labels">
|
|
137
|
-
<span>${this._formatTime(this.
|
|
138
|
-
<span>${this._formatTime(this.
|
|
233
|
+
<span>${this._formatTime(this._appliedFrom)}</span>
|
|
234
|
+
<span>${this._formatTime(this._appliedTo)}</span>
|
|
139
235
|
</div>
|
|
140
236
|
</div>
|
|
141
237
|
|
|
@@ -145,6 +241,14 @@ let PlaybackControls = PlaybackControls_1 = class PlaybackControls extends LitEl
|
|
|
145
241
|
: this._currentPlayTime ? this._formatTime(this._currentPlayTime) : '--:--:--'}
|
|
146
242
|
</span>
|
|
147
243
|
|
|
244
|
+
<div class="speed-group">
|
|
245
|
+
${this._speeds.map(s => html `
|
|
246
|
+
<button class="speed-btn ${this.speed === s ? 'active' : ''}" @click=${() => this._onSpeedChange(s)}>
|
|
247
|
+
×${s}
|
|
248
|
+
</button>
|
|
249
|
+
`)}
|
|
250
|
+
</div>
|
|
251
|
+
|
|
148
252
|
<md-icon-button @click=${this._onStop}>
|
|
149
253
|
<md-icon>close</md-icon>
|
|
150
254
|
</md-icon-button>
|
|
@@ -159,6 +263,19 @@ let PlaybackControls = PlaybackControls_1 = class PlaybackControls extends LitEl
|
|
|
159
263
|
const pad = (n) => String(n).padStart(2, '0');
|
|
160
264
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
161
265
|
}
|
|
266
|
+
/** "HH:MM:SS" — what `<input type="time" step="1">` reads/writes. */
|
|
267
|
+
_toTimeString(date) {
|
|
268
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
269
|
+
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* "YYYY-MM-DDTHH:MM:SS" — what `<input type="datetime-local" step="1">`
|
|
273
|
+
* reads/writes. Local time (no Z suffix), matches the input element's
|
|
274
|
+
* own convention.
|
|
275
|
+
*/
|
|
276
|
+
_toDatetimeLocalString(date) {
|
|
277
|
+
return `${this._toDateString(date)}T${this._toTimeString(date)}`;
|
|
278
|
+
}
|
|
162
279
|
/**
|
|
163
280
|
* 엔진 프레임(currentTime 프로프) 을 유일한 시간 소스로 사용하여
|
|
164
281
|
* _currentPlayTime 및 _seekValue 동기화.
|
|
@@ -170,67 +287,144 @@ let PlaybackControls = PlaybackControls_1 = class PlaybackControls extends LitEl
|
|
|
170
287
|
if (!Number.isFinite(currentMs))
|
|
171
288
|
return;
|
|
172
289
|
this._currentPlayTime = new Date(currentMs);
|
|
173
|
-
const from = this.
|
|
174
|
-
const to = this.
|
|
290
|
+
const from = this._appliedFrom.getTime();
|
|
291
|
+
const to = this._appliedTo.getTime();
|
|
175
292
|
const range = to - from;
|
|
176
293
|
if (range <= 0)
|
|
177
294
|
return;
|
|
178
295
|
this._seekValue = Math.max(0, Math.min(1000, Math.round(((currentMs - from) / range) * 1000)));
|
|
179
296
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
297
|
+
// ── Pending-tier handlers (upper config bar) ──────────────────────
|
|
298
|
+
_onRangeChange(e) {
|
|
299
|
+
const newKey = e.target.value;
|
|
300
|
+
this._pendingRange = newKey;
|
|
301
|
+
if (newKey === 'Range') {
|
|
302
|
+
// Switching to Range: keep current pending bounds, just unlock the
|
|
303
|
+
// inputs. Auto-adjust will kick in when the user edits one side.
|
|
183
304
|
return;
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
}
|
|
195
|
-
_shiftTime(hours) {
|
|
305
|
+
}
|
|
306
|
+
// Fixed preset: end stays where it is (clamped to now), start = end − range.
|
|
307
|
+
const dur = RANGE_DURATIONS[newKey];
|
|
308
|
+
const now = Date.now();
|
|
309
|
+
const newTo = Math.min(this._pendingTo.getTime(), now);
|
|
310
|
+
this._pendingTo = new Date(newTo);
|
|
311
|
+
this._pendingFrom = new Date(newTo - dur);
|
|
312
|
+
this._lastEdited = 'to';
|
|
313
|
+
}
|
|
314
|
+
_shiftPending(direction) {
|
|
196
315
|
return () => {
|
|
197
|
-
|
|
198
|
-
this.
|
|
316
|
+
// Only meaningful in Fixed mode — Range mode disables the buttons.
|
|
317
|
+
if (this._pendingRange === 'Range')
|
|
318
|
+
return;
|
|
319
|
+
const dur = RANGE_DURATIONS[this._pendingRange];
|
|
320
|
+
const now = Date.now();
|
|
321
|
+
let newToMs = this._pendingTo.getTime() + direction * dur;
|
|
322
|
+
newToMs = Math.min(newToMs, now); // future clamp on forward shift
|
|
323
|
+
this._pendingTo = new Date(newToMs);
|
|
324
|
+
this._pendingFrom = new Date(newToMs - dur);
|
|
325
|
+
this._lastEdited = 'to';
|
|
199
326
|
};
|
|
200
327
|
}
|
|
328
|
+
_onPendingDatetimeChange(side, e) {
|
|
329
|
+
// value is "YYYY-MM-DDTHH:MM:SS" (datetime-local with step=1).
|
|
330
|
+
// Parsed as LOCAL time — `new Date(...)` on this format is local
|
|
331
|
+
// (intentional, user picks wall-clock time, not UTC).
|
|
332
|
+
const value = e.target.value;
|
|
333
|
+
if (!value)
|
|
334
|
+
return;
|
|
335
|
+
const next = new Date(value);
|
|
336
|
+
if (Number.isNaN(next.getTime()))
|
|
337
|
+
return;
|
|
338
|
+
this._writePendingSide(side, next);
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Commit a side's new value, then enforce the gap envelope.
|
|
342
|
+
*
|
|
343
|
+
* Rules (per spec):
|
|
344
|
+
* - `to` clamped to now (no future).
|
|
345
|
+
* - `to − from ≥ 10m` (no zero/negative window).
|
|
346
|
+
* - `to − from ≤ 2h`.
|
|
347
|
+
* - The side just edited (`side`) is the anchor; the OTHER side
|
|
348
|
+
* gets pushed to satisfy the envelope.
|
|
349
|
+
* - If anchoring `from` would push `to` past now, fall back to
|
|
350
|
+
* anchoring `to = now` and shrink `from`.
|
|
351
|
+
*/
|
|
352
|
+
_writePendingSide(side, next) {
|
|
353
|
+
this._lastEdited = side;
|
|
354
|
+
const now = Date.now();
|
|
355
|
+
if (side === 'to') {
|
|
356
|
+
const toMs = Math.min(next.getTime(), now);
|
|
357
|
+
this._pendingTo = new Date(toMs);
|
|
358
|
+
const fromMs = this._pendingFrom.getTime();
|
|
359
|
+
const gap = toMs - fromMs;
|
|
360
|
+
if (gap < MIN_GAP_MS)
|
|
361
|
+
this._pendingFrom = new Date(toMs - MIN_GAP_MS);
|
|
362
|
+
else if (gap > MAX_GAP_MS)
|
|
363
|
+
this._pendingFrom = new Date(toMs - MAX_GAP_MS);
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
const fromMs = next.getTime();
|
|
367
|
+
this._pendingFrom = new Date(fromMs);
|
|
368
|
+
const toMs = this._pendingTo.getTime();
|
|
369
|
+
const gap = toMs - fromMs;
|
|
370
|
+
if (gap < MIN_GAP_MS) {
|
|
371
|
+
let adj = fromMs + MIN_GAP_MS;
|
|
372
|
+
if (adj > now) {
|
|
373
|
+
// Pushing `to` would land in the future — re-anchor on `to=now`
|
|
374
|
+
// and shrink `from` instead.
|
|
375
|
+
adj = now;
|
|
376
|
+
this._pendingFrom = new Date(adj - MIN_GAP_MS);
|
|
377
|
+
}
|
|
378
|
+
this._pendingTo = new Date(adj);
|
|
379
|
+
}
|
|
380
|
+
else if (gap > MAX_GAP_MS) {
|
|
381
|
+
let adj = fromMs + MAX_GAP_MS;
|
|
382
|
+
if (adj > now) {
|
|
383
|
+
adj = now;
|
|
384
|
+
this._pendingFrom = new Date(adj - MAX_GAP_MS);
|
|
385
|
+
}
|
|
386
|
+
this._pendingTo = new Date(adj);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
201
390
|
/**
|
|
202
|
-
*
|
|
203
|
-
*
|
|
391
|
+
* "적용" — copy pending → applied, reset seek to window start, and
|
|
392
|
+
* (re)start the engine so it refetches data for the new window.
|
|
393
|
+
*
|
|
394
|
+
* Behavior by prior state:
|
|
395
|
+
* - playing: dispatch `playback-start` → engine restarts in new window.
|
|
396
|
+
* - paused : same — engine refetches; we leave the user in 'playing'
|
|
397
|
+
* state because the alternative (carry "paused" through a window
|
|
398
|
+
* change) ends up showing stale buffered data while the new fetch
|
|
399
|
+
* completes, which reads as a stuck UI.
|
|
400
|
+
* - idle : window updated silently; no engine activity until user
|
|
401
|
+
* presses play.
|
|
204
402
|
*/
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
this._startTime = newStart;
|
|
403
|
+
_onApply() {
|
|
404
|
+
this._appliedFrom = new Date(this._pendingFrom);
|
|
405
|
+
this._appliedTo = new Date(this._pendingTo);
|
|
209
406
|
this._seekValue = 0;
|
|
210
|
-
this._currentPlayTime = new Date(
|
|
211
|
-
|
|
212
|
-
|
|
407
|
+
this._currentPlayTime = new Date(this._appliedFrom);
|
|
408
|
+
this._seekPreviewTime = null;
|
|
409
|
+
if (this.playbackState === 'playing' || this.playbackState === 'paused') {
|
|
213
410
|
this._onStart();
|
|
214
411
|
}
|
|
215
|
-
else if (wasPaused) {
|
|
216
|
-
// pause 중: 엔진에 새 시간 전달 + 데이터 fetch, 하지만 자동 재생 안 함
|
|
217
|
-
this.dispatchEvent(new CustomEvent('playback-seek', {
|
|
218
|
-
detail: { toTime: new Date(newStart) },
|
|
219
|
-
bubbles: true,
|
|
220
|
-
composed: true
|
|
221
|
-
}));
|
|
222
|
-
}
|
|
223
412
|
}
|
|
224
413
|
_onStart() {
|
|
225
414
|
// 현재 seekbar 위치에서 재생 시작 (_seekValue 가 유일한 슬라이더 위치 소스)
|
|
226
415
|
const ratio = this._seekValue / 1000;
|
|
227
|
-
const from = this.
|
|
228
|
-
const to = this.
|
|
416
|
+
const from = this._appliedFrom.getTime();
|
|
417
|
+
const to = this._appliedTo.getTime();
|
|
229
418
|
const fromTime = new Date(from + (to - from) * ratio);
|
|
419
|
+
const toTime = new Date(to);
|
|
230
420
|
this._currentPlayTime = fromTime;
|
|
231
421
|
this.playbackState = 'playing';
|
|
422
|
+
// toTime is the window END (= _appliedTo), NOT a hardcoded
|
|
423
|
+
// fromTime + 1h. The engine uses it as the chunked-fetch upper
|
|
424
|
+
// bound — without it, variable ranges (10m / 30m / Range mode)
|
|
425
|
+
// would silently overshoot the user-selected window.
|
|
232
426
|
this.dispatchEvent(new CustomEvent('playback-start', {
|
|
233
|
-
detail: { fromTime, speed: this.speed },
|
|
427
|
+
detail: { fromTime, toTime, speed: this.speed },
|
|
234
428
|
bubbles: true,
|
|
235
429
|
composed: true
|
|
236
430
|
}));
|
|
@@ -259,8 +453,8 @@ let PlaybackControls = PlaybackControls_1 = class PlaybackControls extends LitEl
|
|
|
259
453
|
this._seekValue = Number(e.target.value);
|
|
260
454
|
// 드래그 중 preview 시간 계산 + UI 표시
|
|
261
455
|
const ratio = this._seekValue / 1000;
|
|
262
|
-
const from = this.
|
|
263
|
-
const to = this.
|
|
456
|
+
const from = this._appliedFrom.getTime();
|
|
457
|
+
const to = this._appliedTo.getTime();
|
|
264
458
|
const previewTime = new Date(from + (to - from) * ratio);
|
|
265
459
|
this._seekPreviewTime = previewTime;
|
|
266
460
|
this._latestPreviewTime = previewTime;
|
|
@@ -311,8 +505,8 @@ let PlaybackControls = PlaybackControls_1 = class PlaybackControls extends LitEl
|
|
|
311
505
|
this._seeking = false;
|
|
312
506
|
this._cancelPreviewThrottle();
|
|
313
507
|
const ratio = Number(e.target.value) / 1000;
|
|
314
|
-
const from = this.
|
|
315
|
-
const to = this.
|
|
508
|
+
const from = this._appliedFrom.getTime();
|
|
509
|
+
const to = this._appliedTo.getTime();
|
|
316
510
|
const targetTime = new Date(from + (to - from) * ratio);
|
|
317
511
|
this._seekPreviewTime = null;
|
|
318
512
|
this._currentPlayTime = targetTime;
|
|
@@ -334,8 +528,8 @@ let PlaybackControls = PlaybackControls_1 = class PlaybackControls extends LitEl
|
|
|
334
528
|
var _a;
|
|
335
529
|
if (!((_a = this.bufferedRanges) === null || _a === void 0 ? void 0 : _a.length))
|
|
336
530
|
return '';
|
|
337
|
-
const from = this.
|
|
338
|
-
const to = this.
|
|
531
|
+
const from = this._appliedFrom.getTime();
|
|
532
|
+
const to = this._appliedTo.getTime();
|
|
339
533
|
const range = to - from;
|
|
340
534
|
if (range <= 0)
|
|
341
535
|
return '';
|
|
@@ -404,10 +598,49 @@ PlaybackControls.styles = css `
|
|
|
404
598
|
border-radius: 50%;
|
|
405
599
|
}
|
|
406
600
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
601
|
+
/*
|
|
602
|
+
* shift 버튼 (←/→) — Material Web 의 md-icon-button 은 shadow DOM
|
|
603
|
+
* 내 ripple/focus-ring/touch-target 영역이 visible 32px 컨테이너
|
|
604
|
+
* 너머로 hit area 를 확장해서 인접 버튼끼리 호버 영역이 겹친다.
|
|
605
|
+
* (사용자 보고: > 의 hover 가 < 의 오른쪽 영역까지 침범) plain
|
|
606
|
+
* <button> 으로 교체해 hit area 를 정확히 32×32px 로 한정.
|
|
607
|
+
*/
|
|
608
|
+
.shift-btn {
|
|
609
|
+
width: 22px;
|
|
610
|
+
height: 28px;
|
|
611
|
+
padding: 0;
|
|
612
|
+
border: none;
|
|
613
|
+
background: transparent;
|
|
614
|
+
cursor: pointer;
|
|
615
|
+
display: inline-flex;
|
|
616
|
+
align-items: center;
|
|
617
|
+
justify-content: center;
|
|
618
|
+
color: #fff;
|
|
619
|
+
flex-shrink: 0;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/*
|
|
623
|
+
* shift 버튼 묶음 — 두 chevron 이 시각적으로 한 쌍으로 보이도록
|
|
624
|
+
* 사이 여백을 완전히 제거 (row-time 의 6px gap 전체를 negative
|
|
625
|
+
* margin 으로 상쇄). 양쪽 인접 요소와의 간격은 row gap 으로 유지.
|
|
626
|
+
*/
|
|
627
|
+
.shift-btn + .shift-btn {
|
|
628
|
+
margin-left: -6px;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
.shift-btn md-icon {
|
|
632
|
+
font-size: 16px;
|
|
633
|
+
width: 16px;
|
|
634
|
+
height: 16px;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
.shift-btn:hover:not(:disabled) {
|
|
638
|
+
color: #ffb766;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
.shift-btn:disabled {
|
|
642
|
+
opacity: 0.4;
|
|
643
|
+
cursor: not-allowed;
|
|
411
644
|
}
|
|
412
645
|
|
|
413
646
|
.date-input {
|
|
@@ -426,7 +659,8 @@ PlaybackControls.styles = css `
|
|
|
426
659
|
border-color: rgba(255, 255, 255, 0.6);
|
|
427
660
|
}
|
|
428
661
|
|
|
429
|
-
.hour-select
|
|
662
|
+
.hour-select,
|
|
663
|
+
.range-select {
|
|
430
664
|
background: rgba(255, 255, 255, 0.15);
|
|
431
665
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
|
432
666
|
border-radius: 6px;
|
|
@@ -438,10 +672,92 @@ PlaybackControls.styles = css `
|
|
|
438
672
|
cursor: pointer;
|
|
439
673
|
}
|
|
440
674
|
|
|
441
|
-
.hour-select:focus
|
|
675
|
+
.hour-select:focus,
|
|
676
|
+
.range-select:focus {
|
|
442
677
|
border-color: rgba(255, 255, 255, 0.6);
|
|
443
678
|
}
|
|
444
679
|
|
|
680
|
+
.range-select {
|
|
681
|
+
min-width: 64px;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
.datetime-input {
|
|
685
|
+
background: rgba(255, 255, 255, 0.15);
|
|
686
|
+
border: 1px solid rgba(255, 255, 255, 0.3);
|
|
687
|
+
border-radius: 6px;
|
|
688
|
+
color: #fff;
|
|
689
|
+
font-size: 12px;
|
|
690
|
+
padding: 4px 6px;
|
|
691
|
+
outline: none;
|
|
692
|
+
color-scheme: dark;
|
|
693
|
+
font-family: inherit;
|
|
694
|
+
/*
|
|
695
|
+
* 브라우저 기본 너비가 컨텐츠 (YYYY-MM-DD HH:MM:SS + 캘린더
|
|
696
|
+
* 아이콘) 보다 넓게 잡혀서 우측에 빈 공간이 생긴다. content
|
|
697
|
+
* 폭에 거의 fit 시키는 명시 너비. 12px 폰트 기준 19자 + 캘린더
|
|
698
|
+
* 아이콘 공간 ≈ 170px (실측 기준).
|
|
699
|
+
*/
|
|
700
|
+
width: 170px;
|
|
701
|
+
box-sizing: border-box;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/*
|
|
705
|
+
* inner sub-fields (YYYY/MM/DD/HH/MM/SS) 사이 spacing 도 일부
|
|
706
|
+
* Chromium 빌드에서 과하게 잡혀 있어 0으로 줄여 합본 시각적
|
|
707
|
+
* 폭을 더 줄임. 다른 엔진은 selector 무시.
|
|
708
|
+
*/
|
|
709
|
+
.datetime-input::-webkit-datetime-edit-fields-wrapper {
|
|
710
|
+
padding: 0;
|
|
711
|
+
}
|
|
712
|
+
.datetime-input::-webkit-datetime-edit-text {
|
|
713
|
+
padding: 0 1px;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
.datetime-input:focus {
|
|
717
|
+
border-color: rgba(255, 255, 255, 0.6);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/*
|
|
721
|
+
* 24시간제 강제 — lang="ko-KR" 만으로 안 먹는 브라우저(Chrome/Edge 일부
|
|
722
|
+
* 버전, OS locale 우선) 대비, AM/PM 필드를 CSS 레벨에서 비표시.
|
|
723
|
+
* Chromium/WebKit 의 -webkit-datetime-edit shadow DOM 만 이름이
|
|
724
|
+
* 노출되어 있어 다른 엔진은 자동으로 무시 (no-op).
|
|
725
|
+
*/
|
|
726
|
+
.datetime-input::-webkit-datetime-edit-ampm-field {
|
|
727
|
+
display: none;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
.datetime-input:disabled,
|
|
731
|
+
.date-input:disabled {
|
|
732
|
+
opacity: 0.5;
|
|
733
|
+
cursor: not-allowed;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
.apply-btn {
|
|
737
|
+
padding: 4px 12px;
|
|
738
|
+
border: 1px solid rgba(255, 255, 255, 0.3);
|
|
739
|
+
border-radius: 4px;
|
|
740
|
+
background: transparent;
|
|
741
|
+
color: #fff;
|
|
742
|
+
font-size: 12px;
|
|
743
|
+
cursor: pointer;
|
|
744
|
+
white-space: nowrap;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
.apply-btn.dirty {
|
|
748
|
+
background: rgba(255, 165, 0, 0.4);
|
|
749
|
+
border-color: rgba(255, 165, 0, 0.8);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
.apply-btn:hover:not(:disabled) {
|
|
753
|
+
background: rgba(255, 255, 255, 0.2);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
.apply-btn:disabled {
|
|
757
|
+
opacity: 0.4;
|
|
758
|
+
cursor: not-allowed;
|
|
759
|
+
}
|
|
760
|
+
|
|
445
761
|
.time-label {
|
|
446
762
|
font-size: 12px;
|
|
447
763
|
opacity: 0.7;
|
|
@@ -596,7 +912,22 @@ __decorate([
|
|
|
596
912
|
], PlaybackControls.prototype, "timeRange", void 0);
|
|
597
913
|
__decorate([
|
|
598
914
|
state()
|
|
599
|
-
], PlaybackControls.prototype, "
|
|
915
|
+
], PlaybackControls.prototype, "_pendingRange", void 0);
|
|
916
|
+
__decorate([
|
|
917
|
+
state()
|
|
918
|
+
], PlaybackControls.prototype, "_pendingFrom", void 0);
|
|
919
|
+
__decorate([
|
|
920
|
+
state()
|
|
921
|
+
], PlaybackControls.prototype, "_pendingTo", void 0);
|
|
922
|
+
__decorate([
|
|
923
|
+
state()
|
|
924
|
+
], PlaybackControls.prototype, "_lastEdited", void 0);
|
|
925
|
+
__decorate([
|
|
926
|
+
state()
|
|
927
|
+
], PlaybackControls.prototype, "_appliedFrom", void 0);
|
|
928
|
+
__decorate([
|
|
929
|
+
state()
|
|
930
|
+
], PlaybackControls.prototype, "_appliedTo", void 0);
|
|
600
931
|
__decorate([
|
|
601
932
|
state()
|
|
602
933
|
], PlaybackControls.prototype, "_seekValue", void 0);
|