出力されるHTML
plotlyでは凡例クリック時に非表示化する機能があります。
このスクリプトでは、完全に非表示するのではなく、以下のように透明度を操作します。

前提
facet構成やサブプロット構成のプロットではうまく動作しないと思います。
コード
import plotly.express as px
# サンプルデータでPlotly Express図を作成
df = px.data.iris()
fig = px.scatter(
df,
x="sepal_width",
y="sepal_length",
color="species",
title="Iris Plot"
)
# HTMLからdivのみ抽出
html_div = fig.to_html(
full_html=False,
include_plotlyjs=False
)
# plotlyjs取得。versionは適宜変更してください。
plotlyjs = "https://cdn.plot.ly/plotly-2.32.0.min.js"
# 非アクティブ時の透明度
opacity = 0.3
# クリックとダブルクリックの判定時間(ms)
delay = 250
# カスタムHTML
html = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Plotly Express Legend Custom Clicks</title>
<script src="{plotlyjs}"></script>
</head>
<body>
{html_div}
<script>
var gd = document.querySelectorAll(".js-plotly-plot")[0];
var traceNames = gd.data.map(d => d.name);
var activeStates = {{}};
traceNames.forEach(name => activeStates[name] = true);
// click/doubleclick判定用変数
var clickTimer = null;
var delay = {delay}; // クリックとダブルクリックの判定時間(ms)
gd.on('plotly_legendclick', function(eventData) {{
if (clickTimer) {{
// ダブルクリックと判定
clearTimeout(clickTimer);
clickTimer = null;
const clickedName = gd.data[eventData.curveNumber].name;
const newOpacity = gd.data.map(trace =>
trace.name === clickedName ? 1.0 : {opacity}
);
gd.data.forEach(trace => {{
activeStates[trace.name] = (trace.name === clickedName);
}});
Plotly.restyle(gd, {{opacity: newOpacity}});
}} else {{
// シングルクリックかもしれない → 遅延実行
clickTimer = setTimeout(function() {{
const traceIndex = eventData.curveNumber;
const clickedName = gd.data[traceIndex].name;
activeStates[clickedName] = !activeStates[clickedName];
const newOpacity = gd.data.map(trace =>
activeStates[trace.name] ? 1.0 : {opacity}
);
Plotly.restyle(gd, {{opacity: newOpacity}});
clickTimer = null;
}}, delay);
}}
return false; // 標準動作キャンセル
}});
</script>
</body>
</html>
"""
# ファイルに保存
output_path = "plotly_legend_grayout.html"
with open(output_path, "w", encoding="utf-8") as f:
f.write(html)
以下はまだ動作未確認
右クリックでtoggleする版
// Optimized right-click JS
const plot = document.querySelector('div.js-plotly-plot');
let lastIndex = null;
let isIsolated = false;
plot.addEventListener('contextmenu', function(event) {
event.preventDefault();
});
plot.addEventListener('pointerdown', function(event) {
if (event.button !== 2) return;
const legendItems = plot.getElementsByClassName('legendtoggle');
Array.from(legendItems).forEach((legendItem, index) => {
if (legendItem.contains(event.target)) {
event.preventDefault();
event.stopPropagation();
const nTraces = plot.data.length;
if (isIsolated && lastIndex === index) {
const opacities = Array(nTraces).fill(1);
const zindexes = Array(nTraces).fill(1);
Plotly.restyle(plot, {opacity: opacities, zindex: zindexes});
isIsolated = false;
lastIndex = null;
} else {
const opacities = Array(nTraces).fill(0.2);
const zindexes = Array(nTraces).fill(0);
opacities[index] = 1;
zindexes[index] = 1;
Plotly.restyle(plot, {opacity: opacities, zindex: zindexes});
isIsolated = true;
lastIndex = index;
}
}
});
});
// Optimized left-click JS
plot.on('plotly_legendclick', function(eventData) {
const index = eventData.curveNumber;
const currentOpacity = plot.data[index].opacity ?? 1;
const newOpacity = currentOpacity === 1 ? 0.2 : 1;
const newZ = currentOpacity === 1 ? 0 : 1;
Plotly.restyle(plot, {
opacity: [newOpacity],
zindex: [newZ]
}, [index]);
return false;
});
facet対応+右クリックtoggle
const plot = document.querySelector("div.js-plotly-plot");
const DIM_OPACITY = 0.2;
const ON_OPACITY = 1.0;
let lastKey = null;
let isIsolated = false;
// Critical: suppress Plotly legendclick that fires after a right-click sequence
let suppressNextLegendClick = false;
plot.addEventListener("contextmenu", (event) => {
event.preventDefault();
}, true);
function getKey(trace) {
if (trace.legendgroup && String(trace.legendgroup).length > 0) return String(trace.legendgroup);
return String(trace.name ?? "");
}
function buildKeyToIndices(gd) {
const m = new Map();
for (let i = 0; i < gd.data.length; i++) {
const k = getKey(gd.data[i]);
if (!m.has(k)) m.set(k, []);
m.get(k).push(i);
}
return m;
}
function setAll(gd, opacityValue) {
Plotly.restyle(gd, { opacity: Array(gd.data.length).fill(opacityValue) });
}
function isolateKey(gd, key) {
const keyToIndices = buildKeyToIndices(gd);
const opacities = Array(gd.data.length).fill(DIM_OPACITY);
const idxs = keyToIndices.get(key) ?? [];
for (const i of idxs) opacities[i] = ON_OPACITY;
Plotly.restyle(gd, { opacity: opacities });
}
function resolveLegendLabelFromPointerEvent(gd, ev) {
const legendItems = gd.getElementsByClassName("legendtoggle");
for (const legendItem of Array.from(legendItems)) {
if (!legendItem.contains(ev.target)) continue;
const textNode = legendItem.querySelector("text");
const label = (textNode && textNode.textContent) ? textNode.textContent.trim() : "";
return label.length > 0 ? label : null;
}
return null;
}
function resolveKeyFromLegendLabel(gd, label) {
const keyToIndices = buildKeyToIndices(gd);
if (keyToIndices.has(label)) return label;
for (let i = 0; i < gd.data.length; i++) {
const tr = gd.data[i];
if (String(tr.name ?? "") === label) return getKey(tr);
}
return null;
}
// Right-click: isolate / reset
plot.addEventListener("pointerdown", (event) => {
if (event.button !== 2) return;
const label = resolveLegendLabelFromPointerEvent(plot, event);
if (!label) return;
const key = resolveKeyFromLegendLabel(plot, label);
if (!key) return;
// This is the key line
suppressNextLegendClick = true;
event.preventDefault();
event.stopPropagation();
if (isIsolated && lastKey === key) {
setAll(plot, ON_OPACITY);
isIsolated = false;
lastKey = null;
return;
}
isolateKey(plot, key);
isIsolated = true;
lastKey = key;
}, true);
// Left-click: toggle (your existing toggle logic), but ignore if it was caused by right-click
plot.on("plotly_legendclick", (eventData) => {
if (suppressNextLegendClick) {
suppressNextLegendClick = false;
return false;
}
const idx = eventData.curveNumber;
const key = getKey(plot.data[idx]);
const keyToIndices = buildKeyToIndices(plot);
const idxs = keyToIndices.get(key) ?? [];
if (idxs.length === 0) return false;
const currentOpacity = plot.data[idxs[0]].opacity ?? ON_OPACITY;
const nextOpacity = (currentOpacity === ON_OPACITY) ? DIM_OPACITY : ON_OPACITY;
Plotly.restyle(plot, { opacity: nextOpacity }, idxs);
isIsolated = false;
lastKey = null;
return false;
});