diff --git a/geemap/core.py b/geemap/core.py
index 62027a3091..fb5599901b 100644
--- a/geemap/core.py
+++ b/geemap/core.py
@@ -673,8 +673,14 @@ def _layer_manager(self) -> Optional[map_widgets.LayerManager]:
Optional[map_widgets.LayerManager]: The layer manager widget if found, else None.
"""
if toolbar_widget := self._toolbar:
- if isinstance(toolbar_widget.accessory_widget, map_widgets.LayerManager):
- return toolbar_widget.accessory_widget
+ return next(
+ (
+ widget
+ for widget in toolbar_widget.accessory_widgets
+ if isinstance(widget, map_widgets.LayerManager)
+ ),
+ None,
+ )
return self._find_widget_of_type(map_widgets.LayerManager)
@property
@@ -917,25 +923,6 @@ def add(self, obj: Any, position: str = "", **kwargs: Any) -> None:
else:
super().add(obj)
- def _on_toggle_toolbar_layers(self, is_open: bool) -> None:
- """Handles the toggle event for the toolbar layers.
-
- Args:
- is_open (bool): Whether the toolbar layers are open.
- """
- if is_open:
- if self._layer_manager:
- return
-
- layer_manager = map_widgets.LayerManager(self)
- layer_manager.header_hidden = True
- layer_manager.close_button_hidden = True
- layer_manager.refresh_layers()
- self._toolbar.accessory_widget = layer_manager
- else:
- self._toolbar.accessory_widget = None
- self.remove("layer_manager")
-
def _add_layer_manager(self, position: str, **kwargs: Any) -> None:
"""Adds a layer manager to the map.
@@ -943,9 +930,6 @@ def _add_layer_manager(self, position: str, **kwargs: Any) -> None:
position (str): The position to place the layer manager.
**kwargs (Any): Additional keyword arguments.
"""
- if self._layer_manager:
- return
-
layer_manager = map_widgets.LayerManager(self, **kwargs)
layer_manager.on_close = lambda: self.remove("layer_manager")
layer_manager.refresh_layers()
@@ -964,16 +948,21 @@ def _add_toolbar(self, position: str, **kwargs: Any) -> None:
if self._toolbar:
return
+ layer_manager = map_widgets.LayerManager(self)
+ layer_manager.header_hidden = True
+ layer_manager.close_button_hidden = True
+ layer_manager.refresh_layers()
+
toolbar_val = toolbar.Toolbar(
- self, self._toolbar_main_tools(), self._toolbar_extra_tools(), **kwargs
+ self,
+ self._toolbar_main_tools(),
+ self._toolbar_extra_tools(),
+ [layer_manager],
)
- toolbar_val.on_layers_toggled = self._on_toggle_toolbar_layers
toolbar_control = ipyleaflet.WidgetControl(
widget=toolbar_val, position=position
)
super().add(toolbar_control)
- # Enable the layer manager by default.
- toolbar_val.toggle_layers(True)
def _add_inspector(self, position: str, **kwargs: Any) -> None:
"""Adds an inspector to the map.
@@ -1255,65 +1244,66 @@ def _add_colorbar(
return control
def _open_help_page(
- self, host_map: "MapInterface", selected: bool, item: toolbar.Toolbar.Item
+ self, host_map: "MapInterface", selected: bool, item: toolbar.ToolbarItem
) -> None:
"""Opens the help page.
Args:
host_map (MapInterface): The host map.
selected (bool): Whether the item is selected.
- item (toolbar.Toolbar.Item): The toolbar item.
+ item (toolbar.ToolbarItem): The toolbar item.
"""
del host_map, item # Unused.
if selected:
coreutils.open_url("https://geemap.org")
- def _toolbar_main_tools(self) -> List[toolbar.Toolbar.Item]:
+ def _toolbar_main_tools(self) -> List[toolbar.ToolbarItem]:
"""Gets the main tools for the toolbar.
Returns:
- List[toolbar.Toolbar.Item]: The main tools for the toolbar.
+ List[toolbar.ToolbarItem]: The main tools for the toolbar.
"""
@toolbar._cleanup_toolbar_item
def inspector_tool_callback(
- map: Map, selected: bool, item: toolbar.Toolbar.Item
+ map: Map, selected: bool, item: toolbar.ToolbarItem
):
del selected, item # Unused.
map.add("inspector")
return map._inspector
@toolbar._cleanup_toolbar_item
- def basemap_tool_callback(map: Map, selected: bool, item: toolbar.Toolbar.Item):
+ def basemap_tool_callback(map: Map, selected: bool, item: toolbar.ToolbarItem):
del selected, item # Unused.
map.add("basemap_selector")
return map._basemap_selector
return [
- toolbar.Toolbar.Item(
+ toolbar.ToolbarItem(
icon="map",
tooltip="Basemap selector",
callback=basemap_tool_callback,
- reset=False,
),
- toolbar.Toolbar.Item(
+ toolbar.ToolbarItem(
icon="info",
tooltip="Inspector",
callback=inspector_tool_callback,
- reset=False,
),
- toolbar.Toolbar.Item(
- icon="question", tooltip="Get help", callback=self._open_help_page
+ toolbar.ToolbarItem(
+ icon="question_mark",
+ tooltip="Get help",
+ callback=self._open_help_page,
+ reset=True,
),
]
- def _toolbar_extra_tools(self) -> Optional[List[toolbar.Toolbar.Item]]:
+ def _toolbar_extra_tools(self) -> List[toolbar.ToolbarItem]:
"""Gets the extra tools for the toolbar.
Returns:
- Optional[List[toolbar.Toolbar.Item]]: The extra tools for the toolbar.
+ List[toolbar.ToolbarItem]: The extra tools for the toolbar.
"""
- return None
+ return []
def _control_config(self) -> Dict[str, List[str]]:
"""Gets the control configuration.
diff --git a/geemap/toolbar.py b/geemap/toolbar.py
index 0415ddf1ef..dda9d6f9e8 100644
--- a/geemap/toolbar.py
+++ b/geemap/toolbar.py
@@ -9,12 +9,16 @@
# *******************************************************************************#
import os
+import pathlib
from dataclasses import dataclass
+
+import anywidget
import ee
import ipyevents
import ipyleaflet
import ipywidgets as widgets
+import traitlets
from ipyfilechooser import FileChooser
from IPython.core.display import display
@@ -27,211 +31,137 @@
@map_widgets.Theme.apply
-class Toolbar(widgets.VBox):
- """A toolbar that can be added to the map."""
-
- @dataclass
- class Item:
- """A representation of an item in the toolbar.
+class ToolbarItem(anywidget.AnyWidget):
+ """A toolbar item widget for geemap."""
+
+ _esm = pathlib.Path(__file__).parent / "static" / "toolbar_item.js"
+ active = traitlets.Bool(False).tag(sync=True)
+ icon = traitlets.Unicode("").tag(sync=True)
+ # Unfortunately, "tooltip" is a property already defined on ipywidgets.
+ tooltip_text = traitlets.Unicode("").tag(sync=True)
+
+ def __init__(
+ self,
+ icon: str,
+ tooltip: str,
+ callback: Callable[[Any, bool, Any], None],
+ control: Optional[widgets.Widget] = None,
+ reset=False,
+ active=False,
+ ):
+ """A togglable, toolbar item.
- Attributes:
- icon: The icon to use for the item, from https://fontawesome.com/icons.
+ Args:
+ icon (str): The icon name to use, from https://fonts.google.com/icons.
tooltip: The tooltip text to show a user on hover.
callback: A callback function to execute when the item icon is clicked.
Its signature should be `callback(map, selected, item)`, where
`map` is the host map, `selected` is a boolean indicating if the
user selected or unselected the tool, and `item` is this object.
- reset: Whether to reset the selection after the callback has finished.
control: The control widget associated with this item. Used to
cleanup state when toggled off.
- toggle_button: The toggle button controlling the item.
+ reset: Whether to reset the selection after the callback has finished.
+ active: Whether the tool is currently active.
"""
+ super().__init__()
+ self.icon = icon
+ self.tooltip_text = tooltip
+ self.callback = callback
+ self.callback_wrapper = lambda *args: None
+ self.control = control
+ self.reset = reset
+ self.active = active
+
+ def toggle_off(self):
+ if self.active:
+ self.active = False
+
+ @traitlets.observe("active")
+ def _observe_value(self, change: Dict[str, Any]) -> None:
+ if (value := change.get("new")) is not None:
+ self.callback_wrapper(self.callback, value, self)
+ if self.active and self.reset:
+ self.active = False
- icon: str
- tooltip: str
- callback: Callable[[Any, bool, Any], None]
- reset: bool = True
- control: Optional[widgets.Widget] = None
- toggle_button: Optional[widgets.ToggleButton] = None
-
- def toggle_off(self):
- if self.toggle_button:
- self.toggle_button.value = False
- ICON_WIDTH = "32px"
- ICON_HEIGHT = "32px"
- NUM_COLS = 3
-
- _TOGGLE_TOOL_EXPAND_ICON = "plus"
- _TOGGLE_TOOL_EXPAND_TOOLTIP = "Expand toolbar"
- _TOGGLE_TOOL_COLLAPSE_ICON = "minus"
- _TOGGLE_TOOL_COLLAPSE_TOOLTIP = "Collapse toolbar"
+@map_widgets.Theme.apply
+class Toolbar(anywidget.AnyWidget):
+ """A toolbar that can be added to the map."""
- def __init__(self, host_map, main_tools, extra_tools=None):
+ _esm = pathlib.Path(__file__).parent / "static" / "toolbar.js"
+
+ # The accessory widget.
+ accessory_widgets = map_widgets.TypedTuple(
+ trait=traitlets.Instance(widgets.Widget),
+ help="The accessory widget",
+ ).tag(sync=True, **widgets.widget_serialization)
+
+ # The list of main tools.
+ main_tools = map_widgets.TypedTuple(
+ trait=traitlets.Instance(widgets.Widget),
+ help="List of main tools",
+ ).tag(sync=True, **widgets.widget_serialization)
+
+ # The list of extra tools.
+ extra_tools = map_widgets.TypedTuple(
+ trait=traitlets.Instance(widgets.Widget),
+ help="List of extra tools",
+ ).tag(sync=True, **widgets.widget_serialization)
+
+ # Whether the toolbar is expanded.
+ expanded = traitlets.Bool(False).tag(sync=True)
+
+ # The currently selected tab.
+ tab_index = traitlets.Int(0).tag(sync=True)
+
+ _TOGGLE_EXPAND_ICON = "add"
+ _TOGGLE_EXPAND_TOOLTIP = "Expand toolbar"
+ _TOGGLE_COLLAPSE_ICON = "remove"
+ _TOGGLE_COLLAPSE_TOOLTIP = "Collapse toolbar"
+
+ def __init__(
+ self,
+ host_map: "geemap.Map",
+ main_tools: List[ToolbarItem],
+ extra_tools: List[ToolbarItem],
+ accessory_widgets: List[widgets.DOMWidget],
+ ):
"""Adds a toolbar with `main_tools` and `extra_tools` to the `host_map`."""
+ super().__init__()
if not main_tools:
raise ValueError("A toolbar cannot be initialized without `main_tools`.")
self.host_map = host_map
- self.toggle_tool = Toolbar.Item(
- icon=self._TOGGLE_TOOL_EXPAND_ICON,
- tooltip=self._TOGGLE_TOOL_EXPAND_TOOLTIP,
+ self.toggle_widget = ToolbarItem(
+ icon=self._TOGGLE_EXPAND_ICON,
+ tooltip=self._TOGGLE_EXPAND_TOOLTIP,
callback=self._toggle_callback,
+ reset=True,
)
- self.on_layers_toggled = None
- self._accessory_widget = None
-
- if extra_tools:
- all_tools = main_tools + [self.toggle_tool] + extra_tools
- else:
- all_tools = main_tools
- icons = [tool.icon for tool in all_tools]
- tooltips = [tool.tooltip for tool in all_tools]
- callbacks = [tool.callback for tool in all_tools]
- resets = [tool.reset for tool in all_tools]
- self.num_collapsed_tools = len(main_tools) + (1 if extra_tools else 0)
- # -(-a//b) is the same as math.ceil(a/b)
- self.num_rows_expanded = -(-len(all_tools) // self.NUM_COLS)
- self.num_rows_collapsed = -(-self.num_collapsed_tools // self.NUM_COLS)
-
- self.all_widgets = [
- widgets.ToggleButton(
- layout=widgets.Layout(
- width="auto", height="auto", padding="0px 0px 0px 4px"
- ),
- button_style="primary",
- icon=icons[i],
- tooltip=tooltips[i],
- )
- for i in range(len(all_tools))
- ]
- self.toggle_widget = self.all_widgets[len(main_tools)] if extra_tools else None
-
- # We start with a collapsed grid of just the main tools and the toggle one.
- self.grid = widgets.GridBox(
- children=self.all_widgets[: self.num_collapsed_tools],
- layout=widgets.Layout(
- width="109px",
- grid_template_columns=(self.ICON_WIDTH + " ") * self.NUM_COLS,
- grid_template_rows=(self.ICON_HEIGHT + " ") * self.num_rows_collapsed,
- grid_gap="1px 1px",
- padding="5px",
- ),
- )
-
- def curry_callback(callback, should_reset_after, widget, item):
- def returned_callback(change):
- if change["type"] != "change":
- return
- callback(self.host_map, change["new"], item)
- if should_reset_after:
- widget.value = False
-
- return returned_callback
-
- for id, widget in enumerate(self.all_widgets):
- all_tools[id].toggle_button = widget
- widget.observe(
- curry_callback(callbacks[id], resets[id], widget, all_tools[id]),
- "value",
- )
-
- self.toolbar_button = widgets.ToggleButton(
- value=False,
- tooltip="Toolbar",
- icon="wrench",
- layout=widgets.Layout(
- width="28px", height="28px", padding="0px 0px 0px 4px"
- ),
- )
-
- self.layers_button = widgets.ToggleButton(
- value=False,
- tooltip="Layers",
- icon="server",
- layout=widgets.Layout(height="28px", width="72px"),
- )
-
- self.toolbar_header = widgets.HBox(
- layout=widgets.Layout(
- display="flex", justify_content="flex-end", align_items="center"
+ self.main_tools = main_tools + ([self.toggle_widget] if extra_tools else [])
+ self.extra_tools = extra_tools
+ for widget in self.main_tools + self.extra_tools:
+ widget.callback_wrapper = lambda callback, value, tool: callback(
+ self.host_map, value, tool
)
- )
- self.toolbar_header.children = [self.layers_button, self.toolbar_button]
- self.toolbar_footer = widgets.VBox()
- self.toolbar_footer.children = [self.grid]
-
- self.toolbar_button.observe(self._toolbar_btn_click, "value")
- self.layers_button.observe(self._layers_btn_click, "value")
-
- super().__init__(children=[self.toolbar_header])
+ self.accessory_widgets = accessory_widgets
def reset(self):
"""Resets the toolbar so that no widget is selected."""
- for widget in self.all_widgets:
+ for widget in self.main_tools + self.extra_tools:
widget.value = False
- def toggle_layers(self, enabled):
- self.layers_button.value = enabled
- self.on_layers_toggled(enabled)
- if enabled:
- self.toolbar_button.value = False
-
- def _reset_others(self, current):
- for other in self.all_widgets:
- if other is not current:
- other.value = False
-
def _toggle_callback(self, m, selected, item):
del m, item # unused
if not selected:
return
- if self.toggle_widget.icon == self._TOGGLE_TOOL_EXPAND_ICON:
- self.grid.layout.grid_template_rows = (
- self.ICON_HEIGHT + " "
- ) * self.num_rows_expanded
- self.grid.children = self.all_widgets
- self.toggle_widget.tooltip = self._TOGGLE_TOOL_COLLAPSE_TOOLTIP
- self.toggle_widget.icon = self._TOGGLE_TOOL_COLLAPSE_ICON
- elif self.toggle_widget.icon == self._TOGGLE_TOOL_COLLAPSE_ICON:
- self.grid.layout.grid_template_rows = (
- self.ICON_HEIGHT + " "
- ) * self.num_rows_collapsed
- self.grid.children = self.all_widgets[: self.num_collapsed_tools]
- self.toggle_widget.tooltip = self._TOGGLE_TOOL_EXPAND_TOOLTIP
- self.toggle_widget.icon = self._TOGGLE_TOOL_EXPAND_ICON
-
- def _toolbar_btn_click(self, change):
- if change["new"]:
- self.layers_button.value = False
- self.children = [self.toolbar_header, self.toolbar_footer]
- else:
- if not self.layers_button.value:
- self.children = [self.toolbar_header]
-
- def _layers_btn_click(self, change):
- # Allow callbacks to set accessory_widget to prevent flicker on click.
- if self.on_layers_toggled:
- self.on_layers_toggled(change["new"])
- if change["new"]:
- self.toolbar_button.value = False
- self.children = [self.toolbar_header, self.toolbar_footer]
- else:
- if not self.toolbar_button.value:
- self.children = [self.toolbar_header]
-
- @property
- def accessory_widget(self):
- """A widget that temporarily replaces the tool grid."""
- return self._accessory_widget
-
- @accessory_widget.setter
- def accessory_widget(self, value):
- """Sets the widget that temporarily replaces the tool grid."""
- self._accessory_widget = value
- if self._accessory_widget:
- self.toolbar_footer.children = [self._accessory_widget]
- else:
- self.toolbar_footer.children = [self.grid]
+ if self.toggle_widget.icon == self._TOGGLE_EXPAND_ICON:
+ self.expanded = True
+ self.toggle_widget.tooltip_text = self._TOGGLE_COLLAPSE_TOOLTIP
+ self.toggle_widget.icon = self._TOGGLE_COLLAPSE_ICON
+ elif self.toggle_widget.icon == self._TOGGLE_COLLAPSE_ICON:
+ self.expanded = False
+ self.toggle_widget.tooltip_text = self._TOGGLE_EXPAND_TOOLTIP
+ self.toggle_widget.icon = self._TOGGLE_EXPAND_ICON
def inspector_gui(m=None):
@@ -608,7 +538,6 @@ def ee_plot_gui(m, position="topright", **kwargs):
m (object): geemap.Map.
position (str, optional): Position of the widget. Defaults to "topright".
"""
-
close_btn = widgets.Button(
icon="times",
tooltip="Close the plot widget",
@@ -722,7 +651,7 @@ def handle_interaction(**kwargs):
dict_values = dict(zip(b_names, [dict_values_tmp[b] for b in b_names]))
generate_chart(dict_values, latlon)
except Exception as e:
- if hasattr(m, "_plot_widget"):
+ if hasattr(m, "_plot_widget") and m._plot_widget is not None:
m._plot_widget.clear_output()
with m._plot_widget:
print("No data for the clicked location.")
@@ -4492,101 +4421,90 @@ def _cog_stac_inspector_callback(map, selected, item):
main_tools = [
- Toolbar.Item(
+ ToolbarItem(
icon="info",
tooltip="Inspector",
callback=_inspector_tool_callback,
- reset=False,
),
- Toolbar.Item(
- icon="bar-chart",
+ ToolbarItem(
+ icon="bar_chart",
tooltip="Plotting",
callback=_plotting_tool_callback,
- reset=False,
),
- Toolbar.Item(
- icon="globe",
+ ToolbarItem(
+ icon="history",
tooltip="Create timelapse",
callback=_timelapse_tool_callback,
- reset=False,
),
- Toolbar.Item(
+ ToolbarItem(
icon="map",
tooltip="Change basemap",
callback=_basemap_tool_callback,
- reset=False,
),
- Toolbar.Item(
- icon="retweet",
+ ToolbarItem(
+ icon="code",
tooltip="Convert Earth Engine JavaScript to Python",
callback=_convert_js_tool_callback,
- reset=False,
),
]
extra_tools = [
- Toolbar.Item(
- icon="eraser",
+ ToolbarItem(
+ icon="ink_eraser",
tooltip="Remove all drawn features",
callback=lambda m, selected, _: m.remove_drawn_features() if selected else None,
+ reset=True,
),
- Toolbar.Item(
- icon="folder-open",
+ ToolbarItem(
+ icon="upload",
tooltip="Open local vector/raster data",
callback=_open_data_tool_callback,
- reset=False,
),
- Toolbar.Item(
- icon="gears",
+ ToolbarItem(
+ icon="manufacturing",
tooltip="WhiteboxTools for local geoprocessing",
callback=_whitebox_tool_callback,
- reset=False,
),
- Toolbar.Item(
- icon="google",
+ ToolbarItem(
+ icon="dns",
tooltip="GEE Toolbox for cloud computing",
callback=_gee_toolbox_tool_callback,
- reset=False,
),
- Toolbar.Item(
- icon="fast-forward",
+ ToolbarItem(
+ icon="fast_forward",
tooltip="Activate timeslider",
callback=_time_slider_tool_callback,
- reset=False,
),
- Toolbar.Item(
- icon="hand-o-up",
+ ToolbarItem(
+ icon="pan_tool_alt",
tooltip="Collect training samples",
callback=_collect_samples_tool_callback,
- reset=False,
),
- Toolbar.Item(
- icon="line-chart",
+ ToolbarItem(
+ icon="show_chart",
tooltip="Creating and plotting transects",
callback=_plot_transect_tool_callback,
- reset=False,
),
- Toolbar.Item(
- icon="random",
+ ToolbarItem(
+ icon="multiline_chart",
tooltip="Sankey plots",
callback=_sankee_tool_callback,
- reset=False,
),
- Toolbar.Item(
- icon="adjust",
+ ToolbarItem(
+ icon="travel_explore",
tooltip="Planet imagery",
callback=_split_basemaps_tool_callback,
),
- Toolbar.Item(
- icon="info-circle",
+ ToolbarItem(
+ icon="target",
tooltip="Get COG/STAC pixel value",
callback=_cog_stac_inspector_callback,
- reset=False,
),
- Toolbar.Item(
- icon="question",
+ ToolbarItem(
+ icon="question_mark",
tooltip="Get help",
callback=_open_help_page_callback,
+ reset=True,
),
]
diff --git a/js/basemap_selector.ts b/js/basemap_selector.ts
index 6f38f0a66e..e873749a28 100644
--- a/js/basemap_selector.ts
+++ b/js/basemap_selector.ts
@@ -52,7 +52,7 @@ export class BasemapSelector extends LitWidget<
@property({ type: Array }) basemaps: string[] = [];
@property({ type: String }) value: string = "";
- @query('select') selectElement!: HTMLSelectElement;
+ @query('select') selectElement!: HTMLSelectElement|null;
render(): TemplateResult {
return html`
diff --git a/js/ipywidgets_styles.ts b/js/ipywidgets_styles.ts
index fdb8162293..cb6c215263 100644
--- a/js/ipywidgets_styles.ts
+++ b/js/ipywidgets_styles.ts
@@ -57,6 +57,24 @@ export const legacyStyles = css`
line-height: var(--jp-widgets-inline-height);
}
+ .legacy-button.active {
+ background-color: var(--colab-primary-surface-color, --jp-layout-color3);
+ color: var(--jp-ui-font-color1);
+ box-shadow: 0 4px 5px 0 rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),
+ 0 1px 10px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity)),
+ 0 2px 4px -1px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity));
+ }
+
+ .legacy-button.primary {
+ background-color: var(--jp-brand-color1);
+ color: var(--jp-ui-inverse-font-color1);
+ }
+
+ .legacy-button.primary.active {
+ background-color: var(--jp-brand-color0);
+ color: var(--jp-ui-inverse-font-color0);
+ }
+
.legacy-select {
-moz-appearance: none;
-webkit-appearance: none;
@@ -79,6 +97,5 @@ export const legacyStyles = css`
padding-left: calc(var(--jp-widgets-input-padding)* 2);
padding-right: 20px;
vertical-align: top;
-}
}
`;
diff --git a/js/layer_manager_row.ts b/js/layer_manager_row.ts
index bc2d4dbbca..33bcae4115 100644
--- a/js/layer_manager_row.ts
+++ b/js/layer_manager_row.ts
@@ -153,7 +153,7 @@ export class LayerManagerRow extends LitWidget<
class="legacy-button row-button settings-button"
@click="${this.onSettingsClicked}"
>
-
+ settings
diff --git a/js/tab_panel.ts b/js/tab_panel.ts
new file mode 100644
index 0000000000..e6458ccadf
--- /dev/null
+++ b/js/tab_panel.ts
@@ -0,0 +1,183 @@
+import { html, css, nothing, LitElement, PropertyValues } from "lit";
+import { property, queryAll, queryAssignedElements } from "lit/decorators.js";
+import { legacyStyles } from "./ipywidgets_styles";
+import { classMap } from "lit/directives/class-map.js";
+import { materialStyles } from "./styles";
+import { styleMap } from "lit/directives/style-map.js";
+
+function convertToId(name: string | undefined): string {
+ return (name || "").trim().replace(" ", "-").toLowerCase();
+}
+
+/** The various modes. */
+export enum TabMode {
+ ALWAYS_SHOW,
+ HIDE_ON_SECOND_CLICK,
+}
+
+/** The tab configuration, as a string or Material Icon. */
+export interface Tab {
+ name: string | undefined,
+ icon: string | undefined,
+ width: number | undefined,
+}
+
+/**
+ * Defines the element which accepts N children, with a zero-based
+ * index determining which child to show, e.g.:
+ *
+ * Show when index is 0
+ * Show when index is 1
+ * Show when index is 2
+ *
+ */
+export class TabPanel extends LitElement {
+ static get componentName() {
+ return `tab-panel`;
+ }
+
+ static styles = [
+ legacyStyles,
+ materialStyles,
+ css`
+ ::slotted(*) {
+ display: none;
+ }
+
+ ::slotted(.show-tab) {
+ display: block;
+ }
+
+ .container {
+ padding: 0;
+ width: 100%;
+ }
+
+ .tab-container {
+ align-items: center;
+ display: flex;
+ flex-direction: row;
+ justify-content: flex-end;
+ }
+
+ .tab-container button {
+ border-radius: 5px;
+ height: 28px;
+ margin: 2px;
+ user-select: none;
+ }
+
+ .tab-container button.icon {
+ font-size: 16px;
+ width: 28px;
+ }
+
+ .tab-container button.name {
+ padding: 0 8px;
+ }
+ `,
+ ];
+
+ @property({ type: Array })
+ tabs: Tab[] = [];
+
+ @property({ type: Number })
+ index = 0;
+
+ @property({ type: Number })
+ mode: TabMode = TabMode.HIDE_ON_SECOND_CLICK;
+
+ /**
+ * The tab elements.
+ */
+ @queryAll(".tab") tabElements!: HTMLDivElement[];
+
+ /**
+ * The tab content element to show at a given index. Note that child
+ * elements are set to display block or none based on the index, and
+ * top-level text elements are ignored.
+ */
+ @queryAssignedElements() tabContentElements!: HTMLElement[];
+
+ render() {
+ return html`
+
+
+ ${this.renderTabs()}
+
+
+
+ `;
+ }
+
+ override update(changedProperties: PropertyValues) {
+ super.update(changedProperties);
+ if (changedProperties.has("index") && changedProperties.get("index") != null) {
+ this.updateSlotChildren();
+ }
+ }
+
+ private updateSlotChildren() {
+ if (!this.tabContentElements) {
+ return;
+ }
+ // Show the element at the current index.
+ this.tabContentElements.forEach((element: HTMLElement, i: number) => {
+ element.classList.remove("show-tab");
+
+ // Also add accessibility attributes.
+ const id = convertToId(this.tabs[i].name);
+ element.setAttribute("id", `tabpanel-${id}-${i}`);
+ element.setAttribute("role", "tabpanel");
+ element.setAttribute("aria-labelledby", `tab-${id}-${i}`);
+ });
+ this.tabContentElements[this.index]?.classList.add("show-tab");
+ }
+
+ private renderTabs() {
+ return this.tabs.map((tab: Tab, i: number) => {
+ const id = convertToId(this.tabs[i].name);
+ return html``;
+ });
+ }
+
+ private onTabClick(index: number) {
+ switch (this.mode) {
+ case TabMode.HIDE_ON_SECOND_CLICK:
+ // Hide the tab panel if clicked twice.
+ this.index = this.index === index ? -1 : index;
+ break;
+ case TabMode.ALWAYS_SHOW:
+ default:
+ this.index = index;
+ }
+ this.dispatchEvent(new CustomEvent("tab-clicked", {
+ detail: index,
+ }));
+ }
+}
+
+// Without this check, there's a component registry issue when developing locally.
+if (!customElements.get(TabPanel.componentName)) {
+ customElements.define(TabPanel.componentName, TabPanel);
+}
diff --git a/js/toolbar.ts b/js/toolbar.ts
new file mode 100644
index 0000000000..7f488444be
--- /dev/null
+++ b/js/toolbar.ts
@@ -0,0 +1,141 @@
+import type { RenderProps } from "@anywidget/types";
+import { html, css } from "lit";
+import { property } from "lit/decorators.js";
+import { classMap } from "lit/directives/class-map.js";
+
+import { legacyStyles } from "./ipywidgets_styles";
+import { LitWidget } from "./lit_widget";
+import { materialStyles } from "./styles";
+import { loadFonts, updateChildren } from "./utils";
+
+import "./tab_panel";
+
+
+export interface ToolbarModel {
+ accessory_widgets: any;
+ main_tools: any;
+ extra_tools: any
+ expanded: boolean;
+ tab_index: number;
+}
+
+export class Toolbar extends LitWidget<
+ ToolbarModel,
+ Toolbar
+> {
+ static get componentName() {
+ return `toolbar-panel`;
+ }
+
+ static styles = [
+ legacyStyles,
+ materialStyles,
+ css`
+ .hide {
+ display: none;
+ }
+
+ .expanded {
+ display: block; !important
+ }
+
+ .tools-container {
+ padding: 4px;
+ }
+
+ slot[name="extra-tools"] {
+ margin-top: 4px;
+ }
+
+ ::slotted([slot="main-tools"]),
+ ::slotted([slot="extra-tools"]) {
+ align-items: center;
+ display: inline-grid;
+ grid-template-columns: auto auto auto;
+ grid-gap: 4px;
+ justify-items: center;
+ }
+ `,
+ ];
+
+ modelNameToViewName(): Map {
+ return new Map([
+ ["accessory_widgets", null],
+ ["main_tools", null],
+ ["extra_tools", null],
+ ["expanded", "expanded"],
+ ["tab_index", "tab_index"],
+ ]);
+ }
+
+ @property()
+ expanded: boolean = false;
+
+ @property()
+ tab_index: number = 0;
+
+ render() {
+ return html`
+ ) => {
+ this.tab_index = e.detail;
+ }}>
+
+
+
+
+
+
+
+
+ `;
+ }
+}
+
+// Without this check, there's a component registry issue when developing locally.
+if (!customElements.get(Toolbar.componentName)) {
+ customElements.define(Toolbar.componentName, Toolbar);
+}
+
+async function render({ model, el }: RenderProps) {
+ loadFonts();
+ const manager = document.createElement(Toolbar.componentName);
+ manager.model = model;
+ el.appendChild(manager);
+
+ const accessoryWidgetEl = document.createElement("div");
+ accessoryWidgetEl.slot = "accessory-widget";
+ manager.appendChild(accessoryWidgetEl);
+
+ updateChildren(accessoryWidgetEl, model, "accessory_widgets");
+ model.on("change:accessory_widgets", () => {
+ updateChildren(accessoryWidgetEl, model, "accessory_widgets");
+ });
+
+ const mainToolsEl = document.createElement("div");
+ mainToolsEl.slot = "main-tools";
+ manager.appendChild(mainToolsEl);
+
+ updateChildren(mainToolsEl, model, "main_tools");
+ model.on("change:main_tools", () => {
+ updateChildren(mainToolsEl, model, "main_tools");
+ });
+
+ const extraToolsEl = document.createElement("div");
+ extraToolsEl.slot = "extra-tools";
+ manager.appendChild(extraToolsEl);
+
+ updateChildren(extraToolsEl, model, "extra_tools");
+ model.on("change:extra_tools", () => {
+ updateChildren(extraToolsEl, model, "extra_tools");
+ });
+}
+
+export default { render };
diff --git a/js/toolbar_item.ts b/js/toolbar_item.ts
new file mode 100644
index 0000000000..24e4fe3939
--- /dev/null
+++ b/js/toolbar_item.ts
@@ -0,0 +1,85 @@
+import type { RenderProps } from "@anywidget/types";
+import { html, css } from "lit";
+import { property } from "lit/decorators.js";
+import { classMap } from 'lit/directives/class-map.js';
+
+import { legacyStyles } from './ipywidgets_styles';
+import { LitWidget } from "./lit_widget";
+import { materialStyles } from "./styles";
+import { loadFonts } from "./utils";
+
+export interface ToolbarItemModel {
+ active: boolean;
+ icon: string;
+ // Note: "tooltip" is already used by ipywidgets.
+ tooltip_text: string;
+}
+
+export class ToolbarItem extends LitWidget {
+ static get componentName() {
+ return `tool-button`;
+ }
+
+ static styles = [
+ legacyStyles,
+ materialStyles,
+ css`
+ button {
+ font-size: 16px !important;
+ height: 32px;
+ padding: 0px 0px 0px 4px;
+ width: 32px;
+ user-select: none;
+ }
+ `,
+ ];
+
+ modelNameToViewName(): Map {
+ return new Map([
+ ["active", "active"],
+ ["icon", "icon"],
+ ["tooltip_text", "tooltip_text"],
+ ]);
+ }
+
+ @property({ type: Boolean })
+ active: boolean = false;
+
+ @property({ type: String })
+ icon: string = '';
+
+ @property({ type: String })
+ tooltip_text: string = '';
+
+ render() {
+ return html`
+ `;
+ }
+
+ private onClick(_: Event) {
+ this.active = !this.active;
+ }
+}
+
+// Without this check, there's a component registry issue when developing locally.
+if (!customElements.get(ToolbarItem.componentName)) {
+ customElements.define(ToolbarItem.componentName, ToolbarItem);
+}
+
+async function render({ model, el }: RenderProps) {
+ loadFonts();
+ const manager = document.createElement(ToolbarItem.componentName);
+ manager.model = model;
+ el.appendChild(manager);
+}
+
+export default { render };
diff --git a/js/utils.ts b/js/utils.ts
index 5431eaf7a2..c93ac022bc 100644
--- a/js/utils.ts
+++ b/js/utils.ts
@@ -24,9 +24,13 @@ export function loadFonts() {
export async function updateChildren(
container: HTMLElement,
- model: AnyModel
+ model: AnyModel,
+ property = "children",
) {
- const children = model.get("children");
+ let children = model.get(property);
+ if (!Array.isArray(children)) {
+ children = [children]
+ }
const child_models = await unpackModels(children, model.widget_manager);
const child_views = await Promise.all(
child_models.map((model) => model.widget_manager.create_view(model))
diff --git a/tests/test_core.py b/tests/test_core.py
index cb1b44feb3..1d6261dae8 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -150,44 +150,17 @@ def test_add_toolbar(self):
self.assertEqual(len(self.core_map.controls), 1)
toolbar_control = self.core_map.controls[0].widget
- # Layer manager is selected and open by default.
- layer_button = utils.query_widget(
- toolbar_control, ipywidgets.ToggleButton, lambda c: c.tooltip == "Layers"
- )
- self.assertTrue(layer_button.value)
- self.assertIsNotNone(
- utils.query_widget(toolbar_control, map_widgets.LayerManager)
- )
- toolbar_button = utils.query_widget(
- toolbar_control, ipywidgets.ToggleButton, lambda c: c.tooltip == "Toolbar"
- )
- toolbar_button.value = True # Open the grid of tools.
- self.assertFalse(layer_button.value)
-
- tool_grid = utils.query_widget(toolbar_control, ipywidgets.GridBox).children
-
- self.assertEqual(len(tool_grid), 3)
- self.assertEqual(tool_grid[0].tooltip, "Basemap selector")
- self.assertEqual(tool_grid[1].tooltip, "Inspector")
- self.assertEqual(tool_grid[2].tooltip, "Get help")
-
- # Closing the toolbar button shows both buttons in the header.
- toolbar_button.value = False
- self.assertIsNotNone(
- utils.query_widget(
- toolbar_control,
- ipywidgets.ToggleButton,
- lambda c: c.tooltip == "Toolbar",
- )
- )
- self.assertIsNotNone(
- utils.query_widget(
- toolbar_control,
- ipywidgets.ToggleButton,
- lambda c: c.tooltip == "Layers",
- )
- )
+ # Layer manager is selected and open by default.
+ self.assertEqual(toolbar_control.tab_index, 0)
+ layer_button = toolbar_control.accessory_widgets[0]
+ self.assertIsInstance(layer_button, map_widgets.LayerManager)
+
+ toolbar_control.tab_index = 1
+ self.assertEqual(len(toolbar_control.main_tools), 3)
+ self.assertEqual(toolbar_control.main_tools[0].tooltip_text, "Basemap selector")
+ self.assertEqual(toolbar_control.main_tools[1].tooltip_text, "Inspector")
+ self.assertEqual(toolbar_control.main_tools[2].tooltip_text, "Get help")
def test_add_draw_control(self):
"""Tests adding and getting the draw widget."""
diff --git a/tests/test_toolbar.py b/tests/test_toolbar.py
index aed699e1d7..e8d093a014 100644
--- a/tests/test_toolbar.py
+++ b/tests/test_toolbar.py
@@ -10,39 +10,27 @@
import ipywidgets
import geemap
-from geemap.toolbar import Toolbar, _cleanup_toolbar_item
+from geemap.toolbar import Toolbar, ToolbarItem, _cleanup_toolbar_item
from tests import fake_map, utils
class TestToolbar(unittest.TestCase):
"""Tests for the Toolbar class in the `toolbar` module."""
- def _query_layers_button(self, toolbar):
- return utils.query_widget(
- toolbar, ipywidgets.ToggleButton, lambda c: c.tooltip == "Layers"
- )
-
- def _query_open_button(self, toolbar):
- return utils.query_widget(
- toolbar, ipywidgets.ToggleButton, lambda c: c.tooltip == "Toolbar"
- )
-
- def _query_tool_grid(self, toolbar):
- return utils.query_widget(toolbar, ipywidgets.GridBox, lambda c: True)
-
def setUp(self) -> None:
self.callback_calls = 0
self.last_called_with_selected = None
self.last_called_item = None
- self.item = Toolbar.Item(
+ self.item = ToolbarItem(
icon="info", tooltip="dummy item", callback=self.dummy_callback
)
- self.no_reset_item = Toolbar.Item(
+ self.reset_item = ToolbarItem(
icon="question",
tooltip="no reset item",
callback=self.dummy_callback,
- reset=False,
+ reset=True,
)
+ self.accessory_widgets = [ipywidgets.Text()]
return super().setUp()
def tearDown(self) -> None:
@@ -57,109 +45,62 @@ def dummy_callback(self, m, selected, item):
def test_no_tools_throws(self):
map = geemap.Map(ee_initialize=False)
- self.assertRaises(ValueError, Toolbar, map, [], [])
+ self.assertRaises(ValueError, Toolbar, map, [], [], self.accessory_widgets)
def test_only_main_tools_exist_if_no_extra_tools(self):
map = geemap.Map(ee_initialize=False)
- toolbar = Toolbar(map, [self.item], [])
- self.assertIsNone(toolbar.toggle_widget)
- self.assertEqual(len(toolbar.all_widgets), 1)
- self.assertEqual(toolbar.all_widgets[0].icon, "info")
- self.assertEqual(toolbar.all_widgets[0].tooltip, "dummy item")
- self.assertFalse(toolbar.all_widgets[0].value)
- self.assertEqual(toolbar.num_rows_collapsed, 1)
- self.assertEqual(toolbar.num_rows_expanded, 1)
+ toolbar = Toolbar(map, [self.item], [], self.accessory_widgets)
+ self.assertNotIn(toolbar.toggle_widget, toolbar.main_tools)
def test_all_tools_and_toggle_exist_if_extra_tools(self):
map = geemap.Map(ee_initialize=False)
- toolbar = Toolbar(map, [self.item], [self.no_reset_item])
+ toolbar = Toolbar(map, [self.item], [self.reset_item], self.accessory_widgets)
self.assertIsNotNone(toolbar.toggle_widget)
- self.assertEqual(len(toolbar.all_widgets), 3)
- self.assertEqual(toolbar.all_widgets[2].icon, "question")
- self.assertEqual(toolbar.all_widgets[2].tooltip, "no reset item")
- self.assertFalse(toolbar.all_widgets[2].value)
- self.assertEqual(toolbar.num_rows_collapsed, 1)
- self.assertEqual(toolbar.num_rows_expanded, 1)
-
- def test_has_correct_number_of_rows(self):
- map = geemap.Map(ee_initialize=False)
- toolbar = Toolbar(map, [self.item, self.item], [self.item, self.item])
- self.assertEqual(toolbar.num_rows_collapsed, 1)
- self.assertEqual(toolbar.num_rows_expanded, 2)
def test_toggle_expands_and_collapses(self):
map = geemap.Map(ee_initialize=False)
- toolbar = Toolbar(map, [self.item], [self.no_reset_item])
- self.assertEqual(len(toolbar.grid.children), 2)
+ toolbar = Toolbar(map, [self.item], [self.reset_item], self.accessory_widgets)
self.assertIsNotNone(toolbar.toggle_widget)
- toggle = toolbar.all_widgets[1]
- self.assertEqual(toggle.icon, "plus")
- self.assertEqual(toggle.tooltip, "Expand toolbar")
+ toggle = toolbar.toggle_widget
+ self.assertEqual(toggle.icon, "add")
+ self.assertEqual(toggle.tooltip_text, "Expand toolbar")
+ self.assertFalse(toolbar.expanded)
# Expand
- toggle.value = True
- self.assertEqual(len(toolbar.grid.children), 3)
- self.assertEqual(toggle.icon, "minus")
- self.assertEqual(toggle.tooltip, "Collapse toolbar")
+ toggle.active = True
+ self.assertTrue(toolbar.expanded)
+ self.assertEqual(toggle.icon, "remove")
+ self.assertEqual(toggle.tooltip_text, "Collapse toolbar")
# After expanding, button is unselected.
- self.assertFalse(toggle.value)
+ self.assertFalse(toggle.active)
# Collapse
- toggle.value = True
- self.assertEqual(len(toolbar.grid.children), 2)
- self.assertEqual(toggle.icon, "plus")
- self.assertEqual(toggle.tooltip, "Expand toolbar")
+ toggle.active = True
+ self.assertFalse(toolbar.expanded)
+ self.assertEqual(toggle.icon, "add")
+ self.assertEqual(toggle.tooltip_text, "Expand toolbar")
# After collapsing, button is unselected.
- self.assertFalse(toggle.value)
+ self.assertFalse(toggle.active)
def test_triggers_callbacks(self):
map = geemap.Map(ee_initialize=False)
- toolbar = Toolbar(map, [self.item, self.no_reset_item])
+ toolbar = Toolbar(map, [self.item, self.reset_item], [], self.accessory_widgets)
self.assertIsNone(self.last_called_with_selected)
self.assertIsNone(self.last_called_item)
- # Select first tool, which resets.
- toolbar.all_widgets[0].value = True
- self.assertFalse(self.last_called_with_selected) # was reset by callback
- self.assertEqual(self.callback_calls, 2)
- self.assertFalse(toolbar.all_widgets[0].value)
+ # Select first tool, which does not reset.
+ toolbar.main_tools[0].active = True
+ self.assertTrue(self.last_called_with_selected)
+ self.assertEqual(self.callback_calls, 1)
+ self.assertTrue(toolbar.main_tools[0].active)
self.assertEqual(self.item, self.last_called_item)
- # Select second tool, which does not reset.
- toolbar.all_widgets[1].value = True
- self.assertTrue(self.last_called_with_selected)
+ # Select second tool, which resets.
+ toolbar.main_tools[1].active = True
+ self.assertFalse(self.last_called_with_selected) # was reset by callback
self.assertEqual(self.callback_calls, 3)
- self.assertTrue(toolbar.all_widgets[1].value)
- self.assertEqual(self.no_reset_item, self.last_called_item)
-
- def test_layers_toggle_callback(self):
- """Verifies the on_layers_toggled callback is triggered."""
- map_fake = fake_map.FakeMap()
- toolbar = Toolbar(map_fake, [self.item, self.no_reset_item])
- self._query_open_button(toolbar).value = True
-
- self.assertIsNotNone(layers_button := self._query_layers_button(toolbar))
- on_toggled_mock = Mock()
- toolbar.on_layers_toggled = on_toggled_mock
- layers_button.value = True
-
- on_toggled_mock.assert_called_once()
-
- def test_accessory_widget(self):
- """Verifies the accessory widget replaces the tool grid."""
- map_fake = fake_map.FakeMap()
- toolbar = Toolbar(map_fake, [self.item, self.no_reset_item])
- self._query_open_button(toolbar).value = True
- self.assertIsNotNone(self._query_tool_grid(toolbar))
-
- toolbar.accessory_widget = ipywidgets.ToggleButton(tooltip="test-button")
-
- self.assertIsNone(self._query_tool_grid(toolbar))
- self.assertIsNotNone(
- utils.query_widget(
- toolbar, ipywidgets.ToggleButton, lambda c: c.tooltip == "test-button"
- )
- )
+ self.assertFalse(toolbar.main_tools[1].active)
+ self.assertEqual(self.reset_item, self.last_called_item)
@dataclass
class TestWidget:
@@ -177,24 +118,24 @@ def callback(m, selected, item):
widget.selected_count += 1
return widget
- item = Toolbar.Item(
+ item = ToolbarItem(
icon="info", tooltip="dummy item", callback=callback, reset=False
)
map_fake = fake_map.FakeMap()
- toolbar = Toolbar(map_fake, [item])
- toolbar.all_widgets[0].value = True
+ toolbar = Toolbar(map_fake, [item], [], self.accessory_widgets)
+ toolbar.main_tools[0].active = True
self.assertEqual(1, widget.selected_count)
self.assertEqual(0, widget.cleanup_count)
- toolbar.all_widgets[0].value = False
+ toolbar.main_tools[0].active = False
self.assertEqual(1, widget.selected_count)
self.assertEqual(1, widget.cleanup_count)
- toolbar.all_widgets[0].value = True
+ toolbar.main_tools[0].active = True
self.assertEqual(2, widget.selected_count)
self.assertEqual(1, widget.cleanup_count)
widget.cleanup()
self.assertEqual(2, widget.selected_count)
self.assertEqual(3, widget.cleanup_count)
- self.assertFalse(toolbar.all_widgets[0].value)
+ self.assertFalse(toolbar.main_tools[0].active)