Skip to content

Fixes for multiple layers in lonboard + binder widget behaviour#4

Merged
batpad merged 2 commits into
mainfrom
lonboard-multiple-layers
Jun 10, 2026
Merged

Fixes for multiple layers in lonboard + binder widget behaviour#4
batpad merged 2 commits into
mainfrom
lonboard-multiple-layers

Conversation

@batpad

@batpad batpad commented Jun 10, 2026

Copy link
Copy Markdown
Member

This was based on this plan that @wrynearson + Sonnet created for upstream fixes after running into issues when using manywidgets:

manywidgets upstream suggestions

Findings from using FilterBinder with a multi-layer lonboard map (one PolygonLayer + one
ScatterplotLayer, both driven by the same RangeSlider). Three concrete issues with proposed
fixes, ordered by impact.


1. FilterBinder doesn't accept a list of layers

What happens

binder = FilterBinder(slider, layers)   # layers = [PolygonLayer, ScatterplotLayer]
# TraitError: The 'layer' trait … expected a Widget or None, not the list […]

The layer trait is traitlets.Instance(Widget), so passing a list raises immediately.

Why it matters

Multi-layer maps are common — a typical pattern is a PolygonLayer for high-intensity events
(ShakeMap footprints) and a ScatterplotLayer for everything else. The user naturally wants one
slider to filter both. Today they must create one FilterBinder per layer and display every one
of them, or the bindings never activate (see issue 2).

Suggested fix

Accept either a single layer or a list in both Python and JS.

widget.py — replace Instance with a Union:

layer = traitlets.Union(
    [
        traitlets.Instance(Widget),
        traitlets.List(traitlets.Instance(Widget)),
    ],
    allow_none=True,
    help="A single lonboard layer or a list of layers to filter.",
).tag(sync=True, **widget_serialization)

src/index.ts — iterate over whatever was passed:

// Resolve either a single layer widget-ref or an array of them
const rawLayer = model.get("layer");
const layerRefs: unknown[] = Array.isArray(rawLayer) ? rawLayer : [rawLayer];
const layers = await Promise.all(
  layerRefs.map((ref) => resolveModel(model, idOf(ref)))
);

// apply() fans out to all layers
const apply = (): void => {
  const low  = asNumber(source.get(lowField));
  const high = asNumber(source.get(highField));
  const key = `${low}:${high}:${layers.map(l => l.models.length).join(",")}`;
  if (key === lastKey) return;
  lastKey = key;
  for (const layer of layers) {
    layer.setByPath(filterField, [low, high]);
    layer.save();
  }
  status.textContent = `✅ ${label}: [${low}, ${high}]`;
};

With this change the workaround (multiple FilterBinders) becomes unnecessary.


2. FilterBinder silently does nothing unless it is rendered in the DOM

What happens

binders = [FilterBinder(slider, layer) for layer in layers]
# binders exists in Python — but the slider does nothing

No error. The map just never responds to the slider. Moving the slider has no effect.

Why it happens

All of FilterBinder's logic lives in its render() JS function. In anywidget, render() only
runs when the widget is mounted in the browser DOM. If you create a FilterBinder but never
display() it (or place it in a Column), the JS never starts, the event listeners are never
registered, and the polling loop never runs.

This is a silent failure. The user gets no traceback, no warning — just a broken UI.

Suggested fixes

Option A — add a Python-side observer as a fallback for live kernel mode:

def __init__(self, source=None, layer=None, **kwargs):
    ...
    super().__init__(**kwargs)
    # Fallback: keep filter_range in sync via Python observe.
    # Works even if this widget is never displayed. JS takes over in static export.
    if self.source is not None:
        self.source.observe(self._sync_filter, names=[self.low_field, self.high_field])

def _sync_filter(self, change=None):
    low  = getattr(self.source, self.low_field)
    high = getattr(self.source, self.high_field)
    targets = self.layer if isinstance(self.layer, list) else [self.layer]
    for layer in targets:
        if layer is not None:
            setattr(layer, self.filter_field, (low, high))

This makes FilterBinder work in live notebooks without ever calling display(). The JS
binding still handles static export, so both paths are covered.

Option B — warn loudly if not displayed within a short timeout:

Not easily implementable in anywidget, but worth noting as a DX improvement.

Minimum viable fix — document it clearly. The current docs show a single example with a
Column(slider, binder, map) but don't explain why binder has to be in the layout.
A note like this in the FilterBinder docs would prevent a lot of confusion:

Important: FilterBinder must be rendered somewhere in the notebook output for its
JavaScript binding to activate. Call display(binder) in a cell, or include it in a
Column/Row. Creating it in Python alone is not enough.


3. Column doesn't flatten list arguments

What happens

binders = [FilterBinder(slider, layer) for layer in layers]
Column(slider, binders, m)
# TraitError: The 'children' trait … contains an Instance of a List
# which expected a Widget, not the list […]

Column.__init__ receives *children positional args and wraps them in list(children). If
any arg is itself a list (e.g. binders), that list ends up as a child element, and the
List(Instance(Widget)) trait rejects it.

Suggested fix

Flatten in __init__:

def __init__(self, *children, **kwargs):
    if children:
        flat = []
        for child in children:
            if isinstance(child, (list, tuple)):
                flat.extend(child)
            else:
                flat.append(child)
        kwargs.setdefault("children", flat)
    super().__init__(**kwargs)

Same fix applies to Row and Grid, which have identical __init__ signatures.

With this, Column(slider, binders, m) just works.


Summary table

Issue Severity Workaround today Fix complexity
FilterBinder.layer rejects a list High — immediate traceback One FilterBinder per layer Low — Union trait + JS loop
FilterBinder silently inactive unless displayed High — silent wrong behavior display(*binders) Low–Medium — Python observe fallback
Column rejects list args Medium — traceback on natural usage Unpack: Column(slider, *binders, m) Low — flatten in __init__

@batpad batpad merged commit 1082be2 into main Jun 10, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant