\n\"\"\"\n\n# write final HTML\nhtml_out = fig_html.replace(\"\", post_script + \"\")\nwith open(\"plotly_multiindex_dropdowns.html\", \"w\", encoding=\"utf-8\") as f:\n f.write(html_out)\n```\n\nWhy this works\n- The Python side only builds traces once and outputs a standalone HTML.\n- The mapping JSON is small relative to the trace payload if your levels are moderate.\n- At runtime the browser computes an intersection of index lists and calls `Plotly.restyle` with a single boolean array — very quick.\n\nWhen to pick this approach\n- You need per-trace styling, separate legend entries or different hover templates per (date,category).\n- You have moderate numbers of unique values per level, or you accept the HTML size for very many values.\n\nRelated guidance: Plotly docs show precomputing boolean arrays and using `restyle` for fast toggles ([dropdowns doc](https://plotly.com/python/dropdowns/)).\n\n---\n\n## Approach B — Single-trace + Plotly transforms (most scalable for many values) {#transforms}\n\nIf you have lots of unique dates and categories (hundreds or thousands), restructure your data into a single long trace and attach a `filter` transform per MultiIndex level. Each transform filters the same arrays by a target column; use `updatemenus` that restyle `transforms[i].value` for each level. This is server-free and pure client-side filtering — highly scalable and still works in a standalone HTML.\n\nPattern sketch:\n\n```python\n# build long-form DataFrame (stack the MultiIndex columns)\ndx = x.stack().reset_index().rename(columns={'level_0':'date', 'level_1':'category', 0:'x'})\ndy = y.stack().reset_index().rename(columns={'level_0':'date', 'level_1':'category', 0:'y'})\ndz = z.stack().reset_index().rename(columns={'level_0':'date', 'level_1':'category', 0:'z'})\n\ndf = dx.merge(dy, on=['Observations','date','category']).merge(dz, on=['Observations','date','category'])\ndf['date_str'] = df['date'].dt.strftime('%Y-%m-%d')\n\ntrace = go.Scatter3d(\n x=df['x'], y=df['y'], z=df['z'], mode='markers',\n transforms=[\n dict(type='filter', target=df['date_str'].tolist(), operation='=', value=df['date_str'].iloc[0]),\n dict(type='filter', target=df['category'].astype(str).tolist(), operation='=', value=str(categories[0]))\n ],\n)\n\nfig = go.Figure(trace)\n\n# one updatemenu per level that restyles the transform.value\ndate_buttons = [\n dict(label=d.strftime('%Y-%m-%d'), method='restyle', args=[{'transforms[0].value': d.strftime('%Y-%m-%d')}])\n for d in dates\n]\ncat_buttons = [\n dict(label=c, method='restyle', args=[{'transforms[1].value': str(c)}])\n for c in categories\n]\n\nfig.update_layout(updatemenus=[\n dict(x=0, y=1.1, buttons=date_buttons),\n dict(x=0.35, y=1.1, buttons=cat_buttons),\n])\n\nfig.write_html(\"plot_transforms.html\", include_plotlyjs=\"cdn\", full_html=True)\n```\n\nWhy this works and when to choose it\n- Transforms are applied client-side by Plotly.js, so no server needed.\n- You keep a single trace: memory and rendering scale better than thousands of tiny traces.\n- Updatemenus that restyle `transforms[i].value` do not override each other, so two independent dropdowns naturally combine (intersection).\n- Best when styling per-point/group is uniform (the same marker style) and you only need point-level filtering, not a separate legend entry per (date,category).\n\nSee Plotly transforms documentation for details and examples ([multiple transforms](https://plotly.com/python/multiple-transforms/)).\n\n---\n\n## Performance, trade-offs and practical tips (scatter3d) {#performance}\n\n- Many traces vs. one trace: Plotly's WebGL renderer (`scatter3d`) handles thousands of points in a single trace well, but many traces are heavier than many points in one trace ([3d scatter notes](https://plotly.com/python/3d-scatter-plots/)). If you have styling per group, you may need multiple traces; otherwise transforms or a single trace are cheaper.\n- Use `restyle` for visibility toggles (fast because the browser receives a precomputed array): see the official dropdown pattern ([dropdowns doc](https://plotly.com/python/dropdowns/), [updatemenus reference](https://plotly.com/python/reference/layout/updatemenus/)).\n- If you choose the JS-visible-mask route and have extremely many level values, the mapping JSON can get large. In that case prefer transforms or server-side solutions (Dash) if server-side compute is available.\n- Grouping: `legendgroup` / `legendgrouptitle` can help reduce legend clutter and allow grouped legend toggles, but they don't replace the dropdown-filtering UX. See the plotly.js discussion on legend grouping (GitHub issue) if grouping is relevant: https://github.com/plotly/plotly.js/issues/3135.\n- Multi-select: For multi-select dropdowns you can build UI ` per level and calls Plotly.restyle to set trace.visible (intersection of selections); or (B) pivot to a single long trace and attach a filter transform per MultiIndex level, then use updatemenus that restyle transforms[i].value to filter client‑side. Both work in a standalone HTML file; transforms scale best for very large numbers of values, while the JS visibility-mask approach preserves per-trace styling and legends.


Contents


Problem overview and UX constraints

You have a pandas.MultiIndex on your DataFrame columns (dates × categories) and you plot each column pair as a separate Plotly scatter3d trace. Building a single dropdown with one button per (date,category) tuple explodes when a level has many values. The UX you want is one dropdown per MultiIndex level (for example, one “date” select and one “category” select) that combine (typically by intersection) and update which traces/points are visible — and everything must work in a standalone HTML file (no Dash, no ipywidgets). The official Plotly docs describe two canonical patterns here: precompute visibility arrays and call restyle, or use client-side callbacks / Plotly.js to compute combined masks in the browser; there’s also a third option using Plotly transforms to avoid many traces entirely (Plotly dropdowns guidance, updatemenus reference, transforms doc).


Data shape and trace → index mapping

First, convert your MultiIndex columns to a flat list of traces and record each trace’s index. That mapping is the core of the JS approach; transforms use a long (stacked) DataFrame instead.

Example: build a map of level value → list of trace indices (Python):

python
# 'columns' is your MultiIndex: for example from your example code
mapping = {"date": {}, "category": {}}
for idx, (date, cat) in enumerate(columns):
 date_str = pd.Timestamp(date).strftime("%Y-%m-%d") if isinstance(date, pd.Timestamp) else str(date)
 mapping["date"].setdefault(date_str, []).append(idx)
 mapping["category"].setdefault(str(cat), []).append(idx)

This mapping (serialized to JSON) is what a small client-side script can use to compute the boolean visible mask for Plotly.restyle.


Approach A — Precompute visible masks + embedded JavaScript (keeps per-(date,category) traces)

When you need per-(date,category) trace styling (different marker/color/legend entry) but still want one dropdown per level, embed a tiny JS routine in the exported HTML that combines the two select values and calls Plotly.restyle with a boolean visibility array. This keeps the Python code simple and the browser work cheap (array lookup + restyle).

Copy-paste-ready pattern (adapted to your example):

python
import json
import numpy as np
import pandas as pd
import plotly.graph_objects as go
import plotly.io as pio

# --- build data (your example) ---
categories = pd.Index(["A", "B"])
dates = pd.date_range("2026-01-01", "2026-01-10")
columns = pd.MultiIndex.from_product([dates, categories])
index = pd.Index(range(6), name="Observations")
rng = np.random.default_rng(0)
x = pd.DataFrame(rng.standard_normal((len(index), len(columns))), columns=columns, index=index)
y = pd.DataFrame(rng.standard_normal((len(index), len(columns))), columns=columns, index=index)
z = pd.DataFrame(rng.standard_normal((len(index), len(columns))), columns=columns, index=index)

# --- add one trace per (date,category) ---
fig = go.Figure()
for i, (date, cat) in enumerate(columns):
 fig.add_trace(
 go.Scatter3d(
 x=x[date, cat], y=y[date, cat], z=z[date, cat],
 mode="markers",
 name=f"{pd.Timestamp(date).strftime('%Y-%m-%d')} | {cat}"
 )
 )

# --- build mapping from each level value -> trace indices ---
mapping = {"date": {}, "category": {}}
for idx, (date, cat) in enumerate(columns):
 dstr = pd.Timestamp(date).strftime("%Y-%m-%d")
 mapping["date"].setdefault(dstr, []).append(idx)
 mapping["category"].setdefault(str(cat), []).append(idx)

# --- create HTML and inject JS that builds two <select> elements and updates visibility ---
fig_html = pio.to_html(fig, full_html=True, include_plotlyjs="cdn", div_id="plotly-div")

date_options = "".join([f'<option value="{d}">{d}</option>' for d in mapping["date"].keys()])
cat_options = "".join([f'<option value="{c}">{c}</option>' for c in mapping["category"].keys()])

post_script = f"""
<script>
const mapping = {json.dumps(mapping)};
const n_traces = {len(columns)};
const gd = document.getElementById('plotly-div');

// insert simple controls above the plot
const controls = document.createElement('div');
controls.style.margin = '6px 0';
controls.innerHTML = `
 <label>Date: <select id="date-select">{date_options}</select></label>
 <label style="margin-left:12px">Category: <select id="cat-select">{cat_options}</select></label>
`;
gd.parentNode.insertBefore(controls, gd);

// compute intersection-visible mask and restyle
function computeVisible(date, cat) {{
 const vis = new Array(n_traces).fill(false);
 const dateIdx = mapping.date[date] || [];
 const catIdx = mapping.category[cat] || [];
 const catSet = new Set(catIdx);
 for (const i of dateIdx) {{
 if (catSet.has(i)) vis[i] = true;
 }}
 Plotly.restyle(gd, {{'visible': vis}});
}}

// wire up events
const dateSelect = document.getElementById('date-select');
const catSelect = document.getElementById('cat-select');
dateSelect.addEventListener('change', ()=> computeVisible(dateSelect.value, catSelect.value));
catSelect.addEventListener('change', ()=> computeVisible(dateSelect.value, catSelect.value));
// initial apply
computeVisible(dateSelect.value, catSelect.value);
</script>
"""

# write final HTML
html_out = fig_html.replace("</body>", post_script + "</body>")
with open("plotly_multiindex_dropdowns.html", "w", encoding="utf-8") as f:
 f.write(html_out)

Why this works

When to pick this approach

Related guidance: Plotly docs show precomputing boolean arrays and using restyle for fast toggles (dropdowns doc).


Approach B — Single-trace + Plotly transforms (most scalable for many values)

If you have lots of unique dates and categories (hundreds or thousands), restructure your data into a single long trace and attach a filter transform per MultiIndex level. Each transform filters the same arrays by a target column; use updatemenus that restyle transforms[i].value for each level. This is server-free and pure client-side filtering — highly scalable and still works in a standalone HTML.

Pattern sketch:

python
# build long-form DataFrame (stack the MultiIndex columns)
dx = x.stack().reset_index().rename(columns={'level_0':'date', 'level_1':'category', 0:'x'})
dy = y.stack().reset_index().rename(columns={'level_0':'date', 'level_1':'category', 0:'y'})
dz = z.stack().reset_index().rename(columns={'level_0':'date', 'level_1':'category', 0:'z'})

df = dx.merge(dy, on=['Observations','date','category']).merge(dz, on=['Observations','date','category'])
df['date_str'] = df['date'].dt.strftime('%Y-%m-%d')

trace = go.Scatter3d(
 x=df['x'], y=df['y'], z=df['z'], mode='markers',
 transforms=[
 dict(type='filter', target=df['date_str'].tolist(), operation='=', value=df['date_str'].iloc[0]),
 dict(type='filter', target=df['category'].astype(str).tolist(), operation='=', value=str(categories[0]))
 ],
)

fig = go.Figure(trace)

# one updatemenu per level that restyles the transform.value
date_buttons = [
 dict(label=d.strftime('%Y-%m-%d'), method='restyle', args=[{'transforms[0].value': d.strftime('%Y-%m-%d')}])
 for d in dates
]
cat_buttons = [
 dict(label=c, method='restyle', args=[{'transforms[1].value': str(c)}])
 for c in categories
]

fig.update_layout(updatemenus=[
 dict(x=0, y=1.1, buttons=date_buttons),
 dict(x=0.35, y=1.1, buttons=cat_buttons),
])

fig.write_html("plot_transforms.html", include_plotlyjs="cdn", full_html=True)

Why this works and when to choose it

See Plotly transforms documentation for details and examples (multiple transforms).


Performance, trade-offs and practical tips (scatter3d)


Exporting to a standalone HTML and debugging notes


Sources


Conclusion

Two practical, standalone-HTML solutions are recommended: use Plotly filter transforms (one transform per MultiIndex level) if you want the most scalable client-side filtering with simple updatemenus, or keep per-(date,category) traces and embed a small Plotly.js script that combines one dropdown per level into a boolean visibility mask and calls Plotly.restyle. Pick transforms when you can represent all points in one trace and you want maximum scale; pick the JS-visible-mask approach when per-trace styling/legend entries matter.

Authors
Verified by moderation
NeuroAnswers
Moderation
Filter Plotly scatter3d by pandas MultiIndex dropdowns