Skip to content

Commit cf14990

Browse files
authored
[feat] ViewSelectorWidget (#9591)
1 parent a1300ab commit cf14990

File tree

5 files changed

+224
-6
lines changed

5 files changed

+224
-6
lines changed

modules/widgets/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export {ThemeWidget as _ThemeWidget} from './theme-widget';
1919
export {InfoWidget as _InfoWidget} from './info-widget';
2020
export {SplitterWidget as _SplitterWidget} from './splitter-widget';
2121
export {TimelineWidget as _TimelineWidget} from './timeline-widget';
22+
export {ViewSelectorWidget as _ViewSelectorWidget} from './view-selector-widget';
2223

2324
export type {FullscreenWidgetProps} from './fullscreen-widget';
2425
export type {CompassWidgetProps} from './compass-widget';
@@ -32,6 +33,7 @@ export type {ThemeWidgetProps} from './theme-widget';
3233
export type {InfoWidgetProps} from './info-widget';
3334
export type {SplitterWidgetProps} from './splitter-widget';
3435
export type {TimelineWidgetProps} from './timeline-widget';
36+
export type {ViewSelectorWidgetProps} from './view-selector-widget';
3537

3638
export {IconButton, ButtonGroup, GroupedIconButton} from './lib/components';
3739

modules/widgets/src/info-widget.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/* global document */
22
import {Widget, WidgetProps} from '@deck.gl/core';
33
import type {Deck, PickingInfo, Viewport} from '@deck.gl/core';
4-
import {render} from 'preact';
4+
import {render, JSX} from 'preact';
55

66
export type InfoWidgetProps = WidgetProps & {
77
/** View to attach to and interact with. Required when using multiple views. */
@@ -133,7 +133,7 @@ export class InfoWidget extends Widget<InfoWidgetProps> {
133133
padding: '10px',
134134
position: 'relative',
135135
// Include any additional styles
136-
...(this.props.style as React.CSSProperties)
136+
...(this.props.style as JSX.CSSProperties)
137137
}}
138138
>
139139
{this.props.text}

modules/widgets/src/lib/components.tsx

Lines changed: 108 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,20 @@
22
// SPDX-License-Identifier: MIT
33
// Copyright (c) vis.gl contributors
44

5+
import type {ComponentChildren, JSX} from 'preact';
6+
import {useState, useRef, useEffect} from 'preact/hooks';
7+
58
export type IconButtonProps = {
69
className: string;
710
label: string;
811
onClick: (event?) => unknown;
12+
/** Optional icon or element to render inside the button */
13+
children?: ComponentChildren;
914
};
1015

16+
/** Renders a button component with widget CSS */
1117
export const IconButton = (props: IconButtonProps) => {
12-
const {className, label, onClick} = props;
18+
const {className, label, onClick, children} = props;
1319
return (
1420
<div className="deck-widget-button">
1521
<button
@@ -18,17 +24,30 @@ export const IconButton = (props: IconButtonProps) => {
1824
onClick={onClick}
1925
title={label}
2026
>
21-
<div className="deck-widget-icon" />
27+
{children ? children : <div className="deck-widget-icon" />}
2228
</button>
2329
</div>
2430
);
2531
};
2632

27-
export const ButtonGroup = props => {
33+
export type ButtonGroupProps = {
34+
children;
35+
orientation;
36+
};
37+
38+
/** Renders a group of buttons with Widget CSS */
39+
export const ButtonGroup = (props: ButtonGroupProps) => {
2840
const {children, orientation} = props;
2941
return <div className={`deck-widget-button-group ${orientation}`}>{children}</div>;
3042
};
3143

44+
export type GroupedIconButtonProps = {
45+
className;
46+
label;
47+
onClick;
48+
};
49+
50+
/** Renders an icon button as part of a ButtonGroup */
3251
export const GroupedIconButton = props => {
3352
const {className, label, onClick} = props;
3453
return (
@@ -42,3 +61,89 @@ export const GroupedIconButton = props => {
4261
</button>
4362
);
4463
};
64+
65+
const MENU_STYLE: JSX.CSSProperties = {
66+
position: 'absolute',
67+
top: '100%',
68+
left: 0,
69+
background: 'white',
70+
border: '1px solid #ccc',
71+
borderRadius: '4px',
72+
marginTop: '4px',
73+
zIndex: 100
74+
};
75+
76+
const MENU_ITEM_STYLE: JSX.CSSProperties = {
77+
background: 'white',
78+
border: 'none',
79+
padding: '4px',
80+
cursor: 'pointer',
81+
pointerEvents: 'auto'
82+
};
83+
84+
export type MenuProps<KeyType = string> = {
85+
className: string;
86+
icon?: () => JSX.Element;
87+
label?: string;
88+
menuItems: {value: KeyType; icon: () => JSX.Element; label: string}[];
89+
initialItem: KeyType;
90+
onSelect: (item: KeyType) => void;
91+
};
92+
93+
/** A component that renders the popup menu for view mode selection. */
94+
export function Menu<KeyType extends string>(props: MenuProps<KeyType>) {
95+
const [menuOpen, setMenuOpen] = useState(false);
96+
const containerRef = useRef<HTMLDivElement>(null);
97+
98+
// Close the menu when clicking outside.
99+
const handleClickOutside = (event: MouseEvent) => {
100+
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
101+
setMenuOpen(false);
102+
}
103+
};
104+
105+
useEffect(() => {
106+
document.addEventListener('mousedown', handleClickOutside);
107+
return () => {
108+
document.removeEventListener('mousedown', handleClickOutside);
109+
};
110+
}, [containerRef]);
111+
112+
const [selectedItem, setSelectedItem] = useState<KeyType>(props.initialItem);
113+
114+
const handleSelectItem = (item: KeyType) => {
115+
setSelectedItem(item);
116+
setMenuOpen(false);
117+
props.onSelect(item);
118+
};
119+
120+
const handleButtonClick = () => setMenuOpen(!menuOpen);
121+
122+
const selectedMenuItem = props.menuItems.find(item => item.value === selectedItem);
123+
const label = props.label || selectedMenuItem?.label || '';
124+
const Icon = (props.icon || selectedMenuItem?.icon)!;
125+
126+
return (
127+
<div style={{position: 'relative', display: 'inline-block'}} ref={containerRef}>
128+
<IconButton className={props.className} label={label} onClick={handleButtonClick}>
129+
<Icon />
130+
</IconButton>
131+
{menuOpen && (
132+
<div style={MENU_STYLE}>
133+
<div style={{display: 'flex', flexDirection: 'column'}}>
134+
{props.menuItems.map(item => (
135+
<button
136+
key={item.value}
137+
style={MENU_ITEM_STYLE}
138+
title={item.label}
139+
onClick={() => handleSelectItem(item.value)}
140+
>
141+
{item.icon}
142+
</button>
143+
))}
144+
</div>
145+
</div>
146+
)}
147+
</div>
148+
);
149+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
// ViewSelectorWidget.tsx
2+
// deck.gl
3+
// SPDX-License-Identifier: MIT
4+
// Copyright (c) vis.gl contributors
5+
6+
import {render, JSX} from 'preact';
7+
import {Widget, type WidgetProps, type WidgetPlacement} from '@deck.gl/core';
8+
import {Menu} from './lib/components';
9+
import {h} from 'preact';
10+
11+
/** The available view modes */
12+
export type ViewMode = 'single' | 'split-horizontal' | 'split-vertical';
13+
14+
/** Properties for the ViewSelectorWidget */
15+
export type ViewSelectorWidgetProps = WidgetProps & {
16+
/** Widget positioning within the view. Default 'top-left'. */
17+
placement?: WidgetPlacement;
18+
/** Tooltip label */
19+
label?: string;
20+
/** The initial view mode. Defaults to 'single'. */
21+
initialViewMode?: ViewMode;
22+
/** Callback invoked when the view mode changes */
23+
onViewModeChange?: (mode: ViewMode) => void;
24+
};
25+
26+
/**
27+
* A widget that renders a popup menu for selecting a view mode.
28+
* It displays a button with the current view mode icon. Clicking the button
29+
* toggles a popup that shows three icons for:
30+
* - Single view
31+
* - Two views, split horizontally
32+
* - Two views, split vertically
33+
*/
34+
export class ViewSelectorWidget extends Widget<ViewSelectorWidgetProps> {
35+
static defaultProps: Required<ViewSelectorWidgetProps> = {
36+
...Widget.defaultProps,
37+
id: 'view-selector-widget',
38+
placement: 'top-left',
39+
label: 'Split View',
40+
initialViewMode: 'single',
41+
// eslint-disable-next-line no-console
42+
onViewModeChange: (viewMode: string) => {
43+
console.log(viewMode);
44+
}
45+
};
46+
47+
className = 'deck-widget-view-selector';
48+
placement: WidgetPlacement = 'top-left';
49+
viewMode: ViewMode;
50+
51+
constructor(props: ViewSelectorWidgetProps = {}) {
52+
super(props, ViewSelectorWidget.defaultProps);
53+
this.placement = this.props.placement;
54+
this.viewMode = this.props.initialViewMode;
55+
}
56+
57+
setProps(props: Partial<ViewSelectorWidgetProps>) {
58+
super.setProps(props);
59+
this.placement = props.placement ?? this.placement;
60+
}
61+
62+
onRenderHTML(rootElement: HTMLElement) {
63+
render(
64+
<Menu<ViewMode>
65+
className="deck-widget-view-selector"
66+
menuItems={MENU_ITEMS}
67+
initialItem={this.props.initialViewMode}
68+
onSelect={this.handleSelectMode}
69+
/>,
70+
rootElement
71+
);
72+
}
73+
74+
handleSelectMode = (viewMode: ViewMode) => {
75+
this.viewMode = viewMode;
76+
this.updateHTML();
77+
};
78+
}
79+
80+
// Define common icon style.
81+
const ICON_STYLE = {width: '24px', height: '24px'};
82+
83+
// Define inline SVG icons for each view mode.
84+
const ICONS: Record<ViewMode, () => JSX.Element> = {
85+
single: () => (
86+
<svg width="24" height="24" style={ICON_STYLE}>
87+
<rect x="4" y="4" width="16" height="16" stroke="black" fill="none" strokeWidth="2" />
88+
</svg>
89+
),
90+
'split-horizontal': () => (
91+
<svg width="24" height="24" style={ICON_STYLE}>
92+
<rect x="4" y="4" width="16" height="7" stroke="black" fill="none" strokeWidth="2" />
93+
<rect x="4" y="13" width="16" height="7" stroke="black" fill="none" strokeWidth="2" />
94+
</svg>
95+
),
96+
'split-vertical': () => (
97+
<svg width="24" height="24" style={ICON_STYLE}>
98+
<rect x="4" y="4" width="7" height="16" stroke="black" fill="none" strokeWidth="2" />
99+
<rect x="13" y="4" width="7" height="16" stroke="black" fill="none" strokeWidth="2" />
100+
</svg>
101+
)
102+
};
103+
104+
// Define menu items for the popup menu.
105+
const MENU_ITEMS: Array<{value: ViewMode; icon: () => JSX.Element; label: string}> = [
106+
{value: 'single', icon: ICONS.single, label: 'Single View'},
107+
{value: 'split-horizontal', icon: ICONS['split-horizontal'], label: 'Split Horizontal'},
108+
{value: 'split-vertical', icon: ICONS['split-vertical'], label: 'Split Vertical'}
109+
];

test/apps/widgets-example-9.2/app.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ import {
1717
_InfoWidget,
1818
_InfoWidget,
1919
_SplitterWidget,
20-
_TimelineWidget
20+
_TimelineWidget,
21+
_ViewSelectorWidget
2122
} from '@deck.gl/widgets';
2223
import '@deck.gl/widgets/stylesheet.css';
2324

@@ -113,6 +114,7 @@ const deck = new Deck({
113114
// eslint-disable-next-line no-console, no-undef
114115
onTimeChange: time => console.log('Time:', time)
115116
}),
117+
new _ViewSelectorWidget(),
116118
new _SplitterWidget({
117119
viewId1: 'left-map',
118120
viewId2: 'right-map',

0 commit comments

Comments
 (0)