diff --git a/docs/api/map.md b/docs/api/map.md index 9e397374..6c9cf768 100644 --- a/docs/api/map.md +++ b/docs/api/map.md @@ -8,6 +8,3 @@ https://mkdocstrings.github.io/python/usage/configuration/members/#filters group_by_category: false show_bases: false filters: - - -::: lonboard.models.ViewState diff --git a/docs/api/view.md b/docs/api/view.md new file mode 100644 index 00000000..0321c9f6 --- /dev/null +++ b/docs/api/view.md @@ -0,0 +1,8 @@ +# lonboard.view + +::: lonboard.view.BaseView +::: lonboard.view.FirstPersonView +::: lonboard.view.GlobeView +::: lonboard.view.MapView +::: lonboard.view.OrbitView +::: lonboard.view.OrthographicView diff --git a/docs/api/view_state.md b/docs/api/view_state.md new file mode 100644 index 00000000..13e03f41 --- /dev/null +++ b/docs/api/view_state.md @@ -0,0 +1,8 @@ +# lonboard.view_state + +::: lonboard.view_state.BaseViewState +::: lonboard.view_state.MapViewState +::: lonboard.view_state.GlobeViewState +::: lonboard.view_state.FirstPersonViewState +::: lonboard.view_state.OrthographicViewState +::: lonboard.view_state.OrbitViewState diff --git a/examples/linked-maps.ipynb b/examples/linked-maps.ipynb index cd8e604d..d084b57d 100644 --- a/examples/linked-maps.ipynb +++ b/examples/linked-maps.ipynb @@ -71,7 +71,7 @@ "\n", "from lonboard import Map\n", "from lonboard.basemap import CartoBasemap\n", - "from lonboard.models import ViewState" + "from lonboard.view_state import MapViewState" ] }, { @@ -143,7 +143,7 @@ "outputs": [], "source": [ "def sync_positron_to_darkmatter(event: traitlets.utils.bunch.Bunch) -> None:\n", - " if isinstance(event.get(\"new\"), ViewState):\n", + " if isinstance(event.get(\"new\"), MapViewState):\n", " darkmatter_map.view_state = positron_map.view_state\n", "\n", "\n", @@ -151,7 +151,7 @@ "\n", "\n", "def sync_darkmatter_to_positron(event: traitlets.utils.bunch.Bunch) -> None:\n", - " if isinstance(event.get(\"new\"), ViewState):\n", + " if isinstance(event.get(\"new\"), MapViewState):\n", " positron_map.view_state = darkmatter_map.view_state\n", "\n", "\n", @@ -179,7 +179,7 @@ " event: traitlets.utils.bunch.Bunch,\n", " other_maps: Sequence[Map] = (),\n", ") -> None:\n", - " if isinstance(event.get(\"new\"), ViewState):\n", + " if isinstance(event.get(\"new\"), MapViewState):\n", " for lonboard_map in other_maps:\n", " lonboard_map.view_state = event[\"new\"]\n", "\n", diff --git a/lonboard/_map.py b/lonboard/_map.py index 22251c46..aa14309f 100644 --- a/lonboard/_map.py +++ b/lonboard/_map.py @@ -1,6 +1,7 @@ from __future__ import annotations import warnings +from dataclasses import replace from pathlib import Path from typing import IO, TYPE_CHECKING, Any, TextIO, overload @@ -21,8 +22,8 @@ ) from lonboard.layer import BaseLayer from lonboard.traits import HeightTrait, VariableLengthTuple, ViewStateTrait -from lonboard.traits._map import DEFAULT_INITIAL_VIEW_STATE -from lonboard.view import BaseView +from lonboard.view import BaseView, GlobeView, MapView +from lonboard.view_state import BaseViewState, GlobeViewState, MapViewState if TYPE_CHECKING: import sys @@ -154,13 +155,13 @@ def on_click(self, callback: Callable, *, remove: bool = False) -> None: _esm = bundler_output_dir / "index.js" _css = bundler_output_dir / "index.css" - # TODO: change this view state to allow non-map view states if we have non-map views - # Also allow a list/tuple of view states for multiple views - view_state = ViewStateTrait() + view_state: BaseViewState | None = ViewStateTrait() # type: ignore """ The view state of the map. - - Type: [`ViewState`][lonboard.models.ViewState] + - Type: A subclass of [`BaseViewState`][lonboard.view_state.BaseViewState], such as + [`MapViewState`][lonboard.view_state.MapViewState] or + [`GlobeViewState`][lonboard.view_state.GlobeViewState]. - Default: Automatically inferred from the data passed to the map. You can initialize the map to a specific view state using this property: @@ -492,8 +493,9 @@ def add_layer( elif reset_zoom: self.view_state = compute_view(self.layers) # type: ignore - def set_view_state( + def set_view_state( # noqa: PLR0913 self, + view_state: BaseViewState | None = None, *, longitude: float | None = None, latitude: float | None = None, @@ -505,6 +507,9 @@ def set_view_state( Any parameters that are unset will not be changed. + Args: + view_state: A complete view state object to set on the map. + Keyword Args: longitude: the new longitude to set on the map. Defaults to None. latitude: the new latitude to set on the map. Defaults to None. @@ -513,24 +518,38 @@ def set_view_state( bearing: the new bearing to set on the map. Defaults to None. """ - view_state = ( - self.view_state._asdict() # type: ignore - if self.view_state is not None - else DEFAULT_INITIAL_VIEW_STATE - ) + if view_state is not None: + self.view_state = view_state + return + current_view_state = self.view_state + + changes = {} if longitude is not None: - view_state["longitude"] = longitude + changes["longitude"] = longitude if latitude is not None: - view_state["latitude"] = latitude + changes["latitude"] = latitude if zoom is not None: - view_state["zoom"] = zoom + changes["zoom"] = zoom + + # Only params allowed by globe view state + if isinstance(current_view_state, GlobeViewState): + self.view_state = replace(current_view_state, **changes) + return + + # Add more params allowed by map view state if pitch is not None: - view_state["pitch"] = pitch + changes["pitch"] = pitch if bearing is not None: - view_state["bearing"] = bearing + changes["bearing"] = bearing + + if isinstance(current_view_state, MapViewState): + self.view_state = replace(current_view_state, **changes) + return - self.view_state = view_state + raise TypeError( + "Can only set MapViewState or GlobeViewState parameters individually via set_view_state.\nFor other view state types, pass a complete view_state object.", + ) def fly_to( # noqa: PLR0913 self, @@ -656,4 +675,7 @@ def as_html(self) -> HTML: @traitlets.default("view_state") def _default_initial_view_state(self) -> dict[str, Any]: - return compute_view(self.layers) # type: ignore + if isinstance(self.views, (MapView, GlobeView)): + return compute_view(self.layers) # type: ignore + + return {} diff --git a/lonboard/_serialization.py b/lonboard/_serialization.py index 40286db1..020863c5 100644 --- a/lonboard/_serialization.py +++ b/lonboard/_serialization.py @@ -2,6 +2,7 @@ import math from concurrent.futures import ThreadPoolExecutor +from dataclasses import asdict from io import BytesIO from typing import TYPE_CHECKING, Any, overload @@ -23,7 +24,7 @@ if TYPE_CHECKING: from lonboard.layer import BaseArrowLayer, TripsLayer - from lonboard.models import ViewState + from lonboard.view_state import BaseViewState DEFAULT_PARQUET_COMPRESSION = "ZSTD" @@ -158,13 +159,6 @@ def validate_accessor_length_matches_table( raise TraitError("accessor must have same length as table") -def serialize_view_state(data: ViewState | None, obj: Any) -> None | dict[str, Any]: # noqa: ARG001 - if data is None: - return None - - return data._asdict() - - def serialize_timestamp_accessor( timestamps: ChunkedArray, obj: TripsLayer, @@ -199,6 +193,20 @@ def serialize_timestamp_accessor( return serialize_accessor(f32_timestamps_col, obj) +def _to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + +def serialize_view_state(data: BaseViewState | None, _obj: Any) -> Any: + if data is None: + return None + + d = asdict(data) # type: ignore + # Convert to camel case and remove None values + return {_to_camel(k): v for k, v in d.items() if v is not None} + + ACCESSOR_SERIALIZATION = {"to_json": serialize_accessor} TIMESTAMP_ACCESSOR_SERIALIZATION = {"to_json": serialize_timestamp_accessor} TABLE_SERIALIZATION = {"to_json": serialize_table} diff --git a/lonboard/_viewport.py b/lonboard/_viewport.py index f1b6f0fe..ebaebf88 100644 --- a/lonboard/_viewport.py +++ b/lonboard/_viewport.py @@ -74,14 +74,10 @@ def compute_view(layers: Sequence[BaseLayer]) -> dict[str, Any]: "longitude": center.x or 0, "latitude": center.y or 0, "zoom": 0, - "pitch": 0, - "bearing": 0, } else: return { "longitude": center.x, "latitude": center.y, "zoom": zoom, - "pitch": 0, - "bearing": 0, } diff --git a/lonboard/models.py b/lonboard/models.py deleted file mode 100644 index a9204078..00000000 --- a/lonboard/models.py +++ /dev/null @@ -1,20 +0,0 @@ -from typing import NamedTuple - - -class ViewState(NamedTuple): - """State of a view position of a map.""" - - longitude: float - """Longitude at the map center""" - - latitude: float - """Latitude at the map center.""" - - zoom: float - """Zoom level.""" - - pitch: float - """Pitch angle in degrees. `0` is top-down.""" - - bearing: float - """Bearing angle in degrees. `0` is north.""" diff --git a/lonboard/traits/_map.py b/lonboard/traits/_map.py index dd7f26a2..365e5fad 100644 --- a/lonboard/traits/_map.py +++ b/lonboard/traits/_map.py @@ -7,8 +7,8 @@ from lonboard._environment import DEFAULT_HEIGHT from lonboard._serialization import serialize_view_state -from lonboard.models import ViewState from lonboard.traits._base import FixedErrorTraitType +from lonboard.view_state import BaseViewState, MapViewState if TYPE_CHECKING: from traitlets import HasTraits @@ -16,14 +16,6 @@ from lonboard._map import Map -DEFAULT_INITIAL_VIEW_STATE = { - "latitude": 10, - "longitude": 0, - "zoom": 0.5, - "bearing": 0, - "pitch": 0, -} - class BasemapUrl(traitlets.Unicode): """Validation for basemap url.""" @@ -79,7 +71,7 @@ class ViewStateTrait(FixedErrorTraitType): """Trait to validate view state input.""" allow_none = True - default_value = DEFAULT_INITIAL_VIEW_STATE + default_value = None def __init__( self: TraitType, @@ -90,16 +82,14 @@ def __init__( self.tag(sync=True, to_json=serialize_view_state) - def validate(self, obj: Map, value: Any) -> None | ViewState: + def validate(self, obj: Map, value: Any) -> None | BaseViewState: + view = obj.views if value is None: return None - if isinstance(value, ViewState): + if isinstance(value, BaseViewState): return value - if isinstance(value, dict): - value = {**DEFAULT_INITIAL_VIEW_STATE, **value} - return ViewState(**value) - - self.error(obj, value) - assert False + # Otherwise dict input + validator = view._view_state_type if view is not None else MapViewState # noqa: SLF001 + return validator(**value) # type: ignore diff --git a/lonboard/types/map.py b/lonboard/types/map.py index 37b0d71e..a11f8b3c 100644 --- a/lonboard/types/map.py +++ b/lonboard/types/map.py @@ -10,6 +10,7 @@ if TYPE_CHECKING: from lonboard.basemap import MaplibreBasemap + from lonboard.models import BaseViewState from lonboard.view import BaseView @@ -24,4 +25,4 @@ class MapKwargs(TypedDict, total=False): show_side_panel: bool use_device_pixels: int | float | bool views: BaseView | list[BaseView] | tuple[BaseView, ...] - view_state: dict[str, Any] + view_state: BaseViewState | dict[str, Any] diff --git a/lonboard/view.py b/lonboard/view.py index 0a2155ff..24256a6c 100644 --- a/lonboard/view.py +++ b/lonboard/view.py @@ -1,6 +1,14 @@ import traitlets as t from lonboard._base import BaseWidget +from lonboard.view_state import ( + BaseViewState, + FirstPersonViewState, + GlobeViewState, + MapViewState, + OrbitViewState, + OrthographicViewState, +) class BaseView(BaseWidget): @@ -10,6 +18,8 @@ class BaseView(BaseWidget): """ + _view_state_type: type[BaseViewState] = BaseViewState + x = t.Union([t.Int(), t.Unicode()], allow_none=True, default_value=None).tag( sync=True, ) @@ -51,6 +61,8 @@ class FirstPersonView(BaseView): _view_type = t.Unicode("first-person-view").tag(sync=True) + _view_state_type = FirstPersonViewState + projection_matrix = t.List( t.Float(), allow_none=True, @@ -98,6 +110,8 @@ class GlobeView(BaseView): _view_type = t.Unicode("globe-view").tag(sync=True) + _view_state_type = GlobeViewState + resolution = t.Float(allow_none=True, default_value=None).tag(sync=True) """The resolution at which to turn flat features into 3D meshes, in degrees. @@ -125,6 +139,8 @@ class MapView(BaseView): _view_type = t.Unicode("map-view").tag(sync=True) + _view_state_type = MapViewState + repeat = t.Bool(allow_none=True, default_value=None).tag(sync=True) """ Whether to render multiple copies of the map at low zoom levels. Default `false`. @@ -183,6 +199,8 @@ class OrbitView(BaseView): _view_type = t.Unicode("orbit-view").tag(sync=True) + _view_state_type = OrbitViewState + orbit_axis = t.Unicode(allow_none=True, default_value=None).tag(sync=True) """Axis with 360 degrees rotating freedom, either `'Y'` or `'Z'`, default to `'Z'`.""" @@ -233,6 +251,8 @@ class OrthographicView(BaseView): _view_type = t.Unicode("orthographic-view").tag(sync=True) + _view_state_type = OrthographicViewState + flip_y = t.Bool(allow_none=True, default_value=None).tag(sync=True) """ Whether to use top-left coordinates (`true`) or bottom-left coordinates (`false`). diff --git a/lonboard/view_state.py b/lonboard/view_state.py new file mode 100644 index 00000000..d238abdd --- /dev/null +++ b/lonboard/view_state.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +class BaseViewState: + """Base class for view states.""" + + +@dataclass(frozen=True) +class MapViewState(BaseViewState): + """State of a [MapView][lonboard.view.MapView].""" + + longitude: float = 0 + """longitude at the map center""" + + latitude: float = 10 + """latitude at the map center.""" + + zoom: float = 0.5 + """zoom level.""" + + pitch: float = 0 + """pitch angle in degrees. `0` is top-down.""" + + bearing: float = 0 + """bearing angle in degrees. `0` is north.""" + + max_zoom: float = 20 + """max zoom level.""" + + min_zoom: float = 0 + """min zoom level.""" + + max_pitch: float = 60 + """max pitch angle.""" + + min_pitch: float = 0 + """min pitch angle.""" + + +@dataclass(frozen=True) +class GlobeViewState(BaseViewState): + """State of a [GlobeView][lonboard.view.GlobeView].""" + + longitude: float + """longitude at the viewport center.""" + + latitude: float + """latitude at the viewport center.""" + + zoom: float + """zoom level.""" + + max_zoom: float = 20 + """max zoom level. Default 20.""" + + min_zoom: float = 0 + """min zoom level. Default 0.""" + + +@dataclass(frozen=True) +class FirstPersonViewState(BaseViewState): + """State of a [FirstPersonView][lonboard.view.FirstPersonView].""" + + longitude: float + """longitude of the camera position.""" + + latitude: float + """latitude of the camera position.""" + + position: tuple[float, float, float] = (0.0, 0.0, 0.0) + """Meter offsets of the camera from the lng-lat anchor point.""" + + bearing: float = 0.0 + """bearing angle in degrees. `0` is north.""" + + pitch: float = 0.0 + """pitch angle in degrees. `0` is horizontal.""" + + max_pitch: float = 90.0 + """max pitch angle. Default 90 (down).""" + + min_pitch: float = -90.0 + """min pitch angle. Default -90 (up).""" + + +@dataclass(frozen=True) +class OrthographicViewState(BaseViewState): + """State of an [OrthographicView][lonboard.view.OrthographicView].""" + + target: tuple[float, float, float] = (0.0, 0.0, 0.0) + """The world position at the center of the viewport.""" + + zoom: float | tuple[float, float] = 0.0 + """The zoom level of the viewport. + + - `zoom: 0` maps one unit distance to one pixel on screen, and increasing `zoom` by `1` scales the same object to twice as large. For example `zoom: 1` is 2x the original size, `zoom: 2` is 4x, `zoom: 3` is 8x etc. + + To apply independent zoom levels to the X and Y axes, supply a tuple [zoomX, zoomY]. + + Default 0. + """ + + min_zoom: float | None = None + """The min zoom level of the viewport. Default -Infinity.""" + + max_zoom: float | None = None + """The max zoom level of the viewport. Default Infinity.""" + + +@dataclass(frozen=True) +class OrbitViewState(BaseViewState): + """State of an [OrbitView][lonboard.view.OrbitView].""" + + target: tuple[float, float, float] = (0.0, 0.0, 0.0) + """The world position at the center of the viewport.""" + + rotation_orbit: float = 0.0 + """Rotating angle around orbit axis. Default 0.""" + + rotation_x: float = 0.0 + """Rotating angle around X axis. Default 0.""" + + zoom: float = 0.0 + """The zoom level of the viewport. + + `zoom: 0` maps one unit distance to one pixel on screen, and increasing `zoom` by + `1` scales the same object to twice as large. + + Default 0. + """ + + min_zoom: float | None = None + """The min zoom level of the viewport. Default -Infinity.""" + + max_zoom: float | None = None + """The max zoom level of the viewport. Default Infinity.""" + + min_rotation_x: float = -90.0 + """The min rotating angle around X axis. Default -90.""" + + max_rotation_x: float = 90.0 + """The max rotating angle around X axis. Default 90.""" diff --git a/mkdocs.yml b/mkdocs.yml index caeeea30..bb134b01 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -81,6 +81,8 @@ nav: - api/colormap.md - api/controls.md - api/traits.md + - api/view.md + - api/view_state.md - Experimental: - Layers: - api/layers/arc-layer.md diff --git a/src/index.tsx b/src/index.tsx index d57a401b..ab774168 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -39,14 +39,6 @@ import "./globals.css"; await initParquetWasm(); -const DEFAULT_INITIAL_VIEW_STATE = { - latitude: 10, - longitude: 0, - zoom: 0.5, - bearing: 0, - pitch: 0, -}; - function App() { const actorRef = MachineContext.useActorRef(); const isDrawingBBoxSelection = MachineContext.useSelector( @@ -191,11 +183,7 @@ function App() { mapStyle: basemapState?.style || DEFAULT_MAP_STYLE, customAttribution, deckRef, - initialViewState: ["longitude", "latitude", "zoom"].every((key) => - Object.keys(initialViewState).includes(key), - ) - ? initialViewState - : DEFAULT_INITIAL_VIEW_STATE, + initialViewState, layers: bboxSelectPolygonLayer ? layers.concat(bboxSelectPolygonLayer) : layers, @@ -211,14 +199,8 @@ function App() { // This condition is necessary to confirm that the viewState is // of type MapViewState. if ("latitude" in viewState) { - const { longitude, latitude, zoom, pitch, bearing } = viewState; - setViewState({ - longitude, - latitude, - zoom, - pitch, - bearing, - }); + // TODO: ensure all view state types get updated on the JS side + setViewState(viewState); } }, parameters: parameters || {}, diff --git a/tests/test_map.py b/tests/test_map.py index 4e6970b0..789b1371 100644 --- a/tests/test_map.py +++ b/tests/test_map.py @@ -3,6 +3,13 @@ from lonboard import Map, ScatterplotLayer, SolidPolygonLayer from lonboard.basemap import MaplibreBasemap +from lonboard.view import FirstPersonView, GlobeView, OrthographicView +from lonboard.view_state import ( + FirstPersonViewState, + GlobeViewState, + MapViewState, + OrthographicViewState, +) def test_map_fails_with_unexpected_argument(): @@ -49,3 +56,94 @@ def test_map_default_basemap(): assert m.basemap.mode == MaplibreBasemap().mode, "Should match default parameters" assert m.basemap.style == MaplibreBasemap().style, "Should match default parameters" + + +def test_view_state_empty_input(): + m = Map([], view_state={}) + assert m.view_state == MapViewState() + + +def test_view_state_partial_dict(): + view_state = { + "longitude": -122.45, + "latitude": 37.8, + } + m = Map([], view_state=view_state) + assert m.view_state == MapViewState(**view_state) + + +def test_view_state_globe_view_dict(): + view_state = { + "longitude": -122.45, + "latitude": 37.8, + "zoom": 2.0, + } + m = Map([], views=GlobeView(), view_state=view_state) + 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([], views=GlobeView(), view_state=view_state) + assert m.view_state == view_state + + +def test_view_state_first_person_dict(): + view_state = { + "longitude": -122.45, + "latitude": 37.8, + "position": [0, 0, 10], + } + m = Map([], views=FirstPersonView(), view_state=view_state) + assert m.view_state == FirstPersonViewState(**view_state) + + +def test_view_state_orthographic_view_empty(): + view_state = {} + m = Map([], views=OrthographicView(), view_state=view_state) + assert m.view_state == OrthographicViewState(**view_state) + + +def test_set_view_state_map_view_kwargs(): + m = Map([]) + set_state = {"longitude": -100, "latitude": 40, "zoom": 5} + m.set_view_state(**set_state) + assert m.view_state == MapViewState(**set_state) + + +def test_set_view_state_map_view_instance(): + m = Map([]) + set_state = MapViewState(longitude=-100, latitude=40, zoom=5) + m.set_view_state(set_state) + assert m.view_state == set_state + + +def test_set_view_state_partial_update(): + m = Map([], view_state={"longitude": -100, "latitude": 40, "zoom": 5}) + m.set_view_state(latitude=45) + assert m.view_state == MapViewState(longitude=-100, latitude=45, zoom=5) + + +def test_globe_view_state_partial_update(): + m = Map( + [], + views=GlobeView(), + view_state={"longitude": -100, "latitude": 40, "zoom": 5}, + ) + m.set_view_state(latitude=45) + assert m.view_state == GlobeViewState(longitude=-100, latitude=45, zoom=5) + + +def test_set_view_state_orbit(): + m = Map( + [], + views=FirstPersonView(), + view_state={"longitude": -100, "latitude": 40}, + ) + new_view_state = FirstPersonViewState( + longitude=-120, + latitude=50, + position=(0, 0, 10), + ) + m.set_view_state(new_view_state) + assert m.view_state == new_view_state