From 02d6e132301b166c3e51cece63fceae604a34a54 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 8 Oct 2025 10:12:56 -0400 Subject: [PATCH 01/20] View model definitions --- lonboard/view.py | 227 +++++++++++++++++++++++++++++++++++++++++ src/model/view.ts | 252 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 479 insertions(+) create mode 100644 lonboard/view.py create mode 100644 src/model/view.ts diff --git a/lonboard/view.py b/lonboard/view.py new file mode 100644 index 00000000..8fa4629b --- /dev/null +++ b/lonboard/view.py @@ -0,0 +1,227 @@ +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()], 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()], 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()], 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()], 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(), 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(default_value=None).tag(sync=True) + """Field of view covered by camera, in the perspective case. In degrees. + + Default `50`. + """ + + near = t.Float(default_value=None).tag(sync=True) + """Distance of near clipping plane. + + Default `0.1`. + """ + + far = t.Float(default_value=None).tag(sync=True) + """Distance of far clipping plane. + + Default `1000`. + """ + + focal_distance = t.Float(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(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(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(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(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(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(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(), 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(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(default_value=None).tag(sync=True) + """Distance of the camera relative to viewport height. + + Default `1.5`. + """ + + orthographic = t.Bool(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(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(), 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(default_value=None).tag(sync=True) + """Field of view covered by camera, in the perspective case. In degrees. + + Default `50`. + """ + + near = t.Float(default_value=None).tag(sync=True) + """Distance of near clipping plane. + + Default `0.1`. + """ + + far = t.Float(default_value=None).tag(sync=True) + """Distance of far clipping plane. + + Default `1000`. + """ + + orthographic = t.Bool(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(default_value=None).tag(sync=True) + """ + Whether to use top-left coordinates (`true`) or bottom-left coordinates (`false`). + + Default `true`. + """ + + near = t.Float(default_value=None).tag(sync=True) + """Distance of near clipping plane. + + Default `0.1`. + """ + + far = t.Float(default_value=None).tag(sync=True) + """Distance of far clipping plane. + + Default `1000`. + """ diff --git a/src/model/view.ts b/src/model/view.ts new file mode 100644 index 00000000..b99c74c4 --- /dev/null +++ b/src/model/view.ts @@ -0,0 +1,252 @@ +import { + FirstPersonView, + FirstPersonViewProps, + FirstPersonViewState, + _GlobeView as GlobeView, + GlobeViewProps, + GlobeViewState, + MapView, + MapViewProps, + MapViewState, + OrbitView, + OrbitViewProps, + OrbitViewState, + OrthographicView, + OrthographicViewProps, + OrthographicViewState, +} 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"; + +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; + protected controller: CommonViewProps["controller"] | 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 }), + ...(isDefined(this.controller) && { controller: this.controller }), + }; + } + + abstract viewProps(): Omit, "id">; +} + +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(), + }); + } +} From ab8c55d7514cab33eca117fab006742d367013ee Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 8 Oct 2025 10:19:12 -0400 Subject: [PATCH 02/20] Allow none --- lonboard/view.py | 82 +++++++++++++++++++++++++++++++----------------- 1 file changed, 54 insertions(+), 28 deletions(-) diff --git a/lonboard/view.py b/lonboard/view.py index 8fa4629b..0a2155ff 100644 --- a/lonboard/view.py +++ b/lonboard/view.py @@ -10,25 +10,33 @@ class BaseView(BaseWidget): """ - x = t.Union([t.Int(), t.Unicode()], default_value=None).tag(sync=True) + 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()], default_value=None).tag(sync=True) + 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()], default_value=None).tag(sync=True) + 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()], default_value=None).tag(sync=True) + 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%'`. @@ -43,7 +51,13 @@ class FirstPersonView(BaseView): _view_type = t.Unicode("first-person-view").tag(sync=True) - projection_matrix = t.List(t.Float(), default_value=None, minlen=16, maxlen=16).tag( + projection_matrix = t.List( + t.Float(), + allow_none=True, + default_value=None, + minlen=16, + maxlen=16, + ).tag( sync=True, ) """Projection matrix. @@ -51,25 +65,25 @@ class FirstPersonView(BaseView): If `projectionMatrix` is not supplied, the `View` class will build a projection matrix from the following parameters: """ - fovy = t.Float(default_value=None).tag(sync=True) + 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(default_value=None).tag(sync=True) + near = t.Float(allow_none=True, default_value=None).tag(sync=True) """Distance of near clipping plane. Default `0.1`. """ - far = t.Float(default_value=None).tag(sync=True) + far = t.Float(allow_none=True, default_value=None).tag(sync=True) """Distance of far clipping plane. Default `1000`. """ - focal_distance = t.Float(default_value=None).tag(sync=True) + 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`. @@ -84,19 +98,19 @@ class GlobeView(BaseView): _view_type = t.Unicode("globe-view").tag(sync=True) - resolution = t.Float(default_value=None).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(default_value=None).tag(sync=True) + 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(default_value=None).tag(sync=True) + 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. @@ -111,24 +125,30 @@ class MapView(BaseView): _view_type = t.Unicode("map-view").tag(sync=True) - repeat = t.Bool(default_value=None).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(default_value=None).tag(sync=True) + 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(default_value=None).tag(sync=True) + 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(), default_value=None, minlen=16, maxlen=16).tag( + projection_matrix = t.List( + t.Float(), + allow_none=True, + default_value=None, + minlen=16, + maxlen=16, + ).tag( sync=True, ) """Projection matrix. @@ -136,19 +156,19 @@ class MapView(BaseView): If `projectionMatrix` is not supplied, the `View` class will build a projection matrix from the following parameters: """ - fovy = t.Float(default_value=None).tag(sync=True) + 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(default_value=None).tag(sync=True) + 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(default_value=None).tag(sync=True) + 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). @@ -163,10 +183,16 @@ class OrbitView(BaseView): _view_type = t.Unicode("orbit-view").tag(sync=True) - orbit_axis = t.Unicode(default_value=None).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(), default_value=None, minlen=16, maxlen=16).tag( + projection_matrix = t.List( + t.Float(), + allow_none=True, + default_value=None, + minlen=16, + maxlen=16, + ).tag( sync=True, ) """Projection matrix. @@ -174,25 +200,25 @@ class OrbitView(BaseView): If `projectionMatrix` is not supplied, the `View` class will build a projection matrix from the following parameters: """ - fovy = t.Float(default_value=None).tag(sync=True) + 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(default_value=None).tag(sync=True) + near = t.Float(allow_none=True, default_value=None).tag(sync=True) """Distance of near clipping plane. Default `0.1`. """ - far = t.Float(default_value=None).tag(sync=True) + far = t.Float(allow_none=True, default_value=None).tag(sync=True) """Distance of far clipping plane. Default `1000`. """ - orthographic = t.Bool(default_value=None).tag(sync=True) + 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). @@ -207,20 +233,20 @@ class OrthographicView(BaseView): _view_type = t.Unicode("orthographic-view").tag(sync=True) - flip_y = t.Bool(default_value=None).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(default_value=None).tag(sync=True) + near = t.Float(allow_none=True, default_value=None).tag(sync=True) """Distance of near clipping plane. Default `0.1`. """ - far = t.Float(default_value=None).tag(sync=True) + far = t.Float(allow_none=True, default_value=None).tag(sync=True) """Distance of far clipping plane. Default `1000`. From 71b0b4ef35704bdb02aef68acd93af82f7477dfe Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 8 Oct 2025 10:25:33 -0400 Subject: [PATCH 03/20] Pass in views to Map constructor --- lonboard/_map.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lonboard/_map.py b/lonboard/_map.py index 9979e476..cb5b72e9 100644 --- a/lonboard/_map.py +++ b/lonboard/_map.py @@ -21,6 +21,7 @@ VariableLengthTuple, ViewStateTrait, ) +from lonboard.view import BaseView if TYPE_CHECKING: import sys @@ -192,6 +193,15 @@ def on_click(self, callback: Callable, *, remove: bool = False) -> None: """One or more `Layer` objects to display on this map. """ + views = VariableLengthTuple(t.Instance(BaseView)).tag( + sync=True, + **ipywidgets.widget_serialization, + ) + """A single View instance, or an array of View instances. + + 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. From 4bfd2ba95e134cca51d97eabe5cb73e0663a6956 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 8 Oct 2025 10:30:38 -0400 Subject: [PATCH 04/20] move html export to separate file --- lonboard/_html_export.py | 84 ++++++++++++++++++++++++++++++++++++++++ lonboard/_map.py | 51 +----------------------- 2 files changed, 86 insertions(+), 49 deletions(-) create mode 100644 lonboard/_html_export.py diff --git a/lonboard/_html_export.py b/lonboard/_html_export.py new file mode 100644 index 00000000..18fe87a1 --- /dev/null +++ b/lonboard/_html_export.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from io import StringIO +from typing import IO, TYPE_CHECKING, TextIO, overload + +from ipywidgets.embed import dependency_state, embed_minimal_html + +if TYPE_CHECKING: + from pathlib import Path + + from lonboard import Map + + +# HTML template to override exported map as 100% height +_HTML_TEMPLATE = """ + + + + {title} + + + +{snippet} + + +""" + + +@overload +def map_to_html( + m: Map, + *, + filename: None = None, + title: str | None = None, +) -> str: ... + + +@overload +def map_to_html( + m: Map, + *, + filename: str | Path | TextIO | IO[str], + title: str | None = None, +) -> None: ... + + +def map_to_html( + m: Map, + *, + filename: str | Path | TextIO | IO[str] | None = None, + title: str | None = None, +) -> str | None: + def inner(fp: str | Path | TextIO | IO[str]) -> None: + original_height = m.height + try: + with m.hold_trait_notifications(): + m.height = "100%" + embed_minimal_html( + fp, + views=[m], + title=title or "Lonboard export", + template=_HTML_TEMPLATE, + drop_defaults=False, + # Necessary to pass the state of _this_ specific map. Otherwise, the + # state of all known widgets will be included, ballooning the file size. + state=dependency_state((m), drop_defaults=False), + ) + finally: + # If the map had a height before the HTML was generated, reset it. + m.height = original_height + + if filename is None: + with StringIO() as sio: + inner(sio) + return sio.getvalue() + + else: + inner(filename) + return None diff --git a/lonboard/_map.py b/lonboard/_map.py index cb5b72e9..83fa1a90 100644 --- a/lonboard/_map.py +++ b/lonboard/_map.py @@ -1,6 +1,5 @@ from __future__ import annotations -from io import StringIO from pathlib import Path from typing import IO, TYPE_CHECKING, Any, TextIO, overload @@ -8,9 +7,9 @@ import traitlets import traitlets as t from ipywidgets import CallbackDispatcher -from ipywidgets.embed import dependency_state, embed_minimal_html from lonboard._base import BaseAnyWidget +from lonboard._html_export import map_to_html from lonboard._layer import BaseLayer from lonboard._viewport import compute_view from lonboard.basemap import CartoBasemap @@ -40,25 +39,6 @@ # bundler yields lonboard/static/{index.js,styles.css} bundler_output_dir = Path(__file__).parent / "static" -# HTML template to override exported map as 100% height -_HTML_TEMPLATE = """ - - - - {title} - - - -{snippet} - - -""" - class Map(BaseAnyWidget): """The top-level class used to display a map in a Jupyter Widget. @@ -575,34 +555,7 @@ def to_html( If `filename` is not passed, returns the HTML content as a `str`. """ - - def inner(fp: str | Path | TextIO | IO[str]) -> None: - original_height = self.height - try: - with self.hold_trait_notifications(): - self.height = "100%" - embed_minimal_html( - fp, - views=[self], - title=title or "Lonboard export", - template=_HTML_TEMPLATE, - drop_defaults=False, - # Necessary to pass the state of _this_ specific map. Otherwise, the - # state of all known widgets will be included, ballooning the file size. - state=dependency_state((self), drop_defaults=False), - ) - finally: - # If the map had a height before the HTML was generated, reset it. - self.height = original_height - - if filename is None: - with StringIO() as sio: - inner(sio) - return sio.getvalue() - - else: - inner(filename) - return None + return map_to_html(self, filename=filename, title=title) def as_html(self) -> HTML: """Render the current map as a static HTML file in IPython. From f14cdba06193cc1ab2b905308879717e82a7fe9b Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 8 Oct 2025 10:42:45 -0400 Subject: [PATCH 05/20] move view_state down after view --- lonboard/_map.py | 49 +++++++++++++++++++++++++----------------------- 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/lonboard/_map.py b/lonboard/_map.py index 83fa1a90..c2a6e4f9 100644 --- a/lonboard/_map.py +++ b/lonboard/_map.py @@ -132,29 +132,6 @@ def on_click(self, callback: Callable, *, remove: bool = False) -> None: _esm = bundler_output_dir / "index.js" _css = bundler_output_dir / "index.css" - view_state = ViewStateTrait() - """ - The view state of the map. - - - Type: [`ViewState`][lonboard.models.ViewState] - - Default: Automatically inferred from the data passed to the map. - - You can initialize the map to a specific view state using this property: - - ```py - Map( - layers, - view_state={"longitude": -74.0060, "latitude": 40.7128, "zoom": 7} - ) - ``` - - !!! note - - The properties of the view state are immutable. Use - [`set_view_state`][lonboard.Map.set_view_state] to modify a map's view state - 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. @@ -182,6 +159,32 @@ def on_click(self, callback: Callable, *, remove: bool = False) -> None: 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. """ + # 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. + + - Type: [`ViewState`][lonboard.models.ViewState] + - Default: Automatically inferred from the data passed to the map. + + You can initialize the map to a specific view state using this property: + + ```py + Map( + layers, + view_state={"longitude": -74.0060, "latitude": 40.7128, "zoom": 7} + ) + ``` + + !!! note + + The properties of the view state are immutable. Use + [`set_view_state`][lonboard.Map.set_view_state] to modify a map's view state + once it's been initially rendered. + + """ + show_tooltip = t.Bool(default_value=False).tag(sync=True) """ Whether to render a tooltip on hover on the map. From c4901760f8030796f8a37f34cfbf37821a040713 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 8 Oct 2025 11:31:55 -0400 Subject: [PATCH 06/20] Set up maplibre basemap widet --- lonboard/_map.py | 17 +++++++------- lonboard/basemap.py | 54 +++++++++++++++++++++++++++++++++++++++++++ lonboard/traits.py | 3 ++- lonboard/types/map.py | 6 +++-- 4 files changed, 68 insertions(+), 12 deletions(-) diff --git a/lonboard/_map.py b/lonboard/_map.py index c2a6e4f9..75200263 100644 --- a/lonboard/_map.py +++ b/lonboard/_map.py @@ -12,10 +12,9 @@ from lonboard._html_export import map_to_html from lonboard._layer import BaseLayer from lonboard._viewport import compute_view -from lonboard.basemap import CartoBasemap +from lonboard.basemap import MaplibreBasemap from lonboard.traits import ( DEFAULT_INITIAL_VIEW_STATE, - BasemapUrl, HeightTrait, VariableLengthTuple, ViewStateTrait, @@ -212,15 +211,15 @@ def on_click(self, callback: Callable, *, remove: bool = False) -> None: - Default: `5` """ - basemap_style = BasemapUrl(CartoBasemap.PositronNoLabels) - """ - A URL to a MapLibre-compatible basemap style. + basemap = t.Instance(MaplibreBasemap, allow_none=True).tag( + sync=True, + **ipywidgets.widget_serialization, + ) + """A basemap instance. - Various styles are provided in [`lonboard.basemap`](https://developmentseed.org/lonboard/latest/api/basemap/). + See [`lonboard.basemap.MaplibreBasemap`] for more information. - - Type: `str`, holding a URL hosting a basemap style. - - Default - [`lonboard.basemap.CartoBasemap.PositronNoLabels`][lonboard.basemap.CartoBasemap.PositronNoLabels] + Pass `None` to disable rendering a basemap. """ custom_attribution = t.Union( diff --git a/lonboard/basemap.py b/lonboard/basemap.py index 6eb1abcc..9f6e2986 100644 --- a/lonboard/basemap.py +++ b/lonboard/basemap.py @@ -1,6 +1,12 @@ """Basemap helpers.""" from enum import Enum +from typing import Literal + +import traitlets as t + +from lonboard._base import BaseWidget +from lonboard.traits import BasemapUrl class CartoBasemap(str, Enum): @@ -43,3 +49,51 @@ class CartoBasemap(str, Enum): "https://basemaps.cartocdn.com/gl/voyager-nolabels-gl-style/style.json" ) """A light, colored map style without labels.""" + + +class MaplibreBasemap(BaseWidget): + """A MapLibre GL JS basemap.""" + + def __init__( + self, + *, + mode: Literal[ + "interleaved", + "overlaid", + "reverse-controlled", + ] = "reverse-controlled", + basemap_style: str | CartoBasemap, + ) -> None: + """Create a MapLibre GL JS basemap.""" + super().__init__(mode=mode, basemap_style=basemap_style) # type: ignore + + mode = t.Unicode().tag(sync=True) + """The basemap integration mode. + + - **`"interleaved"`**: + + The interleaved mode renders deck.gl layers into the same context created by MapLibre. If you need to mix deck.gl layers with MapLibre layers, e.g. having deck.gl surfaces below text labels, or objects occluding each other correctly in 3D, then you have to use this option. + + - **`"overlaid"`**: + + The overlaid mode renders deck.gl in a separate canvas inside the MapLibre's controls container. If your use case does not require interleaving, but you still want to use certain features of maplibre-gl, such as globe view, then you should use this option. + + - **`"reverse-controlled"`**: + + The reverse-controlled mode renders deck.gl above the MapLibre container and blocks any interaction to the base map. + + If you need to have multiple views, you should use this option. + + **Default**: `"reverse-controlled"` + """ + + basemap_style = BasemapUrl(CartoBasemap.PositronNoLabels) + """ + A URL to a MapLibre-compatible basemap style. + + Various styles are provided in [`lonboard.basemap`](https://developmentseed.org/lonboard/latest/api/basemap/). + + - Type: `str`, holding a URL hosting a basemap style. + - Default + [`lonboard.basemap.CartoBasemap.PositronNoLabels`][lonboard.basemap.CartoBasemap.PositronNoLabels] + """ diff --git a/lonboard/traits.py b/lonboard/traits.py index a0a61862..61978699 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 66b80c57..37b0d71e 100644 --- a/lonboard/types/map.py +++ b/lonboard/types/map.py @@ -9,17 +9,19 @@ from typing_extensions import TypedDict if TYPE_CHECKING: - from lonboard.basemap import CartoBasemap + from lonboard.basemap import MaplibreBasemap + from lonboard.view import BaseView class MapKwargs(TypedDict, total=False): """Kwargs to pass into map constructor.""" height: int | str - basemap_style: str | CartoBasemap + basemap: MaplibreBasemap parameters: dict[str, Any] picking_radius: int show_tooltip: bool show_side_panel: bool use_device_pixels: int | float | bool + views: BaseView | list[BaseView] | tuple[BaseView, ...] view_state: dict[str, Any] From aaf66a339c784c1c415f177978cae057e9eefca2 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Mon, 13 Oct 2025 22:29:58 -0400 Subject: [PATCH 07/20] Define split renderers --- src/renderers/deck-first.tsx | 57 +++++++++++++++++++++++++++++ src/renderers/index.ts | 3 ++ src/renderers/overlay.tsx | 70 ++++++++++++++++++++++++++++++++++++ src/renderers/types.ts | 20 +++++++++++ 4 files changed, 150 insertions(+) create mode 100644 src/renderers/deck-first.tsx create mode 100644 src/renderers/index.ts create mode 100644 src/renderers/overlay.tsx create mode 100644 src/renderers/types.ts diff --git a/src/renderers/deck-first.tsx b/src/renderers/deck-first.tsx new file mode 100644 index 00000000..4664cfb4 --- /dev/null +++ b/src/renderers/deck-first.tsx @@ -0,0 +1,57 @@ +import DeckGL from "@deck.gl/react"; +import React from "react"; +import Map from "react-map-gl/maplibre"; +import type { MapRendererProps } from "./types"; + +/** + * DeckFirst renderer: DeckGL wraps Map component + * + * In this rendering mode, deck.gl is the parent component that manages + * the canvas and view state, with the map rendered as a child component. + * This is the traditional approach where deck.gl has full control over + * the rendering pipeline. + */ +const DeckFirst: React.FC = ({ + mapStyle, + customAttribution, + initialViewState, + layers, + deckRef, + getTooltip, + isDrawingBBoxSelection, + pickingRadius, + useDevicePixels, + parameters, + onMapClick, + onMapHover, + onViewStateChange, +}) => { + return ( + (isDrawingBBoxSelection ? "crosshair" : "grab")} + pickingRadius={pickingRadius} + onClick={onMapClick} + onHover={onMapHover} + // @ts-expect-error useDevicePixels should allow number + // https://github.com/visgl/deck.gl/pull/9826 + useDevicePixels={useDevicePixels} + // https://deck.gl/docs/api-reference/core/deck#_typedarraymanagerprops + _typedArrayManagerProps={{ + overAlloc: 1, + poolSize: 0, + }} + onViewStateChange={onViewStateChange} + parameters={parameters} + > + + + ); +}; + +export default DeckFirst; diff --git a/src/renderers/index.ts b/src/renderers/index.ts new file mode 100644 index 00000000..3b97f2ff --- /dev/null +++ b/src/renderers/index.ts @@ -0,0 +1,3 @@ +export { default as DeckFirst } from "./deck-first"; +export { default as Overlay } from "./overlay"; +export type { MapRendererProps } from "./types"; diff --git a/src/renderers/overlay.tsx b/src/renderers/overlay.tsx new file mode 100644 index 00000000..37cdb365 --- /dev/null +++ b/src/renderers/overlay.tsx @@ -0,0 +1,70 @@ +import React from "react"; +import Map, { useControl } from "react-map-gl/maplibre"; +import { MapboxOverlay, MapboxOverlayProps } from "@deck.gl/mapbox"; +import type { MapRendererProps } from "./types"; + +/** + * DeckGLOverlay component that integrates deck.gl with react-map-gl + * + * Uses the useControl hook to create a MapboxOverlay instance that + * renders deck.gl layers on top of the base map. + */ +function DeckGLOverlay(props: MapboxOverlayProps) { + const overlay = useControl(() => new MapboxOverlay(props)); + overlay.setProps(props); + return null; +} + +/** + * Overlay renderer: Map wraps DeckGLOverlay component + * + * In this rendering mode, the map is the parent component that controls + * the view state, with deck.gl layers rendered as an overlay using the + * MapboxOverlay. This approach gives the base map more control and can + * enable features like interleaved rendering between map and deck layers. + */ +const Overlay: React.FC = ({ + mapStyle, + customAttribution, + initialViewState, + layers, + getTooltip, + isDrawingBBoxSelection, + pickingRadius, + useDevicePixels, + parameters, + onMapClick, + onMapHover, + onViewStateChange, +}) => { + return ( + + (isDrawingBBoxSelection ? "crosshair" : "grab")} + pickingRadius={pickingRadius} + onClick={onMapClick} + onHover={onMapHover} + // @ts-expect-error useDevicePixels should allow number + // https://github.com/visgl/deck.gl/pull/9826 + useDevicePixels={useDevicePixels} + // https://deck.gl/docs/api-reference/core/deck#_typedarraymanagerprops + _typedArrayManagerProps={{ + overAlloc: 1, + poolSize: 0, + }} + onViewStateChange={onViewStateChange} + parameters={parameters} + /> + + ); +}; + +export default Overlay; diff --git a/src/renderers/types.ts b/src/renderers/types.ts new file mode 100644 index 00000000..62ef98d9 --- /dev/null +++ b/src/renderers/types.ts @@ -0,0 +1,20 @@ +import { MapViewState, PickingInfo, type Layer } from "@deck.gl/core"; +import { DeckGLRef } from "@deck.gl/react"; +import type { RefObject } from "react"; + +export interface MapRendererProps { + mapStyle: string; + customAttribution: string; + initialViewState: MapViewState; + layers: Layer[]; + deckRef?: RefObject; + showTooltip: boolean; + getTooltip?: ((info: PickingInfo) => string | null) | undefined; + isDrawingBBoxSelection: boolean; + pickingRadius: number; + useDevicePixels: number | boolean; + parameters: object; + onMapClick: (info: PickingInfo) => void; + onMapHover: (info: PickingInfo) => void; + onViewStateChange: (event: { viewState: MapViewState }) => void; +} From ca7bfd44d203a462e137931b2aa18f6a01a81dea Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Mon, 13 Oct 2025 22:59:37 -0400 Subject: [PATCH 08/20] Implement split renderers --- lonboard/_map.py | 2 + src/index.tsx | 106 +++++++++++++++++------------------ src/renderers/deck-first.tsx | 33 ++--------- src/renderers/overlay.tsx | 38 ++++--------- src/renderers/types.ts | 32 ++++++----- 5 files changed, 86 insertions(+), 125 deletions(-) diff --git a/lonboard/_map.py b/lonboard/_map.py index 9979e476..3164cb7d 100644 --- a/lonboard/_map.py +++ b/lonboard/_map.py @@ -179,6 +179,8 @@ def on_click(self, callback: Callable, *, remove: bool = False) -> None: Indicates if a click handler has been registered. """ + render_mode = t.Unicode(default_value="deck-first").tag(sync=True) + height = HeightTrait().tag(sync=True) """Height of the map in pixels, or valid CSS height property. diff --git a/src/index.tsx b/src/index.tsx index bc4aff25..911fdf5a 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -2,11 +2,9 @@ import * as React from "react"; import { useEffect, useCallback, useState, useRef } from "react"; import { createRender, useModelState, useModel } from "@anywidget/react"; import type { Initialize, Render } from "@anywidget/types"; -import Map from "react-map-gl/maplibre"; -import DeckGL from "@deck.gl/react"; import { MapViewState, PickingInfo, type Layer } from "@deck.gl/core"; import { BaseLayerModel, initializeLayer } from "./model/index.js"; -import type { WidgetModel } from "@jupyter-widgets/base"; +import type { IWidgetManager, WidgetModel } from "@jupyter-widgets/base"; import { initParquetWasm } from "./parquet.js"; import { isDefined, loadChildModels } from "./util.js"; import { v4 as uuidv4 } from "uuid"; @@ -25,6 +23,9 @@ import throttle from "lodash.throttle"; import SidePanel from "./sidepanel/index"; import { getTooltip } from "./tooltip/index.js"; import { DeckGLRef } from "@deck.gl/react"; +import OverlayRenderer from "./renderers/overlay.js"; +import { MapRendererProps } from "./renderers/types.js"; +import DeckFirstRenderer from "./renderers/deck-first.js"; await initParquetWasm(); @@ -116,6 +117,7 @@ function App() { ); const [parameters] = useModelState("parameters"); const [customAttribution] = useModelState("custom_attribution"); + const [renderMode] = useModelState("render_mode"); // initialViewState is the value of view_state on the Python side. This is // called `initial` here because it gets passed in to deck's @@ -156,7 +158,7 @@ function App() { const loadAndUpdateLayers = async () => { try { const childModels = await loadChildModels( - model.widget_manager, + model.widget_manager as IWidgetManager, childLayerIds, ); @@ -229,6 +231,45 @@ function App() { [isOnMapHoverEventEnabled, justClicked], ); + const mapRenderProps: MapRendererProps = { + mapStyle: mapStyle || DEFAULT_MAP_STYLE, + customAttribution, + deckRef, + initialViewState: ["longitude", "latitude", "zoom"].every((key) => + Object.keys(initialViewState).includes(key), + ) + ? initialViewState + : DEFAULT_INITIAL_VIEW_STATE, + layers: bboxSelectPolygonLayer + ? layers.concat(bboxSelectPolygonLayer) + : layers, + getTooltip: (showTooltip && getTooltip) || undefined, + getCursor: () => (isDrawingBBoxSelection ? "crosshair" : "grab"), + pickingRadius: pickingRadius, + onClick: onMapClickHandler, + onHover: onMapHoverHandler, + // @ts-expect-error useDevicePixels should allow number + // https://github.com/visgl/deck.gl/pull/9826 + useDevicePixels: isDefined(useDevicePixels) ? useDevicePixels : true, + onViewStateChange: (event) => { + const { viewState } = event; + + // This condition is necessary to confirm that the viewState is + // of type MapViewState. + if ("latitude" in viewState) { + const { longitude, latitude, zoom, pitch, bearing } = viewState; + setViewState({ + longitude, + latitude, + zoom, + pitch, + bearing, + }); + } + }, + parameters: parameters || {}, + }; + return (
)}
- - Object.keys(initialViewState).includes(key), - ) - ? initialViewState - : DEFAULT_INITIAL_VIEW_STATE - } - controller={true} - layers={ - bboxSelectPolygonLayer - ? layers.concat(bboxSelectPolygonLayer) - : layers - } - getTooltip={(showTooltip && getTooltip) || undefined} - getCursor={() => (isDrawingBBoxSelection ? "crosshair" : "grab")} - pickingRadius={pickingRadius} - onClick={onMapClickHandler} - onHover={onMapHoverHandler} - useDevicePixels={ - isDefined(useDevicePixels) ? useDevicePixels : true - } - // https://deck.gl/docs/api-reference/core/deck#_typedarraymanagerprops - _typedArrayManagerProps={{ - overAlloc: 1, - poolSize: 0, - }} - onViewStateChange={(event) => { - const { viewState } = event; - - // This condition is necessary to confirm that the viewState is - // of type MapViewState. - if ("latitude" in viewState) { - const { longitude, latitude, zoom, pitch, bearing } = viewState; - setViewState({ - longitude, - latitude, - zoom, - pitch, - bearing, - }); - } - }} - parameters={parameters || {}} - > - - + {renderMode === "overlay" ? ( + + ) : ( + + )}
diff --git a/src/renderers/deck-first.tsx b/src/renderers/deck-first.tsx index 4664cfb4..8012d288 100644 --- a/src/renderers/deck-first.tsx +++ b/src/renderers/deck-first.tsx @@ -11,47 +11,24 @@ import type { MapRendererProps } from "./types"; * This is the traditional approach where deck.gl has full control over * the rendering pipeline. */ -const DeckFirst: React.FC = ({ - mapStyle, - customAttribution, - initialViewState, - layers, - deckRef, - getTooltip, - isDrawingBBoxSelection, - pickingRadius, - useDevicePixels, - parameters, - onMapClick, - onMapHover, - onViewStateChange, -}) => { +const DeckFirstRenderer: React.FC = (mapProps) => { + // Remove maplibre-specific props before passing to DeckGL + const { mapStyle, customAttribution, deckRef, ...deckProps } = mapProps; return ( (isDrawingBBoxSelection ? "crosshair" : "grab")} - pickingRadius={pickingRadius} - onClick={onMapClick} - onHover={onMapHover} - // @ts-expect-error useDevicePixels should allow number - // https://github.com/visgl/deck.gl/pull/9826 - useDevicePixels={useDevicePixels} // https://deck.gl/docs/api-reference/core/deck#_typedarraymanagerprops _typedArrayManagerProps={{ overAlloc: 1, poolSize: 0, }} - onViewStateChange={onViewStateChange} - parameters={parameters} + {...deckProps} > ); }; -export default DeckFirst; +export default DeckFirstRenderer; diff --git a/src/renderers/overlay.tsx b/src/renderers/overlay.tsx index 37cdb365..be95039a 100644 --- a/src/renderers/overlay.tsx +++ b/src/renderers/overlay.tsx @@ -23,20 +23,15 @@ function DeckGLOverlay(props: MapboxOverlayProps) { * MapboxOverlay. This approach gives the base map more control and can * enable features like interleaved rendering between map and deck layers. */ -const Overlay: React.FC = ({ - mapStyle, - customAttribution, - initialViewState, - layers, - getTooltip, - isDrawingBBoxSelection, - pickingRadius, - useDevicePixels, - parameters, - onMapClick, - onMapHover, - onViewStateChange, -}) => { +const OverlayRenderer: React.FC = (mapProps) => { + // Remove maplibre-specific props before passing to DeckGL + const { + mapStyle, + customAttribution, + initialViewState, + // deckRef, + ...deckProps + } = mapProps; return ( = ({ style={{ width: "100%", height: "100%" }} > (isDrawingBBoxSelection ? "crosshair" : "grab")} - pickingRadius={pickingRadius} - onClick={onMapClick} - onHover={onMapHover} - // @ts-expect-error useDevicePixels should allow number - // https://github.com/visgl/deck.gl/pull/9826 - useDevicePixels={useDevicePixels} + // ref={deckRef} // https://deck.gl/docs/api-reference/core/deck#_typedarraymanagerprops _typedArrayManagerProps={{ overAlloc: 1, poolSize: 0, }} - onViewStateChange={onViewStateChange} - parameters={parameters} + {...deckProps} /> ); }; -export default Overlay; +export default OverlayRenderer; diff --git a/src/renderers/types.ts b/src/renderers/types.ts index 62ef98d9..b699fe62 100644 --- a/src/renderers/types.ts +++ b/src/renderers/types.ts @@ -1,20 +1,22 @@ -import { MapViewState, PickingInfo, type Layer } from "@deck.gl/core"; -import { DeckGLRef } from "@deck.gl/react"; +import type { DeckProps, View } from "@deck.gl/core"; +import type { DeckGLRef } from "@deck.gl/react"; import type { RefObject } from "react"; -export interface MapRendererProps { +type ViewOrViews = View | View[] | null; +export type MapRendererProps = Pick< + DeckProps, + | "layers" + | "getTooltip" + | "getCursor" + | "pickingRadius" + | "useDevicePixels" + | "parameters" + | "initialViewState" + | "onClick" + | "onHover" + | "onViewStateChange" +> & { mapStyle: string; customAttribution: string; - initialViewState: MapViewState; - layers: Layer[]; deckRef?: RefObject; - showTooltip: boolean; - getTooltip?: ((info: PickingInfo) => string | null) | undefined; - isDrawingBBoxSelection: boolean; - pickingRadius: number; - useDevicePixels: number | boolean; - parameters: object; - onMapClick: (info: PickingInfo) => void; - onMapHover: (info: PickingInfo) => void; - onViewStateChange: (event: { viewState: MapViewState }) => void; -} +}; From a043703b75138cf3afb92758b8e2533e609b71e7 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Mon, 13 Oct 2025 23:11:40 -0400 Subject: [PATCH 09/20] alphabetize --- src/renderers/types.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/renderers/types.ts b/src/renderers/types.ts index b699fe62..d6f806d8 100644 --- a/src/renderers/types.ts +++ b/src/renderers/types.ts @@ -5,16 +5,16 @@ import type { RefObject } from "react"; type ViewOrViews = View | View[] | null; export type MapRendererProps = Pick< DeckProps, - | "layers" - | "getTooltip" | "getCursor" - | "pickingRadius" - | "useDevicePixels" - | "parameters" + | "getTooltip" | "initialViewState" + | "layers" | "onClick" | "onHover" | "onViewStateChange" + | "parameters" + | "pickingRadius" + | "useDevicePixels" > & { mapStyle: string; customAttribution: string; From 3571acfdba65206901168a8b6157c9bd3a482401 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 16 Oct 2025 17:07:40 -0400 Subject: [PATCH 10/20] reduce diff --- lonboard/basemap.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lonboard/basemap.py b/lonboard/basemap.py index 0404f486..8fa2ff9e 100644 --- a/lonboard/basemap.py +++ b/lonboard/basemap.py @@ -3,7 +3,7 @@ from __future__ import annotations from enum import Enum -from typing import TYPE_CHECKING, Literal +from typing import TYPE_CHECKING import traitlets as t from typing_extensions import deprecated From 2e59606fe353590482ca31ff57782c61bf9c99f9 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 16 Oct 2025 18:01:28 -0400 Subject: [PATCH 11/20] Support deck views --- eslint.config.js | 6 ++-- lonboard/_map.py | 10 ++++++- package-lock.json | 1 + src/index.tsx | 62 ++++++++++++++++++++++++++++++++------- src/model/base.ts | 4 --- src/model/extension.ts | 1 - src/model/view.ts | 33 ++++++++++++++++++++- src/renderers/overlay.tsx | 6 +++- src/renderers/types.ts | 5 ++-- 9 files changed, 105 insertions(+), 23 deletions(-) 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 4627d564..54d16e82 100644 --- a/lonboard/_map.py +++ b/lonboard/_map.py @@ -172,7 +172,15 @@ def on_click(self, callback: Callable, *, remove: bool = False) -> None: """One or more `Layer` objects to display on this map. """ - views = VariableLengthTuple(t.Instance(BaseView)).tag( + # Note: it would be nicer to have only VariableLengthTuple, which would ensure the + # JS side always receives a list of strings and never a single string, but + # VariableLengthTuple won't coerce a single object into a tuple of length 1. + views = t.Union( + [ + t.Instance(BaseView), + VariableLengthTuple(t.Instance(BaseView)), + ], + ).tag( sync=True, **ipywidgets.widget_serialization, ) 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..7eba3055 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"; @@ -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,38 @@ 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 { + // 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 views = Object.values(viewsState).map((viewModel) => viewModel.build()); + const onMapClickHandler = useCallback((info: PickingInfo) => { // We added this flag to prevent the hover event from firing after a // click event. @@ -261,6 +302,7 @@ function App() { } }, parameters: parameters || {}, + views, }; const overlayRenderProps: OverlayRendererProps = { 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 index b99c74c4..219e5d25 100644 --- a/src/model/view.ts +++ b/src/model/view.ts @@ -15,12 +15,14 @@ import { 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"; -abstract class BaseViewModel extends BaseModel { +export abstract class BaseViewModel extends BaseModel { protected x: CommonViewProps["x"] | null; protected y: CommonViewProps["y"] | null; protected width: CommonViewProps["width"] | null; @@ -51,6 +53,8 @@ abstract class BaseViewModel extends BaseModel { } abstract viewProps(): Omit, "id">; + + abstract build(): View; } export class FirstPersonViewModel extends BaseViewModel { @@ -250,3 +254,30 @@ export class OrthographicViewModel extends BaseViewModel }); } } + +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..bcfc37ae 100644 --- a/src/renderers/overlay.tsx +++ b/src/renderers/overlay.tsx @@ -1,3 +1,4 @@ +import { _GlobeView as GlobeView } from "@deck.gl/core"; import { MapboxOverlay, MapboxOverlayProps } from "@deck.gl/mapbox"; import React from "react"; import Map, { useControl } from "react-map-gl/maplibre"; @@ -28,8 +29,10 @@ 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; + const firstView = Array.isArray(views) ? views[0] : views; + const isGlobeView = firstView instanceof GlobeView; return ( = ( mapStyle={mapStyle} attributionControl={{ customAttribution }} style={{ width: "100%", height: "100%" }} + {...(isGlobeView && { 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; From 6ff3540cb78855878b37ff552272d7a70e691ce7 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 16 Oct 2025 18:01:36 -0400 Subject: [PATCH 12/20] Apply linear gradient background --- src/index.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/index.tsx b/src/index.tsx index 7eba3055..796b62f1 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -325,7 +325,11 @@ function App() {
From e84616461bc06c3f6cf0cc3070d12c6963b28816 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 16 Oct 2025 18:07:58 -0400 Subject: [PATCH 13/20] Add dark background when in globe view --- src/index.tsx | 8 ++++++-- src/renderers/overlay.tsx | 6 ++---- src/util.ts | 10 ++++++++++ 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index 796b62f1..e8a06bd2 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -31,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"; @@ -328,7 +328,11 @@ function App() { style={{ width: "100%", height: "100%", - background: "linear-gradient(0, #000, #223)", + // Use a dark background when in globe view so the globe is easier to + // delineate + ...(isGlobeView(views) && { + background: "linear-gradient(0, #000, #223)", + }), }} > diff --git a/src/renderers/overlay.tsx b/src/renderers/overlay.tsx index bcfc37ae..a2b96043 100644 --- a/src/renderers/overlay.tsx +++ b/src/renderers/overlay.tsx @@ -1,9 +1,9 @@ -import { _GlobeView as GlobeView } from "@deck.gl/core"; import { MapboxOverlay, MapboxOverlayProps } from "@deck.gl/mapbox"; 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 @@ -31,8 +31,6 @@ const OverlayRenderer: React.FC = ( // Remove maplibre-specific props before passing to DeckGL const { mapStyle, customAttribution, initialViewState, views, ...deckProps } = mapProps; - const firstView = Array.isArray(views) ? views[0] : views; - const isGlobeView = firstView instanceof GlobeView; return ( = ( mapStyle={mapStyle} attributionControl={{ customAttribution }} style={{ width: "100%", height: "100%" }} - {...(isGlobeView && { projection: "globe" })} + {...(isGlobeView(views) && { projection: "globe" })} > (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; +} From 8c884878f9ece925e4e244c2d2fed12d38cc50ce Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 16 Oct 2025 18:18:06 -0400 Subject: [PATCH 14/20] pass undefined when no views passed --- src/index.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/index.tsx b/src/index.tsx index e8a06bd2..cdf06eed 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -231,7 +231,12 @@ function App() { loadAndUpdateViews(); }, [viewIds]); - const views = Object.values(viewsState).map((viewModel) => viewModel.build()); + 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 @@ -330,6 +335,7 @@ function App() { height: "100%", // Use a dark background when in globe view so the globe is easier to // delineate + // In the future we may want to allow the user to customize this ...(isGlobeView(views) && { background: "linear-gradient(0, #000, #223)", }), From 188164c236b662600e406ab88354ba327468f6de Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 16 Oct 2025 18:36:54 -0400 Subject: [PATCH 15/20] Remove multi-view support for now --- lonboard/_map.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/lonboard/_map.py b/lonboard/_map.py index 54d16e82..836878a7 100644 --- a/lonboard/_map.py +++ b/lonboard/_map.py @@ -172,19 +172,11 @@ def on_click(self, callback: Callable, *, remove: bool = False) -> None: """One or more `Layer` objects to display on this map. """ - # Note: it would be nicer to have only VariableLengthTuple, which would ensure the - # JS side always receives a list of strings and never a single string, but - # VariableLengthTuple won't coerce a single object into a tuple of length 1. - views = t.Union( - [ - t.Instance(BaseView), - VariableLengthTuple(t.Instance(BaseView)), - ], - ).tag( + views = t.Instance(BaseView).tag( sync=True, **ipywidgets.widget_serialization, ) - """A single View instance, or an array of View instances. + """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. """ From 89d70eb324fb256bf35e92dfbbfd10ff0035e558 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 16 Oct 2025 18:52:55 -0400 Subject: [PATCH 16/20] fix basemap when constructing `Map` without any parameters --- lonboard/_map.py | 3 +++ tests/test_map.py | 11 +++++++++++ 2 files changed, 14 insertions(+) diff --git a/lonboard/_map.py b/lonboard/_map.py index 836878a7..97028d53 100644 --- a/lonboard/_map.py +++ b/lonboard/_map.py @@ -236,6 +236,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/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" From 582d606792078877618955e62876f65c1ce00c55 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 16 Oct 2025 18:54:19 -0400 Subject: [PATCH 17/20] Let `views` be None --- lonboard/_map.py | 2 +- src/index.tsx | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/lonboard/_map.py b/lonboard/_map.py index 97028d53..205cef74 100644 --- a/lonboard/_map.py +++ b/lonboard/_map.py @@ -172,7 +172,7 @@ def on_click(self, callback: Callable, *, remove: bool = False) -> None: """One or more `Layer` objects to display on this map. """ - views = t.Instance(BaseView).tag( + views: t.Instance[BaseView | None] = t.Instance(BaseView, allow_none=True).tag( sync=True, **ipywidgets.widget_serialization, ) diff --git a/src/index.tsx b/src/index.tsx index cdf06eed..704539c1 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -93,7 +93,7 @@ function App() { const [customAttribution] = useModelState("custom_attribution"); const [mapId] = useState(uuidv4()); const [childLayerIds] = useModelState("layers"); - const [viewIds] = useModelState("views"); + 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 @@ -213,6 +213,11 @@ function App() { 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, From 9b8ebcc32b720462eff29f3adff54ab4089f3458 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 16 Oct 2025 19:50:59 -0400 Subject: [PATCH 18/20] remove accidental render_mode --- lonboard/_map.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/lonboard/_map.py b/lonboard/_map.py index 205cef74..ba09764e 100644 --- a/lonboard/_map.py +++ b/lonboard/_map.py @@ -157,8 +157,6 @@ def on_click(self, callback: Callable, *, remove: bool = False) -> None: Indicates if a click handler has been registered. """ - render_mode = t.Unicode(default_value="deck-first").tag(sync=True) - height = HeightTrait().tag(sync=True) """Height of the map in pixels, or valid CSS height property. From d72e7b7fb3f35682a686c386571cc2636c1678d9 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 16 Oct 2025 19:51:46 -0400 Subject: [PATCH 19/20] reduce diff --- lonboard/_map.py | 52 ++++++++++++++++++++++++------------------------ 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/lonboard/_map.py b/lonboard/_map.py index ba09764e..d80c7364 100644 --- a/lonboard/_map.py +++ b/lonboard/_map.py @@ -152,6 +152,32 @@ 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. + + - Type: [`ViewState`][lonboard.models.ViewState] + - Default: Automatically inferred from the data passed to the map. + + You can initialize the map to a specific view state using this property: + + ```py + Map( + layers, + view_state={"longitude": -74.0060, "latitude": 40.7128, "zoom": 7} + ) + ``` + + !!! note + + The properties of the view state are immutable. Use + [`set_view_state`][lonboard.Map.set_view_state] to modify a map's view state + 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. @@ -179,32 +205,6 @@ def on_click(self, callback: Callable, *, remove: bool = False) -> None: 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. """ - # 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. - - - Type: [`ViewState`][lonboard.models.ViewState] - - Default: Automatically inferred from the data passed to the map. - - You can initialize the map to a specific view state using this property: - - ```py - Map( - layers, - view_state={"longitude": -74.0060, "latitude": 40.7128, "zoom": 7} - ) - ``` - - !!! note - - The properties of the view state are immutable. Use - [`set_view_state`][lonboard.Map.set_view_state] to modify a map's view state - once it's been initially rendered. - - """ - show_tooltip = t.Bool(default_value=False).tag(sync=True) """ Whether to render a tooltip on hover on the map. From 5ff4d121b0bad7f8553062200c0a7239d9883820 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 16 Oct 2025 20:08:40 -0400 Subject: [PATCH 20/20] remove controller --- src/model/view.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/model/view.ts b/src/model/view.ts index 219e5d25..60a1cd6f 100644 --- a/src/model/view.ts +++ b/src/model/view.ts @@ -28,7 +28,6 @@ export abstract class BaseViewModel extends BaseModel { protected width: CommonViewProps["width"] | null; protected height: CommonViewProps["height"] | null; protected padding: CommonViewProps["padding"] | null; - protected controller: CommonViewProps["controller"] | null; constructor(model: WidgetModel, updateStateCallback: () => void) { super(model, updateStateCallback); @@ -48,7 +47,6 @@ export abstract class BaseViewModel extends BaseModel { ...(isDefined(this.width) && { width: this.width }), ...(isDefined(this.height) && { height: this.height }), ...(isDefined(this.padding) && { padding: this.padding }), - ...(isDefined(this.controller) && { controller: this.controller }), }; }