@deck.gl/widgets 9.3.0-alpha.6 → 9.3.0-beta.1

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 (55) hide show
  1. package/dist/compass-widget.d.ts +12 -0
  2. package/dist/compass-widget.d.ts.map +1 -1
  3. package/dist/compass-widget.js +9 -3
  4. package/dist/compass-widget.js.map +1 -1
  5. package/dist/dist.dev.js +205 -69
  6. package/dist/fullscreen-widget.d.ts +5 -0
  7. package/dist/fullscreen-widget.d.ts.map +1 -1
  8. package/dist/fullscreen-widget.js +18 -8
  9. package/dist/fullscreen-widget.js.map +1 -1
  10. package/dist/geocoder-widget.d.ts +13 -0
  11. package/dist/geocoder-widget.d.ts.map +1 -1
  12. package/dist/geocoder-widget.js +13 -1
  13. package/dist/geocoder-widget.js.map +1 -1
  14. package/dist/gimbal-widget.d.ts +12 -0
  15. package/dist/gimbal-widget.d.ts.map +1 -1
  16. package/dist/gimbal-widget.js +4 -1
  17. package/dist/gimbal-widget.js.map +1 -1
  18. package/dist/index.cjs +218 -70
  19. package/dist/index.cjs.map +2 -2
  20. package/dist/loading-widget.d.ts +5 -0
  21. package/dist/loading-widget.d.ts.map +1 -1
  22. package/dist/loading-widget.js +4 -1
  23. package/dist/loading-widget.js.map +1 -1
  24. package/dist/reset-view-widget.d.ts +9 -0
  25. package/dist/reset-view-widget.d.ts.map +1 -1
  26. package/dist/reset-view-widget.js +4 -1
  27. package/dist/reset-view-widget.js.map +1 -1
  28. package/dist/stats-widget.d.ts +18 -3
  29. package/dist/stats-widget.d.ts.map +1 -1
  30. package/dist/stats-widget.js +29 -12
  31. package/dist/stats-widget.js.map +1 -1
  32. package/dist/theme-widget.d.ts +20 -2
  33. package/dist/theme-widget.d.ts.map +1 -1
  34. package/dist/theme-widget.js +38 -14
  35. package/dist/theme-widget.js.map +1 -1
  36. package/dist/timeline-widget.d.ts +34 -3
  37. package/dist/timeline-widget.d.ts.map +1 -1
  38. package/dist/timeline-widget.js +98 -24
  39. package/dist/timeline-widget.js.map +1 -1
  40. package/dist/zoom-widget.d.ts +13 -1
  41. package/dist/zoom-widget.d.ts.map +1 -1
  42. package/dist/zoom-widget.js +7 -4
  43. package/dist/zoom-widget.js.map +1 -1
  44. package/dist.min.js +2 -2
  45. package/package.json +2 -2
  46. package/src/compass-widget.tsx +23 -3
  47. package/src/fullscreen-widget.tsx +24 -9
  48. package/src/geocoder-widget.tsx +23 -1
  49. package/src/gimbal-widget.tsx +17 -1
  50. package/src/loading-widget.tsx +11 -1
  51. package/src/reset-view-widget.tsx +15 -1
  52. package/src/stats-widget.tsx +42 -13
  53. package/src/theme-widget.tsx +56 -17
  54. package/src/timeline-widget.tsx +123 -24
  55. package/src/zoom-widget.tsx +20 -4
@@ -48,6 +48,15 @@ export type GeocoderWidgetProps = WidgetProps & {
48
48
  apiKey?: string;
49
49
  /** Whether to use geolocation @note Experimental*/
50
50
  _geolocation?: boolean;
51
+ /**
52
+ * Callback when a location is geocoded and the view will navigate to it.
53
+ */
54
+ onGeocode?: (params: {
55
+ /** The view being updated */
56
+ viewId: string;
57
+ /** The geocoded coordinates */
58
+ coordinates: {longitude: number; latitude: number; zoom?: number};
59
+ }) => void;
51
60
  };
52
61
 
53
62
  /**
@@ -66,7 +75,8 @@ export class GeocoderWidget extends Widget<GeocoderWidgetProps> {
66
75
  geocoder: 'coordinates',
67
76
  customGeocoder: CoordinatesGeocoder,
68
77
  apiKey: '',
69
- _geolocation: false
78
+ _geolocation: false,
79
+ onGeocode: () => {}
70
80
  };
71
81
 
72
82
  className = 'deck-widget-geocoder';
@@ -188,6 +198,18 @@ export class GeocoderWidget extends Widget<GeocoderWidgetProps> {
188
198
  flyTo(viewState: ViewState) {
189
199
  const viewIds = this.viewId ? [this.viewId] : (this.deck?.getViews().map(v => v.id) ?? []);
190
200
  for (const viewId of viewIds) {
201
+ // Call callback with geocoded coordinates
202
+ if ('longitude' in viewState && 'latitude' in viewState) {
203
+ this.props.onGeocode?.({
204
+ viewId,
205
+ coordinates: {
206
+ longitude: viewState.longitude as number,
207
+ latitude: viewState.latitude as number,
208
+ zoom: viewState.zoom as number | undefined
209
+ }
210
+ });
211
+ }
212
+
191
213
  const currentViewState = this.getViewState(viewId);
192
214
  const nextViewState: ViewState = {
193
215
  ...currentViewState,
@@ -16,6 +16,18 @@ export type GimbalWidgetProps = WidgetProps & {
16
16
  strokeWidth?: number;
17
17
  /** Transition duration in ms when resetting rotation. */
18
18
  transitionDuration?: number;
19
+ /**
20
+ * Callback when the gimbal reset button is clicked.
21
+ * Called for each viewport that will be reset.
22
+ */
23
+ onReset?: (params: {
24
+ /** The view being reset */
25
+ viewId: string;
26
+ /** The new rotationOrbit value (0) */
27
+ rotationOrbit: number;
28
+ /** The new rotationX value (0) */
29
+ rotationX: number;
30
+ }) => void;
19
31
  };
20
32
 
21
33
  export class GimbalWidget extends Widget<GimbalWidgetProps> {
@@ -26,7 +38,8 @@ export class GimbalWidget extends Widget<GimbalWidgetProps> {
26
38
  viewId: null,
27
39
  label: 'Gimbal',
28
40
  strokeWidth: 1.5,
29
- transitionDuration: 200
41
+ transitionDuration: 200,
42
+ onReset: () => {}
30
43
  };
31
44
 
32
45
  className = 'deck-widget-gimbal';
@@ -122,6 +135,9 @@ export class GimbalWidget extends Widget<GimbalWidgetProps> {
122
135
  const viewId = this.viewId || viewport?.id || 'OrbitView';
123
136
  const viewState = this.getViewState(viewId);
124
137
  if ('rotationOrbit' in viewState || 'rotationX' in viewState) {
138
+ // Call callback
139
+ this.props.onReset?.({viewId, rotationOrbit: 0, rotationX: 0});
140
+
125
141
  const nextViewState = {
126
142
  ...viewState,
127
143
  rotationOrbit: 0,
@@ -15,6 +15,11 @@ export type LoadingWidgetProps = WidgetProps & {
15
15
  viewId?: string | null;
16
16
  /** Tooltip message when loading */
17
17
  label?: string;
18
+ /**
19
+ * Callback when the loading state changes.
20
+ * Called when layers transition between loading and loaded states.
21
+ */
22
+ onLoadingChange?: (loading: boolean) => void;
18
23
  };
19
24
 
20
25
  /**
@@ -26,7 +31,8 @@ export class LoadingWidget extends Widget<LoadingWidgetProps> {
26
31
  id: 'loading',
27
32
  placement: 'top-left',
28
33
  viewId: null,
29
- label: 'Loading layer data'
34
+ label: 'Loading layer data',
35
+ onLoadingChange: () => {}
30
36
  };
31
37
 
32
38
  className = 'deck-widget-loading';
@@ -62,6 +68,10 @@ export class LoadingWidget extends Widget<LoadingWidgetProps> {
62
68
  const loading = layers.some(layer => !layer.isLoaded);
63
69
  if (loading !== this.loading) {
64
70
  this.loading = loading;
71
+
72
+ // Call callback when loading state changes
73
+ this.props.onLoadingChange?.(loading);
74
+
65
75
  this.updateHTML();
66
76
  }
67
77
  }
@@ -21,6 +21,15 @@ export type ResetViewWidgetProps<ViewsT extends ViewOrViews = null> = WidgetProp
21
21
  initialViewState?: ViewStateMap<ViewsT>;
22
22
  /** View to interact with. Required when using multiple views. */
23
23
  viewId?: string | null;
24
+ /**
25
+ * Callback when the reset view button is clicked.
26
+ */
27
+ onReset?: (params: {
28
+ /** The view being reset */
29
+ viewId: string;
30
+ /** The view state being reset to */
31
+ viewState: Record<string, unknown>;
32
+ }) => void;
24
33
  };
25
34
 
26
35
  /**
@@ -36,7 +45,8 @@ export class ResetViewWidget<ViewsT extends ViewOrViews = null> extends Widget<
36
45
  placement: 'top-left',
37
46
  label: 'Reset View',
38
47
  initialViewState: undefined!,
39
- viewId: null
48
+ viewId: null,
49
+ onReset: () => {}
40
50
  };
41
51
 
42
52
  className = 'deck-widget-reset-view';
@@ -78,6 +88,10 @@ export class ResetViewWidget<ViewsT extends ViewOrViews = null> extends Widget<
78
88
  // transitionDuration: this.props.transitionDuration,
79
89
  // transitionInterpolator: new FlyToInterpolator()
80
90
  };
91
+
92
+ // Call callback
93
+ this.props.onReset?.({viewId, viewState: nextViewState as Record<string, unknown>});
94
+
81
95
  this.setViewState(viewId, nextViewState);
82
96
  }
83
97
  }
@@ -38,7 +38,7 @@ export type StatsWidgetProps = WidgetProps & {
38
38
  /** Expand the stats UI by default.
39
39
  * @default false
40
40
  */
41
- defaultIsExpanded?: boolean;
41
+ initialExpanded?: boolean;
42
42
  /** Stats object to visualize. */
43
43
  stats?: Stats;
44
44
  /** Title shown in the header of the pop-up. Defaults to stats.id. */
@@ -49,6 +49,15 @@ export type StatsWidgetProps = WidgetProps & {
49
49
  formatters?: Record<string, string | ((stat: Stat) => string)>;
50
50
  /** Whether to reset particular stats after each update. */
51
51
  resetOnUpdate?: Record<string, boolean>;
52
+ /**
53
+ * Controlled expanded state. When provided, the widget is in controlled mode.
54
+ */
55
+ expanded?: boolean;
56
+ /**
57
+ * Callback when the expanded state changes (user clicks header).
58
+ * In controlled mode, use this to update the expanded prop.
59
+ */
60
+ onExpandedChange?: (expanded: boolean) => void;
52
61
  };
53
62
 
54
63
  /** Displays probe.gl stats in a floating pop-up. */
@@ -58,13 +67,15 @@ export class StatsWidget extends Widget<StatsWidgetProps> {
58
67
  type: 'deck',
59
68
  placement: 'top-left',
60
69
  viewId: null,
61
- defaultIsExpanded: false,
70
+ initialExpanded: false,
62
71
  stats: undefined!,
63
72
  title: 'Stats',
64
73
  framesPerUpdate: 1,
65
74
  formatters: {},
66
75
  resetOnUpdate: {},
67
- id: 'stats'
76
+ id: 'stats',
77
+ expanded: undefined!,
78
+ onExpandedChange: () => {}
68
79
  };
69
80
 
70
81
  className = 'deck-widget-stats';
@@ -73,13 +84,22 @@ export class StatsWidget extends Widget<StatsWidgetProps> {
73
84
  private _counter = 0;
74
85
  private _formatters: Record<string, (stat: Stat) => string>;
75
86
  private _resetOnUpdate: Record<string, boolean>;
76
- collapsed: boolean = true;
87
+ private _expanded: boolean = false;
88
+
89
+ /**
90
+ * Returns the current expanded state.
91
+ * In controlled mode, returns the expanded prop.
92
+ * In uncontrolled mode, returns the internal state.
93
+ */
94
+ getExpanded(): boolean {
95
+ return this.props.expanded ?? this._expanded;
96
+ }
77
97
 
78
98
  constructor(props: StatsWidgetProps = {}) {
79
99
  super(props);
80
100
  this._formatters = {...DEFAULT_FORMATTERS};
81
101
  this._resetOnUpdate = {...this.props.resetOnUpdate};
82
- this.collapsed = !props.defaultIsExpanded;
102
+ this._expanded = Boolean(props.initialExpanded);
83
103
  this.setProps(props);
84
104
  }
85
105
 
@@ -107,9 +127,9 @@ export class StatsWidget extends Widget<StatsWidgetProps> {
107
127
  }
108
128
 
109
129
  onRenderHTML(rootElement: HTMLElement): void {
110
- const collapsed = this.collapsed;
111
- if (collapsed) {
112
- render(<FpsIcon getFps={this._getFps} onClick={this._toggleCollapsed} />, rootElement);
130
+ const isExpanded = this.getExpanded();
131
+ if (!isExpanded) {
132
+ render(<FpsIcon getFps={this._getFps} onClick={this._toggleExpanded} />, rootElement);
113
133
  return;
114
134
  }
115
135
 
@@ -139,7 +159,7 @@ export class StatsWidget extends Widget<StatsWidgetProps> {
139
159
  <div
140
160
  className="deck-widget-stats-header"
141
161
  style={{cursor: 'pointer', pointerEvents: 'auto'}}
142
- onClick={this._toggleCollapsed}
162
+ onClick={this._toggleExpanded}
143
163
  >
144
164
  <b>{title}</b>
145
165
  {deviceLabel && <span className="deck-widget-stats-device">{deviceLabel}</span>}
@@ -154,7 +174,7 @@ export class StatsWidget extends Widget<StatsWidgetProps> {
154
174
  }
155
175
 
156
176
  onRedraw(): void {
157
- if (!this.collapsed) {
177
+ if (this.getExpanded()) {
158
178
  const framesPerUpdate = Math.max(1, this.props.framesPerUpdate || 1);
159
179
  if (this._counter++ % framesPerUpdate === 0) {
160
180
  this.updateHTML();
@@ -182,9 +202,18 @@ export class StatsWidget extends Widget<StatsWidgetProps> {
182
202
  }
183
203
  }
184
204
 
185
- protected _toggleCollapsed = (): void => {
186
- this.collapsed = !this.collapsed;
187
- this.updateHTML();
205
+ protected _toggleExpanded = (): void => {
206
+ const nextExpanded = !this.getExpanded();
207
+
208
+ // Always call callback if provided
209
+ this.props.onExpandedChange?.(nextExpanded);
210
+
211
+ // Only update internal state if uncontrolled
212
+ if (this.props.expanded === undefined) {
213
+ this._expanded = nextExpanded;
214
+ this.updateHTML();
215
+ }
216
+ // In controlled mode, parent will update expanded prop which triggers updateHTML via setProps
188
217
  };
189
218
 
190
219
  protected _getFps = (): number => {
@@ -23,8 +23,19 @@ export type ThemeWidgetProps = WidgetProps & {
23
23
  darkModeLabel?: string;
24
24
  /** Styles for dark mode theme */
25
25
  darkModeTheme?: DeckWidgetTheme;
26
- /** Initial theme mode. 'auto' reads the browser default setting */
26
+ /** Initial theme mode for uncontrolled usage. 'auto' reads the browser default setting */
27
27
  initialThemeMode?: 'auto' | 'light' | 'dark';
28
+ /**
29
+ * Controlled theme mode. When provided, the widget is in controlled mode
30
+ * and this prop determines the current theme.
31
+ */
32
+ themeMode?: 'light' | 'dark';
33
+ /**
34
+ * Callback when the user clicks the theme toggle button.
35
+ * In controlled mode, use this to update the themeMode prop.
36
+ * In uncontrolled mode, this is called after the internal state updates.
37
+ */
38
+ onThemeModeChange?: (newMode: 'light' | 'dark') => void;
28
39
  };
29
40
 
30
41
  export class ThemeWidget extends Widget<ThemeWidgetProps> {
@@ -37,7 +48,9 @@ export class ThemeWidget extends Widget<ThemeWidgetProps> {
37
48
  lightModeTheme: LightGlassTheme,
38
49
  darkModeLabel: 'Dark Mode',
39
50
  darkModeTheme: DarkGlassTheme,
40
- initialThemeMode: 'auto'
51
+ initialThemeMode: 'auto',
52
+ themeMode: undefined!,
53
+ onThemeModeChange: () => {}
41
54
  };
42
55
 
43
56
  className = 'deck-widget-theme';
@@ -52,36 +65,44 @@ export class ThemeWidget extends Widget<ThemeWidgetProps> {
52
65
 
53
66
  // eslint-disable-next-line complexity
54
67
  setProps(props: Partial<ThemeWidgetProps>) {
55
- const {lightModeTheme, darkModeTheme} = this.props;
68
+ const {lightModeTheme, darkModeTheme, themeMode: prevThemeMode} = this.props;
56
69
  this.placement = props.placement ?? this.placement;
57
70
  this.viewId = props.viewId ?? this.viewId;
58
71
  super.setProps(props);
59
72
 
60
- switch (this.themeMode) {
73
+ const currentMode = this.getThemeMode();
74
+
75
+ // Handle controlled mode - apply theme when controlled prop changes
76
+ if (props.themeMode !== undefined && props.themeMode !== prevThemeMode) {
77
+ this._applyTheme(props.themeMode);
78
+ return;
79
+ }
80
+
81
+ switch (currentMode) {
61
82
  case 'light':
62
83
  if (props.lightModeTheme && !deepEqual(props.lightModeTheme, lightModeTheme, 1)) {
63
- this._setThemeMode('light');
84
+ this._applyTheme('light');
64
85
  }
65
86
  break;
66
87
  case 'dark':
67
88
  if (props.darkModeTheme && !deepEqual(props.darkModeTheme, darkModeTheme, 1)) {
68
- this._setThemeMode('dark');
89
+ this._applyTheme('dark');
69
90
  }
70
91
  break;
71
92
  default:
72
- log.warn(`Invalid theme mode ${this.themeMode}`)();
93
+ log.warn(`Invalid theme mode ${currentMode}`)();
73
94
  }
74
95
  }
75
96
 
76
97
  onRenderHTML(rootElement: HTMLElement): void {
77
98
  const {lightModeLabel, darkModeLabel} = this.props;
78
- // const onClick = useCallback(this._handleClick.bind(this), [this._handleClick]);
99
+ const currentMode = this.getThemeMode();
79
100
 
80
101
  render(
81
102
  <IconButton
82
103
  onClick={this._handleClick.bind(this)}
83
- label={this.themeMode === 'dark' ? darkModeLabel : lightModeLabel}
84
- className={this.themeMode === 'dark' ? 'deck-widget-moon' : 'deck-widget-sun'}
104
+ label={currentMode === 'dark' ? darkModeLabel : lightModeLabel}
105
+ className={currentMode === 'dark' ? 'deck-widget-moon' : 'deck-widget-sun'}
85
106
  />,
86
107
  rootElement
87
108
  );
@@ -89,24 +110,42 @@ export class ThemeWidget extends Widget<ThemeWidgetProps> {
89
110
 
90
111
  onAdd() {
91
112
  // Note: theme styling is applied in here onAdd() once DOM element is created
92
- this._setThemeMode(this.themeMode);
113
+ this._applyTheme(this.getThemeMode());
114
+ }
115
+
116
+ /**
117
+ * Returns the current theme mode.
118
+ * In controlled mode, returns the themeMode prop.
119
+ * In uncontrolled mode, returns the internal state.
120
+ */
121
+ getThemeMode(): 'light' | 'dark' {
122
+ return this.props.themeMode ?? this.themeMode;
93
123
  }
94
124
 
95
125
  _handleClick() {
96
- const newThemeMode = this.themeMode === 'dark' ? 'light' : 'dark';
97
- this._setThemeMode(newThemeMode);
126
+ const currentMode = this.getThemeMode();
127
+ const nextMode = currentMode === 'dark' ? 'light' : 'dark';
128
+
129
+ // Always call callback if provided
130
+ this.props.onThemeModeChange?.(nextMode);
131
+
132
+ // Only update internal state if uncontrolled
133
+ if (this.props.themeMode === undefined) {
134
+ this.themeMode = nextMode;
135
+ this._applyTheme(nextMode);
136
+ }
137
+ // In controlled mode, parent will update themeMode prop which triggers _applyTheme via setProps
98
138
  }
99
139
 
100
- _setThemeMode(themeMode: 'light' | 'dark') {
101
- this.themeMode = themeMode;
140
+ /** Apply theme styling without changing internal state */
141
+ _applyTheme(themeMode: 'light' | 'dark') {
102
142
  const container = this.rootElement?.closest<HTMLDivElement>('.deck-widget-container');
103
143
  if (container) {
104
144
  const themeStyle =
105
145
  themeMode === 'dark' ? this.props.darkModeTheme : this.props.lightModeTheme;
106
146
  applyStyles(container, themeStyle);
107
147
 
108
- const label =
109
- this.themeMode === 'dark' ? this.props.darkModeLabel : this.props.lightModeLabel;
148
+ const label = themeMode === 'dark' ? this.props.darkModeLabel : this.props.lightModeLabel;
110
149
  log.log(1, `Switched theme to ${label}`, themeStyle)();
111
150
 
112
151
  this.updateHTML();
@@ -21,11 +21,16 @@ export type TimelineWidgetProps = WidgetProps & {
21
21
  * @default 1
22
22
  */
23
23
  step?: number;
24
- /** Initial slider value.
24
+ /** Initial slider value for uncontrolled usage.
25
25
  * @default `timeRange[0]`
26
26
  */
27
27
  initialTime?: number;
28
- /** Callback when value changes. */
28
+ /**
29
+ * Controlled time value. When provided, the widget is in controlled mode
30
+ * for the time slider.
31
+ */
32
+ time?: number;
33
+ /** Callback when time value changes (via slider or playback). */
29
34
  onTimeChange?: (value: number) => void;
30
35
  /** Start playing automatically
31
36
  * @default false
@@ -39,6 +44,16 @@ export type TimelineWidgetProps = WidgetProps & {
39
44
  * @default 1000
40
45
  */
41
46
  playInterval?: number;
47
+ /**
48
+ * Controlled playing state. When provided, the widget is in controlled mode
49
+ * for play/pause.
50
+ */
51
+ playing?: boolean;
52
+ /**
53
+ * Callback when play/pause button is clicked.
54
+ * In controlled mode, use this to update the playing prop.
55
+ */
56
+ onPlayingChange?: (playing: boolean) => void;
42
57
  /** Callback to get label from time value */
43
58
  formatLabel?: (value: number) => string;
44
59
  };
@@ -48,10 +63,28 @@ export class TimelineWidget extends Widget<TimelineWidgetProps> {
48
63
  className = 'deck-widget-timeline';
49
64
  placement: WidgetPlacement = 'fill';
50
65
 
51
- private playing = false;
66
+ private _playing = false;
52
67
  private timerId: number | null = null;
53
68
  currentTime: number;
54
69
 
70
+ /**
71
+ * Returns the current time value.
72
+ * In controlled mode, returns the time prop.
73
+ * In uncontrolled mode, returns the internal state.
74
+ */
75
+ getTime(): number {
76
+ return this.props.time ?? this.currentTime;
77
+ }
78
+
79
+ /**
80
+ * Returns the current playing state.
81
+ * In controlled mode, returns the playing prop.
82
+ * In uncontrolled mode, returns the internal state.
83
+ */
84
+ getPlaying(): boolean {
85
+ return this.props.playing ?? this._playing;
86
+ }
87
+
55
88
  static defaultProps: Required<TimelineWidgetProps> = {
56
89
  ...Widget.defaultProps,
57
90
  id: 'timeline',
@@ -61,29 +94,56 @@ export class TimelineWidget extends Widget<TimelineWidgetProps> {
61
94
  timeRange: [0, 100],
62
95
  step: 1,
63
96
  initialTime: undefined!,
97
+ time: undefined!,
64
98
  onTimeChange: () => {},
65
99
  autoPlay: false,
66
100
  loop: false,
67
101
  playInterval: 1000,
102
+ playing: undefined!,
103
+ onPlayingChange: () => {},
68
104
  formatLabel: String
69
105
  };
70
106
 
71
107
  constructor(props: TimelineWidgetProps = {}) {
72
108
  super(props);
73
109
  this.currentTime = this.props.initialTime ?? this.props.timeRange[0];
74
- this.props.timeline?.setTime(this.currentTime);
110
+ // In controlled mode, sync Timeline to the controlled time prop
111
+ const syncTime = this.props.time ?? this.currentTime;
112
+ this.props.timeline?.setTime(syncTime);
75
113
  this.setProps(this.props);
76
114
  }
77
115
 
78
116
  setProps(props: Partial<TimelineWidgetProps>): void {
117
+ const {playing: prevPlaying, time: prevTime} = this.props;
79
118
  this.viewId = props.viewId ?? this.viewId;
80
119
  super.setProps(props);
120
+
121
+ // Sync Timeline object when controlled time prop changes
122
+ if (props.time !== undefined && props.time !== prevTime) {
123
+ this.props.timeline?.setTime(props.time);
124
+ }
125
+
126
+ // Handle controlled playing state changes
127
+ if (props.playing !== undefined && props.playing !== prevPlaying) {
128
+ if (props.playing && !this._playing) {
129
+ this._startTimer();
130
+ } else if (!props.playing && this._playing) {
131
+ this._stopTimer();
132
+ }
133
+ }
81
134
  }
82
135
 
83
136
  onAdd(): void {
84
- this.playing = false;
137
+ this._playing = false;
85
138
  this.timerId = null;
86
- if (this.props.autoPlay) this.play();
139
+ if (this.props.autoPlay) {
140
+ if (this.props.playing !== undefined) {
141
+ // In controlled mode, notify parent instead of starting directly
142
+ this.props.onPlayingChange?.(true);
143
+ } else {
144
+ this.play();
145
+ }
146
+ }
87
147
  }
88
148
 
89
149
  onRemove(): void {
@@ -92,13 +152,14 @@ export class TimelineWidget extends Widget<TimelineWidgetProps> {
92
152
 
93
153
  onRenderHTML(rootElement: HTMLElement): void {
94
154
  const {timeRange, step, formatLabel} = this.props;
95
- const currentTime = this.currentTime;
155
+ const isPlaying = this.getPlaying();
156
+ const currentTime = this.getTime();
96
157
 
97
158
  rootElement.dataset.placement = this.props.placement;
98
159
 
99
160
  render(
100
161
  <div className="deck-widget-button-group">
101
- {this.playing ? (
162
+ {isPlaying ? (
102
163
  <IconButton
103
164
  label="Pause"
104
165
  className="deck-widget-timeline-pause"
@@ -135,40 +196,69 @@ export class TimelineWidget extends Widget<TimelineWidgetProps> {
135
196
  }
136
197
 
137
198
  private handlePlayPause = (): void => {
138
- if (this.playing) {
139
- this.stop();
140
- } else {
141
- this.play();
199
+ const isPlaying = this.getPlaying();
200
+ const nextPlaying = !isPlaying;
201
+
202
+ // Always call callback if provided
203
+ this.props.onPlayingChange?.(nextPlaying);
204
+
205
+ // Only update internal state if uncontrolled
206
+ if (this.props.playing === undefined) {
207
+ if (nextPlaying) {
208
+ this.play();
209
+ } else {
210
+ this.stop();
211
+ }
142
212
  }
213
+ // In controlled mode, parent will update playing prop which triggers start/stop via setProps
143
214
  };
144
215
 
145
216
  private handleTimeChange = ([value]: [number, number]): void => {
146
- this.currentTime = value;
147
- this.props.timeline?.setTime(value);
217
+ // Always call callback
148
218
  this.props.onTimeChange(value);
149
- this.updateHTML();
219
+
220
+ // Only update internal state if uncontrolled
221
+ if (this.props.time === undefined) {
222
+ this.currentTime = value;
223
+ this.props.timeline?.setTime(value);
224
+ this.updateHTML();
225
+ }
226
+ // In controlled mode, parent will update time prop which triggers updateHTML via setProps
150
227
  };
151
228
 
152
229
  public play(): void {
153
- this.playing = true;
230
+ this._playing = true;
154
231
  const {
155
232
  timeRange: [min, max]
156
233
  } = this.props;
157
- if (this.currentTime >= max) {
234
+ // In uncontrolled mode, reset to start if at end
235
+ if (this.props.time === undefined && this.getTime() >= max) {
158
236
  this.currentTime = min;
159
237
  this.props.onTimeChange(min);
238
+ this.props.timeline?.setTime(min);
160
239
  }
161
240
  this.updateHTML();
162
241
  this.tick();
163
242
  }
164
243
 
165
244
  public stop(): void {
166
- this.playing = false;
245
+ this._stopTimer();
246
+ this.updateHTML();
247
+ }
248
+
249
+ /** Start the playback timer (used internally) */
250
+ private _startTimer(): void {
251
+ this._playing = true;
252
+ this.tick();
253
+ }
254
+
255
+ /** Stop the playback timer (used internally) */
256
+ private _stopTimer(): void {
257
+ this._playing = false;
167
258
  if (this.timerId !== null) {
168
259
  window.clearTimeout(this.timerId);
169
260
  this.timerId = null;
170
261
  }
171
- this.updateHTML();
172
262
  }
173
263
 
174
264
  private tick = (): void => {
@@ -178,22 +268,31 @@ export class TimelineWidget extends Widget<TimelineWidgetProps> {
178
268
  loop
179
269
  } = this.props;
180
270
  if (step > 0) {
181
- let next = Math.round(this.currentTime / step) * step + step;
271
+ const currentTime = this.getTime();
272
+ let next = Math.round(currentTime / step) * step + step;
182
273
  if (next > max) {
183
- if (this.currentTime < max) {
274
+ if (currentTime < max) {
184
275
  next = max;
185
276
  } else if (loop) {
186
277
  next = min;
187
278
  } else {
188
279
  next = max;
189
- this.playing = false;
280
+ this._playing = false;
281
+ this.props.onPlayingChange?.(false);
190
282
  }
191
283
  }
192
- this.currentTime = next;
284
+
285
+ // Always call callback
193
286
  this.props.onTimeChange(next);
287
+
288
+ // Only update internal state if uncontrolled
289
+ if (this.props.time === undefined) {
290
+ this.currentTime = next;
291
+ this.props.timeline?.setTime(next);
292
+ }
194
293
  this.updateHTML();
195
294
  }
196
- if (this.playing) {
295
+ if (this._playing) {
197
296
  this.timerId = window.setTimeout(this.tick, this.props.playInterval);
198
297
  } else {
199
298
  this.timerId = null;