概要
Plotly Dashというダッシュボード(WebUI)ライブラリがあるのですが、これとブラウザで動作するPython/micropython環境であるPyScriptが連携できればお互いに活用の幅が広がるんじゃないかと色々模索した結果、とりあえず3つほど思いついて実現もできたので簡単なものから紹介していこうと思っています。
その1 IFrame上のPyScriptページにURLクエリでDashのデータを渡す
最も簡単に連携する方法としてはURLクエリでDashのデータを渡す方法です。Dashの場合はIFrameのURLを更新するにはコールバック関数を書く必要があります。
from dash import Dash, dcc, html, Input, Output, clientside_callback, ClientsideFunction
# アプリケーションの初期化
app = Dash()
app.layout = html.Div(
[
html.H1("スライダーで数値を変更"),
# Iframeにクエリーパラメータを使って値を渡すサンプル
# DashのSliderコンポーネントを使って値を変更する
dcc.Slider(
id="my-slider",
min=0,
max=100,
step=1,
value=50,
marks={i: str(i) for i in range(0, 101, 10)},
),
html.Iframe(
id="my-iframe",
src="/assets/test.html?data={}&t=0",
style={"width": "100%", "height": "300px"},
),
]
)
# クエリーパラメータでDashからIframeに値を渡すサンプル
# Outputには更新したいIFrameのID、InputにIFrameに渡したいデータを指定
@app.callback(
Output("my-iframe", "src"),
Input("my-slider", "value"),
)
def update_iframe_src(value):
# ここではデータをJSONにして送信する
json_str = json.dumps({"value": value})
# キャッシュを抑制するために現在時刻を浮動小数点形式で取得してクエリに追加
timeparam = time.time()
# クエリを追加した新しいURLを返すことでIFrameを更新
return f"/assets/test.html?data={urllib.parse.quote(json_str)}&t={timeparam}"
app.run(debug=True)
PyScript側では、クエリ文字列はjs.location.search
に入っているので、それをパースして値を取得することになります。
import js # type: ignore
import json
from pyscript import display # type: ignore
# ここでは値はJSON形式で設定されているのでURLでコードしたうえでjson文字列を辞書に変換
data = json.loads(js.decodeURIComponent(js.location.search).split("&")[0].split("=")[1])
display(f"Receive: {data["value"]}")
この方式は非常に手軽ではあるんですが、PyScriptからDash側に値を渡すことができないこと、Dash側で値が更新されるたびにPyScriptのIframeがリロードされてしまうという制約があります。
その2 PostMessage関数でデータをやり取りする
2つ目はJavaScriptのPostMessage関数を利用してDashページとIFrame上でデータをやり取りしようという方法です。しかしDashではHTMLのScript要素のようなもので自由にJavaScriptをページ内に差し込むようなことはできません。
ただ、Dashは裏ではJavaScriptフレームワークであるReactを使っている関係でJavaScriptコードとの橋渡しをするための機能がいくつか用意されているので、それらを活用して実現していくことになります。
- assetsディレクトリ(サブディレクトリ除く)内のJavaScriptファイルはすべてDashページ内で利用する外部JavaScriptであるとみなして自動的にロードする機能
- Dashのコールバックは基本的にバックエンドで動く仕組みだが、それでは不都合がある場合のためにフロントエンドで完結するコールバックをJavaScriptで書ける機能(
clientside_callback
,ClientsideFunction
) - JavaScript側からDashのコンポーネントにデータを渡して更新する機能(
set_props
)
PyScriptを使うとどうしてもグローバル汚染してしまうのですが(PyScript上で定義した関数をJavaScript側で使うにはglobalThis
に追加するしかない)、Dashの場合はClientsideFunctionで指定した関数を呼び出すためにglobalThis.dash_clientside
というグローバル変数を使う仕様になっているのでこれに便乗する形で汚染を最小限に抑えることにしました。
とりあえず、Dashのclient_callback
とset_props
の仕様にしたがってPostMessage
でやり取りできるJavaScriptコードを作りました。これであとはJavaScriptコードを書く必要は基本的にありません(オリジンの設定をしたい場合を除く)、これをそのままファイルに保存してassets
フォルダに入れるとDashのロード時に自動で読み込まれて使えるようになります。
globalThis.dash_clientside = globalThis.dash_clientside || {};
// Receiver from PyScript
globalThis.dash_clientside.pys_receiver = globalThis.dash_clientside.pys_receiver || {};
globalThis.dash_clientside.pys_receiver.allow_message_origin_default = [globalThis.location.origin];
globalThis.addEventListener("message", function (event) {
let valid_origin = false;
let allow_message_origin = globalThis.dash_clientside.pys_receiver.allow_message_origin || globalThis.dash_clientside.pys_receiver.allow_message_origin_default;
if (!Array.isArray(allow_message_origin)) allow_message_origin = [allow_message_origin];
for (let i = 0; i < allow_message_origin.length; i++) {
if (i == 0 && allow_message_origin[i] === "*") {
valid_origin = true;
break;
} else if (allow_message_origin[i] instanceof RegExp) {
if (allow_message_origin[i].test(event.origin)) {
valid_origin = true;
break;
}
} else if (typeof allow_message_origin[i] === "string") {
if (event.origin === allow_message_origin[i]) {
valid_origin = true;
break;
}
}
}
if (!valid_origin) {
console.warn("Received message from invalid origin:", event.origin);
return;
}
if (event.data) {
parsedata = JSON.parse(event.data);
if (typeof parsedata.type !== "string" || parsedata.type !== "pys_message") return;
if (!parsedata || !parsedata.target_id || !parsedata.data) {
console.error("Invalid data received:", parsedata);
return;
}
dash_clientside.set_props(parsedata.target_id, parsedata);
}
});
// Sender to PyScript
globalThis.dash_clientside.pys_sender = globalThis.dash_clientside.pys_sender || {};
globalThis.dash_clientside.pys_sender.send_origin_default = "*"
globalThis.dash_clientside.pys_sender.pys_send = function (target_frame_id, ...args) {
const target_frame = document.getElementById(target_frame_id);
if (!target_frame || !target_frame.contentWindow) {
console.error("Invalid target frame provided:", target_frame);
return;
}
const send_origin = globalThis.dash_clientside.pys_sender.send_origin || globalThis.dash_clientside.pys_sender.send_origin_default;
try {
const message = {
type: "pys_message",
target_id: target_frame_id,
data: args
}
const formattedMessage = JSON.stringify(message);
target_frame.contentWindow.postMessage(formattedMessage, send_origin);
} catch (error) {
console.error("Failed to send message:", error);
}
return "";
}
Dash側ではclientside_callback
の中にClientsideFunction
を入れてpys_sender
ネームスペースのpys_send
関数を呼び出すことでPostMessageでの送信ができます、メッセージを送信したいIFrameのidは最初のInput
で指定します。
受信はあらかじめPostMessage用に用意したStore
コンポーネントをInput
に設定しておくことでPostMessageでの受信ができるようになります。
from dash import Dash, dcc, html, Input, Output, clientside_callback, ClientsideFunction
import urllib
import time
import json
import urllib.parse
# アプリケーションの初期化
app = Dash()
app.layout = html.Div(
[
html.H1("スライダーで数値を変更"),
# pys_messengerを使ってPostMessageで値をやり取りするサンプル
dcc.Slider(
id="my-slider2",
min=0,
max=100,
step=1,
value=50,
marks={i: str(i) for i in range(0, 101, 10)},
),
html.Iframe(
id="my-iframe2",
src="/assets/test2.html",
style={"width": "100%", "height": "300px"},
),
# Iframeから受け取った値を保持するためのStoreコンポーネント
dcc.Store(id="iframe_data", data=None),
]
)
# pys_messengerを使って値をIframeに送信する
clientside_callback(
ClientsideFunction(
namespace="pys_sender",
function_name="pys_send",
),
Input("my-iframe2", "id"), # 最初のInputで送信したいIFrameのidを指定
Input("my-slider2", "value"),
)
# pys_messengerでIframeからデータを受け取りStoreに保存された値をDashコンポーネントに反映する
@app.callback(
Output("my-slider2", "value"),
Input("iframe_data", "data"),
)
def update_slider_value(data):
if data is None:
return 50
return data
app.run(debug=True)
pys_messengerはPostMessageでdata
キーにデータ本体をJSON形式で格納しているほかに、type
キーにpys_message
という文字列、target_id
キーに送信時に指定したIframeのidが入っているのでこれらを使ってpys_messangerからの送信であることをチェックしたりメッセージ対象のIframeが何かをチェックできたりします。
PyScript側からpys_messangerを使ってDash側にメッセージを送る場合は同様の形式で送り返す必要があります。target_id
にはあらかじめDash側で追加したデータを格納するためのStore
のidを設定します。
import js # type: ignore
import json
from pyscript import display # type: ignore
data = 50
# 許可するPostMessageオリジンのリスト
allow_origin = [js.location.origin]
# すべてのオリジンを許可したい場合(非推奨)
# allow_origin = ["*"]
# 正規表現を使って特定のオリジンを許可する場合
# import re
# allow_origin = [js.location.origin, re.compile(r"^https?://127\.0\.0\.1(:\d+)?")]
# PostMessageのオリジンが許可されているかどうかを確認する関数
def is_valid_origin(origin, origin_patterns):
if not isinstance(origin_patterns, list):
origin_patterns = [origin_patterns]
for i in range(len(origin_patterns)):
origin_pattern = origin_patterns[i]
if isinstance(origin_pattern, str):
if i == 0 and origin_pattern == "*":
return True
if origin == origin_pattern:
return True
elif hasattr(origin_pattern, "match"):
if origin_pattern.match(origin):
return True
return False
# Dashから送られたPostMessageを受信して処理するサンプル
def receive_data(event):
global data
# オリジンのチェック
if not is_valid_origin(event.origin, allow_origin):
js.console.warn("Origin mismatch:", event.origin)
return
# 受信したデータをJSONとしてパース
receive_data = json.loads(event.data)
# pys_messengerでは常にtypeに"pys_message"という文字列が設定されているはずなので確認する
if "type" not in receive_data or receive_data["type"] != "pys_message":
type_str = receive_data["type"] if "type" in receive_data else "unknown"
js.console.warn("Invalid message type:", type_str)
return
# 受信したデータを保存し、コンソールとページ画面に表示
data = receive_data["data"][0]
js.console.log("Data received:", event)
display(f"Receive: {data}")
# PostMessageの受信イベントを登録
js.addEventListener("message", receive_data)
# PostMessageでDashにデータを送信するサンプル
def send_data(event):
global data
js.console.log("Data sent:", data + 5)
# 受信したデータに5を加算してDashに送信
data += 5
send_data = {
"type": "pys_message", # pys_messengerでは常にこのtypeを設定する必要がある
"target_id": "iframe_data", # 送信した値を格納するDash側StoreコンポーネントのIDを設定
"data": data, # 送信するデータを設定
}
# PostMessageでDashにデータを送信
js.parent.postMessage(js.JSON.stringify(send_data), "*")
# クリックで更新した値をDashに送信するためのボタンを作成
button = js.document.createElement("button", id="testButton")
button.textContent = "add5"
js.document.body.appendChild(button)
button.addEventListener("click", send_data)
一応、githubにコードを置いておきます。
その3 clientside_callbackの内容をPyScript関数を呼び出す関数にする
clientside_callback
を使ってJavaScriptコードでコールバックを設定できるのなら、そのJavaScriptコードをPyScript側で書いたPython関数を呼び出すコードに設定すれば、事実上PyScriptでコールバック書けるんじゃないかということですね。
実現自体はそんなに難しくないのですが、2025年5月現在のバージョン(3.0.4)ではclientside_callback
でJavaScriptコードを直接記述してコールバックを設定すると、デバッグモードでホットリロードがかかった時にエラーが発生してしまうようです。これではさすがに使い物にならないのでどうにかできないか色々試行錯誤した結果、最終的にはDashがレイアウトを設定するタイミングでJavaScriptファイルをassets
に生成してロード時に自動的に読み込んでもらうというトリッキーな方法しか見つかりませんでした。
ちなみにdash-extensions
というDash公式にはない機能をいろいろ追加するプロジェクトにおいてJavaScriptを容易に扱うためのブリッジがあるのですが、assets
にJavaScriptファイルを書き込むことで実現されているのを見てこれしかないなと思いました。
ということで仕組みはそんなに複雑じゃないんですがコードは複雑になってしまったのでこの記事にはコードは載せずにgithubのリンクを載せるにとどめておきます。