Plotly DashとPyScriptを連携する3つの方法

## 概要

Plotly Dashというダッシュボード(WebUI)ライブラリがあるのですが、これとブラウザで動作するPython/micropython環境であるPyScriptが連携できればお互いに活用の幅が広がるんじゃないかと色々模索した結果、とりあえず3つほど思いついて実現もできたので簡単なものから紹介していこうと思っています。


## その1　IFrame上のPyScriptページにURLクエリでDashのデータを渡す

最も簡単に連携する方法としてはURLクエリでDashのデータを渡す方法です。Dashの場合はIFrameのURLを更新するにはコールバック関数を書く必要があります。

```python
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`に入っているので、それをパースして値を取得することになります。

```python
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のロード時に自動で読み込まれて使えるようになります。

```pys_messenger.js
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での受信ができるようになります。

```python
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を設定します。

```python
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にコードを置いておきます。

https://github.com/uniras/pysmessanger

## その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ファイルを書き込むことで実現されているのを見てこれしかないなと思いました。

https://github.com/emilhe/dash-extensions

ということで仕組みはそんなに複雑じゃないんですがコードは複雑になってしまったのでこの記事にはコードは載せずにgithubのリンクを載せるにとどめておきます。

https://github.com/uniras/pyscallback