Skip to content

Commit

Permalink
Implements the basemap selector as anywidget (#2171)
Browse files Browse the repository at this point in the history
* Implement LayerManager using LitElement + anywidget

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Update static files

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Use non-minified JS files to work around property renaming issue

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Set up tests for layer_manager_row

* Set up layer_manager_row test

* Implement LayerManager using LitElement + anywidget

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Update static files

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Use non-minified JS files to work around property renaming issue

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Clean up setuptools references in pyproject.toml

* Clean up setuptools references in pyproject.toml

* Fix dark mode and drop shadow issues in Colab

* Remove common.css, load fonts using JS instead.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Rebuild

* Remove extraneous files

* Address comments from initial review

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Ignore static files

* Fix TS errors

* Convert tsconfig.json to spaces and export model interfaces

* Add TS tests for anywidgets

* clean up styles

* Add css classes for better testability

* Add better css classes (p2), build before test

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Add a rough basemap-selector widget

* Add tests for basemap selector widget

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Increase margin to 4px

* Use primary styling to match old style

* Address review comments.

* Add type annotation

---------

Co-authored-by: Nathaniel Schmitz <[email protected]>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Qiusheng Wu <[email protected]>
  • Loading branch information
4 people authored Nov 18, 2024
1 parent 113b3bd commit b0bf541
Show file tree
Hide file tree
Showing 6 changed files with 256 additions and 66 deletions.
11 changes: 6 additions & 5 deletions geemap/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -687,13 +687,14 @@ def _layer_editor(self) -> Optional[map_widgets.LayerEditor]:
return self._find_widget_of_type(map_widgets.LayerEditor)

@property
def _basemap_selector(self) -> Optional[map_widgets.Basemap]:
def _basemap_selector(self) -> Optional[map_widgets.BasemapSelector]:
"""Finds the basemap selector widget in the map controls.
Returns:
Optional[map_widgets.Basemap]: The basemap selector widget if found, else None.
Optional[map_widgets.BasemapSelector]: The basemap selector widget
if found, else None.
"""
return self._find_widget_of_type(map_widgets.Basemap)
return self._find_widget_of_type(map_widgets.BasemapSelector)

def __init__(self, **kwargs: Any) -> None:
"""Initialize the map with given keyword arguments.
Expand Down Expand Up @@ -1051,7 +1052,7 @@ def _add_basemap_selector(self, position: str, **kwargs: Any) -> None:
value = kwargs.pop(
"value", self._get_preferred_basemap_name(self.layers[0].name)
)
basemap = map_widgets.Basemap(basemap_names, value, **kwargs)
basemap = map_widgets.BasemapSelector(basemap_names, value, **kwargs)
basemap.on_close = lambda: self.remove("basemap_selector")
basemap.on_basemap_changed = self._replace_basemap
basemap_control = ipyleaflet.WidgetControl(widget=basemap, position=position)
Expand All @@ -1073,7 +1074,7 @@ def remove(self, widget: Any) -> None:
"layer_manager": map_widgets.LayerManager,
"layer_editor": map_widgets.LayerEditor,
"draw_control": MapDrawControl,
"basemap_selector": map_widgets.Basemap,
"basemap_selector": map_widgets.BasemapSelector,
}
if widget_type := basic_controls.get(widget, None):
if control := self._find_widget_of_type(widget_type, return_control=True):
Expand Down
57 changes: 30 additions & 27 deletions geemap/map_widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -1022,53 +1022,56 @@ def _observe_visible(self, change: Dict[str, Any]) -> None:


@Theme.apply
class Basemap(ipywidgets.HBox):
class BasemapSelector(anywidget.AnyWidget):
"""Widget for selecting a basemap."""

_esm = pathlib.Path(__file__).parent / "static" / "basemap_selector.js"

# The list of basemap names to make available for selection.
basemaps = traitlets.List([]).tag(sync=True)

# The currently selected basemap value.
value = traitlets.Unicode("").tag(sync=True)

def __init__(self, basemaps: List[str], value: str):
"""Creates a widget for selecting a basemap.
Args:
basemaps (list): The list of basemap names to make available for selection.
value (str): The default value from basemaps to select.
"""
super().__init__()
self.on_close = None
self.on_basemap_changed = None
self.basemaps = basemaps
self.value = value
self._setup_event_listeners()

self._dropdown = ipywidgets.Dropdown(
options=list(basemaps),
value=value,
layout=ipywidgets.Layout(width="200px"),
)
self._dropdown.observe(self._on_dropdown_click, "value")

close_button = ipywidgets.Button(
icon="times",
tooltip="Close the basemap widget",
button_style="primary",
layout=ipywidgets.Layout(width="32px"),
)
close_button.on_click(self._on_close_click)

super().__init__([self._dropdown, close_button])
def _setup_event_listeners(self) -> None:
self.on_msg(self._handle_message_event)

def _on_dropdown_click(self, change: dict) -> None:
"""Handles the dropdown value change event.
def _handle_message_event(
self, widget: ipywidgets.Widget, content: Dict[str, Any], buffers: List[Any]
) -> None:
del widget, buffers # Unused
if content.get("type") == "click":
msg_id = content.get("id", "")
if msg_id == "close":
self.cleanup()

Args:
change (dict): The change event dictionary.
"""
if self.on_basemap_changed and change["new"]:
self.on_basemap_changed(self._dropdown.value)
@traitlets.observe("value")
def _observe_value(self, change: Dict[str, Any]) -> None:
if (value := change.get("new")) is not None and self.on_basemap_changed:
self.on_basemap_changed(value)

def cleanup(self) -> None:
"""Cleans up the widget by calling the on_close callback if set."""
if self.on_close:
self.on_close()

def _on_close_click(self, _) -> None:
"""Handles the close button click event."""
self.cleanup()

# Type alias for backwards compatibility.
Basemap = BasemapSelector


@Theme.apply
Expand Down
103 changes: 103 additions & 0 deletions js/basemap_selector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import type { RenderProps } from "@anywidget/types";
import { css, html, PropertyValues, TemplateResult } from "lit";
import { property, query } from "lit/decorators.js";

import { legacyStyles } from "./ipywidgets_styles";
import { LitWidget } from "./lit_widget";
import { materialStyles } from "./styles";
import { loadFonts } from "./utils";

export interface BasemapSelectorModel {
basemaps: string[];
value: string;
}

export class BasemapSelector extends LitWidget<
BasemapSelectorModel,
BasemapSelector
> {
static get componentName(): string {
return `basemap-selector`;
}

static styles = [
legacyStyles,
materialStyles,
css`
.row-container {
align-items: center;
display: flex;
height: 32px;
width: 200px;
}
.row-button {
font-size: 14px;
height: 26px;
margin: 4px;
width: 26px;
}
`,
];

modelNameToViewName(): Map<
keyof BasemapSelectorModel,
keyof BasemapSelector
> {
return new Map([
["basemaps", "basemaps"],
["value", "value"],
]);
}

@property({ type: Array }) basemaps: string[] = [];
@property({ type: String }) value: string = "";
@query('select') selectElement!: HTMLSelectElement;

render(): TemplateResult {
return html`
<div class="row-container">
<select class="legacy-select" @change=${this.onChange}>
${this.basemaps.map((basemap) => html`<option>${basemap}</option>`)}
</select>
<button
class="legacy-button primary row-button close-button"
@click="${this.onCloseClicked}"
>
<span class="close-icon material-symbols-outlined">close</span>
</button>
</div>`;
}

override update(changedProperties: PropertyValues): void {
if (changedProperties.has("value") && this.selectElement) {
this.selectElement.value = this.value;
}
super.update(changedProperties);
}

private onChange(event: Event) {
const target = event.target as HTMLInputElement;
this.value = target.value;
}

private onCloseClicked(_: Event) {
this.model?.send({ type: "click", id: "close" });
}
}

// Without this check, there's a component registry issue when developing locally.
if (!customElements.get(BasemapSelector.componentName)) {
customElements.define(BasemapSelector.componentName, BasemapSelector);
}

function render({ model, el }: RenderProps<BasemapSelectorModel>) {
loadFonts();
const row = <BasemapSelector>(
document.createElement(BasemapSelector.componentName)
);
row.model = model;
el.appendChild(row);
}

export default { render };
25 changes: 25 additions & 0 deletions js/ipywidgets_styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,29 @@ export const legacyStyles = css`
height: var(--jp-widgets-inline-height);
line-height: var(--jp-widgets-inline-height);
}
.legacy-select {
-moz-appearance: none;
-webkit-appearance: none;
appearance: none;
background-color: var(--jp-widgets-input-background-color);
background-image: var(--jp-widgets-dropdown-arrow);
background-position: right center;
background-repeat: no-repeat;
background-size: 20px;
border-radius: 0;
border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);
box-shadow: none;
box-sizing: border-box;
color: var(--jp-widgets-input-color);
flex: 1 1 var(--jp-widgets-inline-width-short);
font-size: var(--jp-widgets-font-size);
height: inherit;
min-width: 0;
outline: none !important;
padding-left: calc(var(--jp-widgets-input-padding)* 2);
padding-right: 20px;
vertical-align: top;
}
}
`;
75 changes: 75 additions & 0 deletions tests/basemap_selector.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { AnyModel } from "@anywidget/types";
import "../js/basemap_selector";
import { default as selectorRender, BasemapSelector, BasemapSelectorModel } from "../js/basemap_selector";
import { FakeAnyModel } from "./fake_anywidget";

describe("<basemap-selector>", () => {
let selector: BasemapSelector;

async function makeSelector(model: AnyModel<BasemapSelectorModel>) {
const container = document.createElement("div");
selectorRender.render({
model, el: container, experimental: {
invoke: () => new Promise(() => [model, []]),
}
});
const element = container.firstElementChild as BasemapSelector;
document.body.appendChild(element);
await element.updateComplete;
return element;
}

beforeEach(async () => {
selector = await makeSelector(new FakeAnyModel<BasemapSelectorModel>({
basemaps: ["select", "default", "bounded"],
value: "default",
}));
});

afterEach(() => {
Array.from(document.querySelectorAll("basemap-selector")).forEach((el) => {
el.remove();
})
});

it("can be instantiated.", () => {
expect(selector.shadowRoot?.querySelector("select")?.textContent).toContain("bounded");
});

it("renders the basemap options.", () => {
const options = selector.shadowRoot?.querySelectorAll("option")!;
expect(options.length).toBe(3);
expect(options[0].textContent).toContain("select");
expect(options[1].textContent).toContain("default");
expect(options[2].textContent).toContain("bounded");
});

it("setting the value on model changes the value on select.", async () => {
selector.value = "select";
await selector.updateComplete;
expect(selector.selectElement.value).toBe("select");
});

it("sets value on model when option changes.", async () => {
const setSpy = spyOn(FakeAnyModel.prototype, "set");
const saveSpy = spyOn(FakeAnyModel.prototype, "save_changes");

selector.selectElement.value = "select";
selector.selectElement.dispatchEvent(new Event('change'));

await selector.updateComplete;
expect(setSpy).toHaveBeenCalledOnceWith("value", "select");
expect(saveSpy).toHaveBeenCalledTimes(1);
});

it("emits close event when clicked.", async () => {
const sendSpy = spyOn(FakeAnyModel.prototype, "send");
// Close button emits an event.
(selector.shadowRoot?.querySelector(".close-button") as HTMLButtonElement).click();
await selector.updateComplete;
expect(sendSpy).toHaveBeenCalledOnceWith({
type: "click",
id: "close"
});
});
});
51 changes: 17 additions & 34 deletions tests/test_map_widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -638,51 +638,34 @@ def test_visibility_updates_children(self):
self.assertTrue(child.visible)


class TestBasemap(unittest.TestCase):
"""Tests for the Basemap class in the `map_widgets` module."""
class TestBasemapSelector(unittest.TestCase):
"""Tests for the BasemapSelector class in the `map_widgets` module."""

def setUp(self):
self.basemaps = ["first", "default", "bounded"]
self.default = "default"
self.basemap_widget = map_widgets.Basemap(self.basemaps, self.default)
self.basemap_widget = map_widgets.BasemapSelector(self.basemaps, self.default)

@property
def _close_button(self):
return utils.query_widget(
self.basemap_widget,
ipywidgets.Button,
lambda c: c.tooltip == "Close the basemap widget",
)

@property
def _dropdown(self):
return utils.query_widget(
self.basemap_widget, ipywidgets.Dropdown, lambda _: True
)

def test_basemap(self):
"""Tests that the basemap's initial UI is set up properly."""
self.assertIsNotNone(self._close_button)
self.assertIsNotNone(self._dropdown)
self.assertEqual(self._dropdown.value, "default")
self.assertEqual(len(self._dropdown.options), 3)
def test_basemap_default(self):
"""Tests that the default value is set."""
self.assertEqual(self.basemap_widget.value, "default")

def test_basemap_close(self):
"""Tests that triggering the closing button fires the close event."""
"""Tests that triggering the closing button fires the close callback."""
on_close_mock = Mock()
self.basemap_widget.on_close = on_close_mock
self._close_button.click()

msg = {"type": "click", "id": "close"}
self.basemap_widget._handle_custom_msg(
msg, []
) # pylint: disable=protected-access
on_close_mock.assert_called_once()

def test_basemap_selection(self):
"""Tests that a basemap selection fires the selected event."""
on_basemap_changed_mock = Mock()
self.basemap_widget.on_basemap_changed = on_basemap_changed_mock

self._dropdown.value = "first"

on_basemap_changed_mock.assert_called_once()
def test_basemap_change(self):
"""Tests that value change fires the basemap_changed callback."""
on_change_mock = Mock()
self.basemap_widget.on_basemap_changed = on_change_mock
self.basemap_widget.value = "ROADMAP"
on_change_mock.assert_called_once_with("ROADMAP")


class LayerEditorTestHarness:
Expand Down

0 comments on commit b0bf541

Please sign in to comment.