diff --git a/eslint.config.js b/eslint.config.js index d65de095..b7a91583 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,9 +1,9 @@ -import globals from "globals"; import pluginJs from "@eslint/js"; -import tseslint from "typescript-eslint"; -import pluginReact from "eslint-plugin-react"; import eslintConfigPrettier from "eslint-config-prettier"; import pluginImport from "eslint-plugin-import"; +import pluginReact from "eslint-plugin-react"; +import globals from "globals"; +import tseslint from "typescript-eslint"; export default [ { files: ["**/*.{js,mjs,cjs,ts,jsx,tsx}"] }, diff --git a/lonboard/_map.py b/lonboard/_map.py index 579fa0fc..d80c7364 100644 --- a/lonboard/_map.py +++ b/lonboard/_map.py @@ -20,6 +20,7 @@ VariableLengthTuple, ViewStateTrait, ) +from lonboard.view import BaseView if TYPE_CHECKING: import sys @@ -151,6 +152,8 @@ 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() """ The view state of the map. @@ -174,6 +177,7 @@ def on_click(self, callback: Callable, *, remove: bool = False) -> None: once it's been initially rendered. """ + _has_click_handlers = t.Bool(default_value=False, allow_none=False).tag(sync=True) """ Indicates if a click handler has been registered. @@ -192,6 +196,15 @@ def on_click(self, callback: Callable, *, remove: bool = False) -> None: """One or more `Layer` objects to display on this map. """ + views: t.Instance[BaseView | None] = t.Instance(BaseView, allow_none=True).tag( + sync=True, + **ipywidgets.widget_serialization, + ) + """A View instance. + + Views represent the "camera(s)" (essentially viewport dimensions and projection matrices) that you look at your data with. deck.gl offers multiple view types for both geospatial and non-geospatial use cases. Read the [Views and Projections](https://deck.gl/docs/developer-guide/views) guide for the concept and examples. + """ + show_tooltip = t.Bool(default_value=False).tag(sync=True) """ Whether to render a tooltip on hover on the map. @@ -221,6 +234,9 @@ def on_click(self, callback: Callable, *, remove: bool = False) -> None: basemap: t.Instance[MaplibreBasemap | None] = t.Instance( MaplibreBasemap, + # If both `args` and `kw` are None, then the default value is None. + # Set empty kw so that the default is MaplibreBasemap() with default params + kw={}, allow_none=True, ).tag( sync=True, diff --git a/lonboard/traits.py b/lonboard/traits.py index fba25a4d..81c2738b 100644 --- a/lonboard/traits.py +++ b/lonboard/traits.py @@ -47,6 +47,7 @@ from traitlets.utils.sentinel import Sentinel from lonboard._layer import BaseArrowLayer + from lonboard._map import Map DEFAULT_INITIAL_VIEW_STATE = { "latitude": 10, @@ -943,7 +944,7 @@ def __init__( self.tag(sync=True, to_json=serialize_view_state) - def validate(self, obj: Any, value: Any) -> None | ViewState: + def validate(self, obj: Map, value: Any) -> None | ViewState: if value is None: return None diff --git a/lonboard/types/map.py b/lonboard/types/map.py index 57f7f89f..37b0d71e 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.view import BaseView class MapKwargs(TypedDict, total=False): @@ -22,4 +23,5 @@ class MapKwargs(TypedDict, total=False): show_tooltip: bool show_side_panel: bool use_device_pixels: int | float | bool + views: BaseView | list[BaseView] | tuple[BaseView, ...] view_state: dict[str, Any] diff --git a/lonboard/view.py b/lonboard/view.py new file mode 100644 index 00000000..0a2155ff --- /dev/null +++ b/lonboard/view.py @@ -0,0 +1,253 @@ +import traitlets as t + +from lonboard._base import BaseWidget + + +class BaseView(BaseWidget): + """A deck.gl View. + + The `View` class and its subclasses are used to specify where and how your deck.gl layers should be rendered. Applications typically instantiate at least one `View` subclass. + + """ + + x = t.Union([t.Int(), t.Unicode()], allow_none=True, default_value=None).tag( + sync=True, + ) + """The x position of the view. + + A relative (e.g. `'50%'`) or absolute position. Default `0`. + """ + + y = t.Union([t.Int(), t.Unicode()], allow_none=True, default_value=None).tag( + sync=True, + ) + """The y position of the view. + + A relative (e.g. `'50%'`) or absolute position. Default `0`. + """ + + width = t.Union([t.Int(), t.Unicode()], allow_none=True, default_value=None).tag( + sync=True, + ) + """The width of the view. + + A relative (e.g. `'50%'`) or absolute extent. Default `'100%'`. + """ + + height = t.Union([t.Int(), t.Unicode()], allow_none=True, default_value=None).tag( + sync=True, + ) + """The height of the view. + + A relative (e.g. `'50%'`) or absolute extent. Default `'100%'`. + """ + + +class FirstPersonView(BaseView): + """A deck.gl FirstPersonView. + + The `FirstPersonView` class is a subclass of `View` that describes a camera placed at a provided location, looking towards the direction and orientation specified by viewState. The behavior is similar to that of a first-person game. + """ + + _view_type = t.Unicode("first-person-view").tag(sync=True) + + projection_matrix = t.List( + t.Float(), + allow_none=True, + default_value=None, + minlen=16, + maxlen=16, + ).tag( + sync=True, + ) + """Projection matrix. + + If `projectionMatrix` is not supplied, the `View` class will build a projection matrix from the following parameters: + """ + + fovy = t.Float(allow_none=True, default_value=None).tag(sync=True) + """Field of view covered by camera, in the perspective case. In degrees. + + Default `50`. + """ + + near = t.Float(allow_none=True, default_value=None).tag(sync=True) + """Distance of near clipping plane. + + Default `0.1`. + """ + + far = t.Float(allow_none=True, default_value=None).tag(sync=True) + """Distance of far clipping plane. + + Default `1000`. + """ + + focal_distance = t.Float(allow_none=True, default_value=None).tag(sync=True) + """Modifier of viewport scale. + + Corresponds to the number of pixels per meter. Default `1`. + """ + + +class GlobeView(BaseView): + """A deck.gl GlobeView. + + The `GlobeView` class is a subclass of `View`. This view projects the earth into a 3D globe. + """ + + _view_type = t.Unicode("globe-view").tag(sync=True) + + 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. + + Smaller numbers will generate more detailed mesh. Default `10`. + """ + + near_z_multiplier = t.Float(allow_none=True, default_value=None).tag(sync=True) + """Scaler for the near plane, 1 unit equals to the height of the viewport. + + Default to `0.1`. Overwrites the `near` parameter. + """ + + far_z_multiplier = t.Float(allow_none=True, default_value=None).tag(sync=True) + """Scaler for the far plane, 1 unit equals to the distance from the camera to the top edge of the screen. + + Default to `2`. Overwrites the `far` parameter. + """ + + +class MapView(BaseView): + """A deck.gl MapView. + + The `MapView` class is a subclass of `View`. This viewport creates a camera that looks at a geospatial location on a map from a certain direction. The behavior of `MapView` is generally modeled after that of Mapbox GL JS. + """ + + _view_type = t.Unicode("map-view").tag(sync=True) + + 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`. + """ + + near_z_multiplier = t.Float(allow_none=True, default_value=None).tag(sync=True) + """Scaler for the near plane, 1 unit equals to the height of the viewport. + + Default to `0.1`. Overwrites the `near` parameter. + """ + + far_z_multiplier = t.Float(allow_none=True, default_value=None).tag(sync=True) + """Scaler for the far plane, 1 unit equals to the distance from the camera to the top edge of the screen. + + Default to `1.01`. Overwrites the `far` parameter. + """ + + projection_matrix = t.List( + t.Float(), + allow_none=True, + default_value=None, + minlen=16, + maxlen=16, + ).tag( + sync=True, + ) + """Projection matrix. + + If `projectionMatrix` is not supplied, the `View` class will build a projection matrix from the following parameters: + """ + + fovy = t.Float(allow_none=True, default_value=None).tag(sync=True) + """Field of view covered by camera, in the perspective case. In degrees. + + If not supplied, will be calculated from `altitude`. + """ + + altitude = t.Float(allow_none=True, default_value=None).tag(sync=True) + """Distance of the camera relative to viewport height. + + Default `1.5`. + """ + + orthographic = t.Bool(allow_none=True, default_value=None).tag(sync=True) + """Whether to create an orthographic or perspective projection matrix. + + Default is `false` (perspective projection). + """ + + +class OrbitView(BaseView): + """A deck.gl OrbitView. + + The `OrbitView` class is a subclass of `View` that creates a 3D camera that rotates around a target position. It is usually used for the examination of a 3D scene in non-geospatial use cases. + """ + + _view_type = t.Unicode("orbit-view").tag(sync=True) + + 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'`.""" + + projection_matrix = t.List( + t.Float(), + allow_none=True, + default_value=None, + minlen=16, + maxlen=16, + ).tag( + sync=True, + ) + """Projection matrix. + + If `projectionMatrix` is not supplied, the `View` class will build a projection matrix from the following parameters: + """ + + fovy = t.Float(allow_none=True, default_value=None).tag(sync=True) + """Field of view covered by camera, in the perspective case. In degrees. + + Default `50`. + """ + + near = t.Float(allow_none=True, default_value=None).tag(sync=True) + """Distance of near clipping plane. + + Default `0.1`. + """ + + far = t.Float(allow_none=True, default_value=None).tag(sync=True) + """Distance of far clipping plane. + + Default `1000`. + """ + + orthographic = t.Bool(allow_none=True, default_value=None).tag(sync=True) + """Whether to create an orthographic or perspective projection matrix. + + Default is `false` (perspective projection). + """ + + +class OrthographicView(BaseView): + """A deck.gl OrthographicView. + + The `OrthographicView` class is a subclass of `View` that creates a top-down view of the XY plane. It is usually used for rendering 2D charts in non-geospatial use cases. + """ + + _view_type = t.Unicode("orthographic-view").tag(sync=True) + + 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`). + + Default `true`. + """ + + near = t.Float(allow_none=True, default_value=None).tag(sync=True) + """Distance of near clipping plane. + + Default `0.1`. + """ + + far = t.Float(allow_none=True, default_value=None).tag(sync=True) + """Distance of far clipping plane. + + Default `1000`. + """ diff --git a/package-lock.json b/package-lock.json index 3d16a3ee..f8b6c0a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16494,6 +16494,7 @@ "integrity": "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", diff --git a/src/index.tsx b/src/index.tsx index 5d8f20e3..704539c1 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -17,6 +17,7 @@ import { initializeChildModels, } from "./model/index.js"; import { loadModel } from "./model/initialize.js"; +import { BaseViewModel, initializeView } from "./model/view.js"; import { initParquetWasm } from "./parquet.js"; import DeckFirstRenderer from "./renderers/deck-first.js"; import OverlayRenderer from "./renderers/overlay.js"; @@ -30,7 +31,7 @@ import { useViewStateDebounced } from "./state"; import Toolbar from "./toolbar.js"; import { getTooltip } from "./tooltip/index.js"; import { Message } from "./types.js"; -import { isDefined } from "./util.js"; +import { isDefined, isGlobeView } from "./util.js"; import { MachineContext, MachineProvider } from "./xstate"; import * as selectors from "./xstate/selectors"; @@ -90,6 +91,9 @@ function App() { ); const [parameters] = useModelState("parameters"); const [customAttribution] = useModelState("custom_attribution"); + const [mapId] = useState(uuidv4()); + const [childLayerIds] = useModelState("layers"); + const [viewIds] = useModelState("views"); // initialViewState is the value of view_state on the Python side. This is // called `initial` here because it gets passed in to deck's @@ -115,22 +119,19 @@ function App() { } }); - const [mapId] = useState(uuidv4()); - const [layersState, setLayersState] = useState< - Record - >({}); + // Fake state just to get react to re-render when a model callback is called + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [stateCounter, setStateCounter] = useState(new Date()); + const updateStateCallback = () => setStateCounter(new Date()); - const [childLayerIds] = useModelState("layers"); + ////////////////////// + // Basemap state + ////////////////////// const [basemapState, setBasemapState] = useState( null, ); - // Fake state just to get react to re-render when a model callback is called - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [stateCounter, setStateCounter] = useState(new Date()); - const updateStateCallback = () => setStateCounter(new Date()); - useEffect(() => { const loadBasemap = async () => { try { @@ -156,6 +157,14 @@ function App() { loadBasemap(); }, [basemapModelId]); + ////////////////////// + // Layers state + ////////////////////// + + const [layersState, setLayersState] = useState< + Record + >({}); + useEffect(() => { const loadAndUpdateLayers = async () => { try { @@ -192,6 +201,48 @@ function App() { layerModel.render(), ); + ////////////////////// + // Views state + ////////////////////// + + const [viewsState, setViewsState] = useState< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Record> + >({}); + + useEffect(() => { + const loadAndUpdateViews = async () => { + try { + if (!viewIds) { + setViewsState({}); + return; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const viewsModels = await initializeChildModels>( + model.widget_manager as IWidgetManager, + typeof viewIds === "string" ? [viewIds] : viewIds, + viewsState, + async (model: WidgetModel) => + initializeView(model, updateStateCallback), + ); + + setViewsState(viewsModels); + } catch (error) { + console.error("Error loading child views:", error); + } + }; + + loadAndUpdateViews(); + }, [viewIds]); + + const _deckViews = Object.values(viewsState).map((viewModel) => + viewModel.build(), + ); + // When the user hasn't specified any views, we let deck.gl create + // a default view, and so set undefined here. + const views = _deckViews.length > 0 ? _deckViews : undefined; + const onMapClickHandler = useCallback((info: PickingInfo) => { // We added this flag to prevent the hover event from firing after a // click event. @@ -261,6 +312,7 @@ function App() { } }, parameters: parameters || {}, + views, }; const overlayRenderProps: OverlayRendererProps = { @@ -283,7 +335,16 @@ function App() {
diff --git a/src/model/base.ts b/src/model/base.ts index 0d9faa08..39710dee 100644 --- a/src/model/base.ts +++ b/src/model/base.ts @@ -17,10 +17,6 @@ export abstract class BaseModel { this.callbacks.set("change", updateStateCallback); } - async loadSubModels() { - return; - } - /** * Initialize an attribute that does not need any transformation from its * serialized representation to its deck.gl representation. diff --git a/src/model/extension.ts b/src/model/extension.ts index 7ae75f7b..ede75be1 100644 --- a/src/model/extension.ts +++ b/src/model/extension.ts @@ -258,6 +258,5 @@ export async function initializeExtension( throw new Error(`no known model for extension type ${extensionType}`); } - await extensionModel.loadSubModels(); return extensionModel; } diff --git a/src/model/view.ts b/src/model/view.ts new file mode 100644 index 00000000..60a1cd6f --- /dev/null +++ b/src/model/view.ts @@ -0,0 +1,281 @@ +import { + FirstPersonView, + FirstPersonViewProps, + FirstPersonViewState, + _GlobeView as GlobeView, + GlobeViewProps, + GlobeViewState, + MapView, + MapViewProps, + MapViewState, + OrbitView, + OrbitViewProps, + OrbitViewState, + OrthographicView, + OrthographicViewProps, + OrthographicViewState, +} from "@deck.gl/core"; +import type { View } from "@deck.gl/core"; +import { CommonViewProps } from "@deck.gl/core/dist/views/view"; +import { WidgetModel } from "@jupyter-widgets/base"; + +import { isDefined } from "../util"; +import { BaseModel } from "./base"; + +export abstract class BaseViewModel extends BaseModel { + protected x: CommonViewProps["x"] | null; + protected y: CommonViewProps["y"] | null; + protected width: CommonViewProps["width"] | null; + protected height: CommonViewProps["height"] | null; + protected padding: CommonViewProps["padding"] | null; + + constructor(model: WidgetModel, updateStateCallback: () => void) { + super(model, updateStateCallback); + + this.initRegularAttribute("x", "x"); + this.initRegularAttribute("y", "y"); + this.initRegularAttribute("width", "width"); + this.initRegularAttribute("height", "height"); + this.initRegularAttribute("padding", "padding"); + } + + baseViewProps(): CommonViewProps { + return { + id: this.model.model_id, + ...(isDefined(this.x) && { x: this.x }), + ...(isDefined(this.y) && { y: this.y }), + ...(isDefined(this.width) && { width: this.width }), + ...(isDefined(this.height) && { height: this.height }), + ...(isDefined(this.padding) && { padding: this.padding }), + }; + } + + abstract viewProps(): Omit, "id">; + + abstract build(): View; +} + +export class FirstPersonViewModel extends BaseViewModel { + static viewType = "first-person-view"; + + protected projectionMatrix: FirstPersonViewProps["projectionMatrix"] | null; + protected fovy: FirstPersonViewProps["fovy"] | null; + protected near: FirstPersonViewProps["near"] | null; + protected far: FirstPersonViewProps["far"] | null; + protected focalDistance: FirstPersonViewProps["focalDistance"] | null; + + constructor(model: WidgetModel, updateStateCallback: () => void) { + super(model, updateStateCallback); + + this.initRegularAttribute("projection_matrix", "projectionMatrix"); + this.initRegularAttribute("fovy", "fovy"); + this.initRegularAttribute("near", "near"); + this.initRegularAttribute("far", "far"); + this.initRegularAttribute("focal_distance", "focalDistance"); + } + + viewProps(): Omit { + return { + ...(isDefined(this.projectionMatrix) && { + projectionMatrix: this.projectionMatrix, + }), + ...(isDefined(this.fovy) && { fovy: this.fovy }), + ...(isDefined(this.near) && { near: this.near }), + ...(isDefined(this.far) && { far: this.far }), + ...(isDefined(this.focalDistance) && { + focalDistance: this.focalDistance, + }), + }; + } + + build(): FirstPersonView { + return new FirstPersonView({ + ...this.baseViewProps(), + ...this.viewProps(), + }); + } +} + +export class GlobeViewModel extends BaseViewModel { + static viewType = "globe-view"; + + protected resolution: GlobeViewProps["resolution"] | null; + protected nearZMultiplier: GlobeViewProps["nearZMultiplier"] | null; + protected farZMultiplier: GlobeViewProps["farZMultiplier"] | null; + + constructor(model: WidgetModel, updateStateCallback: () => void) { + super(model, updateStateCallback); + + this.initRegularAttribute("resolution", "resolution"); + this.initRegularAttribute("near_z_multiplier", "nearZMultiplier"); + this.initRegularAttribute("far_z_multiplier", "farZMultiplier"); + } + + viewProps(): Omit { + return { + ...(isDefined(this.resolution) && { resolution: this.resolution }), + ...(isDefined(this.nearZMultiplier) && { + nearZMultiplier: this.nearZMultiplier, + }), + ...(isDefined(this.farZMultiplier) && { + farZMultiplier: this.farZMultiplier, + }), + }; + } + + build(): GlobeView { + return new GlobeView({ + ...this.baseViewProps(), + ...this.viewProps(), + }); + } +} + +export class MapViewModel extends BaseViewModel { + static viewType = "map-view"; + + protected repeat: MapViewProps["repeat"] | null; + protected nearZMultiplier: MapViewProps["nearZMultiplier"] | null; + protected farZMultiplier: MapViewProps["farZMultiplier"] | null; + protected projectionMatrix: MapViewProps["projectionMatrix"] | null; + protected fovy: MapViewProps["fovy"] | null; + protected altitude: MapViewProps["altitude"] | null; + protected orthographic: MapViewProps["orthographic"] | null; + + constructor(model: WidgetModel, updateStateCallback: () => void) { + super(model, updateStateCallback); + + this.initRegularAttribute("repeat", "repeat"); + this.initRegularAttribute("near_z_multiplier", "nearZMultiplier"); + this.initRegularAttribute("far_z_multiplier", "farZMultiplier"); + this.initRegularAttribute("projection_matrix", "projectionMatrix"); + this.initRegularAttribute("fovy", "fovy"); + this.initRegularAttribute("altitude", "altitude"); + this.initRegularAttribute("orthographic", "orthographic"); + } + + viewProps(): Omit { + return { + ...(isDefined(this.repeat) && { repeat: this.repeat }), + ...(isDefined(this.nearZMultiplier) && { + nearZMultiplier: this.nearZMultiplier, + }), + ...(isDefined(this.farZMultiplier) && { + farZMultiplier: this.farZMultiplier, + }), + ...(isDefined(this.projectionMatrix) && { + projectionMatrix: this.projectionMatrix, + }), + ...(isDefined(this.fovy) && { fovy: this.fovy }), + ...(isDefined(this.altitude) && { altitude: this.altitude }), + ...(isDefined(this.orthographic) && { orthographic: this.orthographic }), + }; + } + + build(): MapView { + return new MapView({ + ...this.baseViewProps(), + ...this.viewProps(), + }); + } +} + +export class OrbitViewModel extends BaseViewModel { + static viewType = "orbit-view"; + + protected orbitAxis: OrbitViewProps["orbitAxis"] | null; + protected projectionMatrix: OrbitViewProps["projectionMatrix"] | null; + protected fovy: OrbitViewProps["fovy"] | null; + protected near: OrbitViewProps["near"] | null; + protected far: OrbitViewProps["far"] | null; + protected orthographic: OrbitViewProps["orthographic"] | null; + + constructor(model: WidgetModel, updateStateCallback: () => void) { + super(model, updateStateCallback); + + this.initRegularAttribute("orbit_axis", "orbitAxis"); + this.initRegularAttribute("projection_matrix", "projectionMatrix"); + this.initRegularAttribute("fovy", "fovy"); + this.initRegularAttribute("near", "near"); + this.initRegularAttribute("far", "far"); + this.initRegularAttribute("orthographic", "orthographic"); + } + + viewProps(): Omit { + return { + ...(isDefined(this.orbitAxis) && { orbitAxis: this.orbitAxis }), + ...(isDefined(this.projectionMatrix) && { + projectionMatrix: this.projectionMatrix, + }), + ...(isDefined(this.fovy) && { fovy: this.fovy }), + ...(isDefined(this.near) && { near: this.near }), + ...(isDefined(this.far) && { far: this.far }), + ...(isDefined(this.orthographic) && { orthographic: this.orthographic }), + }; + } + + build(): OrbitView { + return new OrbitView({ + ...this.baseViewProps(), + ...this.viewProps(), + }); + } +} + +export class OrthographicViewModel extends BaseViewModel { + static viewType = "orthographic-view"; + + protected flipY: OrthographicViewProps["flipY"] | null; + protected near: OrthographicViewProps["near"] | null; + protected far: OrthographicViewProps["far"] | null; + + constructor(model: WidgetModel, updateStateCallback: () => void) { + super(model, updateStateCallback); + + this.initRegularAttribute("flip_y", "flipY"); + this.initRegularAttribute("near", "near"); + this.initRegularAttribute("far", "far"); + } + + viewProps(): Omit { + return { + ...(isDefined(this.flipY) && { flipY: this.flipY }), + ...(isDefined(this.near) && { near: this.near }), + ...(isDefined(this.far) && { far: this.far }), + }; + } + + build(): OrthographicView { + return new OrthographicView({ + ...this.baseViewProps(), + ...this.viewProps(), + }); + } +} + +export async function initializeView( + model: WidgetModel, + updateStateCallback: () => void, + // eslint-disable-next-line @typescript-eslint/no-explicit-any +): Promise> { + const viewType = model.get("_view_type"); + switch (viewType) { + case FirstPersonViewModel.viewType: + return new FirstPersonViewModel(model, updateStateCallback); + + case GlobeViewModel.viewType: + return new GlobeViewModel(model, updateStateCallback); + + case MapViewModel.viewType: + return new MapViewModel(model, updateStateCallback); + + case OrbitViewModel.viewType: + return new OrbitViewModel(model, updateStateCallback); + + case OrthographicViewModel.viewType: + return new OrthographicViewModel(model, updateStateCallback); + + default: + throw new Error(`no view supported for ${viewType}`); + } +} diff --git a/src/renderers/overlay.tsx b/src/renderers/overlay.tsx index 5509e79a..a2b96043 100644 --- a/src/renderers/overlay.tsx +++ b/src/renderers/overlay.tsx @@ -3,6 +3,7 @@ import React from "react"; import Map, { useControl } from "react-map-gl/maplibre"; import type { MapRendererProps, OverlayRendererProps } from "./types"; +import { isGlobeView } from "../util"; /** * DeckGLOverlay component that integrates deck.gl with react-map-gl @@ -28,7 +29,7 @@ const OverlayRenderer: React.FC = ( mapProps, ) => { // Remove maplibre-specific props before passing to DeckGL - const { mapStyle, customAttribution, initialViewState, ...deckProps } = + const { mapStyle, customAttribution, initialViewState, views, ...deckProps } = mapProps; return ( = ( mapStyle={mapStyle} attributionControl={{ customAttribution }} style={{ width: "100%", height: "100%" }} + {...(isGlobeView(views) && { projection: "globe" })} > = Pick< +type ViewOrViews = View | View[]; +export type MapRendererProps = Pick< DeckProps, | "getCursor" | "getTooltip" @@ -15,6 +15,7 @@ export type MapRendererProps = Pick< | "parameters" | "pickingRadius" | "useDevicePixels" + | "views" > & { mapStyle: string; customAttribution: string; diff --git a/src/util.ts b/src/util.ts index 647bc4c0..ec37c154 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,4 +1,9 @@ /** Check for null and undefined */ + +import { _GlobeView as GlobeView } from "@deck.gl/core"; + +import { MapRendererProps } from "./renderers"; + // https://stackoverflow.com/a/52097445 export function isDefined(value: T | undefined | null): value is T { return value !== undefined && value !== null; @@ -7,3 +12,8 @@ export function isDefined(value: T | undefined | null): value is T { export function makePolygon(pt1: number[], pt2: number[]) { return [pt1, [pt1[0], pt2[1]], pt2, [pt2[0], pt1[1]], pt1]; } + +export function isGlobeView(views: MapRendererProps["views"]) { + const firstView = Array.isArray(views) ? views[0] : views; + return firstView instanceof GlobeView; +} diff --git a/tests/test_map.py b/tests/test_map.py index 4dee1d30..4e6970b0 100644 --- a/tests/test_map.py +++ b/tests/test_map.py @@ -2,6 +2,7 @@ from traitlets import TraitError from lonboard import Map, ScatterplotLayer, SolidPolygonLayer +from lonboard.basemap import MaplibreBasemap def test_map_fails_with_unexpected_argument(): @@ -38,3 +39,13 @@ def allow_single_layer(): def test_map_basemap_non_url(): with pytest.raises(TraitError, match=r"expected to be a HTTP\(s\) URL"): _m = Map([], basemap_style="hello world") + + +def test_map_default_basemap(): + m = Map([]) + assert isinstance(m.basemap, MaplibreBasemap), ( + "Default basemap should be MaplibreBasemap" + ) + + assert m.basemap.mode == MaplibreBasemap().mode, "Should match default parameters" + assert m.basemap.style == MaplibreBasemap().style, "Should match default parameters"