Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2,772 changes: 1,704 additions & 1,068 deletions docs/examples/lonboard-map.ipynb

Large diffs are not rendered by default.

31 changes: 28 additions & 3 deletions docs/guides/lonboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ and fanning each write out to every proxy of the layer (the `Map` keeps its own)
from a `RangeSlider` (`DataFilterExtension`).
- [`LayerFilter`](../widgets/layer_filter.ipynb) — filter a layer by category
(`filter_categories`) via a checkbox legend.
- [`MapFlyer`](../widgets/map_flyer.ipynb) — buttons that animate the `Map` to
preset locations (`fly_to`).

Compose them with the [layout widgets](../widgets/row.ipynb) to put a control panel
beside the map:
Expand Down Expand Up @@ -74,11 +76,34 @@ legend = Legend([[palette[i].tolist(), name] for i, name in enumerate(["A", "B",
`["0–10", "10–20", …]`.) See the
[interop example](../examples/lonboard-map.ipynb).

## Recipe: fly to preset locations

`MapFlyer` repositions an already-rendered `Map` — something `Map.view_state` can't do
(it's *uncontrolled*: deck.gl reads it once as `initialViewState`). Each preset is a
dict with a `label` and camera keys; clicking a button animates the map there:

```python
from lonboard import Map
from manywidgets import Column
from manywidgets.lonboard import MapFlyer

m = Map(layer)
flyer = MapFlyer(m, locations=[
{"label": "New York", "longitude": -74.0, "latitude": 40.7, "zoom": 10},
{"label": "London", "longitude": -0.12, "latitude": 51.5, "zoom": 9},
], duration=3000)

Column(flyer, m)
```

It drives lonboard's existing `fly_to` (a deck.gl `FlyToInterpolator` animation) from the
browser — no kernel needed — so it works the same live and in static export.

## Caveats

- **No `MapFlyer` / live view control.** lonboard's `Map.view_state` is *uncontrolled*
(deck.gl `initialViewState`): writing it does **not** re-position an already-rendered
map. Set the initial `view_state` on the `Map` itself; there's no widget to fly it.
- **`MapFlyer` is fire-and-forget.** A fly-to is a one-shot animation command, not stored
state, so it positions the map on **click**, not on load, and won't replay if the map
re-renders. Set the starting position via the `Map`'s own `view_state`.
- **Seconds, not milliseconds** for time filters — `DataFilterExtension` compares as
float32 in the shader, so millisecond timestamps overflow its exact-integer range.
Use seconds for `get_filter_value` and the slider bounds.
Expand Down
2 changes: 1 addition & 1 deletion docs/guides/static-export.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ rules the plugin needs.
```yaml
project:
plugins:
- https://github.com/developmentseed/myst-anywidget-static-export/releases/download/v0.2.0/plugin.mjs
- https://github.com/developmentseed/myst-anywidget-static-export/releases/download/v0.3.0/plugin.mjs
```

3. Build the site: `myst build --html`. The plugin rewrites each widget output
Expand Down
3 changes: 2 additions & 1 deletion docs/myst.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ project:
# NO code dependency on it. (CI may pre-download this to docs/plugin.mjs and
# reference it locally; see .github/workflows/deploy.yml.)
plugins:
- https://github.com/developmentseed/myst-anywidget-static-export/releases/download/v0.2.0/plugin.mjs
- https://github.com/developmentseed/myst-anywidget-static-export/releases/download/v0.3.0/plugin.mjs
toc:
- file: index.md
- title: Widgets
Expand All @@ -46,6 +46,7 @@ project:
- file: widgets/layer_toggle.ipynb
- file: widgets/filter_binder.ipynb
- file: widgets/layer_filter.ipynb
- file: widgets/map_flyer.ipynb
- title: Guides
children:
- file: guides/linking.md
Expand Down
47 changes: 43 additions & 4 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ export interface AnyModel {
save_changes?: () => void;
on?: (event: string, cb: (...args: unknown[]) => void) => void;
off?: (event: string, cb: (...args: unknown[]) => void) => void;
/** Live (Backbone) model: fire an event locally. Used to deliver a custom
* message (`trigger("msg:custom", content, buffers)`) without a kernel. */
trigger?: (event: string, ...args: unknown[]) => void;
/** Static-export proxy: simulate an inbound kernel→frontend custom message by
* firing the model's `msg:custom` listeners locally. */
receiveCustomMessage?: (content: unknown, buffers?: unknown[]) => void;
widget_manager?: {
get_model?: (id: string) => Promise<AnyModel>;
create_view?: (model: AnyModel) => Promise<{ el: HTMLElement; remove?: () => void }>;
Expand Down Expand Up @@ -66,10 +72,10 @@ export function onChange(
/**
* Subscribe one callback to several trait changes.
*
* IMPORTANT: always register one listener per event. The live (Backbone) model
* accepts space-separated event names in `on(...)`, but the static-export model
* emitter does NOT — `on("change:a change:b", fn)` silently never fires there.
* Use this helper instead of space-separated names.
* Registers one listener per event. Both the live (Backbone) model and the
* static-export emitter now accept space-separated event names in `on(...)`, so
* `on("change:a change:b", fn)` works in either; this helper just keeps the
* per-trait form explicit (and our test `fakeModel` enforces it as a style guard).
*/
export function onChanges(
model: AnyModel,
Expand Down Expand Up @@ -132,6 +138,34 @@ export function setByPath(model: AnyModel, path: string, value: unknown): void {
model.set(topKey, next);
}

/**
* Deliver a Jupyter "custom message" to a model's frontend `msg:custom`
* listeners, locally and without a kernel.
*
* This lets a control widget drive another widget's comm-based behaviour (e.g.
* lonboard's `Map.fly_to`, which reacts to `model.on("msg:custom", …)`) in both
* contexts:
*
* - **Live kernel:** the target is a Backbone `WidgetModel`; `trigger("msg:custom",
* content, buffers)` fires its listeners — exactly what the comm layer does
* internally on a real kernel message, but with no round-trip.
* - **Static export:** the target is a proxy whose `receiveCustomMessage` (added
* by myst-anywidget-static-export) fires the same listeners.
*
* Falls back to a no-op when neither path exists.
*/
export function deliverCustomMessage(
model: AnyModel,
content: unknown,
buffers: unknown[] = [],
): void {
if (typeof model.receiveCustomMessage === "function") {
model.receiveCustomMessage(content, buffers); // static export
} else if (typeof model.trigger === "function") {
model.trigger("msg:custom", content, buffers); // live (Backbone)
}
}

// ── Static-export host registry ──────────────────────────────────────────────

type Registry = {
Expand Down Expand Up @@ -195,6 +229,8 @@ export interface ModelHandle {
save(): void;
/** Subscribe a trait-change handler on every currently-resolvable model. */
on(field: string, fn: (value: unknown) => void): void;
/** Deliver a custom message (`msg:custom`) to every matching proxy. */
sendCustom(content: unknown, buffers?: unknown[]): void;
}

function makeHandle(getModels: () => AnyModel[]): ModelHandle {
Expand All @@ -218,6 +254,9 @@ function makeHandle(getModels: () => AnyModel[]): ModelHandle {
on(field, fn) {
for (const m of getModels()) m.on?.(`change:${field}`, () => fn(m.get(field)));
},
sendCustom(content, buffers = []) {
for (const m of getModels()) deliverCustomMessage(m, content, buffers);
},
};
}

Expand Down
61 changes: 60 additions & 1 deletion packages/core/tests/core.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
asNumber,
deliverCustomMessage,
onChanges,
renderChild,
resolveModel,
safeSaveChanges,
setByPath,
} from "@manywidgets/core";
import { fakeHost, fakeModel, installHostRegistry, mountEl } from "@manywidgets/test-utils";
import {
fakeHost,
fakeModel,
installHostRegistry,
liveModel,
mountEl,
} from "@manywidgets/test-utils";

describe("asNumber", () => {
it("coerces and falls back", () => {
Expand Down Expand Up @@ -100,6 +107,58 @@ describe("resolveModel (live kernel)", () => {
});
});

describe("deliverCustomMessage", () => {
it("static export: fires msg:custom listeners via receiveCustomMessage", () => {
const m = fakeModel({}); // static proxy: has receiveCustomMessage, no trigger
let got: unknown;
m.on("msg:custom", (msg) => {
got = msg;
});
deliverCustomMessage(m as never, { type: "fly-to", zoom: 4 });
expect(got).toEqual({ type: "fly-to", zoom: 4 });
});

it("live kernel: fires msg:custom listeners via Backbone trigger", () => {
const m = liveModel({}); // live model: has trigger, no receiveCustomMessage
let got: unknown;
let buffers: unknown;
m.on("msg:custom", (msg, b) => {
got = msg;
buffers = b;
});
deliverCustomMessage(m as never, { type: "fly-to" }, ["buf"]);
expect(got).toEqual({ type: "fly-to" });
expect(buffers).toEqual(["buf"]);
});

it("no-ops when neither delivery path exists", () => {
const bare = { get: () => undefined, set: () => {} };
expect(() => deliverCustomMessage(bare as never, { type: "x" })).not.toThrow();
});
});

describe("ModelHandle.sendCustom", () => {
let cleanup: () => void;
afterEach(() => cleanup?.());

it("fans a custom message out to every matching proxy", async () => {
const p1 = fakeModel({}, { model_id: "map-1" });
const p2 = fakeModel({}, { model_id: "map-1" });
cleanup = installHostRegistry([p1, p2]);
const seen: unknown[] = [];
p1.on("msg:custom", (m) => seen.push(m));
p2.on("msg:custom", (m) => seen.push(m));

const handle = await resolveModel(fakeModel({}) as never, "map-1");
handle.sendCustom({ type: "fly-to", longitude: 1 });

expect(seen).toEqual([
{ type: "fly-to", longitude: 1 },
{ type: "fly-to", longitude: 1 },
]);
});
});

describe("renderChild", () => {
it("static: delegates to host.renderChild and returns its dispose", async () => {
const host = fakeHost();
Expand Down
3 changes: 2 additions & 1 deletion src/manywidgets/lonboard/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,6 @@
from .filter_binder import FilterBinder
from .layer_filter import LayerFilter
from .layer_toggle import LayerToggle
from .map_flyer import MapFlyer

__all__ = ["LayerToggle", "FilterBinder", "LayerFilter"]
__all__ = ["LayerToggle", "FilterBinder", "LayerFilter", "MapFlyer"]
3 changes: 3 additions & 0 deletions src/manywidgets/lonboard/map_flyer/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .widget import MapFlyer

__all__ = ["MapFlyer"]
43 changes: 43 additions & 0 deletions src/manywidgets/lonboard/map_flyer/doc.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# MapFlyer

Buttons that animate a [lonboard](https://developmentseed.org/lonboard/) `Map` to
preset locations, driving lonboard's `fly_to` from the browser.

```{note}
`manywidgets.lonboard` is optional — install it with
`pip install "manywidgets[lonboard]"`. See the [lonboard guide](../guides/lonboard.md).
This page is reference only; the [interop example](../examples/lonboard-map.ipynb)
shows a **live map** with these controls.
```

Each preset is a dict with a `label` and deck.gl camera keys (`longitude`, `latitude`,
`zoom`, and optionally `pitch` / `bearing`). Clicking a button flies the map there —
no kernel required, so it behaves the same live and in static export.

## Import

```python
from manywidgets.lonboard import MapFlyer
```

## Example

```python
from lonboard import Map, ScatterplotLayer
from manywidgets.lonboard import MapFlyer

layer = ScatterplotLayer.from_geopandas(gdf)
m = Map(layer, basemap=None)
flyer = MapFlyer(m, locations=[
{"label": "New York", "longitude": -74.0, "latitude": 40.7, "zoom": 10},
{"label": "London", "longitude": -0.12, "latitude": 51.5, "zoom": 9},
], duration=3000)

# show the control and the map together (see the Layout widgets)
from manywidgets import Column
Column(flyer, m)
```

## API

{api-table}
78 changes: 78 additions & 0 deletions src/manywidgets/lonboard/map_flyer/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import type { RenderProps } from "@anywidget/types";
import { asNumber, idOf, type ModelHandle, onChanges, resolveModel } from "@manywidgets/core";

interface Location {
label?: string;
longitude?: number;
latitude?: number;
zoom?: number;
[key: string]: unknown;
}

interface MapFlyerModel {
map: unknown;
locations: Location[];
duration: number;
label: string;
}

// Build the content of lonboard's "fly-to" custom message from a preset. lonboard
// passes this object straight to deck.gl's flyTo (FlyToInterpolator + setViewState),
// so we forward every camera key on the preset and stamp the transition duration.
function flyToMessage(loc: Location, duration: number): Record<string, unknown> {
const { label: _label, ...camera } = loc;
return { type: "fly-to", transitionDuration: duration, ...camera };
}

async function render({ model, el }: RenderProps<MapFlyerModel>): Promise<void> {
const container = document.createElement("div");
container.className = "manywidgets-mapflyer";

const heading = document.createElement("div");
heading.className = "manywidgets-mapflyer__label";

const buttons = document.createElement("div");
buttons.className = "manywidgets-mapflyer__buttons";

container.append(heading, buttons);
el.appendChild(container);

let handle: ModelHandle | null = null;
try {
handle = await resolveModel(model, idOf(model.get("map")));
} catch (err) {
console.warn("[manywidgets:map-flyer] could not resolve map", err);
}

function flyTo(loc: Location): void {
if (!handle) return;
handle.sendCustom(flyToMessage(loc, asNumber(model.get("duration"), 4000)));
}

function renderButtons(): void {
buttons.replaceChildren();
const locations = model.get("locations");
const list = Array.isArray(locations) ? locations : [];
list.forEach((loc, i) => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "manywidgets-mapflyer__button";
btn.textContent = loc.label || `Location ${i + 1}`;
btn.addEventListener("click", () => flyTo(loc));
buttons.appendChild(btn);
});
}

function renderLabel(): void {
const text = String(model.get("label") ?? "");
heading.textContent = text;
heading.style.display = text ? "" : "none";
}

renderLabel();
renderButtons();
onChanges(model, ["label"], renderLabel);
onChanges(model, ["locations"], renderButtons);
}

export default { render };
Loading
Loading