diff --git a/geemap/coreutils.py b/geemap/coreutils.py index b9e3311308..7af8cd8934 100644 --- a/geemap/coreutils.py +++ b/geemap/coreutils.py @@ -114,7 +114,7 @@ def ee_initialize( ee.Initialize(**kwargs) -def _new_tree_node( +def new_tree_node( label: str, children: Optional[list[dict[str, Any]]] = None, expanded: bool = False, @@ -169,7 +169,7 @@ def _generate_tree( if isinstance(item, dict): node_name = _format_dictionary_node_name(index, item) children = _generate_tree(item, opened) - node_list.append(_new_tree_node(node_name, children, expanded=opened)) + node_list.append(new_tree_node(node_name, children, expanded=opened)) elif isinstance(info, dict): for k, v in info.items(): if isinstance(v, (list, dict)): @@ -178,12 +178,12 @@ def _generate_tree( elif k == "bands": k = f"bands: List ({len(v)} elements)" node_list.append( - _new_tree_node(f"{k}", _generate_tree(v, opened), expanded=opened) + new_tree_node(f"{k}", _generate_tree(v, opened), expanded=opened) ) else: - node_list.append(_new_tree_node(f"{k}: {v}", expanded=opened)) + node_list.append(new_tree_node(f"{k}: {v}", expanded=opened)) else: - node_list.append(_new_tree_node(f"{info}", expanded=opened)) + node_list.append(new_tree_node(f"{info}", expanded=opened)) return node_list @@ -233,7 +233,7 @@ def build_computed_object_tree( if layer_name: layer_name = f"{layer_name}: " - return _new_tree_node( + return new_tree_node( f"{layer_name}{ee_type}{band_info}", _generate_tree(layer_info, opened), expanded=opened, diff --git a/geemap/map_widgets.py b/geemap/map_widgets.py index e16b39d4f8..02f40cccf7 100644 --- a/geemap/map_widgets.py +++ b/geemap/map_widgets.py @@ -9,7 +9,6 @@ import anywidget import ee -import ipytree import ipywidgets import traitlets @@ -515,9 +514,21 @@ def __create_layout_property(name, default_value, **kwargs): @Theme.apply -class Inspector(ipywidgets.VBox): +class Inspector(anywidget.AnyWidget): """Inspector widget for Earth Engine data.""" + _esm = pathlib.Path(__file__).parent / "static" / "inspector.js" + + hide_close_button = traitlets.Bool(False).tag(sync=True) + + expand_points = traitlets.Bool(False).tag(sync=True) + expand_pixels = traitlets.Bool(True).tag(sync=True) + expand_objects = traitlets.Bool(False).tag(sync=True) + + point_info = traitlets.Dict({}).tag(sync=True) + pixel_info = traitlets.Dict({}).tag(sync=True) + object_info = traitlets.Dict({}).tag(sync=True) + def __init__( self, host_map: "geemap.Map", @@ -542,6 +553,7 @@ def __init__( show_close_button (bool, optional): Whether to show the close button. Defaults to True. """ + super().__init__() self._host_map = host_map if not host_map: @@ -551,70 +563,13 @@ def __init__( self._visible = visible self._decimals = decimals self._opened = opened + self.hide_close_button = not show_close_button self.on_close = None - self._expand_point_tree = False - self._expand_pixels_tree = True - self._expand_objects_tree = False - host_map.default_style = {"cursor": "crosshair"} - - left_padded_square = ipywidgets.Layout( - width="28px", height="28px", padding="0px 0px 0px 4px" - ) - - self.toolbar_button = ipywidgets.ToggleButton( - value=opened, tooltip="Inspector", icon="info", layout=left_padded_square - ) - self.toolbar_button.observe(self._on_toolbar_btn_click, "value") - - close_button = ipywidgets.ToggleButton( - value=False, - tooltip="Close the tool", - icon="times", - button_style="primary", - layout=left_padded_square, - ) - close_button.observe(self._on_close_btn_click, "value") - - point_checkbox = self._create_checkbox("Point", self._expand_point_tree) - pixels_checkbox = self._create_checkbox("Pixels", self._expand_pixels_tree) - objects_checkbox = self._create_checkbox("Objects", self._expand_objects_tree) - point_checkbox.observe(self._on_point_checkbox_changed, "value") - pixels_checkbox.observe(self._on_pixels_checkbox_changed, "value") - objects_checkbox.observe(self._on_objects_checkbox_changed, "value") - self.inspector_checks = ipywidgets.HBox( - children=[ - ipywidgets.Label( - "Expand", layout=ipywidgets.Layout(padding="0px 8px 0px 4px") - ), - point_checkbox, - pixels_checkbox, - objects_checkbox, - ] - ) - - if show_close_button: - self.toolbar_header = ipywidgets.HBox( - children=[close_button, self.toolbar_button] - ) - else: - self.toolbar_header = ipywidgets.HBox(children=[self.toolbar_button]) - self.tree_output = ipywidgets.VBox( - children=[], - layout=ipywidgets.Layout( - max_width="600px", max_height="300px", overflow="auto", display="block" - ), - ) - self._clear_inspector_output() - host_map.on_interaction(self._on_map_interaction) - self.toolbar_button.value = opened - - super().__init__( - children=[self.toolbar_header, self.inspector_checks, self.tree_output] - ) + self.on_msg(self._handle_message_event) def cleanup(self): """Removes the widget from the map and performs cleanup.""" @@ -624,20 +579,12 @@ def cleanup(self): if self.on_close is not None: self.on_close() - def _create_checkbox(self, title: str, checked: bool) -> ipywidgets.Checkbox: - """Creates a checkbox widget. - - Args: - title (str): The title of the checkbox. - checked (bool): Whether the checkbox is checked. - - Returns: - ipywidgets.Checkbox: The created checkbox widget. - """ - layout = ipywidgets.Layout(width="auto", padding="0px 6px 0px 0px") - return ipywidgets.Checkbox( - description=title, indent=False, value=checked, layout=layout - ) + 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" and content.get("id") == "close": + self._on_close_btn_click() def _on_map_interaction(self, **kwargs: Any) -> None: """Handles map interaction events. @@ -655,77 +602,24 @@ def _on_map_click(self, latlon: List[float]) -> None: Args: latlon (List[float]): The latitude and longitude of the click event. """ - if self.toolbar_button.value: - self._host_map.default_style = {"cursor": "wait"} - self._clear_inspector_output() + self._clear_inspector_output() + self._host_map.default_style = {"cursor": "wait"} - nodes = [self._point_info(latlon)] - pixels_node = self._pixels_info(latlon) - if pixels_node.nodes: - nodes.append(pixels_node) - objects_node = self._objects_info(latlon) - if objects_node.nodes: - nodes.append(objects_node) + self.point_info = self._point_info(latlon) + self.pixel_info = self._pixel_info(latlon) + self.object_info = self._object_info(latlon) - self.tree_output.children = [ipytree.Tree(nodes=nodes)] - self._host_map.default_style = {"cursor": "crosshair"} + self._host_map.default_style = {"cursor": "crosshair"} def _clear_inspector_output(self) -> None: """Clears the inspector output.""" - self.tree_output.children = [] - self.children = [] - self.children = [self.toolbar_header, self.inspector_checks, self.tree_output] - - def _on_point_checkbox_changed(self, change: Dict[str, Any]) -> None: - """Handles changes to the point checkbox. + self.point_info = {} + self.pixel_info = {} + self.object_info = {} - Args: - change (Dict[str, Any]): The change event arguments. - """ - self._expand_point_tree = change["new"] - - def _on_pixels_checkbox_changed(self, change: Dict[str, Any]) -> None: - """Handles changes to the pixels checkbox. - - Args: - change (Dict[str, Any]): The change event arguments. - """ - self._expand_pixels_tree = change["new"] - - def _on_objects_checkbox_changed(self, change: Dict[str, Any]) -> None: - """Handles changes to the objects checkbox. - - Args: - change (Dict[str, Any]): The change event arguments. - """ - self._expand_objects_tree = change["new"] - - def _on_toolbar_btn_click(self, change: Dict[str, Any]) -> None: - """Handles toolbar button click events. - - Args: - change (Dict[str, Any]): The change event arguments. - """ - if change["new"]: - self._host_map.default_style = {"cursor": "crosshair"} - self.children = [ - self.toolbar_header, - self.inspector_checks, - self.tree_output, - ] - self._clear_inspector_output() - else: - self.children = [self.toolbar_button] - self._host_map.default_style = {"cursor": "default"} - - def _on_close_btn_click(self, change: Dict[str, Any]) -> None: - """Handles close button click events. - - Args: - change (Dict[str, Any]): The change event arguments. - """ - if change["new"]: - self.cleanup() + def _on_close_btn_click(self) -> None: + """Handles close button click events.""" + self.cleanup() def _get_visible_map_layers(self) -> Dict[str, Any]: """Gets the visible map layers. @@ -743,51 +637,31 @@ def _get_visible_map_layers(self) -> Dict[str, Any]: layers = self._host_map.ee_layers return {k: v for k, v in layers.items() if v["ee_layer"].visible} - def _root_node( - self, title: str, nodes: List[ipytree.Node], **kwargs: Any - ) -> ipytree.Node: - """Creates a root node for the tree. - - Args: - title (str): The title of the root node. - nodes (List[ipytree.Node]): The child nodes of the root node. - **kwargs (Any): Additional keyword arguments. - - Returns: - ipytree.Node: The created root node. - """ - return ipytree.Node( - title, - icon="archive", - nodes=nodes, - open_icon="plus-square", - open_icon_style="success", - close_icon="minus-square", - close_icon_style="info", - **kwargs, - ) - - def _point_info(self, latlon: List[float]) -> ipytree.Node: + def _point_info(self, latlon: List[float]) -> Dict[str, Any]: """Gets information about a point. Args: latlon (List[float]): The latitude and longitude of the point. Returns: - ipytree.Node: The node containing the point information. + Dict[str, Any]: The node containing the point information. """ scale = self._host_map.get_scale() label = ( f"Point ({latlon[1]:.{self._decimals}f}, " + f"{latlon[0]:.{self._decimals}f}) at {int(scale)}m/px" ) - nodes = [ - ipytree.Node(f"Longitude: {latlon[1]}"), - ipytree.Node(f"Latitude: {latlon[0]}"), - ipytree.Node(f"Zoom Level: {self._host_map.zoom}"), - ipytree.Node(f"Scale (approx. m/px): {scale}"), - ] - return self._root_node(label, nodes, opened=self._expand_point_tree) + return coreutils.new_tree_node( + label, + [ + coreutils.new_tree_node(f"Longitude: {latlon[1]}"), + coreutils.new_tree_node(f"Latitude: {latlon[0]}"), + coreutils.new_tree_node(f"Zoom Level: {self._host_map.zoom}"), + coreutils.new_tree_node(f"Scale (approx. m/px): {scale}"), + ], + top_level=True, + expanded=self.expand_points, + ) def _query_point( self, latlon: List[float], ee_object: ee.ComputedObject @@ -809,20 +683,21 @@ def _query_point( return ee_object.reduceRegion(ee.Reducer.first(), point, scale).getInfo() return None - def _pixels_info(self, latlon: List[float]) -> ipytree.Node: + def _pixel_info(self, latlon: List[float]) -> Dict[str, Any]: """Gets information about pixels at a point. Args: latlon (List[float]): The latitude and longitude of the point. Returns: - ipytree.Node: The node containing the pixels information. + Dict[str, Any]: The node containing the pixels information. """ + + root = coreutils.new_tree_node("Pixels", expanded=True, top_level=True) if not self._visible: - return self._root_node("Pixels", []) + return root layers = self._get_visible_map_layers() - nodes = [] for layer_name, layer in layers.items(): ee_object = layer["ee_object"] pixel = self._query_point(latlon, ee_object) @@ -831,14 +706,18 @@ def _pixels_info(self, latlon: List[float]) -> ipytree.Node: pluralized_band = "band" if len(pixel) == 1 else "bands" ee_obj_type = ee_object.__class__.__name__ label = f"{layer_name}: {ee_obj_type} ({len(pixel)} {pluralized_band})" - layer_node = ipytree.Node(label, opened=self._expand_pixels_tree) + layer_node = coreutils.new_tree_node(label, expanded=self.expand_pixels) for key, value in sorted(pixel.items()): if isinstance(value, float): value = round(value, self._decimals) - layer_node.add_node(ipytree.Node(f"{key}: {value}", icon="file")) - nodes.append(layer_node) + layer_node["children"].append( + coreutils.new_tree_node( + f"{key}: {value}", expanded=self.expand_pixels + ) + ) + root["children"].append(layer_node) - return self._root_node("Pixels", nodes) + return root def _get_bbox(self, latlon: List[float]) -> ee.Geometry.BBox: """Gets a bounding box around a point. @@ -853,7 +732,7 @@ def _get_bbox(self, latlon: List[float]) -> ee.Geometry.BBox: delta = 0.005 return ee.Geometry.BBox(lon - delta, lat - delta, lon + delta, lat + delta) - def _objects_info(self, latlon: List[float]) -> ipytree.Node: + def _object_info(self, latlon: List[float]) -> Dict[str, Any]: """Gets information about objects at a point. Args: @@ -862,12 +741,12 @@ def _objects_info(self, latlon: List[float]) -> ipytree.Node: Returns: ipytree.Node: The node containing the objects information. """ + root = coreutils.new_tree_node("Objects", top_level=True, expanded=True) if not self._visible: - return self._root_node("Objects", []) + return root layers = self._get_visible_map_layers() point = ee.Geometry.Point(latlon[::-1]) - nodes = [] for layer_name, layer in layers.items(): ee_object = layer["ee_object"] if isinstance(ee_object, ee.FeatureCollection): @@ -877,13 +756,13 @@ def _objects_info(self, latlon: List[float]) -> ipytree.Node: geom.type().compareTo(ee.String("Point")), point, bbox ) ee_object = ee_object.filterBounds(is_point).first() - tree_node = coreutils.get_info( - ee_object, layer_name, self._expand_objects_tree, True + tree_node = coreutils.build_computed_object_tree( + ee_object, layer_name, self.expand_objects ) if tree_node: - nodes.append(tree_node) + root["children"].append(tree_node) - return self._root_node("Objects", nodes) + return root @Theme.apply diff --git a/js/container.ts b/js/container.ts new file mode 100644 index 0000000000..8b74e097a2 --- /dev/null +++ b/js/container.ts @@ -0,0 +1,128 @@ +import type { RenderProps } from "@anywidget/types"; +import { css, html, TemplateResult } from "lit"; +import { property } from "lit/decorators.js"; + +import { legacyStyles } from "./ipywidgets_styles"; +import { LitWidget } from "./lit_widget"; +import { materialStyles } from "./styles"; + +export interface ContainerModel { + title: string; + collapsed: boolean; + hide_close_button: boolean; +} + +export class Container extends LitWidget { + static get componentName(): string { + return `widget-container`; + } + + static styles = [ + legacyStyles, + materialStyles, + css` + .header { + display: flex; + gap: 4px; + margin: 4px; + } + + .widget-container { + margin: 4px; + } + + .hidden { + display: none; + } + + .header-button { + font-size: 16px; + height: 28px; + width: 28px; + } + + .header-text { + align-content: center; + padding-left: 4px; + padding-right: 4px; + } + `, + ]; + + @property() title: string = ""; + @property() collapsed: boolean = false; + @property() hideCloseButton: boolean = false; + + modelNameToViewName(): Map { + return new Map([ + ["collapsed", "collapsed"], + ["title", "title"], + ["hide_close_button", "hideCloseButton"], + ]); + } + + render() { + return html` +
+ + + + ${this.title} + +
+
+ +
+ `; + } + + private onCloseButtonClicked(): void { + this.dispatchEvent(new CustomEvent("close-clicked", {})); + } + + private onCollapseToggled(): void { + this.collapsed = !this.collapsed; + this.dispatchEvent(new CustomEvent("collapse-clicked", {})); + } + + private renderCollapseButtonIcon(): TemplateResult { + if (this.collapsed) { + return html``; + } + return html``; + } +} + +// Without this check, there's a component registry issue when developing locally. +if (!customElements.get(Container.componentName)) { + customElements.define(Container.componentName, Container); +} + +async function render({ model, el }: RenderProps) { + const manager = document.createElement( + Container.componentName + ) as Container; + manager.model = model; + el.appendChild(manager); +} + +export default { render }; diff --git a/js/inspector.ts b/js/inspector.ts new file mode 100644 index 0000000000..8b01a3657f --- /dev/null +++ b/js/inspector.ts @@ -0,0 +1,153 @@ +import type { RenderProps } from '@anywidget/types'; +import { css, html, nothing, TemplateResult } from 'lit'; +import { property } from 'lit/decorators.js'; + +import { legacyStyles } from './ipywidgets_styles'; +import { LitWidget } from "./lit_widget"; +import type { Node } from './tree_node'; + +import './container'; +import './tree_node'; + +export interface InspectorModel { + hide_close_button: boolean; + expand_points: boolean; + expand_pixels: boolean; + expand_objects: boolean; + point_info: { [key: string]: any }; + pixel_info: { [key: string]: any }; + object_info: { [key: string]: any }; +} + +export class Inspector extends LitWidget { + static get componentName(): string { + return `inspector-widget`; + } + + static styles = [ + legacyStyles, + css` + .checkbox-container { + align-items: center; + display: flex; + gap: 8px; + height: 32px; + } + + .spacer { + width: 8px; + } + + .object-browser { + max-height: 300px; + min-width: 290px; + overflow: scroll; + } + + input[type='checkbox'] { + vertical-align: middle; + } + `, + ]; + + @property() hideCloseButton: boolean = false; + @property() expandPoints: boolean = false; + @property() expandPixels: boolean = true; + @property() expandObjects: boolean = false; + @property() pointInfo: Node = {}; + @property() pixelInfo: Node = {}; + @property() objectInfo: Node = {}; + + modelNameToViewName(): Map { + return new Map([ + ['hide_close_button', 'hideCloseButton'], + ['expand_points', 'expandPoints'], + ['expand_pixels', 'expandPixels'], + ['expand_objects', 'expandObjects'], + ['point_info', 'pointInfo'], + ['pixel_info', 'pixelInfo'], + ['object_info', 'objectInfo'], + ]); + } + + render() { + return html` + +
+ Expand +
+ + Point +
+
+ + Pixels +
+
+ + Objects +
+
+
+ ${this.renderNode(this.pointInfo)} + ${this.renderNode(this.pixelInfo)} + ${this.renderNode(this.objectInfo)} +
+
+ `; + } + + private renderNode(node: Node): TemplateResult | typeof nothing { + if ((node.children?.length ?? 0) > 0) { + return html` `; + } + return nothing; + } + + private onPointCheckboxEvent(event: Event) { + const target = event.target as HTMLInputElement; + this.expandPoints = target.checked; + } + + private onPixelCheckboxEvent(event: Event) { + const target = event.target as HTMLInputElement; + this.expandPixels = target.checked; + } + + private onFeatureCheckboxEvent(event: Event) { + const target = event.target as HTMLInputElement; + this.expandObjects = target.checked; + } + + private onCloseButtonClicked(_: Event) { + this.model?.send({ "type": "click", "id": "close" }); + } +} + +// Without this check, there's a component registry issue when developing locally. +if (!customElements.get(Inspector.componentName)) { + customElements.define(Inspector.componentName, Inspector); +} + +async function render({ model, el }: RenderProps) { + const widget = document.createElement(Inspector.componentName) as Inspector; + widget.model = model; + el.appendChild(widget); +} + +export default { render }; \ No newline at end of file diff --git a/js/tree_node.ts b/js/tree_node.ts new file mode 100644 index 0000000000..c698101738 --- /dev/null +++ b/js/tree_node.ts @@ -0,0 +1,142 @@ +import { + css, + html, + LitElement, + nothing, + PropertyValues, + TemplateResult +} from "lit"; +import { property } from "lit/decorators.js"; +import { legacyStyles } from "./ipywidgets_styles"; +import { materialStyles } from "./styles"; + +export interface Node { + label?: string; + children?: Array; + expanded?: boolean; + topLevel?: boolean; +} + +export class TreeNode extends LitElement { + static get componentName() { + return `tree-node`; + } + + static styles = [ + legacyStyles, + materialStyles, + css` + .node { + align-items: center; + cursor: pointer; + display: flex; + } + + .node-text { + height: auto; + line-height: 24px; + } + + .node:hover { + background-color: var(--jp-layout-color2); + margin-left: -100%; + padding-left: 100%; + } + + .icon { + font-size: 13px; + width: 20px; + } + + ul { + list-style: none; + padding-left: 20px; + margin: 0; + } + `, + ]; + + @property() node: Node = {}; + @property({ reflect: true }) expanded: boolean = false; + + updated(changedProperties: PropertyValues): void { + super.updated(changedProperties); + if (changedProperties.has("node") && this.node) { + if ("expanded" in this.node) { + this.expanded = this.node.expanded ?? false; + } + } + } + + render(): TemplateResult { + return html` +
+ ${this.renderBullet()} ${this.renderIcon()} + ${this.node.label} +
+ ${this.renderChildren()} + `; + } + + private toggleExpand(): void { + this.expanded = !this.expanded; + } + + private hasChildren(): boolean { + return (this.node.children?.length ?? 0) > 0; + } + + private renderChildren(): TemplateResult | typeof nothing { + if (this.expanded && this.hasChildren()) { + return html`
    ${this.node.children?.map(this.renderChild)}
`; + } + return nothing; + } + + private renderChild(child: Node): TemplateResult { + return html`
  • `; + } + + private renderBullet(): TemplateResult | typeof nothing { + if (this.node.topLevel) { + if (this.expanded) { + return html` + indeterminate_check_box + `; + } + return html`add_box`; + } else if (this.hasChildren()) { + if (this.expanded) { + return html`remove`; + } + return html`add`; + } + return html``; + } + + private renderIcon(): TemplateResult | typeof nothing { + if (this.node.topLevel) { + return html`inventory_2`; + } else if (this.hasChildren()) { + if (this.expanded) { + return html` + folder_open + `; + } + return html`folder`; + } + return html`draft`; + } +} + +// Without this check, there's a component registry issue when developing locally. +if (!customElements.get(TreeNode.componentName)) { + customElements.define(TreeNode.componentName, TreeNode); +} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 6ef1b4d192..9f79866f68 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -180,10 +180,14 @@ artifacts = ["geemap/static/*"] [tool.hatch.build.hooks.jupyter-builder] build-function = "hatch_jupyter_builder.npm_builder" ensured-targets = [ + "geemap/static/container.js", + "geemap/static/inspector.js", "geemap/static/layer_manager_row.js", "geemap/static/layer_manager.js", ] skip-if-exists = [ + "geemap/static/container.js", + "geemap/static/inspector.js", "geemap/static/layer_manager_row.js", "geemap/static/layer_manager.js", ] diff --git a/tests/test_map_widgets.py b/tests/test_map_widgets.py index 4a09922f98..1522a002ab 100644 --- a/tests/test_map_widgets.py +++ b/tests/test_map_widgets.py @@ -4,12 +4,11 @@ import unittest from unittest.mock import patch, MagicMock, Mock, ANY -import ipytree import ipywidgets import ee from matplotlib import pyplot -from geemap import map_widgets +from geemap import coreutils, map_widgets from tests import fake_ee, fake_map, utils from geemap.legends import builtin_legends @@ -297,40 +296,6 @@ def setUp(self): def tearDown(self): pass - def _query_checkbox(self, description): - return utils.query_widget( - self.inspector, ipywidgets.Checkbox, lambda c: c.description == description - ) - - def _query_node(self, root, name): - return utils.query_widget(root, ipytree.Node, lambda c: c.name == name) - - @property - def _point_checkbox(self): - return self._query_checkbox("Point") - - @property - def _pixels_checkbox(self): - return self._query_checkbox("Pixels") - - @property - def _objects_checkbox(self): - return self._query_checkbox("Objects") - - @property - def _inspector_toggle(self): - return utils.query_widget( - self.inspector, ipywidgets.ToggleButton, lambda c: c.tooltip == "Inspector" - ) - - @property - def _close_toggle(self): - return utils.query_widget( - self.inspector, - ipywidgets.ToggleButton, - lambda c: c.tooltip == "Close the tool", - ) - def test_inspector_no_map(self): """Tests that a valid map must be passed in.""" with self.assertRaisesRegex(ValueError, "valid map"): @@ -339,59 +304,43 @@ def test_inspector_no_map(self): def test_inspector(self): """Tests that the inspector's initial UI is set up properly.""" self.assertEqual(self.map_fake.cursor_style, "crosshair") - self.assertFalse(self._point_checkbox.value) - self.assertTrue(self._pixels_checkbox.value) - self.assertFalse(self._objects_checkbox.value) - self.assertTrue(self._inspector_toggle.value) - self.assertIsNotNone(self._close_toggle) - - def test_inspector_toggle(self): - """Tests that toggling the inspector button hides/shows the inspector.""" - self._point_checkbox.value = True - self._pixels_checkbox.value = False - self._objects_checkbox.value = True - - self._inspector_toggle.value = False + self.assertFalse(self.inspector.hide_close_button) + + self.assertFalse(self.inspector.expand_points) + self.assertTrue(self.inspector.expand_pixels) + self.assertFalse(self.inspector.expand_objects) - self.assertEqual(self.map_fake.cursor_style, "default") - self.assertIsNotNone(self._inspector_toggle) - self.assertIsNone(self._point_checkbox) - self.assertIsNone(self._pixels_checkbox) - self.assertIsNone(self._objects_checkbox) - self.assertIsNone(self._close_toggle) - - self._inspector_toggle.value = True - - self.assertEqual(self.map_fake.cursor_style, "crosshair") - self.assertIsNotNone(self._inspector_toggle) - self.assertTrue(self._point_checkbox.value) - self.assertFalse(self._pixels_checkbox.value) - self.assertTrue(self._objects_checkbox.value) - self.assertIsNotNone(self._close_toggle.value) - - def test_inspector_close(self): - """Tests that toggling the close button fires the close event.""" - on_close_mock = Mock() - self.inspector.on_close = on_close_mock - self._close_toggle.value = True - - on_close_mock.assert_called_once() - self.assertEqual(self.map_fake.cursor_style, "default") - self.assertSetEqual(self.map_fake.interaction_handlers, set()) + self.assertEqual(self.inspector.point_info, {}) + self.assertEqual(self.inspector.pixel_info, {}) + self.assertEqual(self.inspector.object_info, {}) def test_map_empty_click(self): """Tests that clicking the map triggers inspection.""" self.map_fake.click((1, 2), "click") self.assertEqual(self.map_fake.cursor_style, "crosshair") - point_root = self._query_node(self.inspector, "Point (2.00, 1.00) at 1024m/px") - self.assertIsNotNone(point_root) - self.assertIsNotNone(self._query_node(point_root, "Longitude: 2")) - self.assertIsNotNone(self._query_node(point_root, "Latitude: 1")) - self.assertIsNotNone(self._query_node(point_root, "Zoom Level: 7")) - self.assertIsNotNone(self._query_node(point_root, "Scale (approx. m/px): 1024")) - self.assertIsNone(self._query_node(self.inspector, "Pixels")) - self.assertIsNone(self._query_node(self.inspector, "Objects")) + + expected_point_info = coreutils.new_tree_node( + "Point (2.00, 1.00) at 1024m/px", + [ + coreutils.new_tree_node("Longitude: 2"), + coreutils.new_tree_node("Latitude: 1"), + coreutils.new_tree_node("Zoom Level: 7"), + coreutils.new_tree_node("Scale (approx. m/px): 1024"), + ], + top_level=True, + ) + self.assertEqual(self.inspector.point_info, expected_point_info) + + expected_pixel_info = coreutils.new_tree_node( + "Pixels", top_level=True, expanded=True + ) + self.assertEqual(self.inspector.pixel_info, expected_pixel_info) + + expected_object_info = coreutils.new_tree_node( + "Objects", top_level=True, expanded=True + ) + self.assertEqual(self.inspector.object_info, expected_object_info) def test_map_click(self): """Tests that clicking the map triggers inspection.""" @@ -414,32 +363,62 @@ def test_map_click(self): } self.map_fake.click((1, 2), "click") - self.assertEqual(self.map_fake.cursor_style, "crosshair") - self.assertIsNotNone( - self._query_node(self.inspector, "Point (2.00, 1.00) at 1024m/px") - ) - - pixels_root = self._query_node(self.inspector, "Pixels") - self.assertIsNotNone(pixels_root) - layer_1_root = self._query_node(pixels_root, "test-map-1: Image (2 bands)") - self.assertIsNotNone(layer_1_root) - self.assertIsNotNone(self._query_node(layer_1_root, "B1: 42")) - self.assertIsNotNone(self._query_node(layer_1_root, "B2: 3.14")) - self.assertIsNone(self._query_node(pixels_root, "test-map-2: Image (2 bands)")) - - objects_root = self._query_node(self.inspector, "Objects") - self.assertIsNotNone(objects_root) - layer_3_root = self._query_node(objects_root, "test-map-3: Feature") - self.assertIsNotNone(layer_3_root) - self.assertIsNotNone(self._query_node(layer_3_root, "type: Feature")) - self.assertIsNotNone(self._query_node(layer_3_root, "id: 00000000000000000001")) - self.assertIsNotNone(self._query_node(layer_3_root, "fullname: some-full-name")) - self.assertIsNotNone(self._query_node(layer_3_root, "linearid: 110469267091")) - self.assertIsNotNone(self._query_node(layer_3_root, "mtfcc: S1400")) - self.assertIsNotNone(self._query_node(layer_3_root, "rttyp: some-rttyp")) + expected_point_info = coreutils.new_tree_node( + "Point (2.00, 1.00) at 1024m/px", + [ + coreutils.new_tree_node("Longitude: 2"), + coreutils.new_tree_node("Latitude: 1"), + coreutils.new_tree_node("Zoom Level: 7"), + coreutils.new_tree_node("Scale (approx. m/px): 1024"), + ], + top_level=True, + ) + self.assertEqual(self.inspector.point_info, expected_point_info) + + expected_pixel_info = coreutils.new_tree_node( + "Pixels", + [ + coreutils.new_tree_node( + "test-map-1: Image (2 bands)", + [ + coreutils.new_tree_node("B1: 42", expanded=True), + coreutils.new_tree_node("B2: 3.14", expanded=True), + ], + expanded=True, + ), + ], + top_level=True, + expanded=True, + ) + self.assertEqual(self.inspector.pixel_info, expected_pixel_info) + + expected_object_info = coreutils.new_tree_node( + "Objects", + [ + coreutils.new_tree_node( + "test-map-3: Feature", + [ + coreutils.new_tree_node("type: Feature"), + coreutils.new_tree_node("id: 00000000000000000001"), + coreutils.new_tree_node( + "properties: Object (4 properties)", + [ + coreutils.new_tree_node("fullname: some-full-name"), + coreutils.new_tree_node("linearid: 110469267091"), + coreutils.new_tree_node("mtfcc: S1400"), + coreutils.new_tree_node("rttyp: some-rttyp"), + ], + ), + ], + ), + ], + top_level=True, + expanded=True, + ) + self.assertEqual(self.inspector.object_info, expected_object_info) def test_map_click_twice(self): - """Tests that clicking the map a second time removes the original output.""" + """Tests that clicking the map a second time resets the point info.""" self.map_fake.ee_layers = { "test-map-1": { "ee_object": ee.Image(1), @@ -451,12 +430,36 @@ def test_map_click_twice(self): self.map_fake.click((1, 2), "click") self.map_fake.click((4, 1), "click") - self.assertIsNotNone( - self._query_node(self.inspector, "Point (1.00, 4.00) at 32m/px") - ) - self.assertIsNone( - self._query_node(self.inspector, "Point (2.00, 1.00) at 1024m/px") + expected_point_info = coreutils.new_tree_node( + "Point (1.00, 4.00) at 32m/px", + [ + coreutils.new_tree_node("Longitude: 1"), + coreutils.new_tree_node("Latitude: 4"), + coreutils.new_tree_node("Zoom Level: 7"), + coreutils.new_tree_node("Scale (approx. m/px): 32"), + ], + top_level=True, ) + self.assertEqual(self.inspector.point_info, expected_point_info) + + def test_map_click_expanded(self): + """Tests that nodes are expanded when the expand boolean is true.""" + self.inspector.expand_points = True + + self.map_fake.click((4, 1), "click") + + expected_point_info = coreutils.new_tree_node( + "Point (1.00, 4.00) at 1024m/px", + [ + coreutils.new_tree_node("Longitude: 1"), + coreutils.new_tree_node("Latitude: 4"), + coreutils.new_tree_node("Zoom Level: 7"), + coreutils.new_tree_node("Scale (approx. m/px): 1024"), + ], + top_level=True, + expanded=True, + ) + self.assertEqual(self.inspector.point_info, expected_point_info) def _create_fake_map() -> fake_map.FakeMap: