Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implements the basemap selector as anywidget #2171

Merged
merged 46 commits into from
Nov 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
f2b7037
Implement LayerManager using LitElement + anywidget
naschmitz Oct 10, 2024
8e3ee04
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 10, 2024
acde734
Merge branch 'master' into anywidget
giswqs Oct 10, 2024
919e39a
Update static files
naschmitz Oct 11, 2024
f5af00d
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 11, 2024
b87f39d
Use non-minified JS files to work around property renaming issue
naschmitz Oct 14, 2024
6381d46
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 14, 2024
c31d5a9
Set up tests for layer_manager_row
sufyanAbbasi Oct 15, 2024
51a8556
Set up layer_manager_row test
sufyanAbbasi Oct 15, 2024
cdd5040
Implement LayerManager using LitElement + anywidget
naschmitz Oct 10, 2024
bbfa97d
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 10, 2024
5fa9733
Update static files
naschmitz Oct 11, 2024
98ef790
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 11, 2024
0729c3d
Use non-minified JS files to work around property renaming issue
naschmitz Oct 14, 2024
b9e0d69
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 14, 2024
7195ac8
Clean up setuptools references in pyproject.toml
naschmitz Oct 14, 2024
ce4c34d
Clean up setuptools references in pyproject.toml
naschmitz Oct 15, 2024
28092e7
Fix dark mode and drop shadow issues in Colab
naschmitz Oct 15, 2024
78e0021
Remove common.css, load fonts using JS instead.
sufyanAbbasi Oct 16, 2024
9d359bc
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 16, 2024
8325c5a
Merge branch 'anywidget' into anywidget-test
sufyanAbbasi Oct 16, 2024
301d254
Rebuild
sufyanAbbasi Oct 16, 2024
0d20641
Remove extraneous files
sufyanAbbasi Oct 16, 2024
84f2661
Address comments from initial review
naschmitz Oct 16, 2024
4a462c0
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 16, 2024
befdaa7
Ignore static files
naschmitz Oct 16, 2024
fc9952b
Fix TS errors
sufyanAbbasi Oct 16, 2024
7c1d6fc
Merge branch 'anywidget' into anywidget-test
sufyanAbbasi Oct 16, 2024
243c178
Merge branch 'master' into anywidget
giswqs Oct 17, 2024
25817a7
Convert tsconfig.json to spaces and export model interfaces
naschmitz Oct 18, 2024
cd6c259
Add TS tests for anywidgets
sufyanAbbasi Oct 18, 2024
9dbe4f6
Merge branch 'anywidget' into anywidget-test
sufyanAbbasi Oct 18, 2024
f116e98
Merge remote-tracking branch 'origin/master' into anywidget-test
sufyanAbbasi Nov 11, 2024
b79ea78
clean up styles
sufyanAbbasi Nov 11, 2024
7ae4e23
Add css classes for better testability
sufyanAbbasi Nov 11, 2024
bc8183e
Add better css classes (p2), build before test
sufyanAbbasi Nov 11, 2024
3136832
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Nov 11, 2024
41afc67
Add a rough basemap-selector widget
sufyanAbbasi Nov 12, 2024
d26c584
Add tests for basemap selector widget
sufyanAbbasi Nov 13, 2024
8058de7
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Nov 14, 2024
98c61bb
Increase margin to 4px
sufyanAbbasi Nov 14, 2024
db21945
Merge remote-tracking branch 'refs/remotes/origin/anywidget-basemap-s…
sufyanAbbasi Nov 14, 2024
6bf6fce
Merge branch 'master' into anywidget-basemap-selector
sufyanAbbasi Nov 14, 2024
8c7d332
Use primary styling to match old style
sufyanAbbasi Nov 15, 2024
49e363b
Address review comments.
sufyanAbbasi Nov 15, 2024
c3ae62f
Add type annotation
sufyanAbbasi Nov 15, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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()
sufyanAbbasi marked this conversation as resolved.
Show resolved Hide resolved

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"
sufyanAbbasi marked this conversation as resolved.
Show resolved Hide resolved
@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);
}
sufyanAbbasi marked this conversation as resolved.
Show resolved Hide resolved

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;
}
sufyanAbbasi marked this conversation as resolved.
Show resolved Hide resolved
}
`;
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