@deck.gl/widgets 9.3.0-alpha.1 → 9.3.0-alpha.2

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.
Files changed (89) hide show
  1. package/dist/context-menu-widget.d.ts +7 -5
  2. package/dist/context-menu-widget.d.ts.map +1 -1
  3. package/dist/context-menu-widget.js +2 -1
  4. package/dist/context-menu-widget.js.map +1 -1
  5. package/dist/dist.dev.js +687 -555
  6. package/dist/icon-widget.d.ts +28 -0
  7. package/dist/icon-widget.d.ts.map +1 -0
  8. package/dist/icon-widget.js +35 -0
  9. package/dist/icon-widget.js.map +1 -0
  10. package/dist/index.cjs +468 -309
  11. package/dist/index.cjs.map +4 -4
  12. package/dist/index.d.ts +11 -9
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +7 -7
  15. package/dist/index.js.map +1 -1
  16. package/dist/info-widget.d.ts +1 -0
  17. package/dist/info-widget.d.ts.map +1 -1
  18. package/dist/info-widget.js +6 -0
  19. package/dist/info-widget.js.map +1 -1
  20. package/dist/lib/components/dropdown-menu.d.ts +3 -2
  21. package/dist/lib/components/dropdown-menu.d.ts.map +1 -1
  22. package/dist/lib/components/dropdown-menu.js +12 -8
  23. package/dist/lib/components/dropdown-menu.js.map +1 -1
  24. package/dist/lib/components/icon-button.d.ts +3 -0
  25. package/dist/lib/components/icon-button.d.ts.map +1 -1
  26. package/dist/lib/components/icon-button.js +10 -2
  27. package/dist/lib/components/icon-button.js.map +1 -1
  28. package/dist/lib/components/popover.js +1 -1
  29. package/dist/lib/components/popover.js.map +1 -1
  30. package/dist/lib/data-url.d.ts +5 -0
  31. package/dist/lib/data-url.d.ts.map +1 -0
  32. package/dist/lib/data-url.js +7 -0
  33. package/dist/lib/data-url.js.map +1 -0
  34. package/dist/popup-widget.d.ts +1 -0
  35. package/dist/popup-widget.d.ts.map +1 -1
  36. package/dist/popup-widget.js +6 -0
  37. package/dist/popup-widget.js.map +1 -1
  38. package/dist/selector-widget.d.ts +44 -0
  39. package/dist/selector-widget.d.ts.map +1 -0
  40. package/dist/selector-widget.js +64 -0
  41. package/dist/selector-widget.js.map +1 -0
  42. package/dist/splitter-widget.d.ts +52 -11
  43. package/dist/splitter-widget.d.ts.map +1 -1
  44. package/dist/splitter-widget.js +177 -76
  45. package/dist/splitter-widget.js.map +1 -1
  46. package/dist/stats-widget.d.ts +2 -1
  47. package/dist/stats-widget.d.ts.map +1 -1
  48. package/dist/stats-widget.js +37 -8
  49. package/dist/stats-widget.js.map +1 -1
  50. package/dist/stylesheet.css +50 -13
  51. package/dist/timeline-widget.d.ts.map +1 -1
  52. package/dist/timeline-widget.js +6 -1
  53. package/dist/timeline-widget.js.map +1 -1
  54. package/dist/toggle-widget.d.ts +38 -0
  55. package/dist/toggle-widget.d.ts.map +1 -0
  56. package/dist/toggle-widget.js +47 -0
  57. package/dist/toggle-widget.js.map +1 -0
  58. package/dist.min.js +2 -2
  59. package/package.json +3 -3
  60. package/src/context-menu-widget.tsx +9 -9
  61. package/src/icon-widget.tsx +69 -0
  62. package/src/index.ts +11 -9
  63. package/src/info-widget.tsx +7 -0
  64. package/src/lib/components/dropdown-menu.tsx +22 -15
  65. package/src/lib/components/icon-button.tsx +15 -3
  66. package/src/lib/components/popover.tsx +1 -1
  67. package/src/lib/data-url.ts +5 -0
  68. package/src/popup-widget.tsx +7 -0
  69. package/src/selector-widget.tsx +111 -0
  70. package/src/splitter-widget.tsx +281 -96
  71. package/src/stats-widget.tsx +50 -9
  72. package/src/stylesheet.css +50 -13
  73. package/src/timeline-widget.tsx +8 -1
  74. package/src/toggle-widget.tsx +101 -0
  75. package/dist/fps-widget.d.ts +0 -28
  76. package/dist/fps-widget.d.ts.map +0 -1
  77. package/dist/fps-widget.js +0 -51
  78. package/dist/fps-widget.js.map +0 -1
  79. package/dist/lib/components/icon-menu.d.ts +0 -16
  80. package/dist/lib/components/icon-menu.d.ts.map +0 -1
  81. package/dist/lib/components/icon-menu.js +0 -33
  82. package/dist/lib/components/icon-menu.js.map +0 -1
  83. package/dist/view-selector-widget.d.ts +0 -35
  84. package/dist/view-selector-widget.d.ts.map +0 -1
  85. package/dist/view-selector-widget.js +0 -63
  86. package/dist/view-selector-widget.js.map +0 -1
  87. package/src/fps-widget.tsx +0 -77
  88. package/src/lib/components/icon-menu.tsx +0 -70
  89. package/src/view-selector-widget.tsx +0 -151
@@ -2,22 +2,157 @@
2
2
  // SPDX-License-Identifier: MIT
3
3
  // Copyright (c) vis.gl contributors
4
4
 
5
- import {h, render} from 'preact';
6
- import {useState, useRef} from 'preact/hooks';
7
- import {Widget, type WidgetProps} from '@deck.gl/core';
5
+ import {JSX, render} from 'preact';
6
+ import {useState, useRef, useEffect} from 'preact/hooks';
7
+ import {
8
+ Widget,
9
+ _deepEqual as deepEqual,
10
+ type Deck,
11
+ type WidgetProps,
12
+ type View
13
+ } from '@deck.gl/core';
14
+
15
+ export type ViewLayout = {
16
+ /** Stacking orientation of the sub views */
17
+ orientation: 'vertical' | 'horizontal';
18
+ /** Initial instances that describe the sub views.
19
+ * x, y, width and height of the views' props will be overwritten by the SplitterWidget as split changes. */
20
+ views: [view1: View | ViewLayout, view2: View | ViewLayout];
21
+ /** The ratio of view1's share over the whole available height (vertical) or width (horizontal). Between 0-1.
22
+ * @default 0.5
23
+ */
24
+ initialSplit?: number;
25
+ /** Whether the split can be changed by dragging the border between the two views.
26
+ * @default true
27
+ */
28
+ editable?: boolean;
29
+ /** Min value of the split
30
+ * @default 0.05
31
+ */
32
+ minSplit?: number;
33
+ /** Max value of the split
34
+ * @default 0.95
35
+ */
36
+ maxSplit?: number;
37
+ };
38
+
39
+ type ManagedViewLayout = {
40
+ id: number;
41
+ orientation: 'vertical' | 'horizontal';
42
+ views: [view1: View | ManagedViewLayout, view2: View | ManagedViewLayout];
43
+ split: number;
44
+ editable: boolean;
45
+ minSplit: number;
46
+ maxSplit: number;
47
+ x: number;
48
+ y: number;
49
+ width: number;
50
+ height: number;
51
+ };
52
+
53
+ function parseViewLayout(root: ViewLayout): ManagedViewLayout[] {
54
+ const layoutsById: ManagedViewLayout[] = [];
55
+ const isViewLayout = (v: View | ViewLayout): v is ViewLayout => 'views' in v;
56
+ function createManagedViewLayout(l: ViewLayout): ManagedViewLayout {
57
+ const id = layoutsById.length;
58
+ const minSplit = l.minSplit ?? 0.05;
59
+ const maxSplit = l.maxSplit ?? 0.95;
60
+ const split = Math.min(Math.max(l.initialSplit ?? 0.5, minSplit), maxSplit);
61
+ const managed: ManagedViewLayout = {
62
+ id,
63
+ orientation: l.orientation,
64
+ views: l.views as [view1: View | ManagedViewLayout, view2: View | ManagedViewLayout],
65
+ split,
66
+ editable: l.editable ?? true,
67
+ minSplit,
68
+ maxSplit,
69
+ x: 0,
70
+ y: 0,
71
+ width: 0,
72
+ height: 0
73
+ };
74
+ layoutsById.push(managed);
75
+ managed.views = [
76
+ isViewLayout(l.views[0]) ? createManagedViewLayout(l.views[0]) : l.views[0],
77
+ isViewLayout(l.views[1]) ? createManagedViewLayout(l.views[1]) : l.views[1]
78
+ ];
79
+ return managed;
80
+ }
81
+ createManagedViewLayout(root);
82
+ return layoutsById;
83
+ }
84
+
85
+ function evaluateViews(root: ManagedViewLayout): View[] {
86
+ const views: View[] = [];
87
+ function evaluateViewLayout(
88
+ l: ManagedViewLayout,
89
+ x: number,
90
+ y: number,
91
+ width: number,
92
+ height: number
93
+ ) {
94
+ l.x = x;
95
+ l.y = y;
96
+ l.width = width;
97
+ l.height = height;
98
+
99
+ const child1X = x;
100
+ const child1Y = y;
101
+ let child1Width = width;
102
+ let child1Height = height;
103
+ let child2X = x;
104
+ let child2Y = y;
105
+ let child2Width = width;
106
+ let child2Height = height;
107
+
108
+ if (l.orientation === 'horizontal') {
109
+ child1Width = width * l.split;
110
+ child2X = x + child1Width;
111
+ child2Width = width - child1Width;
112
+ } else {
113
+ child1Height = height * l.split;
114
+ child2Y = y + child1Height;
115
+ child2Height = height - child1Height;
116
+ }
117
+
118
+ const [view1, view2] = l.views;
119
+ if ('views' in view1) {
120
+ evaluateViewLayout(view1, child1X, child1Y, child1Width, child1Height);
121
+ } else {
122
+ views.push(
123
+ view1.clone({
124
+ x: `${child1X}%`,
125
+ y: `${child1Y}%`,
126
+ width: `${child1Width}%`,
127
+ height: `${child1Height}%`
128
+ })
129
+ );
130
+ }
131
+
132
+ if ('views' in view2) {
133
+ evaluateViewLayout(view2, child2X, child2Y, child2Width, child2Height);
134
+ } else {
135
+ views.push(
136
+ view2.clone({
137
+ x: `${child2X}%`,
138
+ y: `${child2Y}%`,
139
+ width: `${child2Width}%`,
140
+ height: `${child2Height}%`
141
+ })
142
+ );
143
+ }
144
+ }
145
+
146
+ evaluateViewLayout(root, 0, 0, 100, 100);
147
+ return views;
148
+ }
8
149
 
9
150
  /** Properties for the SplitterWidget */
10
151
  export type SplitterWidgetProps = WidgetProps & {
11
- /** The view id for the first (resizable) view */
12
- viewId1: string;
13
- /** The view id for the second view */
14
- viewId2: string;
15
- /** Orientation of the splitter: vertical (default) or horizontal */
16
- orientation?: 'vertical' | 'horizontal';
17
- /** The initial split percentage (0 to 1) for the first view, default 0.5 */
18
- initialSplit?: number;
152
+ /** Stacking views descriptor */
153
+ viewLayout: ViewLayout;
19
154
  /** Callback invoked when the splitter is dragged with the new split value */
20
- onChange?: (newSplit: number) => void;
155
+ onChange?: (views: View[]) => void;
21
156
  /** Callback invoked when dragging starts */
22
157
  onDragStart?: () => void;
23
158
  /** Callback invoked when dragging ends */
@@ -29,14 +164,11 @@ export type SplitterWidgetProps = WidgetProps & {
29
164
  * across the deck.gl canvas. It positions itself based on the split percentage
30
165
  * of the first view and provides callbacks when dragged.
31
166
  */
32
- export class SplitterWidget extends Widget<SplitterWidgetProps> {
167
+ export class SplitterWidget extends Widget<SplitterWidgetProps, View[]> {
33
168
  static defaultProps: Required<SplitterWidgetProps> = {
34
169
  ...Widget.defaultProps,
35
170
  id: 'splitter-widget',
36
- viewId1: '',
37
- viewId2: '',
38
- orientation: 'vertical',
39
- initialSplit: 0.5,
171
+ viewLayout: undefined!,
40
172
  onChange: () => {},
41
173
  onDragStart: () => {},
42
174
  onDragEnd: () => {}
@@ -44,33 +176,88 @@ export class SplitterWidget extends Widget<SplitterWidgetProps> {
44
176
 
45
177
  className = 'deck-widget-splitter';
46
178
  placement = 'fill' as const;
179
+ viewLayouts!: ManagedViewLayout[];
180
+ /** evaluated from the current viewLayouts */
181
+ views!: View[];
182
+ /** views set in the last update */
183
+ lastViews?: View[];
184
+ needsUpdate = true;
47
185
 
48
186
  constructor(props: SplitterWidgetProps) {
49
187
  super(props);
188
+ this.viewLayouts = parseViewLayout(this.props.viewLayout);
50
189
  }
51
190
 
52
191
  setProps(props: Partial<SplitterWidgetProps>) {
192
+ if (props.viewLayout && !deepEqual(props.viewLayout, this.props.viewLayout, -1)) {
193
+ this.viewLayouts = parseViewLayout(props.viewLayout);
194
+ this.views = undefined!;
195
+ }
53
196
  super.setProps(props);
54
197
  }
55
198
 
56
- onRenderHTML(rootElement: HTMLElement): void {
57
- // Ensure the widget container fills the deck.gl canvas.
58
- // TODO - Move styling to CSS
59
- rootElement.style.position = 'absolute';
60
- rootElement.style.top = '0';
61
- rootElement.style.left = '0';
62
- rootElement.style.width = '100%';
63
- rootElement.style.height = '100%';
64
- rootElement.style.margin = '0px';
199
+ onRedraw() {
200
+ // Actually update DOM
201
+ super.updateHTML();
202
+ }
203
+
204
+ // Usually widgets rerender their DOM elements here
205
+ // In this case we need the widget UI to synchronize with deck view states
206
+ // so we update deck props here and rerender DOM in the next onRedraw
207
+ updateHTML() {
208
+ if (!this.views) {
209
+ // viewLayouts has changed, re-evaluate
210
+ this.views = evaluateViews(this.viewLayouts[0]);
211
+ // we send a copy to the callback so that externally set views can be differentiated from internal
212
+ this.props.onChange(this.views.slice());
213
+ }
214
+ // This method is called inside deck.setProps > widgetManager.setProps > widget.setProps
215
+ // Calling deck.setProps immediately would cause infinite loop
216
+ requestAnimationFrame(() => {
217
+ this.doUpdate();
218
+ });
219
+ }
220
+
221
+ private doUpdate() {
222
+ if (this.deck) {
223
+ const deckViews = this.deck.props.views;
224
+ const isManagedExternally =
225
+ // is not empty
226
+ deckViews &&
227
+ // is not set by us
228
+ deckViews !== this.lastViews;
65
229
 
230
+ if (!isManagedExternally && this.lastViews !== this.views) {
231
+ this.lastViews = this.views;
232
+ this.deck.setProps({views: this.views});
233
+ }
234
+ }
235
+ }
236
+
237
+ private onChange(newSplit: number, layout: ManagedViewLayout) {
238
+ layout.split = newSplit;
239
+ // layout has updated, re-evaluate
240
+ this.views = evaluateViews(this.viewLayouts[0]);
241
+ // we send a copy to the callback so that externally set views can be differentiated from internal
242
+ this.props.onChange(this.views.slice());
243
+ this.doUpdate();
244
+ }
245
+
246
+ onRenderHTML(rootElement: HTMLElement): void {
66
247
  render(
67
- <Splitter
68
- orientation={this.props.orientation}
69
- initialSplit={this.props.initialSplit}
70
- onChange={this.props.onChange}
71
- onDragStart={this.props.onDragStart}
72
- onDragEnd={this.props.onDragEnd}
73
- />,
248
+ <>
249
+ {this.viewLayouts.map(
250
+ layout =>
251
+ layout.editable && (
252
+ <Splitter
253
+ {...layout}
254
+ onChange={newSplit => this.onChange(newSplit, layout)}
255
+ onDragStart={() => this.props.onDragStart()}
256
+ onDragEnd={() => this.props.onDragStart()}
257
+ />
258
+ )
259
+ )}
260
+ </>,
74
261
  rootElement
75
262
  );
76
263
  }
@@ -83,94 +270,92 @@ export class SplitterWidget extends Widget<SplitterWidgetProps> {
83
270
  */
84
271
  function Splitter({
85
272
  orientation,
86
- initialSplit,
273
+ x,
274
+ y,
275
+ width,
276
+ height,
277
+ split,
278
+ minSplit,
279
+ maxSplit,
87
280
  onChange,
88
281
  onDragStart,
89
282
  onDragEnd
90
283
  }: {
91
284
  orientation: 'vertical' | 'horizontal';
92
- initialSplit: number;
285
+ x: number;
286
+ y: number;
287
+ width: number;
288
+ height: number;
289
+ split: number;
290
+ minSplit: number;
291
+ maxSplit: number;
93
292
  onChange?: (newSplit: number) => void;
94
293
  onDragStart?: () => void;
95
294
  onDragEnd?: () => void;
96
295
  }) {
97
- const [split, setSplit] = useState(initialSplit);
98
- const dragging = useRef(false);
296
+ const [dragging, setDragging] = useState(false);
99
297
  const containerRef = useRef<HTMLDivElement>(null);
100
298
 
101
- const handleDragStart = (event: MouseEvent) => {
102
- dragging.current = true;
103
- onDragStart?.();
104
- document.addEventListener('mousemove', handleDragging);
105
- document.addEventListener('mouseup', handleDragEnd);
106
- event.preventDefault();
107
- };
108
-
109
- const handleDragging = (event: MouseEvent) => {
110
- if (!dragging.current || !containerRef.current) return;
111
- const rect = containerRef.current.getBoundingClientRect();
112
- let newSplit: number;
113
- if (orientation === 'vertical') {
114
- newSplit = (event.clientX - rect.left) / rect.width;
115
- } else {
116
- newSplit = (event.clientY - rect.top) / rect.height;
299
+ useEffect(() => {
300
+ if (!dragging) {
301
+ return undefined;
117
302
  }
118
- // Clamp newSplit between 5% and 95%
119
- newSplit = Math.min(Math.max(newSplit, 0.05), 0.95);
120
- setSplit(newSplit);
121
- onChange?.(newSplit);
122
- };
123
303
 
124
- const handleDragEnd = (event: MouseEvent) => {
125
- if (!dragging.current) return;
126
- dragging.current = false;
127
- onDragEnd?.();
128
- document.removeEventListener('mousemove', handleDragging);
129
- document.removeEventListener('mouseup', handleDragEnd);
304
+ const handleDragging = (event: PointerEvent) => {
305
+ if (!containerRef.current) return;
306
+ const rect = containerRef.current.getBoundingClientRect();
307
+ let newSplit: number;
308
+ if (orientation === 'horizontal') {
309
+ newSplit = (event.clientX - rect.left) / rect.width;
310
+ } else {
311
+ newSplit = (event.clientY - rect.top) / rect.height;
312
+ }
313
+ // Clamp newSplit between 5% and 95%
314
+ newSplit = Math.min(Math.max(newSplit, minSplit), maxSplit);
315
+ onChange?.(newSplit);
316
+ };
317
+
318
+ const handleDragEnd = () => {
319
+ onDragEnd?.();
320
+ setDragging(false);
321
+ };
322
+
323
+ document.addEventListener('pointermove', handleDragging);
324
+ document.addEventListener('pointerup', handleDragEnd);
325
+ document.addEventListener('pointerleave', handleDragEnd);
326
+
327
+ return () => {
328
+ document.removeEventListener('pointermove', handleDragging);
329
+ document.removeEventListener('pointerup', handleDragEnd);
330
+ document.removeEventListener('pointerleave', handleDragEnd);
331
+ };
332
+ }, [dragging]);
333
+
334
+ const handleDragStart = (event: PointerEvent) => {
335
+ setDragging(true);
336
+ onDragStart?.();
337
+ event.preventDefault();
130
338
  };
131
339
 
132
340
  // The splitter line style based on orientation and the current split percentage.
133
- const splitterStyle: h.JSX.CSSProperties =
134
- orientation === 'vertical'
135
- ? {
136
- position: 'absolute',
137
- top: 0,
138
- bottom: 0,
139
- left: `${split * 100}%`,
140
- width: '4px',
141
- cursor: 'col-resize',
142
- background: '#ccc',
143
- zIndex: 10,
144
- pointerEvents: 'auto',
145
- boxShadow: 'inset -1px 0 0 white, inset 1px 0 0 white'
146
- }
147
- : {
148
- position: 'absolute',
149
- left: 0,
150
- right: 0,
151
- top: `${split * 100}%`,
152
- height: '4px',
153
- cursor: 'row-resize',
154
- background: '#ccc',
155
- zIndex: 10,
156
- pointerEvents: 'auto',
157
- boxShadow: 'inset -1px 0 0 white, inset 1px 0 0 white'
158
- };
341
+ const splitterStyle: JSX.CSSProperties =
342
+ orientation === 'horizontal' ? {left: `${split * 100}%`} : {top: `${split * 100}%`};
159
343
 
160
344
  // Container style to fill the entire deck.gl canvas.
161
- const containerStyle: h.JSX.CSSProperties = {
345
+ const containerStyle: JSX.CSSProperties = {
162
346
  position: 'absolute',
163
- top: 0,
164
- left: 0,
165
- right: 0,
166
- bottom: 0
347
+ top: `${y}%`,
348
+ left: `${x}%`,
349
+ width: `${width}%`,
350
+ height: `${height}%`
167
351
  };
168
352
 
169
353
  return (
170
354
  <div ref={containerRef} style={containerStyle}>
171
355
  <div
356
+ className={`deck-widget-splitter-handle deck-widget-splitter-handle--${orientation} ${dragging ? 'active' : ''}`}
172
357
  style={splitterStyle}
173
- onMouseDown={handleDragStart as h.JSX.MouseEventHandler<HTMLElement>} // Use the appropriate Preact event type.
358
+ onPointerDown={handleDragStart} // Use the appropriate Preact event type.
174
359
  />
175
360
  </div>
176
361
  );
@@ -5,7 +5,9 @@
5
5
  import {Widget, type WidgetPlacement, type WidgetProps} from '@deck.gl/core';
6
6
  import {luma} from '@luma.gl/core';
7
7
  import {render} from 'preact';
8
+ import {useEffect, useState} from 'preact/hooks';
8
9
  import type {Stats, Stat} from '@probe.gl/stats';
10
+ import {IconButton} from './lib/components/icon-button';
9
11
 
10
12
  const DEFAULT_COUNT_FORMATTER = (stat: Stat): string => `${stat.name}: ${stat.count}`;
11
13
 
@@ -97,17 +99,25 @@ export class StatsWidget extends Widget<StatsWidgetProps> {
97
99
  super.setProps(props);
98
100
  }
99
101
 
100
- onAdd(): void {
101
- this.updateHTML();
102
+ onRemove() {
103
+ if (this.rootElement) {
104
+ // Make sure all preact hooks are finalized
105
+ render(null, this.rootElement);
106
+ }
102
107
  }
103
108
 
104
109
  onRenderHTML(rootElement: HTMLElement): void {
105
- const stats = this._getStats();
106
110
  const collapsed = this.collapsed;
111
+ if (collapsed) {
112
+ render(<FpsIcon getFps={this._getFps} onClick={this._toggleCollapsed} />, rootElement);
113
+ return;
114
+ }
115
+
116
+ const stats = this._getStats();
107
117
  const title = this.props.title || ('id' in stats ? stats.id : null) || 'Stats';
108
118
  const items: JSX.Element[] = [];
109
119
 
110
- if (!collapsed && stats) {
120
+ if (stats) {
111
121
  stats.forEach(stat => {
112
122
  const lines = this._getLines(stat).split('\n');
113
123
  if (this._resetOnUpdate && this._resetOnUpdate[stat.name]) {
@@ -132,19 +142,21 @@ export class StatsWidget extends Widget<StatsWidgetProps> {
132
142
  >
133
143
  <b>{title}</b>
134
144
  <button className="deck-widget-dropdown-button">
135
- <span className={`deck-widget-dropdown-icon ${collapsed ? '' : 'open'}`} />
145
+ <span className="deck-widget-dropdown-icon open" />
136
146
  </button>
137
147
  </div>
138
- {!collapsed && <div className="deck-widget-stats-content">{items}</div>}
148
+ <div className="deck-widget-stats-content">{items}</div>
139
149
  </div>,
140
150
  rootElement
141
151
  );
142
152
  }
143
153
 
144
154
  onRedraw(): void {
145
- const framesPerUpdate = Math.max(1, this.props.framesPerUpdate || 1);
146
- if (this._counter++ % framesPerUpdate === 0) {
147
- this.updateHTML();
155
+ if (!this.collapsed) {
156
+ const framesPerUpdate = Math.max(1, this.props.framesPerUpdate || 1);
157
+ if (this._counter++ % framesPerUpdate === 0) {
158
+ this.updateHTML();
159
+ }
148
160
  }
149
161
  }
150
162
 
@@ -173,6 +185,11 @@ export class StatsWidget extends Widget<StatsWidgetProps> {
173
185
  this.updateHTML();
174
186
  };
175
187
 
188
+ protected _getFps = (): number => {
189
+ // @ts-expect-error metrics is protected
190
+ return Math.round(this.deck?.metrics.fps ?? 0);
191
+ };
192
+
176
193
  protected _getLines(stat: Stat | [key: string, value: number]): string {
177
194
  if ('count' in stat) {
178
195
  const formatter =
@@ -189,3 +206,27 @@ export class StatsWidget extends Widget<StatsWidgetProps> {
189
206
  return `${key}: ${formattedValue}`;
190
207
  }
191
208
  }
209
+
210
+ function FpsIcon({getFps, onClick}: {getFps: () => number; onClick: () => void}) {
211
+ const [fps, setFps] = useState(getFps());
212
+ useEffect(() => {
213
+ const onUpdate = () => {
214
+ setFps(getFps());
215
+ timer = requestAnimationFrame(onUpdate);
216
+ };
217
+ let timer = requestAnimationFrame(onUpdate);
218
+ return () => {
219
+ cancelAnimationFrame(timer);
220
+ };
221
+ }, [getFps]);
222
+
223
+ return (
224
+ <IconButton onClick={onClick}>
225
+ <div className="text">
226
+ FPS
227
+ <br />
228
+ {fps}
229
+ </div>
230
+ </IconButton>
231
+ );
232
+ }
@@ -257,19 +257,6 @@
257
257
  padding: 4px;
258
258
  }
259
259
 
260
- /* FPS widget styles */
261
-
262
- .deck-widget.deck-widget-fps button {
263
- pointer-events: none;
264
- }
265
-
266
- .deck-widget.deck-widget-fps .text {
267
- color: var(--button-text, rgb(24, 24, 26));
268
- font-family: monospace;
269
- font-size: 8px;
270
- font-weight: 700;
271
- }
272
-
273
260
  /* Loading styles */
274
261
  @keyframes spin {
275
262
  to {
@@ -451,6 +438,12 @@
451
438
  }
452
439
 
453
440
  /* Stats styles */
441
+ .deck-widget.deck-widget-stats button .text {
442
+ color: var(--button-text, rgb(24, 24, 26));
443
+ font-family: monospace;
444
+ font-size: 8px;
445
+ font-weight: 700;
446
+ }
454
447
  .deck-widget-stats-container {
455
448
  border-radius: var(--button-corner-radius, 8px);
456
449
  display: inline-flex;
@@ -647,3 +640,47 @@
647
640
  .deck-widget-scrollbar[data-placement^=bottom-] .deck-widget-range--horizontal {
648
641
  bottom: 0;
649
642
  }
643
+
644
+ /** Splitter */
645
+ .deck-widget.deck-widget-splitter {
646
+ position: absolute;
647
+ margin: 0;
648
+ left: 0;
649
+ right: 0;
650
+ top: 0;
651
+ bottom: 0;
652
+ }
653
+ .deck-widget-splitter-handle {
654
+ position: absolute;
655
+ background: var(--button-background, #fff);
656
+ z-index: 10;
657
+ pointer-events: auto;
658
+ box-shadow: inset 1px 1px 1px var(--button-icon-idle, #616166);
659
+ }
660
+ .deck-widget-splitter-handle.active {
661
+ box-shadow: var(--button-shadow, 0px 0px 8px 0px rgba(0, 0, 0, 0.25));
662
+ }
663
+ .deck-widget-splitter-handle:before {
664
+ content: ' ';
665
+ display: block;
666
+ position: absolute;
667
+ box-sizing: content-box;
668
+ width: 100%;
669
+ height: 100%;
670
+ padding: 4px;
671
+ margin: -4px;
672
+ }
673
+ .deck-widget-splitter-handle--horizontal {
674
+ top: 0;
675
+ bottom: 0;
676
+ width: 2px;
677
+ margin-left: -1px;
678
+ cursor: col-resize;
679
+ }
680
+ .deck-widget-splitter-handle--vertical {
681
+ left: 0;
682
+ right: 0;
683
+ height: 2px;
684
+ margin-top: -1px;
685
+ cursor: row-resize;
686
+ }
@@ -151,6 +151,13 @@ export class TimelineWidget extends Widget<TimelineWidgetProps> {
151
151
 
152
152
  public play(): void {
153
153
  this.playing = true;
154
+ const {
155
+ timeRange: [min, max]
156
+ } = this.props;
157
+ if (this.currentTime >= max) {
158
+ this.currentTime = min;
159
+ this.props.onTimeChange(min);
160
+ }
154
161
  this.updateHTML();
155
162
  this.tick();
156
163
  }
@@ -171,7 +178,7 @@ export class TimelineWidget extends Widget<TimelineWidgetProps> {
171
178
  loop
172
179
  } = this.props;
173
180
  if (step > 0) {
174
- let next = Math.floor(this.currentTime / step) * step + step;
181
+ let next = Math.round(this.currentTime / step) * step + step;
175
182
  if (next > max) {
176
183
  if (this.currentTime < max) {
177
184
  next = max;