Skip to content

Commit 7d769b8

Browse files
authored
feat: Implement view state validation for non map-view states (#1008)
In #908 we added support for other view modes. We now actively support GlobeView and _technically_ support other [deck.gl supported view modes](https://deck.gl/docs/developer-guide/views) (awaiting when someone actually tries to use them). This adds validation that ensures the correct view state dict is being used with the correct view classs Closes #948
1 parent 1217c03 commit 7d769b8

File tree

15 files changed

+354
-98
lines changed

15 files changed

+354
-98
lines changed

docs/api/map.md

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,3 @@ https://mkdocstrings.github.io/python/usage/configuration/members/#filters
88
group_by_category: false
99
show_bases: false
1010
filters:
11-
12-
13-
::: lonboard.models.ViewState

docs/api/view.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# lonboard.view
2+
3+
::: lonboard.view.BaseView
4+
::: lonboard.view.FirstPersonView
5+
::: lonboard.view.GlobeView
6+
::: lonboard.view.MapView
7+
::: lonboard.view.OrbitView
8+
::: lonboard.view.OrthographicView

docs/api/view_state.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# lonboard.view_state
2+
3+
::: lonboard.view_state.BaseViewState
4+
::: lonboard.view_state.MapViewState
5+
::: lonboard.view_state.GlobeViewState
6+
::: lonboard.view_state.FirstPersonViewState
7+
::: lonboard.view_state.OrthographicViewState
8+
::: lonboard.view_state.OrbitViewState

examples/linked-maps.ipynb

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@
7171
"\n",
7272
"from lonboard import Map\n",
7373
"from lonboard.basemap import CartoBasemap\n",
74-
"from lonboard.models import ViewState"
74+
"from lonboard.view_state import MapViewState"
7575
]
7676
},
7777
{
@@ -143,15 +143,15 @@
143143
"outputs": [],
144144
"source": [
145145
"def sync_positron_to_darkmatter(event: traitlets.utils.bunch.Bunch) -> None:\n",
146-
" if isinstance(event.get(\"new\"), ViewState):\n",
146+
" if isinstance(event.get(\"new\"), MapViewState):\n",
147147
" darkmatter_map.view_state = positron_map.view_state\n",
148148
"\n",
149149
"\n",
150150
"positron_map.observe(sync_positron_to_darkmatter)\n",
151151
"\n",
152152
"\n",
153153
"def sync_darkmatter_to_positron(event: traitlets.utils.bunch.Bunch) -> None:\n",
154-
" if isinstance(event.get(\"new\"), ViewState):\n",
154+
" if isinstance(event.get(\"new\"), MapViewState):\n",
155155
" positron_map.view_state = darkmatter_map.view_state\n",
156156
"\n",
157157
"\n",
@@ -179,7 +179,7 @@
179179
" event: traitlets.utils.bunch.Bunch,\n",
180180
" other_maps: Sequence[Map] = (),\n",
181181
") -> None:\n",
182-
" if isinstance(event.get(\"new\"), ViewState):\n",
182+
" if isinstance(event.get(\"new\"), MapViewState):\n",
183183
" for lonboard_map in other_maps:\n",
184184
" lonboard_map.view_state = event[\"new\"]\n",
185185
"\n",

lonboard/_map.py

Lines changed: 41 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import warnings
4+
from dataclasses import replace
45
from pathlib import Path
56
from typing import IO, TYPE_CHECKING, Any, TextIO, overload
67

@@ -21,8 +22,8 @@
2122
)
2223
from lonboard.layer import BaseLayer
2324
from lonboard.traits import HeightTrait, VariableLengthTuple, ViewStateTrait
24-
from lonboard.traits._map import DEFAULT_INITIAL_VIEW_STATE
25-
from lonboard.view import BaseView
25+
from lonboard.view import BaseView, GlobeView, MapView
26+
from lonboard.view_state import BaseViewState, GlobeViewState, MapViewState
2627

2728
if TYPE_CHECKING:
2829
import sys
@@ -154,13 +155,13 @@ def on_click(self, callback: Callable, *, remove: bool = False) -> None:
154155
_esm = bundler_output_dir / "index.js"
155156
_css = bundler_output_dir / "index.css"
156157

157-
# TODO: change this view state to allow non-map view states if we have non-map views
158-
# Also allow a list/tuple of view states for multiple views
159-
view_state = ViewStateTrait()
158+
view_state: BaseViewState | None = ViewStateTrait() # type: ignore
160159
"""
161160
The view state of the map.
162161
163-
- Type: [`ViewState`][lonboard.models.ViewState]
162+
- Type: A subclass of [`BaseViewState`][lonboard.view_state.BaseViewState], such as
163+
[`MapViewState`][lonboard.view_state.MapViewState] or
164+
[`GlobeViewState`][lonboard.view_state.GlobeViewState].
164165
- Default: Automatically inferred from the data passed to the map.
165166
166167
You can initialize the map to a specific view state using this property:
@@ -492,8 +493,9 @@ def add_layer(
492493
elif reset_zoom:
493494
self.view_state = compute_view(self.layers) # type: ignore
494495

495-
def set_view_state(
496+
def set_view_state( # noqa: PLR0913
496497
self,
498+
view_state: BaseViewState | None = None,
497499
*,
498500
longitude: float | None = None,
499501
latitude: float | None = None,
@@ -505,6 +507,9 @@ def set_view_state(
505507
506508
Any parameters that are unset will not be changed.
507509
510+
Args:
511+
view_state: A complete view state object to set on the map.
512+
508513
Keyword Args:
509514
longitude: the new longitude to set on the map. Defaults to None.
510515
latitude: the new latitude to set on the map. Defaults to None.
@@ -513,24 +518,38 @@ def set_view_state(
513518
bearing: the new bearing to set on the map. Defaults to None.
514519
515520
"""
516-
view_state = (
517-
self.view_state._asdict() # type: ignore
518-
if self.view_state is not None
519-
else DEFAULT_INITIAL_VIEW_STATE
520-
)
521+
if view_state is not None:
522+
self.view_state = view_state
523+
return
521524

525+
current_view_state = self.view_state
526+
527+
changes = {}
522528
if longitude is not None:
523-
view_state["longitude"] = longitude
529+
changes["longitude"] = longitude
524530
if latitude is not None:
525-
view_state["latitude"] = latitude
531+
changes["latitude"] = latitude
526532
if zoom is not None:
527-
view_state["zoom"] = zoom
533+
changes["zoom"] = zoom
534+
535+
# Only params allowed by globe view state
536+
if isinstance(current_view_state, GlobeViewState):
537+
self.view_state = replace(current_view_state, **changes)
538+
return
539+
540+
# Add more params allowed by map view state
528541
if pitch is not None:
529-
view_state["pitch"] = pitch
542+
changes["pitch"] = pitch
530543
if bearing is not None:
531-
view_state["bearing"] = bearing
544+
changes["bearing"] = bearing
545+
546+
if isinstance(current_view_state, MapViewState):
547+
self.view_state = replace(current_view_state, **changes)
548+
return
532549

533-
self.view_state = view_state
550+
raise TypeError(
551+
"Can only set MapViewState or GlobeViewState parameters individually via set_view_state.\nFor other view state types, pass a complete view_state object.",
552+
)
534553

535554
def fly_to( # noqa: PLR0913
536555
self,
@@ -656,4 +675,7 @@ def as_html(self) -> HTML:
656675

657676
@traitlets.default("view_state")
658677
def _default_initial_view_state(self) -> dict[str, Any]:
659-
return compute_view(self.layers) # type: ignore
678+
if isinstance(self.views, (MapView, GlobeView)):
679+
return compute_view(self.layers) # type: ignore
680+
681+
return {}

lonboard/_serialization.py

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import math
44
from concurrent.futures import ThreadPoolExecutor
5+
from dataclasses import asdict
56
from io import BytesIO
67
from typing import TYPE_CHECKING, Any, overload
78

@@ -23,7 +24,7 @@
2324

2425
if TYPE_CHECKING:
2526
from lonboard.layer import BaseArrowLayer, TripsLayer
26-
from lonboard.models import ViewState
27+
from lonboard.view_state import BaseViewState
2728

2829

2930
DEFAULT_PARQUET_COMPRESSION = "ZSTD"
@@ -158,13 +159,6 @@ def validate_accessor_length_matches_table(
158159
raise TraitError("accessor must have same length as table")
159160

160161

161-
def serialize_view_state(data: ViewState | None, obj: Any) -> None | dict[str, Any]: # noqa: ARG001
162-
if data is None:
163-
return None
164-
165-
return data._asdict()
166-
167-
168162
def serialize_timestamp_accessor(
169163
timestamps: ChunkedArray,
170164
obj: TripsLayer,
@@ -199,6 +193,20 @@ def serialize_timestamp_accessor(
199193
return serialize_accessor(f32_timestamps_col, obj)
200194

201195

196+
def _to_camel(s: str) -> str:
197+
parts = s.split("_")
198+
return parts[0] + "".join(p.title() for p in parts[1:])
199+
200+
201+
def serialize_view_state(data: BaseViewState | None, _obj: Any) -> Any:
202+
if data is None:
203+
return None
204+
205+
d = asdict(data) # type: ignore
206+
# Convert to camel case and remove None values
207+
return {_to_camel(k): v for k, v in d.items() if v is not None}
208+
209+
202210
ACCESSOR_SERIALIZATION = {"to_json": serialize_accessor}
203211
TIMESTAMP_ACCESSOR_SERIALIZATION = {"to_json": serialize_timestamp_accessor}
204212
TABLE_SERIALIZATION = {"to_json": serialize_table}

lonboard/_viewport.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,14 +74,10 @@ def compute_view(layers: Sequence[BaseLayer]) -> dict[str, Any]:
7474
"longitude": center.x or 0,
7575
"latitude": center.y or 0,
7676
"zoom": 0,
77-
"pitch": 0,
78-
"bearing": 0,
7977
}
8078
else:
8179
return {
8280
"longitude": center.x,
8381
"latitude": center.y,
8482
"zoom": zoom,
85-
"pitch": 0,
86-
"bearing": 0,
8783
}

lonboard/models.py

Lines changed: 0 additions & 20 deletions
This file was deleted.

lonboard/traits/_map.py

Lines changed: 8 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,23 +7,15 @@
77

88
from lonboard._environment import DEFAULT_HEIGHT
99
from lonboard._serialization import serialize_view_state
10-
from lonboard.models import ViewState
1110
from lonboard.traits._base import FixedErrorTraitType
11+
from lonboard.view_state import BaseViewState, MapViewState
1212

1313
if TYPE_CHECKING:
1414
from traitlets import HasTraits
1515
from traitlets.traitlets import TraitType
1616

1717
from lonboard._map import Map
1818

19-
DEFAULT_INITIAL_VIEW_STATE = {
20-
"latitude": 10,
21-
"longitude": 0,
22-
"zoom": 0.5,
23-
"bearing": 0,
24-
"pitch": 0,
25-
}
26-
2719

2820
class BasemapUrl(traitlets.Unicode):
2921
"""Validation for basemap url."""
@@ -79,7 +71,7 @@ class ViewStateTrait(FixedErrorTraitType):
7971
"""Trait to validate view state input."""
8072

8173
allow_none = True
82-
default_value = DEFAULT_INITIAL_VIEW_STATE
74+
default_value = None
8375

8476
def __init__(
8577
self: TraitType,
@@ -90,16 +82,14 @@ def __init__(
9082

9183
self.tag(sync=True, to_json=serialize_view_state)
9284

93-
def validate(self, obj: Map, value: Any) -> None | ViewState:
85+
def validate(self, obj: Map, value: Any) -> None | BaseViewState:
86+
view = obj.views
9487
if value is None:
9588
return None
9689

97-
if isinstance(value, ViewState):
90+
if isinstance(value, BaseViewState):
9891
return value
9992

100-
if isinstance(value, dict):
101-
value = {**DEFAULT_INITIAL_VIEW_STATE, **value}
102-
return ViewState(**value)
103-
104-
self.error(obj, value)
105-
assert False
93+
# Otherwise dict input
94+
validator = view._view_state_type if view is not None else MapViewState # noqa: SLF001
95+
return validator(**value) # type: ignore

lonboard/types/map.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
if TYPE_CHECKING:
1212
from lonboard.basemap import MaplibreBasemap
13+
from lonboard.models import BaseViewState
1314
from lonboard.view import BaseView
1415

1516

@@ -24,4 +25,4 @@ class MapKwargs(TypedDict, total=False):
2425
show_side_panel: bool
2526
use_device_pixels: int | float | bool
2627
views: BaseView | list[BaseView] | tuple[BaseView, ...]
27-
view_state: dict[str, Any]
28+
view_state: BaseViewState | dict[str, Any]

0 commit comments

Comments
 (0)