Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
02d6e13
View model definitions
kylebarron Oct 8, 2025
ab8c55d
Allow none
kylebarron Oct 8, 2025
71b0b4e
Pass in views to Map constructor
kylebarron Oct 8, 2025
4bfd2ba
move html export to separate file
kylebarron Oct 8, 2025
f14cdba
move view_state down after view
kylebarron Oct 8, 2025
c490176
Set up maplibre basemap widet
kylebarron Oct 8, 2025
aaf66a3
Define split renderers
kylebarron Oct 14, 2025
ca7bfd4
Implement split renderers
kylebarron Oct 14, 2025
a043703
alphabetize
kylebarron Oct 14, 2025
fa0940c
Merge branch 'main' into kyle/view-basemap-refactor
kylebarron Oct 14, 2025
879bff0
Merge branch 'main' into kyle/split-renderers2
kylebarron Oct 14, 2025
08ce7f8
Merge branch 'kyle/split-renderers2' into kyle/view-basemap-refactor
kylebarron Oct 14, 2025
18aacb4
Merge branch 'main' into kyle/view-basemap-refactor
kylebarron Oct 14, 2025
e185723
Merge branch 'main' into kyle/view-basemap-refactor
kylebarron Oct 15, 2025
05b1ec2
Merge branch 'main' into kyle/view-basemap-refactor
kylebarron Oct 16, 2025
3571acf
reduce diff
kylebarron Oct 16, 2025
2e59606
Support deck views
kylebarron Oct 16, 2025
6ff3540
Apply linear gradient background
kylebarron Oct 16, 2025
e846164
Add dark background when in globe view
kylebarron Oct 16, 2025
8c88487
pass undefined when no views passed
kylebarron Oct 16, 2025
188164c
Remove multi-view support for now
kylebarron Oct 16, 2025
89d70eb
fix basemap when constructing `Map` without any parameters
kylebarron Oct 16, 2025
582d606
Let `views` be None
kylebarron Oct 16, 2025
9b8ebcc
remove accidental render_mode
kylebarron Oct 16, 2025
d72e7b7
reduce diff
kylebarron Oct 16, 2025
5ff4d12
remove controller
kylebarron Oct 17, 2025
a273191
Merge branch 'main' into kyle/view-basemap-refactor
kylebarron Oct 22, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions lonboard/_html_export.py
Original file line number Diff line number Diff line change
@@ -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 = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{title}</title>
</head>
<style>
html {{ height: 100%; }}
body {{ height: 100%; overflow: hidden;}}
.widget-subarea {{ height: 100%; }}
.jupyter-widgets-disconnected {{ height: 100%; }}
</style>
<body>
{snippet}
</body>
</html>
"""


@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
115 changes: 40 additions & 75 deletions lonboard/_map.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,25 @@
from __future__ import annotations

from io import StringIO
from pathlib import Path
from typing import IO, TYPE_CHECKING, Any, TextIO, overload

import ipywidgets
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
from lonboard.basemap import MaplibreBasemap
from lonboard.traits import (
DEFAULT_INITIAL_VIEW_STATE,
BasemapUrl,
HeightTrait,
VariableLengthTuple,
ViewStateTrait,
)
from lonboard.view import BaseView

if TYPE_CHECKING:
import sys
Expand All @@ -39,25 +38,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 = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{title}</title>
</head>
<style>
html {{ height: 100%; }}
body {{ height: 100%; overflow: hidden;}}
.widget-subarea {{ height: 100%; }}
.jupyter-widgets-disconnected {{ height: 100%; }}
</style>
<body>
{snippet}
</body>
</html>
"""


class Map(BaseAnyWidget):
"""The top-level class used to display a map in a Jupyter Widget.
Expand Down Expand Up @@ -151,6 +131,35 @@ def on_click(self, callback: Callable, *, remove: bool = False) -> None:
_esm = bundler_output_dir / "index.js"
_css = bundler_output_dir / "index.css"

_has_click_handlers = t.Bool(default_value=False, allow_none=False).tag(sync=True)
"""
Indicates if a click handler has been registered.
"""

height = HeightTrait().tag(sync=True)
"""Height of the map in pixels, or valid CSS height property.

This API is not yet stabilized and may change in the future.
"""

layers = VariableLengthTuple(t.Instance(BaseLayer)).tag(
sync=True,
**ipywidgets.widget_serialization,
)
"""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.
"""

# 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.
Expand All @@ -174,23 +183,6 @@ 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.
"""

height = HeightTrait().tag(sync=True)
"""Height of the map in pixels, or valid CSS height property.

This API is not yet stabilized and may change in the future.
"""

layers = VariableLengthTuple(t.Instance(BaseLayer)).tag(
sync=True,
**ipywidgets.widget_serialization,
)
"""One or more `Layer` objects to display on this map.
"""

show_tooltip = t.Bool(default_value=False).tag(sync=True)
"""
Expand Down Expand Up @@ -219,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(
Expand Down Expand Up @@ -565,34 +557,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.
Expand Down
54 changes: 54 additions & 0 deletions lonboard/basemap.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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]
"""
3 changes: 2 additions & 1 deletion lonboard/traits.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand Down
6 changes: 4 additions & 2 deletions lonboard/types/map.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Loading
Loading