Building a Custom Layer Switcher in PyDeck

Part of the Layer Management & Toggling guide.

Operative rule: Toggle each layer’s visible prop and call deck.setProps({layers}) — do not recreate the Deck; flipping visible reuses GPU buffers and interaction state, while a rebuild reuploads all data.

How a PyDeck Layer Switcher Works

pydeck renders a deck.gl scene, and deck.gl has no layer-control widget of its own — unlike Folium, where toggling GeoJSON overlays with Folium LayerControl is a one-line call. So a switcher in PyDeck is something you build: a set of HTML checkboxes in the exported page plus a small script that talks to the live deck instance through the deck.gl JavaScript API. That is the essence of Layer Management & Toggling for this renderer — the Python side defines the layers and their identities, and the browser side flips them on and off.

The mechanism that makes toggling cheap is deck.gl’s reactive layer model. Every render, deck.gl diffs the new layer list against the previous one by each layer’s id. If an id persists and only a prop like visible changed, deck.gl reuses the layer’s existing GPU buffers and simply re-renders — no data re-upload, no loss of hover or view state. That is why the operative rule is to flip visible and call deck.setProps({layers}) rather than to rebuild the Deck: a rebuild throws away the diff baseline and reuploads every attribute. Stable ids are the linchpin. Assign an explicit id to each pydeck.Layer in Python so the exported JSON carries it, then key your checkboxes to those same ids. This keeps toggling smooth even when rendering a million points with a PyDeck ScatterplotLayer, and it survives the export path described in exporting PyDeck visualizations to standalone HTML.

Toggle visible vs recreate the deck A checkbox change routes to a JS handler. The cheap path flips layer.visible and calls deck.setProps, reusing GPU buffers. The costly path recreates the Deck and reuploads all data and loses state. [x] checkbox data-layer-id flip layer.visible deck.setProps({layers}) same id -> diff, not rebuild GPU buffers reused, state kept recreate Deck new deck.gl instance reupload all data buffers dropped hover/view reset Prefer the top path; recreate only when the layer set itself changes

Production-Ready Implementation

The Python side builds two pydeck.Layer objects with explicit, stable ids and injects a checkbox panel plus a toggle script into the exported HTML. The JavaScript keeps the layer list in memory, flips the matching layer’s visible flag, and pushes the change through deck.setProps. The __deck handle that pydeck exposes on the page is the live deck.gl instance.

from __future__ import annotations

import pandas as pd
import pydeck as pdk

# Stable ids: these MUST match the checkbox data-layer-id values below.
SCATTER_ID = "incidents"
HEX_ID = "density"


def build_switcher_html(output_path: str = "pydeck_switcher.html") -> str:
    df = pd.DataFrame(
        {
            "lat": [40.71, 40.72, 40.70, 40.73],
            "lon": [-74.00, -74.01, -73.99, -74.02],
            "weight": [10, 40, 25, 60],
        }
    )
    records = df.to_dict(orient="records")

    scatter = pdk.Layer(
        "ScatterplotLayer",
        id=SCATTER_ID,                    # explicit id -> deck.gl can diff it
        data=records,
        get_position=["lon", "lat"],
        get_radius="weight",
        get_fill_color=[228, 87, 86, 180],
        radius_min_pixels=3,
        pickable=True,
        visible=True,
    )
    hexagon = pdk.Layer(
        "HexagonLayer",
        id=HEX_ID,
        data=records,
        get_position=["lon", "lat"],
        radius=200,
        elevation_scale=4,
        extruded=True,
        visible=False,                    # starts hidden; user can switch on
    )

    deck = pdk.Deck(
        layers=[scatter, hexagon],
        initial_view_state=pdk.ViewState(latitude=40.715, longitude=-74.0,
                                         zoom=13, pitch=30),
        map_style="https://basemaps.cartocdn.com/gl/positron-gl-style/style.json",
        tooltip={"text": "weight: {weight}"},
    )

    # Export as a string so we can splice in the control panel + script.
    html = deck.to_html(notebook_display=False, as_string=True)

    controls = f"""
<div id="layer-switch" style="position:absolute;top:12px;right:12px;z-index:10;
     background:rgba(255,255,255,0.9);padding:8px 12px;border-radius:6px;font-family:sans-serif;">
  <label style="display:block"><input type="checkbox" data-layer-id="{SCATTER_ID}" checked> Incidents</label>
  <label style="display:block"><input type="checkbox" data-layer-id="{HEX_ID}"> Density</label>
</div>
<script>
(function () {{
  // pydeck exposes the live deck.gl instance on window as __deck.
  function wire() {{
    var deck = window.__deck || (window.deckInstance);
    if (!deck) {{ return setTimeout(wire, 100); }}   // wait for boot
    // Keep our own reference to the current layer array.
    var layers = deck.props.layers.slice();
    document.querySelectorAll('#layer-switch input').forEach(function (box) {{
      box.addEventListener('change', function () {{
        var id = box.getAttribute('data-layer-id');
        layers = layers.map(function (layer) {{
          // Clone with the flipped visible prop; keep the SAME id so
          // deck.gl diffs instead of rebuilding.
          return layer.id === id
            ? layer.clone({{ visible: box.checked }})
            : layer;
        }});
        deck.setProps({{ layers: layers }});   // re-render in place
      }});
    }});
  }}
  wire();
}})();
</script>
"""
    html = html.replace("</body>", controls + "</body>")
    with open(output_path, "w", encoding="utf-8") as fh:
        fh.write(html)
    return output_path


if __name__ == "__main__":
    print("Wrote", build_switcher_html())

Alternative Variants

Regenerating the layer set by id when the data changes

Flipping visible covers show/hide. When the set of layers changes — a new category arrives from a fresh data fetch — rebuild the array by id and call setProps once. This still avoids constructing a new Deck; it only replaces the layer list.

// Rebuild the layer array from a spec keyed by id, then push once.
function applyLayerSpec(deck, specById) {
  const layers = Object.entries(specById).map(([id, cfg]) =>
    new deck.constructor.Layer({ id, ...cfg })  // stable id per entry
  );
  deck.setProps({ layers });   // one re-render; no new Deck instance
}

Toggle strategy reference

Change Approach Cost
Show/hide one layer clone({visible}) + setProps Cheapest — buffer reuse
Restyle (color/radius) clone({getFillColor}) + setProps Cheap — attribute update
Add/remove a layer Rebuild array by id + setProps Moderate — diff by id
Change data source New data prop, same id Moderate — data re-upload
Recreate Deck new deck.Deck(...) Most expensive — avoid

Verification Steps

  • Ids are present: open the exported HTML and confirm each layer object in the embedded JSON carries the id you set; missing ids force rebuilds.
  • Toggle hides in place: uncheck a box and confirm the layer vanishes with no visible reload of the other layer and no view-state reset.
  • No data re-upload on toggle: open DevTools → Network, toggle a layer, and confirm no new data request fires — visibility changes must stay on the GPU.
  • State survives: hover a point to show a tooltip, toggle the other layer, and confirm the view position and interaction are preserved.
  • Checkbox ids match: confirm every data-layer-id equals a real layer id; a mismatch silently does nothing.

Common Errors & Fixes

Toggling a layer reloads or flickers the whole scene

The layers have no stable id, so deck.gl cannot match old to new and treats each setProps as an entirely new layer set, reuploading data. Fix: give every pydeck.Layer an explicit id in Python and preserve it when you clone the layer in JavaScript. With matching ids, deck.gl diffs and only flips visible.

Checkbox does nothing

Either the data-layer-id does not match any layer id, or the script ran before pydeck created the deck instance so window.__deck was undefined. Fix: confirm the ids line up exactly, and poll for the deck handle (as the implementation does with setTimeout(wire, 100)) so wiring happens only after the deck has booted.

Layer disappears but the map resets its pitch and zoom

The handler is recreating the Deck (or calling setProps without preserving initialViewState/other layers) instead of cloning the single changed layer. Fix: mutate only the target layer via clone({visible}), keep the rest of the array intact, and pass just {layers} to setProps so the view state is untouched. This is the operative rule — toggle visible, never rebuild.

layer.clone is not a function

The handler is holding plain serialized layer descriptors rather than live deck.gl Layer instances. pydeck’s exported JSON is instantiated by the runtime, so read the current layers from deck.props.layers, which are real instances with clone, rather than reconstructing them from the JSON. Fix: seed your working array from deck.props.layers.slice().