diff --git a/lonboard/_map.py b/lonboard/_map.py index 0f200316..486111e7 100644 --- a/lonboard/_map.py +++ b/lonboard/_map.py @@ -6,7 +6,6 @@ from typing import IO, TYPE_CHECKING, Any, TextIO, overload import ipywidgets -import traitlets import traitlets as t from ipywidgets import CallbackDispatcher @@ -31,8 +30,14 @@ from IPython.display import HTML # type: ignore + from lonboard._validators.types import TraitProposal from lonboard.types.map import MapKwargs + if sys.version_info >= (3, 11): + from typing import Self + else: + from typing_extensions import Self + if sys.version_info >= (3, 12): from typing import Unpack else: @@ -221,6 +226,23 @@ def on_click(self, callback: Callable, *, remove: bool = False) -> None: Views represent the "camera(s)" (essentially viewport dimensions and projection matrices) that you look at your data with. deck.gl offers multiple view types for both geospatial and non-geospatial use cases. Read the [Views and Projections](https://deck.gl/docs/developer-guide/views) guide for the concept and examples. """ + @t.validate("view") + def _validate_view( + self, + proposal: TraitProposal[t.Instance[BaseView | None], BaseView, Self], + ) -> BaseView: + # if proposed view is a globe view, ensure that basemap is interleaved + if ( + isinstance(proposal["value"], GlobeView) + and self.basemap is not None + and self.basemap.mode != "interleaved" + ): + raise t.TraitError( + "GlobeView requires the basemap mode to be 'interleaved'. Please set `basemap.mode='interleaved'`.", + ) + + return proposal["value"] + show_tooltip = t.Bool(default_value=False).tag(sync=True) """ Whether to render a tooltip on hover on the map. @@ -265,6 +287,27 @@ def on_click(self, callback: Callable, *, remove: bool = False) -> None: Pass `None` to disable rendering a basemap. """ + @t.validate("basemap") + def _validate_basemap( + self, + proposal: TraitProposal[ + t.Instance[MaplibreBasemap | None], + MaplibreBasemap, + Self, + ], + ) -> MaplibreBasemap | None: + # If proposed basemap is not interleaved, ensure current view is not globe view + if ( + proposal["value"] is not None + and proposal["value"].mode != "interleaved" + and isinstance(self.view, GlobeView) + ): + raise t.TraitError( + "GlobeView requires the basemap mode to be 'interleaved'. Please set `basemap.mode='interleaved'`.", + ) + + return proposal["value"] + @property def basemap_style(self) -> str | None: """The URL of the basemap style in use.""" @@ -673,7 +716,7 @@ def as_html(self) -> HTML: return HTML(self.to_html()) - @traitlets.default("view_state") + @t.default("view_state") def _default_initial_view_state(self) -> dict[str, Any]: if isinstance(self.view, (MapView, GlobeView)): return compute_view(self.layers) # type: ignore diff --git a/lonboard/_validators/__init__.py b/lonboard/_validators/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/lonboard/_validators/types.py b/lonboard/_validators/types.py new file mode 100644 index 00000000..564e507b --- /dev/null +++ b/lonboard/_validators/types.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from typing import Generic, TypedDict, TypeVar + +from traitlets import HasTraits, TraitType + +Trait = TypeVar("Trait", bound=TraitType) +Value = TypeVar("Value") +Owner = TypeVar("Owner", bound=HasTraits) + + +class TraitProposal(TypedDict, Generic[Trait, Value, Owner]): + """The type of a traitlets proposal. + + The input into a `@validate` method. + """ + + trait: Trait + value: Value + owner: Owner diff --git a/tests/test_map.py b/tests/test_map.py index 39fa9cc7..9f0dd55a 100644 --- a/tests/test_map.py +++ b/tests/test_map.py @@ -78,13 +78,23 @@ def test_view_state_globe_view_dict(): "latitude": 37.8, "zoom": 2.0, } - m = Map([], view=GlobeView(), view_state=view_state) + m = Map( + [], + view=GlobeView(), + view_state=view_state, + basemap=MaplibreBasemap(mode="interleaved"), + ) assert m.view_state == GlobeViewState(**view_state) def test_view_state_globe_view_instance(): view_state = GlobeViewState(longitude=-122.45, latitude=37.8, zoom=2.0) - m = Map([], view=GlobeView(), view_state=view_state) + m = Map( + [], + view=GlobeView(), + view_state=view_state, + basemap=MaplibreBasemap(mode="interleaved"), + ) assert m.view_state == view_state @@ -129,6 +139,7 @@ def test_globe_view_state_partial_update(): [], view=GlobeView(), view_state={"longitude": -100, "latitude": 40, "zoom": 5}, + basemap=MaplibreBasemap(mode="interleaved"), ) m.set_view_state(latitude=45) assert m.view_state == GlobeViewState(longitude=-100, latitude=45, zoom=5) @@ -147,3 +158,27 @@ def test_set_view_state_orbit(): ) m.set_view_state(new_view_state) assert m.view_state == new_view_state + + +def test_map_view_validate_globe_view_basemap(): + with pytest.raises( + TraitError, + match=r"GlobeView requires the basemap mode to be 'interleaved'.", + ): + Map([], view=GlobeView(), basemap=MaplibreBasemap(mode="overlaid")) + + # Start with interleaved then try to set overlaid + m = Map([], view=GlobeView(), basemap=MaplibreBasemap(mode="interleaved")) + with pytest.raises( + TraitError, + match=r"GlobeView requires the basemap mode to be 'interleaved'.", + ): + m.basemap = MaplibreBasemap(mode="overlaid") + + # Start with overlaid then try to set to GlobeView + m = Map([], basemap=MaplibreBasemap(mode="overlaid")) + with pytest.raises( + TraitError, + match=r"GlobeView requires the basemap mode to be 'interleaved'.", + ): + m.view = GlobeView()