Showing Tooltips for a Million Points with PyDeck Picking

Part of the Popups, Tooltips & Feature Interaction guide.

Operative rule: set pickable=True only on the layers a reader will actually interrogate — each pickable layer costs a full extra render pass, and the cost is paid on every pointer move whether or not anything is hit.

How the Picking Pass Works

Deck.gl does not test geometry to find what is under the cursor. It renders the scene a second time, off-screen, with every object painted in a unique colour derived from its index, then reads one pixel at the pointer’s position and decodes that colour back into a layer and a row number. The lookup is a single pixel read, so identifying one point among five million costs exactly what identifying one among five hundred costs.

What is not free is the extra pass. Every pickable layer is drawn twice per interaction frame, so making a decorative basemap overlay pickable doubles its cost for no benefit. The optimisation that matters is therefore not making picking faster but making fewer things pickable.

How a colour-encoded picking pass identifies a point The visible pass draws the scene normally. A second off-screen pass draws every object in a colour that encodes its index. One pixel is read at the pointer position. That colour decodes to a layer and row index, which looks up the original row in constant time regardless of dataset size. Constant-time identification, paid for with one extra render pass visible pass what the reader sees picking pass, off-screen each object drawn as its index read 1 pixel at the pointer decode → row object + index cost of the lookup: identical at 5 000 and 5 000 000 objects this is why point-in-polygon maths in JavaScript is never the right answer here cost of the pass: one extra draw per pickable layer, per interaction frame so the tuning knob is how many layers are pickable, not how picking works

Production-Ready Implementation

from __future__ import annotations

import geopandas as gpd
import pandas as pd
import pydeck as pdk

# Only the columns the tooltip needs travel to the browser.
TOOLTIP_COLUMNS = ["site_id", "site_name", "reading", "recorded_at"]


def build_deck(gdf: gpd.GeoDataFrame) -> pdk.Deck:
    """A million-point scatterplot with a tooltip and one pickable layer."""
    if gdf.crs is None or gdf.crs.to_epsg() != 4326:
        gdf = gdf.to_crs(epsg=4326)

    df = pd.DataFrame({
        "lon": gdf.geometry.x,
        "lat": gdf.geometry.y,
        **{c: gdf[c] for c in TOOLTIP_COLUMNS if c in gdf.columns},
    })

    points = pdk.Layer(
        "ScatterplotLayer",
        data=df,
        get_position="[lon, lat]",
        get_radius=40,
        radius_min_pixels=1.5,
        radius_max_pixels=6,
        get_fill_color=[43, 140, 190, 180],
        pickable=True,               # the ONLY pickable layer
        auto_highlight=True,         # free hover feedback, no extra code
        highlight_color=[255, 209, 102, 220],
    )

    context = pdk.Layer(
        "GeoJsonLayer",
        data="boundaries.geojson",
        stroked=True,
        filled=False,
        get_line_color=[120, 120, 120, 120],
        pickable=False,              # decorative — never make it pickable
    )

    tooltip = {
        "html": (
            "<b>{site_name}</b><br/>"
            "reading: {reading}<br/>"
            "<span class='muted'>{recorded_at}</span>"
        ),
        "style": {"backgroundColor": "#0D2B45", "color": "white",
                  "fontSize": "12px", "maxWidth": "260px"},
    }

    view = pdk.ViewState(
        latitude=float(df["lat"].mean()),
        longitude=float(df["lon"].mean()),
        zoom=8,
    )
    return pdk.Deck(layers=[context, points], initial_view_state=view,
                    tooltip=tooltip, map_style="light")


if __name__ == "__main__":
    sites = gpd.read_file("sensor_readings.geojson")
    build_deck(sites).to_html("sensors.html", notebook_display=False)

Two choices there are worth naming. Building a narrow DataFrame with only the tooltip columns keeps the payload small — the whole dataset does not need to travel just because four of its fields are shown. And auto_highlight gives hover feedback without a single line of JavaScript, which on a million-point layer is both the cheapest and the most reliable option.

Keeping Picking Affordable

Four levers on picking cost Marking fewer layers pickable removes whole render passes. A modest picking radius makes small points easier to hit without ambiguity. Sending only the tooltip's columns keeps the payload small. Aggregating dense data means picking returns a bin, which is usually the more useful answer anyway. Four levers, in the order they pay off fewer pickable layers removes an entire render pass each — by far the biggest win picking radius ≈ 4 px small points become hittable without becoming ambiguous tooltip columns only the payload tracks what is shown, not what the table happens to hold aggregate when dense picking a bin answers a better question than picking one of 4 000 overlapping dots

What a Tooltip Should Say at This Scale

At a million points, the tooltip is often the only way a reader can ask about an individual record — but it is also, at that density, the least likely thing they clicked deliberately. Design for both: keep it to the identity of the record plus the one value the map is about, and put anything longer behind a click that opens a panel.

What belongs in a hover tooltip versus a click panel The hover tooltip carries the record's name, the value the map encodes and a timestamp, three lines at most. The click panel carries the full allowed field set, related links and any action. Splitting them this way keeps hovering informative without making it noisy. Three lines on hover, everything else on click hover tooltip — 3 lines 1 · the record's name or id 2 · the value the map encodes 3 · when it was recorded anything more is unreadable while moving click panel — the full record every allowed field, formatted history, related records, links any action the reader can take stays open, so it can be read and copied

A final note on expectations: at very high density a hover result is a plausible answer rather than a deliberate choice, because several points sit under the cursor and only one can win. Designing the tooltip to be informative rather than authoritative — and offering aggregation when density makes individual picking meaningless — keeps the interaction honest about what it can tell the reader.

Verification Steps

  • Confirm pickable is False on every decorative layer, then profile a pointer sweep before and after.
  • Hover the densest region and confirm the tooltip resolves within a frame.
  • Remove a tooltip column from the DataFrame and confirm the template degrades to a blank rather than the word undefined.
  • Confirm the exported HTML does not carry columns the tooltip never shows.
  • Test on a machine without a discrete GPU: picking depends on the same context the render does.

Common Errors & Fixes

The tooltip never appears

pickable is unset on the layer. It defaults to false, and there is no warning — the layer simply never reports a hit.

Hovering shows a neighbouring point rather than the one under the cursor

The picking radius is larger than the spacing between points. Reduce it, or aggregate so that the reader picks a bin instead.

The tooltip flickers between two layers

Two layers are pickable and overlap. Make only the data layer pickable, or handle the hit list explicitly and take the topmost deterministically.

Everything works locally and the deployed page shows nothing

The data path in the exported HTML is relative to the notebook rather than to the deployment root — the same class of problem covered in exporting PyDeck visualizations to standalone HTML.


Working With the Payload, Not Against It

At a million rows the tooltip’s field list is also a payload decision. Every column named in the template travels to the browser for every row, whether or not a reader ever hovers that point — so a tooltip showing five fields on a million-row layer ships five million values to display, at most, a few dozen of them.

There are three ways to keep that honest. The first is simply to name fewer fields: an identifier, the mapped value and a timestamp are usually enough, with everything else behind a click. The second is to shorten the values themselves — an integer code rather than a long category string, a Unix timestamp rather than an ISO string — and expand them in the template. The third, for genuinely large attribute sets, is to ship only an identifier with the points and fetch the full record on demand when a reader clicks, which turns a per-row cost into a per-interaction one.

Column types matter as much as column count. A string column is stored as individual JavaScript strings; a numeric column becomes a compact typed array. Converting a categorical column to a small integer code before export can halve the payload of a large layer on its own, and the decode table costs a few dozen bytes.

It is also worth being deliberate about precision. Coordinates exported at fifteen decimal places carry no information a screen can show, and they inflate every row. Rounding to six decimals — about ten centimetres — before the frame leaves Python is invisible on the map and measurably smaller on the wire, particularly once the file is compressed, because repeated short values compress far better than long unique ones.

Gotchas & Edge Cases

  • The picking pass encodes object indices as colours, so an enormous single layer can exhaust the index space; splitting into several layers is the remedy, not reducing the data.
  • Tooltip templates read the row object directly, so a column renamed during export produces the literal placeholder text rather than an error.
  • auto_highlight allocates its own buffers; on a memory-constrained device it can be the difference between a layer that renders and one that does not.
  • Picking respects layer order, so a large transparent layer drawn above the points will absorb every hover unless it is marked unpickable.
  • On a high-density display the picking buffer is allocated at device resolution, which multiplies its memory cost by four — worth knowing when a layer works on a laptop and fails on a tablet.
  • A tooltip that appears near the viewport edge should flip rather than be clipped; deck.gl does not do this for you, and a clipped tooltip is indistinguishable from a broken one.