diff --git a/lonboard/traits/_map.py b/lonboard/traits/_map.py index 95aacf26..6d8f83ad 100644 --- a/lonboard/traits/_map.py +++ b/lonboard/traits/_map.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re from typing import TYPE_CHECKING, Any from urllib.parse import urlparse @@ -92,4 +93,12 @@ def validate(self, obj: Map, value: Any) -> None | BaseViewState: # Otherwise dict input view = obj.view validator = view._view_state_type if view is not None else MapViewState # noqa: SLF001 - return validator(**value) # type: ignore + + # The frontend currently sends back data in camelCase + snake_case_kwargs = {_camel_to_snake(k): v for k, v in value.items()} + return validator(**snake_case_kwargs) # type: ignore + + +def _camel_to_snake(name: str) -> str: + s1 = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name) + return re.sub("([a-z0-9])([A-Z])", r"\1_\2", s1).lower() diff --git a/src/index.tsx b/src/index.tsx index e45a0fba..5fb3f9da 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -30,7 +30,7 @@ import { useViewStateDebounced } from "./state"; import Toolbar from "./toolbar.js"; import { getTooltip } from "./tooltip/index.js"; import { Message } from "./types.js"; -import { isDefined, isGlobeView } from "./util.js"; +import { isDefined, isGlobeView, sanitizeViewState } from "./util.js"; import { MachineContext, MachineProvider } from "./xstate"; import * as selectors from "./xstate/selectors"; @@ -194,14 +194,7 @@ function App() { onHover: onMapHoverHandler, ...(isDefined(useDevicePixels) && { useDevicePixels }), onViewStateChange: (event) => { - const { viewState } = event; - - // This condition is necessary to confirm that the viewState is - // of type MapViewState. - if (viewState && "latitude" in viewState) { - // TODO: ensure all view state types get updated on the JS side - setViewState(viewState); - } + setViewState(sanitizeViewState(views, event.viewState)); }, parameters: parameters || {}, views, diff --git a/src/state.ts b/src/state.ts index 47608163..29e4263d 100644 --- a/src/state.ts +++ b/src/state.ts @@ -3,24 +3,7 @@ import type { AnyModel } from "@anywidget/types"; import debounce from "lodash.debounce"; import * as React from "react"; -const debouncedModelSaveViewState = debounce((model: AnyModel) => { - // TODO: this and below is hard-coded to the view_state model property! - const viewState = model.get("view_state"); - - // transitionInterpolator is sometimes a key in the view state while panning - // This is a function object and so can't be serialized via JSON. - // - // In the future anywidget may support custom serializers for sending data - // back from JS to Python. Until then, we need to clean the object ourselves. - // Because this is in a debounce it shouldn't often mess with deck's internal - // transition state it expects, because hopefully the transition will have - // finished in the 300ms that the user has stopped panning. - if ("transitionInterpolator" in viewState) { - console.debug("Deleting transitionInterpolator!"); - delete viewState.transitionInterpolator; - model.set("view_state", viewState); - } - +const debouncedModelSaveChanges = debounce((model: AnyModel) => { model.save_changes(); }, 300); @@ -41,7 +24,7 @@ export function useViewStateDebounced(key: string): [T, (value: T) => void] { model.set(key, value); // Note: I think this has to be defined outside of this function so that // you're calling debounce on the same function object? - debouncedModelSaveViewState(model); + debouncedModelSaveChanges(model); }, ]; } diff --git a/src/util.ts b/src/util.ts index ec37c154..ecc2f21b 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,6 +1,11 @@ /** Check for null and undefined */ -import { _GlobeView as GlobeView } from "@deck.gl/core"; +import { + _GlobeView as GlobeView, + GlobeViewState, + MapView, + MapViewState, +} from "@deck.gl/core"; import { MapRendererProps } from "./renderers"; @@ -17,3 +22,31 @@ export function isGlobeView(views: MapRendererProps["views"]) { const firstView = Array.isArray(views) ? views[0] : views; return firstView instanceof GlobeView; } + +export function isMapView(views: MapRendererProps["views"]) { + const firstView = Array.isArray(views) ? views[0] : views; + return firstView instanceof MapView; +} + +export function sanitizeViewState( + views: MapRendererProps["views"], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + viewState: (MapViewState | GlobeViewState) & Record, +): MapViewState | GlobeViewState { + const sanitized: MapViewState | GlobeViewState = { + longitude: Number.isFinite(viewState.longitude) ? viewState.longitude : 0, + latitude: Number.isFinite(viewState.latitude) ? viewState.latitude : 0, + zoom: Number.isFinite(viewState.zoom) ? viewState.zoom : 0, + ...(Number.isFinite(viewState.minZoom) + ? { + minZoom: viewState.minZoom, + } + : 0), + ...(Number.isFinite(viewState.maxZoom) + ? { + maxZoom: viewState.maxZoom, + } + : 0), + }; + return sanitized; +}