Skip to content

Commit 12300ad

Browse files
kylebarronbatpad
andauthored
Sync view state between JS and Python (#448)
Closes #112 In #444 @batpad mentioned > It does seem to make the map a bit slow for me, and we should investigate that, but in principle, this for me works as expected While playing around with that PR, I also saw some view state lagging at low zooms with lots of data rendered. In this screencast I'm smoothly zooming in, but the rendering is very jagged, and sometimes zooms back out slightly. **This screencast is of the previous PR** https://github.com/developmentseed/lonboard/assets/15164633/bbe0a168-b76e-4563-bc30-828d648210db My hypothesis of what's happening is that when rendering lots of data there's a slight lag for deck to call the `onViewStateChange` handler. And so deck will occasionally call the handler with "old" data and that's where you sometimes get the "snap back" in the scrolling. I think this is a really bad UX, and it seems not to happen when deck manages the view state internally. So I think potentially the best UX and performance solution is to decouple the view state from the model, while still syncing from deck to Python in the debounced model handler. This PR tries to implement this. --------- Co-authored-by: Sanjay Bhangar <[email protected]>
1 parent eafc862 commit 12300ad

File tree

11 files changed

+221
-35
lines changed

11 files changed

+221
-35
lines changed

Diff for: docs/api/map.md

+3
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,6 @@ 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

Diff for: lonboard/_map.py

+56-20
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from lonboard._layer import BaseLayer
1414
from lonboard._viewport import compute_view
1515
from lonboard.basemap import CartoBasemap
16+
from lonboard.traits import DEFAULT_INITIAL_VIEW_STATE, ViewStateTrait
1617
from lonboard.types.map import MapKwargs
1718

1819
if TYPE_CHECKING:
@@ -95,32 +96,28 @@ def __init__(
9596
_esm = bundler_output_dir / "index.js"
9697
_css = bundler_output_dir / "index.css"
9798

98-
_initial_view_state = traitlets.Dict().tag(sync=True)
99+
view_state = ViewStateTrait()
99100
"""
100-
The initial view state of the map.
101+
The view state of the map.
101102
102-
- Type: `dict`, optional
103+
- Type: [`ViewState`][lonboard.models.ViewState]
103104
- Default: Automatically inferred from the data passed to the map.
104105
105-
The keys _must_ include:
106+
You can initialize the map to a specific view state using this property:
106107
107-
- `longitude`: longitude at the map center.
108-
- `latitude`: latitude at the map center.
109-
- `zoom`: zoom level.
110-
111-
Keys may additionally include:
108+
```py
109+
Map(
110+
layers,
111+
view_state={"longitude": -74.0060, "latitude": 40.7128, "zoom": 7}
112+
)
113+
```
112114
113-
- `pitch` (float, optional) - pitch angle in degrees. Default `0` (top-down).
114-
- `bearing` (float, optional) - bearing angle in degrees. Default `0` (north).
115-
- `maxZoom` (float, optional) - max zoom level. Default `20`.
116-
- `minZoom` (float, optional) - min zoom level. Default `0`.
117-
- `maxPitch` (float, optional) - max pitch angle. Default `60`.
118-
- `minPitch` (float, optional) - min pitch angle. Default `0`.
115+
!!! note
119116
120-
Note that currently no camel-case/snake-case translation occurs for this method, and
121-
so keys must be in camel case.
117+
The properties of the view state are immutable. Use
118+
[`set_view_state`][lonboard.Map.set_view_state] to modify a map's view state
119+
once it's been initially rendered.
122120
123-
This API is not yet stabilized and may change in the future.
124121
"""
125122

126123
_height = traitlets.Int(default_value=DEFAULT_HEIGHT, allow_none=True).tag(
@@ -268,12 +265,51 @@ def __init__(
268265
global `parameters` when that layer is rendered.
269266
"""
270267

268+
def set_view_state(
269+
self,
270+
*,
271+
longitude: Optional[float] = None,
272+
latitude: Optional[float] = None,
273+
zoom: Optional[float] = None,
274+
pitch: Optional[float] = None,
275+
bearing: Optional[float] = None,
276+
) -> None:
277+
"""Set the view state of the map.
278+
279+
Any parameters that are unset will not be changed.
280+
281+
Other Args:
282+
longitude: the new longitude to set on the map. Defaults to None.
283+
latitude: the new latitude to set on the map. Defaults to None.
284+
zoom: the new zoom to set on the map. Defaults to None.
285+
pitch: the new pitch to set on the map. Defaults to None.
286+
bearing: the new bearing to set on the map. Defaults to None.
287+
"""
288+
view_state = (
289+
self.view_state._asdict() # type: ignore
290+
if self.view_state is not None
291+
else DEFAULT_INITIAL_VIEW_STATE
292+
)
293+
294+
if longitude is not None:
295+
view_state["longitude"] = longitude
296+
if latitude is not None:
297+
view_state["latitude"] = latitude
298+
if zoom is not None:
299+
view_state["zoom"] = zoom
300+
if pitch is not None:
301+
view_state["pitch"] = pitch
302+
if bearing is not None:
303+
view_state["bearing"] = bearing
304+
305+
self.view_state = view_state
306+
271307
def fly_to(
272308
self,
273309
*,
274310
longitude: Union[int, float],
275311
latitude: Union[int, float],
276-
zoom: int,
312+
zoom: float,
277313
duration: int = 4000,
278314
pitch: Union[int, float] = 0,
279315
bearing: Union[int, float] = 0,
@@ -333,6 +369,6 @@ def to_html(
333369
drop_defaults=False,
334370
)
335371

336-
@traitlets.default("_initial_view_state")
372+
@traitlets.default("view_state")
337373
def _default_initial_view_state(self):
338374
return compute_view(self.layers)

Diff for: lonboard/_serialization.py

+10-1
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import math
22
from io import BytesIO
3-
from typing import List, Tuple, Union
3+
from typing import List, Optional, Tuple, Union
44

55
import numpy as np
66
import pyarrow as pa
77
import pyarrow.parquet as pq
88
from numpy.typing import NDArray
99
from traitlets import TraitError
1010

11+
from lonboard.models import ViewState
12+
1113
DEFAULT_PARQUET_COMPRESSION = "ZSTD"
1214
DEFAULT_PARQUET_COMPRESSION_LEVEL = 7
1315
DEFAULT_PARQUET_CHUNK_SIZE = 2**16
@@ -88,5 +90,12 @@ def validate_accessor_length_matches_table(accessor, table):
8890
raise TraitError("accessor must have same length as table")
8991

9092

93+
def serialize_view_state(data: Optional[ViewState], obj):
94+
if data is None:
95+
return None
96+
97+
return data._asdict()
98+
99+
91100
ACCESSOR_SERIALIZATION = {"to_json": serialize_accessor}
92101
TABLE_SERIALIZATION = {"to_json": serialize_table}

Diff for: lonboard/_viewport.py

+14-2
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,18 @@ def compute_view(layers: List[BaseLayer]):
6262
# When no geo column is found, bbox will have inf values
6363
try:
6464
zoom = bbox_to_zoom_level(bbox)
65-
return {"longitude": center.x, "latitude": center.y, "zoom": zoom}
65+
return {
66+
"longitude": center.x,
67+
"latitude": center.y,
68+
"zoom": zoom,
69+
"pitch": 0,
70+
"bearing": 0,
71+
}
6672
except OverflowError:
67-
return {"longitude": center.x or 0, "latitude": center.y or 0, "zoom": 0}
73+
return {
74+
"longitude": center.x or 0,
75+
"latitude": center.y or 0,
76+
"zoom": 0,
77+
"pitch": 0,
78+
"bearing": 0,
79+
}

Diff for: lonboard/models.py

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from typing import NamedTuple
2+
3+
4+
class ViewState(NamedTuple):
5+
longitude: float
6+
"""Longitude at the map center"""
7+
8+
latitude: float
9+
"""Latitude at the map center."""
10+
11+
zoom: float
12+
"""Zoom level."""
13+
14+
pitch: float
15+
"""Pitch angle in degrees. `0` is top-down."""
16+
17+
bearing: float
18+
"""Bearing angle in degrees. `0` is north."""

Diff for: lonboard/traits.py

+38
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,18 @@
2121
from lonboard._serialization import (
2222
ACCESSOR_SERIALIZATION,
2323
TABLE_SERIALIZATION,
24+
serialize_view_state,
2425
)
2526
from lonboard._utils import get_geometry_column_index
27+
from lonboard.models import ViewState
28+
29+
DEFAULT_INITIAL_VIEW_STATE = {
30+
"latitude": 10,
31+
"longitude": 0,
32+
"zoom": 0.5,
33+
"bearing": 0,
34+
"pitch": 0,
35+
}
2636

2737

2838
# This is a custom subclass of traitlets.TraitType because its `error` method ignores
@@ -822,3 +832,31 @@ def validate(
822832

823833
self.error(obj, value)
824834
assert False
835+
836+
837+
class ViewStateTrait(FixedErrorTraitType):
838+
allow_none = True
839+
default_value = DEFAULT_INITIAL_VIEW_STATE
840+
841+
def __init__(
842+
self: TraitType,
843+
*args,
844+
**kwargs: Any,
845+
) -> None:
846+
super().__init__(*args, **kwargs)
847+
848+
self.tag(sync=True, to_json=serialize_view_state)
849+
850+
def validate(self, obj, value):
851+
if value is None:
852+
return None
853+
854+
if isinstance(value, ViewState):
855+
return value
856+
857+
if isinstance(value, dict):
858+
value = {**DEFAULT_INITIAL_VIEW_STATE, **value}
859+
return ViewState(**value)
860+
861+
self.error(obj, value)
862+
assert False

Diff for: src/actions/fly-to.ts

+1-2
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
1-
import { SetStateAction } from "react";
21
import { FlyToMessage } from "../types";
32
import { FlyToInterpolator, MapViewState } from "@deck.gl/core/typed";
43
import { isDefined } from "../util";
54

65
export function flyTo(
76
msg: FlyToMessage,
8-
setInitialViewState: (value: SetStateAction<MapViewState>) => void,
7+
setInitialViewState: (value: MapViewState) => void,
98
) {
109
const {
1110
longitude,

Diff for: src/index.tsx

+24-7
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { isDefined, loadChildModels } from "./util.js";
1313
import { v4 as uuidv4 } from "uuid";
1414
import { Message } from "./types.js";
1515
import { flyTo } from "./actions/fly-to.js";
16+
import { useViewStateDebounced } from "./state";
1617

1718
await initParquetWasm();
1819

@@ -64,25 +65,30 @@ async function getChildModelState(
6465
function App() {
6566
let model = useModel();
6667

67-
let [pythonInitialViewState] = useModelState<MapViewState>(
68-
"_initial_view_state",
69-
);
7068
let [mapStyle] = useModelState<string>("basemap_style");
7169
let [mapHeight] = useModelState<number>("_height");
7270
let [showTooltip] = useModelState<boolean>("show_tooltip");
7371
let [pickingRadius] = useModelState<number>("picking_radius");
7472
let [useDevicePixels] = useModelState<number | boolean>("use_device_pixels");
7573
let [parameters] = useModelState<object>("parameters");
7674

77-
let [initialViewState, setInitialViewState] = useState(
78-
pythonInitialViewState,
79-
);
75+
// initialViewState is the value of view_state on the Python side. This is
76+
// called `initial` here because it gets passed in to deck's
77+
// `initialViewState` param, as deck manages its own view state. Further
78+
// updates to `view_state` from Python are set on the deck `initialViewState`
79+
// property, which can set new camera state, as described here:
80+
// https://deck.gl/docs/developer-guide/interactivity
81+
//
82+
// `setViewState` is a debounced way to update the model and send view
83+
// state information back to Python.
84+
const [initialViewState, setViewState] =
85+
useViewStateDebounced<MapViewState>("view_state");
8086

8187
// Handle custom messages
8288
model.on("msg:custom", (msg: Message, buffers) => {
8389
switch (msg.type) {
8490
case "fly-to":
85-
flyTo(msg, setInitialViewState);
91+
flyTo(msg, setViewState);
8692
break;
8793

8894
default:
@@ -165,6 +171,17 @@ function App() {
165171
overAlloc: 1,
166172
poolSize: 0,
167173
}}
174+
onViewStateChange={(event) => {
175+
const { viewState } = event;
176+
const { longitude, latitude, zoom, pitch, bearing } = viewState;
177+
setViewState({
178+
longitude,
179+
latitude,
180+
zoom,
181+
pitch,
182+
bearing,
183+
});
184+
}}
168185
parameters={parameters || {}}
169186
>
170187
<Map mapStyle={mapStyle || DEFAULT_MAP_STYLE} />

Diff for: src/model/layer.ts

-3
Original file line numberDiff line numberDiff line change
@@ -584,9 +584,6 @@ export class PolygonModel extends BaseArrowLayerModel {
584584
}
585585

586586
layerProps(): Omit<GeoArrowPolygonLayerProps, "id"> {
587-
console.log("table", this.table);
588-
console.log("filled", this.filled);
589-
590587
return {
591588
data: this.table,
592589
...(isDefined(this.stroked) && { stroked: this.stroked }),

Diff for: src/state.ts

+47
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import * as React from "react";
2+
import { useModel } from "@anywidget/react";
3+
import type { AnyModel } from "@anywidget/types";
4+
import { debounce } from "./util";
5+
6+
const debouncedModelSaveViewState = debounce((model: AnyModel) => {
7+
// TODO: this and below is hard-coded to the view_state model property!
8+
const viewState = model.get("view_state");
9+
10+
// transitionInterpolator is sometimes a key in the view state while panning
11+
// This is a function object and so can't be serialized via JSON.
12+
//
13+
// In the future anywidget may support custom serializers for sending data
14+
// back from JS to Python. Until then, we need to clean the object ourselves.
15+
// Because this is in a debounce it shouldn't often mess with deck's internal
16+
// transition state it expects, because hopefully the transition will have
17+
// finished in the 300ms that the user has stopped panning.
18+
if ("transitionInterpolator" in viewState) {
19+
console.debug("Deleting transitionInterpolator!");
20+
delete viewState.transitionInterpolator;
21+
model.set("view_state", viewState);
22+
}
23+
24+
model.save_changes();
25+
}, 300);
26+
27+
// TODO: add a `wait` parameter here, instead of having it hard-coded?
28+
export function useViewStateDebounced<T>(key: string): [T, (value: T) => void] {
29+
let model = useModel();
30+
let [value, setValue] = React.useState(model.get(key));
31+
React.useEffect(() => {
32+
let callback = () => {
33+
setValue(model.get(key));
34+
};
35+
model.on(`change:${key}`, callback);
36+
return () => model.off(`change:${key}`, callback);
37+
}, [model, key]);
38+
return [
39+
value,
40+
(value) => {
41+
model.set(key, value);
42+
// Note: I think this has to be defined outside of this function so that
43+
// you're calling debounce on the same function object?
44+
debouncedModelSaveViewState(model);
45+
},
46+
];
47+
}

Diff for: src/util.ts

+10
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,13 @@ export async function loadChildModels(
2121
export function isDefined<T>(value: T | undefined | null): value is T {
2222
return value !== undefined && value !== null;
2323
}
24+
25+
// From https://gist.github.com/ca0v/73a31f57b397606c9813472f7493a940
26+
export function debounce<T extends Function>(cb: T, wait = 20) {
27+
let h: ReturnType<typeof setTimeout> | undefined;
28+
let callable = (...args: any) => {
29+
clearTimeout(h);
30+
h = setTimeout(() => cb(...args), wait);
31+
};
32+
return <T>(<any>callable);
33+
}

0 commit comments

Comments
 (0)