Skip to content

Commit 295dd5e

Browse files
committed
implement dataclasses for view states
1 parent 1217c03 commit 295dd5e

File tree

3 files changed

+234
-41
lines changed

3 files changed

+234
-41
lines changed

lonboard/_serialization.py

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import math
44
from concurrent.futures import ThreadPoolExecutor
55
from io import BytesIO
6-
from typing import TYPE_CHECKING, Any, overload
6+
from typing import TYPE_CHECKING, overload
77

88
import arro3.compute as ac
99
from arro3.core import (
@@ -23,7 +23,6 @@
2323

2424
if TYPE_CHECKING:
2525
from lonboard.layer import BaseArrowLayer, TripsLayer
26-
from lonboard.models import ViewState
2726

2827

2928
DEFAULT_PARQUET_COMPRESSION = "ZSTD"
@@ -158,13 +157,6 @@ def validate_accessor_length_matches_table(
158157
raise TraitError("accessor must have same length as table")
159158

160159

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-
168160
def serialize_timestamp_accessor(
169161
timestamps: ChunkedArray,
170162
obj: TripsLayer,

lonboard/models.py

Lines changed: 149 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,159 @@
1-
from typing import NamedTuple
1+
from __future__ import annotations
22

3+
from dataclasses import asdict, dataclass
4+
from typing import Any
35

4-
class ViewState(NamedTuple):
5-
"""State of a view position of a map."""
6+
7+
class BaseViewState:
8+
"""Base class for view states."""
9+
10+
11+
@dataclass(frozen=True)
12+
class MapViewState(BaseViewState):
13+
"""State of a [MapView][lonboard.view.MapView]."""
14+
15+
longitude: float = 0
16+
"""longitude at the map center"""
17+
18+
latitude: float = 10
19+
"""latitude at the map center."""
20+
21+
zoom: float = 0.5
22+
"""zoom level."""
23+
24+
pitch: float = 0
25+
"""pitch angle in degrees. `0` is top-down."""
26+
27+
bearing: float = 0
28+
"""bearing angle in degrees. `0` is north."""
29+
30+
max_zoom: float = 20
31+
"""max zoom level."""
32+
33+
min_zoom: float = 0
34+
"""min zoom level."""
35+
36+
max_pitch: float = 60
37+
"""max pitch angle."""
38+
39+
min_pitch: float = 0
40+
"""min pitch angle."""
41+
42+
43+
@dataclass(frozen=True)
44+
class GlobeViewState(BaseViewState):
45+
"""State of a [GlobeView][lonboard.view.GlobeView]."""
646

747
longitude: float
8-
"""Longitude at the map center"""
48+
"""longitude at the viewport center."""
949

1050
latitude: float
11-
"""Latitude at the map center."""
51+
"""latitude at the viewport center."""
1252

1353
zoom: float
14-
"""Zoom level."""
54+
"""zoom level."""
55+
56+
max_zoom: float = 20
57+
"""max zoom level. Default 20."""
58+
59+
min_zoom: float = 0
60+
"""min zoom level. Default 0."""
61+
62+
63+
@dataclass(frozen=True)
64+
class FirstPersonViewState(BaseViewState):
65+
"""State of a [FirstPersonView][lonboard.view.FirstPersonView]."""
66+
67+
longitude: float
68+
"""longitude of the camera position."""
69+
70+
latitude: float
71+
"""latitude of the camera position."""
72+
73+
position: tuple[float, float, float] = (0.0, 0.0, 0.0)
74+
"""Meter offsets of the camera from the lng-lat anchor point."""
75+
76+
bearing: float = 0.0
77+
"""bearing angle in degrees. `0` is north."""
78+
79+
pitch: float = 0.0
80+
"""pitch angle in degrees. `0` is horizontal."""
81+
82+
max_pitch: float = 90.0
83+
"""max pitch angle. Default 90 (down)."""
84+
85+
min_pitch: float = -90.0
86+
"""min pitch angle. Default -90 (up)."""
87+
88+
89+
@dataclass(frozen=True)
90+
class OrthographicViewState(BaseViewState):
91+
"""State of an [OrthographicView][lonboard.view.OrthographicView]."""
92+
93+
target: tuple[float, float, float] = (0.0, 0.0, 0.0)
94+
"""The world position at the center of the viewport."""
95+
96+
zoom: float | tuple[float, float] = 0.0
97+
"""The zoom level of the viewport.
98+
99+
- `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.
100+
101+
To apply independent zoom levels to the X and Y axes, supply a tuple [zoomX, zoomY].
102+
103+
Default 0.
104+
"""
105+
106+
min_zoom: float | None = None
107+
"""The min zoom level of the viewport. Default -Infinity."""
108+
109+
max_zoom: float | None = None
110+
"""The max zoom level of the viewport. Default Infinity."""
111+
112+
113+
@dataclass(frozen=True)
114+
class OrbitViewState(BaseViewState):
115+
"""State of an [OrbitView][lonboard.view.OrbitView]."""
116+
117+
target: tuple[float, float, float] = (0.0, 0.0, 0.0)
118+
"""The world position at the center of the viewport."""
119+
120+
rotation_orbit: float = 0.0
121+
"""Rotating angle around orbit axis. Default 0."""
122+
123+
rotation_x: float = 0.0
124+
"""Rotating angle around X axis. Default 0."""
125+
126+
zoom: float = 0.0
127+
"""The zoom level of the viewport.
128+
129+
`zoom: 0` maps one unit distance to one pixel on screen, and increasing `zoom` by
130+
`1` scales the same object to twice as large.
131+
132+
Default 0.
133+
"""
134+
135+
min_zoom: float | None = None
136+
"""The min zoom level of the viewport. Default -Infinity."""
137+
138+
max_zoom: float | None = None
139+
"""The max zoom level of the viewport. Default Infinity."""
140+
141+
min_rotation_x: float = -90.0
142+
"""The min rotating angle around X axis. Default -90."""
143+
144+
max_rotation_x: float = 90.0
145+
"""The max rotating angle around X axis. Default 90."""
146+
147+
148+
def _to_camel(s: str) -> str:
149+
parts = s.split("_")
150+
return parts[0] + "".join(p.title() for p in parts[1:])
151+
15152

16-
pitch: float
17-
"""Pitch angle in degrees. `0` is top-down."""
153+
def _serialize_view_state(data: BaseViewState | None, _obj: Any) -> Any:
154+
if data is None:
155+
return None
18156

19-
bearing: float
20-
"""Bearing angle in degrees. `0` is north."""
157+
d = asdict(data) # type: ignore
158+
# Convert to camel case and remove None values
159+
return {_to_camel(k): v for k, v in d.items() if v is not None}

lonboard/traits/_map.py

Lines changed: 84 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,24 +6,31 @@
66
import traitlets
77

88
from lonboard._environment import DEFAULT_HEIGHT
9-
from lonboard._serialization import serialize_view_state
10-
from lonboard.models import ViewState
9+
from lonboard.models import (
10+
BaseViewState,
11+
FirstPersonViewState,
12+
GlobeViewState,
13+
MapViewState,
14+
OrbitViewState,
15+
OrthographicViewState,
16+
_serialize_view_state,
17+
)
1118
from lonboard.traits._base import FixedErrorTraitType
19+
from lonboard.view import (
20+
BaseView,
21+
FirstPersonView,
22+
GlobeView,
23+
MapView,
24+
OrbitView,
25+
OrthographicView,
26+
)
1227

1328
if TYPE_CHECKING:
1429
from traitlets import HasTraits
1530
from traitlets.traitlets import TraitType
1631

1732
from lonboard._map import Map
1833

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

2835
class BasemapUrl(traitlets.Unicode):
2936
"""Validation for basemap url."""
@@ -75,11 +82,20 @@ def validate(self, obj: Any, value: Any) -> str:
7582
assert False
7683

7784

85+
VIEW_STATE_VALIDATORS: dict[type[BaseView], type[BaseViewState]] = {
86+
MapView: MapViewState,
87+
GlobeView: GlobeViewState,
88+
FirstPersonView: FirstPersonViewState,
89+
OrbitView: OrbitViewState,
90+
OrthographicView: OrthographicViewState,
91+
}
92+
93+
7894
class ViewStateTrait(FixedErrorTraitType):
7995
"""Trait to validate view state input."""
8096

8197
allow_none = True
82-
default_value = DEFAULT_INITIAL_VIEW_STATE
98+
default_value = None
8399

84100
def __init__(
85101
self: TraitType,
@@ -88,18 +104,64 @@ def __init__(
88104
) -> None:
89105
super().__init__(*args, **kwargs)
90106

91-
self.tag(sync=True, to_json=serialize_view_state)
107+
self.tag(sync=True, to_json=_serialize_view_state)
92108

93-
def validate(self, obj: Map, value: Any) -> None | ViewState:
94-
if value is None:
95-
return None
109+
def validate(self, obj: Map, value: Any) -> None | BaseViewState:
110+
view = obj.views
111+
if view is None:
112+
return MapViewState() if value is None else value
113+
else: # noqa: RET505 (typing issue)
114+
validator = VIEW_STATE_VALIDATORS.get(type(view))
115+
if validator is None:
116+
self.error(obj, value, info="unsupported view type")
117+
assert False
96118

97-
if isinstance(value, ViewState):
98-
return value
119+
return validator(value) # type: ignore
120+
# view
99121

100-
if isinstance(value, dict):
101-
value = {**DEFAULT_INITIAL_VIEW_STATE, **value}
102-
return ViewState(**value)
122+
# reveal_type(view)
123+
# view
103124

104-
self.error(obj, value)
105-
assert False
125+
# if isinstance(view, MapView) or view is None:
126+
# if value is None:
127+
# return MapViewState()
128+
129+
# if isinstance(value, MapViewState):
130+
# return value
131+
132+
# if isinstance(value, dict):
133+
# return MapViewState(**value)
134+
135+
# elif isinstance(view, GlobeView):
136+
# if isinstance(value, GlobeViewState):
137+
# return value
138+
139+
# if isinstance(value, dict):
140+
# return GlobeViewState(**value)
141+
142+
# elif isinstance(view, FirstPersonView):
143+
# if isinstance(value, FirstPersonViewState):
144+
# return value
145+
146+
# if isinstance(value, dict):
147+
# return FirstPersonViewState(**value)
148+
149+
# elif isinstance(view, OrbitView):
150+
# if isinstance(value, OrbitViewState):
151+
# return value
152+
153+
# if isinstance(value, dict):
154+
# return OrbitViewState(**value)
155+
156+
# elif isinstance(view, OrthographicView):
157+
# if isinstance(value, OrthographicViewState):
158+
# return value
159+
160+
# if isinstance(value, dict):
161+
# return OrthographicViewState(**value)
162+
163+
# if value is None:
164+
# return None
165+
166+
# self.error(obj, value)
167+
# assert False

0 commit comments

Comments
 (0)