From 795d2f895e306246703e5efa7fd8ad04aba8cb92 Mon Sep 17 00:00:00 2001 From: mioupa Date: Wed, 7 Oct 2026 17:22:49 +0900 Subject: [PATCH 01/37] Add requirements document and Pages config docs/REQUIREMENTS.md: requirements for the refactor and feature work. _config.yml: exclude development docs from GitHub Pages. Co-Authored-By: Claude Opus 5.5 --- _config.yml | 5 + docs/REQUIREMENTS.md | 244 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 249 insertions(+) create mode 100644 _config.yml create mode 100644 docs/REQUIREMENTS.md diff --git a/_config.yml b/_config.yml new file mode 100644 index 0000000..30386ad --- /dev/null +++ b/_config.yml @@ -0,0 +1,5 @@ +# GitHub Pages (Jekyll) の配信設定 +# 開発用ドキュメントはリポジトリには置くが、Pages では配信しない +exclude: + - docs/ + - AGENTS.md diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md new file mode 100644 index 0000000..7dc8548 --- /dev/null +++ b/docs/REQUIREMENTS.md @@ -0,0 +1,244 @@ +# matplotlib GUI 改善・機能拡充 要件定義書 + +- 作成日: 2026-10-07 +- 対象リポジトリ: `mioupa/matplotlib_gui`(GitHub Pages で公開) +- 位置づけ: 既存実装(`index.html` 単体、PyScript 2024.11.1)を改修するための要件。実装が進んだら AGENTS.md / README.md をこの内容に合わせて更新する。 + +--- + +## 1. 目的 + +研究データ(CSV / TXT / XLSX)から、論文・学会発表にそのまま使える matplotlib の図を、ブラウザ上の GUI だけで作れるようにする。今回の改修では次の3点を狙う。 + +1. 現行の不具合と仕様とのズレを解消する。 +2. 保守しやすい構成(ファイル分割、自動テスト)に作り替える。 +3. 「出力品質」「データ取り込み」「グラフ表現・解析」の3分野を拡充する。 + +## 2. 決定事項(2026-10-07 にユーザーが決定) + +| 項目 | 決定 | +|---|---| +| 利用形態 | **GitHub Pages の URL から使う**。`file://` で直接開く使い方はサポート対象外とする | +| ファイル構成 | **ファイルを分割**する(HTML / CSS / JS / Python パッケージ)。ビルド工程は設けない | +| テスト | **自動テストを導入**する(pytest による単体テストと、Playwright による E2E テスト) | +| Pythonコード(beta)タブ | **GUI 設定から matplotlib コードを生成して表示し、コピー・編集・実行できる機能に作り替える** | +| 優先する分野 | 出力品質(論文・発表)、データ取り込み、グラフ表現・解析 | +| 優先しない分野 | 操作性・再現性(設定保存、Undo、ズーム、オフライン化)。今回は後回しにする | + +### 2.1 引き続き守る前提(AGENTS.md より) + +- サーバー処理を持たない。計算はすべてブラウザ内の Python(PyScript / Pyodide)で行う。GitHub Pages は静的配信のみに使う。 +- 読み込んだデータを外部に送信しない(研究データを扱うため)。 +- 列の指定は列番号で行う(同じ名前の列があっても区別できるようにするため)。 +- 自動読込・自動描画の操作感を保つ(「読み込む」「描画する」ボタンは置かない)。 +- データ確認タブで、描画から除外される先頭行をグレーで表示する。 +- `parentNode` 系エラーの抑制・回復処理は削除しない(必要な場合は範囲を絞る。A9 を参照)。 + +### 2.2 AGENTS.md から変える点 + +- 「実装ファイルは `index.html` 単体」「`index.html` を開くだけで動く」は廃止し、GitHub Pages 配信を前提にする。 +- §12 の作業対象は絶対パスで書かれており、しかも実際の場所と合っていない。絶対パスはやめて「このリポジトリ配下のみ」と書く(ローカル環境のパスを公開リポジトリに載せないため)。 + +--- + +## 3. 現状の構成(2026-10-07 時点) + +- `index.html` 1ファイル(2,937行)。内訳は CSS 約600行、HTML 約270行、JS 約620行、Python 約1,440行。 +- PyScript 2024.11.1 を使い、パッケージは pandas、matplotlib、openpyxl を読み込む。 +- 処理の流れ: + 1. ファイルを選ぶと、JS が `requestLoadColumns` を呼ぶ。 + 2. Python の `_load_dataframe` が DataFrame を作り、グローバル変数 `CURRENT_DF` に保持する。 + 3. 設定を変えると、JS が 250ms のデバウンス後に `requestRender` を呼ぶ。 + 4. Python の `_create_plot_figure` が DOM から全設定を読み直して図を作り、dpi 100 の PNG にして `` で表示する。 + 5. 保存時は図を作り直し、dpi 120 で書き出す。 +- AGENTS.md に書かれていない実装済み機能: 軸の対数表示と範囲指定(第2軸を含む)、余白指定、背景透過での保存、Pythonコード(beta)タブ、ダークモード(OS 設定に追従)。 + +--- + +## 4. 目標アーキテクチャ + +### 4.1 ファイル構成(案) + +``` +index.html 画面のマークアップのみ +pyscript.toml PyScript の設定(パッケージ、Python ファイルの配置) +icon.svg +css/style.css +js/main.js エントリ(ES Modules) +js/state.js 設定オブジェクト(唯一の正)とその更新・購読 +js/bridge.js Python の呼び出し、描画要求の世代管理 +js/ui/ 系列カード、色選択、タブ、データ確認表、ステータス表示 +py/main.py PyScript のエントリ。JS と Python の橋渡しだけを書く +py/mplgui/loader.py バイト列から DataFrame を作る(文字コード、区切り、シート、前置き行) +py/mplgui/settings.py 設定スキーマ、既定値、検証、日本語エラーメッセージ +py/mplgui/codegen.py 設定から matplotlib コードを生成する +py/mplgui/runner.py コードを実行し、Figure を画像(PNG/JPG/SVG/PDF)にする +py/mplgui/fonts.py 日本語フォントなどの取得と登録 +tests/unit/ pytest(CPython と Agg バックエンドで実行) +tests/e2e/ pytest-playwright(ブラウザで実行) +tests/fixtures/ テスト用データ(UTF-8、BOM 付き、cp932、複数シートの xlsx、前置き行付きなど) +pyproject.toml 開発用の依存のみ(実行時には使わない) +docs/REQUIREMENTS.md 本書 +``` + +### 4.2 設計方針 + +- **設定オブジェクトを唯一の正とする**: 画面の状態はすべて JS 側の設定オブジェクト(JSON。`version` を持つ)に集める。Python は DOM を読まず、受け取った設定 JSON だけで処理する。 +- **描画経路をコード生成に一本化する**: + - 描画は「設定 → codegen が matplotlib コードを生成 → 実行 → Figure」の1経路だけにする。 + - こうすると、画面に表示される図と書き出したコードの結果が必ず一致する。 + - 現在の GUI 経路とカスタムコード経路の重複(凡例、目盛、グリッド、軸範囲の処理)もなくなる。 + - 生成するコードは pandas、matplotlib、numpy だけを使う。独自ライブラリに依存させず、ローカルの Python でもそのまま動くようにする。 +- **Python のロジックを純粋関数にする**: `loader`、`settings`、`codegen`、`runner` は `js` モジュールに依存させない。これによって CPython 上で pytest を実行できる。 +- **バージョンを固定する**: PyScript、Pyodide、フォントの取得元は、すべてバージョンを固定した URL にする。 +- **開発環境を Pyodide に揃える**: 開発用の pandas と matplotlib のバージョンは、Pyodide に同梱されているものに合わせる。 + +--- + +## 5. 機能要件 + +優先度は Must(必須)、Should(できれば今回)、Could(余裕があれば)の3段階。 + +### 5.1 不具合・仕様とのズレの修正(Phase 1) + +| ID | 優先度 | 現状 | 要件・受け入れ条件 | +|---|---|---|---| +| A1 | Must | データ確認タブの行番号列 `#` と skipRows のグレーアウトが未実装。CSS(`.skipped-row`)だけがあり、`_render_data_preview` は `skip_rows` を受け取るが使っていない。初回コミットからこの状態 | 先頭100行に行番号列 `#` を表示する。描画から除外される行には `.skipped-row` を付ける。除外行数を変えたら、ファイルを読み直さずにすぐ反映する | +| A2 | Must | 同じファイルを選び直しても `change` イベントが発火せず、再読込されない | 読込後に input の値をクリアし、選択中のファイル名は別の場所に表示する。同じファイルを選び直したら再読込される | +| A3 | Must | 連続操作で描画が重なり、古い描画が後から終わって新しい結果を上書きすることがある | 描画要求ごとに世代番号を付け、最新の要求の結果だけを画面に反映する。E2E テストで設定を連続変更しても、最後の設定の図が表示される | +| A4 | Must | 起動中・読込中の操作は、通知なしに捨てられる | 起動中はローディング状態を表示する。準備ができたら、その時点の最新設定で1回描画する | +| A5 | Must | 文字コードは utf-8、shift_jis の順に試すだけ。BOM 付き UTF-8 だと先頭の列名に不可視文字が残る。機種依存文字(①、㈱など)があると失敗する | utf-8-sig、cp932 の順に試す(必要なら euc-jp も)。判定した文字コードを画面に表示する。すべて失敗したら、日本語で原因と対処を示す | +| A6 | Must | 日本語フォント(約16MB)の取得に失敗すると、通知なしに文字化け(豆腐)になる。しかも描画のたびに取得をやり直す。取得元は `@main` でバージョンが固定されていない | 失敗は1回だけ通知し、そのセッションでは再試行しない。取得元のバージョンを固定する。ブラウザキャッシュ(Cache Storage など)で再訪時の取得を省く | +| A7 | Must | 「1,234」や単位付きの値が NaN になり、通知なしに除外される | 数値に変換できず除外した件数を、系列ごとに警告として表示する | +| A8 | Must | カスタムコード実行時、GUI のタイトル・グリッド・スケールなどがコード側の設定を強制的に上書きする。`fig.axes[1]` を第2軸とみなすため、subplots を使うと誤動作する | Phase 2 のタブ刷新で解消する。ユーザーが編集したコードには GUI の設定を上書き適用しない | +| A9 | Must | `parentNode` を含むエラーをすべて握りつぶし、前の画像が残っていると「描画に成功しました」と表示する | 抑制処理は残す。ただし成功の誤表示はやめる。PyScript の更新後にこのエラーが再現するか確かめ、再現しない場合は削除するかをユーザーに確認する | +| A10 | Must | DeprecationWarning を全体で無視している。`_spawn_task` は作成したタスクの参照を保持していない | 無視する警告を pyarrow 由来のものに限定する。実行中のタスクは参照を保持する | +| A11 | Must | README に廃止済みの「描画する」ボタンが残っている。AGENTS.md に実装済み機能が書かれておらず、作業パスも誤っている | 各 Phase の完了時に README.md と AGENTS.md を実装に合わせて更新する | +| B5 | Must | Pyodide の起動中(十数秒以上)と描画中に何も表示されない | 起動の進み具合(パッケージ読込、フォント取得)と描画中の状態を表示する | +| B6 | Should | 系列カードのラベルが入力欄に関連付けられていない。タブに role/aria、ステータスに aria-live がない | ラベルと入力欄を関連付ける。タブに `role="tab"` と `aria-selected` を付け、ステータスを `aria-live` にする | +| B7 | Must | 系列を削除すると既定色が重複する。scatter から line に戻すと、自分で入れた点サイズ24が0に戻る。不正な入力には `invalid literal for int()` などの英語の生メッセージが出る | 既定色は使われていない色から選ぶ。点サイズはユーザーが変更していない場合だけ自動で切り替える。入力エラーはすべて日本語で、どの項目が悪いかを示す | + +### 5.2 基盤の刷新(Phase 1) + +| ID | 優先度 | 要件・受け入れ条件 | +|---|---|---| +| F1 | Must | PyScript を最新の安定版に更新する(2026-10 時点の検索では 2026.7.3。実装時に改めて確認する)。記法は ` + + - - packages = ["pandas", "matplotlib", "openpyxl"] -
@@ -1498,7 +1495,7 @@

Matplotlib GUI

})(); - + diff --git a/pyscript.toml b/pyscript.toml new file mode 100644 index 0000000..3836d43 --- /dev/null +++ b/pyscript.toml @@ -0,0 +1,2 @@ +# PyScript 2026.7.3 (Pyodide 314.0.3 / Python 3.14)。openpyxl は Pyodide 同梱ではないため PyPI から micropip で導入する。 +packages = ["pandas", "matplotlib", "openpyxl==3.1.5", "et_xmlfile==2.0.0"] From 0236c0abe014ccc8be40dc88cc5f9de4cfb8542d Mon Sep 17 00:00:00 2001 From: mioupa Date: Wed, 7 Oct 2026 17:36:14 +0900 Subject: [PATCH 07/37] Pin dev dependencies to Pyodide 314 versions Python 3.14, pandas 3.0.2, matplotlib 3.10.8, numpy 2.4.3, openpyxl 3.1.5. Co-Authored-By: Claude Opus 5.5 --- .python-version | 1 + pyproject.toml | 8 +- uv.lock | 392 ++++++++++++++++++++++++++++++++++-------------- 3 files changed, 288 insertions(+), 113 deletions(-) create mode 100644 .python-version diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..6324d40 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/pyproject.toml b/pyproject.toml index 85f43dd..7db5512 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ name = "matplotlib-gui-dev" version = "0.0.0" description = "開発用の依存のみ(実行時には使わない)" -requires-python = ">=3.12" +requires-python = ">=3.14,<3.15" dependencies = [] [tool.uv] @@ -18,7 +18,11 @@ markers = [ [dependency-groups] dev = [ - "openpyxl>=3.1.5", + # ブラウザ(Pyodide 314.0.3)と同じバージョンに固定する + "pandas==3.0.2", + "matplotlib==3.10.8", + "numpy==2.4.3", + "openpyxl==3.1.5", "pytest>=9.1.1", "pytest-playwright>=0.9.0", ] diff --git a/uv.lock b/uv.lock index 085cf5a..c690797 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,11 @@ version = 1 revision = 5 -requires-python = ">=3.12" +requires-python = "==3.14.*" +resolution-markers = [ + "sys_platform == 'win32'", + "sys_platform == 'emscripten'", + "sys_platform != 'emscripten' and sys_platform != 'win32'", +] [[package]] name = "certifi" @@ -17,43 +22,6 @@ version = "3.5.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef", size = 172659, upload-time = "2026-09-30T04:39:23.398Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/c8/693809898870237d82785a03f3b2b58fe4c9f14669f84a7d4e623c92a59e/charset_normalizer-3.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed2a239c0ea213acc1908150a3037257083c7c083128f1a4cec2ec4b97dca491", size = 367780, upload-time = "2026-09-30T04:35:30.888Z" }, - { url = "https://files.pythonhosted.org/packages/c9/87/2fea8c13dc24b3ca9c6f803a5b2dfdeae73eb4f9e12c7885ed908ff0433c/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b91363207bd9dc966a691e959bb47f64b30f7ac4b072be9968b366982f7db77c", size = 246730, upload-time = "2026-09-30T04:35:32.286Z" }, - { url = "https://files.pythonhosted.org/packages/a8/9e/09efac30b937722f46d3110ba30b875b24b2e3a266ed746cc4e376a94d80/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:38a873987f3be698494da8b2e3085e29da02da7b633dce73e79c699a113d7bf0", size = 237707, upload-time = "2026-09-30T04:35:33.709Z" }, - { url = "https://files.pythonhosted.org/packages/9e/18/70d76670b13686237863a379928d60bd10e021f17d243ab3d7014c4a5f4e/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:355ad8011081dec5412240c087a9a0c9d4d5039f3ed11a3f13e18c2b29b56c51", size = 273050, upload-time = "2026-09-30T04:35:35.138Z" }, - { url = "https://files.pythonhosted.org/packages/54/e2/77a8b09d5adc013ed07b95b01b8b8fa5441c4e810e83ee7e4aae2fa4d91a/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee21e28f0430bd6dc9086c6e525d5e818a44a5ad19720c8a0ef766792f3eb5e5", size = 270345, upload-time = "2026-09-30T04:35:36.502Z" }, - { url = "https://files.pythonhosted.org/packages/7f/c5/38806a25ab5e65fc178f39affeda20858efafede2fce1ffc2556cfc9fe73/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d31298449090ab8d47b7b1b2a555ff73cac7ed438a08b7ac160980c7ebed649", size = 257601, upload-time = "2026-09-30T04:35:37.919Z" }, - { url = "https://files.pythonhosted.org/packages/ae/8d/213565184708fdb263ae55e2c04ee1ff748129dd65d48ed0e3502da9c85a/charset_normalizer-3.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5cde776b7cc66e4f6c99612cea4aa7269aa65863f7a15841b2c264f103822f4e", size = 252222, upload-time = "2026-09-30T04:35:39.544Z" }, - { url = "https://files.pythonhosted.org/packages/7e/24/76d2cefc25472531e4c5c7dfff68865eb1c39b78482f0fdc15b46f047830/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae4f5fea5b8b8ccff88238cc8569303e5ee95efae67fa62922a311397a71f346", size = 248482, upload-time = "2026-09-30T04:35:41.088Z" }, - { url = "https://files.pythonhosted.org/packages/7d/dc/65a801b66ab4c197e22c433ab25e7ac24324ac6f45a2269aca42cce309bf/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f7d486c83842422badd511868fd8a9a20e9407ace71564b6af47ce7e60a336c1", size = 241206, upload-time = "2026-09-30T04:35:42.59Z" }, - { url = "https://files.pythonhosted.org/packages/a7/95/ca9b5eabde673002c6f1e7ada1b223916fe18f6d661da7aabd4d643718f1/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:11a4d68a6ecda3292cb1e50239e111543ba5d709bb62a6b4ea1afcfa729d8875", size = 273190, upload-time = "2026-09-30T04:35:44.347Z" }, - { url = "https://files.pythonhosted.org/packages/2d/8b/803b4d2a3f6e1740f63f1e87b04d14b42f3d4fdfe6ed7d4db2d34102b14f/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d6734d2ef8a50fbf8445c139477da401f50d62a0606bf00e20ec6d87773fefb1", size = 253527, upload-time = "2026-09-30T04:35:45.915Z" }, - { url = "https://files.pythonhosted.org/packages/a9/55/93c0e5dbd085ae0471346026abbe7e0db9ea2d6fea74e51f0b5a46f233a7/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a815775b6c38d4e0ff7bcffbeba67feded90202bb6a226b8dd35f1c855217413", size = 271285, upload-time = "2026-09-30T04:35:47.49Z" }, - { url = "https://files.pythonhosted.org/packages/95/69/0dbd0e0b9b16cfa816cdfcb3e2e3854a1f680dc07fb1245ea125e7448060/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:23851fb4e1b85ed3f6c2a27b777cdfe2e19fb5b38429a8faf38c7542b7665869", size = 260010, upload-time = "2026-09-30T04:35:48.996Z" }, - { url = "https://files.pythonhosted.org/packages/58/9d/e7b88e7b1bf403590c3b573277b5e1e488c68c7a6fbacca310a2c324e90c/charset_normalizer-3.5.2-cp312-cp312-win32.whl", hash = "sha256:db19d07e2e0129e974a0e65d0064fc222a446cd5122c2fd4184d2af9fc734a9e", size = 184125, upload-time = "2026-09-30T04:35:50.777Z" }, - { url = "https://files.pythonhosted.org/packages/eb/e6/e6e083884cbcfd49c64865af05027fe7011be7b2d9179524f099a1b611f3/charset_normalizer-3.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:780fbe7cab297b81dad9fb8dc5eb003c0468ffb0d9e5f65068c53a34661a96bc", size = 207486, upload-time = "2026-09-30T04:35:52.194Z" }, - { url = "https://files.pythonhosted.org/packages/c4/e3/017aea0911ada7405a825c7d937eb3a13009664e2f5b38e8c4bbf2abf894/charset_normalizer-3.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:e2af3aad578aa6bd1384bcf4750fc285e5a9de53f40b7d41e5a0bf748edeb2b3", size = 196734, upload-time = "2026-09-30T04:35:53.636Z" }, - { url = "https://files.pythonhosted.org/packages/c5/34/68292d68512768591aaff07c59bb53ee31341c87759433a859c4641a50c2/charset_normalizer-3.5.2-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:ed905975ab14056a2e5eb1c376cb2e1ebc5396baf84163939c518556fccde9f5", size = 225946, upload-time = "2026-09-30T04:35:55.313Z" }, - { url = "https://files.pythonhosted.org/packages/e3/80/bee0b01b90ccd5322ae1d0abb33fab1bd95b7c2eadaf02aeccf22e04ee83/charset_normalizer-3.5.2-cp313-cp313-android_24_x86_64.whl", hash = "sha256:a66c3bc5ab1f0ff2164fc9965ddd611ff0802173f4b9d24554c563f6ab7e1d6e", size = 238619, upload-time = "2026-09-30T04:35:56.863Z" }, - { url = "https://files.pythonhosted.org/packages/78/6e/60ce52a85a7fd631ae8482ae6d74521014ca2f255892679484dc04d7ef56/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2374b62878abb00cd8309b32af6c0b715cd02dec0ca74ef12e5069bdc64144a", size = 206701, upload-time = "2026-09-30T04:35:58.639Z" }, - { url = "https://files.pythonhosted.org/packages/36/8c/71aafad23f971afc84c2b295bc0c560739ce1dac558aad9fec22e39f3639/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d376bbd28b3a8999db1a103b3b388aee6f1ddeb3e51bc2172993efdcd86e064d", size = 210506, upload-time = "2026-09-30T04:36:00.147Z" }, - { url = "https://files.pythonhosted.org/packages/91/da/3c5a7798c046df7d2d68ad653cf5b6c5a8bfee225055a843c6f2f42aac1a/charset_normalizer-3.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6045373d5a89a5ec71afde535db987ca28e76dfa276c2d4c818265b375d4b055", size = 366626, upload-time = "2026-09-30T04:36:01.77Z" }, - { url = "https://files.pythonhosted.org/packages/e1/16/710ac3de2ee354e2bd1a9c94efe45a2d27b5c6ad39b2d6a905be2c094b6c/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:849df64e889b2e17230d58410a03dba311a65b163508fd33679b2b737d4b7858", size = 244624, upload-time = "2026-09-30T04:36:03.389Z" }, - { url = "https://files.pythonhosted.org/packages/d6/39/45c7439f5b63d24f7d5b2a1d760f34af7628782d7144b4cc8ded45c2d4bc/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:15c44f7edfd477b06f517a5cc317fc1707edb9de2c865f43d4b6513907473234", size = 235731, upload-time = "2026-09-30T04:36:04.987Z" }, - { url = "https://files.pythonhosted.org/packages/4d/34/38f3154785ce92e9f56eb226f4d35bdfae6b008480dd055f58837a89c810/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a89012d6d5476ee112d20d998570ed58df2260a852afb1758809cd6900411d21", size = 270352, upload-time = "2026-09-30T04:36:06.412Z" }, - { url = "https://files.pythonhosted.org/packages/04/f3/859f74e7babc977705026b30593b3be04049632a522fb7000f83c033d747/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0c951d5e6dd9c2ff60609476752bee49da4206adde960ebc247766937f72e718", size = 267393, upload-time = "2026-09-30T04:36:07.865Z" }, - { url = "https://files.pythonhosted.org/packages/4b/85/41d27f234b82e47c167a5f6c0f62501dc0c640585ff4aba79e08a390336a/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7218e8f32b0956cfcd048fd42d9d5779809745ca1d86113ca56f66e7ae1549c4", size = 254660, upload-time = "2026-09-30T04:36:09.248Z" }, - { url = "https://files.pythonhosted.org/packages/58/ca/5d1a997587febe5b26d8daffe363b5c1a091cece19828eec6502fd09c5ef/charset_normalizer-3.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19a731138fc27d5682277d3b9df22855cea1239bce7fcec5f78f42ef2d1f3c3", size = 249996, upload-time = "2026-09-30T04:36:10.73Z" }, - { url = "https://files.pythonhosted.org/packages/b3/1f/d1e78246f7ed60c8c8d606b4ac27f66ce49cc3e95f24893ccbeba9f77302/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62603db9a7caa0802eaa28c1c46fecd7b3a263a774069c24c3c28c302448721c", size = 247028, upload-time = "2026-09-30T04:36:12.294Z" }, - { url = "https://files.pythonhosted.org/packages/8e/37/eba316edd4f0c4d3a5d945924c4eeeae59abac4056aa815d8a4268f863a2/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b6856554c4f44d79fc2307d5768854310a8f0096e501c75637542c82292b0429", size = 240562, upload-time = "2026-09-30T04:36:13.887Z" }, - { url = "https://files.pythonhosted.org/packages/c8/8e/aaa037d40ca9ef045977f1a661048b1aa33f223adfce3452fe9be9f79d14/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1bc0baf5ef96b6ede57d47f4b8fe4d9d84019c3bfcbeb20a41edc6a6ee341f1f", size = 271699, upload-time = "2026-09-30T04:36:15.41Z" }, - { url = "https://files.pythonhosted.org/packages/26/19/1c1c9f75974adf523b87f34b8a2adc5a435cd65916812bcbd0dfa45f9a29/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:56bc200a365efb37383b7852e4cc5898d3b2da5987289b543956cf8cad71018a", size = 252030, upload-time = "2026-09-30T04:36:16.839Z" }, - { url = "https://files.pythonhosted.org/packages/bc/90/0660ef18e18df0a4d2a1a0edff7dfbba42d4e50ef2425557a5bb7051f77b/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2c9ad19a6cfcd5ea5c0d41161d22f9df1dcc277e9bef2751391334546a314c00", size = 268565, upload-time = "2026-09-30T04:36:18.468Z" }, - { url = "https://files.pythonhosted.org/packages/79/ba/57adc269824e8658f1a0f97a9e514c247445a9632b3419b97e0ba37f16dc/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e243bd13217235fc7290c621941c3f5cc8b66e4872495be821d7436ba2fb838d", size = 257627, upload-time = "2026-09-30T04:36:19.938Z" }, - { url = "https://files.pythonhosted.org/packages/9a/85/33abd4315c052d3d4f54c92b1ee49bfbc0dc7115a981e462a793b6d2ab87/charset_normalizer-3.5.2-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:a090bb2c68df85450502e3e20d665e3a5af9c65a84d6508ed477badd49166fd3", size = 143407, upload-time = "2026-09-30T04:36:21.376Z" }, - { url = "https://files.pythonhosted.org/packages/4f/de/6435e18d1aaa5d910b896d551411c96af1f42a0c56c29afc2016c61ccc2e/charset_normalizer-3.5.2-cp313-cp313-win32.whl", hash = "sha256:2b7b3bbfb4fe8ef40600792d762fbaa9057559f9d3fad209525b7a22b99e91fd", size = 183207, upload-time = "2026-09-30T04:36:22.776Z" }, - { url = "https://files.pythonhosted.org/packages/9c/76/b8ec57f4e9ee3253541abf95e4a462c0175fe8032dcd070f1f2421240942/charset_normalizer-3.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:78456a747de8dc58360ffa581f30a002baf5aa28cb262536545e91f113ed7639", size = 206489, upload-time = "2026-09-30T04:36:24.306Z" }, - { url = "https://files.pythonhosted.org/packages/3e/60/c647c6ae47480221e875ea5d743ff94946f7416e3c69415ab772928e8d32/charset_normalizer-3.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:11912e4bb14baae7c5d8791aa55ba0a3a03ec6729073307b0f57270abaa713d3", size = 195501, upload-time = "2026-09-30T04:36:25.846Z" }, { url = "https://files.pythonhosted.org/packages/58/ca/7aa91362a2f77ac8e9e28a9b902a74f7d0e11a851ef0d27a74308da8cd90/charset_normalizer-3.5.2-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:1afb975bd5d68d5ce9f6b6d44fdf2f7e34b895a35e95708a7a91b20a3b51d187", size = 226010, upload-time = "2026-09-30T04:36:27.669Z" }, { url = "https://files.pythonhosted.org/packages/a8/cf/ac8878d0322cf88a1aad4c7b147db32ca0bd806eb0060957b2e31486dbe6/charset_normalizer-3.5.2-cp314-cp314-android_24_x86_64.whl", hash = "sha256:bbbfc8e28816f19d7c0f1816664980c0a9875d01b27cdf8eedddb639d9e108ad", size = 239266, upload-time = "2026-09-30T04:36:29.434Z" }, { url = "https://files.pythonhosted.org/packages/c9/6d/9a08d7e0b29b7208e2c6c01dc56c8e0520e7c7beadbbfb024b58fd69c8a5/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7967d08cf06dee78443b874f98c98036f624f3a4e73e11f9f64f5be4d25393cf", size = 206783, upload-time = "2026-09-30T04:36:30.872Z" }, @@ -91,38 +59,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/92/9f/181fd07e1bffea1d95cd80c84ac537354f50699c22cfc4d3c02b6fc16208/charset_normalizer-3.5.2-cp314-cp314t-win32.whl", hash = "sha256:598a11a2c7ebaa5334bf698bf29568c9c390abac6a154d8170fedecd1cea38c5", size = 197797, upload-time = "2026-09-30T04:37:23.235Z" }, { url = "https://files.pythonhosted.org/packages/49/1c/25d8415ec1c4f2f41f1680435e4c87cfb378ff2f677d950946f2a45d0632/charset_normalizer-3.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:7fdde2c9fd9e3eca40631e024664cf2584272cc8f96308cbe5fdfc930f51d8bc", size = 222765, upload-time = "2026-09-30T04:37:24.891Z" }, { url = "https://files.pythonhosted.org/packages/3e/b4/46b48f013dadfc0d0d33b375438e31bdf5a989dc68389c6bf627054d4df9/charset_normalizer-3.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d1befeed746d247c81127bb14de9dc3d30edb6e5976d34f83f86ed262b1d9105", size = 210985, upload-time = "2026-09-30T04:37:26.634Z" }, - { url = "https://files.pythonhosted.org/packages/ca/e9/34e597dee616d0b8ee4b34d29399e85c2204ade174157a48505d42baa4ff/charset_normalizer-3.5.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:87475fabc8d9996fd9c27debb395e642e8c838d78a00b6e932227a0e06b81e26", size = 367676, upload-time = "2026-09-30T04:37:28.329Z" }, - { url = "https://files.pythonhosted.org/packages/60/9f/a5d1c91c0263745e2cd344c5a4415d787c575501ab1d449f1148ac6b495d/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9409a8bf35cf78353942504b24a57de3d75b708997a1e4bd8db71ac8633ce364", size = 248414, upload-time = "2026-09-30T04:37:30.167Z" }, - { url = "https://files.pythonhosted.org/packages/26/79/e697f77464748a3ee3cf490c83d592459400d4898380d66c38366b03080c/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:498dc3188ca05a68231ac3fdbfc7f57eb67e1343c30e0fea17f8218c1599b253", size = 244775, upload-time = "2026-09-30T04:37:31.964Z" }, - { url = "https://files.pythonhosted.org/packages/ca/87/3d42a42e18ea066e2513936fd678a00696e77878b5ae04528976abdbcb83/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e242bb1c5e76e97dfa9e7f209a71e93a01d7f19ffdd5cfbb2e2d55b4f08f8ab0", size = 273877, upload-time = "2026-09-30T04:37:33.661Z" }, - { url = "https://files.pythonhosted.org/packages/c3/76/8a28136f3938ba9836f84280ce0c4d61ed1cf15a036b2034900c62634162/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def79fa35ef0cef8d2accec024f4fdc7ead3012ff02f5215c783f39f03ef8cfc", size = 269455, upload-time = "2026-09-30T04:37:35.573Z" }, - { url = "https://files.pythonhosted.org/packages/a0/a1/4fbf5d0f0f1b2a080474c1cf9a2f12c4c6531bb0e8ba591055e846d2b4e9/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229", size = 257336, upload-time = "2026-09-30T04:37:37.397Z" }, - { url = "https://files.pythonhosted.org/packages/ba/a2/8b50aa320adb880ad579518e6f718f24944804b42a88b83d267d5d444125/charset_normalizer-3.5.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04851f73ae72b8413dddadb16a49dfee95263553741fd42d546f7d66907e6be5", size = 257584, upload-time = "2026-09-30T04:37:39.522Z" }, - { url = "https://files.pythonhosted.org/packages/a5/57/50e3fed84e175f40349bd0da7a4fce94c87f0378f52d74f511d89e0bdc20/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:183b88127acdb4fabe59d951ab424faf1af7b63cdbb5f776186c1ea2ffcaed98", size = 251854, upload-time = "2026-09-30T04:37:41.23Z" }, - { url = "https://files.pythonhosted.org/packages/d6/54/f7fbb3493c9f49091213b9c2d6dd65800696f1ce1a3f196a4205f50417b1/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:16fa0eccf81304b79c5cd87f9271c3b85dd9dd99245e4422ae9c0dd45e0f99d3", size = 248767, upload-time = "2026-09-30T04:37:42.883Z" }, - { url = "https://files.pythonhosted.org/packages/d9/37/b3a6385acc5a1e45b39ae9c90bfb9cf838a09b9dd37ef2740ab4c6b4a2eb/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:7441d755b7ab94f8d4eb3e43ec05482d760842fd263d003a99102d742cd835e2", size = 274479, upload-time = "2026-09-30T04:37:44.658Z" }, - { url = "https://files.pythonhosted.org/packages/89/44/809913e2cfd279e635a9294fdbbfb1b1dc62a8189d473d561f649fce98d8/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:ca403d7e4798f525fdfc78e258820419cbbd0f0ecbab9de7840e3c017cf6b8cf", size = 258310, upload-time = "2026-09-30T04:37:46.529Z" }, - { url = "https://files.pythonhosted.org/packages/af/a2/f28400ab13359d91bd39179df8e149376b9bf36588e739a3a4f9de2b84b2/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:df29a0a7107f7011e77f4eebdddec4c7331e24d787a0b21a46d63bdf7445da95", size = 271100, upload-time = "2026-09-30T04:37:48.399Z" }, - { url = "https://files.pythonhosted.org/packages/e9/89/9bab37955edf0adb3b66f8a3a6617d9f2f487e0d56f295a6a286cb640aa6/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f3c96f633825733f735c5a9cf21d21a257d8e1edf0b1cee0a064b9c424ca0f7d", size = 259936, upload-time = "2026-09-30T04:37:50.023Z" }, - { url = "https://files.pythonhosted.org/packages/23/b5/4459e08d45a679f903d50fea08bc52cfa728cca4d7bd02c757b5e5abda2e/charset_normalizer-3.5.2-cp315-cp315-win32.whl", hash = "sha256:281cb91036248400f4cc957495cccd44c275c2e0c5854f7e45ac5cf7dc193847", size = 186301, upload-time = "2026-09-30T04:37:51.722Z" }, - { url = "https://files.pythonhosted.org/packages/98/e8/55d5fd3935b4bce6da4fe0df61898e8c82653e317e677bd58aceb9c60f13/charset_normalizer-3.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:89b53f3cda69831909888e0494f4fa0bcd3537e3e138dabeb620bd6ad946bae8", size = 210858, upload-time = "2026-09-30T04:37:53.427Z" }, - { url = "https://files.pythonhosted.org/packages/a9/5b/974423c2fd8e524c7a7f64318c1e02240ef954912fa2b4d70344107b9c68/charset_normalizer-3.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:6be488a102b8cf28d0391d8c4ba7748938ae28b78ad901f8585520fca33ead1a", size = 200309, upload-time = "2026-09-30T04:37:55.015Z" }, - { url = "https://files.pythonhosted.org/packages/ee/f9/00ee0195db1013d8f7c416fd770fbeb560bb46eb2e36b054d05cb56f6cfa/charset_normalizer-3.5.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:915563965d418f986e7e145accc592eae9e1a1be3566ff98a05d7a9ec42a76e1", size = 395343, upload-time = "2026-09-30T04:37:56.743Z" }, - { url = "https://files.pythonhosted.org/packages/04/3a/c00b50e94c964cf934c7899cd47c97952fc11dad71cc5884b3c61795b09b/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65cd72beeeca9d3aaea1201e5923859f308f952f9c71de93f06063c79f0f7a3b", size = 250114, upload-time = "2026-09-30T04:37:58.607Z" }, - { url = "https://files.pythonhosted.org/packages/50/27/d102dc880bbcffd0479ab64dfc1fb96777a854355a55e2bda72a71efadcb/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b7fd005a73d9e657273b7a10dc71a9e03c8fb9ee6999798d6918ce095b81ac7f", size = 239997, upload-time = "2026-09-30T04:38:00.511Z" }, - { url = "https://files.pythonhosted.org/packages/a5/4a/bf7ef45794dd293fab5f98a9309817977fbb845b9998f171b8cc5d8437a3/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e54da4baf05720032d527874d40b65fa4d7e5c6c6a43d0c3adbeffcaf275a2b3", size = 272313, upload-time = "2026-09-30T04:38:02.509Z" }, - { url = "https://files.pythonhosted.org/packages/e8/ee/008a2837737991474c5754bb3191010007663860979701990982a502cbaf/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:124fbf1a8ff966d87ae05bb8bd45a71f966055ed8bba320d0c7cf450bc5f4d0e", size = 269986, upload-time = "2026-09-30T04:38:04.435Z" }, - { url = "https://files.pythonhosted.org/packages/93/ad/bd74a283940dc910c5b14f8e4f80a248082bc9c0fcbe1f54530cb6d9cc5e/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28b4f0d66fb834ff90f28209ac7bce77868c45d8c93e26f906709d9b7c2e1af9", size = 260202, upload-time = "2026-09-30T04:38:06.549Z" }, - { url = "https://files.pythonhosted.org/packages/8a/7b/ed341c66f69f688723501fac752be3d63c7159ca0d0d4174fc611e5710bb/charset_normalizer-3.5.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58ca3755ee7ff7f59b57789ec9833c9de9ea275405cdd240eda1f193112e398a", size = 259083, upload-time = "2026-09-30T04:38:08.311Z" }, - { url = "https://files.pythonhosted.org/packages/cc/9d/e41588b777965e5031a43128a1e96173ebb35ac75fc53ec3b517e7c21cd4/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:443eae2bf318abeaf6f15d785138f71fd6de770e99a92158b8b814265e079115", size = 253081, upload-time = "2026-09-30T04:38:10.402Z" }, - { url = "https://files.pythonhosted.org/packages/81/35/b761eb6d8c1eb218b9b42b9b4d5ac902afdc399fb6dac6f9a9aac7bda589/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:58f361dcbab699cf8f42db3f47c8e7fd1036f138c23a5d08de9fde5f425a730c", size = 248712, upload-time = "2026-09-30T04:38:12.317Z" }, - { url = "https://files.pythonhosted.org/packages/4d/2c/147169a041b747759f37405c0a97157e8e92de967968373101ff14915cba/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1b4cbc7c3491ccb4aa17fcd8165649d01cf39f76de1696da8631b5f71b85401d", size = 274176, upload-time = "2026-09-30T04:38:14.138Z" }, - { url = "https://files.pythonhosted.org/packages/f0/2d/0ff8db0d373ba8538db686db11cd7e8912031490b9e4f383b41912e8d594/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:ba0b1d2620edf869789c3879223f52bf2afc5d31b3cb47cc57b3a12c05e2aa9d", size = 259413, upload-time = "2026-09-30T04:38:15.841Z" }, - { url = "https://files.pythonhosted.org/packages/8a/8e/b4a085fb47c9d3a7e43576a4784fdd8fe23f907514a972de8086edaf7a48/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:5e2b6b57e9733d39f0c9fd3185efa6b8e29652c4cd8fe94180272cf6ed9a78c4", size = 269434, upload-time = "2026-09-30T04:38:17.626Z" }, - { url = "https://files.pythonhosted.org/packages/83/1c/d8d8d7322a7c3eecdf3237a4a419cf41d2eaad8e006ce7dfdd9d4c8fa2eb/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:51cf45226a9b588d0d2b4880c62d686934b63ab0bd79ca23ab0e9762eb27441b", size = 262716, upload-time = "2026-09-30T04:38:19.214Z" }, - { url = "https://files.pythonhosted.org/packages/a0/16/0e4c6ba9b44e97a2da150e52d331e8f9c968b21b358fbffa6c856cebcd89/charset_normalizer-3.5.2-cp315-cp315t-win32.whl", hash = "sha256:5fb29fb8cd1a46c27a1bf9613ad5ec2599310d46b4025d9556404a6b6a292800", size = 197159, upload-time = "2026-09-30T04:38:21.037Z" }, - { url = "https://files.pythonhosted.org/packages/be/33/e90bc2b1374f7f36ef106f56620de5a783907e19ca857efe2277e31cac3e/charset_normalizer-3.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:a192e2c40070d92c3ccf777e3a5c4ff515573cd2bb7ed0c537fdadbbec5bbf21", size = 221322, upload-time = "2026-09-30T04:38:22.886Z" }, - { url = "https://files.pythonhosted.org/packages/66/89/dfa6dcb08c200b7830ab56439e8c1890f2971d51aafbb3937894a2e7fcfc/charset_normalizer-3.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:749e97e1b32313717a565abbe321bc2190bc8b35f1a67e4cdbc7c56c8d8ffe58", size = 209192, upload-time = "2026-09-30T04:38:24.648Z" }, { url = "https://files.pythonhosted.org/packages/8c/ab/176fbfd5b64939c55d652366aa5b9ef1d767af207a3aa6ebeb0d226c484d/charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd", size = 331815, upload-time = "2026-09-30T04:38:26.216Z" }, { url = "https://files.pythonhosted.org/packages/7e/84/371eac6b30bdbcbf2d632a1a01809103459216fcaae61b8b8d922c1bfb8a/charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7", size = 253276, upload-time = "2026-09-30T04:38:28.032Z" }, { url = "https://files.pythonhosted.org/packages/43/6f/c4fbae58febff71709c51bc7e18fdfa55341dc382704740f9f0cbf03817b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f", size = 241239, upload-time = "2026-09-30T04:38:29.732Z" }, @@ -151,6 +87,49 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "contourpy" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/5a/a55177dd22553a277388e8a1b3220e92de91bacb28356cdc73caa240121d/contourpy-1.4.0.tar.gz", hash = "sha256:20156f5a1ac4f8ce02656e39a61e82164a3d359796dc8026f75b062783d500e1", size = 13323726, upload-time = "2026-09-11T19:05:05.808Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/b0/b548628c8dddf6e5e0a981b7fa8e4c008024df09c7d17c6a9e271edfa0b3/contourpy-1.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a3e67bc1a6a4618a1dac7c3053a9ffece5ddfa2046b2670b342cf430bb0b87d1", size = 297354, upload-time = "2026-09-11T19:03:35.017Z" }, + { url = "https://files.pythonhosted.org/packages/04/5a/513484208742f65af2648c519f0b7ab034616e183c4403538024c136a1ab/contourpy-1.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:072a702e2e178f4fcf96f04775d0c059e4c917925abf0d3208ebb6865df3c23d", size = 283729, upload-time = "2026-09-11T19:03:36.818Z" }, + { url = "https://files.pythonhosted.org/packages/35/da/5a6562febc994b2c4bf9d01e57a50458ef8a2051bb3e7b35a00333e0fc26/contourpy-1.4.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:404dbcd9233513dfd1323b66ea593fef90e899f658f6b8a9ed8e93bd0ca669db", size = 357819, upload-time = "2026-09-11T19:03:39.058Z" }, + { url = "https://files.pythonhosted.org/packages/6d/99/7b358ce888aaa426f755daa096c99c59d17c352037344e836f20e5d3b957/contourpy-1.4.0-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:61624f6722480aa7e168fd746164e7bfdf48f8ccb6542f1613741200dc37e2b1", size = 407795, upload-time = "2026-09-11T19:03:41.293Z" }, + { url = "https://files.pythonhosted.org/packages/be/02/34d7548adf08c60967435d79d7a90882eacc84f5093435287974c7e99943/contourpy-1.4.0-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2e741a39dfc96babe1722561e81351aeec104526c57dbe91c167c1db606c2d55", size = 408552, upload-time = "2026-09-11T19:03:42.728Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8c/9677dad226e5aaac5b823d5b09d881ebc32ff1648af417597649841e516c/contourpy-1.4.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e439ab450c93455feb0218532ded3e946ec7a9b5f459069f122cfa44b89229f6", size = 384850, upload-time = "2026-09-11T19:03:44.402Z" }, + { url = "https://files.pythonhosted.org/packages/08/f5/e3fbeaa489c223629fbb434737cb3de4ecaca9cae4825e77b92490f640f8/contourpy-1.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:417be048f7e122cefbe2a34c51a1f2b0410f8eb35796f794d45475ddb7872d9d", size = 1357601, upload-time = "2026-09-11T19:03:46.746Z" }, + { url = "https://files.pythonhosted.org/packages/88/be/55fec293d342c222ed58a5d4056df4362e192af2e59527a3d5d822de8828/contourpy-1.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:43d80072a32299bf945de0a6dd4e034ea0162e832261de2e07a274a5adbba9c9", size = 1427340, upload-time = "2026-09-11T19:03:48.745Z" }, + { url = "https://files.pythonhosted.org/packages/86/40/0faabf453edf59b0e3633381d7ca72c3414fb9b72b5cbb08b06ed792d3e5/contourpy-1.4.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:fa1b787362a3856e63dd2b89449f6c384d55ee1beb8f94f4bcc871b40f02462c", size = 118355, upload-time = "2026-09-11T19:03:50.047Z" }, + { url = "https://files.pythonhosted.org/packages/38/0c/7ea75aa3559ae8a6805015e15a19c92b6480752ecc92526361e5776e88c5/contourpy-1.4.0-cp314-cp314-win32.whl", hash = "sha256:738c44fa71735a617f36da58e32810512407d283d5d1bb5b1cecb00d7eeba7cf", size = 356977, upload-time = "2026-09-11T19:04:11.401Z" }, + { url = "https://files.pythonhosted.org/packages/15/41/3df8cc14bb572c8b447f9dfbc876703f0f2f2896058285b5c87a1d4a638d/contourpy-1.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:181bea01bc742734ae672fa00c717d855dd35e1f029536406538c52e7b0cd73d", size = 240101, upload-time = "2026-09-11T19:04:12.772Z" }, + { url = "https://files.pythonhosted.org/packages/66/01/ee6830a5aa4565662a345172f6f35eeeed625d20f1c9d4ecd35318fe4ade/contourpy-1.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:64039341e2d8804f1a13bda8c69083eab935167bbd7e856cf096241f17d5bd45", size = 576359, upload-time = "2026-09-11T19:04:14.983Z" }, + { url = "https://files.pythonhosted.org/packages/51/af/49ee9c9cf012699e505c1b63c531cc99f19df50f205173092c4d541e3ec7/contourpy-1.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ef47fef9a912c77e3d84b014f701e5a9340f25703e9ed3bcebe37f91fb69dc49", size = 313601, upload-time = "2026-09-11T19:03:51.452Z" }, + { url = "https://files.pythonhosted.org/packages/ad/59/ce411ab2be0f805038626d8f4ca9ce563487ff51ff6c363e7512271564ed/contourpy-1.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:96019f059bcb3774acc0104be3360a57b36d33eaaa2e1d7f77c800ca8e07618b", size = 302812, upload-time = "2026-09-11T19:03:53.022Z" }, + { url = "https://files.pythonhosted.org/packages/95/b0/390915f9af14c1e2d3b2a9ad99d85b98df4352346a51e368cbb825f813c3/contourpy-1.4.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ddf5a2596d716fd3793434844caf31abc7a40b1e8718431420c89858268fb909", size = 348996, upload-time = "2026-09-11T19:03:54.594Z" }, + { url = "https://files.pythonhosted.org/packages/c7/85/e0952576d54322f3e9983b1e049a7a31f42ce82c5c29d01821e6dd3780d7/contourpy-1.4.0-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dd59df9e2fb0aff8bd7fd1adb9f349b7c835245a9d0f18c2f2deeb537190f2b1", size = 400272, upload-time = "2026-09-11T19:03:56.822Z" }, + { url = "https://files.pythonhosted.org/packages/d6/7d/f13543a5e1598e4a4ad01d9d5e2d65f9c09c37660616429913d606c5977d/contourpy-1.4.0-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b7794ea07cab575633daad8d6e963b662053391022294d3aacc1d9ba9ef54114", size = 412895, upload-time = "2026-09-11T19:03:58.487Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a8/6e5eaff53507dfd2dfc428240eecda32453067277d4b3e7ca71210b2d611/contourpy-1.4.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1c8a74744fa746eeaa12f0f9ed7f8b4add9d8f1b14d95e3b5e133675eac888f", size = 377060, upload-time = "2026-09-11T19:04:00.122Z" }, + { url = "https://files.pythonhosted.org/packages/16/a3/d00e44d35511b3cead5bd794ee672d4eecf30d9de4ceac57a49d6204064f/contourpy-1.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:107ec46f7aa1664266d69b1181aadc953d58d39f7d8d648fa5b795d4a060b0de", size = 1345688, upload-time = "2026-09-11T19:04:02.705Z" }, + { url = "https://files.pythonhosted.org/packages/1e/01/1e533b0cae32c8edd63fe40435ec3b29345dab87321afcd28722d7f957d1/contourpy-1.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5a4c89c7f38a0d7a94356d74e3391073c4325c4d47ad2fd065c3fe7dbae16c0c", size = 1416140, upload-time = "2026-09-11T19:04:04.77Z" }, + { url = "https://files.pythonhosted.org/packages/6d/0c/4ee66759f00d6a5d6909782f14f441d639fa18a4a43142e334351efc47f8/contourpy-1.4.0-cp314-cp314t-win32.whl", hash = "sha256:912c6afaa106e2f74ba22b30416b77ef7eb94e3bdc5a4df51ab147845dba9b6d", size = 371982, upload-time = "2026-09-11T19:04:06.233Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ac/b3e5324138c3e3741f526663f4f265f4f69dfdf26415a39428940864a5f6/contourpy-1.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:ef9440f6f8506246269a82734f5ff9e2e4c5e775b3996fc883cc491c5257eca6", size = 262907, upload-time = "2026-09-11T19:04:07.882Z" }, + { url = "https://files.pythonhosted.org/packages/19/37/c9aa45e47819dc15a38fc5c81a2fb987fde55e9d3b991fbde514e3b6b5f5/contourpy-1.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:fc9feef8f1f001c5b87decadc67c4a5d1eebb62ca39c4763d1237ff62cf2b707", size = 587071, upload-time = "2026-09-11T19:04:09.898Z" }, +] + +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, +] + [[package]] name = "et-xmlfile" version = "2.0.0" @@ -160,32 +139,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, ] +[[package]] +name = "fonttools" +version = "4.66.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/87/b6/126c659ab7e0e03e01a5f5d223abf7b2c0691ae92718085a212a3924a2a3/fonttools-4.66.1.tar.gz", hash = "sha256:64967c6ddb0d4c610dfd8cb1485981b2d27972ddfb7d4bbbd9e199d2a089c450", size = 3694174, upload-time = "2026-09-29T16:11:53.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/83/c745b210ec49379ebfe627e166b527f44671a1f6ec5e1e219d91caa8964d/fonttools-4.66.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b18803cbdef248e7ee1be59cb277fbbe1da1faaa6f726fa5d3557904e6a3d967", size = 3099264, upload-time = "2026-09-29T16:10:41.998Z" }, + { url = "https://files.pythonhosted.org/packages/35/af/dd698f10bf0f743873077259e8a6fce075861dde3bb01eb22b2c4f7aefe8/fonttools-4.66.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f08ab7f8461c37ecfdd29ad97fb0c0780b50501bd664bb0f46b6e83ed2b9d2a7", size = 2588769, upload-time = "2026-09-29T16:10:43.933Z" }, + { url = "https://files.pythonhosted.org/packages/c5/65/10b5caa2aa779e62411b67949bda9741d4d7532ba0b6dea647b715131260/fonttools-4.66.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7cf4f996f9b1cb549bff9ea4c50813988a26ec922c95cfa85c7e4f1270447e06", size = 5374278, upload-time = "2026-09-29T16:10:45.727Z" }, + { url = "https://files.pythonhosted.org/packages/6a/db/9ac5c6773feec1b40e57eac106d869886f66a1e44082d343ac1e1e1fb773/fonttools-4.66.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9261ef507f2dd74203443a472b65b5a26429eb378f975016dec7dc7305b24898", size = 5317826, upload-time = "2026-09-29T16:10:48.056Z" }, + { url = "https://files.pythonhosted.org/packages/04/0a/69beb11f6b714ac90ee73ad4600ac91d7dd4e1ce361d087c8425bb8472de/fonttools-4.66.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e1cde50b3ec84ca6fe63ca815de183dbecb88e8adf8ada82d8ea130ef12b2b43", size = 5316239, upload-time = "2026-09-29T16:10:50.201Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/7a77359e469d3a638df91d3e225cef4a3c1184c20e98381238042f7835fa/fonttools-4.66.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d8f0a8f16c4f3a5a87ca971de2631792d8cb4d570951f2000acf712f157d40db", size = 5449423, upload-time = "2026-09-29T16:10:52.337Z" }, + { url = "https://files.pythonhosted.org/packages/93/cf/ea0b2f1ef90431b1879d6e6c680a7fde497129cf511ab995ade0ff8e19a7/fonttools-4.66.1-cp314-cp314-win32.whl", hash = "sha256:b878c78b2af11b879bd4f26bb0d8bda2a4c64543fdd3f28efe2c80f97f043885", size = 2437673, upload-time = "2026-09-29T16:10:54.281Z" }, + { url = "https://files.pythonhosted.org/packages/b2/53/629dbb4a40c4a7b3de61442c6b4430d36ab6e0e8cf941c547f4fd66f3337/fonttools-4.66.1-cp314-cp314-win_amd64.whl", hash = "sha256:05aeb146451f37289f782c3c861f3d0f4b86c2dd2e4620b46683544c7406640e", size = 2489763, upload-time = "2026-09-29T16:10:56.262Z" }, + { url = "https://files.pythonhosted.org/packages/0e/59/342e5fce9438f88882524128d1feb0311d4014cb6f8bdeb4607fcc00713f/fonttools-4.66.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:66fad3b7874062c2a2692f0ae6dea56d24f01b778c7f191950ca3ff997e25a88", size = 3172931, upload-time = "2026-09-29T16:10:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/50/92/96196ebfd02676f28fa9b3776d85e18281bca0c8450d7e214c40e346bf92/fonttools-4.66.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eef76d5796e604f9d6753fa6d323c4eb9f4e0e43f1dcca553f3e6914f1667b64", size = 2621937, upload-time = "2026-09-29T16:11:00.845Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c3/3f4b761037ebc2e5597c52c218a9e95dbc4a2cab572828654f6004f422f5/fonttools-4.66.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c47299bca4b5acaaeb32100f77b944feea151de9ef1773365a410dc3d49b945b", size = 5546932, upload-time = "2026-09-29T16:11:03.126Z" }, + { url = "https://files.pythonhosted.org/packages/b6/d1/3f506cc79608becbc287785db8c44eb3f93079b49752266eb9f57700ecc4/fonttools-4.66.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dfba62cc93199ba62c376f90f2a9147d92730d301e44f88e013e50ff5edf6193", size = 5353546, upload-time = "2026-09-29T16:11:05.394Z" }, + { url = "https://files.pythonhosted.org/packages/6d/27/6534d84430ba1641185f8a0c9e2c7ecd395b15ff98f96967e3fb3c728b09/fonttools-4.66.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2c7340497cf53490293e0c2b61011e0191633022ede0a0a964a68157a98b0fb4", size = 5414313, upload-time = "2026-09-29T16:11:07.618Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/831ceca06d78855b11dc203b0e3ba5e6fd8a63a71ee0343ea8bd367fda55/fonttools-4.66.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c666fefdd5613a0e99aa4516e6ff4ef87aa86cf1c7ba12a73550f4770e46b750", size = 5449661, upload-time = "2026-09-29T16:11:09.997Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e4/21dc18bcbc8d0354814f6ea58af3d76d3bcd9b0d7246df454cb9e00c1740/fonttools-4.66.1-cp314-cp314t-win32.whl", hash = "sha256:2ce4c93160535761f22c80b2afbc96cabc09855363a5d1a5554265b8a4c85901", size = 2471431, upload-time = "2026-09-29T16:11:12.237Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f4/eb0489e7d58ac0d3387584afc7f3e505f60f60fe4b4f5a0274f013d444a2/fonttools-4.66.1-cp314-cp314t-win_amd64.whl", hash = "sha256:b13c8c541ce0b794add3211b3641cc0e113d707f73e06235e6fe9731bd7c45a9", size = 2521288, upload-time = "2026-09-29T16:11:14.52Z" }, + { url = "https://files.pythonhosted.org/packages/f6/10/d45b74135d5d642cb3a4fb0a957c1613ef93de4c8548671dfc3a5bf38299/fonttools-4.66.1-py3-none-any.whl", hash = "sha256:7234ae9e28db64273fbbfa72caebd0a97e3bdba6b05064114741b9539ef339d0", size = 1202222, upload-time = "2026-09-29T16:11:51.678Z" }, +] + [[package]] name = "greenlet" version = "3.5.6" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/3e/6e/0091f175ccd02b02bc8811bbcbcc6ac2e980be116e3b2f7a736ca322bf84/greenlet-3.5.6.tar.gz", hash = "sha256:8e67c43bdfc88d5fee6db0d3e40175b362fc95fb85f0412d233b9b203c53a575", size = 207653, upload-time = "2026-09-14T15:42:51.806Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/72/18/3fc6d951466ae9a2a688edcddde3b2e388da0a8244e0caf7117bbeb0eb95/greenlet-3.5.6-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:a5876d0a60355af98d535c47f6cd6eb0f8a432396dab26845d380b92f8412422", size = 295668, upload-time = "2026-09-14T14:22:33.241Z" }, - { url = "https://files.pythonhosted.org/packages/27/89/366d2af5061eeefa5012f510d95a99c8620dcc457609838db4d538820318/greenlet-3.5.6-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e85880b538e59a59f55117b81f208a6660ad5ac328aad9305f812d9b8bc67a0f", size = 611700, upload-time = "2026-09-14T15:12:01.962Z" }, - { url = "https://files.pythonhosted.org/packages/54/1c/07f133f865fd58ae593dd2bbec3144acaee9b04ffe2eb48c6e121747ceef/greenlet-3.5.6-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f0ba7c2a329d650628f4c8572fd1db29f0a59dd70a3e3e0710dcf18a35cce9d8", size = 624223, upload-time = "2026-09-14T15:20:42.459Z" }, - { url = "https://files.pythonhosted.org/packages/a7/f2/844dc823ff2752ad049caa6b59d57e4572f9c445934b02d3518f4c67197c/greenlet-3.5.6-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee7d9da3bf493909cf811a3f038840cb34fab5ae2956b8a263919f6e289ab188", size = 629529, upload-time = "2026-09-14T15:25:06.354Z" }, - { url = "https://files.pythonhosted.org/packages/66/6a/1594f3869c57c149abdb380492529e04d4c0229b5e4d79572c5bd0aaa673/greenlet-3.5.6-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:975736b002ed080d124cf81a79cb7e05cb26d6b3f5c7a7b651c0fcce70353aa1", size = 621404, upload-time = "2026-09-14T14:35:59.027Z" }, - { url = "https://files.pythonhosted.org/packages/c0/42/b1f8dbc89a53b9e77859fc1ad1627d106fc361daa3ea4bdf43a91ebb4338/greenlet-3.5.6-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:71890d5247020c25c21a6b65202782bfc281d4e6e244842419d30e3492bb6dcc", size = 432385, upload-time = "2026-09-14T15:28:37.369Z" }, - { url = "https://files.pythonhosted.org/packages/a2/f5/33e5c9e48178b9259fd000f8f45caa4a65036f65d3d0c06a602f570f025d/greenlet-3.5.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0616b8f878098c5681fd8f0dc92d887551717402342a70f0abcbfea5f5ad8a44", size = 1584998, upload-time = "2026-09-14T15:10:06.653Z" }, - { url = "https://files.pythonhosted.org/packages/ef/31/9b4e140bc24d0ad7927ebd651f5608b0acc2334d061748c3b6ad19085cfa/greenlet-3.5.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3dbb4596a6a4e5d47121a33ff20533a81e60f302d9e67b69909a8bc21a43f0a7", size = 1647568, upload-time = "2026-09-14T14:35:49.787Z" }, - { url = "https://files.pythonhosted.org/packages/c3/71/d79f1791f824f8ff15c2978746640467ae932a2365e0201069f7f272395f/greenlet-3.5.6-cp312-cp312-win_amd64.whl", hash = "sha256:7ac4abb3877c43af320392c664774eef6fa2cc063c79a55fc02d844a3cbe7395", size = 324203, upload-time = "2026-09-14T14:22:54.504Z" }, - { url = "https://files.pythonhosted.org/packages/63/af/42aca4d56e8cb321912203069d8d34734cb288222f10ad2ae102718cc577/greenlet-3.5.6-cp312-cp312-win_arm64.whl", hash = "sha256:301102a49120b095e72a7838792b41233975fc1c155daec6d98f81c00c9280e0", size = 308310, upload-time = "2026-09-14T14:24:03.008Z" }, - { url = "https://files.pythonhosted.org/packages/f1/a1/e720a38852366c589e1a46cf570b886507ad2cf591050c203365638baab0/greenlet-3.5.6-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:f96f0e30b5a95c7631b12bfe214cbc90ec8fe8cfa36920596c10514a65743519", size = 294627, upload-time = "2026-09-14T14:24:40.102Z" }, - { url = "https://files.pythonhosted.org/packages/eb/c3/58187858df41354a11e6a55b421e7af9059798abdab3a384cc51b8567c38/greenlet-3.5.6-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c75116c9de79949de23006e2d9b35ee82874c594fcf5c0311b439acaa14b8441", size = 614356, upload-time = "2026-09-14T15:12:03.399Z" }, - { url = "https://files.pythonhosted.org/packages/ce/b9/3a7e67d5f05c9760b1ad411fa52264bd69cc08e22a2ebfb4018b90628ced/greenlet-3.5.6-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cad5782f93f7f738b62c6527b6f32a60694d924029f299a8b524758cfa53d815", size = 626756, upload-time = "2026-09-14T15:20:44.269Z" }, - { url = "https://files.pythonhosted.org/packages/c6/7c/40400455f5b5a65bb83e94fde66d1be9e5ec518638113f8083ace746c309/greenlet-3.5.6-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a93ee7c6e8fd0f8a83525a51bd777be57ee17787e91d805bd8d6faf9dcada18e", size = 632632, upload-time = "2026-09-14T15:25:07.813Z" }, - { url = "https://files.pythonhosted.org/packages/85/cb/ab0c123c514ed4e94c0dc9ee2e86362633e6b998cfc05de7fc9ac2eb9690/greenlet-3.5.6-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f98e8215e172f567ce80eeaed9107fb4d32b6c44f26983d9b8334658136a205a", size = 623779, upload-time = "2026-09-14T14:36:01.104Z" }, - { url = "https://files.pythonhosted.org/packages/f9/67/1f35cff30a6c51c3f23b63d4afcc7313ab4f97490ba3676fa78178984b27/greenlet-3.5.6-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:7f731ebac68ea06d628658295cb2d217b10186329fcf9a3b6a149045059bf92e", size = 434933, upload-time = "2026-09-14T15:28:38.858Z" }, - { url = "https://files.pythonhosted.org/packages/a5/26/fda8a5a06e7073333ccb038133c5893b9e0c4fe29d5992a17e83c241bc6e/greenlet-3.5.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df19e2d0b1620039af5102563fbd96e8938c7f5c3f5828528d641d9fc585525e", size = 1584930, upload-time = "2026-09-14T15:10:08.234Z" }, - { url = "https://files.pythonhosted.org/packages/2f/37/50f8813163148d6234e08b23dcad6a9e37f01d148c8ec976e4c44ea2d918/greenlet-3.5.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:06c0e933290fba8ffe53ead4ae1b8044b0e9754b75cebf381aa2bc3e50d82fac", size = 1647590, upload-time = "2026-09-14T14:35:51.173Z" }, - { url = "https://files.pythonhosted.org/packages/86/da/b7669b09586365654083a62bd0724cf06cb74bd5085a15cdd161271f992f/greenlet-3.5.6-cp313-cp313-win_amd64.whl", hash = "sha256:5b602b4201b965a8354d74e232364a66ff243dd142e350d035f46169bb36e13d", size = 324086, upload-time = "2026-09-14T14:23:48.428Z" }, - { url = "https://files.pythonhosted.org/packages/e5/5d/c9663cfe84a2a9e0aa96f066f5b0594c227ea4c647511e087e2e11d4ac0a/greenlet-3.5.6-cp313-cp313-win_arm64.whl", hash = "sha256:876077e7ebb8c84ed068e2b23d4c62ebb010d60df84b9591af1be2f39010ffb2", size = 308211, upload-time = "2026-09-14T14:28:01.634Z" }, { url = "https://files.pythonhosted.org/packages/66/c0/d254544ae2b8bdd311aef000fafc02828c2771b17d994b3075620ea7cc6e/greenlet-3.5.6-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8cddea1b8339451c2fb3388e138347b6126744f33b611bdb55b7357361cfef46", size = 295221, upload-time = "2026-09-14T14:25:11.583Z" }, { url = "https://files.pythonhosted.org/packages/18/18/eb54be16b9cc3971e09ca5b73334e1b8c804a4630d9addaaf218a4fe300f/greenlet-3.5.6-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c59acfa8eb73a1e0d484392dc002bdf001fd4ce73394e0132df3d1ab6093d7cb", size = 660992, upload-time = "2026-09-14T15:12:04.876Z" }, { url = "https://files.pythonhosted.org/packages/8f/b4/e193efe65671dcf294bc51fcc59efb52d154adf8612c4ea016da0d2c486c/greenlet-3.5.6-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3b4a01c6da07ef9f80d4fe8933b994bc99747bcea3eab0330a9c34d3c12655b", size = 673428, upload-time = "2026-09-14T15:20:45.756Z" }, @@ -205,26 +189,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b2/04/0d018e0d05bcdde19a0fcb907834155f1fc853a9bedd3f3f5e6acadcae19/greenlet-3.5.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca80a49b53ed1d22f7282da7255f7bb2fd1935fd0f623d8613fda38745f18961", size = 1641479, upload-time = "2026-09-14T15:10:11.216Z" }, { url = "https://files.pythonhosted.org/packages/59/bb/f02ef9073919158f6403fe3701d4ed4403d646720e7201dfc6e9d264bac3/greenlet-3.5.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:916f92f2a8db10508f739d0b5e00b83defe5d1115a997c54532a6d7cf8c95404", size = 1698758, upload-time = "2026-09-14T14:35:54.336Z" }, { url = "https://files.pythonhosted.org/packages/08/a5/1f48fe647473a2dcccfd1839b2ff2c78eb57009be776b4da071e901c9bff/greenlet-3.5.6-cp314-cp314t-win_amd64.whl", hash = "sha256:886bcf1870af74c32bc310fd00a6b803445e17e51b7d5a107c7b35c0f362cc16", size = 331574, upload-time = "2026-09-14T14:27:18.451Z" }, - { url = "https://files.pythonhosted.org/packages/cd/72/3882855a75838faeb54a58aeef4fd77d20b2a86d4bad570c70d41b565dcf/greenlet-3.5.6-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:3ac3494c381dab876cad7d0b22f3a722f3e0c8deb3a65b9e7f35ad7f58b8fcb3", size = 295926, upload-time = "2026-09-14T14:27:21.16Z" }, - { url = "https://files.pythonhosted.org/packages/10/1f/be4d957d8a9b90bcbe8db206548a42134d96222d43e5ed3fc4708fb6e24b/greenlet-3.5.6-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:602024dae6d77e161f4b89491b62ca1d4f19949d79d47b2db057e476d21179d6", size = 666910, upload-time = "2026-09-14T15:12:07.901Z" }, - { url = "https://files.pythonhosted.org/packages/a1/af/60d62571a7d6de961e4ce7625d6c2faf359345659fc782d2cdf517c34577/greenlet-3.5.6-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8e63209c3e1e828ee6a457529b4a6d8b05d050fe0ae03a7ae49e967c5d312e0", size = 677415, upload-time = "2026-09-14T15:20:48.817Z" }, - { url = "https://files.pythonhosted.org/packages/f5/41/b3114c97c10e796010f00a30f51c81470072bca4b53e396ccca87484fcf7/greenlet-3.5.6-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9133d68624b1f2e89ec2f554d56aea8a5b0d7168cd9320200ba58d4d794845a4", size = 681337, upload-time = "2026-09-14T15:25:12.812Z" }, - { url = "https://files.pythonhosted.org/packages/fb/16/ac9e547b611539aaed1870eb1d6ddc57abdd5924b3a99bb9b5f0b44176b8/greenlet-3.5.6-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccadce0130fd813ec86ebfe969a6c58b42acc1d0fe55a47525375b740e07b605", size = 675927, upload-time = "2026-09-14T14:36:05.34Z" }, - { url = "https://files.pythonhosted.org/packages/48/1b/d41861c2fa00968e39e467a495ca8db9ce9b6310a5d9b57561b3d0dc48fa/greenlet-3.5.6-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:5adcbbfe78bdc242c71740a02e0991cc1b2f34d33c8bb15ca45eee8fd1140942", size = 487339, upload-time = "2026-09-14T15:28:43.497Z" }, - { url = "https://files.pythonhosted.org/packages/c4/b1/b7ba08d6431121741f1d30be0d5d292e76873325179a63586cd9217b62f6/greenlet-3.5.6-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9297fb9c39b9a2c039dbcd306c410bd6906b95244dec3bba4318d36c718c164c", size = 1637236, upload-time = "2026-09-14T15:10:12.442Z" }, - { url = "https://files.pythonhosted.org/packages/af/c5/3b1cbc68f0c082022fc8717f7fe4b8b13b8d583c52352be37f4e9f55bcd2/greenlet-3.5.6-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b374e79ffa7511afc11773aef40a4ccea6191fba1c856ea2f9c56738dca69d7a", size = 1698526, upload-time = "2026-09-14T14:35:56.039Z" }, - { url = "https://files.pythonhosted.org/packages/de/56/12941ed2711400451c89d544e10f831800a2770f19dd55eac8f0f7f2003b/greenlet-3.5.6-cp315-cp315-win_amd64.whl", hash = "sha256:7969bffa322c097bd46ae595ada6a931cefda613f18ba64587e9cff4cb320756", size = 327739, upload-time = "2026-09-14T14:23:55.768Z" }, - { url = "https://files.pythonhosted.org/packages/c5/3b/576b9ed5ac929252e340cf60b4bcb6a8515350dc20797064b1922dc4ea75/greenlet-3.5.6-cp315-cp315-win_arm64.whl", hash = "sha256:8dba0129b93e7091dfefaf4cf7000172741bff7f47bf6326fcf17f32fbb54d6b", size = 311715, upload-time = "2026-09-14T14:28:25.154Z" }, - { url = "https://files.pythonhosted.org/packages/16/c2/86cfc5555a98e12b86966ddbd24fd39af32f71f2f785c6595b7feb2db156/greenlet-3.5.6-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:de3de000d459402cda015068fd135aa50c0bf6f2477a80d4da1e646f123b4e78", size = 306244, upload-time = "2026-09-14T14:27:57.565Z" }, - { url = "https://files.pythonhosted.org/packages/14/6d/83ffc9d05a75a80ab3a7595dbb1d9604e5d4fc2996d73a8ae2dbd1284900/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45663c01a4de48b9a64a2ee1509d92d1dfd3afb02b2ccfc9333029d11aef996a", size = 676578, upload-time = "2026-09-14T15:12:09.468Z" }, - { url = "https://files.pythonhosted.org/packages/5d/d6/c2cf684810e5caded075970aaadea654ecb58b8382b9aecf1d231b936894/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3deccbb57a481e3a408fe61cdfd5c13e0678fc0a30fdd09597917ca87b4be877", size = 684203, upload-time = "2026-09-14T15:20:50.261Z" }, - { url = "https://files.pythonhosted.org/packages/f2/d1/039c353d5593a97a89699e989324c9bc86af499e6c6152fe0180f5742204/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:63aff70fe5aac59c72215f42ec39fcb59ff46774fa966e717f8ecb6ee2273577", size = 686023, upload-time = "2026-09-14T15:25:14.528Z" }, - { url = "https://files.pythonhosted.org/packages/62/19/00e1bee5d2af890dc8f400b54d0b0f9b489965f92bc12b407ff72cc6f469/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:311018b46472fb26ee85870847fb89eb64cc8aaddb617400789d87076f7cfeec", size = 681253, upload-time = "2026-09-14T14:36:06.742Z" }, - { url = "https://files.pythonhosted.org/packages/8a/62/97ceb8e0b2ea96046cdf8e95b042715020ebb12d83ea0690db80a8f03d23/greenlet-3.5.6-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:520648db8fb92eef7b3e6013f5a6f901cdf0d6685f639c2f7a245879f865bef7", size = 517058, upload-time = "2026-09-14T15:28:44.924Z" }, - { url = "https://files.pythonhosted.org/packages/89/58/c9275fd0ca195d1d3402931bcce8cfcc74726ff76efb1883d229e6e1a3d7/greenlet-3.5.6-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7f924a5a9d5890649566f2f6682e0d8ad8ca23028bacffbbac36dbd7fd680176", size = 1645988, upload-time = "2026-09-14T15:10:13.758Z" }, - { url = "https://files.pythonhosted.org/packages/e0/36/b35747582fa4f1a5453f8f3002405dbac788e450cec7674dc2d204b6ccb5/greenlet-3.5.6-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:de9923832f2d8c1a5ecd8d7260465a6ca5a86888a0d129e3bd5cf0406d2fc5bf", size = 1702383, upload-time = "2026-09-14T14:35:58.143Z" }, - { url = "https://files.pythonhosted.org/packages/ed/69/6ec22ac9351e474d2a134d0ff9400dc80362d1c20f0721088ffffdfc205b/greenlet-3.5.6-cp315-cp315t-win_amd64.whl", hash = "sha256:2ab5f42ac6c238eb71770715e6e909ad9a1a92b6c681ccb64cd5a0f07edb953f", size = 331845, upload-time = "2026-09-14T14:27:41.723Z" }, - { url = "https://files.pythonhosted.org/packages/30/cf/697c051fd534e223461fb8b523890e21a24eeca229cd50624cff6f02fabd/greenlet-3.5.6-cp315-cp315t-win_arm64.whl", hash = "sha256:f9fe868463ec7e1363733af77e38a5fda3e9b63940337048c945d69e0c80ff24", size = 315070, upload-time = "2026-09-14T14:22:21.476Z" }, ] [[package]] @@ -245,6 +209,80 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, ] +[[package]] +name = "kiwisolver" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/07/bd78e6a8fae171ea041ef5bba3ed21a003522fa088834b069b1909981f30/kiwisolver-1.5.1.tar.gz", hash = "sha256:f1303ef2eec81262a4b708c3e858afe58d7c75ad91c1c05266eda7673369859a", size = 104395, upload-time = "2026-08-28T10:28:27.153Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/87/2d5dfad0daf17dcc18d98c48ed2332fc3f051cf599e60be6182a30dd4cf1/kiwisolver-1.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0324cd2567259b7a095f6cf18a52b0ffc6f3de9e69528ff1bc0e7a37bd43ff1a", size = 62337, upload-time = "2026-08-28T10:26:18.778Z" }, + { url = "https://files.pythonhosted.org/packages/08/c8/83e1624f15d6262b470dbcc80b09979fd4d5b2ea3ddfc6b6e3327e235726/kiwisolver-1.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:74ea337e0ec3f6f342a36a4f1b5cd94dd9affddcd28ba9aae2905af932ee8c6b", size = 64513, upload-time = "2026-08-28T10:26:19.909Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2d/827ec30eb07f528c08d8459ffb318ae91a56d793ee8acbea8b491f0ff906/kiwisolver-1.5.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ee9df1f0d77b9c6e94f4ac0fec533fbddd5ea3a327807f18d7b069ae019ded80", size = 66287, upload-time = "2026-08-28T10:26:21.078Z" }, + { url = "https://files.pythonhosted.org/packages/53/11/5c43a562529dad8def4b81e5e1877c612a7e0298105a5939b3b409d2079c/kiwisolver-1.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fc271a6f0a2126958f4090e5507b9da5848927dae331f8f763bd4aa642b3d2cd", size = 123940, upload-time = "2026-08-28T10:26:22.475Z" }, + { url = "https://files.pythonhosted.org/packages/64/db/9bd6c505c95128c258a55236bfbb3a7a3fb6023f863316b6d7d9f3c69052/kiwisolver-1.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9b3092d8992a1d69b7a59c3e39f35e1b9be327a17f68a7c35fc17329e337d6f2", size = 66493, upload-time = "2026-08-28T10:26:23.743Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f4/b3007a3ed5c9be73f81161140684cf7d9bdb9c4b632f5f484d2a1c713fb9/kiwisolver-1.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c2306e8bb53601979fcb3fa09cc65e031876d9ae01eff2fcbcd7a84ef94d5bc1", size = 64720, upload-time = "2026-08-28T10:26:24.95Z" }, + { url = "https://files.pythonhosted.org/packages/a1/13/08188f0cafa3a800403e4ff62b9aad4e7a17f9c4c7e080dc8f18c64794cf/kiwisolver-1.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:18a0cfb124546a4c2e6087c5f3029c7f44b37c85b142e0ced71f73a7599ac208", size = 1475867, upload-time = "2026-08-28T10:26:26.393Z" }, + { url = "https://files.pythonhosted.org/packages/8a/3e/053bdc3c9abdb8f2606225eda398adca25c0c91ab90add8222a69db65ee0/kiwisolver-1.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34ec467940442c9943016fb2d4c81d1ba84351eeca2f1a78f8bc87f1ba0d414c", size = 1282865, upload-time = "2026-08-28T10:26:28.118Z" }, + { url = "https://files.pythonhosted.org/packages/5e/64/a44c341b36b610588cc2f1e89b3cae072a3119aa8be578e90987cd640751/kiwisolver-1.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a83ee7107df13abe42a54a6654670eef9bb39425cf2e27f65e0007465e1286ab", size = 1300865, upload-time = "2026-08-28T10:26:30.125Z" }, + { url = "https://files.pythonhosted.org/packages/60/5e/7e7d716dca38c714478b741257a5b4a321d9932b8d851551a136dcaf3984/kiwisolver-1.5.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bebb89489b279b2f5661bbbb2abcc87bcd4a46607bb4a5c966f04f1db6b8df9a", size = 1348071, upload-time = "2026-08-28T10:26:31.829Z" }, + { url = "https://files.pythonhosted.org/packages/10/1a/2b98fdda8bf45b7be317e48ed12393d44334394d315c74b81f4a14c0e31b/kiwisolver-1.5.1-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:509735237ae0d849e8a843551d423d2500d2e0a9ac1611a145658b29c0fb9f85", size = 992191, upload-time = "2026-08-28T10:26:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/b6/55/d893f5ede0e50f9e3fcf01f6015f42ec7d9cd221e26772701fe4a98745f9/kiwisolver-1.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:824c3d763a05ea9e9003610145186b0e9848c7584a5575c79bac5a8e7cd80bad", size = 2233854, upload-time = "2026-08-28T10:26:35.282Z" }, + { url = "https://files.pythonhosted.org/packages/b1/82/f85f6279555a6ee1639fef7bfe83adb037a03e11a6fc9eaa54b8d0380339/kiwisolver-1.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1fff05e239575b1481b6ed1a782f6fad616efbf1f0b1f44e6e85c4dfe426e483", size = 2330621, upload-time = "2026-08-28T10:26:36.9Z" }, + { url = "https://files.pythonhosted.org/packages/56/31/e11aea078f66fc2fffcc179d38ca90d9da97652a241b64519169742ba46a/kiwisolver-1.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0627b9bceb9c3cdcf12b8a18655eedfed2692b038df27423383c120d0b7dc2d6", size = 1982848, upload-time = "2026-08-28T10:26:39.01Z" }, + { url = "https://files.pythonhosted.org/packages/af/ea/2956b63bf5140ca46aa2c2818e6aa03e2d5754dd2fa41db1c6b28922940c/kiwisolver-1.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:8a708a47ade1fe19e8371d5da076bac0dd4b0a5a7985ad6c637f7f7e361b6baa", size = 2494850, upload-time = "2026-08-28T10:26:40.837Z" }, + { url = "https://files.pythonhosted.org/packages/11/d1/3829542258d8b3fc0898d221e7ef0e2c83eca0d348709bb8dbe54f3d4005/kiwisolver-1.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:007a5553dfc4f4e8d184f588a0200e2cd4b63a59cc8796df3c39909e679dc7a0", size = 2298067, upload-time = "2026-08-28T10:26:42.803Z" }, + { url = "https://files.pythonhosted.org/packages/4e/0e/49522e1ab5788cbaf63a26fbd3b851f9028616828c961b8a31b35cb96df8/kiwisolver-1.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:f4167e87b397f273dc2356fcf1eaf50a6bac51e6105f45103ef7129c8efb0255", size = 72282, upload-time = "2026-08-28T10:26:44.268Z" }, + { url = "https://files.pythonhosted.org/packages/f5/b6/22e7ca5315d363e6f81c9f37c9472e12e7b298731e77c0428e6a911a2c39/kiwisolver-1.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:5c490db2168a508088f59140dd392556a54b8bd1048fc6383c8baff13c359673", size = 69855, upload-time = "2026-08-28T10:26:45.725Z" }, + { url = "https://files.pythonhosted.org/packages/30/8c/03a9cfbe871964c8758a816eb03ac96c806da2795a9a7cd9bf9648bfb594/kiwisolver-1.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4d4ca09bf13cff792b1884f64b98ee6c2467930d632233be25c56b442d99f10e", size = 126289, upload-time = "2026-08-28T10:26:47.023Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e3/14ce3041ca79dff9c9d884ca00c7bf32374e76028a865a9ecd99b4f5a517/kiwisolver-1.5.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:44b8faef94f1857e77fa0238f3390ff1ac51d2ea20a487e2e452a59fd2b5f5ca", size = 67709, upload-time = "2026-08-28T10:26:48.268Z" }, + { url = "https://files.pythonhosted.org/packages/af/c4/45030471a66ec8ef042e9f96ffe1d522c9ab12da180186a0898966fc1385/kiwisolver-1.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2ae70bc59790d2af72a3f76f24b272403e135070340281108b447cb77ea70819", size = 65909, upload-time = "2026-08-28T10:26:49.523Z" }, + { url = "https://files.pythonhosted.org/packages/42/73/17dce073a6ae259bb32cf9d686c4079d2e538868bc45967462bf33df914a/kiwisolver-1.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:43844c1a7ad6d723d5b5b4c4fc7f5bd399c40e288120d16257c7c9e8765c6e85", size = 1584907, upload-time = "2026-08-28T10:26:50.933Z" }, + { url = "https://files.pythonhosted.org/packages/8c/84/ae3c75909f507283cbfcc7e916c7e822579ef962020b97e6882b27b4478f/kiwisolver-1.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22d5e5aaad6be121f2515765e3b1c444352cb8eb4c86510801db8f2e50757316", size = 1392474, upload-time = "2026-08-28T10:26:52.638Z" }, + { url = "https://files.pythonhosted.org/packages/34/31/8bcc83caad5bce8fa4577152389848bf6bc110e51e573a2b4e7c2aa34c89/kiwisolver-1.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3fa5855898f6d3d01b72ccd48a2d65cbdee301251603fefe34e2025bddba219c", size = 1405246, upload-time = "2026-08-28T10:26:54.248Z" }, + { url = "https://files.pythonhosted.org/packages/55/72/220345537d790cf4ae54f8acfff4b5cc2468e0702a384d651cf7a771c63e/kiwisolver-1.5.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d66a64dd5dec136040ec2ae94aa026a912ee60fdd45bc28d3db30037fd809e88", size = 1456099, upload-time = "2026-08-28T10:26:56.042Z" }, + { url = "https://files.pythonhosted.org/packages/cd/10/3725fd2398f66d18c34b4e0f81a8d03764cd4f4f089f58a527f0b4428086/kiwisolver-1.5.1-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:9e51c119992ea8820706871c30a4642ec76de20ae82f9b50b9a45517d8e9f810", size = 1073695, upload-time = "2026-08-28T10:26:57.658Z" }, + { url = "https://files.pythonhosted.org/packages/86/cb/28d6e09e66b93e4588b2e6b7d84d020ccefea09e2f4de788510a07efeab7/kiwisolver-1.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:70ed9a45c7484d2b30cdacf60d220f494a1763b9fec1ad03285c6553fa0889f2", size = 2335355, upload-time = "2026-08-28T10:26:59.202Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b3/a0f31d5e4e40af7dc97c36b8a74fdd3a36cf3c8bbd098da9a23466ff6a94/kiwisolver-1.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:98b208a7cc42c803445ef551d6753cc42a5ea13e9cab1ee66cd8b9cb70195330", size = 2426524, upload-time = "2026-08-28T10:27:01.181Z" }, + { url = "https://files.pythonhosted.org/packages/2f/c9/728f63bd58c72cafdc79fc306abeeac7391bec03b757a48dadeb30906521/kiwisolver-1.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c6834b92dd2428e2dd85ef3d85f723d3c12f20aaf43a2ddd4f944ca25d833408", size = 2063430, upload-time = "2026-08-28T10:27:03.06Z" }, + { url = "https://files.pythonhosted.org/packages/84/df/ce188b96f92f9a2c958231da140768918cba53c9713dc887b82f85462118/kiwisolver-1.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:5d142e352eb13facc7dd047489aebdff6ba78576c239f1ea04931979caaf0567", size = 2597513, upload-time = "2026-08-28T10:27:05.072Z" }, + { url = "https://files.pythonhosted.org/packages/69/d6/76947c8203768968382e5bd74d9cc95654746703a61ea53015f2c74a2e06/kiwisolver-1.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9b1c4900736e489a812c529100de4b8fb617d4db075e931e213c57424b83d9b", size = 2394488, upload-time = "2026-08-28T10:27:07.423Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d4/14b21e4eb203c4d15425e8b6a2c625a320b4a1f2f7557eead63ffc30ffb7/kiwisolver-1.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5978c3340f16a35c30f8ab2fa7bcf559973c55f1a5ef6970e1f621acf3c4db13", size = 75404, upload-time = "2026-08-28T10:27:08.892Z" }, + { url = "https://files.pythonhosted.org/packages/cb/f5/53157899fc7f45f76421b77b99eb1639dd0f83f26ff9d76300c96bb4a3b0/kiwisolver-1.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ca307d6c259e5c98d3cb9ade55342b47a6839762caf2536f3d7b46ee660cc82e", size = 72946, upload-time = "2026-08-28T10:27:10.944Z" }, +] + +[[package]] +name = "matplotlib" +version = "3.10.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/76/d3c6e3a13fe484ebe7718d14e269c9569c4eb0020a968a327acb3b9a8fe6/matplotlib-3.10.8.tar.gz", hash = "sha256:2299372c19d56bcd35cf05a2738308758d32b9eaed2371898d8f5bd33f084aa3", size = 34806269, upload-time = "2025-12-10T22:56:51.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/43/9c0ff7a2f11615e516c3b058e1e6e8f9614ddeca53faca06da267c48345d/matplotlib-3.10.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b53285e65d4fa4c86399979e956235deb900be5baa7fc1218ea67fbfaeaadd6f", size = 8262481, upload-time = "2025-12-10T22:56:10.885Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ca/e8ae28649fcdf039fda5ef554b40a95f50592a3c47e6f7270c9561c12b07/matplotlib-3.10.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32f8dce744be5569bebe789e46727946041199030db8aeb2954d26013a0eb26b", size = 8151473, upload-time = "2025-12-10T22:56:12.377Z" }, + { url = "https://files.pythonhosted.org/packages/f1/6f/009d129ae70b75e88cbe7e503a12a4c0670e08ed748a902c2568909e9eb5/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cf267add95b1c88300d96ca837833d4112756045364f5c734a2276038dae27d", size = 9553896, upload-time = "2025-12-10T22:56:14.432Z" }, + { url = "https://files.pythonhosted.org/packages/f5/26/4221a741eb97967bc1fd5e4c52b9aa5a91b2f4ec05b59f6def4d820f9df9/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2cf5bd12cecf46908f286d7838b2abc6c91cda506c0445b8223a7c19a00df008", size = 9824193, upload-time = "2025-12-10T22:56:16.29Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/3abf75f38605772cf48a9daf5821cd4f563472f38b4b828c6fba6fa6d06e/matplotlib-3.10.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:41703cc95688f2516b480f7f339d8851a6035f18e100ee6a32bc0b8536a12a9c", size = 9615444, upload-time = "2025-12-10T22:56:18.155Z" }, + { url = "https://files.pythonhosted.org/packages/93/a5/de89ac80f10b8dc615807ee1133cd99ac74082581196d4d9590bea10690d/matplotlib-3.10.8-cp314-cp314-win_amd64.whl", hash = "sha256:83d282364ea9f3e52363da262ce32a09dfe241e4080dcedda3c0db059d3c1f11", size = 8272719, upload-time = "2025-12-10T22:56:20.366Z" }, + { url = "https://files.pythonhosted.org/packages/69/ce/b006495c19ccc0a137b48083168a37bd056392dee02f87dba0472f2797fe/matplotlib-3.10.8-cp314-cp314-win_arm64.whl", hash = "sha256:2c1998e92cd5999e295a731bcb2911c75f597d937341f3030cc24ef2733d78a8", size = 8144205, upload-time = "2025-12-10T22:56:22.239Z" }, + { url = "https://files.pythonhosted.org/packages/68/d9/b31116a3a855bd313c6fcdb7226926d59b041f26061c6c5b1be66a08c826/matplotlib-3.10.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b5a2b97dbdc7d4f353ebf343744f1d1f1cca8aa8bfddb4262fcf4306c3761d50", size = 8305785, upload-time = "2025-12-10T22:56:24.218Z" }, + { url = "https://files.pythonhosted.org/packages/1e/90/6effe8103f0272685767ba5f094f453784057072f49b393e3ea178fe70a5/matplotlib-3.10.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3f5c3e4da343bba819f0234186b9004faba952cc420fbc522dc4e103c1985908", size = 8198361, upload-time = "2025-12-10T22:56:26.787Z" }, + { url = "https://files.pythonhosted.org/packages/d7/65/a73188711bea603615fc0baecca1061429ac16940e2385433cc778a9d8e7/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f62550b9a30afde8c1c3ae450e5eb547d579dd69b25c2fc7a1c67f934c1717a", size = 9561357, upload-time = "2025-12-10T22:56:28.953Z" }, + { url = "https://files.pythonhosted.org/packages/f4/3d/b5c5d5d5be8ce63292567f0e2c43dde9953d3ed86ac2de0a72e93c8f07a1/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:495672de149445ec1b772ff2c9ede9b769e3cb4f0d0aa7fa730d7f59e2d4e1c1", size = 9823610, upload-time = "2025-12-10T22:56:31.455Z" }, + { url = "https://files.pythonhosted.org/packages/4d/4b/e7beb6bbd49f6bae727a12b270a2654d13c397576d25bd6786e47033300f/matplotlib-3.10.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:595ba4d8fe983b88f0eec8c26a241e16d6376fe1979086232f481f8f3f67494c", size = 9614011, upload-time = "2025-12-10T22:56:33.85Z" }, + { url = "https://files.pythonhosted.org/packages/7c/e6/76f2813d31f032e65f6f797e3f2f6e4aab95b65015924b1c51370395c28a/matplotlib-3.10.8-cp314-cp314t-win_amd64.whl", hash = "sha256:25d380fe8b1dc32cf8f0b1b448470a77afb195438bafdf1d858bfb876f3edf7b", size = 8362801, upload-time = "2025-12-10T22:56:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/5d/49/d651878698a0b67f23aa28e17f45a6d6dd3d3f933fa29087fa4ce5947b5a/matplotlib-3.10.8-cp314-cp314t-win_arm64.whl", hash = "sha256:113bb52413ea508ce954a02c10ffd0d565f9c3bc7f2eddc27dfe1731e71c7b5f", size = 8192560, upload-time = "2025-12-10T22:56:38.008Z" }, +] + [[package]] name = "matplotlib-gui-dev" version = "0.0.0" @@ -252,7 +290,10 @@ source = { virtual = "." } [package.dev-dependencies] dev = [ + { name = "matplotlib" }, + { name = "numpy" }, { name = "openpyxl" }, + { name = "pandas" }, { name = "pytest" }, { name = "pytest-playwright" }, ] @@ -261,11 +302,43 @@ dev = [ [package.metadata.requires-dev] dev = [ - { name = "openpyxl", specifier = ">=3.1.5" }, + { name = "matplotlib", specifier = "==3.10.8" }, + { name = "numpy", specifier = "==2.4.3" }, + { name = "openpyxl", specifier = "==3.1.5" }, + { name = "pandas", specifier = "==3.0.2" }, { name = "pytest", specifier = ">=9.1.1" }, { name = "pytest-playwright", specifier = ">=0.9.0" }, ] +[[package]] +name = "numpy" +version = "2.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/10/8b/c265f4823726ab832de836cdd184d0986dcf94480f81e8739692a7ac7af2/numpy-2.4.3.tar.gz", hash = "sha256:483a201202b73495f00dbc83796c6ae63137a9bdade074f7648b3e32613412dd", size = 20727743, upload-time = "2026-03-09T07:58:53.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/ae/3936f79adebf8caf81bd7a599b90a561334a658be4dcc7b6329ebf4ee8de/numpy-2.4.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5884ce5c7acfae1e4e1b6fde43797d10aa506074d25b531b4f54bde33c0c31d4", size = 16664563, upload-time = "2026-03-09T07:57:43.817Z" }, + { url = "https://files.pythonhosted.org/packages/9b/62/760f2b55866b496bb1fa7da2a6db076bef908110e568b02fcfc1422e2a3a/numpy-2.4.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:297837823f5bc572c5f9379b0c9f3a3365f08492cbdc33bcc3af174372ebb168", size = 14702161, upload-time = "2026-03-09T07:57:46.169Z" }, + { url = "https://files.pythonhosted.org/packages/32/af/a7a39464e2c0a21526fb4fb76e346fb172ebc92f6d1c7a07c2c139cc17b1/numpy-2.4.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:a111698b4a3f8dcbe54c64a7708f049355abd603e619013c346553c1fd4ca90b", size = 5208738, upload-time = "2026-03-09T07:57:48.506Z" }, + { url = "https://files.pythonhosted.org/packages/29/8c/2a0cf86a59558fa078d83805589c2de490f29ed4fb336c14313a161d358a/numpy-2.4.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:4bd4741a6a676770e0e97fe9ab2e51de01183df3dcbcec591d26d331a40de950", size = 6543618, upload-time = "2026-03-09T07:57:50.591Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b8/612ce010c0728b1c363fa4ea3aa4c22fe1c5da1de008486f8c2f5cb92fae/numpy-2.4.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54f29b877279d51e210e0c80709ee14ccbbad647810e8f3d375561c45ef613dd", size = 15680676, upload-time = "2026-03-09T07:57:52.34Z" }, + { url = "https://files.pythonhosted.org/packages/a9/7e/4f120ecc54ba26ddf3dc348eeb9eb063f421de65c05fc961941798feea18/numpy-2.4.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:679f2a834bae9020f81534671c56fd0cc76dd7e5182f57131478e23d0dc59e24", size = 16613492, upload-time = "2026-03-09T07:57:54.91Z" }, + { url = "https://files.pythonhosted.org/packages/2c/86/1b6020db73be330c4b45d5c6ee4295d59cfeef0e3ea323959d053e5a6909/numpy-2.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d84f0f881cb2225c2dfd7f78a10a5645d487a496c6668d6cc39f0f114164f3d0", size = 17031789, upload-time = "2026-03-09T07:57:57.641Z" }, + { url = "https://files.pythonhosted.org/packages/07/3a/3b90463bf41ebc21d1b7e06079f03070334374208c0f9a1f05e4ae8455e7/numpy-2.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d213c7e6e8d211888cc359bab7199670a00f5b82c0978b9d1c75baf1eddbeac0", size = 18339941, upload-time = "2026-03-09T07:58:00.577Z" }, + { url = "https://files.pythonhosted.org/packages/a8/74/6d736c4cd962259fd8bae9be27363eb4883a2f9069763747347544c2a487/numpy-2.4.3-cp314-cp314-win32.whl", hash = "sha256:52077feedeff7c76ed7c9f1a0428558e50825347b7545bbb8523da2cd55c547a", size = 6007503, upload-time = "2026-03-09T07:58:03.331Z" }, + { url = "https://files.pythonhosted.org/packages/48/39/c56ef87af669364356bb011922ef0734fc49dad51964568634c72a009488/numpy-2.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:0448e7f9caefb34b4b7dd2b77f21e8906e5d6f0365ad525f9f4f530b13df2afc", size = 12444915, upload-time = "2026-03-09T07:58:06.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1f/ab8528e38d295fd349310807496fabb7cf9fe2e1f70b97bc20a483ea9d4a/numpy-2.4.3-cp314-cp314-win_arm64.whl", hash = "sha256:b44fd60341c4d9783039598efadd03617fa28d041fc37d22b62d08f2027fa0e7", size = 10494875, upload-time = "2026-03-09T07:58:08.734Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ef/b7c35e4d5ef141b836658ab21a66d1a573e15b335b1d111d31f26c8ef80f/numpy-2.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0a195f4216be9305a73c0e91c9b026a35f2161237cf1c6de9b681637772ea657", size = 14822225, upload-time = "2026-03-09T07:58:11.034Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8d/7730fa9278cf6648639946cc816e7cc89f0d891602584697923375f801ed/numpy-2.4.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:cd32fbacb9fd1bf041bf8e89e4576b6f00b895f06d00914820ae06a616bdfef7", size = 5328769, upload-time = "2026-03-09T07:58:13.67Z" }, + { url = "https://files.pythonhosted.org/packages/47/01/d2a137317c958b074d338807c1b6a383406cdf8b8e53b075d804cc3d211d/numpy-2.4.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:2e03c05abaee1f672e9d67bc858f300b5ccba1c21397211e8d77d98350972093", size = 6649461, upload-time = "2026-03-09T07:58:15.912Z" }, + { url = "https://files.pythonhosted.org/packages/5c/34/812ce12bc0f00272a4b0ec0d713cd237cb390666eb6206323d1cc9cedbb2/numpy-2.4.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d1ce23cce91fcea443320a9d0ece9b9305d4368875bab09538f7a5b4131938a", size = 15725809, upload-time = "2026-03-09T07:58:17.787Z" }, + { url = "https://files.pythonhosted.org/packages/25/c0/2aed473a4823e905e765fee3dc2cbf504bd3e68ccb1150fbdabd5c39f527/numpy-2.4.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c59020932feb24ed49ffd03704fbab89f22aa9c0d4b180ff45542fe8918f5611", size = 16655242, upload-time = "2026-03-09T07:58:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/f2/c8/7e052b2fc87aa0e86de23f20e2c42bd261c624748aa8efd2c78f7bb8d8c6/numpy-2.4.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9684823a78a6cd6ad7511fc5e25b07947d1d5b5e2812c93fe99d7d4195130720", size = 17080660, upload-time = "2026-03-09T07:58:23.067Z" }, + { url = "https://files.pythonhosted.org/packages/f3/3d/0876746044db2adcb11549f214d104f2e1be00f07a67edbb4e2812094847/numpy-2.4.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0200b25c687033316fb39f0ff4e3e690e8957a2c3c8d22499891ec58c37a3eb5", size = 18380384, upload-time = "2026-03-09T07:58:25.839Z" }, + { url = "https://files.pythonhosted.org/packages/07/12/8160bea39da3335737b10308df4f484235fd297f556745f13092aa039d3b/numpy-2.4.3-cp314-cp314t-win32.whl", hash = "sha256:5e10da9e93247e554bb1d22f8edc51847ddd7dde52d85ce31024c1b4312bfba0", size = 6154547, upload-time = "2026-03-09T07:58:28.289Z" }, + { url = "https://files.pythonhosted.org/packages/42/f3/76534f61f80d74cc9cdf2e570d3d4eeb92c2280a27c39b0aaf471eda7b48/numpy-2.4.3-cp314-cp314t-win_amd64.whl", hash = "sha256:45f003dbdffb997a03da2d1d0cb41fbd24a87507fb41605c0420a3db5bd4667b", size = 12633645, upload-time = "2026-03-09T07:58:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/1f/b6/7c0d4334c15983cec7f92a69e8ce9b1e6f31857e5ee3a413ac424e6bd63d/numpy-2.4.3-cp314-cp314t-win_arm64.whl", hash = "sha256:4d382735cecd7bcf090172489a525cd7d4087bc331f7df9f60ddc9a296cf208e", size = 10565454, upload-time = "2026-03-09T07:58:33.031Z" }, +] + [[package]] name = "openpyxl" version = "3.1.5" @@ -287,6 +360,64 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] +[[package]] +name = "pandas" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/99/b342345300f13440fe9fe385c3c481e2d9a595ee3bab4d3219247ac94e9a/pandas-3.0.2.tar.gz", hash = "sha256:f4753e73e34c8d83221ba58f232433fca2748be8b18dbca02d242ed153945043", size = 4645855, upload-time = "2026-03-31T06:48:30.816Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/40/c6ea527147c73b24fc15c891c3fcffe9c019793119c5742b8784a062c7db/pandas-3.0.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:db0dbfd2a6cdf3770aa60464d50333d8f3d9165b2f2671bcc299b72de5a6677b", size = 10326084, upload-time = "2026-03-31T06:47:43.834Z" }, + { url = "https://files.pythonhosted.org/packages/95/25/bdb9326c3b5455f8d4d3549fce7abcf967259de146fe2cf7a82368141948/pandas-3.0.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0555c5882688a39317179ab4a0ed41d3ebc8812ab14c69364bbee8fb7a3f6288", size = 9914146, upload-time = "2026-03-31T06:47:46.67Z" }, + { url = "https://files.pythonhosted.org/packages/8d/77/3a227ff3337aa376c60d288e1d61c5d097131d0ac71f954d90a8f369e422/pandas-3.0.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:01f31a546acd5574ef77fe199bc90b55527c225c20ccda6601cf6b0fd5ed597c", size = 10444081, upload-time = "2026-03-31T06:47:49.681Z" }, + { url = "https://files.pythonhosted.org/packages/15/88/3cdd54fa279341afa10acf8d2b503556b1375245dccc9315659f795dd2e9/pandas-3.0.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:deeca1b5a931fdf0c2212c8a659ade6d3b1edc21f0914ce71ef24456ca7a6535", size = 10897535, upload-time = "2026-03-31T06:47:53.033Z" }, + { url = "https://files.pythonhosted.org/packages/06/9d/98cc7a7624f7932e40f434299260e2917b090a579d75937cb8a57b9d2de3/pandas-3.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f48afd9bb13300ffb5a3316973324c787054ba6665cda0da3fbd67f451995db", size = 11446992, upload-time = "2026-03-31T06:47:56.193Z" }, + { url = "https://files.pythonhosted.org/packages/9a/cd/19ff605cc3760e80602e6826ddef2824d8e7050ed80f2e11c4b079741dc3/pandas-3.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6c4d8458b97a35717b62469a4ea0e85abd5ed8687277f5ccfc67f8a5126f8c53", size = 11968257, upload-time = "2026-03-31T06:47:59.137Z" }, + { url = "https://files.pythonhosted.org/packages/db/60/aba6a38de456e7341285102bede27514795c1eaa353bc0e7638b6b785356/pandas-3.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:b35d14bb5d8285d9494fe93815a9e9307c0876e10f1e8e89ac5b88f728ec8dcf", size = 9865893, upload-time = "2026-03-31T06:48:02.038Z" }, + { url = "https://files.pythonhosted.org/packages/08/71/e5ec979dd2e8a093dacb8864598c0ff59a0cee0bbcdc0bfec16a51684d4f/pandas-3.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:63d141b56ef686f7f0d714cfb8de4e320475b86bf4b620aa0b7da89af8cbdbbb", size = 9188644, upload-time = "2026-03-31T06:48:05.045Z" }, + { url = "https://files.pythonhosted.org/packages/f1/6c/7b45d85db19cae1eb524f2418ceaa9d85965dcf7b764ed151386b7c540f0/pandas-3.0.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:140f0cffb1fa2524e874dde5b477d9defe10780d8e9e220d259b2c0874c89d9d", size = 10776246, upload-time = "2026-03-31T06:48:07.789Z" }, + { url = "https://files.pythonhosted.org/packages/a8/3e/7b00648b086c106e81766f25322b48aa8dfa95b55e621dbdf2fdd413a117/pandas-3.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae37e833ff4fed0ba352f6bdd8b73ba3ab3256a85e54edfd1ab51ae40cca0af8", size = 10424801, upload-time = "2026-03-31T06:48:10.897Z" }, + { url = "https://files.pythonhosted.org/packages/da/6e/558dd09a71b53b4008e7fc8a98ec6d447e9bfb63cdaeea10e5eb9b2dabe8/pandas-3.0.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d888a5c678a419a5bb41a2a93818e8ed9fd3172246555c0b37b7cc27027effd", size = 10345643, upload-time = "2026-03-31T06:48:13.7Z" }, + { url = "https://files.pythonhosted.org/packages/be/e3/921c93b4d9a280409451dc8d07b062b503bbec0531d2627e73a756e99a82/pandas-3.0.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b444dc64c079e84df91baa8bf613d58405645461cabca929d9178f2cd392398d", size = 10743641, upload-time = "2026-03-31T06:48:16.659Z" }, + { url = "https://files.pythonhosted.org/packages/56/ca/fd17286f24fa3b4d067965d8d5d7e14fe557dd4f979a0b068ac0deaf8228/pandas-3.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4544c7a54920de8eeacaa1466a6b7268ecfbc9bc64ab4dbb89c6bbe94d5e0660", size = 11361993, upload-time = "2026-03-31T06:48:19.475Z" }, + { url = "https://files.pythonhosted.org/packages/e4/a5/2f6ed612056819de445a433ca1f2821ac3dab7f150d569a59e9cc105de1d/pandas-3.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:734be7551687c00fbd760dc0522ed974f82ad230d4a10f54bf51b80d44a08702", size = 11815274, upload-time = "2026-03-31T06:48:22.695Z" }, + { url = "https://files.pythonhosted.org/packages/00/2f/b622683e99ec3ce00b0854bac9e80868592c5b051733f2cf3a868e5fea26/pandas-3.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:57a07209bebcbcf768d2d13c9b78b852f9a15978dac41b9e6421a81ad4cdd276", size = 10888530, upload-time = "2026-03-31T06:48:25.806Z" }, + { url = "https://files.pythonhosted.org/packages/cb/2b/f8434233fab2bd66a02ec014febe4e5adced20e2693e0e90a07d118ed30e/pandas-3.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:5371b72c2d4d415d08765f32d689217a43227484e81b2305b52076e328f6f482", size = 9455341, upload-time = "2026-03-31T06:48:28.418Z" }, +] + +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, +] + [[package]] name = "playwright" version = "1.63.0" @@ -336,6 +467,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, ] +[[package]] +name = "pyparsing" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/11/b213bebff182584360cb8d17c72c1677fec5c5c228de439e63bcf8ab1c8f/pyparsing-3.3.3.tar.gz", hash = "sha256:928ae7e20211f3b6f3915a72f06a0cfd29ab9d24279dd6346b6b1a7146397d36", size = 1050487, upload-time = "2026-09-20T20:59:05.609Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl", hash = "sha256:ece8c00a69cf01b45d0b1dedabb469c90d8caf996d4fda40f147627a122849a4", size = 126420, upload-time = "2026-09-20T20:59:04.025Z" }, +] + [[package]] name = "pytest" version = "9.1.1" @@ -380,6 +520,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ab/70/72ff5c3833f0faa9b5fb23f24c0a606f070ad33bb61b802ba38dfe125e7d/pytest_playwright-0.9.0-py3-none-any.whl", hash = "sha256:9d9dc74e335c647944cecfffa706cc9e6e4bf4c25e84592c128b584d494d4471", size = 17915, upload-time = "2026-08-10T10:20:18.253Z" }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + [[package]] name = "python-slugify" version = "8.0.4" @@ -407,6 +559,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + [[package]] name = "text-unidecode" version = "1.3" @@ -425,6 +586,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] +[[package]] +name = "tzdata" +version = "2026.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/68/f1b440335057bfce71b6e50a9d09445aa2ecbd08359a337976627b8409e7/tzdata-2026.5.tar.gz", hash = "sha256:8cc73c0a0bfca7dbfa59235d60b2eff82231dee33f53d206db1acd9173cfc0a7", size = 200404, upload-time = "2026-10-03T09:23:14.143Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/21/1e5995a1c920cce14e4bffae20c665ec10e7ed03ab25e006cd741092b718/tzdata-2026.5-py2.py3-none-any.whl", hash = "sha256:b683bd1b6659ddcd810ff02ad09ba821d4bf1065072805063eb35c49617905ac", size = 347996, upload-time = "2026-10-03T09:23:12.535Z" }, +] + [[package]] name = "urllib3" version = "2.8.0" From 30aee0d91170e6e08663d5dd68aa1a991ab5d1b8 Mon Sep 17 00:00:00 2001 From: mioupa Date: Wed, 7 Oct 2026 17:51:33 +0900 Subject: [PATCH 08/37] Log suppressed parentNode errors via console.warn Suppression is unchanged. Each suppression point (JS error/unhandledrejection/ console.error handlers and the Python except blocks) now emits console.warn("[mplgui] suppressed parentNode error:", where, message). Co-Authored-By: Claude Opus 5.5 --- index.html | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/index.html b/index.html index 72e6fd6..79161ea 100644 --- a/index.html +++ b/index.html @@ -868,10 +868,17 @@

Matplotlib GUI

-
@@ -866,2096 +284,6 @@

Matplotlib GUI

- - - + diff --git a/js/bridge.js b/js/bridge.js new file mode 100644 index 0000000..ff8cf21 --- /dev/null +++ b/js/bridge.js @@ -0,0 +1,52 @@ +// Python 側(PyScript)との橋渡し。Python は window 上の関数を直接呼ぶため window に公開する。 +window.__matplotDataReady = false; +window.setMatplotDataReady = (ready) => { + window.__matplotDataReady = !!ready; +}; +window.downloadDataUri = (filename, dataUri) => { + if (!filename || !dataUri) return; + const link = document.createElement("a"); + link.href = dataUri; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); +}; + +let renderTimer = null; +let loadTimer = null; + + +export const hasSelectedFile = () => { + const fileInput = document.getElementById("fileInput"); + return !!(fileInput && fileInput.files && fileInput.files.length > 0); +}; +export const canAutoRender = () => !!window.__matplotDataReady; +export const runPyAction = (name) => { + const fn = window[name]; + if (typeof fn === "function") { + fn(); + return true; + } + return false; +}; + +export const scheduleRender = (delay = 250) => { + if (renderTimer) window.clearTimeout(renderTimer); + renderTimer = window.setTimeout(() => { + renderTimer = null; + if (canAutoRender()) { + runPyAction("requestRender"); + } + }, delay); +}; + +export const scheduleLoad = (delay = 350) => { + if (!hasSelectedFile()) return; + if (loadTimer) window.clearTimeout(loadTimer); + loadTimer = window.setTimeout(() => { + loadTimer = null; + window.setMatplotDataReady(false); + runPyAction("requestLoadColumns"); + }, delay); +}; diff --git a/js/main.js b/js/main.js new file mode 100644 index 0000000..a53f771 --- /dev/null +++ b/js/main.js @@ -0,0 +1,75 @@ +// エントリ。DOM 構築後(module は defer)に UI を初期化する。 +// notify.js は parentNode 系例外のハンドラを登録するため、必ず最初に import する。 +import "./ui/notify.js"; +import { scheduleLoad, scheduleRender } from "./bridge.js"; +import { bindColorPanelOutsideClick } from "./ui/colorPicker.js"; +import { bindCustomCodeEvents } from "./ui/customCode.js"; +import { syncSaveFormatOptions, bindSaveFormatEvents } from "./ui/saveFormat.js"; +import { + bindSeriesEvents, + syncMarkerSizeForPlotType, + syncPlotTypeUI, + syncY2LabelUI, +} from "./ui/series.js"; +// series.js は window.ensureSeriesItems / window.refreshSeriesColumnOptions を公開する。 +// bridge.js は window.setMatplotDataReady / window.downloadDataUri / window.__matplotDataReady を公開する。 +// Python 側が登録する window.requestRender / window.requestLoadColumns は bridge.js から呼ぶ。 + +const fileInput = document.getElementById("fileInput"); +const delimiterInput = document.getElementById("delimiter"); +const hasHeaderInput = document.getElementById("hasHeader"); + +if (fileInput) { + fileInput.addEventListener("change", () => scheduleLoad(0)); +} +if (delimiterInput) { + delimiterInput.addEventListener("input", () => scheduleLoad(450)); +} +if (hasHeaderInput) { + hasHeaderInput.addEventListener("change", () => scheduleLoad(0)); +} + +bindSeriesEvents(); +bindSaveFormatEvents(); +bindCustomCodeEvents(); +bindColorPanelOutsideClick(); + +const autoRenderIds = [ + "xColumn", + "title", + "xLabel", + "yLabel", + "y2Label", + "xScale", + "yScale", + "xMin", + "xMax", + "yMin", + "yMax", + "y2Scale", + "y2Min", + "y2Max", + "fontSize", + "showMajorGrid", + "showMinorGrid", + "legendLocation", + "figWidth", + "figHeight", + "subplotLeft", + "subplotRight", + "subplotBottom", + "subplotTop", + "skipRows", +]; +for (const id of autoRenderIds) { + const el = document.getElementById(id); + if (!el) continue; + el.addEventListener("input", () => scheduleRender()); + el.addEventListener("change", () => scheduleRender()); +} + +window.ensureSeriesItems(); +syncPlotTypeUI(); +syncMarkerSizeForPlotType(); +syncY2LabelUI(); +syncSaveFormatOptions(); diff --git a/js/ui/colorPicker.js b/js/ui/colorPicker.js new file mode 100644 index 0000000..b43adc8 --- /dev/null +++ b/js/ui/colorPicker.js @@ -0,0 +1,105 @@ +// 系列ごとの色選択UI(オーバーレイパネル)。 +import { scheduleRender } from "../bridge.js"; + +const seriesList = document.getElementById("seriesList"); + +export const SERIES_COLORS = [ + "#FF4B00", + "#005AFF", + "#03AF7A", + "#4DC4FF", + "#F6AA00", + "#FFF100", + "#990099", + "#84919E", + "#000000", + "#804000", + "#FF8082", +]; + +export const bindSeriesColorControls = (item) => { + const trigger = item.querySelector(".series-color-trigger"); + const panel = item.querySelector(".series-color-panel"); + const grid = item.querySelector(".series-color-grid"); + const customToggle = item.querySelector(".series-color-custom-toggle"); + const colorInput = item.querySelector(".series-color"); + const swatch = item.querySelector(".series-color-swatch-inline"); + const valueLabel = item.querySelector(".series-color-value"); + if (!trigger || !panel || !grid || !customToggle || !colorInput || !swatch || !valueLabel) return; + + const closeAllColorPanels = (exceptPanel = null) => { + if (!seriesList) return; + const panels = seriesList.querySelectorAll(".series-color-panel"); + for (const p of panels) { + if (exceptPanel && p === exceptPanel) continue; + p.classList.add("hidden"); + } + }; + + const normalizeColor = (value) => String(value || "").trim().toUpperCase(); + + const syncSeriesColorView = () => { + const current = normalizeColor(colorInput.value); + swatch.style.backgroundColor = current || "#000000"; + valueLabel.textContent = current || "#000000"; + + const isPaletteColor = SERIES_COLORS.some((c) => normalizeColor(c) === current); + colorInput.classList.toggle("hidden", isPaletteColor); + const chips = grid.querySelectorAll(".series-color-chip"); + for (const chip of chips) { + const chipColor = normalizeColor(chip.dataset.color); + chip.classList.toggle("active", chipColor === current); + } + }; + + grid.innerHTML = ""; + for (const color of SERIES_COLORS) { + const chip = document.createElement("button"); + chip.type = "button"; + chip.className = "series-color-chip"; + chip.dataset.color = color; + chip.style.backgroundColor = color; + chip.title = color; + chip.addEventListener("click", () => { + colorInput.value = color; + syncSeriesColorView(); + panel.classList.add("hidden"); + scheduleRender(); + }); + grid.appendChild(chip); + } + + trigger.addEventListener("click", () => { + const willOpen = panel.classList.contains("hidden"); + closeAllColorPanels(panel); + panel.classList.toggle("hidden", !willOpen); + }); + + customToggle.addEventListener("click", () => { + colorInput.classList.remove("hidden"); + if (typeof colorInput.showPicker === "function") { + colorInput.showPicker(); + } else { + colorInput.click(); + } + }); + + colorInput.addEventListener("input", () => { + syncSeriesColorView(); + }); + + syncSeriesColorView(); +}; + +// パネル外クリックでオーバーレイを閉じる +export const bindColorPanelOutsideClick = () => { + document.addEventListener("click", (event) => { + if (!seriesList) return; + const target = event.target; + if (target && target.closest(".series-color-picker")) return; + const panels = seriesList.querySelectorAll(".series-color-panel"); + for (const panel of panels) { + panel.classList.add("hidden"); + } + }); +}; diff --git a/js/ui/customCode.js b/js/ui/customCode.js new file mode 100644 index 0000000..7ba9228 --- /dev/null +++ b/js/ui/customCode.js @@ -0,0 +1,41 @@ +// Pythonコード(beta)タブのボタン・ショートカット。 +import { canAutoRender, runPyAction, scheduleRender } from "../bridge.js"; + +const customCodeInput = document.getElementById("customPyCode"); +const useCustomCodeInput = document.getElementById("useCustomCode"); +const applyCustomCodeBtn = document.getElementById("applyCustomCodeBtn"); +const resetCustomCodeBtn = document.getElementById("resetCustomCodeBtn"); + +export const bindCustomCodeEvents = () => { + if (useCustomCodeInput) { + useCustomCodeInput.addEventListener("change", () => scheduleRender(0)); + } + if (applyCustomCodeBtn) { + applyCustomCodeBtn.addEventListener("click", () => { + if (canAutoRender()) { + runPyAction("requestRender"); + } + }); + } + if (resetCustomCodeBtn && customCodeInput) { + resetCustomCodeBtn.addEventListener("click", () => { + const defaultCode = customCodeInput.dataset.defaultCode || ""; + customCodeInput.value = defaultCode; + if (useCustomCodeInput) { + useCustomCodeInput.checked = false; + } + if (canAutoRender()) { + runPyAction("requestRender"); + } + }); + } + if (customCodeInput) { + customCodeInput.addEventListener("keydown", (event) => { + if (!(event.ctrlKey || event.metaKey) || event.key !== "Enter") return; + event.preventDefault(); + if (canAutoRender()) { + runPyAction("requestRender"); + } + }); + } +}; diff --git a/js/ui/notify.js b/js/ui/notify.js new file mode 100644 index 0000000..d4b8b35 --- /dev/null +++ b/js/ui/notify.js @@ -0,0 +1,96 @@ +// ステータス表示と、parentNode 系例外の抑制・回復ハンドラ。 +// 他のモジュールより先に評価されるよう、main.js の先頭で import する。 +const __warn = console.warn.bind(console); +const isTargetError = (msg) => + typeof msg === "string" && + (msg.includes("parentNode") || msg.includes("'NoneType' object has no attribute 'parentNode'")); + +const logSuppressed = (where, msg, stack = "") => { + try { + __warn("[mplgui] suppressed parentNode error:", where, String(msg)); + } catch (_) {} +}; + +const buildJsErrorDetail = (source, msg, stack = "", extras = []) => { + const lines = [`source: ${source}`]; + if (msg) lines.push(`message: ${msg}`); + for (const extra of extras) { + if (extra) lines.push(extra); + } + const normalizedStack = typeof stack === "string" ? stack.trim() : ""; + if (normalizedStack) { + lines.push(""); + lines.push("stack:"); + lines.push(normalizedStack); + } + return lines.join("\n"); +}; + +export const setStatus = (message, isError = false, detail = "") => { + const status = document.getElementById("status"); + if (status) { + status.textContent = message || ""; + status.style.color = isError ? "var(--status-error)" : "var(--status-ok)"; + } + + const detailWrap = document.getElementById("statusDetailWrap"); + const detailEl = document.getElementById("statusDetail"); + if (!detailWrap || !detailEl) return; + const normalized = typeof detail === "string" ? detail.trim() : ""; + if (normalized) { + detailEl.textContent = normalized; + detailWrap.classList.remove("hidden"); + detailWrap.open = true; + } else { + detailEl.textContent = ""; + detailWrap.classList.add("hidden"); + detailWrap.open = false; + } +}; + +export const notify = (detail = "") => { + const plotArea = document.getElementById("plotArea"); + const hasPlot = !!(plotArea && plotArea.querySelector("img")); + if (hasPlot) { + setStatus("描画に成功しました。"); + } else { + setStatus("内部エラーを自動回避しました。もう一度操作してください。", true, detail); + } +}; + +window.addEventListener("error", (event) => { + const msg = event?.message || ""; + if (isTargetError(msg)) { + event.preventDefault(); + const stack = event?.error?.stack || ""; + logSuppressed("window.error", msg, stack); + const extras = [ + event?.filename ? `file: ${event.filename}` : "", + event?.lineno ? `line: ${event.lineno}` : "", + event?.colno ? `column: ${event.colno}` : "", + ]; + notify(buildJsErrorDetail("window.error", msg, stack, extras)); + } +}); + +window.addEventListener("unhandledrejection", (event) => { + const reason = event?.reason; + const msg = (reason && (reason.message || String(reason))) || ""; + if (isTargetError(msg)) { + event.preventDefault(); + const stack = reason?.stack || ""; + logSuppressed("window.unhandledrejection", msg, stack); + notify(buildJsErrorDetail("window.unhandledrejection", msg, stack)); + } +}); + +const originalConsoleError = console.error.bind(console); +console.error = (...args) => { + const msg = args.map((x) => String(x)).join(" "); + if (isTargetError(msg)) { + logSuppressed("console.error", msg, new Error().stack || ""); + notify(buildJsErrorDetail("console.error", msg)); + return; + } + originalConsoleError(...args); +}; diff --git a/js/ui/saveFormat.js b/js/ui/saveFormat.js new file mode 100644 index 0000000..b53602d --- /dev/null +++ b/js/ui/saveFormat.js @@ -0,0 +1,34 @@ +// 保存形式セレクトの選択肢(背景透過時は png / svg のみ)。 +const saveFormatSelect = document.getElementById("saveFormat"); +const saveTransparentInput = document.getElementById("saveTransparent"); + +const SAVE_FORMATS_ALL = ["png", "jpg", "svg", "pdf"]; +const SAVE_FORMATS_TRANSPARENT = ["png", "svg"]; +export const syncSaveFormatOptions = () => { + if (!saveFormatSelect) return; + const allowTransparentOnly = !!(saveTransparentInput && saveTransparentInput.checked); + const formats = allowTransparentOnly ? SAVE_FORMATS_TRANSPARENT : SAVE_FORMATS_ALL; + const currentValue = (saveFormatSelect.value || "").toLowerCase(); + + saveFormatSelect.innerHTML = ""; + for (const fmt of formats) { + const opt = document.createElement("option"); + opt.value = fmt; + opt.textContent = fmt; + saveFormatSelect.appendChild(opt); + } + + if (formats.includes(currentValue)) { + saveFormatSelect.value = currentValue; + } else { + saveFormatSelect.value = formats[0]; + } +}; + +export const bindSaveFormatEvents = () => { + if (saveTransparentInput) { + saveTransparentInput.addEventListener("change", () => { + syncSaveFormatOptions(); + }); + } +}; diff --git a/js/ui/series.js b/js/ui/series.js new file mode 100644 index 0000000..c7f0157 --- /dev/null +++ b/js/ui/series.js @@ -0,0 +1,280 @@ +// 系列カード(追加・削除・列セレクト・種別/第2軸に応じた表示切替)。 +import { scheduleRender } from "../bridge.js"; +import { SERIES_COLORS, bindSeriesColorControls } from "./colorPicker.js"; + +const seriesList = document.getElementById("seriesList"); +const addSeriesBtn = document.getElementById("addSeriesBtn"); +const plotTypeSelect = document.getElementById("plotType"); +const globalXGroup = document.getElementById("globalXGroup"); +const xColumnSelect = document.getElementById("xColumn"); +const y2LabelGroup = document.getElementById("y2LabelGroup"); +const y2AxisSettingsGroup = document.getElementById("y2AxisSettingsGroup"); +let lastPlotType = plotTypeSelect ? plotTypeSelect.value : "line"; + +const getColumnOptions = (includeIndex = false) => { + const xColumn = document.getElementById("xColumn"); + if (!xColumn) return []; + const options = []; + for (const opt of xColumn.options) { + if (!includeIndex && !opt.value) continue; + options.push({ value: opt.value, label: opt.textContent || opt.value }); + } + return options; +}; + +const fillSeriesYSelect = (selectEl, selectedValue = "") => { + if (!selectEl) return; + const columns = getColumnOptions(false); + selectEl.innerHTML = ""; + + const autoOption = document.createElement("option"); + autoOption.value = ""; + autoOption.textContent = "(自動)"; + selectEl.appendChild(autoOption); + + for (const col of columns) { + const opt = document.createElement("option"); + opt.value = col.value; + opt.textContent = col.label; + selectEl.appendChild(opt); + } + + if (selectedValue && Array.from(selectEl.options).some((opt) => opt.value === selectedValue)) { + selectEl.value = selectedValue; + } +}; + +const fillSeriesXSelect = (selectEl, selectedValue = "") => { + if (!selectEl) return; + const columns = getColumnOptions(true); + selectEl.innerHTML = ""; + for (const col of columns) { + const opt = document.createElement("option"); + opt.value = col.value; + opt.textContent = col.label; + selectEl.appendChild(opt); + } + if (Array.from(selectEl.options).some((opt) => opt.value === selectedValue)) { + selectEl.value = selectedValue; + } else { + selectEl.value = ""; + } +}; + +export const syncPlotTypeUI = () => { + const type = plotTypeSelect ? plotTypeSelect.value : "line"; + const usePerSeriesX = type === "line" || type === "scatter"; + if (globalXGroup) { + globalXGroup.style.display = usePerSeriesX ? "none" : ""; + } + if (!seriesList) return; + const xGroups = seriesList.querySelectorAll(".series-x-wrap"); + for (const group of xGroups) { + group.style.display = usePerSeriesX ? "block" : "none"; + } +}; + +export const syncMarkerSizeForPlotType = () => { + if (!seriesList || !plotTypeSelect) return; + const currentType = plotTypeSelect.value; + const markerInputs = seriesList.querySelectorAll(".series-marker-size"); + if (currentType === "scatter") { + for (const input of markerInputs) { + const current = Number(input.value); + if (!Number.isFinite(current) || current <= 0) { + input.value = "24"; + } + } + } else if (currentType === "line" && lastPlotType === "scatter") { + for (const input of markerInputs) { + const current = Number(input.value); + if (Number.isFinite(current) && current === 24) { + input.value = "0"; + } + } + } + lastPlotType = currentType; +}; + +export const syncY2LabelUI = () => { + if (!seriesList) return; + const hasSecondary = !!seriesList.querySelector(".series-use-y2:checked"); + if (y2LabelGroup) { + y2LabelGroup.style.display = hasSecondary ? "" : "none"; + } + if (y2AxisSettingsGroup) { + y2AxisSettingsGroup.style.display = hasSecondary ? "" : "none"; + } +}; + +const renumberSeries = () => { + if (!seriesList) return; + const items = Array.from(seriesList.querySelectorAll(".series-item")); + items.forEach((item, idx) => { + const title = item.querySelector(".series-item-title"); + if (title) title.textContent = `系列 ${idx + 1}`; + }); + const disableRemove = items.length <= 1; + items.forEach((item) => { + const removeBtn = item.querySelector(".remove-series"); + if (removeBtn) { + removeBtn.disabled = disableRemove; + removeBtn.style.opacity = disableRemove ? "0.5" : "1"; + removeBtn.style.cursor = disableRemove ? "not-allowed" : "pointer"; + } + }); +}; + +const addSeriesItem = (preset = {}) => { + if (!seriesList) return; + const item = document.createElement("div"); + item.className = "series-item"; + const color = preset.color || SERIES_COLORS[seriesList.children.length % SERIES_COLORS.length]; + const defaultMarkerSize = Object.prototype.hasOwnProperty.call(preset, "markerSize") + ? preset.markerSize + : plotTypeSelect && plotTypeSelect.value === "line" + ? 0 + : 24; + item.innerHTML = ` +
+
系列
+ +
+
+
+ + +
+
+ + +
+
+
+
+ +
+ + +
+
${color}
+
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+ `; + + seriesList.appendChild(item); + const ySelect = item.querySelector(".series-y"); + fillSeriesYSelect(ySelect, preset.yRequest || ""); + const xSelect = item.querySelector(".series-x"); + fillSeriesXSelect(xSelect, preset.xRequest || (xColumnSelect ? xColumnSelect.value : "")); + bindSeriesColorControls(item); + + const lineStyleSelect = item.querySelector(".series-line-style"); + if (lineStyleSelect) { + lineStyleSelect.value = preset.lineStyle || "solid"; + } + + const removeBtn = item.querySelector(".remove-series"); + if (removeBtn) { + removeBtn.addEventListener("click", () => { + if (seriesList.children.length <= 1) return; + item.remove(); + renumberSeries(); + syncY2LabelUI(); + scheduleRender(); + }); + } + + syncPlotTypeUI(); + syncY2LabelUI(); + renumberSeries(); +}; + +window.refreshSeriesColumnOptions = () => { + if (!seriesList) return; + const ySelects = seriesList.querySelectorAll(".series-y"); + for (const selectEl of ySelects) { + fillSeriesYSelect(selectEl, selectEl.value); + } + const xSelects = seriesList.querySelectorAll(".series-x"); + for (const selectEl of xSelects) { + fillSeriesXSelect(selectEl, selectEl.value); + } +}; + +window.ensureSeriesItems = () => { + if (!seriesList) return; + if (seriesList.children.length === 0) { + addSeriesItem(); + } else { + renumberSeries(); + } + syncPlotTypeUI(); + syncY2LabelUI(); +}; + +export const bindSeriesEvents = () => { + if (addSeriesBtn) { + addSeriesBtn.addEventListener("click", () => { + addSeriesItem(); + scheduleRender(); + }); + } + if (plotTypeSelect) { + plotTypeSelect.addEventListener("change", () => { + syncPlotTypeUI(); + syncMarkerSizeForPlotType(); + scheduleRender(); + }); + } + if (seriesList) { + const onSeriesInput = (event) => { + const target = event.target; + if (!target) return; + if (target.matches("input, select")) { + if (target.matches(".series-use-y2")) { + syncY2LabelUI(); + } + scheduleRender(); + } + }; + seriesList.addEventListener("input", onSeriesInput); + seriesList.addEventListener("change", onSeriesInput); + } +}; diff --git a/py/main.py b/py/main.py new file mode 100644 index 0000000..0c157ef --- /dev/null +++ b/py/main.py @@ -0,0 +1,1342 @@ +import io +import base64 +import asyncio +import traceback +import warnings +from pathlib import Path + +import matplotlib.pyplot as plt +warnings.simplefilter("ignore", DeprecationWarning) +warnings.filterwarnings( + "ignore", + message=".*Pyarrow will become a required dependency of pandas.*", + category=DeprecationWarning, +) +warnings.filterwarnings( + "ignore", + message=".*missing from current font.*", + category=UserWarning, +) +import pandas as pd +from js import Uint8Array, document, window, fetch +from pyodide.ffi import create_proxy + +from mplgui.fonts import configure_rcparams, register_font_file +from mplgui.loader import load_dataframe +from mplgui.runner import build_filename, figure_to_data_uri + +PREVIEW_ROWS = 100 +CURRENT_DF = None +_EVENT_PROXIES = [] +JP_FONT_URL = "https://cdn.jsdelivr.net/gh/googlefonts/noto-cjk@main/Sans/OTF/Japanese/NotoSansCJKjp-Regular.otf" +JP_FONT_PATH = "/tmp/NotoSansCJKjp-Regular.otf" +_JP_FONT_READY = False +DEFAULT_CUSTOM_PLOT_CODE = """# 利用可能オブジェクト: +# - df: skipRows適用後のDataFrame +# - current_df: 読み込み直後のDataFrame(skipRows未適用) +# - settings: GUI設定(dict) +# - plt, pd +# 必須: +# - fig に Figure を代入 +# 任意: +# - plotted_count (int) を設定(未設定時は自動判定) + +fig, ax = plt.subplots(figsize=(settings["fig_width"], settings["fig_height"]), dpi=100) + +if df.shape[1] < 2: + raise ValueError("2列以上のデータが必要です。") + +x = pd.to_numeric(df.iloc[:, 0], errors="coerce") +y = pd.to_numeric(df.iloc[:, 1], errors="coerce") +mask = x.notna() & y.notna() +if not bool(mask.any()): + raise ValueError("先頭2列に描画可能な数値データがありません。") + +series_settings = settings.get("series_settings", []) +default_legend = "" +if isinstance(series_settings, list) and len(series_settings) > 0: + default_legend = str(series_settings[0].get("legend_name", "") or "").strip() +if not default_legend: + default_legend = str(df.columns[1]) + +ax.plot( + x[mask], + y[mask], + color="#005AFF", + linewidth=2.0, + marker="o", + markersize=4, + label=default_legend, +) +ax.set_title(settings["title"]) +ax.set_xlabel(settings["x_label"] or str(df.columns[0])) +ax.set_ylabel(settings["y_label"] or str(df.columns[1])) +ax.grid(True, alpha=0.3) +ax.legend(loc="best", edgecolor="black") +fig.tight_layout() + +plotted_count = 1 +""" + +configure_rcparams() + + +async def _ensure_japanese_font() -> None: + global _JP_FONT_READY + if _JP_FONT_READY: + return + try: + target = Path(JP_FONT_PATH) + if not target.exists(): + response = await fetch(JP_FONT_URL) + if not bool(response.ok): + return + buffer = await response.arrayBuffer() + data = Uint8Array.new(buffer) + target.write_bytes(bytes(data.to_py())) + + register_font_file(target) + _JP_FONT_READY = True + except Exception: + # フォント取得に失敗しても描画処理は継続する + return + + +def set_status(message: str, is_error: bool = False, detail: str = "") -> None: + status = document.getElementById("status") + if status is not None: + status.textContent = message or "" + status.style.color = "var(--status-error)" if is_error else "var(--status-ok)" + + detail_wrap = document.getElementById("statusDetailWrap") + detail_area = document.getElementById("statusDetail") + if detail_wrap is None or detail_area is None: + return + + detail_text = str(detail or "").strip() + if detail_text: + detail_area.textContent = detail_text + detail_wrap.classList.remove("hidden") + detail_wrap.open = True + else: + detail_area.textContent = "" + detail_wrap.classList.add("hidden") + detail_wrap.open = False + + +def _format_internal_error_detail(exc: Exception, context: str) -> str: + lines = [ + f"context: {context}", + f"type: {type(exc).__name__}", + f"message: {exc}", + ] + trace = traceback.format_exc().strip() + if trace and trace != "NoneType: None": + lines.append("") + lines.append("traceback:") + lines.append(trace) + return "\n".join(lines) + + +def _is_parentnode_error(exc: Exception) -> bool: + text = str(exc) + return ("parentNode" in text) or ("NoneType" in text and "parentNode" in text) + + +def _log_suppressed(where: str, exc) -> None: + try: + from js import console + if False: + console.warn("[mplgui] suppressed parentNode error:", where, str(exc), traceback.format_exc()) + else: + console.warn("[mplgui] suppressed parentNode error:", where, str(exc)) + except Exception: + pass + + +def _safe_close_figure(fig) -> None: + if fig is None: + return + try: + plt.close(fig) + except Exception as exc: + # Pyodide/ブラウザ描画バックエンドの後処理競合は無視する + if _is_parentnode_error(exc): + _log_suppressed("_safe_close_figure", exc) + return + raise + + +def _switch_tab(tab_name: str) -> None: + plot_btn = document.getElementById("tabPlotBtn") + data_btn = document.getElementById("tabDataBtn") + code_btn = document.getElementById("tabCodeBtn") + plot_panel = document.getElementById("plotPanel") + data_panel = document.getElementById("dataPanel") + code_panel = document.getElementById("codePanel") + + selected = tab_name if tab_name in {"plot", "data", "code"} else "plot" + tab_items = [ + ("plot", plot_btn, plot_panel), + ("data", data_btn, data_panel), + ("code", code_btn, code_panel), + ] + for name, btn, panel in tab_items: + if btn is not None: + if name == selected: + btn.classList.add("active") + else: + btn.classList.remove("active") + if panel is not None: + if name == selected: + panel.classList.remove("hidden") + else: + panel.classList.add("hidden") + + +def _spawn_task(coro) -> None: + task = asyncio.create_task(coro) + + def _consume_done(finished_task): + try: + finished_task.result() + except Exception as exc: + if _is_parentnode_error(exc): + _log_suppressed("_spawn_task", exc) + try: + if _has_rendered_plot(): + set_status("描画に成功しました。") + else: + detail = _format_internal_error_detail(exc, "async task callback") + set_status("内部エラーを自動回避しました。もう一度操作してください。", True, detail) + except Exception: + pass + else: + try: + set_status(str(exc), True) + except Exception: + pass + + task.add_done_callback(_consume_done) + + +def _set_data_ready(ready: bool) -> None: + try: + if hasattr(window, "setMatplotDataReady"): + window.setMatplotDataReady(bool(ready)) + except Exception: + pass + + +def _init_custom_code_editor() -> None: + editor = document.getElementById("customPyCode") + if editor is None: + return + default_code = DEFAULT_CUSTOM_PLOT_CODE.strip() + "\n" + editor.dataset.defaultCode = default_code + current = str(editor.value or "").strip() + if not current: + editor.value = default_code + + +def _is_custom_code_enabled() -> bool: + element = document.getElementById("useCustomCode") + return bool(element.checked) if element is not None else False + + +def _get_custom_code_text() -> str: + element = document.getElementById("customPyCode") + if element is None: + return "" + return str(element.value or "") + + +def _get_text_value(element_id: str, fallback: str = "") -> str: + element = document.getElementById(element_id) + if element is None: + return fallback + return str(element.value or "").strip() + + +def _get_optional_float_value(element_id: str, label: str) -> float | None: + element = document.getElementById(element_id) + raw = element.value.strip() if element is not None else "" + if not raw: + return None + try: + return float(raw) + except Exception: + raise ValueError(f"{label}は数値で指定してください。") + + +def _get_subplot_margin_settings() -> dict: + left = _get_optional_float_value("subplotLeft", "余白 左(left)") + right = _get_optional_float_value("subplotRight", "余白 右(right)") + bottom = _get_optional_float_value("subplotBottom", "余白 下(bottom)") + top = _get_optional_float_value("subplotTop", "余白 上(top)") + + for value, label in ( + (left, "余白 左(left)"), + (right, "余白 右(right)"), + (bottom, "余白 下(bottom)"), + (top, "余白 上(top)"), + ): + if value is not None and (value < 0 or value > 1): + raise ValueError(f"{label}は0〜1で指定してください。") + + if left is not None and right is not None and left >= right: + raise ValueError("余白は left < right になるように指定してください。") + if bottom is not None and top is not None and bottom >= top: + raise ValueError("余白は bottom < top になるように指定してください。") + + return { + "left": left, + "right": right, + "bottom": bottom, + "top": top, + } + + +def _apply_subplot_margin_settings(fig, margins: dict, use_tight_layout_if_auto: bool = False) -> None: + provided = { + key: value + for key, value in (margins or {}).items() + if value is not None + } + if provided: + if use_tight_layout_if_auto and len(provided) < 4: + try: + fig.tight_layout() + auto_margins = { + "left": fig.subplotpars.left, + "right": fig.subplotpars.right, + "bottom": fig.subplotpars.bottom, + "top": fig.subplotpars.top, + } + auto_margins.update(provided) + fig.subplots_adjust(**auto_margins) + return + except Exception: + pass + try: + fig.subplots_adjust(**provided) + except Exception as exc: + raise ValueError(f"余白設定の適用に失敗しました: {exc}") + elif use_tight_layout_if_auto: + fig.tight_layout() + + +def _apply_axis_scale_and_limits( + target_ax, + axis_kind: str, + axis_label: str, + scale: str, + min_value: float | None, + max_value: float | None, +) -> None: + if scale not in {"linear", "log"}: + raise ValueError(f"{axis_label}軸スケールが不正です。") + if min_value is not None and max_value is not None and min_value >= max_value: + raise ValueError(f"{axis_label}軸の範囲は最小値 < 最大値で指定してください。") + if scale == "log": + if (min_value is not None and min_value <= 0) or (max_value is not None and max_value <= 0): + raise ValueError(f"{axis_label}軸を対数にする場合、範囲は0より大きい値で指定してください。") + + try: + if axis_kind == "x": + target_ax.set_xscale(scale) + if min_value is not None or max_value is not None: + target_ax.set_xlim(left=min_value, right=max_value) + elif axis_kind == "y": + target_ax.set_yscale(scale) + if min_value is not None or max_value is not None: + target_ax.set_ylim(bottom=min_value, top=max_value) + else: + raise ValueError(f"軸種別が不正です: {axis_kind}") + except Exception as exc: + raise ValueError(f"{axis_label}軸設定の適用に失敗しました: {exc}") + + +def _get_skip_rows_for_preview() -> int: + raw = document.getElementById("skipRows").value.strip() + if not raw: + return 0 + try: + skip_rows = int(raw) + except Exception: + return 0 + return max(skip_rows, 0) + + +def _get_skip_rows() -> int: + raw = document.getElementById("skipRows").value.strip() + if not raw: + return 0 + skip_rows = int(raw) + if skip_rows < 0: + raise ValueError("スキップ行数は0以上で指定してください。") + return skip_rows + + +def _resolve_column(df: pd.DataFrame, requested: str): + if requested.startswith("__idx__"): + idx_text = requested.replace("__idx__", "", 1) + if not idx_text.isdigit(): + raise ValueError(f"列指定が不正です: {requested}") + idx = int(idx_text) + if idx < 0 or idx >= len(df.columns): + raise ValueError(f"列インデックスが範囲外です: {idx}") + return df.columns[idx] + + if requested in df.columns: + return requested + matched = [col for col in df.columns if str(col) == requested] + if len(matched) == 1: + return matched[0] + raise ValueError(f"列が見つかりません: {requested}") + + +def _parse_column_index(requested: str) -> int | None: + if not requested.startswith("__idx__"): + return None + idx_text = requested.replace("__idx__", "", 1) + if not idx_text.isdigit(): + return None + return int(idx_text) + + +def _get_series_from_request(df: pd.DataFrame, requested: str) -> tuple[pd.Series, str, int]: + idx = _parse_column_index(requested) + if idx is not None: + if idx < 0 or idx >= len(df.columns): + raise ValueError(f"列インデックスが範囲外です: {idx}") + return df.iloc[:, idx], str(df.columns[idx]), idx + + resolved = _resolve_column(df, requested) + found_idx = -1 + for i, col in enumerate(df.columns): + if col == resolved: + found_idx = i + break + return df[resolved], str(resolved), found_idx + + +async def _read_upload() -> tuple[bytes, str]: + file_input = document.getElementById("fileInput") + if file_input.files.length == 0: + raise ValueError("ファイルを選択してください。") + + js_file = file_input.files.item(0) + buffer = await js_file.arrayBuffer() + data = Uint8Array.new(buffer) + return bytes(data.to_py()), str(js_file.name) + + +def _collect_series_settings() -> list[dict]: + series_list = document.getElementById("seriesList") + if series_list is None: + raise ValueError("系列設定UIが見つかりません。") + + rows = series_list.querySelectorAll(".series-item") + if rows.length == 0: + raise ValueError("描画系列を1つ以上追加してください。") + + allowed_line_styles = {"solid", "dashed", "dashdot", "dotted"} + plot_type = document.getElementById("plotType").value + default_marker = "0" if plot_type == "line" else "24" + settings = [] + for i in range(rows.length): + row = rows.item(i) + y_select = row.querySelector(".series-y") + x_select = row.querySelector(".series-x") + color_input = row.querySelector(".series-color") + line_width_input = row.querySelector(".series-line-width") + line_style_select = row.querySelector(".series-line-style") + marker_size_input = row.querySelector(".series-marker-size") + legend_name_input = row.querySelector(".series-legend-name") + use_y2_input = row.querySelector(".series-use-y2") + + y_request = y_select.value.strip() if y_select is not None else "" + x_request = x_select.value.strip() if x_select is not None else "" + color = color_input.value.strip() if color_input is not None else "#FF4B00" + line_width_text = line_width_input.value.strip() if line_width_input is not None else "2.0" + line_style = line_style_select.value.strip() if line_style_select is not None else "solid" + marker_size_text = marker_size_input.value.strip() if marker_size_input is not None else default_marker + legend_name = legend_name_input.value.strip() if legend_name_input is not None else "" + use_secondary_axis = bool(use_y2_input.checked) if use_y2_input is not None else False + + line_width = float(line_width_text or "2.0") + marker_size = float(marker_size_text or default_marker) + + if plot_type == "scatter" and marker_size <= 0: + marker_size = 24.0 + + if line_width <= 0: + raise ValueError(f"{i + 1}番目の系列: 線幅は0より大きくしてください。") + if marker_size < 0: + raise ValueError(f"{i + 1}番目の系列: 点サイズは0以上で指定してください。") + if line_style not in allowed_line_styles: + raise ValueError(f"{i + 1}番目の系列: 線種が不正です。") + + settings.append( + { + "y_request": y_request, + "x_request": x_request, + "color": color or "#FF4B00", + "line_width": line_width, + "line_style": line_style, + "marker_size": marker_size, + "legend_name": legend_name, + "use_secondary_axis": use_secondary_axis, + } + ) + + return settings + + +def _resolve_series_requests(df: pd.DataFrame, settings: list[dict]) -> list[str]: + numeric_indices = [i for i, dtype in enumerate(df.dtypes) if pd.api.types.is_numeric_dtype(dtype)] + candidates = numeric_indices if numeric_indices else [0] + used_indices = set() + resolved = [] + + for setting in settings: + request = setting["y_request"] + if request: + idx = _parse_column_index(request) + if idx is not None: + used_indices.add(idx) + resolved.append(request) + continue + + chosen = None + for idx in candidates: + if idx not in used_indices: + chosen = idx + break + if chosen is None: + chosen = candidates[0] + used_indices.add(chosen) + resolved.append(f"__idx__{chosen}") + + return resolved + + +def _get_plot_data(df: pd.DataFrame, series_settings: list[dict]) -> tuple[pd.Series, str, list[dict]]: + x_req = document.getElementById("xColumn").value.strip() + y_requests = _resolve_series_requests(df, series_settings) + + if x_req: + x, x_label, _ = _get_series_from_request(df, x_req) + else: + x = pd.Series(df.index, name="index") + x_label = "index" + + plot_df = pd.DataFrame({"x": x}) + series_entries = [] + for i, (setting, request) in enumerate(zip(series_settings, y_requests)): + y_series, y_name, y_idx = _get_series_from_request(df, request) + y_key = f"y_{i}" + label_suffix = f" [{y_idx}]" if y_idx >= 0 else "" + data_label = f"{y_name}{label_suffix}" + plot_df[y_key] = pd.to_numeric(y_series, errors="coerce") + entry = dict(setting) + entry["y_key"] = y_key + entry["data_label"] = data_label + entry["legend_label"] = setting.get("legend_name") or data_label + series_entries.append(entry) + + y_keys = [entry["y_key"] for entry in series_entries] + plot_df = plot_df.dropna(subset=y_keys, how="all") + if plot_df.empty: + raise ValueError("描画可能な数値データがありません。") + + for entry in series_entries: + entry["y_series"] = plot_df[entry["y_key"]] + + return plot_df["x"], x_label, series_entries + + +def _get_per_series_xy_data(df: pd.DataFrame, series_settings: list[dict]) -> tuple[list[dict], str]: + y_requests = _resolve_series_requests(df, series_settings) + entries = [] + x_labels = [] + + for setting, y_request in zip(series_settings, y_requests): + x_request = setting.get("x_request", "").strip() + if x_request: + x_series, x_label, _ = _get_series_from_request(df, x_request) + else: + x_series = pd.Series(df.index, name="index") + x_label = "index" + + y_series, y_name, y_idx = _get_series_from_request(df, y_request) + label_suffix = f" [{y_idx}]" if y_idx >= 0 else "" + data_label = f"{y_name}{label_suffix}" + + entry = dict(setting) + entry["x_series"] = x_series.reset_index(drop=True) + entry["y_series"] = pd.to_numeric(y_series, errors="coerce").reset_index(drop=True) + entry["data_label"] = data_label + entry["legend_label"] = setting.get("legend_name") or data_label + entry["x_label"] = x_label + entries.append(entry) + x_labels.append(x_label) + + has_points = False + for entry in entries: + mask = entry["y_series"].notna() & pd.notna(entry["x_series"]) + if bool(mask.any()): + has_points = True + break + if not has_points: + raise ValueError("描画可能な数値データがありません。") + + common_x_label = x_labels[0] if len(set(x_labels)) == 1 else "x" + return entries, common_x_label + + +def _fill_columns(df: pd.DataFrame) -> None: + x_select = document.getElementById("xColumn") + + x_select.innerHTML = "" + + x_default = document.createElement("option") + x_default.value = "" + x_default.textContent = "(index)" + x_select.appendChild(x_default) + + for idx, col in enumerate(df.columns): + text = str(col) + value = f"__idx__{idx}" + label = f"{text} [{idx}]" + + x_opt = document.createElement("option") + x_opt.value = value + x_opt.textContent = label + x_select.appendChild(x_opt) + + if hasattr(window, "refreshSeriesColumnOptions"): + window.refreshSeriesColumnOptions() + + +def _render_data_preview(df: pd.DataFrame, skip_rows: int = 0) -> None: + data_area = document.getElementById("dataArea") + if data_area is None: + return + + preview_df = df.head(PREVIEW_ROWS) + rows = len(df) + cols = len(df.columns) + truncated = "(先頭100行のみ表示)" if rows > PREVIEW_ROWS else "" + table_html = preview_df.to_html(index=False, border=0, classes=["data-table"]) + + data_area.innerHTML = ( + f'
行数: {rows} / 列数: {cols} {truncated}
' + f'
{table_html}
' + ) + + +def _render_plot_image(fig) -> None: + buffer = io.BytesIO() + fig.savefig(buffer, format="png", dpi=100) + encoded = base64.b64encode(buffer.getvalue()).decode("ascii") + plot_area = document.getElementById("plotArea") + plot_area.innerHTML = f'plot' + + +def _has_rendered_plot() -> bool: + plot_area = document.getElementById("plotArea") + if plot_area is None: + return False + return plot_area.querySelector("img") is not None + + +def on_tab_plot(event=None): + _switch_tab("plot") + + +def on_tab_data(event=None): + _switch_tab("data") + + +def on_tab_code(event=None): + _switch_tab("code") + + +def _figure_has_visible_artists(fig) -> bool: + if fig is None or not hasattr(fig, "axes"): + return False + for axis in fig.axes: + if len(axis.lines) > 0: + return True + if len(axis.collections) > 0: + return True + if len(axis.patches) > 0: + return True + if len(axis.images) > 0: + return True + if hasattr(axis, "containers") and len(axis.containers) > 0: + return True + return False + + +def _collect_custom_plot_settings(skip_rows: int) -> dict: + font_size = float(_get_text_value("fontSize", "15") or "15") + fig_width = float(_get_text_value("figWidth", "8") or "8") + fig_height = float(_get_text_value("figHeight", "6") or "6") + subplot_margins = _get_subplot_margin_settings() + if fig_width <= 0 or fig_height <= 0: + raise ValueError("図幅・図高さは0より大きくしてください。") + if font_size <= 0: + raise ValueError("フォントサイズは0より大きくしてください。") + + series_settings = [] + series_settings_error = "" + try: + series_settings = _collect_series_settings() + except Exception as exc: + series_settings_error = str(exc) + + return { + "skip_rows": skip_rows, + "plot_type": _get_text_value("plotType", "line") or "line", + "title": _get_text_value("title", ""), + "x_label": _get_text_value("xLabel", ""), + "y_label": _get_text_value("yLabel", ""), + "y2_label": _get_text_value("y2Label", ""), + "x_scale": _get_text_value("xScale", "linear") or "linear", + "y_scale": _get_text_value("yScale", "linear") or "linear", + "y2_scale": _get_text_value("y2Scale", "linear") or "linear", + "x_min": _get_optional_float_value("xMin", "X最小値"), + "x_max": _get_optional_float_value("xMax", "X最大値"), + "y_min": _get_optional_float_value("yMin", "Y最小値"), + "y_max": _get_optional_float_value("yMax", "Y最大値"), + "y2_min": _get_optional_float_value("y2Min", "第2Y最小値"), + "y2_max": _get_optional_float_value("y2Max", "第2Y最大値"), + "show_major_grid": bool(document.getElementById("showMajorGrid").checked), + "show_minor_grid": bool(document.getElementById("showMinorGrid").checked), + "legend_location": _get_text_value("legendLocation", "best") or "best", + "font_size": font_size, + "fig_width": fig_width, + "fig_height": fig_height, + "subplot_margins": subplot_margins, + "subplot_left": subplot_margins["left"], + "subplot_right": subplot_margins["right"], + "subplot_bottom": subplot_margins["bottom"], + "subplot_top": subplot_margins["top"], + "x_column_request": _get_text_value("xColumn", ""), + "series_settings": series_settings, + "series_settings_error": series_settings_error, + } + + +def _create_plot_figure_with_custom_code() -> tuple[object, int, int]: + if CURRENT_DF is None: + raise ValueError("先にファイルを読み込んでください。") + + skip_rows = _get_skip_rows() + if skip_rows >= len(CURRENT_DF): + raise ValueError("スキップ行数がデータ行数以上です。") + + code = _get_custom_code_text() + if not code.strip(): + raise ValueError("カスタムコードが空です。") + + df = CURRENT_DF.iloc[skip_rows:].reset_index(drop=True) + settings = _collect_custom_plot_settings(skip_rows) + context = { + "df": df, + "current_df": CURRENT_DF, + "settings": settings, + "series_settings": settings.get("series_settings", []), + "helpers": { + "get_plot_data": _get_plot_data, + "get_per_series_xy_data": _get_per_series_xy_data, + "resolve_series_requests": _resolve_series_requests, + }, + } + + local_scope = { + "df": df.copy(), + "current_df": CURRENT_DF.copy(), + "settings": settings, + "ctx": context, + "series_settings": settings.get("series_settings", []), + "get_plot_data": _get_plot_data, + "get_per_series_xy_data": _get_per_series_xy_data, + "resolve_series_requests": _resolve_series_requests, + "plt": plt, + "pd": pd, + "fig": None, + "plotted_count": None, + "skip_rows": skip_rows, + } + global_scope = { + "__builtins__": __builtins__, + "plt": plt, + "pd": pd, + } + try: + exec(code, global_scope, local_scope) + except Exception as exc: + raise ValueError(f"カスタムコードの実行に失敗しました: {exc}") from exc + + fig = local_scope.get("fig") + if fig is None: + raise ValueError("カスタムコードで fig を生成してください。") + if not hasattr(fig, "savefig"): + raise ValueError("fig には matplotlib.figure.Figure を設定してください。") + _apply_gui_overrides_to_custom_figure(fig, settings) + _apply_subplot_margin_settings(fig, settings.get("subplot_margins", {}), use_tight_layout_if_auto=True) + + plotted_count_raw = local_scope.get("plotted_count") + if plotted_count_raw is None: + plotted_count = 1 if _figure_has_visible_artists(fig) else 0 + else: + try: + plotted_count = int(plotted_count_raw) + except Exception: + raise ValueError("plotted_count は整数で指定してください。") + + if plotted_count <= 0 and _figure_has_visible_artists(fig): + plotted_count = 1 + if plotted_count <= 0: + raise ValueError("描画可能なデータがありません。") + + custom_skip_rows = local_scope.get("skip_rows", skip_rows) + try: + custom_skip_rows = int(custom_skip_rows) + except Exception: + custom_skip_rows = skip_rows + if custom_skip_rows < 0: + custom_skip_rows = skip_rows + + return fig, plotted_count, custom_skip_rows + + +def _apply_gui_overrides_to_custom_figure(fig, settings: dict) -> None: + if fig is None or not hasattr(fig, "axes") or len(fig.axes) == 0: + return + + ax = fig.axes[0] + ax2 = fig.axes[1] if len(fig.axes) >= 2 else None + font_size = float(settings.get("font_size", 15) or 15) + x_label_override = str(settings.get("x_label", "") or "").strip() + y_label_override = str(settings.get("y_label", "") or "").strip() + y2_label_override = str(settings.get("y2_label", "") or "").strip() + title = str(settings.get("title", "") or "").strip() + x_scale = str(settings.get("x_scale", "linear") or "linear").strip() + y_scale = str(settings.get("y_scale", "linear") or "linear").strip() + y2_scale = str(settings.get("y2_scale", "linear") or "linear").strip() + x_min = settings.get("x_min") + x_max = settings.get("x_max") + y_min = settings.get("y_min") + y_max = settings.get("y_max") + y2_min = settings.get("y2_min") + y2_max = settings.get("y2_max") + show_major_grid = bool(settings.get("show_major_grid", False)) + show_minor_grid = bool(settings.get("show_minor_grid", False)) + legend_location = str(settings.get("legend_location", "best") or "best").strip() + allowed_legend_locations = { + "best", + "upper right", + "upper left", + "lower right", + "lower left", + "upper center", + "lower center", + "center right", + "center left", + "center", + "none", + } + if legend_location not in allowed_legend_locations: + raise ValueError("凡例位置が不正です。") + + if title: + ax.set_title(title, fontsize=font_size + 2) + + current_x_label = ax.get_xlabel() + current_y_label = ax.get_ylabel() + ax.set_xlabel(x_label_override or current_x_label, fontsize=font_size) + ax.set_ylabel(y_label_override or current_y_label, fontsize=font_size) + if ax2 is not None: + current_y2_label = ax2.get_ylabel() + ax2.set_ylabel(y2_label_override or current_y2_label, fontsize=font_size) + + _apply_axis_scale_and_limits(ax, "x", "X", x_scale, x_min, x_max) + _apply_axis_scale_and_limits(ax, "y", "Y", y_scale, y_min, y_max) + if ax2 is not None: + _apply_axis_scale_and_limits(ax2, "y", "第2Y", y2_scale, y2_min, y2_max) + + ax.tick_params( + axis="x", + which="both", + direction="in", + bottom=True, + top=False, + labelsize=max(font_size - 1, 1), + ) + ax.tick_params( + axis="y", + which="both", + direction="in", + left=True, + right=False, + labelsize=max(font_size - 1, 1), + ) + if ax2 is not None: + ax2.tick_params( + axis="y", + which="both", + direction="in", + left=False, + right=True, + labelsize=max(font_size - 1, 1), + ) + + if show_minor_grid: + ax.minorticks_on() + if ax2 is not None: + ax2.minorticks_on() + else: + ax.minorticks_off() + if ax2 is not None: + ax2.minorticks_off() + + ax.grid(False, which="both", axis="both") + if show_major_grid: + ax.grid(True, which="major", axis="both", alpha=0.3) + if show_minor_grid: + ax.grid(True, which="minor", axis="both", alpha=0.2, linestyle=":") + if ax2 is not None: + ax2.grid(False, which="both", axis="y") + if show_major_grid: + ax2.grid(True, which="major", axis="y", alpha=0.3) + if show_minor_grid: + ax2.grid(True, which="minor", axis="y", alpha=0.2, linestyle=":") + + for axis in fig.axes: + existing_legend = axis.get_legend() + if existing_legend is not None: + existing_legend.remove() + + if legend_location == "none": + return + + handles = [] + labels = [] + for axis in fig.axes: + h, l = axis.get_legend_handles_labels() + handles.extend(h) + labels.extend(l) + + if not labels: + return + + series_settings = settings.get("series_settings", []) + for i, setting in enumerate(series_settings): + if i >= len(labels): + break + legend_name = str(setting.get("legend_name", "") or "").strip() + if legend_name: + labels[i] = legend_name + + ax.legend( + handles, + labels, + loc=legend_location, + fontsize=max(font_size - 1, 1), + edgecolor="black", + ) + + +async def on_load_columns(event=None): + global CURRENT_DF + try: + _set_data_ready(False) + file_bytes, filename = await _read_upload() + delimiter = document.getElementById("delimiter").value + has_header = document.getElementById("hasHeader").checked + + df = load_dataframe(file_bytes, filename, delimiter, has_header) + CURRENT_DF = df + _fill_columns(df) + _render_data_preview(df, _get_skip_rows_for_preview()) + _set_data_ready(True) + set_status("") + await on_render() + except Exception as exc: + CURRENT_DF = None + _set_data_ready(False) + if _is_parentnode_error(exc): + _log_suppressed("on_load_columns", exc) + detail = _format_internal_error_detail(exc, "on_load_columns") + set_status("内部エラーを自動回避しました。もう一度同じ操作を実行してください。", True, detail) + return + set_status(str(exc), True) + + +def _create_plot_figure() -> tuple[object, int, int]: + if _is_custom_code_enabled(): + return _create_plot_figure_with_custom_code() + + if CURRENT_DF is None: + raise ValueError("先にファイルを読み込んでください。") + + skip_rows = _get_skip_rows() + if skip_rows >= len(CURRENT_DF): + raise ValueError("スキップ行数がデータ行数以上です。") + + df = CURRENT_DF.iloc[skip_rows:].reset_index(drop=True) + plot_type = document.getElementById("plotType").value + + title = document.getElementById("title").value.strip() + x_label_override = document.getElementById("xLabel").value.strip() + y_label_override = document.getElementById("yLabel").value.strip() + y2_label_override = document.getElementById("y2Label").value.strip() + x_scale = (document.getElementById("xScale").value or "linear").strip() + y_scale = (document.getElementById("yScale").value or "linear").strip() + y2_scale = (document.getElementById("y2Scale").value or "linear").strip() + x_min = _get_optional_float_value("xMin", "X最小値") + x_max = _get_optional_float_value("xMax", "X最大値") + y_min = _get_optional_float_value("yMin", "Y最小値") + y_max = _get_optional_float_value("yMax", "Y最大値") + y2_min = _get_optional_float_value("y2Min", "第2Y最小値") + y2_max = _get_optional_float_value("y2Max", "第2Y最大値") + show_major_grid = bool(document.getElementById("showMajorGrid").checked) + show_minor_grid = bool(document.getElementById("showMinorGrid").checked) + legend_location = document.getElementById("legendLocation").value + + series_settings = _collect_series_settings() + font_size = float(document.getElementById("fontSize").value or "15") + fig_width = float(document.getElementById("figWidth").value or "8") + fig_height = float(document.getElementById("figHeight").value or "6") + subplot_margins = _get_subplot_margin_settings() + allowed_legend_locations = { + "best", + "upper right", + "upper left", + "lower right", + "lower left", + "upper center", + "lower center", + "center right", + "center left", + "center", + "none", + } + + if fig_width <= 0 or fig_height <= 0: + raise ValueError("図幅・図高さは0より大きくしてください。") + if font_size <= 0: + raise ValueError("フォントサイズは0より大きくしてください。") + if plot_type not in {"line", "scatter", "bar"}: + raise ValueError("プロット種別が不正です。") + if legend_location not in allowed_legend_locations: + raise ValueError("凡例位置が不正です。") + + fig, ax = plt.subplots(figsize=(fig_width, fig_height), dpi=100) + ax2 = None + plotted_labels = [] + all_entries = [] + + if plot_type in {"line", "scatter"}: + per_series_entries, x_label = _get_per_series_xy_data(df, series_settings) + all_entries = per_series_entries + for entry in per_series_entries: + x_series = entry["x_series"] + y_series = entry["y_series"] + mask = y_series.notna() & pd.notna(x_series) + if not bool(mask.any()): + continue + + target_ax = ax + if entry.get("use_secondary_axis", False): + if ax2 is None: + ax2 = ax.twinx() + target_ax = ax2 + + if plot_type == "line": + marker = "o" if entry["marker_size"] > 0 else "" + marker_size_for_line = entry["marker_size"] ** 0.5 if entry["marker_size"] > 0 else 0 + target_ax.plot( + x_series[mask], + y_series[mask], + color=entry["color"], + linewidth=entry["line_width"], + linestyle=entry["line_style"], + marker=marker, + markersize=marker_size_for_line, + label=entry["legend_label"], + ) + else: + target_ax.scatter( + x_series[mask], + y_series[mask], + color=entry["color"], + s=entry["marker_size"], + label=entry["legend_label"], + ) + plotted_labels.append(entry["legend_label"]) + else: + x, x_label, series_entries = _get_plot_data(df, series_settings) + all_entries = series_entries + if len(series_entries) == 1: + entry = series_entries[0] + y_series = entry["y_series"] + mask = y_series.notna() & pd.notna(x) + if bool(mask.any()): + target_ax = ax + if entry.get("use_secondary_axis", False): + if ax2 is None: + ax2 = ax.twinx() + target_ax = ax2 + target_ax.bar( + x[mask], + y_series[mask], + color=entry["color"], + linewidth=entry["line_width"], + linestyle=entry["line_style"], + label=entry["legend_label"], + ) + plotted_labels.append(entry["legend_label"]) + else: + x_labels = [str(v) for v in x.tolist()] + positions = list(range(len(x_labels))) + width = 0.8 / len(series_entries) + + for i, entry in enumerate(series_entries): + y_series = entry["y_series"] + y_values = [float(v) if pd.notna(v) else 0.0 for v in y_series.tolist()] + offset = -0.4 + (width / 2.0) + (i * width) + x_positions = [p + offset for p in positions] + + target_ax = ax + if entry.get("use_secondary_axis", False): + if ax2 is None: + ax2 = ax.twinx() + target_ax = ax2 + target_ax.bar( + x_positions, + y_values, + width=width, + color=entry["color"], + linewidth=entry["line_width"], + linestyle=entry["line_style"], + label=entry["legend_label"], + ) + plotted_labels.append(entry["legend_label"]) + + if positions: + if len(positions) > 25: + step = max(1, len(positions) // 25) + tick_positions = [positions[i] for i in range(0, len(positions), step)] + tick_labels = [x_labels[i] for i in range(0, len(x_labels), step)] + else: + tick_positions = positions + tick_labels = x_labels + ax.set_xticks(tick_positions) + ax.set_xticklabels(tick_labels, rotation=45, ha="right") + + if not plotted_labels: + raise ValueError("描画可能なデータがありません。") + + primary_labels = [entry["data_label"] for entry in all_entries if not entry.get("use_secondary_axis", False)] + secondary_labels = [entry["data_label"] for entry in all_entries if entry.get("use_secondary_axis", False)] + + if title: + ax.set_title(title, fontsize=font_size + 2) + ax.set_xlabel(x_label_override or x_label, fontsize=font_size) + + if y_label_override: + ax.set_ylabel(y_label_override, fontsize=font_size) + else: + left_default = primary_labels[0] if len(primary_labels) == 1 else "values" + ax.set_ylabel(left_default, fontsize=font_size) + + if ax2 is not None: + right_default = secondary_labels[0] if len(secondary_labels) == 1 else "values" + ax2.set_ylabel(y2_label_override or right_default, fontsize=font_size) + + _apply_axis_scale_and_limits(ax, "x", "X", x_scale, x_min, x_max) + _apply_axis_scale_and_limits(ax, "y", "Y", y_scale, y_min, y_max) + if ax2 is not None: + _apply_axis_scale_and_limits(ax2, "y", "第2Y", y2_scale, y2_min, y2_max) + + ax.tick_params( + axis="x", + which="both", + direction="in", + bottom=True, + top=False, + labelsize=max(font_size - 1, 1), + ) + ax.tick_params( + axis="y", + which="both", + direction="in", + left=True, + right=False, + labelsize=max(font_size - 1, 1), + ) + if ax2 is not None: + ax2.tick_params( + axis="y", + which="both", + direction="in", + left=False, + right=True, + labelsize=max(font_size - 1, 1), + ) + + if show_minor_grid: + ax.minorticks_on() + if ax2 is not None: + ax2.minorticks_on() + else: + ax.minorticks_off() + if ax2 is not None: + ax2.minorticks_off() + + ax.grid(False, which="both", axis="both") + if show_major_grid: + ax.grid(True, which="major", axis="both", alpha=0.3) + if show_minor_grid: + ax.grid(True, which="minor", axis="both", alpha=0.2, linestyle=":") + if ax2 is not None: + ax2.grid(False, which="both", axis="y") + if show_major_grid: + ax2.grid(True, which="major", axis="y", alpha=0.3) + if show_minor_grid: + ax2.grid(True, which="minor", axis="y", alpha=0.2, linestyle=":") + + if legend_location != "none" and len(plotted_labels) >= 1: + if ax2 is None: + ax.legend( + loc=legend_location, + fontsize=max(font_size - 1, 1), + edgecolor="black", + ) + else: + handles1, labels1 = ax.get_legend_handles_labels() + handles2, labels2 = ax2.get_legend_handles_labels() + merged_handles = handles1 + handles2 + merged_labels = labels1 + labels2 + if len(merged_labels) >= 1: + ax.legend( + merged_handles, + merged_labels, + loc=legend_location, + fontsize=max(font_size - 1, 1), + edgecolor="black", + ) + _apply_subplot_margin_settings(fig, subplot_margins, use_tight_layout_if_auto=True) + return fig, len(plotted_labels), skip_rows + + +async def on_render(event=None): + fig = None + try: + if CURRENT_DF is not None: + _render_data_preview(CURRENT_DF, _get_skip_rows_for_preview()) + await _ensure_japanese_font() + fig, plotted_count, skip_rows = _create_plot_figure() + _render_plot_image(fig) + _set_data_ready(True) + set_status(f"描画に成功しました({plotted_count}系列、スキップ{skip_rows}行)。") + except Exception as exc: + if _is_parentnode_error(exc): + _log_suppressed("on_render", exc) + if _has_rendered_plot(): + set_status("描画に成功しました。") + else: + detail = _format_internal_error_detail(exc, "on_render") + set_status("内部エラーを自動回避しました。もう一度描画を実行してください。", True, detail) + return + set_status(str(exc), True) + finally: + _safe_close_figure(fig) + + +async def on_save_plot(event=None): + fig = None + try: + await _ensure_japanese_font() + save_format = document.getElementById("saveFormat").value.strip().lower() + transparent = bool(document.getElementById("saveTransparent").checked) + raw_filename = document.getElementById("saveFilename").value.strip() + fig, plotted_count, skip_rows = _create_plot_figure() + data_uri, ext = figure_to_data_uri(fig, save_format, transparent=transparent) + filename = build_filename(raw_filename, ext) + if not hasattr(window, "downloadDataUri"): + raise ValueError("ダウンロード機能の初期化に失敗しました。") + window.downloadDataUri(filename, data_uri) + set_status(f"描画データを保存しました: {filename}") + except Exception as exc: + if _is_parentnode_error(exc): + _log_suppressed("on_save_plot", exc) + detail = _format_internal_error_detail(exc, "on_save_plot") + set_status("内部エラーを自動回避しました。もう一度保存を実行してください。", True, detail) + return + set_status(str(exc), True) + finally: + _safe_close_figure(fig) + + +def _bind_click(element_id: str, handler, is_async: bool = False) -> None: + element = document.getElementById(element_id) + if element is None: + raise ValueError(f"UI要素が見つかりません: {element_id}") + + if is_async: + def wrapped(event): + _spawn_task(handler(event)) + else: + def wrapped(event): + handler(event) + + proxy = create_proxy(wrapped) + _EVENT_PROXIES.append(proxy) + element.addEventListener("click", proxy) + + +def _bind_events() -> None: + _bind_click("tabPlotBtn", on_tab_plot) + _bind_click("tabDataBtn", on_tab_data) + _bind_click("tabCodeBtn", on_tab_code) + _bind_click("savePlotBtn", on_save_plot, is_async=True) + + +def _register_js_api() -> None: + def trigger_load(event=None): + _spawn_task(on_load_columns()) + + def trigger_render(event=None): + _spawn_task(on_render()) + + load_proxy = create_proxy(trigger_load) + render_proxy = create_proxy(trigger_render) + _EVENT_PROXIES.append(load_proxy) + _EVENT_PROXIES.append(render_proxy) + window.requestLoadColumns = load_proxy + window.requestRender = render_proxy + + +set_status("") +_set_data_ready(False) +_init_custom_code_editor() +_switch_tab("plot") +if hasattr(window, "ensureSeriesItems"): + window.ensureSeriesItems() +try: + _register_js_api() + _bind_events() +except Exception as exc: + if _is_parentnode_error(exc): + _log_suppressed("initialization", exc) + detail = _format_internal_error_detail(exc, "initialization") + set_status("内部エラーを自動回避しました。ページを再読み込みして再試行してください。", True, detail) + else: + set_status(f"初期化エラー: {exc}", True) diff --git a/py/mplgui/__init__.py b/py/mplgui/__init__.py new file mode 100644 index 0000000..b972a05 --- /dev/null +++ b/py/mplgui/__init__.py @@ -0,0 +1 @@ +"""matplotlib GUI の Python ロジック(ブラウザ非依存の純粋関数)。""" diff --git a/py/mplgui/fonts.py b/py/mplgui/fonts.py new file mode 100644 index 0000000..6c7bd54 --- /dev/null +++ b/py/mplgui/fonts.py @@ -0,0 +1,31 @@ +"""日本語フォントの設定と登録(js / pyodide には依存しない。取得処理は py/main.py 側)。""" +from matplotlib import font_manager +import matplotlib.pyplot as plt + +SANS_SERIF_PRIORITY = [ + "Noto Sans JP", + "Noto Sans CJK JP", + "IPAexGothic", + "Yu Gothic", + "Hiragino Sans", + "MS Gothic", + "DejaVu Sans", +] +PREFERRED_JP_FONTS = ["Noto Sans CJK JP", "Noto Sans JP"] + + +def configure_rcparams() -> None: + plt.rcParams["font.family"] = "sans-serif" + plt.rcParams["font.sans-serif"] = list(SANS_SERIF_PRIORITY) + plt.rcParams["axes.unicode_minus"] = False + + +def register_font_file(path: str) -> None: + """フォントファイルを matplotlib に登録し、日本語フォントを sans-serif の先頭へ移す。""" + font_manager.fontManager.addfont(str(path)) + current = list(plt.rcParams.get("font.sans-serif", [])) + for name in reversed(PREFERRED_JP_FONTS): + if name in current: + current.remove(name) + current.insert(0, name) + plt.rcParams["font.sans-serif"] = current diff --git a/py/mplgui/loader.py b/py/mplgui/loader.py new file mode 100644 index 0000000..dcd4e5a --- /dev/null +++ b/py/mplgui/loader.py @@ -0,0 +1,55 @@ +"""ファイルのバイト列から DataFrame を作る(DOM / js には依存しない)。""" +import io +from pathlib import Path + +import pandas as pd + +SUPPORTED = {".xlsx", ".csv", ".txt"} + + +def decode_delimiter(delimiter: str, suffix: str) -> str: + if delimiter: + return bytes(delimiter, "utf-8").decode("unicode_escape") + if suffix == ".csv": + return "," + return r"\s+" + + +def load_dataframe( + file_bytes: bytes, + filename: str, + delimiter: str, + has_header: bool, +) -> pd.DataFrame: + suffix = Path(filename).suffix.lower() + if suffix not in SUPPORTED: + raise ValueError("対応していない拡張子です。xlsx/csv/txtを選択してください。") + + header = 0 if has_header else None + stream = io.BytesIO(file_bytes) + + if suffix == ".xlsx": + df = pd.read_excel(stream, header=header) + else: + sep = decode_delimiter(delimiter, suffix) + # UTF-8 で読めなければ Shift-JIS にフォールバック + for enc in ("utf-8", "shift_jis"): + try: + stream.seek(0) + df = pd.read_csv( + stream, sep=sep, header=header, + engine="python", encoding=enc, + ) + break + except (UnicodeDecodeError, pd.errors.ParserError): + if enc == "shift_jis": + raise + continue + + if df.empty: + raise ValueError("データが空です。") + + if not has_header: + df.columns = [f"column_{i}" for i in range(len(df.columns))] + + return df diff --git a/py/mplgui/runner.py b/py/mplgui/runner.py new file mode 100644 index 0000000..dbd1139 --- /dev/null +++ b/py/mplgui/runner.py @@ -0,0 +1,53 @@ +"""Figure を画像(data URI)にする。保存ファイル名の組み立ても行う(DOM / js には依存しない)。""" +import base64 +import io + + +def figure_to_data_uri(fig, file_format: str, transparent: bool = False) -> tuple[str, str]: + fmt = (file_format or "").lower() + if fmt == "png": + save_format = "png" + mime = "image/png" + ext = "png" + elif fmt == "jpg": + save_format = "jpeg" + mime = "image/jpeg" + ext = "jpg" + elif fmt == "svg": + save_format = "svg" + mime = "image/svg+xml" + ext = "svg" + elif fmt == "pdf": + save_format = "pdf" + mime = "application/pdf" + ext = "pdf" + else: + raise ValueError("保存形式が不正です。") + + if transparent and fmt not in {"png", "svg"}: + raise ValueError("背景透過を有効にした場合、保存形式はpngまたはsvgを選択してください。") + + buffer = io.BytesIO() + save_kwargs = {} + if save_format in {"png", "jpeg"}: + save_kwargs["dpi"] = 120 + if save_format == "jpeg": + save_kwargs["facecolor"] = "white" + if transparent: + save_kwargs["transparent"] = True + + fig.savefig(buffer, format=save_format, **save_kwargs) + encoded = base64.b64encode(buffer.getvalue()).decode("ascii") + return f"data:{mime};base64,{encoded}", ext + + +def build_filename(raw_filename: str, ext: str) -> str: + """保存ファイル名を作る。空欄時は plot.。入力中の拡張子は取り除いて ext を付ける。""" + raw_filename = (raw_filename or "").strip() + if raw_filename: + filename_base = raw_filename.rstrip(".") + if "." in filename_base: + filename_base = filename_base.rsplit(".", 1)[0] + filename_base = filename_base.strip() or "plot" + return f"{filename_base}.{ext}" + return f"plot.{ext}" diff --git a/pyscript.toml b/pyscript.toml index 3836d43..55ea780 100644 --- a/pyscript.toml +++ b/pyscript.toml @@ -1,2 +1,10 @@ # PyScript 2026.7.3 (Pyodide 314.0.3 / Python 3.14)。openpyxl は Pyodide 同梱ではないため PyPI から micropip で導入する。 packages = ["pandas", "matplotlib", "openpyxl==3.1.5", "et_xmlfile==2.0.0"] + +# py/mplgui/*.py を Pyodide の仮想FS(カレントディレクトリ直下)に配置し、`import mplgui` できるようにする。 +# キーは取得元(ページからの相対パス)、値は配置先。 +[files] +"./py/mplgui/__init__.py" = "./mplgui/__init__.py" +"./py/mplgui/loader.py" = "./mplgui/loader.py" +"./py/mplgui/runner.py" = "./mplgui/runner.py" +"./py/mplgui/fonts.py" = "./mplgui/fonts.py" From 6c5aa2d8363f7f2b3d888dfe65054fa4a24cd20d Mon Sep 17 00:00:00 2001 From: mioupa Date: Wed, 7 Oct 2026 18:05:55 +0900 Subject: [PATCH 11/37] Configure Pages include/exclude, add publish check and unit tests __init__.py is listed under include (Jekyll skips underscore files); dev-only files are excluded. pytest runs tests/unit by default with py/ on pythonpath. Co-Authored-By: Claude Opus 5.5 --- _config.yml | 9 ++- pyproject.toml | 4 +- tests/unit/test_mplgui_modules.py | 52 ++++++++++++++ tests/unit/test_pages_publish.py | 112 ++++++++++++++++++++++++++++++ uv.lock | 28 ++++++++ 5 files changed, 203 insertions(+), 2 deletions(-) create mode 100644 tests/unit/test_mplgui_modules.py create mode 100644 tests/unit/test_pages_publish.py diff --git a/_config.yml b/_config.yml index 30386ad..768aefa 100644 --- a/_config.yml +++ b/_config.yml @@ -1,5 +1,12 @@ # GitHub Pages (Jekyll) の配信設定 -# 開発用ドキュメントはリポジトリには置くが、Pages では配信しない +# Jekyll は "_" で始まるファイルを既定で配信しないため、Python パッケージの __init__.py だけ明示的に含める +include: + - __init__.py +# 開発用のファイルはリポジトリには置くが、Pages では配信しない exclude: - docs/ - AGENTS.md + - tests/ + - pyproject.toml + - uv.lock + - .python-version diff --git a/pyproject.toml b/pyproject.toml index 7db5512..62fb3d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,8 @@ dependencies = [] package = false [tool.pytest.ini_options] -testpaths = ["tests"] +testpaths = ["tests/unit"] +pythonpath = ["py"] markers = [ "e2e: ブラウザ(Playwright)を使うE2Eテスト", "unit: 単体テスト", @@ -25,4 +26,5 @@ dev = [ "openpyxl==3.1.5", "pytest>=9.1.1", "pytest-playwright>=0.9.0", + "pyyaml>=6.0.3", ] diff --git a/tests/unit/test_mplgui_modules.py b/tests/unit/test_mplgui_modules.py new file mode 100644 index 0000000..b9ef919 --- /dev/null +++ b/tests/unit/test_mplgui_modules.py @@ -0,0 +1,52 @@ +"""mplgui の各モジュールが js / pyodide / pyscript に依存せず CPython で import でき、基本動作することを確認する。""" +import ast +from pathlib import Path + +import pytest + +from mplgui.loader import decode_delimiter, load_dataframe +from mplgui.runner import build_filename, figure_to_data_uri + +pytestmark = pytest.mark.unit + +FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" +PKG = Path(__file__).resolve().parents[2] / "py" / "mplgui" + + +def test_no_browser_imports(): + for path in PKG.glob("*.py"): + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + names = [] + if isinstance(node, ast.Import): + names = [a.name for a in node.names] + elif isinstance(node, ast.ImportFrom): + names = [node.module or ""] + for n in names: + assert n.split(".")[0] not in {"js", "pyodide", "pyscript"}, f"{path.name} imports {n}" + + +def test_load_csv_and_delimiter(): + df = load_dataframe((FIXTURES / "utf8.csv").read_bytes(), "utf8.csv", "", True) + assert not df.empty + assert decode_delimiter("", ".csv") == "," + assert decode_delimiter(r"\t", ".txt") == "\t" + + +def test_build_filename(): + assert build_filename("", "png") == "plot.png" + assert build_filename("fig.1.svg", "pdf") == "fig.1.pdf" + assert build_filename("name.", "jpg") == "name.jpg" + + +def test_figure_to_data_uri_png(): + import matplotlib + + matplotlib.use("Agg") + import matplotlib.pyplot as plt + + fig, ax = plt.subplots() + ax.plot([0, 1], [0, 1]) + uri, ext = figure_to_data_uri(fig, "png") + plt.close(fig) + assert uri.startswith("data:image/png;base64,") and ext == "png" diff --git a/tests/unit/test_pages_publish.py b/tests/unit/test_pages_publish.py new file mode 100644 index 0000000..511a1ba --- /dev/null +++ b/tests/unit/test_pages_publish.py @@ -0,0 +1,112 @@ +"""GitHub Pages (Jekyll) で公開されるファイルに、index.html / pyscript.toml が参照するものが全て含まれるか。 + +ブラウザは使わない。Jekyll の既定の除外規則("_" "." "#" "~" 始まりは、include に無ければ配信しない)と +_config.yml の exclude / include を再現して判定する。 +""" +import fnmatch +import tomllib +from html.parser import HTMLParser +from pathlib import Path +from urllib.parse import urlparse + +import pytest +import yaml + +pytestmark = pytest.mark.unit + +REPO = Path(__file__).resolve().parents[2] + + +class _RefParser(HTMLParser): + def __init__(self): + super().__init__() + self.refs = [] # (tag, attr, value) + + def handle_starttag(self, tag, attrs): + for name, value in attrs: + if name in ("href", "src", "config") and value: + self.refs.append((tag, name, value)) + + +def _local_path(ref: str) -> str | None: + """ローカルファイルへの参照なら、リポジトリルートからの相対パスを返す。""" + parsed = urlparse(ref) + if parsed.scheme or parsed.netloc or ref.startswith(("//", "#", "data:")): + return None + path = parsed.path + if not path: + return None + return path.removeprefix("./").lstrip("/") + + +def _matches(pattern: str, rel: str) -> bool: + pattern = pattern.rstrip("/") + parts = rel.split("/") + candidates = ["/".join(parts[: i + 1]) for i in range(len(parts))] # 親ディレクトリを含む + return any(fnmatch.fnmatch(c, pattern) or fnmatch.fnmatch(c.rsplit("/", 1)[-1], pattern) for c in candidates) + + +def _is_published(rel: str, config: dict) -> bool: + includes = config.get("include", []) or [] + excludes = config.get("exclude", []) or [] + if any(_matches(p, rel) for p in excludes): + return False + for part_end in range(1, len(rel.split("/")) + 1): + sub = "/".join(rel.split("/")[:part_end]) + name = sub.rsplit("/", 1)[-1] + if name.startswith(("_", ".", "#", "~")) and not any( + fnmatch.fnmatch(sub, p) or fnmatch.fnmatch(name, p) for p in includes + ): + return False + return True + + +@pytest.fixture(scope="module") +def jekyll_config(): + return yaml.safe_load((REPO / "_config.yml").read_text(encoding="utf-8")) or {} + + +def _assert_published(rel: str, config: dict, origin: str): + assert (REPO / rel).is_file(), f"{origin}: {rel} が存在しない" + assert _is_published(rel, config), f"{origin}: {rel} は Jekyll では公開されない(_config.yml の include/exclude を確認)" + + +def test_index_html_references_are_published(jekyll_config): + parser = _RefParser() + parser.feed((REPO / "index.html").read_text(encoding="utf-8")) + local = [(t, a, p) for t, a, v in parser.refs if (p := _local_path(v))] + assert any(p == "css/style.css" for _, _, p in local) + assert any(p == "js/main.js" for _, _, p in local) + assert any(p == "py/main.py" for _, _, p in local) + for tag, attr, rel in local: + _assert_published(rel, jekyll_config, f"index.html <{tag} {attr}>") + + +def test_pyscript_toml_files_are_published(jekyll_config): + cfg = tomllib.loads((REPO / "pyscript.toml").read_text(encoding="utf-8")) + files = cfg.get("files", {}) + assert files, "[files] が定義されていない" + for src in files: + rel = _local_path(src) + assert rel, f"[files] の取得元がローカルパスではない: {src}" + _assert_published(rel, jekyll_config, "pyscript.toml [files]") + destinations = [d for d in files.values() if d] + assert len(destinations) == len(set(destinations)), "[files] の配置先が重複している" + + +def test_every_mplgui_module_is_listed_in_pyscript_toml(): + cfg = tomllib.loads((REPO / "pyscript.toml").read_text(encoding="utf-8")) + listed = {_local_path(src) for src in cfg.get("files", {})} + on_disk = {p.relative_to(REPO).as_posix() for p in (REPO / "py" / "mplgui").glob("*.py")} + assert on_disk <= listed, f"pyscript.toml [files] に無いモジュール: {sorted(on_disk - listed)}" + + +def test_dev_files_are_not_published(jekyll_config): + for rel in ("tests/e2e/test_smoke.py", "pyproject.toml", "uv.lock", ".python-version", "docs/REQUIREMENTS.md", "AGENTS.md"): + assert not _is_published(rel, jekyll_config), f"{rel} が公開されてしまう" + + +def test_publish_rules_sanity(jekyll_config): + assert _is_published("py/mplgui/__init__.py", jekyll_config) + assert not _is_published("py/mplgui/__init__.py", {}) # include が無ければ Jekyll は配信しない + assert not _is_published("_hidden/a.js", jekyll_config) diff --git a/uv.lock b/uv.lock index c690797..7ac725f 100644 --- a/uv.lock +++ b/uv.lock @@ -296,6 +296,7 @@ dev = [ { name = "pandas" }, { name = "pytest" }, { name = "pytest-playwright" }, + { name = "pyyaml" }, ] [package.metadata] @@ -308,6 +309,7 @@ dev = [ { name = "pandas", specifier = "==3.0.2" }, { name = "pytest", specifier = ">=9.1.1" }, { name = "pytest-playwright", specifier = ">=0.9.0" }, + { name = "pyyaml", specifier = ">=6.0.3" }, ] [[package]] @@ -544,6 +546,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8", size = 10051, upload-time = "2024-02-08T18:32:43.911Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + [[package]] name = "requests" version = "2.34.2" From 612d032e231d5eb3b0f2c284fd12481b4d697c5f Mon Sep 17 00:00:00 2001 From: mioupa Date: Wed, 7 Oct 2026 18:07:59 +0900 Subject: [PATCH 12/37] Remove parentNode error suppression (A9) Delete the JS window error/unhandledrejection/console.error handlers, the success fallback in notify, and the Python parentNode branches. Unexpected errors now always surface as an error status with details. Co-Authored-By: Claude Opus 5.5 --- js/main.js | 2 -- js/ui/notify.js | 76 +---------------------------------------- py/main.py | 80 +++----------------------------------------- tests/e2e/helpers.py | 5 +-- 4 files changed, 7 insertions(+), 156 deletions(-) diff --git a/js/main.js b/js/main.js index a53f771..9f1b19f 100644 --- a/js/main.js +++ b/js/main.js @@ -1,6 +1,4 @@ // エントリ。DOM 構築後(module は defer)に UI を初期化する。 -// notify.js は parentNode 系例外のハンドラを登録するため、必ず最初に import する。 -import "./ui/notify.js"; import { scheduleLoad, scheduleRender } from "./bridge.js"; import { bindColorPanelOutsideClick } from "./ui/colorPicker.js"; import { bindCustomCodeEvents } from "./ui/customCode.js"; diff --git a/js/ui/notify.js b/js/ui/notify.js index d4b8b35..5939030 100644 --- a/js/ui/notify.js +++ b/js/ui/notify.js @@ -1,31 +1,4 @@ -// ステータス表示と、parentNode 系例外の抑制・回復ハンドラ。 -// 他のモジュールより先に評価されるよう、main.js の先頭で import する。 -const __warn = console.warn.bind(console); -const isTargetError = (msg) => - typeof msg === "string" && - (msg.includes("parentNode") || msg.includes("'NoneType' object has no attribute 'parentNode'")); - -const logSuppressed = (where, msg, stack = "") => { - try { - __warn("[mplgui] suppressed parentNode error:", where, String(msg)); - } catch (_) {} -}; - -const buildJsErrorDetail = (source, msg, stack = "", extras = []) => { - const lines = [`source: ${source}`]; - if (msg) lines.push(`message: ${msg}`); - for (const extra of extras) { - if (extra) lines.push(extra); - } - const normalizedStack = typeof stack === "string" ? stack.trim() : ""; - if (normalizedStack) { - lines.push(""); - lines.push("stack:"); - lines.push(normalizedStack); - } - return lines.join("\n"); -}; - +// ステータス表示。 export const setStatus = (message, isError = false, detail = "") => { const status = document.getElementById("status"); if (status) { @@ -47,50 +20,3 @@ export const setStatus = (message, isError = false, detail = "") => { detailWrap.open = false; } }; - -export const notify = (detail = "") => { - const plotArea = document.getElementById("plotArea"); - const hasPlot = !!(plotArea && plotArea.querySelector("img")); - if (hasPlot) { - setStatus("描画に成功しました。"); - } else { - setStatus("内部エラーを自動回避しました。もう一度操作してください。", true, detail); - } -}; - -window.addEventListener("error", (event) => { - const msg = event?.message || ""; - if (isTargetError(msg)) { - event.preventDefault(); - const stack = event?.error?.stack || ""; - logSuppressed("window.error", msg, stack); - const extras = [ - event?.filename ? `file: ${event.filename}` : "", - event?.lineno ? `line: ${event.lineno}` : "", - event?.colno ? `column: ${event.colno}` : "", - ]; - notify(buildJsErrorDetail("window.error", msg, stack, extras)); - } -}); - -window.addEventListener("unhandledrejection", (event) => { - const reason = event?.reason; - const msg = (reason && (reason.message || String(reason))) || ""; - if (isTargetError(msg)) { - event.preventDefault(); - const stack = reason?.stack || ""; - logSuppressed("window.unhandledrejection", msg, stack); - notify(buildJsErrorDetail("window.unhandledrejection", msg, stack)); - } -}); - -const originalConsoleError = console.error.bind(console); -console.error = (...args) => { - const msg = args.map((x) => String(x)).join(" "); - if (isTargetError(msg)) { - logSuppressed("console.error", msg, new Error().stack || ""); - notify(buildJsErrorDetail("console.error", msg)); - return; - } - originalConsoleError(...args); -}; diff --git a/py/main.py b/py/main.py index 0c157ef..2abd454 100644 --- a/py/main.py +++ b/py/main.py @@ -138,33 +138,9 @@ def _format_internal_error_detail(exc: Exception, context: str) -> str: return "\n".join(lines) -def _is_parentnode_error(exc: Exception) -> bool: - text = str(exc) - return ("parentNode" in text) or ("NoneType" in text and "parentNode" in text) - - -def _log_suppressed(where: str, exc) -> None: - try: - from js import console - if False: - console.warn("[mplgui] suppressed parentNode error:", where, str(exc), traceback.format_exc()) - else: - console.warn("[mplgui] suppressed parentNode error:", where, str(exc)) - except Exception: - pass - - def _safe_close_figure(fig) -> None: - if fig is None: - return - try: + if fig is not None: plt.close(fig) - except Exception as exc: - # Pyodide/ブラウザ描画バックエンドの後処理競合は無視する - if _is_parentnode_error(exc): - _log_suppressed("_safe_close_figure", exc) - return - raise def _switch_tab(tab_name: str) -> None: @@ -201,21 +177,8 @@ def _consume_done(finished_task): try: finished_task.result() except Exception as exc: - if _is_parentnode_error(exc): - _log_suppressed("_spawn_task", exc) - try: - if _has_rendered_plot(): - set_status("描画に成功しました。") - else: - detail = _format_internal_error_detail(exc, "async task callback") - set_status("内部エラーを自動回避しました。もう一度操作してください。", True, detail) - except Exception: - pass - else: - try: - set_status(str(exc), True) - except Exception: - pass + detail = _format_internal_error_detail(exc, "async task callback") + set_status("内部エラーが発生しました。もう一度操作してください。", True, detail) task.add_done_callback(_consume_done) @@ -645,13 +608,6 @@ def _render_plot_image(fig) -> None: plot_area.innerHTML = f'plot' -def _has_rendered_plot() -> bool: - plot_area = document.getElementById("plotArea") - if plot_area is None: - return False - return plot_area.querySelector("img") is not None - - def on_tab_plot(event=None): _switch_tab("plot") @@ -970,11 +926,6 @@ async def on_load_columns(event=None): except Exception as exc: CURRENT_DF = None _set_data_ready(False) - if _is_parentnode_error(exc): - _log_suppressed("on_load_columns", exc) - detail = _format_internal_error_detail(exc, "on_load_columns") - set_status("内部エラーを自動回避しました。もう一度同じ操作を実行してください。", True, detail) - return set_status(str(exc), True) @@ -1247,14 +1198,6 @@ async def on_render(event=None): _set_data_ready(True) set_status(f"描画に成功しました({plotted_count}系列、スキップ{skip_rows}行)。") except Exception as exc: - if _is_parentnode_error(exc): - _log_suppressed("on_render", exc) - if _has_rendered_plot(): - set_status("描画に成功しました。") - else: - detail = _format_internal_error_detail(exc, "on_render") - set_status("内部エラーを自動回避しました。もう一度描画を実行してください。", True, detail) - return set_status(str(exc), True) finally: _safe_close_figure(fig) @@ -1275,11 +1218,6 @@ async def on_save_plot(event=None): window.downloadDataUri(filename, data_uri) set_status(f"描画データを保存しました: {filename}") except Exception as exc: - if _is_parentnode_error(exc): - _log_suppressed("on_save_plot", exc) - detail = _format_internal_error_detail(exc, "on_save_plot") - set_status("内部エラーを自動回避しました。もう一度保存を実行してください。", True, detail) - return set_status(str(exc), True) finally: _safe_close_figure(fig) @@ -1330,13 +1268,5 @@ def trigger_render(event=None): _switch_tab("plot") if hasattr(window, "ensureSeriesItems"): window.ensureSeriesItems() -try: - _register_js_api() - _bind_events() -except Exception as exc: - if _is_parentnode_error(exc): - _log_suppressed("initialization", exc) - detail = _format_internal_error_detail(exc, "initialization") - set_status("内部エラーを自動回避しました。ページを再読み込みして再試行してください。", True, detail) - else: - set_status(f"初期化エラー: {exc}", True) +_register_js_api() +_bind_events() diff --git a/tests/e2e/helpers.py b/tests/e2e/helpers.py index 832fc4d..936ef49 100644 --- a/tests/e2e/helpers.py +++ b/tests/e2e/helpers.py @@ -33,11 +33,8 @@ def _add(self, kind, text): def mark(self, stage): self.stage = stage - def parentnode(self): - return [e for e in self.events if "parentNode" in e[3]] - def report(self): - print(f"\n[console-errors] total={len(self.events)} parentNode={len(self.parentnode())}") + print(f"\n[console-errors] total={len(self.events)}") for t, stage, kind, text in self.events: print(f" [{t}s][{stage}][{kind}] {text}") From d2d7d31be20b37f2b2043d98b200336af01b276d Mon Sep 17 00:00:00 2001 From: mioupa Date: Wed, 7 Oct 2026 18:21:20 +0900 Subject: [PATCH 13/37] Move UI state to a JS settings object; make Python logic pure functions JS: defaults.js, state.js (single source of truth), bridge.js (JSON calls, debounced scheduling), ui/* bind inputs to the state and render payloads. Python: errors, settings (schema v1, Japanese validation), plotting (transitional create_figure), api (JSON in/out); loader/runner/fonts are pure. py/main.py only registers window.mplgui. Unit tests added. Co-Authored-By: Claude Opus 5.5 --- index.html | 2 +- js/bridge.js | 220 ++++- js/defaults.js | 54 ++ js/main.js | 87 +- js/state.js | 79 ++ js/ui/colorPicker.js | 57 +- js/ui/customCode.js | 66 +- js/ui/dataPreview.js | 33 + js/ui/forms.js | 75 ++ js/ui/notify.js | 1 + js/ui/plotView.js | 10 + js/ui/saveFormat.js | 34 +- js/ui/series.js | 346 +++----- js/ui/tabs.js | 24 + py/main.py | 1289 +--------------------------- py/mplgui/api.py | 139 +++ py/mplgui/errors.py | 22 + py/mplgui/fonts.py | 29 +- py/mplgui/loader.py | 107 ++- py/mplgui/plotting.py | 417 +++++++++ py/mplgui/runner.py | 279 +++++- py/mplgui/settings.py | 459 ++++++++++ pyscript.toml | 4 + tests/e2e/helpers.py | 38 +- tests/unit/conftest.py | 30 + tests/unit/test_api.py | 98 +++ tests/unit/test_defaults_parity.py | 32 + tests/unit/test_loader.py | 115 +++ tests/unit/test_mplgui_modules.py | 39 +- tests/unit/test_pages_publish.py | 14 + tests/unit/test_plotting.py | 323 +++++++ tests/unit/test_runner.py | 171 ++++ tests/unit/test_settings.py | 215 +++++ 33 files changed, 3173 insertions(+), 1735 deletions(-) create mode 100644 js/defaults.js create mode 100644 js/state.js create mode 100644 js/ui/dataPreview.js create mode 100644 js/ui/forms.js create mode 100644 js/ui/plotView.js create mode 100644 js/ui/tabs.js create mode 100644 py/mplgui/api.py create mode 100644 py/mplgui/errors.py create mode 100644 py/mplgui/plotting.py create mode 100644 py/mplgui/settings.py create mode 100644 tests/unit/conftest.py create mode 100644 tests/unit/test_api.py create mode 100644 tests/unit/test_defaults_parity.py create mode 100644 tests/unit/test_loader.py create mode 100644 tests/unit/test_plotting.py create mode 100644 tests/unit/test_runner.py create mode 100644 tests/unit/test_settings.py diff --git a/index.html b/index.html index 64b89ba..176ce2a 100644 --- a/index.html +++ b/index.html @@ -1,5 +1,5 @@ - + diff --git a/js/bridge.js b/js/bridge.js index ff8cf21..4b07121 100644 --- a/js/bridge.js +++ b/js/bridge.js @@ -1,52 +1,210 @@ -// Python 側(PyScript)との橋渡し。Python は window 上の関数を直接呼ぶため window に公開する。 -window.__matplotDataReady = false; -window.setMatplotDataReady = (ready) => { - window.__matplotDataReady = !!ready; -}; -window.downloadDataUri = (filename, dataUri) => { - if (!filename || !dataUri) return; - const link = document.createElement("a"); - link.href = dataUri; - link.download = filename; - document.body.appendChild(link); - link.click(); - link.remove(); -}; +// Python(window.mplgui)との唯一の窓口。設定は JSON 文字列で渡し、結果の JSON を UI へ反映する。 +// - 再描画は設定変更の 250ms 後(デバウンス)、読込はファイル/ヘッダ 0ms・区切り文字 450ms。保存設定の変更では再描画しない。 +// - 描画の要求はすべてここを通る(世代番号・合体・起動待ちキューは Step 5 でここに足す)。 +import { subscribe, toJson, getSettings } from "./state.js"; +import { setStatus } from "./ui/notify.js"; +import { showPlot } from "./ui/plotView.js"; +import { showPreview } from "./ui/dataPreview.js"; +import { setColumns } from "./ui/series.js"; + +const RENDER_DELAY_MS = 250; +const LOAD_DELAY_MS = { file: 0, header: 0, delimiter: 450 }; +const JP_FONT_URL = "https://cdn.jsdelivr.net/gh/googlefonts/noto-cjk@main/Sans/OTF/Japanese/NotoSansCJKjp-Regular.otf"; +const root = document.documentElement; +let api = null; // window.mplgui(Python 側が登録) +let selectedFile = null; +let dataReady = false; let renderTimer = null; let loadTimer = null; +let renderGeneration = 0; +let loadGeneration = 0; +let customCodeProvider = () => null; // 有効なカスタムコードの文字列、無効なら null +let readyCallbacks = []; +let fontPromise = null; + +const setDataState = (state) => { + dataReady = state === "ready"; + root.dataset.dataState = state; // none | loading | ready | error +}; +const setRenderState = (state) => { + root.dataset.renderState = state; // idle | pending | rendering +}; +export const isPythonReady = () => api !== null; +export const canAutoRender = () => dataReady; +export const setCustomCodeProvider = (fn) => { + customCodeProvider = fn; +}; + +const callPython = (name, ...args) => JSON.parse(api[name](...args)); -export const hasSelectedFile = () => { - const fileInput = document.getElementById("fileInput"); - return !!(fileInput && fileInput.files && fileInput.files.length > 0); +const reportError = (error) => { + setStatus(error.message, true, error.detail || ""); }; -export const canAutoRender = () => !!window.__matplotDataReady; -export const runPyAction = (name) => { - const fn = window[name]; - if (typeof fn === "function") { - fn(); - return true; +const reportException = (err, context) => { + const detail = `context: ${context}\n${err && err.stack ? err.stack : String(err)}`; + setStatus("内部エラーが発生しました。もう一度操作してください。", true, detail); +}; + +// 日本語フォントは初回の描画前に1回だけ取得する。失敗しても描画は継続する(A6 で font-cache.js に置き換える)。 +const ensureFont = () => { + if (!fontPromise) { + fontPromise = (async () => { + try { + const response = await fetch(JP_FONT_URL); + if (!response.ok) return; + const bytes = new Uint8Array(await response.arrayBuffer()); + api.registerFont(bytes); + } catch (_) { + // フォールバックフォントで継続する + } + })(); + } + return fontPromise; +}; + +const doRender = async () => { + if (!api) return; + const generation = ++renderGeneration; + root.dataset.renderGeneration = String(generation); + setRenderState("rendering"); + try { + await ensureFont(); + if (generation !== renderGeneration) return; // 新しい要求が出ている + const result = callPython("render", toJson(), customCodeProvider()); + if (generation !== renderGeneration) return; + if (result.ok) { + showPlot(result.image, generation); + setStatus(`描画に成功しました(${result.seriesCount}系列、スキップ${result.skipRows}行)。`); + } else { + reportError(result.error); + } + } catch (err) { + if (generation === renderGeneration) reportException(err, "render"); + } finally { + if (generation === renderGeneration) setRenderState("idle"); + } +}; + +const doLoad = async () => { + if (!api || !selectedFile) return; + const generation = ++loadGeneration; + setDataState("loading"); + try { + const bytes = new Uint8Array(await selectedFile.arrayBuffer()); + if (generation !== loadGeneration) return; + const loadJson = JSON.stringify({ version: getSettings().version, load: getSettings().load }); + const result = callPython("loadFile", selectedFile.name, bytes, loadJson); + if (generation !== loadGeneration) return; + if (!result.ok) { + setDataState("error"); + reportError(result.error); + return; + } + setColumns(result.columns); + showPreview(result.preview); + setDataState("ready"); + setStatus(""); + await doRender(); + } catch (err) { + if (generation === loadGeneration) { + setDataState("error"); + reportException(err, "loadFile"); + } } - return false; }; -export const scheduleRender = (delay = 250) => { +export const scheduleRender = (delay = RENDER_DELAY_MS) => { if (renderTimer) window.clearTimeout(renderTimer); + setRenderState("pending"); renderTimer = window.setTimeout(() => { renderTimer = null; - if (canAutoRender()) { - runPyAction("requestRender"); + if (api && canAutoRender()) { + doRender(); + } else { + setRenderState("idle"); } }, delay); }; -export const scheduleLoad = (delay = 350) => { - if (!hasSelectedFile()) return; +export const scheduleLoad = (delay = LOAD_DELAY_MS.file) => { + if (!selectedFile) return; + setDataState("loading"); // 読込が済むまで自動再描画は止める if (loadTimer) window.clearTimeout(loadTimer); loadTimer = window.setTimeout(() => { loadTimer = null; - window.setMatplotDataReady(false); - runPyAction("requestLoadColumns"); + doLoad(); }, delay); }; + +export const renderNow = () => { + if (api && canAutoRender()) doRender(); +}; + +export const selectFile = (file) => { + selectedFile = file || null; + scheduleLoad(LOAD_DELAY_MS.file); +}; + +const downloadDataUri = (filename, dataUri) => { + if (!filename || !dataUri) return; + const link = document.createElement("a"); + link.href = dataUri; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); +}; + +export const saveNow = async () => { + if (!api) return; + try { + await ensureFont(); + const result = callPython("save", toJson(), customCodeProvider()); + if (!result.ok) { + reportError(result.error); + return; + } + downloadDataUri(result.filename, result.dataUri); + setStatus(`描画データを保存しました: ${result.filename}`); + } catch (err) { + reportException(err, "save"); + } +}; + +export const getDefaultCustomCode = () => (api ? api.defaultCustomCode() : ""); +export const onPythonReady = (fn) => { + if (api) fn(); + else readyCallbacks.push(fn); +}; + +const handlePythonReady = () => { + if (api || !window.mplgui) return; + api = window.mplgui; + root.dataset.appState = "ready"; + const callbacks = readyCallbacks; + readyCallbacks = []; + for (const fn of callbacks) fn(); + scheduleLoad(LOAD_DELAY_MS.file); // 起動前に選ばれたファイルがあれば読み込む +}; + +// 設定の変更を、読込・再描画・無視のどれにするか振り分ける +const routeChange = (_settings, change) => { + if (change.origin !== "user") return; + const path = change.kind === "path" ? change.path : "series"; + if (path.startsWith("load.")) { + scheduleLoad(path === "load.delimiter" ? LOAD_DELAY_MS.delimiter : LOAD_DELAY_MS.header); + } else if (!path.startsWith("save.")) { + scheduleRender(); + } +}; + +export const initBridge = () => { + root.dataset.appState = root.dataset.appState || "starting"; + setDataState("none"); + setRenderState("idle"); + subscribe(routeChange); + if (window.mplgui) handlePythonReady(); + else window.addEventListener("mplgui-ready", handlePythonReady, { once: true }); +}; diff --git a/js/defaults.js b/js/defaults.js new file mode 100644 index 0000000..8a1f8c2 --- /dev/null +++ b/js/defaults.js @@ -0,0 +1,54 @@ +// 設定オブジェクトの既定値。純粋な ES module(DOM に触れない)。 +// py/mplgui/settings.py の default_settings() と同一でなければならない(tests/unit/test_defaults_parity.py が比較する)。 + +export const SCHEMA_VERSION = 1; + +// UDカラーセット(系列の既定色の候補。この順で未使用の色を割り当てる) +export const PALETTE = [ + "#FF4B00", + "#005AFF", + "#03AF7A", + "#4DC4FF", + "#F6AA00", + "#FFF100", + "#990099", + "#84919E", + "#000000", + "#804000", + "#FF8082", +]; + +export const DEFAULT_SERIES = { + id: "s1", + x: "", + y: "", + color: "#FF4B00", + lineWidth: 2, + lineStyle: "solid", + markerSize: null, + label: "", + secondaryAxis: false, +}; + +export const DEFAULT_SETTINGS = { + version: SCHEMA_VERSION, + load: { delimiter: "", hasHeader: true }, + plot: { + type: "line", + skipRows: 0, + xColumn: "", + title: "", + fontSize: 15, + figure: { width: 8, height: 6 }, + legend: { location: "best" }, + grid: { major: false, minor: false }, + margins: { left: null, right: null, bottom: null, top: null }, + }, + axes: { + x: { label: "", scale: "linear", min: null, max: null }, + y: { label: "", scale: "linear", min: null, max: null }, + y2: { label: "", scale: "linear", min: null, max: null }, + }, + series: [DEFAULT_SERIES], + save: { filename: "", format: "png", transparent: false }, +}; diff --git a/js/main.js b/js/main.js index 9f1b19f..7baba3c 100644 --- a/js/main.js +++ b/js/main.js @@ -1,73 +1,40 @@ -// エントリ。DOM 構築後(module は defer)に UI を初期化する。 -import { scheduleLoad, scheduleRender } from "./bridge.js"; +// エントリ。DOM 構築後(module は defer)に UI を設定オブジェクトへ結び付ける。 +// 画面の入力 → state.js(設定オブジェクト)→ bridge.js が JSON で Python を呼ぶ → 結果を ui/* が描く +import { subscribe } from "./state.js"; +import { getDefaultCustomCode, initBridge, onPythonReady, saveNow, selectFile } from "./bridge.js"; import { bindColorPanelOutsideClick } from "./ui/colorPicker.js"; -import { bindCustomCodeEvents } from "./ui/customCode.js"; -import { syncSaveFormatOptions, bindSaveFormatEvents } from "./ui/saveFormat.js"; -import { - bindSeriesEvents, - syncMarkerSizeForPlotType, - syncPlotTypeUI, - syncY2LabelUI, -} from "./ui/series.js"; -// series.js は window.ensureSeriesItems / window.refreshSeriesColumnOptions を公開する。 -// bridge.js は window.setMatplotDataReady / window.downloadDataUri / window.__matplotDataReady を公開する。 -// Python 側が登録する window.requestRender / window.requestLoadColumns は bridge.js から呼ぶ。 +import { bindCustomCodeEvents, initDefaultCode } from "./ui/customCode.js"; +import { applyStateToForm, bindForms } from "./ui/forms.js"; +import { bindSaveFormatEvents, syncSaveFormatOptions } from "./ui/saveFormat.js"; +import { bindSeriesEvents, renderSeriesList, setColumns, syncMarkerSizeDisplay, syncVisibility } from "./ui/series.js"; +import { bindTabs } from "./ui/tabs.js"; const fileInput = document.getElementById("fileInput"); -const delimiterInput = document.getElementById("delimiter"); -const hasHeaderInput = document.getElementById("hasHeader"); - if (fileInput) { - fileInput.addEventListener("change", () => scheduleLoad(0)); -} -if (delimiterInput) { - delimiterInput.addEventListener("input", () => scheduleLoad(450)); -} -if (hasHeaderInput) { - hasHeaderInput.addEventListener("change", () => scheduleLoad(0)); + fileInput.addEventListener("change", () => selectFile(fileInput.files && fileInput.files[0])); } +const savePlotBtn = document.getElementById("savePlotBtn"); +if (savePlotBtn) savePlotBtn.addEventListener("click", () => saveNow()); +bindTabs(); +bindForms(); bindSeriesEvents(); bindSaveFormatEvents(); bindCustomCodeEvents(); bindColorPanelOutsideClick(); -const autoRenderIds = [ - "xColumn", - "title", - "xLabel", - "yLabel", - "y2Label", - "xScale", - "yScale", - "xMin", - "xMax", - "yMin", - "yMax", - "y2Scale", - "y2Min", - "y2Max", - "fontSize", - "showMajorGrid", - "showMinorGrid", - "legendLocation", - "figWidth", - "figHeight", - "subplotLeft", - "subplotRight", - "subplotBottom", - "subplotTop", - "skipRows", -]; -for (const id of autoRenderIds) { - const el = document.getElementById(id); - if (!el) continue; - el.addEventListener("input", () => scheduleRender()); - el.addEventListener("change", () => scheduleRender()); -} +// state の変更に応じた画面の更新(再描画・読込の予約は bridge.js が購読して行う) +subscribe((_settings, change) => { + if (change.kind === "series-list") { + renderSeriesList(); + } else { + syncVisibility(); + if (change.kind === "path" && change.path === "plot.type") syncMarkerSizeDisplay(); + } +}); -window.ensureSeriesItems(); -syncPlotTypeUI(); -syncMarkerSizeForPlotType(); -syncY2LabelUI(); +applyStateToForm(); +setColumns([]); syncSaveFormatOptions(); +initBridge(); +onPythonReady(() => initDefaultCode(getDefaultCustomCode())); diff --git a/js/state.js b/js/state.js new file mode 100644 index 0000000..0955792 --- /dev/null +++ b/js/state.js @@ -0,0 +1,79 @@ +// 設定オブジェクト(単一の正)。画面の入力はすべてここへ書き込み、描画・読込はここから JSON を作る。 +// DOM には触れない。変更は subscribe で購読する。 +import { DEFAULT_SERIES, DEFAULT_SETTINGS, PALETTE } from "./defaults.js"; + +const clone = (value) => JSON.parse(JSON.stringify(value)); + +let settings = clone(DEFAULT_SETTINGS); +let seriesCounter = 1; // 系列 id は連番で払い出し、削除しても再利用しない +const listeners = new Set(); + +export const getSettings = () => settings; +export const toJson = () => JSON.stringify(settings); + +export const subscribe = (fn) => { + listeners.add(fn); + return () => listeners.delete(fn); +}; + +const notify = (change) => { + for (const fn of Array.from(listeners)) fn(settings, change); +}; + +// "plot.figure.width" のようなパスの値を返す +export const getPath = (path, source = settings) => path.split(".").reduce((obj, key) => (obj == null ? undefined : obj[key]), source); + +const assign = (target, path, value) => { + const keys = path.split("."); + const last = keys.pop(); + let obj = target; + for (const key of keys) obj = obj[key]; + obj[last] = value; +}; + +// origin: "user"(既定。変更の通知先で再描画等を予約する)| "reconcile"(読込後の整合のため。再描画は呼び出し側が行う) +export const setPath = (path, value, origin = "user") => { + if (getPath(path) === value) return; + assign(settings, path, value); + notify({ kind: "path", path, origin }); +}; + +const findSeries = (id) => settings.series.find((s) => s.id === id); + +export const updateSeries = (id, patch, origin = "user") => { + const series = findSeries(id); + if (!series) return; + let changed = false; + for (const [key, value] of Object.entries(patch)) { + if (series[key] !== value) { + series[key] = value; + changed = true; + } + } + if (changed) notify({ kind: "series-field", id, fields: Object.keys(patch), origin }); +}; + +// 既定色: パレットのうち未使用の最初の色。すべて使用済みなら系列数で循環する。 +export const nextSeriesColor = () => { + const used = new Set(settings.series.map((s) => String(s.color).toUpperCase())); + const free = PALETTE.find((c) => !used.has(c.toUpperCase())); + return free || PALETTE[settings.series.length % PALETTE.length]; +}; + +export const addSeries = (preset = {}) => { + seriesCounter += 1; + const series = { ...clone(DEFAULT_SERIES), color: nextSeriesColor(), ...preset, id: `s${seriesCounter}` }; + settings.series.push(series); + notify({ kind: "series-list", origin: "user" }); + return series; +}; + +export const removeSeries = (id) => { + if (settings.series.length <= 1) return; + const idx = settings.series.findIndex((s) => s.id === id); + if (idx < 0) return; + settings.series.splice(idx, 1); + notify({ kind: "series-list", origin: "user" }); +}; + +export const hasSecondaryAxis = () => settings.series.some((s) => s.secondaryAxis); diff --git a/js/ui/colorPicker.js b/js/ui/colorPicker.js index b43adc8..bc96405 100644 --- a/js/ui/colorPicker.js +++ b/js/ui/colorPicker.js @@ -1,23 +1,18 @@ -// 系列ごとの色選択UI(オーバーレイパネル)。 -import { scheduleRender } from "../bridge.js"; +// 系列ごとの色選択UI(オーバーレイパネル)。選んだ色は onChange(color) で呼び出し側(state)へ渡す。 +import { PALETTE } from "../defaults.js"; -const seriesList = document.getElementById("seriesList"); +const normalizeColor = (value) => String(value || "").trim().toUpperCase(); -export const SERIES_COLORS = [ - "#FF4B00", - "#005AFF", - "#03AF7A", - "#4DC4FF", - "#F6AA00", - "#FFF100", - "#990099", - "#84919E", - "#000000", - "#804000", - "#FF8082", -]; +const closeAllColorPanels = (exceptPanel = null) => { + const seriesList = document.getElementById("seriesList"); + if (!seriesList) return; + for (const p of seriesList.querySelectorAll(".series-color-panel")) { + if (exceptPanel && p === exceptPanel) continue; + p.classList.add("hidden"); + } +}; -export const bindSeriesColorControls = (item) => { +export const bindSeriesColorControls = (item, onChange) => { const trigger = item.querySelector(".series-color-trigger"); const panel = item.querySelector(".series-color-panel"); const grid = item.querySelector(".series-color-grid"); @@ -27,33 +22,20 @@ export const bindSeriesColorControls = (item) => { const valueLabel = item.querySelector(".series-color-value"); if (!trigger || !panel || !grid || !customToggle || !colorInput || !swatch || !valueLabel) return; - const closeAllColorPanels = (exceptPanel = null) => { - if (!seriesList) return; - const panels = seriesList.querySelectorAll(".series-color-panel"); - for (const p of panels) { - if (exceptPanel && p === exceptPanel) continue; - p.classList.add("hidden"); - } - }; - - const normalizeColor = (value) => String(value || "").trim().toUpperCase(); - const syncSeriesColorView = () => { const current = normalizeColor(colorInput.value); swatch.style.backgroundColor = current || "#000000"; valueLabel.textContent = current || "#000000"; - const isPaletteColor = SERIES_COLORS.some((c) => normalizeColor(c) === current); + const isPaletteColor = PALETTE.some((c) => normalizeColor(c) === current); colorInput.classList.toggle("hidden", isPaletteColor); - const chips = grid.querySelectorAll(".series-color-chip"); - for (const chip of chips) { - const chipColor = normalizeColor(chip.dataset.color); - chip.classList.toggle("active", chipColor === current); + for (const chip of grid.querySelectorAll(".series-color-chip")) { + chip.classList.toggle("active", normalizeColor(chip.dataset.color) === current); } }; grid.innerHTML = ""; - for (const color of SERIES_COLORS) { + for (const color of PALETTE) { const chip = document.createElement("button"); chip.type = "button"; chip.className = "series-color-chip"; @@ -64,7 +46,7 @@ export const bindSeriesColorControls = (item) => { colorInput.value = color; syncSeriesColorView(); panel.classList.add("hidden"); - scheduleRender(); + onChange(color); }); grid.appendChild(chip); } @@ -86,6 +68,7 @@ export const bindSeriesColorControls = (item) => { colorInput.addEventListener("input", () => { syncSeriesColorView(); + onChange(colorInput.value); }); syncSeriesColorView(); @@ -94,11 +77,11 @@ export const bindSeriesColorControls = (item) => { // パネル外クリックでオーバーレイを閉じる export const bindColorPanelOutsideClick = () => { document.addEventListener("click", (event) => { + const seriesList = document.getElementById("seriesList"); if (!seriesList) return; const target = event.target; if (target && target.closest(".series-color-picker")) return; - const panels = seriesList.querySelectorAll(".series-color-panel"); - for (const panel of panels) { + for (const panel of seriesList.querySelectorAll(".series-color-panel")) { panel.classList.add("hidden"); } }); diff --git a/js/ui/customCode.js b/js/ui/customCode.js index 7ba9228..574a9be 100644 --- a/js/ui/customCode.js +++ b/js/ui/customCode.js @@ -1,41 +1,45 @@ -// Pythonコード(beta)タブのボタン・ショートカット。 -import { canAutoRender, runPyAction, scheduleRender } from "../bridge.js"; +// Pythonコード(beta)タブ。コード本文と「使用する」は設定オブジェクトには入れず、描画要求のたびに別引数で渡す。 +import { canAutoRender, renderNow, scheduleRender, setCustomCodeProvider } from "../bridge.js"; -const customCodeInput = document.getElementById("customPyCode"); -const useCustomCodeInput = document.getElementById("useCustomCode"); -const applyCustomCodeBtn = document.getElementById("applyCustomCodeBtn"); -const resetCustomCodeBtn = document.getElementById("resetCustomCodeBtn"); +const customCodeInput = () => document.getElementById("customPyCode"); +const useCustomCodeInput = () => document.getElementById("useCustomCode"); + +// 有効時はコード文字列、無効時は null +export const getCustomCode = () => { + const use = useCustomCodeInput(); + const editor = customCodeInput(); + return use && use.checked && editor ? String(editor.value || "") : null; +}; + +// Python 起動後に既定コードを入れる(未入力のときだけ) +export const initDefaultCode = (defaultCode) => { + const editor = customCodeInput(); + if (!editor) return; + editor.dataset.defaultCode = defaultCode; + if (!String(editor.value || "").trim()) editor.value = defaultCode; +}; export const bindCustomCodeEvents = () => { - if (useCustomCodeInput) { - useCustomCodeInput.addEventListener("change", () => scheduleRender(0)); - } - if (applyCustomCodeBtn) { - applyCustomCodeBtn.addEventListener("click", () => { - if (canAutoRender()) { - runPyAction("requestRender"); - } - }); - } - if (resetCustomCodeBtn && customCodeInput) { - resetCustomCodeBtn.addEventListener("click", () => { - const defaultCode = customCodeInput.dataset.defaultCode || ""; - customCodeInput.value = defaultCode; - if (useCustomCodeInput) { - useCustomCodeInput.checked = false; - } - if (canAutoRender()) { - runPyAction("requestRender"); - } + setCustomCodeProvider(getCustomCode); + const use = useCustomCodeInput(); + const editor = customCodeInput(); + const applyBtn = document.getElementById("applyCustomCodeBtn"); + const resetBtn = document.getElementById("resetCustomCodeBtn"); + + if (use) use.addEventListener("change", () => scheduleRender(0)); + if (applyBtn) applyBtn.addEventListener("click", () => renderNow()); + if (resetBtn && editor) { + resetBtn.addEventListener("click", () => { + editor.value = editor.dataset.defaultCode || ""; + if (use) use.checked = false; + if (canAutoRender()) renderNow(); }); } - if (customCodeInput) { - customCodeInput.addEventListener("keydown", (event) => { + if (editor) { + editor.addEventListener("keydown", (event) => { if (!(event.ctrlKey || event.metaKey) || event.key !== "Enter") return; event.preventDefault(); - if (canAutoRender()) { - runPyAction("requestRender"); - } + renderNow(); }); } }; diff --git a/js/ui/dataPreview.js b/js/ui/dataPreview.js new file mode 100644 index 0000000..3a04e54 --- /dev/null +++ b/js/ui/dataPreview.js @@ -0,0 +1,33 @@ +// データ確認タブの表(先頭100行。Python が返した preview ペイロードから組み立てる)。 +const el = (tag, text) => { + const node = document.createElement(tag); + if (text !== undefined) node.textContent = text; + return node; +}; + +export const showPreview = (preview) => { + const dataArea = document.getElementById("dataArea"); + if (!dataArea) return; + const truncated = preview.truncated ? `(先頭${preview.limit}行のみ表示)` : ""; + const meta = el("div", `行数: ${preview.totalRows} / 列数: ${preview.totalColumns} ${truncated}`); + meta.className = "data-meta"; + + const table = el("table"); + table.border = "0"; + table.className = "dataframe data-table"; + const headRow = el("tr"); + for (const name of preview.columns) headRow.appendChild(el("th", name)); + table.appendChild(el("thead")).appendChild(headRow); + const body = el("tbody"); + for (const row of preview.rows) { + const tr = el("tr"); + for (const cell of row) tr.appendChild(el("td", cell)); + body.appendChild(tr); + } + table.appendChild(body); + + const wrap = el("div"); + wrap.className = "table-wrap"; + wrap.appendChild(table); + dataArea.replaceChildren(meta, wrap); +}; diff --git a/js/ui/forms.js b/js/ui/forms.js new file mode 100644 index 0000000..4785367 --- /dev/null +++ b/js/ui/forms.js @@ -0,0 +1,75 @@ +// 画面の入力欄 ⇄ 設定オブジェクトの対応表。入力は state に書き込み、初期表示は state から描く。 +import { getPath, setPath } from "../state.js"; + +// [要素 id, 設定のパス, 種別] +export const FIELDS = [ + ["delimiter", "load.delimiter", "text"], + ["hasHeader", "load.hasHeader", "checkbox"], + ["plotType", "plot.type", "select"], + ["skipRows", "plot.skipRows", "number"], + ["xColumn", "plot.xColumn", "select"], + ["title", "plot.title", "text"], + ["xLabel", "axes.x.label", "text"], + ["yLabel", "axes.y.label", "text"], + ["y2Label", "axes.y2.label", "text"], + ["xScale", "axes.x.scale", "select"], + ["xMin", "axes.x.min", "number"], + ["xMax", "axes.x.max", "number"], + ["yScale", "axes.y.scale", "select"], + ["yMin", "axes.y.min", "number"], + ["yMax", "axes.y.max", "number"], + ["y2Scale", "axes.y2.scale", "select"], + ["y2Min", "axes.y2.min", "number"], + ["y2Max", "axes.y2.max", "number"], + ["fontSize", "plot.fontSize", "number"], + ["showMajorGrid", "plot.grid.major", "checkbox"], + ["showMinorGrid", "plot.grid.minor", "checkbox"], + ["legendLocation", "plot.legend.location", "select"], + ["figWidth", "plot.figure.width", "number"], + ["figHeight", "plot.figure.height", "number"], + ["subplotLeft", "plot.margins.left", "number"], + ["subplotRight", "plot.margins.right", "number"], + ["subplotBottom", "plot.margins.bottom", "number"], + ["subplotTop", "plot.margins.top", "number"], + ["saveFilename", "save.filename", "text"], + ["saveTransparent", "save.transparent", "checkbox"], + ["saveFormat", "save.format", "select"], +]; + +// 数値欄: 空 → null、数値 → number、数値にならない入力 → 文字列のまま(Python が日本語のエラーにする) +export const readNumber = (input) => { + if (input.validity && input.validity.badInput) return "invalid"; + const raw = input.value.trim(); + if (raw === "") return null; + const num = Number(raw); + return Number.isFinite(num) ? num : raw; +}; + +export const readField = (el, kind) => { + if (kind === "checkbox") return el.checked; + if (kind === "number") return readNumber(el); + return el.value; +}; + +const writeField = (el, kind, value) => { + if (kind === "checkbox") el.checked = !!value; + else el.value = value === null || value === undefined ? "" : String(value); +}; + +// state → 画面(初期表示用。入力中の欄を書き換えないよう、通常の入力では呼ばない) +export const applyStateToForm = () => { + for (const [id, path, kind] of FIELDS) { + const el = document.getElementById(id); + if (el) writeField(el, kind, getPath(path)); + } +}; + +export const bindForms = () => { + for (const [id, path, kind] of FIELDS) { + const el = document.getElementById(id); + if (!el) continue; + const onEdit = () => setPath(path, readField(el, kind)); + el.addEventListener("input", onEdit); + el.addEventListener("change", onEdit); + } +}; diff --git a/js/ui/notify.js b/js/ui/notify.js index 5939030..8654872 100644 --- a/js/ui/notify.js +++ b/js/ui/notify.js @@ -3,6 +3,7 @@ export const setStatus = (message, isError = false, detail = "") => { const status = document.getElementById("status"); if (status) { status.textContent = message || ""; + status.dataset.kind = !message ? "" : isError ? "error" : "ok"; status.style.color = isError ? "var(--status-error)" : "var(--status-ok)"; } diff --git a/js/ui/plotView.js b/js/ui/plotView.js new file mode 100644 index 0000000..aae1403 --- /dev/null +++ b/js/ui/plotView.js @@ -0,0 +1,10 @@ +// プロット画像の表示。 +export const showPlot = (dataUri, generation) => { + const plotArea = document.getElementById("plotArea"); + if (!plotArea) return; + const img = document.createElement("img"); + img.alt = "plot"; + img.src = dataUri; + img.dataset.generation = String(generation); + plotArea.replaceChildren(img); +}; diff --git a/js/ui/saveFormat.js b/js/ui/saveFormat.js index b53602d..f732e75 100644 --- a/js/ui/saveFormat.js +++ b/js/ui/saveFormat.js @@ -1,34 +1,30 @@ -// 保存形式セレクトの選択肢(背景透過時は png / svg のみ)。 -const saveFormatSelect = document.getElementById("saveFormat"); -const saveTransparentInput = document.getElementById("saveTransparent"); +// 保存形式セレクトの選択肢(背景透過時は png / svg のみ)。選択肢が変わったら state の保存形式も追従させる。 +import { getSettings, setPath } from "../state.js"; const SAVE_FORMATS_ALL = ["png", "jpg", "svg", "pdf"]; const SAVE_FORMATS_TRANSPARENT = ["png", "svg"]; + export const syncSaveFormatOptions = () => { - if (!saveFormatSelect) return; - const allowTransparentOnly = !!(saveTransparentInput && saveTransparentInput.checked); - const formats = allowTransparentOnly ? SAVE_FORMATS_TRANSPARENT : SAVE_FORMATS_ALL; - const currentValue = (saveFormatSelect.value || "").toLowerCase(); + const select = document.getElementById("saveFormat"); + if (!select) return; + const formats = getSettings().save.transparent ? SAVE_FORMATS_TRANSPARENT : SAVE_FORMATS_ALL; + const current = String(getSettings().save.format || select.value || "").toLowerCase(); - saveFormatSelect.innerHTML = ""; + select.innerHTML = ""; for (const fmt of formats) { const opt = document.createElement("option"); opt.value = fmt; opt.textContent = fmt; - saveFormatSelect.appendChild(opt); - } - - if (formats.includes(currentValue)) { - saveFormatSelect.value = currentValue; - } else { - saveFormatSelect.value = formats[0]; + select.appendChild(opt); } + select.value = formats.includes(current) ? current : formats[0]; + setPath("save.format", select.value); }; export const bindSaveFormatEvents = () => { - if (saveTransparentInput) { - saveTransparentInput.addEventListener("change", () => { - syncSaveFormatOptions(); - }); + const transparent = document.getElementById("saveTransparent"); + if (transparent) { + // forms.js の入力ハンドラ(state 更新)より後に走るよう、change を購読する + transparent.addEventListener("change", syncSaveFormatOptions); } }; diff --git a/js/ui/series.js b/js/ui/series.js index c7f0157..36333a3 100644 --- a/js/ui/series.js +++ b/js/ui/series.js @@ -1,180 +1,72 @@ -// 系列カード(追加・削除・列セレクト・種別/第2軸に応じた表示切替)。 -import { scheduleRender } from "../bridge.js"; -import { SERIES_COLORS, bindSeriesColorControls } from "./colorPicker.js"; +// 系列カード(追加・削除・列セレクト・種別/第2軸に応じた表示切替)。カードは state の系列リストから描く。 +import { addSeries, getSettings, hasSecondaryAxis, removeSeries, updateSeries, setPath } from "../state.js"; +import { bindSeriesColorControls } from "./colorPicker.js"; +import { readNumber } from "./forms.js"; -const seriesList = document.getElementById("seriesList"); -const addSeriesBtn = document.getElementById("addSeriesBtn"); -const plotTypeSelect = document.getElementById("plotType"); -const globalXGroup = document.getElementById("globalXGroup"); -const xColumnSelect = document.getElementById("xColumn"); -const y2LabelGroup = document.getElementById("y2LabelGroup"); -const y2AxisSettingsGroup = document.getElementById("y2AxisSettingsGroup"); -let lastPlotType = plotTypeSelect ? plotTypeSelect.value : "line"; +let columns = []; // [{value: "__idx__0", label: "時間 [0]"}](読込のたびに更新) -const getColumnOptions = (includeIndex = false) => { - const xColumn = document.getElementById("xColumn"); - if (!xColumn) return []; - const options = []; - for (const opt of xColumn.options) { - if (!includeIndex && !opt.value) continue; - options.push({ value: opt.value, label: opt.textContent || opt.value }); - } - return options; -}; - -const fillSeriesYSelect = (selectEl, selectedValue = "") => { - if (!selectEl) return; - const columns = getColumnOptions(false); - selectEl.innerHTML = ""; +const esc = (text) => + String(text).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); - const autoOption = document.createElement("option"); - autoOption.value = ""; - autoOption.textContent = "(自動)"; - selectEl.appendChild(autoOption); +const defaultMarkerSize = (plotType) => (plotType === "line" ? 0 : 24); +const displayMarkerSize = (series, plotType) => (series.markerSize === null ? defaultMarkerSize(plotType) : series.markerSize); - for (const col of columns) { - const opt = document.createElement("option"); - opt.value = col.value; - opt.textContent = col.label; - selectEl.appendChild(opt); - } - - if (selectedValue && Array.from(selectEl.options).some((opt) => opt.value === selectedValue)) { - selectEl.value = selectedValue; - } -}; - -const fillSeriesXSelect = (selectEl, selectedValue = "") => { - if (!selectEl) return; - const columns = getColumnOptions(true); +const fillSelect = (selectEl, options, selected) => { selectEl.innerHTML = ""; - for (const col of columns) { - const opt = document.createElement("option"); - opt.value = col.value; - opt.textContent = col.label; - selectEl.appendChild(opt); - } - if (Array.from(selectEl.options).some((opt) => opt.value === selectedValue)) { - selectEl.value = selectedValue; - } else { - selectEl.value = ""; - } -}; - -export const syncPlotTypeUI = () => { - const type = plotTypeSelect ? plotTypeSelect.value : "line"; - const usePerSeriesX = type === "line" || type === "scatter"; - if (globalXGroup) { - globalXGroup.style.display = usePerSeriesX ? "none" : ""; - } - if (!seriesList) return; - const xGroups = seriesList.querySelectorAll(".series-x-wrap"); - for (const group of xGroups) { - group.style.display = usePerSeriesX ? "block" : "none"; + for (const opt of options) { + const o = document.createElement("option"); + o.value = opt.value; + o.textContent = opt.label; + selectEl.appendChild(o); } + selectEl.value = options.some((o) => o.value === selected) ? selected : ""; }; -export const syncMarkerSizeForPlotType = () => { - if (!seriesList || !plotTypeSelect) return; - const currentType = plotTypeSelect.value; - const markerInputs = seriesList.querySelectorAll(".series-marker-size"); - if (currentType === "scatter") { - for (const input of markerInputs) { - const current = Number(input.value); - if (!Number.isFinite(current) || current <= 0) { - input.value = "24"; - } - } - } else if (currentType === "line" && lastPlotType === "scatter") { - for (const input of markerInputs) { - const current = Number(input.value); - if (Number.isFinite(current) && current === 24) { - input.value = "0"; - } - } - } - lastPlotType = currentType; -}; +const xOptions = () => [{ value: "", label: "(index)" }, ...columns]; +const yOptions = () => [{ value: "", label: "(自動)" }, ...columns]; -export const syncY2LabelUI = () => { - if (!seriesList) return; - const hasSecondary = !!seriesList.querySelector(".series-use-y2:checked"); - if (y2LabelGroup) { - y2LabelGroup.style.display = hasSecondary ? "" : "none"; - } - if (y2AxisSettingsGroup) { - y2AxisSettingsGroup.style.display = hasSecondary ? "" : "none"; - } -}; - -const renumberSeries = () => { - if (!seriesList) return; - const items = Array.from(seriesList.querySelectorAll(".series-item")); - items.forEach((item, idx) => { - const title = item.querySelector(".series-item-title"); - if (title) title.textContent = `系列 ${idx + 1}`; - }); - const disableRemove = items.length <= 1; - items.forEach((item) => { - const removeBtn = item.querySelector(".remove-series"); - if (removeBtn) { - removeBtn.disabled = disableRemove; - removeBtn.style.opacity = disableRemove ? "0.5" : "1"; - removeBtn.style.cursor = disableRemove ? "not-allowed" : "pointer"; - } - }); -}; - -const addSeriesItem = (preset = {}) => { - if (!seriesList) return; - const item = document.createElement("div"); - item.className = "series-item"; - const color = preset.color || SERIES_COLORS[seriesList.children.length % SERIES_COLORS.length]; - const defaultMarkerSize = Object.prototype.hasOwnProperty.call(preset, "markerSize") - ? preset.markerSize - : plotTypeSelect && plotTypeSelect.value === "line" - ? 0 - : 24; - item.innerHTML = ` +const cardHtml = (series, plotType) => { + const id = (name) => `series-${series.id}-${name}`; + return `
系列
- - + +
- - + +
- +
-
-
${color}
+
${esc(series.color)}
- - + +
- - @@ -182,99 +74,129 @@ const addSeriesItem = (preset = {}) => {
- - + +
- - + +
`; +}; - seriesList.appendChild(item); - const ySelect = item.querySelector(".series-y"); - fillSeriesYSelect(ySelect, preset.yRequest || ""); - const xSelect = item.querySelector(".series-x"); - fillSeriesXSelect(xSelect, preset.xRequest || (xColumnSelect ? xColumnSelect.value : "")); - bindSeriesColorControls(item); +const buildCard = (series, plotType) => { + const item = document.createElement("div"); + item.className = "series-item"; + item.dataset.seriesId = series.id; + item.innerHTML = cardHtml(series, plotType); + fillSelect(item.querySelector(".series-x"), xOptions(), series.x); + fillSelect(item.querySelector(".series-y"), yOptions(), series.y); + item.querySelector(".series-line-style").value = series.lineStyle; + bindSeriesColorControls(item, (color) => updateSeries(series.id, { color })); + item.querySelector(".remove-series").addEventListener("click", () => removeSeries(series.id)); + return item; +}; - const lineStyleSelect = item.querySelector(".series-line-style"); - if (lineStyleSelect) { - lineStyleSelect.value = preset.lineStyle || "solid"; +export const syncVisibility = () => { + const settings = getSettings(); + const usePerSeriesX = settings.plot.type !== "bar"; + const globalX = document.getElementById("globalXGroup"); + if (globalX) globalX.style.display = usePerSeriesX ? "none" : ""; + for (const group of document.querySelectorAll("#seriesList .series-x-wrap")) { + group.style.display = usePerSeriesX ? "block" : "none"; + } + const hasY2 = hasSecondaryAxis(); + for (const id of ["y2LabelGroup", "y2AxisSettingsGroup"]) { + const el = document.getElementById(id); + if (el) el.style.display = hasY2 ? "" : "none"; } +}; - const removeBtn = item.querySelector(".remove-series"); - if (removeBtn) { - removeBtn.addEventListener("click", () => { - if (seriesList.children.length <= 1) return; - item.remove(); - renumberSeries(); - syncY2LabelUI(); - scheduleRender(); - }); +// 点サイズが自動(null)の系列は、プロット種別に応じた値を表示する(ユーザーが入力した値は変えない) +export const syncMarkerSizeDisplay = () => { + const { plot, series } = getSettings(); + for (const item of document.querySelectorAll("#seriesList .series-item")) { + const s = series.find((x) => x.id === item.dataset.seriesId); + const input = item.querySelector(".series-marker-size"); + if (s && input && s.markerSize === null && document.activeElement !== input) { + input.value = String(defaultMarkerSize(plot.type)); + } } +}; - syncPlotTypeUI(); - syncY2LabelUI(); - renumberSeries(); +export const renderSeriesList = () => { + const list = document.getElementById("seriesList"); + if (!list) return; + const { series, plot } = getSettings(); + list.replaceChildren(...series.map((s) => buildCard(s, plot.type))); + const items = list.querySelectorAll(".series-item"); + items.forEach((item, idx) => { + item.querySelector(".series-item-title").textContent = `系列 ${idx + 1}`; + const removeBtn = item.querySelector(".remove-series"); + const disable = items.length <= 1; + removeBtn.disabled = disable; + removeBtn.style.opacity = disable ? "0.5" : "1"; + removeBtn.style.cursor = disable ? "not-allowed" : "pointer"; + }); + syncVisibility(); }; -window.refreshSeriesColumnOptions = () => { - if (!seriesList) return; - const ySelects = seriesList.querySelectorAll(".series-y"); - for (const selectEl of ySelects) { - fillSeriesYSelect(selectEl, selectEl.value); - } - const xSelects = seriesList.querySelectorAll(".series-x"); - for (const selectEl of xSelects) { - fillSeriesXSelect(selectEl, selectEl.value); - } +const refreshXColumnSelect = () => { + const select = document.getElementById("xColumn"); + if (select) fillSelect(select, xOptions(), getSettings().plot.xColumn); }; -window.ensureSeriesItems = () => { - if (!seriesList) return; - if (seriesList.children.length === 0) { - addSeriesItem(); - } else { - renumberSeries(); - } - syncPlotTypeUI(); - syncY2LabelUI(); +// 読込後の列の選択肢を更新する。もう存在しない列の選択は解除する(再描画は呼び出し側が行う)。 +export const setColumns = (newColumns) => { + columns = newColumns; + const valid = new Set(columns.map((c) => c.value)); + const settings = getSettings(); + if (settings.plot.xColumn && !valid.has(settings.plot.xColumn)) setPath("plot.xColumn", "", "reconcile"); + for (const s of settings.series) { + const patch = {}; + if (s.x && !valid.has(s.x)) patch.x = ""; + if (s.y && !valid.has(s.y)) patch.y = ""; + if (Object.keys(patch).length) updateSeries(s.id, patch, "reconcile"); + } + refreshXColumnSelect(); + renderSeriesList(); +}; + +const FIELD_BY_CLASS = [ + ["series-x", "x", "text"], + ["series-y", "y", "text"], + ["series-line-width", "lineWidth", "number"], + ["series-line-style", "lineStyle", "text"], + ["series-marker-size", "markerSize", "number"], + ["series-legend-name", "label", "text"], + ["series-use-y2", "secondaryAxis", "checkbox"], +]; + +const onSeriesInput = (event) => { + const target = event.target; + if (!target || !target.matches || !target.matches("input, select")) return; + const item = target.closest(".series-item"); + if (!item) return; + const entry = FIELD_BY_CLASS.find(([cls]) => target.classList.contains(cls)); + if (!entry) return; + const [, field, kind] = entry; + const value = kind === "checkbox" ? target.checked : kind === "number" ? readNumber(target) : target.value; + updateSeries(item.dataset.seriesId, { [field]: value }); }; export const bindSeriesEvents = () => { - if (addSeriesBtn) { - addSeriesBtn.addEventListener("click", () => { - addSeriesItem(); - scheduleRender(); - }); - } - if (plotTypeSelect) { - plotTypeSelect.addEventListener("change", () => { - syncPlotTypeUI(); - syncMarkerSizeForPlotType(); - scheduleRender(); - }); - } - if (seriesList) { - const onSeriesInput = (event) => { - const target = event.target; - if (!target) return; - if (target.matches("input, select")) { - if (target.matches(".series-use-y2")) { - syncY2LabelUI(); - } - scheduleRender(); - } - }; - seriesList.addEventListener("input", onSeriesInput); - seriesList.addEventListener("change", onSeriesInput); + const list = document.getElementById("seriesList"); + const addBtn = document.getElementById("addSeriesBtn"); + if (addBtn) addBtn.addEventListener("click", () => addSeries()); + if (list) { + list.addEventListener("input", onSeriesInput); + list.addEventListener("change", onSeriesInput); } }; diff --git a/js/ui/tabs.js b/js/ui/tabs.js new file mode 100644 index 0000000..453be67 --- /dev/null +++ b/js/ui/tabs.js @@ -0,0 +1,24 @@ +// 「プロット / データ確認 / Pythonコード(beta)」のタブ切替。 +const TABS = [ + ["plot", "tabPlotBtn", "plotPanel"], + ["data", "tabDataBtn", "dataPanel"], + ["code", "tabCodeBtn", "codePanel"], +]; + +export const switchTab = (selected) => { + for (const [name, btnId, panelId] of TABS) { + const on = name === selected; + const btn = document.getElementById(btnId); + const panel = document.getElementById(panelId); + if (btn) btn.classList.toggle("active", on); + if (panel) panel.classList.toggle("hidden", !on); + } +}; + +export const bindTabs = () => { + for (const [name, btnId] of TABS) { + const btn = document.getElementById(btnId); + if (btn) btn.addEventListener("click", () => switchTab(name)); + } + switchTab("plot"); +}; diff --git a/py/main.py b/py/main.py index 2abd454..85f3112 100644 --- a/py/main.py +++ b/py/main.py @@ -1,1272 +1,59 @@ -import io -import base64 -import asyncio -import traceback -import warnings -from pathlib import Path - -import matplotlib.pyplot as plt -warnings.simplefilter("ignore", DeprecationWarning) -warnings.filterwarnings( - "ignore", - message=".*Pyarrow will become a required dependency of pandas.*", - category=DeprecationWarning, -) -warnings.filterwarnings( - "ignore", - message=".*missing from current font.*", - category=UserWarning, -) -import pandas as pd -from js import Uint8Array, document, window, fetch -from pyodide.ffi import create_proxy - -from mplgui.fonts import configure_rcparams, register_font_file -from mplgui.loader import load_dataframe -from mplgui.runner import build_filename, figure_to_data_uri - -PREVIEW_ROWS = 100 -CURRENT_DF = None -_EVENT_PROXIES = [] -JP_FONT_URL = "https://cdn.jsdelivr.net/gh/googlefonts/noto-cjk@main/Sans/OTF/Japanese/NotoSansCJKjp-Regular.otf" -JP_FONT_PATH = "/tmp/NotoSansCJKjp-Regular.otf" -_JP_FONT_READY = False -DEFAULT_CUSTOM_PLOT_CODE = """# 利用可能オブジェクト: -# - df: skipRows適用後のDataFrame -# - current_df: 読み込み直後のDataFrame(skipRows未適用) -# - settings: GUI設定(dict) -# - plt, pd -# 必須: -# - fig に Figure を代入 -# 任意: -# - plotted_count (int) を設定(未設定時は自動判定) - -fig, ax = plt.subplots(figsize=(settings["fig_width"], settings["fig_height"]), dpi=100) +"""PyScript のエントリ。JS と mplgui の橋渡しだけを行う(DOM には触れない)。 -if df.shape[1] < 2: - raise ValueError("2列以上のデータが必要です。") - -x = pd.to_numeric(df.iloc[:, 0], errors="coerce") -y = pd.to_numeric(df.iloc[:, 1], errors="coerce") -mask = x.notna() & y.notna() -if not bool(mask.any()): - raise ValueError("先頭2列に描画可能な数値データがありません。") - -series_settings = settings.get("series_settings", []) -default_legend = "" -if isinstance(series_settings, list) and len(series_settings) > 0: - default_legend = str(series_settings[0].get("legend_name", "") or "").strip() -if not default_legend: - default_legend = str(df.columns[1]) - -ax.plot( - x[mask], - y[mask], - color="#005AFF", - linewidth=2.0, - marker="o", - markersize=4, - label=default_legend, -) -ax.set_title(settings["title"]) -ax.set_xlabel(settings["x_label"] or str(df.columns[0])) -ax.set_ylabel(settings["y_label"] or str(df.columns[1])) -ax.grid(True, alpha=0.3) -ax.legend(loc="best", edgecolor="black") -fig.tight_layout() - -plotted_count = 1 +JS(js/bridge.js)は window.mplgui の関数を呼ぶ。引数・戻り値は JSON 文字列。 """ +import warnings -configure_rcparams() - - -async def _ensure_japanese_font() -> None: - global _JP_FONT_READY - if _JP_FONT_READY: - return - try: - target = Path(JP_FONT_PATH) - if not target.exists(): - response = await fetch(JP_FONT_URL) - if not bool(response.ok): - return - buffer = await response.arrayBuffer() - data = Uint8Array.new(buffer) - target.write_bytes(bytes(data.to_py())) - - register_font_file(target) - _JP_FONT_READY = True - except Exception: - # フォント取得に失敗しても描画処理は継続する - return - - -def set_status(message: str, is_error: bool = False, detail: str = "") -> None: - status = document.getElementById("status") - if status is not None: - status.textContent = message or "" - status.style.color = "var(--status-error)" if is_error else "var(--status-ok)" - - detail_wrap = document.getElementById("statusDetailWrap") - detail_area = document.getElementById("statusDetail") - if detail_wrap is None or detail_area is None: - return - - detail_text = str(detail or "").strip() - if detail_text: - detail_area.textContent = detail_text - detail_wrap.classList.remove("hidden") - detail_wrap.open = True - else: - detail_area.textContent = "" - detail_wrap.classList.add("hidden") - detail_wrap.open = False - - -def _format_internal_error_detail(exc: Exception, context: str) -> str: - lines = [ - f"context: {context}", - f"type: {type(exc).__name__}", - f"message: {exc}", - ] - trace = traceback.format_exc().strip() - if trace and trace != "NoneType: None": - lines.append("") - lines.append("traceback:") - lines.append(trace) - return "\n".join(lines) - - -def _safe_close_figure(fig) -> None: - if fig is not None: - plt.close(fig) - - -def _switch_tab(tab_name: str) -> None: - plot_btn = document.getElementById("tabPlotBtn") - data_btn = document.getElementById("tabDataBtn") - code_btn = document.getElementById("tabCodeBtn") - plot_panel = document.getElementById("plotPanel") - data_panel = document.getElementById("dataPanel") - code_panel = document.getElementById("codePanel") - - selected = tab_name if tab_name in {"plot", "data", "code"} else "plot" - tab_items = [ - ("plot", plot_btn, plot_panel), - ("data", data_btn, data_panel), - ("code", code_btn, code_panel), - ] - for name, btn, panel in tab_items: - if btn is not None: - if name == selected: - btn.classList.add("active") - else: - btn.classList.remove("active") - if panel is not None: - if name == selected: - panel.classList.remove("hidden") - else: - panel.classList.add("hidden") - - -def _spawn_task(coro) -> None: - task = asyncio.create_task(coro) - - def _consume_done(finished_task): - try: - finished_task.result() - except Exception as exc: - detail = _format_internal_error_detail(exc, "async task callback") - set_status("内部エラーが発生しました。もう一度操作してください。", True, detail) - - task.add_done_callback(_consume_done) - - -def _set_data_ready(ready: bool) -> None: - try: - if hasattr(window, "setMatplotDataReady"): - window.setMatplotDataReady(bool(ready)) - except Exception: - pass - - -def _init_custom_code_editor() -> None: - editor = document.getElementById("customPyCode") - if editor is None: - return - default_code = DEFAULT_CUSTOM_PLOT_CODE.strip() + "\n" - editor.dataset.defaultCode = default_code - current = str(editor.value or "").strip() - if not current: - editor.value = default_code - - -def _is_custom_code_enabled() -> bool: - element = document.getElementById("useCustomCode") - return bool(element.checked) if element is not None else False - - -def _get_custom_code_text() -> str: - element = document.getElementById("customPyCode") - if element is None: - return "" - return str(element.value or "") - - -def _get_text_value(element_id: str, fallback: str = "") -> str: - element = document.getElementById(element_id) - if element is None: - return fallback - return str(element.value or "").strip() - - -def _get_optional_float_value(element_id: str, label: str) -> float | None: - element = document.getElementById(element_id) - raw = element.value.strip() if element is not None else "" - if not raw: - return None - try: - return float(raw) - except Exception: - raise ValueError(f"{label}は数値で指定してください。") - - -def _get_subplot_margin_settings() -> dict: - left = _get_optional_float_value("subplotLeft", "余白 左(left)") - right = _get_optional_float_value("subplotRight", "余白 右(right)") - bottom = _get_optional_float_value("subplotBottom", "余白 下(bottom)") - top = _get_optional_float_value("subplotTop", "余白 上(top)") - - for value, label in ( - (left, "余白 左(left)"), - (right, "余白 右(right)"), - (bottom, "余白 下(bottom)"), - (top, "余白 上(top)"), - ): - if value is not None and (value < 0 or value > 1): - raise ValueError(f"{label}は0〜1で指定してください。") - - if left is not None and right is not None and left >= right: - raise ValueError("余白は left < right になるように指定してください。") - if bottom is not None and top is not None and bottom >= top: - raise ValueError("余白は bottom < top になるように指定してください。") - - return { - "left": left, - "right": right, - "bottom": bottom, - "top": top, - } - - -def _apply_subplot_margin_settings(fig, margins: dict, use_tight_layout_if_auto: bool = False) -> None: - provided = { - key: value - for key, value in (margins or {}).items() - if value is not None - } - if provided: - if use_tight_layout_if_auto and len(provided) < 4: - try: - fig.tight_layout() - auto_margins = { - "left": fig.subplotpars.left, - "right": fig.subplotpars.right, - "bottom": fig.subplotpars.bottom, - "top": fig.subplotpars.top, - } - auto_margins.update(provided) - fig.subplots_adjust(**auto_margins) - return - except Exception: - pass - try: - fig.subplots_adjust(**provided) - except Exception as exc: - raise ValueError(f"余白設定の適用に失敗しました: {exc}") - elif use_tight_layout_if_auto: - fig.tight_layout() - - -def _apply_axis_scale_and_limits( - target_ax, - axis_kind: str, - axis_label: str, - scale: str, - min_value: float | None, - max_value: float | None, -) -> None: - if scale not in {"linear", "log"}: - raise ValueError(f"{axis_label}軸スケールが不正です。") - if min_value is not None and max_value is not None and min_value >= max_value: - raise ValueError(f"{axis_label}軸の範囲は最小値 < 最大値で指定してください。") - if scale == "log": - if (min_value is not None and min_value <= 0) or (max_value is not None and max_value <= 0): - raise ValueError(f"{axis_label}軸を対数にする場合、範囲は0より大きい値で指定してください。") - - try: - if axis_kind == "x": - target_ax.set_xscale(scale) - if min_value is not None or max_value is not None: - target_ax.set_xlim(left=min_value, right=max_value) - elif axis_kind == "y": - target_ax.set_yscale(scale) - if min_value is not None or max_value is not None: - target_ax.set_ylim(bottom=min_value, top=max_value) - else: - raise ValueError(f"軸種別が不正です: {axis_kind}") - except Exception as exc: - raise ValueError(f"{axis_label}軸設定の適用に失敗しました: {exc}") - - -def _get_skip_rows_for_preview() -> int: - raw = document.getElementById("skipRows").value.strip() - if not raw: - return 0 - try: - skip_rows = int(raw) - except Exception: - return 0 - return max(skip_rows, 0) - - -def _get_skip_rows() -> int: - raw = document.getElementById("skipRows").value.strip() - if not raw: - return 0 - skip_rows = int(raw) - if skip_rows < 0: - raise ValueError("スキップ行数は0以上で指定してください。") - return skip_rows - - -def _resolve_column(df: pd.DataFrame, requested: str): - if requested.startswith("__idx__"): - idx_text = requested.replace("__idx__", "", 1) - if not idx_text.isdigit(): - raise ValueError(f"列指定が不正です: {requested}") - idx = int(idx_text) - if idx < 0 or idx >= len(df.columns): - raise ValueError(f"列インデックスが範囲外です: {idx}") - return df.columns[idx] - - if requested in df.columns: - return requested - matched = [col for col in df.columns if str(col) == requested] - if len(matched) == 1: - return matched[0] - raise ValueError(f"列が見つかりません: {requested}") - - -def _parse_column_index(requested: str) -> int | None: - if not requested.startswith("__idx__"): - return None - idx_text = requested.replace("__idx__", "", 1) - if not idx_text.isdigit(): - return None - return int(idx_text) - - -def _get_series_from_request(df: pd.DataFrame, requested: str) -> tuple[pd.Series, str, int]: - idx = _parse_column_index(requested) - if idx is not None: - if idx < 0 or idx >= len(df.columns): - raise ValueError(f"列インデックスが範囲外です: {idx}") - return df.iloc[:, idx], str(df.columns[idx]), idx - - resolved = _resolve_column(df, requested) - found_idx = -1 - for i, col in enumerate(df.columns): - if col == resolved: - found_idx = i - break - return df[resolved], str(resolved), found_idx - - -async def _read_upload() -> tuple[bytes, str]: - file_input = document.getElementById("fileInput") - if file_input.files.length == 0: - raise ValueError("ファイルを選択してください。") - - js_file = file_input.files.item(0) - buffer = await js_file.arrayBuffer() - data = Uint8Array.new(buffer) - return bytes(data.to_py()), str(js_file.name) - - -def _collect_series_settings() -> list[dict]: - series_list = document.getElementById("seriesList") - if series_list is None: - raise ValueError("系列設定UIが見つかりません。") - - rows = series_list.querySelectorAll(".series-item") - if rows.length == 0: - raise ValueError("描画系列を1つ以上追加してください。") - - allowed_line_styles = {"solid", "dashed", "dashdot", "dotted"} - plot_type = document.getElementById("plotType").value - default_marker = "0" if plot_type == "line" else "24" - settings = [] - for i in range(rows.length): - row = rows.item(i) - y_select = row.querySelector(".series-y") - x_select = row.querySelector(".series-x") - color_input = row.querySelector(".series-color") - line_width_input = row.querySelector(".series-line-width") - line_style_select = row.querySelector(".series-line-style") - marker_size_input = row.querySelector(".series-marker-size") - legend_name_input = row.querySelector(".series-legend-name") - use_y2_input = row.querySelector(".series-use-y2") - - y_request = y_select.value.strip() if y_select is not None else "" - x_request = x_select.value.strip() if x_select is not None else "" - color = color_input.value.strip() if color_input is not None else "#FF4B00" - line_width_text = line_width_input.value.strip() if line_width_input is not None else "2.0" - line_style = line_style_select.value.strip() if line_style_select is not None else "solid" - marker_size_text = marker_size_input.value.strip() if marker_size_input is not None else default_marker - legend_name = legend_name_input.value.strip() if legend_name_input is not None else "" - use_secondary_axis = bool(use_y2_input.checked) if use_y2_input is not None else False - - line_width = float(line_width_text or "2.0") - marker_size = float(marker_size_text or default_marker) - - if plot_type == "scatter" and marker_size <= 0: - marker_size = 24.0 - - if line_width <= 0: - raise ValueError(f"{i + 1}番目の系列: 線幅は0より大きくしてください。") - if marker_size < 0: - raise ValueError(f"{i + 1}番目の系列: 点サイズは0以上で指定してください。") - if line_style not in allowed_line_styles: - raise ValueError(f"{i + 1}番目の系列: 線種が不正です。") - - settings.append( - { - "y_request": y_request, - "x_request": x_request, - "color": color or "#FF4B00", - "line_width": line_width, - "line_style": line_style, - "marker_size": marker_size, - "legend_name": legend_name, - "use_secondary_axis": use_secondary_axis, - } - ) - - return settings - - -def _resolve_series_requests(df: pd.DataFrame, settings: list[dict]) -> list[str]: - numeric_indices = [i for i, dtype in enumerate(df.dtypes) if pd.api.types.is_numeric_dtype(dtype)] - candidates = numeric_indices if numeric_indices else [0] - used_indices = set() - resolved = [] - - for setting in settings: - request = setting["y_request"] - if request: - idx = _parse_column_index(request) - if idx is not None: - used_indices.add(idx) - resolved.append(request) - continue - - chosen = None - for idx in candidates: - if idx not in used_indices: - chosen = idx - break - if chosen is None: - chosen = candidates[0] - used_indices.add(chosen) - resolved.append(f"__idx__{chosen}") - - return resolved - - -def _get_plot_data(df: pd.DataFrame, series_settings: list[dict]) -> tuple[pd.Series, str, list[dict]]: - x_req = document.getElementById("xColumn").value.strip() - y_requests = _resolve_series_requests(df, series_settings) - - if x_req: - x, x_label, _ = _get_series_from_request(df, x_req) - else: - x = pd.Series(df.index, name="index") - x_label = "index" - - plot_df = pd.DataFrame({"x": x}) - series_entries = [] - for i, (setting, request) in enumerate(zip(series_settings, y_requests)): - y_series, y_name, y_idx = _get_series_from_request(df, request) - y_key = f"y_{i}" - label_suffix = f" [{y_idx}]" if y_idx >= 0 else "" - data_label = f"{y_name}{label_suffix}" - plot_df[y_key] = pd.to_numeric(y_series, errors="coerce") - entry = dict(setting) - entry["y_key"] = y_key - entry["data_label"] = data_label - entry["legend_label"] = setting.get("legend_name") or data_label - series_entries.append(entry) - - y_keys = [entry["y_key"] for entry in series_entries] - plot_df = plot_df.dropna(subset=y_keys, how="all") - if plot_df.empty: - raise ValueError("描画可能な数値データがありません。") - - for entry in series_entries: - entry["y_series"] = plot_df[entry["y_key"]] - - return plot_df["x"], x_label, series_entries - - -def _get_per_series_xy_data(df: pd.DataFrame, series_settings: list[dict]) -> tuple[list[dict], str]: - y_requests = _resolve_series_requests(df, series_settings) - entries = [] - x_labels = [] - - for setting, y_request in zip(series_settings, y_requests): - x_request = setting.get("x_request", "").strip() - if x_request: - x_series, x_label, _ = _get_series_from_request(df, x_request) - else: - x_series = pd.Series(df.index, name="index") - x_label = "index" - - y_series, y_name, y_idx = _get_series_from_request(df, y_request) - label_suffix = f" [{y_idx}]" if y_idx >= 0 else "" - data_label = f"{y_name}{label_suffix}" - - entry = dict(setting) - entry["x_series"] = x_series.reset_index(drop=True) - entry["y_series"] = pd.to_numeric(y_series, errors="coerce").reset_index(drop=True) - entry["data_label"] = data_label - entry["legend_label"] = setting.get("legend_name") or data_label - entry["x_label"] = x_label - entries.append(entry) - x_labels.append(x_label) - - has_points = False - for entry in entries: - mask = entry["y_series"].notna() & pd.notna(entry["x_series"]) - if bool(mask.any()): - has_points = True - break - if not has_points: - raise ValueError("描画可能な数値データがありません。") - - common_x_label = x_labels[0] if len(set(x_labels)) == 1 else "x" - return entries, common_x_label - - -def _fill_columns(df: pd.DataFrame) -> None: - x_select = document.getElementById("xColumn") - - x_select.innerHTML = "" - - x_default = document.createElement("option") - x_default.value = "" - x_default.textContent = "(index)" - x_select.appendChild(x_default) - - for idx, col in enumerate(df.columns): - text = str(col) - value = f"__idx__{idx}" - label = f"{text} [{idx}]" - - x_opt = document.createElement("option") - x_opt.value = value - x_opt.textContent = label - x_select.appendChild(x_opt) - - if hasattr(window, "refreshSeriesColumnOptions"): - window.refreshSeriesColumnOptions() - - -def _render_data_preview(df: pd.DataFrame, skip_rows: int = 0) -> None: - data_area = document.getElementById("dataArea") - if data_area is None: - return - - preview_df = df.head(PREVIEW_ROWS) - rows = len(df) - cols = len(df.columns) - truncated = "(先頭100行のみ表示)" if rows > PREVIEW_ROWS else "" - table_html = preview_df.to_html(index=False, border=0, classes=["data-table"]) - - data_area.innerHTML = ( - f'
行数: {rows} / 列数: {cols} {truncated}
' - f'
{table_html}
' - ) - - -def _render_plot_image(fig) -> None: - buffer = io.BytesIO() - fig.savefig(buffer, format="png", dpi=100) - encoded = base64.b64encode(buffer.getvalue()).decode("ascii") - plot_area = document.getElementById("plotArea") - plot_area.innerHTML = f'plot' - - -def on_tab_plot(event=None): - _switch_tab("plot") - - -def on_tab_data(event=None): - _switch_tab("data") - - -def on_tab_code(event=None): - _switch_tab("code") - - -def _figure_has_visible_artists(fig) -> bool: - if fig is None or not hasattr(fig, "axes"): - return False - for axis in fig.axes: - if len(axis.lines) > 0: - return True - if len(axis.collections) > 0: - return True - if len(axis.patches) > 0: - return True - if len(axis.images) > 0: - return True - if hasattr(axis, "containers") and len(axis.containers) > 0: - return True - return False - - -def _collect_custom_plot_settings(skip_rows: int) -> dict: - font_size = float(_get_text_value("fontSize", "15") or "15") - fig_width = float(_get_text_value("figWidth", "8") or "8") - fig_height = float(_get_text_value("figHeight", "6") or "6") - subplot_margins = _get_subplot_margin_settings() - if fig_width <= 0 or fig_height <= 0: - raise ValueError("図幅・図高さは0より大きくしてください。") - if font_size <= 0: - raise ValueError("フォントサイズは0より大きくしてください。") - - series_settings = [] - series_settings_error = "" - try: - series_settings = _collect_series_settings() - except Exception as exc: - series_settings_error = str(exc) - - return { - "skip_rows": skip_rows, - "plot_type": _get_text_value("plotType", "line") or "line", - "title": _get_text_value("title", ""), - "x_label": _get_text_value("xLabel", ""), - "y_label": _get_text_value("yLabel", ""), - "y2_label": _get_text_value("y2Label", ""), - "x_scale": _get_text_value("xScale", "linear") or "linear", - "y_scale": _get_text_value("yScale", "linear") or "linear", - "y2_scale": _get_text_value("y2Scale", "linear") or "linear", - "x_min": _get_optional_float_value("xMin", "X最小値"), - "x_max": _get_optional_float_value("xMax", "X最大値"), - "y_min": _get_optional_float_value("yMin", "Y最小値"), - "y_max": _get_optional_float_value("yMax", "Y最大値"), - "y2_min": _get_optional_float_value("y2Min", "第2Y最小値"), - "y2_max": _get_optional_float_value("y2Max", "第2Y最大値"), - "show_major_grid": bool(document.getElementById("showMajorGrid").checked), - "show_minor_grid": bool(document.getElementById("showMinorGrid").checked), - "legend_location": _get_text_value("legendLocation", "best") or "best", - "font_size": font_size, - "fig_width": fig_width, - "fig_height": fig_height, - "subplot_margins": subplot_margins, - "subplot_left": subplot_margins["left"], - "subplot_right": subplot_margins["right"], - "subplot_bottom": subplot_margins["bottom"], - "subplot_top": subplot_margins["top"], - "x_column_request": _get_text_value("xColumn", ""), - "series_settings": series_settings, - "series_settings_error": series_settings_error, - } - - -def _create_plot_figure_with_custom_code() -> tuple[object, int, int]: - if CURRENT_DF is None: - raise ValueError("先にファイルを読み込んでください。") - - skip_rows = _get_skip_rows() - if skip_rows >= len(CURRENT_DF): - raise ValueError("スキップ行数がデータ行数以上です。") - - code = _get_custom_code_text() - if not code.strip(): - raise ValueError("カスタムコードが空です。") - - df = CURRENT_DF.iloc[skip_rows:].reset_index(drop=True) - settings = _collect_custom_plot_settings(skip_rows) - context = { - "df": df, - "current_df": CURRENT_DF, - "settings": settings, - "series_settings": settings.get("series_settings", []), - "helpers": { - "get_plot_data": _get_plot_data, - "get_per_series_xy_data": _get_per_series_xy_data, - "resolve_series_requests": _resolve_series_requests, - }, - } - - local_scope = { - "df": df.copy(), - "current_df": CURRENT_DF.copy(), - "settings": settings, - "ctx": context, - "series_settings": settings.get("series_settings", []), - "get_plot_data": _get_plot_data, - "get_per_series_xy_data": _get_per_series_xy_data, - "resolve_series_requests": _resolve_series_requests, - "plt": plt, - "pd": pd, - "fig": None, - "plotted_count": None, - "skip_rows": skip_rows, - } - global_scope = { - "__builtins__": __builtins__, - "plt": plt, - "pd": pd, - } - try: - exec(code, global_scope, local_scope) - except Exception as exc: - raise ValueError(f"カスタムコードの実行に失敗しました: {exc}") from exc - - fig = local_scope.get("fig") - if fig is None: - raise ValueError("カスタムコードで fig を生成してください。") - if not hasattr(fig, "savefig"): - raise ValueError("fig には matplotlib.figure.Figure を設定してください。") - _apply_gui_overrides_to_custom_figure(fig, settings) - _apply_subplot_margin_settings(fig, settings.get("subplot_margins", {}), use_tight_layout_if_auto=True) - - plotted_count_raw = local_scope.get("plotted_count") - if plotted_count_raw is None: - plotted_count = 1 if _figure_has_visible_artists(fig) else 0 - else: - try: - plotted_count = int(plotted_count_raw) - except Exception: - raise ValueError("plotted_count は整数で指定してください。") - - if plotted_count <= 0 and _figure_has_visible_artists(fig): - plotted_count = 1 - if plotted_count <= 0: - raise ValueError("描画可能なデータがありません。") - - custom_skip_rows = local_scope.get("skip_rows", skip_rows) - try: - custom_skip_rows = int(custom_skip_rows) - except Exception: - custom_skip_rows = skip_rows - if custom_skip_rows < 0: - custom_skip_rows = skip_rows - - return fig, plotted_count, custom_skip_rows - - -def _apply_gui_overrides_to_custom_figure(fig, settings: dict) -> None: - if fig is None or not hasattr(fig, "axes") or len(fig.axes) == 0: - return - - ax = fig.axes[0] - ax2 = fig.axes[1] if len(fig.axes) >= 2 else None - font_size = float(settings.get("font_size", 15) or 15) - x_label_override = str(settings.get("x_label", "") or "").strip() - y_label_override = str(settings.get("y_label", "") or "").strip() - y2_label_override = str(settings.get("y2_label", "") or "").strip() - title = str(settings.get("title", "") or "").strip() - x_scale = str(settings.get("x_scale", "linear") or "linear").strip() - y_scale = str(settings.get("y_scale", "linear") or "linear").strip() - y2_scale = str(settings.get("y2_scale", "linear") or "linear").strip() - x_min = settings.get("x_min") - x_max = settings.get("x_max") - y_min = settings.get("y_min") - y_max = settings.get("y_max") - y2_min = settings.get("y2_min") - y2_max = settings.get("y2_max") - show_major_grid = bool(settings.get("show_major_grid", False)) - show_minor_grid = bool(settings.get("show_minor_grid", False)) - legend_location = str(settings.get("legend_location", "best") or "best").strip() - allowed_legend_locations = { - "best", - "upper right", - "upper left", - "lower right", - "lower left", - "upper center", - "lower center", - "center right", - "center left", - "center", - "none", - } - if legend_location not in allowed_legend_locations: - raise ValueError("凡例位置が不正です。") - - if title: - ax.set_title(title, fontsize=font_size + 2) - - current_x_label = ax.get_xlabel() - current_y_label = ax.get_ylabel() - ax.set_xlabel(x_label_override or current_x_label, fontsize=font_size) - ax.set_ylabel(y_label_override or current_y_label, fontsize=font_size) - if ax2 is not None: - current_y2_label = ax2.get_ylabel() - ax2.set_ylabel(y2_label_override or current_y2_label, fontsize=font_size) - - _apply_axis_scale_and_limits(ax, "x", "X", x_scale, x_min, x_max) - _apply_axis_scale_and_limits(ax, "y", "Y", y_scale, y_min, y_max) - if ax2 is not None: - _apply_axis_scale_and_limits(ax2, "y", "第2Y", y2_scale, y2_min, y2_max) - - ax.tick_params( - axis="x", - which="both", - direction="in", - bottom=True, - top=False, - labelsize=max(font_size - 1, 1), - ) - ax.tick_params( - axis="y", - which="both", - direction="in", - left=True, - right=False, - labelsize=max(font_size - 1, 1), - ) - if ax2 is not None: - ax2.tick_params( - axis="y", - which="both", - direction="in", - left=False, - right=True, - labelsize=max(font_size - 1, 1), - ) - - if show_minor_grid: - ax.minorticks_on() - if ax2 is not None: - ax2.minorticks_on() - else: - ax.minorticks_off() - if ax2 is not None: - ax2.minorticks_off() - - ax.grid(False, which="both", axis="both") - if show_major_grid: - ax.grid(True, which="major", axis="both", alpha=0.3) - if show_minor_grid: - ax.grid(True, which="minor", axis="both", alpha=0.2, linestyle=":") - if ax2 is not None: - ax2.grid(False, which="both", axis="y") - if show_major_grid: - ax2.grid(True, which="major", axis="y", alpha=0.3) - if show_minor_grid: - ax2.grid(True, which="minor", axis="y", alpha=0.2, linestyle=":") - - for axis in fig.axes: - existing_legend = axis.get_legend() - if existing_legend is not None: - existing_legend.remove() - - if legend_location == "none": - return - - handles = [] - labels = [] - for axis in fig.axes: - h, l = axis.get_legend_handles_labels() - handles.extend(h) - labels.extend(l) - - if not labels: - return - - series_settings = settings.get("series_settings", []) - for i, setting in enumerate(series_settings): - if i >= len(labels): - break - legend_name = str(setting.get("legend_name", "") or "").strip() - if legend_name: - labels[i] = legend_name - - ax.legend( - handles, - labels, - loc=legend_location, - fontsize=max(font_size - 1, 1), - edgecolor="black", - ) - - -async def on_load_columns(event=None): - global CURRENT_DF - try: - _set_data_ready(False) - file_bytes, filename = await _read_upload() - delimiter = document.getElementById("delimiter").value - has_header = document.getElementById("hasHeader").checked - - df = load_dataframe(file_bytes, filename, delimiter, has_header) - CURRENT_DF = df - _fill_columns(df) - _render_data_preview(df, _get_skip_rows_for_preview()) - _set_data_ready(True) - set_status("") - await on_render() - except Exception as exc: - CURRENT_DF = None - _set_data_ready(False) - set_status(str(exc), True) - - -def _create_plot_figure() -> tuple[object, int, int]: - if _is_custom_code_enabled(): - return _create_plot_figure_with_custom_code() - - if CURRENT_DF is None: - raise ValueError("先にファイルを読み込んでください。") - - skip_rows = _get_skip_rows() - if skip_rows >= len(CURRENT_DF): - raise ValueError("スキップ行数がデータ行数以上です。") - - df = CURRENT_DF.iloc[skip_rows:].reset_index(drop=True) - plot_type = document.getElementById("plotType").value - - title = document.getElementById("title").value.strip() - x_label_override = document.getElementById("xLabel").value.strip() - y_label_override = document.getElementById("yLabel").value.strip() - y2_label_override = document.getElementById("y2Label").value.strip() - x_scale = (document.getElementById("xScale").value or "linear").strip() - y_scale = (document.getElementById("yScale").value or "linear").strip() - y2_scale = (document.getElementById("y2Scale").value or "linear").strip() - x_min = _get_optional_float_value("xMin", "X最小値") - x_max = _get_optional_float_value("xMax", "X最大値") - y_min = _get_optional_float_value("yMin", "Y最小値") - y_max = _get_optional_float_value("yMax", "Y最大値") - y2_min = _get_optional_float_value("y2Min", "第2Y最小値") - y2_max = _get_optional_float_value("y2Max", "第2Y最大値") - show_major_grid = bool(document.getElementById("showMajorGrid").checked) - show_minor_grid = bool(document.getElementById("showMinorGrid").checked) - legend_location = document.getElementById("legendLocation").value - - series_settings = _collect_series_settings() - font_size = float(document.getElementById("fontSize").value or "15") - fig_width = float(document.getElementById("figWidth").value or "8") - fig_height = float(document.getElementById("figHeight").value or "6") - subplot_margins = _get_subplot_margin_settings() - allowed_legend_locations = { - "best", - "upper right", - "upper left", - "lower right", - "lower left", - "upper center", - "lower center", - "center right", - "center left", - "center", - "none", - } - - if fig_width <= 0 or fig_height <= 0: - raise ValueError("図幅・図高さは0より大きくしてください。") - if font_size <= 0: - raise ValueError("フォントサイズは0より大きくしてください。") - if plot_type not in {"line", "scatter", "bar"}: - raise ValueError("プロット種別が不正です。") - if legend_location not in allowed_legend_locations: - raise ValueError("凡例位置が不正です。") - - fig, ax = plt.subplots(figsize=(fig_width, fig_height), dpi=100) - ax2 = None - plotted_labels = [] - all_entries = [] - - if plot_type in {"line", "scatter"}: - per_series_entries, x_label = _get_per_series_xy_data(df, series_settings) - all_entries = per_series_entries - for entry in per_series_entries: - x_series = entry["x_series"] - y_series = entry["y_series"] - mask = y_series.notna() & pd.notna(x_series) - if not bool(mask.any()): - continue - - target_ax = ax - if entry.get("use_secondary_axis", False): - if ax2 is None: - ax2 = ax.twinx() - target_ax = ax2 - - if plot_type == "line": - marker = "o" if entry["marker_size"] > 0 else "" - marker_size_for_line = entry["marker_size"] ** 0.5 if entry["marker_size"] > 0 else 0 - target_ax.plot( - x_series[mask], - y_series[mask], - color=entry["color"], - linewidth=entry["line_width"], - linestyle=entry["line_style"], - marker=marker, - markersize=marker_size_for_line, - label=entry["legend_label"], - ) - else: - target_ax.scatter( - x_series[mask], - y_series[mask], - color=entry["color"], - s=entry["marker_size"], - label=entry["legend_label"], - ) - plotted_labels.append(entry["legend_label"]) - else: - x, x_label, series_entries = _get_plot_data(df, series_settings) - all_entries = series_entries - if len(series_entries) == 1: - entry = series_entries[0] - y_series = entry["y_series"] - mask = y_series.notna() & pd.notna(x) - if bool(mask.any()): - target_ax = ax - if entry.get("use_secondary_axis", False): - if ax2 is None: - ax2 = ax.twinx() - target_ax = ax2 - target_ax.bar( - x[mask], - y_series[mask], - color=entry["color"], - linewidth=entry["line_width"], - linestyle=entry["line_style"], - label=entry["legend_label"], - ) - plotted_labels.append(entry["legend_label"]) - else: - x_labels = [str(v) for v in x.tolist()] - positions = list(range(len(x_labels))) - width = 0.8 / len(series_entries) - - for i, entry in enumerate(series_entries): - y_series = entry["y_series"] - y_values = [float(v) if pd.notna(v) else 0.0 for v in y_series.tolist()] - offset = -0.4 + (width / 2.0) + (i * width) - x_positions = [p + offset for p in positions] - - target_ax = ax - if entry.get("use_secondary_axis", False): - if ax2 is None: - ax2 = ax.twinx() - target_ax = ax2 - target_ax.bar( - x_positions, - y_values, - width=width, - color=entry["color"], - linewidth=entry["line_width"], - linestyle=entry["line_style"], - label=entry["legend_label"], - ) - plotted_labels.append(entry["legend_label"]) - - if positions: - if len(positions) > 25: - step = max(1, len(positions) // 25) - tick_positions = [positions[i] for i in range(0, len(positions), step)] - tick_labels = [x_labels[i] for i in range(0, len(x_labels), step)] - else: - tick_positions = positions - tick_labels = x_labels - ax.set_xticks(tick_positions) - ax.set_xticklabels(tick_labels, rotation=45, ha="right") - - if not plotted_labels: - raise ValueError("描画可能なデータがありません。") - - primary_labels = [entry["data_label"] for entry in all_entries if not entry.get("use_secondary_axis", False)] - secondary_labels = [entry["data_label"] for entry in all_entries if entry.get("use_secondary_axis", False)] - - if title: - ax.set_title(title, fontsize=font_size + 2) - ax.set_xlabel(x_label_override or x_label, fontsize=font_size) - - if y_label_override: - ax.set_ylabel(y_label_override, fontsize=font_size) - else: - left_default = primary_labels[0] if len(primary_labels) == 1 else "values" - ax.set_ylabel(left_default, fontsize=font_size) - - if ax2 is not None: - right_default = secondary_labels[0] if len(secondary_labels) == 1 else "values" - ax2.set_ylabel(y2_label_override or right_default, fontsize=font_size) - - _apply_axis_scale_and_limits(ax, "x", "X", x_scale, x_min, x_max) - _apply_axis_scale_and_limits(ax, "y", "Y", y_scale, y_min, y_max) - if ax2 is not None: - _apply_axis_scale_and_limits(ax2, "y", "第2Y", y2_scale, y2_min, y2_max) - - ax.tick_params( - axis="x", - which="both", - direction="in", - bottom=True, - top=False, - labelsize=max(font_size - 1, 1), - ) - ax.tick_params( - axis="y", - which="both", - direction="in", - left=True, - right=False, - labelsize=max(font_size - 1, 1), - ) - if ax2 is not None: - ax2.tick_params( - axis="y", - which="both", - direction="in", - left=False, - right=True, - labelsize=max(font_size - 1, 1), - ) - - if show_minor_grid: - ax.minorticks_on() - if ax2 is not None: - ax2.minorticks_on() - else: - ax.minorticks_off() - if ax2 is not None: - ax2.minorticks_off() +import matplotlib - ax.grid(False, which="both", axis="both") - if show_major_grid: - ax.grid(True, which="major", axis="both", alpha=0.3) - if show_minor_grid: - ax.grid(True, which="minor", axis="both", alpha=0.2, linestyle=":") - if ax2 is not None: - ax2.grid(False, which="both", axis="y") - if show_major_grid: - ax2.grid(True, which="major", axis="y", alpha=0.3) - if show_minor_grid: - ax2.grid(True, which="minor", axis="y", alpha=0.2, linestyle=":") +matplotlib.use("Agg") # ブラウザ描画バックエンドを使わない(pyplot を import する前に指定する) - if legend_location != "none" and len(plotted_labels) >= 1: - if ax2 is None: - ax.legend( - loc=legend_location, - fontsize=max(font_size - 1, 1), - edgecolor="black", - ) - else: - handles1, labels1 = ax.get_legend_handles_labels() - handles2, labels2 = ax2.get_legend_handles_labels() - merged_handles = handles1 + handles2 - merged_labels = labels1 + labels2 - if len(merged_labels) >= 1: - ax.legend( - merged_handles, - merged_labels, - loc=legend_location, - fontsize=max(font_size - 1, 1), - edgecolor="black", - ) - _apply_subplot_margin_settings(fig, subplot_margins, use_tight_layout_if_auto=True) - return fig, len(plotted_labels), skip_rows +warnings.simplefilter("ignore", DeprecationWarning) +warnings.filterwarnings("ignore", message=".*Pyarrow will become a required dependency of pandas.*", category=DeprecationWarning) +warnings.filterwarnings("ignore", message=".*missing from current font.*", category=UserWarning) +from js import CustomEvent, Object, window # noqa: E402 +from pyodide.ffi import create_proxy # noqa: E402 -async def on_render(event=None): - fig = None - try: - if CURRENT_DF is not None: - _render_data_preview(CURRENT_DF, _get_skip_rows_for_preview()) - await _ensure_japanese_font() - fig, plotted_count, skip_rows = _create_plot_figure() - _render_plot_image(fig) - _set_data_ready(True) - set_status(f"描画に成功しました({plotted_count}系列、スキップ{skip_rows}行)。") - except Exception as exc: - set_status(str(exc), True) - finally: - _safe_close_figure(fig) +from mplgui import api # noqa: E402 +from mplgui.fonts import configure_rcparams # noqa: E402 +configure_rcparams() -async def on_save_plot(event=None): - fig = None - try: - await _ensure_japanese_font() - save_format = document.getElementById("saveFormat").value.strip().lower() - transparent = bool(document.getElementById("saveTransparent").checked) - raw_filename = document.getElementById("saveFilename").value.strip() - fig, plotted_count, skip_rows = _create_plot_figure() - data_uri, ext = figure_to_data_uri(fig, save_format, transparent=transparent) - filename = build_filename(raw_filename, ext) - if not hasattr(window, "downloadDataUri"): - raise ValueError("ダウンロード機能の初期化に失敗しました。") - window.downloadDataUri(filename, data_uri) - set_status(f"描画データを保存しました: {filename}") - except Exception as exc: - set_status(str(exc), True) - finally: - _safe_close_figure(fig) +def _load_file(name, data, load_settings_json): + return api.load_file_json(str(name), data.to_py(), str(load_settings_json)) -def _bind_click(element_id: str, handler, is_async: bool = False) -> None: - element = document.getElementById(element_id) - if element is None: - raise ValueError(f"UI要素が見つかりません: {element_id}") - if is_async: - def wrapped(event): - _spawn_task(handler(event)) - else: - def wrapped(event): - handler(event) +def _code(value): + # JS の null / undefined は Python では None とは限らない(jsnull)。文字列のときだけコードとして扱う + return value if isinstance(value, str) else None - proxy = create_proxy(wrapped) - _EVENT_PROXIES.append(proxy) - element.addEventListener("click", proxy) +def _render(settings_json, custom_code=None): + return api.render_json(str(settings_json), _code(custom_code)) -def _bind_events() -> None: - _bind_click("tabPlotBtn", on_tab_plot) - _bind_click("tabDataBtn", on_tab_data) - _bind_click("tabCodeBtn", on_tab_code) - _bind_click("savePlotBtn", on_save_plot, is_async=True) +def _save(settings_json, custom_code=None): + return api.save_json(str(settings_json), _code(custom_code)) -def _register_js_api() -> None: - def trigger_load(event=None): - _spawn_task(on_load_columns()) - def trigger_render(event=None): - _spawn_task(on_render()) +def _register_font(data): + return api.register_font(data.to_py()) - load_proxy = create_proxy(trigger_load) - render_proxy = create_proxy(trigger_render) - _EVENT_PROXIES.append(load_proxy) - _EVENT_PROXIES.append(render_proxy) - window.requestLoadColumns = load_proxy - window.requestRender = render_proxy +# create_proxy の参照を保持しておく(GC されると JS から呼べなくなる) +_PROXIES = { + "loadFile": create_proxy(_load_file), + "render": create_proxy(_render), + "save": create_proxy(_save), + "registerFont": create_proxy(_register_font), + "fontStatus": create_proxy(api.font_status), + "defaultCustomCode": create_proxy(api.default_custom_code), +} +_API = Object.new() +for _name, _proxy in _PROXIES.items(): + setattr(_API, _name, _proxy) -set_status("") -_set_data_ready(False) -_init_custom_code_editor() -_switch_tab("plot") -if hasattr(window, "ensureSeriesItems"): - window.ensureSeriesItems() -_register_js_api() -_bind_events() +window.mplgui = _API +window.dispatchEvent(CustomEvent.new("mplgui-ready")) diff --git a/py/mplgui/api.py b/py/mplgui/api.py new file mode 100644 index 0000000..62d1260 --- /dev/null +++ b/py/mplgui/api.py @@ -0,0 +1,139 @@ +"""JS から呼ばれる JSON 入出力の API(純粋な Python。js / pyodide には依存しない)。 + +py/main.py はこの関数を window.mplgui に登録するだけ。引数・戻り値はすべて JSON 文字列(ファイル/フォントはバイト列)。 +成功: {"ok": true, ...} 失敗: {"ok": false, "error": {"message", "field"?, "detail"?}} +""" +from __future__ import annotations + +import json +import traceback + +import matplotlib.pyplot as plt + +from . import fonts +from .errors import UserError +from .loader import PREVIEW_ROWS, build_preview, column_options, load_file +from .runner import ( + DEFAULT_CUSTOM_PLOT_CODE, + build_filename, + figure_to_data_uri, + make_figure, +) +from .settings import parse_load_settings, parse_settings + +INTERNAL_ERROR_MESSAGE = "内部エラーが発生しました。もう一度操作してください。" + + +class Session: + """読み込み済みデータ(ブラウザのタブごとに1つ)。""" + + def __init__(self): + self.df = None + self.filename = None + + +SESSION = Session() + + +def _dumps(obj) -> str: + return json.dumps(obj, ensure_ascii=False) + + +def _error_payload(exc: BaseException, context: str) -> dict: + if isinstance(exc, UserError): + return {"ok": False, "error": exc.to_dict()} + detail = f"context: {context}\ntype: {type(exc).__name__}\nmessage: {exc}\n\ntraceback:\n{traceback.format_exc().strip()}" + return {"ok": False, "error": {"message": INTERNAL_ERROR_MESSAGE, "detail": detail}} + + +def _guard(context: str): + def deco(fn): + def wrapper(*args, **kwargs): + try: + return _dumps(fn(*args, **kwargs)) + except Exception as exc: # noqa: BLE001 - 境界。UserError 以外は内部エラーとして詳細つきで返す + return _dumps(_error_payload(exc, context)) + + wrapper.__name__ = fn.__name__ + wrapper.__doc__ = fn.__doc__ + return wrapper + + return deco + + +def _parse_json(text: str) -> dict: + try: + data = json.loads(text) if text else {} + except json.JSONDecodeError as exc: + raise UserError("設定を解釈できませんでした。ページを再読み込みしてください。", detail=str(exc)) from exc + return data + + +@_guard("load_file") +def load_file_json(name: str, data: bytes, load_settings_json: str) -> dict: + """ファイルを読み込み、列の選択肢とプレビューを返す。失敗時は読込済みデータを破棄する。""" + SESSION.df = None + SESSION.filename = None + load = parse_load_settings(_parse_json(load_settings_json)) + loaded = load_file(bytes(data), name, load.delimiter, load.has_header) + SESSION.df = loaded.df + SESSION.filename = name + return { + "ok": True, + "encoding": loaded.encoding, + "columns": column_options(loaded.df), + "preview": build_preview(loaded.df, PREVIEW_ROWS), + "warnings": [], + } + + +def _figure_for(settings_json: str, custom_code: str | None): + settings = parse_settings(_parse_json(settings_json)) + if SESSION.df is None: + raise UserError("先にファイルを読み込んでください。") + return settings, make_figure(SESSION.df, settings, custom_code) + + +@_guard("render") +def render_json(settings_json: str, custom_code: str | None = None) -> dict: + """設定どおりに描画し、PNG の data URI を返す。""" + _, result = _figure_for(settings_json, custom_code) + try: + uri, _ext = figure_to_data_uri(result.fig, "png", dpi=100) + finally: + plt.close(result.fig) + return { + "ok": True, + "image": uri, + "seriesCount": result.plotted_count, + "skipRows": result.skip_rows, + "warnings": result.warnings, + } + + +@_guard("save") +def save_json(settings_json: str, custom_code: str | None = None) -> dict: + """保存設定の形式で図を書き出し、data URI とファイル名を返す(ダウンロードは JS が行う)。""" + settings, result = _figure_for(settings_json, custom_code) + try: + uri, ext = figure_to_data_uri(result.fig, settings.save.format, transparent=settings.save.transparent) + finally: + plt.close(result.fig) + mime = uri[5 : uri.index(";")] + return {"ok": True, "filename": build_filename(settings.save.filename, ext), "mime": mime, "dataUri": uri} + + +def register_font(data: bytes) -> str: + """JS が取得した日本語フォントを登録する。""" + try: + return _dumps({"ok": True, "registered": fonts.register_font_bytes(bytes(data))}) + except Exception as exc: # noqa: BLE001 + return _dumps(_error_payload(exc, "register_font")) + + +def font_status() -> str: + return _dumps({"ok": True, "registered": fonts.is_registered()}) + + +def default_custom_code() -> str: + return DEFAULT_CUSTOM_PLOT_CODE.strip() + "\n" diff --git a/py/mplgui/errors.py b/py/mplgui/errors.py new file mode 100644 index 0000000..4cd6f6b --- /dev/null +++ b/py/mplgui/errors.py @@ -0,0 +1,22 @@ +"""エラー型。UserError はユーザーの入力・操作が原因のエラー(日本語メッセージをそのまま画面に出す)。 + +それ以外の例外は内部エラーとして扱い、短い日本語メッセージと traceback を表示する。 +""" + + +class UserError(Exception): + """ユーザーが直せる原因のエラー。message は日本語で、どの項目が悪いかを含める。""" + + def __init__(self, message: str, field: str | None = None, detail: str | None = None): + super().__init__(message) + self.message = message + self.field = field + self.detail = detail + + def to_dict(self) -> dict: + out: dict = {"message": self.message} + if self.field: + out["field"] = self.field + if self.detail: + out["detail"] = self.detail + return out diff --git a/py/mplgui/fonts.py b/py/mplgui/fonts.py index 6c7bd54..d296421 100644 --- a/py/mplgui/fonts.py +++ b/py/mplgui/fonts.py @@ -1,6 +1,11 @@ -"""日本語フォントの設定と登録(js / pyodide には依存しない。取得処理は py/main.py 側)。""" -from matplotlib import font_manager +"""日本語フォントの設定と登録(js / pyodide には依存しない。取得処理は JS 側)。""" +from __future__ import annotations + +import tempfile +from pathlib import Path + import matplotlib.pyplot as plt +from matplotlib import font_manager SANS_SERIF_PRIORITY = [ "Noto Sans JP", @@ -13,6 +18,8 @@ ] PREFERRED_JP_FONTS = ["Noto Sans CJK JP", "Noto Sans JP"] +_registered = False + def configure_rcparams() -> None: plt.rcParams["font.family"] = "sans-serif" @@ -20,7 +27,7 @@ def configure_rcparams() -> None: plt.rcParams["axes.unicode_minus"] = False -def register_font_file(path: str) -> None: +def register_font_file(path) -> None: """フォントファイルを matplotlib に登録し、日本語フォントを sans-serif の先頭へ移す。""" font_manager.fontManager.addfont(str(path)) current = list(plt.rcParams.get("font.sans-serif", [])) @@ -29,3 +36,19 @@ def register_font_file(path: str) -> None: current.remove(name) current.insert(0, name) plt.rcParams["font.sans-serif"] = current + + +def register_font_bytes(data: bytes, filename: str = "NotoSansCJKjp-Regular.otf") -> bool: + """フォントのバイト列を登録する。登録は1セッション1回だけ(2回目以降は何もしない)。""" + global _registered + if _registered: + return False + target = Path(tempfile.gettempdir()) / filename + target.write_bytes(bytes(data)) + register_font_file(target) + _registered = True + return True + + +def is_registered() -> bool: + return _registered diff --git a/py/mplgui/loader.py b/py/mplgui/loader.py index dcd4e5a..36e9e74 100644 --- a/py/mplgui/loader.py +++ b/py/mplgui/loader.py @@ -1,10 +1,22 @@ -"""ファイルのバイト列から DataFrame を作る(DOM / js には依存しない)。""" +"""ファイルのバイト列から DataFrame を作り、列の選択肢とプレビューを返す(DOM / js には依存しない)。""" +from __future__ import annotations + import io +from dataclasses import dataclass from pathlib import Path import pandas as pd +from .errors import UserError + SUPPORTED = {".xlsx", ".csv", ".txt"} +PREVIEW_ROWS = 100 + + +@dataclass +class LoadedData: + df: pd.DataFrame + encoding: str | None # xlsx は None def decode_delimiter(delimiter: str, suffix: str) -> str: @@ -15,41 +27,82 @@ def decode_delimiter(delimiter: str, suffix: str) -> str: return r"\s+" -def load_dataframe( - file_bytes: bytes, - filename: str, - delimiter: str, - has_header: bool, -) -> pd.DataFrame: +def load_file(file_bytes: bytes, filename: str, delimiter: str, has_header: bool) -> LoadedData: suffix = Path(filename).suffix.lower() if suffix not in SUPPORTED: - raise ValueError("対応していない拡張子です。xlsx/csv/txtを選択してください。") + raise UserError("対応していない拡張子です。xlsx/csv/txtを選択してください。", field="入力ファイル") header = 0 if has_header else None stream = io.BytesIO(file_bytes) + used_encoding: str | None = None - if suffix == ".xlsx": - df = pd.read_excel(stream, header=header) - else: - sep = decode_delimiter(delimiter, suffix) - # UTF-8 で読めなければ Shift-JIS にフォールバック - for enc in ("utf-8", "shift_jis"): - try: - stream.seek(0) - df = pd.read_csv( - stream, sep=sep, header=header, - engine="python", encoding=enc, - ) - break - except (UnicodeDecodeError, pd.errors.ParserError): - if enc == "shift_jis": - raise - continue + try: + if suffix == ".xlsx": + df = pd.read_excel(stream, header=header) + else: + sep = decode_delimiter(delimiter, suffix) + # UTF-8 で読めなければ Shift-JIS にフォールバック(A5 で見直す) + df = None + for enc in ("utf-8", "shift_jis"): + try: + stream.seek(0) + df = pd.read_csv(stream, sep=sep, header=header, engine="python", encoding=enc) + used_encoding = enc + break + except UnicodeDecodeError: + if enc == "shift_jis": + raise UserError( + "文字コードを判定できませんでした。UTF-8 または Shift-JIS で保存し直してください。", + field="入力ファイル", + ) + continue + except UserError: + raise + except pd.errors.ParserError as exc: + raise UserError( + "ファイルを表として読み込めませんでした。区切り文字や行の形式を確認してください。", + field="区切り文字", + detail=str(exc), + ) from exc + except pd.errors.EmptyDataError as exc: + raise UserError("データが空です。", field="入力ファイル") from exc if df.empty: - raise ValueError("データが空です。") + raise UserError("データが空です。", field="入力ファイル") if not has_header: df.columns = [f"column_{i}" for i in range(len(df.columns))] - return df + return LoadedData(df=df, encoding=used_encoding) + + +def load_dataframe(file_bytes: bytes, filename: str, delimiter: str, has_header: bool) -> pd.DataFrame: + return load_file(file_bytes, filename, delimiter, has_header).df + + +def column_options(df: pd.DataFrame) -> list[dict]: + """列セレクト用の選択肢。値は列番号ベース("__idx__N")で、同名列も区別できる。""" + return [{"value": f"__idx__{i}", "label": f"{col} [{i}]"} for i, col in enumerate(df.columns)] + + +def _format_cells(preview_df: pd.DataFrame) -> list[list[str]]: + """pandas の表示と同じ書式(to_html と同じ桁数・NaN 表記)で文字列にする。""" + try: + from pandas.io.formats.format import format_array + + cols = [[s.strip() for s in format_array(preview_df.iloc[:, i]._values, None)] for i in range(preview_df.shape[1])] + return [list(row) for row in zip(*cols)] if cols else [] + except Exception: + return [[str(v) for v in row] for row in preview_df.itertuples(index=False, name=None)] + + +def build_preview(df: pd.DataFrame, limit: int = PREVIEW_ROWS) -> dict: + preview_df = df.head(limit) + return { + "columns": [str(c) for c in df.columns], + "rows": _format_cells(preview_df), + "totalRows": int(len(df)), + "totalColumns": int(len(df.columns)), + "limit": limit, + "truncated": bool(len(df) > limit), + } diff --git a/py/mplgui/plotting.py b/py/mplgui/plotting.py new file mode 100644 index 0000000..a336d99 --- /dev/null +++ b/py/mplgui/plotting.py @@ -0,0 +1,417 @@ +"""TRANSITIONAL: 設定 → matplotlib Figure(Phase 2 で codegen に置き換える)。 + +DOM / js には依存しない。呼び出し側が戻り値の Figure を必ず plt.close() すること。 +列指定は "__idx__N"(列番号)形式を使う。同名列があっても区別できる。 +""" +from __future__ import annotations + +from dataclasses import dataclass, field + +import matplotlib.pyplot as plt +import pandas as pd + +from .errors import UserError +from .settings import MarginSettings, Settings, series_to_legacy + + +@dataclass +class FigureResult: + fig: object + plotted_count: int + skip_rows: int + warnings: list = field(default_factory=list) # [{"series": 1始まり, "message": str}](A7 で使用) + + +# ---------------------------------------------------------------- column helpers (legacy-dict based) + + +def parse_column_index(requested: str) -> int | None: + if not requested.startswith("__idx__"): + return None + idx_text = requested.replace("__idx__", "", 1) + if not idx_text.isdigit(): + return None + return int(idx_text) + + +def resolve_column(df: pd.DataFrame, requested: str): + if requested.startswith("__idx__"): + idx_text = requested.replace("__idx__", "", 1) + if not idx_text.isdigit(): + raise UserError(f"列の指定が不正です: {requested}") + idx = int(idx_text) + if idx < 0 or idx >= len(df.columns): + raise UserError(f"列番号 {idx} がデータの列数を超えています。") + return df.columns[idx] + + if requested in df.columns: + return requested + matched = [col for col in df.columns if str(col) == requested] + if len(matched) == 1: + return matched[0] + raise UserError(f"列が見つかりません: {requested}") + + +def get_series_from_request(df: pd.DataFrame, requested: str) -> tuple[pd.Series, str, int]: + idx = parse_column_index(requested) + if idx is not None: + if idx < 0 or idx >= len(df.columns): + raise UserError(f"列番号 {idx} がデータの列数を超えています。") + return df.iloc[:, idx], str(df.columns[idx]), idx + + resolved = resolve_column(df, requested) + found_idx = -1 + for i, col in enumerate(df.columns): + if col == resolved: + found_idx = i + break + return df[resolved], str(resolved), found_idx + + +def resolve_series_requests(df: pd.DataFrame, settings: list[dict]) -> list[str]: + """Y列が自動("")の系列に、未使用の最初の数値列を割り当てる。""" + numeric_indices = [i for i, dtype in enumerate(df.dtypes) if pd.api.types.is_numeric_dtype(dtype)] + candidates = numeric_indices if numeric_indices else [0] + used_indices = set() + resolved = [] + + for setting in settings: + request = setting["y_request"] + if request: + idx = parse_column_index(request) + if idx is not None: + used_indices.add(idx) + resolved.append(request) + continue + + chosen = None + for idx in candidates: + if idx not in used_indices: + chosen = idx + break + if chosen is None: + chosen = candidates[0] + used_indices.add(chosen) + resolved.append(f"__idx__{chosen}") + + return resolved + + +def get_plot_data(df: pd.DataFrame, series_settings: list[dict], x_request: str = "") -> tuple[pd.Series, str, list[dict]]: + """共通X列を使うデータ整形(bar 用)。x_request が空なら index。""" + x_req = (x_request or "").strip() + y_requests = resolve_series_requests(df, series_settings) + + if x_req: + x, x_label, _ = get_series_from_request(df, x_req) + else: + x = pd.Series(df.index, name="index") + x_label = "index" + + plot_df = pd.DataFrame({"x": x}) + series_entries = [] + for i, (setting, request) in enumerate(zip(series_settings, y_requests)): + y_series, y_name, y_idx = get_series_from_request(df, request) + y_key = f"y_{i}" + label_suffix = f" [{y_idx}]" if y_idx >= 0 else "" + data_label = f"{y_name}{label_suffix}" + plot_df[y_key] = pd.to_numeric(y_series, errors="coerce") + entry = dict(setting) + entry["y_key"] = y_key + entry["data_label"] = data_label + entry["legend_label"] = setting.get("legend_name") or data_label + series_entries.append(entry) + + y_keys = [entry["y_key"] for entry in series_entries] + plot_df = plot_df.dropna(subset=y_keys, how="all") + if plot_df.empty: + raise UserError("描画可能な数値データがありません。") + + for entry in series_entries: + entry["y_series"] = plot_df[entry["y_key"]] + + return plot_df["x"], x_label, series_entries + + +def get_per_series_xy_data(df: pd.DataFrame, series_settings: list[dict]) -> tuple[list[dict], str]: + """系列ごとにX列を指定できるデータ整形(line / scatter 用)。""" + y_requests = resolve_series_requests(df, series_settings) + entries = [] + x_labels = [] + + for setting, y_request in zip(series_settings, y_requests): + x_request = setting.get("x_request", "").strip() + if x_request: + x_series, x_label, _ = get_series_from_request(df, x_request) + else: + x_series = pd.Series(df.index, name="index") + x_label = "index" + + y_series, y_name, y_idx = get_series_from_request(df, y_request) + label_suffix = f" [{y_idx}]" if y_idx >= 0 else "" + data_label = f"{y_name}{label_suffix}" + + entry = dict(setting) + entry["x_series"] = x_series.reset_index(drop=True) + entry["y_series"] = pd.to_numeric(y_series, errors="coerce").reset_index(drop=True) + entry["data_label"] = data_label + entry["legend_label"] = setting.get("legend_name") or data_label + entry["x_label"] = x_label + entries.append(entry) + x_labels.append(x_label) + + has_points = False + for entry in entries: + mask = entry["y_series"].notna() & pd.notna(entry["x_series"]) + if bool(mask.any()): + has_points = True + break + if not has_points: + raise UserError("描画可能な数値データがありません。") + + common_x_label = x_labels[0] if len(set(x_labels)) == 1 else "x" + return entries, common_x_label + + +# ---------------------------------------------------------------- styling shared with the legacy override + + +def apply_axis_scale_and_limits(target_ax, axis_kind: str, axis_label: str, scale: str, + min_value: float | None, max_value: float | None) -> None: + if scale not in {"linear", "log"}: + raise UserError(f"{axis_label}軸スケールが不正です。") + if min_value is not None and max_value is not None and min_value >= max_value: + raise UserError(f"{axis_label}軸の範囲は最小値 < 最大値で指定してください。") + if scale == "log" and ((min_value is not None and min_value <= 0) or (max_value is not None and max_value <= 0)): + raise UserError(f"{axis_label}軸を対数にする場合、範囲は0より大きい値で指定してください。") + + try: + if axis_kind == "x": + target_ax.set_xscale(scale) + if min_value is not None or max_value is not None: + target_ax.set_xlim(left=min_value, right=max_value) + elif axis_kind == "y": + target_ax.set_yscale(scale) + if min_value is not None or max_value is not None: + target_ax.set_ylim(bottom=min_value, top=max_value) + else: + raise UserError(f"軸種別が不正です: {axis_kind}") + except UserError: + raise + except Exception as exc: + raise UserError(f"{axis_label}軸設定の適用に失敗しました。", detail=str(exc)) from exc + + +def apply_subplot_margins(fig, margins: dict, use_tight_layout_if_auto: bool = False) -> None: + provided = {k: v for k, v in (margins or {}).items() if v is not None} + if provided: + if use_tight_layout_if_auto and len(provided) < 4: + try: + fig.tight_layout() + auto_margins = { + "left": fig.subplotpars.left, + "right": fig.subplotpars.right, + "bottom": fig.subplotpars.bottom, + "top": fig.subplotpars.top, + } + auto_margins.update(provided) + fig.subplots_adjust(**auto_margins) + return + except Exception: + pass + try: + fig.subplots_adjust(**provided) + except Exception as exc: + raise UserError("余白設定の適用に失敗しました。", detail=str(exc)) from exc + elif use_tight_layout_if_auto: + fig.tight_layout() + + +def apply_axes_decoration(ax, ax2, *, font_size: float, scales: dict, limits: dict, + show_major_grid: bool, show_minor_grid: bool) -> None: + """スケール・範囲・目盛(内向き)・グリッドを設定する。 + + scales / limits は {"x","y","y2"} をキーにし、limits の値は (min, max)。 + """ + apply_axis_scale_and_limits(ax, "x", "X", scales["x"], *limits["x"]) + apply_axis_scale_and_limits(ax, "y", "Y", scales["y"], *limits["y"]) + if ax2 is not None: + apply_axis_scale_and_limits(ax2, "y", "第2Y", scales["y2"], *limits["y2"]) + + label_size = max(font_size - 1, 1) + ax.tick_params(axis="x", which="both", direction="in", bottom=True, top=False, labelsize=label_size) + ax.tick_params(axis="y", which="both", direction="in", left=True, right=False, labelsize=label_size) + if ax2 is not None: + ax2.tick_params(axis="y", which="both", direction="in", left=False, right=True, labelsize=label_size) + + if show_minor_grid: + ax.minorticks_on() + if ax2 is not None: + ax2.minorticks_on() + else: + ax.minorticks_off() + if ax2 is not None: + ax2.minorticks_off() + + ax.grid(False, which="both", axis="both") + if show_major_grid: + ax.grid(True, which="major", axis="both", alpha=0.3) + if show_minor_grid: + ax.grid(True, which="minor", axis="both", alpha=0.2, linestyle=":") + if ax2 is not None: + ax2.grid(False, which="both", axis="y") + if show_major_grid: + ax2.grid(True, which="major", axis="y", alpha=0.3) + if show_minor_grid: + ax2.grid(True, which="minor", axis="y", alpha=0.2, linestyle=":") + + +def settings_scales_and_limits(settings: Settings) -> tuple[dict, dict]: + a = settings.axes + scales = {"x": a.x.scale, "y": a.y.scale, "y2": a.y2.scale} + limits = {"x": (a.x.min, a.x.max), "y": (a.y.min, a.y.max), "y2": (a.y2.min, a.y2.max)} + return scales, limits + + +def figure_has_visible_artists(fig) -> bool: + if fig is None or not hasattr(fig, "axes"): + return False + for axis in fig.axes: + if len(axis.lines) > 0 or len(axis.collections) > 0 or len(axis.patches) > 0 or len(axis.images) > 0: + return True + if hasattr(axis, "containers") and len(axis.containers) > 0: + return True + return False + + +# ---------------------------------------------------------------- figure creation + + +def slice_skip_rows(df_full: pd.DataFrame, skip_rows: int) -> pd.DataFrame: + if skip_rows >= len(df_full): + raise UserError("スキップ行数がデータ行数以上です。「除外する先頭行数」を小さくしてください。", field="除外する先頭行数") + return df_full.iloc[skip_rows:].reset_index(drop=True) + + +def create_figure(df_full: pd.DataFrame, settings: Settings) -> FigureResult: + """設定どおりの Figure を作る。失敗時は作りかけの Figure を閉じて例外を送出する。""" + plot = settings.plot + skip_rows = plot.skip_rows + df = slice_skip_rows(df_full, skip_rows) + series_settings = series_to_legacy(settings) + plot_type = plot.type + font_size = plot.font_size + + fig, ax = plt.subplots(figsize=(plot.figure.width, plot.figure.height), dpi=100) + try: + ax2 = None + plotted_labels = [] + + def target_axis(entry): + nonlocal ax2 + if entry.get("use_secondary_axis", False): + if ax2 is None: + ax2 = ax.twinx() + return ax2 + return ax + + if plot_type in {"line", "scatter"}: + all_entries, x_label = get_per_series_xy_data(df, series_settings) + for entry in all_entries: + x_series = entry["x_series"] + y_series = entry["y_series"] + mask = y_series.notna() & pd.notna(x_series) + if not bool(mask.any()): + continue + target_ax = target_axis(entry) + if plot_type == "line": + marker = "o" if entry["marker_size"] > 0 else "" + marker_size_for_line = entry["marker_size"] ** 0.5 if entry["marker_size"] > 0 else 0 + target_ax.plot( + x_series[mask], y_series[mask], + color=entry["color"], linewidth=entry["line_width"], linestyle=entry["line_style"], + marker=marker, markersize=marker_size_for_line, label=entry["legend_label"], + ) + else: + target_ax.scatter( + x_series[mask], y_series[mask], + color=entry["color"], s=entry["marker_size"], label=entry["legend_label"], + ) + plotted_labels.append(entry["legend_label"]) + else: + x, x_label, all_entries = get_plot_data(df, series_settings, plot.x_column) + if len(all_entries) == 1: + entry = all_entries[0] + y_series = entry["y_series"] + mask = y_series.notna() & pd.notna(x) + if bool(mask.any()): + target_axis(entry).bar( + x[mask], y_series[mask], + color=entry["color"], linewidth=entry["line_width"], linestyle=entry["line_style"], + label=entry["legend_label"], + ) + plotted_labels.append(entry["legend_label"]) + else: + x_labels = [str(v) for v in x.tolist()] + positions = list(range(len(x_labels))) + width = 0.8 / len(all_entries) + for i, entry in enumerate(all_entries): + y_values = [float(v) if pd.notna(v) else 0.0 for v in entry["y_series"].tolist()] + offset = -0.4 + (width / 2.0) + (i * width) + target_axis(entry).bar( + [p + offset for p in positions], y_values, width=width, + color=entry["color"], linewidth=entry["line_width"], linestyle=entry["line_style"], + label=entry["legend_label"], + ) + plotted_labels.append(entry["legend_label"]) + if positions: + if len(positions) > 25: + step = max(1, len(positions) // 25) + tick_positions = [positions[i] for i in range(0, len(positions), step)] + tick_labels = [x_labels[i] for i in range(0, len(x_labels), step)] + else: + tick_positions = positions + tick_labels = x_labels + ax.set_xticks(tick_positions) + ax.set_xticklabels(tick_labels, rotation=45, ha="right") + + if not plotted_labels: + raise UserError("描画可能なデータがありません。") + + primary_labels = [e["data_label"] for e in all_entries if not e.get("use_secondary_axis", False)] + secondary_labels = [e["data_label"] for e in all_entries if e.get("use_secondary_axis", False)] + axes = settings.axes + + if plot.title: + ax.set_title(plot.title, fontsize=font_size + 2) + ax.set_xlabel(axes.x.label or x_label, fontsize=font_size) + if axes.y.label: + ax.set_ylabel(axes.y.label, fontsize=font_size) + else: + ax.set_ylabel(primary_labels[0] if len(primary_labels) == 1 else "values", fontsize=font_size) + if ax2 is not None: + right_default = secondary_labels[0] if len(secondary_labels) == 1 else "values" + ax2.set_ylabel(axes.y2.label or right_default, fontsize=font_size) + + scales, limits = settings_scales_and_limits(settings) + apply_axes_decoration( + ax, ax2, font_size=font_size, scales=scales, limits=limits, + show_major_grid=plot.grid_major, show_minor_grid=plot.grid_minor, + ) + + if plot.legend_location != "none" and plotted_labels: + handles, labels = ax.get_legend_handles_labels() + if ax2 is not None: + h2, l2 = ax2.get_legend_handles_labels() + handles, labels = handles + h2, labels + l2 + if labels: + ax.legend(handles, labels, loc=plot.legend_location, fontsize=max(font_size - 1, 1), edgecolor="black") + + m: MarginSettings = plot.margins + apply_subplot_margins( + fig, {"left": m.left, "right": m.right, "bottom": m.bottom, "top": m.top}, use_tight_layout_if_auto=True, + ) + except BaseException: + plt.close(fig) + raise + return FigureResult(fig=fig, plotted_count=len(plotted_labels), skip_rows=skip_rows) diff --git a/py/mplgui/runner.py b/py/mplgui/runner.py index dbd1139..25061c0 100644 --- a/py/mplgui/runner.py +++ b/py/mplgui/runner.py @@ -1,44 +1,113 @@ -"""Figure を画像(data URI)にする。保存ファイル名の組み立ても行う(DOM / js には依存しない)。""" +"""Figure → 画像バイト列、保存ファイル名、旧 Pythonコード(beta) タブの実行(DOM / js には依存しない)。 + +カスタムコードは設定とは別の引数で受け取る。GUI 設定による上書き(A8)は従来どおり残している(Phase 2 で変更予定)。 +""" +from __future__ import annotations + import base64 +import builtins import io +import traceback +from functools import partial +import matplotlib.pyplot as plt +import pandas as pd -def figure_to_data_uri(fig, file_format: str, transparent: bool = False) -> tuple[str, str]: - fmt = (file_format or "").lower() - if fmt == "png": - save_format = "png" - mime = "image/png" - ext = "png" - elif fmt == "jpg": - save_format = "jpeg" - mime = "image/jpeg" - ext = "jpg" - elif fmt == "svg": - save_format = "svg" - mime = "image/svg+xml" - ext = "svg" - elif fmt == "pdf": - save_format = "pdf" - mime = "application/pdf" - ext = "pdf" - else: - raise ValueError("保存形式が不正です。") +from .errors import UserError +from .plotting import ( + FigureResult, + apply_axes_decoration, + apply_subplot_margins, + create_figure, + figure_has_visible_artists, + get_per_series_xy_data, + get_plot_data, + resolve_series_requests, + settings_scales_and_limits, + slice_skip_rows, +) +from .settings import Settings, to_legacy_dict + +DEFAULT_CUSTOM_PLOT_CODE = """# 利用可能オブジェクト: +# - df: skipRows適用後のDataFrame +# - current_df: 読み込み直後のDataFrame(skipRows未適用) +# - settings: GUI設定(dict) +# - plt, pd +# 必須: +# - fig に Figure を代入 +# 任意: +# - plotted_count (int) を設定(未設定時は自動判定) + +fig, ax = plt.subplots(figsize=(settings["fig_width"], settings["fig_height"]), dpi=100) + +if df.shape[1] < 2: + raise ValueError("2列以上のデータが必要です。") + +x = pd.to_numeric(df.iloc[:, 0], errors="coerce") +y = pd.to_numeric(df.iloc[:, 1], errors="coerce") +mask = x.notna() & y.notna() +if not bool(mask.any()): + raise ValueError("先頭2列に描画可能な数値データがありません。") + +series_settings = settings.get("series_settings", []) +default_legend = "" +if isinstance(series_settings, list) and len(series_settings) > 0: + default_legend = str(series_settings[0].get("legend_name", "") or "").strip() +if not default_legend: + default_legend = str(df.columns[1]) + +ax.plot( + x[mask], + y[mask], + color="#005AFF", + linewidth=2.0, + marker="o", + markersize=4, + label=default_legend, +) +ax.set_title(settings["title"]) +ax.set_xlabel(settings["x_label"] or str(df.columns[0])) +ax.set_ylabel(settings["y_label"] or str(df.columns[1])) +ax.grid(True, alpha=0.3) +ax.legend(loc="best", edgecolor="black") +fig.tight_layout() + +plotted_count = 1 +""" +_FORMATS = { + "png": ("png", "image/png", "png"), + "jpg": ("jpeg", "image/jpeg", "jpg"), + "svg": ("svg", "image/svg+xml", "svg"), + "pdf": ("pdf", "application/pdf", "pdf"), +} + + +def figure_to_bytes(fig, file_format: str, transparent: bool = False, dpi: int = 120) -> tuple[bytes, str, str]: + """Figure を指定形式のバイト列にする。戻り値は (bytes, mime, 拡張子)。""" + fmt = (file_format or "").lower() + if fmt not in _FORMATS: + raise UserError("保存形式が不正です。", field="保存形式") if transparent and fmt not in {"png", "svg"}: - raise ValueError("背景透過を有効にした場合、保存形式はpngまたはsvgを選択してください。") + raise UserError("背景透過を有効にした場合、保存形式はpngまたはsvgを選択してください。", field="保存形式") + save_format, mime, ext = _FORMATS[fmt] - buffer = io.BytesIO() save_kwargs = {} if save_format in {"png", "jpeg"}: - save_kwargs["dpi"] = 120 + save_kwargs["dpi"] = dpi if save_format == "jpeg": save_kwargs["facecolor"] = "white" if transparent: save_kwargs["transparent"] = True + buffer = io.BytesIO() fig.savefig(buffer, format=save_format, **save_kwargs) - encoded = base64.b64encode(buffer.getvalue()).decode("ascii") - return f"data:{mime};base64,{encoded}", ext + return buffer.getvalue(), mime, ext + + +def figure_to_data_uri(fig, file_format: str, transparent: bool = False, dpi: int = 120) -> tuple[str, str]: + data, mime, ext = figure_to_bytes(fig, file_format, transparent, dpi) + return f"data:{mime};base64,{base64.b64encode(data).decode('ascii')}", ext def build_filename(raw_filename: str, ext: str) -> str: @@ -51,3 +120,161 @@ def build_filename(raw_filename: str, ext: str) -> str: filename_base = filename_base.strip() or "plot" return f"{filename_base}.{ext}" return f"plot.{ext}" + + +# ---------------------------------------------------------------- legacy custom code + + +def _apply_gui_overrides_to_custom_figure(fig, legacy: dict, settings: Settings) -> None: + """従来どおり、GUI設定(タイトル・ラベル・スケール・目盛・グリッド・凡例)でカスタムコードの図を上書きする。""" + if fig is None or not hasattr(fig, "axes") or len(fig.axes) == 0: + return + + ax = fig.axes[0] + ax2 = fig.axes[1] if len(fig.axes) >= 2 else None + font_size = float(legacy.get("font_size", 15) or 15) + legend_location = legacy["legend_location"] + + if legacy["title"]: + ax.set_title(legacy["title"], fontsize=font_size + 2) + ax.set_xlabel(legacy["x_label"] or ax.get_xlabel(), fontsize=font_size) + ax.set_ylabel(legacy["y_label"] or ax.get_ylabel(), fontsize=font_size) + if ax2 is not None: + ax2.set_ylabel(legacy["y2_label"] or ax2.get_ylabel(), fontsize=font_size) + + scales, limits = settings_scales_and_limits(settings) + apply_axes_decoration( + ax, ax2, font_size=font_size, scales=scales, limits=limits, + show_major_grid=legacy["show_major_grid"], show_minor_grid=legacy["show_minor_grid"], + ) + + for axis in fig.axes: + existing_legend = axis.get_legend() + if existing_legend is not None: + existing_legend.remove() + + if legend_location == "none": + return + + handles = [] + labels = [] + for axis in fig.axes: + h, lab = axis.get_legend_handles_labels() + handles.extend(h) + labels.extend(lab) + if not labels: + return + + for i, setting in enumerate(legacy.get("series_settings", [])): + if i >= len(labels): + break + legend_name = str(setting.get("legend_name", "") or "").strip() + if legend_name: + labels[i] = legend_name + + ax.legend(handles, labels, loc=legend_location, fontsize=max(font_size - 1, 1), edgecolor="black") + + +def run_custom_code(code: str, df_full: pd.DataFrame, settings: Settings) -> FigureResult: + """Pythonコード(beta)を実行し、fig を取り出す。名前空間は従来と同じ。""" + skip_rows = settings.plot.skip_rows + df = slice_skip_rows(df_full, skip_rows) + if not (code or "").strip(): + raise UserError("カスタムコードが空です。", field="カスタムコード") + + legacy = to_legacy_dict(settings) + series_legacy = legacy["series_settings"] + x_request = settings.plot.x_column + helper_plot_data = partial(get_plot_data, x_request=x_request) + context = { + "df": df, + "current_df": df_full, + "settings": legacy, + "series_settings": series_legacy, + "helpers": { + "get_plot_data": helper_plot_data, + "get_per_series_xy_data": get_per_series_xy_data, + "resolve_series_requests": resolve_series_requests, + }, + } + local_scope = { + "df": df.copy(), + "current_df": df_full.copy(), + "settings": legacy, + "ctx": context, + "series_settings": series_legacy, + "get_plot_data": helper_plot_data, + "get_per_series_xy_data": get_per_series_xy_data, + "resolve_series_requests": resolve_series_requests, + "plt": plt, + "pd": pd, + "fig": None, + "plotted_count": None, + "skip_rows": skip_rows, + } + global_scope = {"__builtins__": builtins, "plt": plt, "pd": pd} + + before = set(plt.get_fignums()) + + def close_new_figures(keep=None): + for num in set(plt.get_fignums()) - before: + if keep is not None and getattr(keep, "number", None) == num: + continue + plt.close(num) + + fig = None + try: + try: + exec(code, global_scope, local_scope) + except UserError: + raise + except Exception as exc: + raise UserError( + f"カスタムコードの実行に失敗しました: {exc}", field="カスタムコード", detail=traceback.format_exc() + ) from exc + + fig = local_scope.get("fig") + if fig is None: + raise UserError("カスタムコードで fig を生成してください。", field="カスタムコード") + if not hasattr(fig, "savefig"): + raise UserError("fig には matplotlib.figure.Figure を設定してください。", field="カスタムコード") + _apply_gui_overrides_to_custom_figure(fig, legacy, settings) + m = settings.plot.margins + apply_subplot_margins( + fig, {"left": m.left, "right": m.right, "bottom": m.bottom, "top": m.top}, use_tight_layout_if_auto=True + ) + + plotted_raw = local_scope.get("plotted_count") + if plotted_raw is None: + plotted_count = 1 if figure_has_visible_artists(fig) else 0 + else: + try: + plotted_count = int(plotted_raw) + except Exception: + raise UserError("plotted_count は整数で指定してください。", field="カスタムコード") + if plotted_count <= 0 and figure_has_visible_artists(fig): + plotted_count = 1 + if plotted_count <= 0: + raise UserError("描画可能なデータがありません。") + + custom_skip_rows = local_scope.get("skip_rows", skip_rows) + try: + custom_skip_rows = int(custom_skip_rows) + except Exception: + custom_skip_rows = skip_rows + if custom_skip_rows < 0: + custom_skip_rows = skip_rows + except BaseException: + close_new_figures() + if fig is not None and hasattr(fig, "number"): + plt.close(fig) + raise + close_new_figures(keep=fig) + return FigureResult(fig=fig, plotted_count=plotted_count, skip_rows=custom_skip_rows) + + +def make_figure(df_full: pd.DataFrame, settings: Settings, custom_code: str | None = None) -> FigureResult: + """custom_code が None なら GUI 設定で、文字列ならそのコードで Figure を作る。""" + if custom_code is not None: + return run_custom_code(custom_code, df_full, settings) + return create_figure(df_full, settings) diff --git a/py/mplgui/settings.py b/py/mplgui/settings.py new file mode 100644 index 0000000..fbd4cd5 --- /dev/null +++ b/py/mplgui/settings.py @@ -0,0 +1,459 @@ +"""設定オブジェクト(JSON, camelCase, version=1)の既定値・検証・型付きデータクラス。 + +JS 側の js/defaults.js と default_settings() は同一でなければならない(単体テストで比較する)。 +このモジュールは js / pyodide / pyscript に依存しない。 +""" +from __future__ import annotations + +import copy +import math +from dataclasses import dataclass, field +from typing import Any + +from matplotlib.colors import is_color_like + +from .errors import UserError + +SCHEMA_VERSION = 1 + +PLOT_TYPES = ("line", "scatter", "bar") +LINE_STYLES = ("solid", "dashed", "dashdot", "dotted") +SCALES = ("linear", "log") +SAVE_FORMATS = ("png", "jpg", "svg", "pdf") +LEGEND_LOCATIONS = ( + "best", + "upper right", + "upper left", + "lower right", + "lower left", + "upper center", + "lower center", + "center right", + "center left", + "center", + "none", +) +DEFAULT_SCATTER_MARKER_SIZE = 24.0 + +_DEFAULT_SERIES = { + "id": "s1", + "x": "", + "y": "", + "color": "#FF4B00", + "lineWidth": 2, + "lineStyle": "solid", + "markerSize": None, + "label": "", + "secondaryAxis": False, +} + + +def default_settings() -> dict: + """既定の設定(JSON 互換の dict)。呼ぶたびに新しい dict を返す。""" + return { + "version": SCHEMA_VERSION, + "load": {"delimiter": "", "hasHeader": True}, + "plot": { + "type": "line", + "skipRows": 0, + "xColumn": "", + "title": "", + "fontSize": 15, + "figure": {"width": 8, "height": 6}, + "legend": {"location": "best"}, + "grid": {"major": False, "minor": False}, + "margins": {"left": None, "right": None, "bottom": None, "top": None}, + }, + "axes": { + "x": {"label": "", "scale": "linear", "min": None, "max": None}, + "y": {"label": "", "scale": "linear", "min": None, "max": None}, + "y2": {"label": "", "scale": "linear", "min": None, "max": None}, + }, + "series": [copy.deepcopy(_DEFAULT_SERIES)], + "save": {"filename": "", "format": "png", "transparent": False}, + } + + +# ---------------------------------------------------------------- dataclasses + + +@dataclass(frozen=True) +class LoadSettings: + delimiter: str = "" + has_header: bool = True + + +@dataclass(frozen=True) +class AxisSettings: + label: str = "" + scale: str = "linear" + min: float | None = None + max: float | None = None + + +@dataclass(frozen=True) +class FigureSettings: + width: float = 8.0 + height: float = 6.0 + + +@dataclass(frozen=True) +class MarginSettings: + left: float | None = None + right: float | None = None + bottom: float | None = None + top: float | None = None + + def provided(self) -> dict: + return {k: v for k, v in (("left", self.left), ("right", self.right), ("bottom", self.bottom), ("top", self.top)) if v is not None} + + +@dataclass(frozen=True) +class PlotSettings: + type: str = "line" + skip_rows: int = 0 + x_column: str = "" + title: str = "" + font_size: float = 15.0 + figure: FigureSettings = field(default_factory=FigureSettings) + legend_location: str = "best" + grid_major: bool = False + grid_minor: bool = False + margins: MarginSettings = field(default_factory=MarginSettings) + + +@dataclass(frozen=True) +class AxesSettings: + x: AxisSettings = field(default_factory=AxisSettings) + y: AxisSettings = field(default_factory=AxisSettings) + y2: AxisSettings = field(default_factory=AxisSettings) + + +@dataclass(frozen=True) +class SeriesSettings: + id: str = "s1" + x: str = "" + y: str = "" + color: str = "#FF4B00" + line_width: float = 2.0 + line_style: str = "solid" + marker_size: float | None = None # None = プロット種別ごとの自動値 + label: str = "" + secondary_axis: bool = False + + def effective_marker_size(self, plot_type: str) -> float: + """自動(None)は line=0 / それ以外=24。scatter で 0 以下なら 24 に補正する。""" + size = self.marker_size + if size is None: + return 0.0 if plot_type == "line" else DEFAULT_SCATTER_MARKER_SIZE + if plot_type == "scatter" and size <= 0: + return DEFAULT_SCATTER_MARKER_SIZE + return float(size) + + +@dataclass(frozen=True) +class SaveSettings: + filename: str = "" + format: str = "png" + transparent: bool = False + + +@dataclass(frozen=True) +class Settings: + version: int = SCHEMA_VERSION + load: LoadSettings = field(default_factory=LoadSettings) + plot: PlotSettings = field(default_factory=PlotSettings) + axes: AxesSettings = field(default_factory=AxesSettings) + series: tuple[SeriesSettings, ...] = () + save: SaveSettings = field(default_factory=SaveSettings) + + @property + def uses_secondary_axis(self) -> bool: + return any(s.secondary_axis for s in self.series) + + +# ---------------------------------------------------------------- coercion helpers + + +def _is_blank(value: Any) -> bool: + return value is None or (isinstance(value, str) and not value.strip()) + + +def _to_float(value: Any) -> float | None: + """数値 | 数値文字列 → float。変換できなければ None(bool・NaN・inf も不可)。""" + if isinstance(value, bool): + return None + if isinstance(value, (int, float)): + num = float(value) + elif isinstance(value, str): + try: + num = float(value.strip()) + except ValueError: + return None + else: + return None + return num if math.isfinite(num) else None + + +def _number(value: Any, label: str, *, default: float | None = None, minimum: float | None = None, + exclusive_min: float | None = None, maximum: float | None = None, owner: str = "") -> float | None: + """数値項目を検証する。空(None/空文字)は default を返す(None なら None)。""" + name = f"{owner}「{label}」" + if _is_blank(value): + return default + num = _to_float(value) + if num is None: + raise UserError(f"{name}は数値で入力してください。", field=label) + if exclusive_min is not None and num <= exclusive_min: + raise UserError(f"{name}は{_fmt(exclusive_min)}より大きい数値で入力してください。", field=label) + if minimum is not None and num < minimum: + raise UserError(f"{name}は{_fmt(minimum)}以上の数値で入力してください。", field=label) + if maximum is not None and num > maximum: + raise UserError(f"{name}は{_fmt(maximum)}以下の数値で入力してください。", field=label) + return num + + +def _fmt(v: float) -> str: + return str(int(v)) if float(v).is_integer() else str(v) + + +def _integer(value: Any, label: str, *, default: int = 0, minimum: int = 0) -> int: + if _is_blank(value): + return default + num = _to_float(value) + if num is None or not num.is_integer(): + raise UserError(f"「{label}」は{minimum}以上の整数で入力してください。", field=label) + if num < minimum: + raise UserError(f"「{label}」は{minimum}以上の整数で入力してください。", field=label) + return int(num) + + +def _string(value: Any, label: str, *, owner: str = "") -> str: + if value is None: + return "" + if not isinstance(value, str): + raise UserError(f"{owner}「{label}」は文字列で指定してください。", field=label) + return value + + +def _boolean(value: Any, label: str, *, owner: str = "") -> bool: + if isinstance(value, bool): + return value + raise UserError(f"{owner}「{label}」の値が不正です。", field=label) + + +def _choice(value: Any, label: str, choices: tuple[str, ...], *, owner: str = "") -> str: + if not isinstance(value, str) or value not in choices: + raise UserError(f"{owner}「{label}」の値が不正です。", field=label) + return value + + +def _merge(defaults: dict, given: Any) -> dict: + """given の値で defaults を再帰的に上書きする(欠けているキーは既定値)。given が dict でなければ既定値。""" + out = copy.deepcopy(defaults) + if not isinstance(given, dict): + return out + for key, value in given.items(): + if key in out and isinstance(out[key], dict): + out[key] = _merge(out[key], value) + else: + out[key] = value + return out + + +def _check_version(raw: dict) -> None: + version = raw.get("version", SCHEMA_VERSION) + if isinstance(version, bool) or version != SCHEMA_VERSION: + raise UserError(f"設定のバージョン({version})に対応していません。ページを再読み込みしてください。", field="version") + + +# ---------------------------------------------------------------- parsing + + +def parse_load_settings(raw: dict | None) -> LoadSettings: + """読み込み設定だけを検証する(描画設定の不備でファイル読込を止めないため)。""" + raw = raw if isinstance(raw, dict) else {} + _check_version(raw) + load = _merge(default_settings()["load"], raw.get("load")) + return LoadSettings( + delimiter=_string(load["delimiter"], "区切り文字"), + has_header=_boolean(load["hasHeader"], "先頭行をヘッダとして扱う"), + ) + + +def _parse_axis(raw: dict, key: str, name: str) -> AxisSettings: + axis = raw[key] + lo = _number(axis["min"], f"{name}軸の最小値", default=None) + hi = _number(axis["max"], f"{name}軸の最大値", default=None) + scale = _choice(axis["scale"], f"{name}軸スケール", SCALES) + return AxisSettings(label=_string(axis["label"], f"{name}軸ラベル").strip(), scale=scale, min=lo, max=hi) + + +def _check_axis_range(axis: AxisSettings, name: str) -> None: + if axis.min is not None and axis.max is not None and axis.min >= axis.max: + raise UserError(f"{name}軸の範囲は最小値 < 最大値で指定してください。", field=f"{name}軸の範囲") + if axis.scale == "log" and ((axis.min is not None and axis.min <= 0) or (axis.max is not None and axis.max <= 0)): + raise UserError(f"{name}軸を対数にする場合、範囲は0より大きい値で指定してください。", field=f"{name}軸の範囲") + + +def _parse_series(raw_series: Any) -> tuple[SeriesSettings, ...]: + if not isinstance(raw_series, list) or not raw_series: + raise UserError("描画系列を1つ以上追加してください。", field="描画系列") + out = [] + used_ids: set[str] = set() + for i, given in enumerate(raw_series): + n = i + 1 + owner = f"系列{n}の" + s = _merge(_DEFAULT_SERIES, given) + sid = s["id"] if isinstance(s["id"], str) and s["id"] and s["id"] not in used_ids else f"s{n}" + while sid in used_ids: + sid += "_" + used_ids.add(sid) + color = _string(s["color"], "色", owner=owner).strip() or _DEFAULT_SERIES["color"] + if not is_color_like(color): + raise UserError(f"{owner}「色」が不正です。", field="色") + line_width = _number(s["lineWidth"], "線幅", default=2.0, exclusive_min=0, owner=owner) + marker = _number(s["markerSize"], "点サイズ", default=None, minimum=0, owner=owner) + out.append( + SeriesSettings( + id=sid, + x=_string(s["x"], "X列", owner=owner).strip(), + y=_string(s["y"], "Y列", owner=owner).strip(), + color=color, + line_width=line_width, + line_style=_choice(s["lineStyle"], "線種", LINE_STYLES, owner=owner), + marker_size=marker, + label=_string(s["label"], "凡例名", owner=owner).strip(), + secondary_axis=_boolean(s["secondaryAxis"], "第2軸を使用", owner=owner), + ) + ) + return tuple(out) + + +def parse_settings(raw: dict | None) -> Settings: + """設定 dict を検証し、型付きの Settings にする。欠けているキーは既定値で補う。 + + 不正な値は UserError(日本語・項目名つき)。 + """ + raw = raw if isinstance(raw, dict) else {} + _check_version(raw) + d = default_settings() + merged = { + "load": _merge(d["load"], raw.get("load")), + "plot": _merge(d["plot"], raw.get("plot")), + "axes": _merge(d["axes"], raw.get("axes")), + "save": _merge(d["save"], raw.get("save")), + } + load = parse_load_settings(raw) + + p = merged["plot"] + fig = p["figure"] if isinstance(p["figure"], dict) else {} + figure = FigureSettings( + width=_number(fig.get("width"), "図幅", default=8.0, exclusive_min=0), + height=_number(fig.get("height"), "図高さ", default=6.0, exclusive_min=0), + ) + legend = p["legend"] if isinstance(p["legend"], dict) else {} + grid = p["grid"] if isinstance(p["grid"], dict) else {} + m = p["margins"] if isinstance(p["margins"], dict) else {} + margin_labels = (("left", "余白 左(left)"), ("right", "余白 右(right)"), ("bottom", "余白 下(bottom)"), ("top", "余白 上(top)")) + margin_values = {} + for key, label in margin_labels: + value = _number(m.get(key), label, default=None) + if value is not None and not 0 <= value <= 1: + raise UserError(f"「{label}」は0〜1の数値で入力してください。", field=label) + margin_values[key] = value + margins = MarginSettings(**margin_values) + if margins.left is not None and margins.right is not None and margins.left >= margins.right: + raise UserError("余白は left < right になるように指定してください。", field="余白") + if margins.bottom is not None and margins.top is not None and margins.bottom >= margins.top: + raise UserError("余白は bottom < top になるように指定してください。", field="余白") + + plot = PlotSettings( + type=_choice(p["type"], "プロット種別", PLOT_TYPES), + skip_rows=_integer(p["skipRows"], "除外する先頭行数", default=0, minimum=0), + x_column=_string(p["xColumn"], "X列").strip(), + title=_string(p["title"], "タイトル").strip(), + font_size=_number(p["fontSize"], "フォントサイズ", default=15.0, exclusive_min=0), + figure=figure, + legend_location=_choice(legend.get("location", "best"), "凡例位置", LEGEND_LOCATIONS), + grid_major=_boolean(grid.get("major", False), "主目盛線を表示"), + grid_minor=_boolean(grid.get("minor", False), "副目盛線を表示"), + margins=margins, + ) + + ax = merged["axes"] + axes = AxesSettings( + x=_parse_axis(ax, "x", "X"), + y=_parse_axis(ax, "y", "Y"), + y2=_parse_axis(ax, "y2", "第2Y"), + ) + series = _parse_series(raw.get("series", d["series"])) + _check_axis_range(axes.x, "X") + _check_axis_range(axes.y, "Y") + if any(s.secondary_axis for s in series): + _check_axis_range(axes.y2, "第2Y") + + sv = merged["save"] + save = SaveSettings( + filename=_string(sv["filename"], "保存ファイル名").strip(), + format=_choice(str(sv["format"]).strip().lower() if isinstance(sv["format"], str) else sv["format"], "保存形式", SAVE_FORMATS), + transparent=_boolean(sv["transparent"], "背景を透過して保存"), + ) + return Settings(version=SCHEMA_VERSION, load=load, plot=plot, axes=axes, series=series, save=save) + + +# ---------------------------------------------------------------- legacy (custom-code tab) + + +def series_to_legacy(settings: Settings) -> list[dict]: + """系列設定を従来の snake_case dict のリストにする(旧 _collect_series_settings と同形)。""" + return [ + { + "y_request": s.y, + "x_request": s.x, + "color": s.color, + "line_width": s.line_width, + "line_style": s.line_style, + "marker_size": s.effective_marker_size(settings.plot.type), + "legend_name": s.label, + "use_secondary_axis": s.secondary_axis, + } + for s in settings.series + ] + + +def to_legacy_dict(settings: Settings) -> dict: + """Pythonコード(beta)タブに渡す従来形式の settings dict(キーは従来と同じ)。""" + p, a = settings.plot, settings.axes + m = p.margins + return { + "skip_rows": p.skip_rows, + "plot_type": p.type, + "title": p.title, + "x_label": a.x.label, + "y_label": a.y.label, + "y2_label": a.y2.label, + "x_scale": a.x.scale, + "y_scale": a.y.scale, + "y2_scale": a.y2.scale, + "x_min": a.x.min, + "x_max": a.x.max, + "y_min": a.y.min, + "y_max": a.y.max, + "y2_min": a.y2.min, + "y2_max": a.y2.max, + "show_major_grid": p.grid_major, + "show_minor_grid": p.grid_minor, + "legend_location": p.legend_location, + "font_size": p.font_size, + "fig_width": p.figure.width, + "fig_height": p.figure.height, + "subplot_margins": {"left": m.left, "right": m.right, "bottom": m.bottom, "top": m.top}, + "subplot_left": m.left, + "subplot_right": m.right, + "subplot_bottom": m.bottom, + "subplot_top": m.top, + "x_column_request": p.x_column, + "series_settings": series_to_legacy(settings), + "series_settings_error": "", + } diff --git a/pyscript.toml b/pyscript.toml index 55ea780..ccfb65a 100644 --- a/pyscript.toml +++ b/pyscript.toml @@ -5,6 +5,10 @@ packages = ["pandas", "matplotlib", "openpyxl==3.1.5", "et_xmlfile==2.0.0"] # キーは取得元(ページからの相対パス)、値は配置先。 [files] "./py/mplgui/__init__.py" = "./mplgui/__init__.py" +"./py/mplgui/api.py" = "./mplgui/api.py" +"./py/mplgui/errors.py" = "./mplgui/errors.py" +"./py/mplgui/settings.py" = "./mplgui/settings.py" "./py/mplgui/loader.py" = "./mplgui/loader.py" +"./py/mplgui/plotting.py" = "./mplgui/plotting.py" "./py/mplgui/runner.py" = "./mplgui/runner.py" "./py/mplgui/fonts.py" = "./mplgui/fonts.py" diff --git a/tests/e2e/helpers.py b/tests/e2e/helpers.py index 936ef49..6d56e49 100644 --- a/tests/e2e/helpers.py +++ b/tests/e2e/helpers.py @@ -1,18 +1,25 @@ """アプリ固有の待機・操作ロジックはすべてここに置く(要素は id のみで選ぶ)。 -使用する信号(現行 index.html): -- アプリ準備完了: PyScript の初期化末尾で Python が `window.requestRender` / - `window.requestLoadColumns` を登録する。`typeof window.requestRender === 'function'` - を「Python準備完了」とする。 -- データ読込完了: `window.__matplotDataReady === true`(読込開始時に false、完了時に true)。 -- プロット更新: `#plotArea img` の src(PNG data URI)が変化したこと。 - 描画のたびに innerHTML ごと置き換えられる。 +使用する信号( の data 属性。js/bridge.js が更新する): +- data-app-state: "starting" → "ready"(Python 起動完了) +- data-data-state: "none" | "loading" | "ready" | "error"(データ読込) +- data-render-state: "idle" | "pending"(デバウンス待ち)| "rendering" +- data-render-generation: 最新の描画要求の世代番号。表示中の `#plotArea img` は data-generation を持つ。 +- `#status` の data-kind: "" | "ok" | "error" """ import time -READY_JS = "typeof window.requestRender === 'function' && typeof window.requestLoadColumns === 'function'" -DATA_READY_JS = "window.__matplotDataReady === true" +READY_JS = "document.documentElement.dataset.appState === 'ready'" +DATA_READY_JS = "document.documentElement.dataset.dataState === 'ready'" PLOT_SRC_JS = "(document.querySelector('#plotArea img') || {}).src || ''" +# 描画要求が残っておらず、表示中の画像が最新の要求のものになっている +SETTLED_JS = """() => { + const d = document.documentElement.dataset; + const img = document.querySelector('#plotArea img'); + return d.renderState === 'idle' && d.dataState !== 'loading' + && (!d.renderGeneration || (img && img.dataset.generation === d.renderGeneration) + || document.getElementById('status').dataset.kind === 'error'); +}""" class ConsoleCollector: @@ -67,6 +74,19 @@ def wait_plot_changed(page, previous_src, timeout=120_000): return plot_src(page) +def wait_settled(page, timeout=120_000): + """読込・描画が一段落するまで待つ(エラー終了を含む)。""" + page.wait_for_function(SETTLED_JS, timeout=timeout) + + +def status_text(page) -> str: + return page.inner_text("#status") + + +def status_kind(page) -> str: + return page.get_attribute("#status", "data-kind") or "" + + def set_title(page, text): page.fill("#title", text) # input イベントで250msデバウンス後に再描画 diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py new file mode 100644 index 0000000..7114122 --- /dev/null +++ b/tests/unit/conftest.py @@ -0,0 +1,30 @@ +import matplotlib + +matplotlib.use("Agg") # ブラウザ版と同じバックエンド。import 時点で固定する + +import warnings # noqa: E402 + +import pytest # noqa: E402 + +FIXTURES_NAME = "fixtures" + + +@pytest.fixture(autouse=True) +def _quiet_glyph_warnings(): + # 開発環境には日本語フォントが無いことがあるので、グリフ不足の警告は無視する + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", message=".*missing from font.*", category=UserWarning) + warnings.filterwarnings("ignore", message=".*missing from current font.*", category=UserWarning) + yield + + +@pytest.fixture(autouse=True) +def _no_leaked_figures(): + import matplotlib.pyplot as plt + + before = set(plt.get_fignums()) + yield + leaked = set(plt.get_fignums()) - before + for num in leaked: + plt.close(num) + assert not leaked, f"閉じられていない Figure が残っている: {sorted(leaked)}" diff --git a/tests/unit/test_api.py b/tests/unit/test_api.py new file mode 100644 index 0000000..4d69a3a --- /dev/null +++ b/tests/unit/test_api.py @@ -0,0 +1,98 @@ +import json +from pathlib import Path + +import pytest + +from mplgui import api +from mplgui.settings import default_settings + +pytestmark = pytest.mark.unit + +FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" + + +@pytest.fixture(autouse=True) +def fresh_session(): + api.SESSION.df = None + yield + api.SESSION.df = None + + +def load(name="utf8.csv", settings=None): + s = settings or default_settings() + return json.loads(api.load_file_json(name, (FIXTURES / name).read_bytes(), json.dumps(s))) + + +def test_load_file_response_shape(): + r = load() + assert r["ok"] is True and r["encoding"] == "utf-8" and r["warnings"] == [] + assert r["columns"][0] == {"value": "__idx__0", "label": "時間 [0]"} + assert r["preview"]["totalRows"] == 30 + + +def test_render_before_load_is_user_error(): + r = json.loads(api.render_json(json.dumps(default_settings()))) + assert r["ok"] is False and "ファイルを読み込んで" in r["error"]["message"] + + +def test_render_response_shape(): + load() + r = json.loads(api.render_json(json.dumps(default_settings()))) + assert r["ok"] is True and r["image"].startswith("data:image/png;base64,") + assert r["seriesCount"] == 1 and r["skipRows"] == 0 and r["warnings"] == [] + + +def test_render_validation_error_is_field_specific(): + load() + s = default_settings() + s["plot"]["fontSize"] = "x" + r = json.loads(api.render_json(json.dumps(s))) + assert r["ok"] is False and "フォントサイズ" in r["error"]["message"] and r["error"]["field"] == "フォントサイズ" + assert "detail" not in r["error"] + + +def test_bad_json_is_user_error(): + r = json.loads(api.render_json("{not json")) + assert r["ok"] is False and not r["error"]["message"].isascii() + + +def test_internal_error_has_short_message_and_traceback(monkeypatch): + load() + + def boom(*a, **k): + raise RuntimeError("kaboom") + + monkeypatch.setattr(api, "make_figure", boom) + r = json.loads(api.render_json(json.dumps(default_settings()))) + assert r["ok"] is False and r["error"]["message"] == api.INTERNAL_ERROR_MESSAGE + assert "RuntimeError" in r["error"]["detail"] and "kaboom" in r["error"]["detail"] and "Traceback" in r["error"]["detail"] + + +def test_load_failure_discards_previous_data(): + load() + bad = json.loads(api.load_file_json("x.pdf", b"x", json.dumps(default_settings()))) + assert bad["ok"] is False and api.SESSION.df is None + + +def test_save_response(): + load() + s = default_settings() + s["save"].update(filename="結果.csv", format="svg") + r = json.loads(api.save_json(json.dumps(s))) + assert r["ok"] and r["filename"] == "結果.svg" and r["mime"] == "image/svg+xml" + assert r["dataUri"].startswith("data:image/svg+xml;base64,") + s["save"].update(format="jpg", transparent=True) + r = json.loads(api.save_json(json.dumps(s))) + assert r["ok"] is False and "背景透過" in r["error"]["message"] + + +def test_render_with_custom_code(): + load() + r = json.loads(api.render_json(json.dumps(default_settings()), api.default_custom_code())) + assert r["ok"] is True + r = json.loads(api.render_json(json.dumps(default_settings()), "raise ValueError('x')")) + assert r["ok"] is False and "カスタムコード" in r["error"]["message"] + + +def test_font_status_before_register(): + assert json.loads(api.font_status())["ok"] is True diff --git a/tests/unit/test_defaults_parity.py b/tests/unit/test_defaults_parity.py new file mode 100644 index 0000000..fcfc227 --- /dev/null +++ b/tests/unit/test_defaults_parity.py @@ -0,0 +1,32 @@ +"""js/defaults.js の DEFAULT_SETTINGS と mplgui.settings.default_settings() が一致すること(node が無ければ skip)。""" +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +from mplgui.settings import default_settings + +pytestmark = pytest.mark.unit + +REPO = Path(__file__).resolve().parents[2] + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node が無い") +def test_js_and_python_defaults_match(tmp_path): + module = tmp_path / "defaults.mjs" + module.write_text((REPO / "js" / "defaults.js").read_text(encoding="utf-8"), encoding="utf-8") + script = f"import {{ DEFAULT_SETTINGS }} from {json.dumps(module.as_uri())}; console.log(JSON.stringify(DEFAULT_SETTINGS));" + out = subprocess.run(["node", "--input-type=module", "-e", script], capture_output=True, text=True, check=True, timeout=30) + assert json.loads(out.stdout) == default_settings() + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node が無い") +def test_js_palette_has_default_series_color(tmp_path): + module = tmp_path / "defaults.mjs" + module.write_text((REPO / "js" / "defaults.js").read_text(encoding="utf-8"), encoding="utf-8") + script = f"import {{ PALETTE, DEFAULT_SERIES }} from {json.dumps(module.as_uri())}; console.log(JSON.stringify([PALETTE, DEFAULT_SERIES.color]));" + out = subprocess.run(["node", "--input-type=module", "-e", script], capture_output=True, text=True, check=True, timeout=30) + palette, color = json.loads(out.stdout) + assert len(palette) == 11 and palette[0] == color == default_settings()["series"][0]["color"] diff --git a/tests/unit/test_loader.py b/tests/unit/test_loader.py new file mode 100644 index 0000000..a1eed94 --- /dev/null +++ b/tests/unit/test_loader.py @@ -0,0 +1,115 @@ +from pathlib import Path + +import pytest + +from mplgui.errors import UserError +from mplgui.loader import build_preview, column_options, decode_delimiter, load_dataframe, load_file + +pytestmark = pytest.mark.unit + +FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" + + +def _load(name, delimiter="", has_header=True): + return load_file((FIXTURES / name).read_bytes(), name, delimiter, has_header) + + +def test_decode_delimiter(): + assert decode_delimiter("", ".csv") == "," + assert decode_delimiter("", ".txt") == r"\s+" + assert decode_delimiter(r"\t", ".txt") == "\t" + assert decode_delimiter(";", ".csv") == ";" + + +def test_utf8_csv(): + loaded = _load("utf8.csv") + assert list(loaded.df.columns) == ["時間", "電圧", "電流"] + assert loaded.encoding == "utf-8" and len(loaded.df) == 30 + + +def test_utf8_bom_csv_loads(): + # BOM が列名に残らない対応は A5(Step 5)。ここでは読めて列数が正しいことだけ確認する + loaded = _load("utf8_bom.csv") + assert loaded.df.shape == (30, 3) + + +def test_cp932_fixture_is_not_supported_by_current_logic(): + # 現行の文字コード判定(utf-8 → shift_jis)では機種依存文字を含む cp932 は読めない。A5 で対応する + with pytest.raises(UserError) as info: + _load("cp932.csv") + assert "文字コード" in info.value.message + + +def test_tab_txt_whitespace_auto(): + loaded = _load("tab.txt") + assert list(loaded.df.columns) == ["時間", "電圧", "電流"] + + +def test_tab_txt_explicit_delimiter(): + loaded = _load("tab.txt", delimiter=r"\t") + assert loaded.df.shape == (30, 3) + + +def test_xlsx_first_sheet(): + loaded = _load("multi_sheet.xlsx") + assert list(loaded.df.columns) == ["時間", "電圧", "電流"] + assert loaded.encoding is None + + +def test_duplicate_columns_are_distinguishable_by_index(): + df = _load("duplicate_columns.csv").df + opts = column_options(df) + assert [o["value"] for o in opts] == ["__idx__0", "__idx__1", "__idx__2"] + assert len({o["label"] for o in opts}) == 3 + assert opts[0]["label"].endswith("[0]") and opts[1]["label"].endswith("[1]") + assert not df.iloc[:, 0].equals(df.iloc[:, 1]) + + +def test_no_header_names_columns(): + loaded = _load("utf8.csv", has_header=False) + assert list(loaded.df.columns) == ["column_0", "column_1", "column_2"] + assert len(loaded.df) == 31 + + +def test_non_numeric_fixture_loads_as_text_columns(): + df = _load("non_numeric.csv").df + import pandas as pd + + assert not pd.api.types.is_numeric_dtype(df["金額"]) and not pd.api.types.is_numeric_dtype(df["単位付き"]) + + +def test_preview_payload_shape(): + df = _load("utf8.csv").df + p = build_preview(df) + assert set(p) == {"columns", "rows", "totalRows", "totalColumns", "limit", "truncated"} + assert p["columns"] == ["時間", "電圧", "電流"] + assert p["totalRows"] == 30 and p["totalColumns"] == 3 and p["truncated"] is False + assert len(p["rows"]) == 30 and all(len(r) == 3 for r in p["rows"]) + assert all(isinstance(c, str) for r in p["rows"] for c in r) + assert p["rows"][1] == ["0.1", "0.1987", "1.4777"] + + +def test_preview_is_truncated_to_100_rows(): + import pandas as pd + + p = build_preview(pd.DataFrame({"a": range(250), "b": range(250)})) + assert len(p["rows"]) == 100 and p["truncated"] is True and p["totalRows"] == 250 and p["limit"] == 100 + + +def test_nan_cells_in_preview(): + p = build_preview(_load("non_numeric.csv").df) + assert any("NaN" in row for row in p["rows"]) + + +@pytest.mark.parametrize( + "name, data", + [("a.pdf", b"x"), ("empty.csv", b""), ("onlyheader.csv", b"a,b\n")], +) +def test_errors_are_user_errors_in_japanese(name, data): + with pytest.raises(UserError) as info: + load_file(data, name, "", True) + assert not info.value.message.isascii() + + +def test_load_dataframe_wrapper(): + assert not load_dataframe((FIXTURES / "utf8.csv").read_bytes(), "utf8.csv", "", True).empty diff --git a/tests/unit/test_mplgui_modules.py b/tests/unit/test_mplgui_modules.py index b9ef919..98b7963 100644 --- a/tests/unit/test_mplgui_modules.py +++ b/tests/unit/test_mplgui_modules.py @@ -1,16 +1,14 @@ -"""mplgui の各モジュールが js / pyodide / pyscript に依存せず CPython で import でき、基本動作することを確認する。""" +"""mplgui の各モジュールが js / pyodide / pyscript に依存せず CPython で import できること。""" import ast +import importlib from pathlib import Path import pytest -from mplgui.loader import decode_delimiter, load_dataframe -from mplgui.runner import build_filename, figure_to_data_uri - pytestmark = pytest.mark.unit -FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" PKG = Path(__file__).resolve().parents[2] / "py" / "mplgui" +FORBIDDEN = {"js", "pyodide", "pyscript"} def test_no_browser_imports(): @@ -23,30 +21,15 @@ def test_no_browser_imports(): elif isinstance(node, ast.ImportFrom): names = [node.module or ""] for n in names: - assert n.split(".")[0] not in {"js", "pyodide", "pyscript"}, f"{path.name} imports {n}" - - -def test_load_csv_and_delimiter(): - df = load_dataframe((FIXTURES / "utf8.csv").read_bytes(), "utf8.csv", "", True) - assert not df.empty - assert decode_delimiter("", ".csv") == "," - assert decode_delimiter(r"\t", ".txt") == "\t" - - -def test_build_filename(): - assert build_filename("", "png") == "plot.png" - assert build_filename("fig.1.svg", "pdf") == "fig.1.pdf" - assert build_filename("name.", "jpg") == "name.jpg" + assert n.split(".")[0] not in FORBIDDEN, f"{path.name} imports {n}" -def test_figure_to_data_uri_png(): - import matplotlib +def test_every_module_imports_on_cpython(): + for path in sorted(PKG.glob("*.py")): + if path.name != "__init__.py": + importlib.import_module(f"mplgui.{path.stem}") - matplotlib.use("Agg") - import matplotlib.pyplot as plt - fig, ax = plt.subplots() - ax.plot([0, 1], [0, 1]) - uri, ext = figure_to_data_uri(fig, "png") - plt.close(fig) - assert uri.startswith("data:image/png;base64,") and ext == "png" +def test_main_py_does_not_touch_the_dom(): + text = (PKG.parent / "main.py").read_text(encoding="utf-8") + assert "document" not in text and "getElementById" not in text diff --git a/tests/unit/test_pages_publish.py b/tests/unit/test_pages_publish.py index 511a1ba..6939d50 100644 --- a/tests/unit/test_pages_publish.py +++ b/tests/unit/test_pages_publish.py @@ -110,3 +110,17 @@ def test_publish_rules_sanity(jekyll_config): assert _is_published("py/mplgui/__init__.py", jekyll_config) assert not _is_published("py/mplgui/__init__.py", {}) # include が無ければ Jekyll は配信しない assert not _is_published("_hidden/a.js", jekyll_config) + + +def test_all_js_files_are_published_and_imports_resolve(jekyll_config): + import re + + js_files = sorted((REPO / "js").rglob("*.js")) + assert js_files + for path in js_files: + rel = path.relative_to(REPO).as_posix() + _assert_published(rel, jekyll_config, "js/") + for spec in re.findall(r"""(?:import|from)\s+["'](\.{1,2}/[^"']+)["']""", path.read_text(encoding="utf-8")): + target = (path.parent / spec).resolve() + assert target.is_file(), f"{rel}: import {spec} が解決できない" + _assert_published(target.relative_to(REPO).as_posix(), jekyll_config, f"{rel} import") diff --git a/tests/unit/test_plotting.py b/tests/unit/test_plotting.py new file mode 100644 index 0000000..3c22375 --- /dev/null +++ b/tests/unit/test_plotting.py @@ -0,0 +1,323 @@ +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd +import pytest +from matplotlib.collections import PathCollection +from matplotlib.patches import Rectangle + +from mplgui.errors import UserError +from mplgui.plotting import create_figure, get_per_series_xy_data, get_plot_data, resolve_series_requests +from mplgui.settings import default_settings, parse_settings, series_to_legacy + +pytestmark = pytest.mark.unit + + +@pytest.fixture +def df(): + t = np.arange(20, dtype=float) + return pd.DataFrame({"t": t, "a": np.sin(t), "b": t * 2.0, "name": [f"n{i}" for i in range(20)]}) + + +def settings_with(mutator=None, series=None): + raw = default_settings() + if series is not None: + raw["series"] = series + if mutator: + mutator(raw) + return parse_settings(raw) + + +def build(df, mutator=None, series=None): + result = create_figure(df, settings_with(mutator, series)) + return result + + +def close(result): + plt.close(result.fig) + + +def lines(ax): + return [ln for ln in ax.get_lines()] + + +def bars(ax): + return [p for p in ax.patches if isinstance(p, Rectangle)] + + +def test_line_two_series(): + r = build( + pd.DataFrame({"t": range(10), "a": range(10), "b": range(10, 20)}), + series=[{"x": "__idx__0", "y": "__idx__1", "color": "#005AFF"}, {"x": "__idx__0", "y": "__idx__2", "color": "#03AF7A"}], + ) + try: + ax = r.fig.axes[0] + assert len(r.fig.axes) == 1 and len(lines(ax)) == 2 and r.plotted_count == 2 + assert lines(ax)[0].get_color().lower() == "#005aff" and lines(ax)[0].get_linewidth() == 2 + assert lines(ax)[0].get_marker() in ("", "None", None) # 点サイズ自動(line)=0 は点なし + assert not ax.collections + finally: + close(r) + + +def test_line_marker_shown_when_size_positive(): + r = build(pd.DataFrame({"a": [1.0, 2.0, 3.0]}), series=[{"markerSize": 9}]) + try: + ln = lines(r.fig.axes[0])[0] + assert ln.get_marker() == "o" and ln.get_markersize() == pytest.approx(3.0) + finally: + close(r) + + +def test_line_style_and_width(): + r = build(pd.DataFrame({"a": [1.0, 2.0, 3.0]}), series=[{"lineStyle": "dashed", "lineWidth": 3.5}]) + try: + ln = lines(r.fig.axes[0])[0] + assert ln.get_linestyle() == "--" and ln.get_linewidth() == 3.5 + finally: + close(r) + + +def test_scatter_creates_path_collections(): + r = build( + pd.DataFrame({"x": range(5), "a": range(5), "b": range(5)}), + mutator=lambda s: s["plot"].__setitem__("type", "scatter"), + series=[{"x": "__idx__0", "y": "__idx__1"}, {"x": "__idx__0", "y": "__idx__2", "markerSize": 50}], + ) + try: + ax = r.fig.axes[0] + cols = [c for c in ax.collections if isinstance(c, PathCollection)] + assert len(cols) == 2 and not lines(ax) + assert cols[0].get_sizes()[0] == 24 # 自動 → scatter の既定 24 + assert cols[1].get_sizes()[0] == 50 + finally: + close(r) + + +def test_scatter_size_zero_is_corrected_to_24(): + r = build( + pd.DataFrame({"a": [1.0, 2.0]}), + mutator=lambda s: s["plot"].__setitem__("type", "scatter"), + series=[{"markerSize": 0}], + ) + try: + assert r.fig.axes[0].collections[0].get_sizes()[0] == 24 + finally: + close(r) + + +def test_bar_single_series_uses_x_column(df): + r = build( + df.head(6), + mutator=lambda s: s["plot"].update(type="bar", xColumn="__idx__0"), + series=[{"y": "__idx__1"}], + ) + try: + assert len(bars(r.fig.axes[0])) == 6 + finally: + close(r) + + +def test_bar_multiple_series_grouped(df): + r = build( + df.head(5), + mutator=lambda s: s["plot"].update(type="bar"), + series=[{"y": "__idx__1"}, {"y": "__idx__2"}], + ) + try: + ax = r.fig.axes[0] + assert len(bars(ax)) == 10 and r.plotted_count == 2 + assert set(range(5)) <= set(ax.get_xticks()) + finally: + close(r) + + +def test_secondary_axis_uses_twinx(): + d = pd.DataFrame({"x": range(10), "a": range(10), "b": [v * 100 for v in range(10)]}) + r = build( + d, + series=[{"x": "__idx__0", "y": "__idx__1"}, {"x": "__idx__0", "y": "__idx__2", "secondaryAxis": True}], + mutator=lambda s: s["axes"]["y2"].update(label="右軸"), + ) + try: + assert len(r.fig.axes) == 2 + ax, ax2 = r.fig.axes + assert len(lines(ax)) == 1 and len(lines(ax2)) == 1 + assert ax2.get_ylabel() == "右軸" + assert ax2.get_shared_x_axes().joined(ax, ax2) + # 凡例は両軸のハンドルを統合する + assert [t.get_text() for t in ax.get_legend().get_texts()] == ["a [1]", "b [2]"] + # ヒゲ: 主軸は下・左、第2軸は右のみ + assert ax2.yaxis.get_ticks_position() in ("right", "default") and ax.xaxis.get_ticks_position() == "bottom" + finally: + close(r) + + +def test_no_second_axis_without_secondary_series(df): + r = build(df, series=[{"y": "__idx__1"}]) + try: + assert len(r.fig.axes) == 1 + finally: + close(r) + + +@pytest.mark.parametrize("loc, expect_legend", [("best", True), ("upper left", True), ("none", False)]) +def test_legend_presence(df, loc, expect_legend): + r = build(df, mutator=lambda s: s["plot"]["legend"].__setitem__("location", loc), series=[{"y": "__idx__1"}]) + try: + legend = r.fig.axes[0].get_legend() + assert (legend is not None) is expect_legend + if expect_legend: + assert [t.get_text() for t in legend.get_texts()] == ["a [1]"] + finally: + close(r) + + +def test_legend_custom_label(df): + r = build(df, series=[{"y": "__idx__1", "label": "電圧"}]) + try: + assert [t.get_text() for t in r.fig.axes[0].get_legend().get_texts()] == ["電圧"] + finally: + close(r) + + +def test_grid_flags(df): + def grids(ax): + return ( + any(ln.get_visible() for ln in ax.get_xgridlines()), + any(ln.get_visible() for ln in ax.get_ygridlines()), + ) + + r = build(df, mutator=lambda s: s["plot"]["grid"].update(major=True)) + try: + assert grids(r.fig.axes[0]) == (True, True) + finally: + close(r) + r = build(df) + try: + assert grids(r.fig.axes[0]) == (False, False) + finally: + close(r) + r = build(df, mutator=lambda s: s["plot"]["grid"].update(minor=True)) + try: + ax = r.fig.axes[0] + assert ax.xaxis.get_minor_ticks()[0].gridline.get_visible() + assert not any(ln.get_visible() for ln in ax.get_xgridlines()) # 主目盛線は出さない + finally: + close(r) + + +def test_log_scale_and_limits(df): + def m(s): + s["axes"]["y"].update(scale="log", min=0.5, max=100) + s["axes"]["x"].update(min=2, max=10) + + r = build(df.assign(a=np.arange(1, 21)), mutator=m, series=[{"y": "__idx__1"}]) + try: + ax = r.fig.axes[0] + assert ax.get_yscale() == "log" and ax.get_xscale() == "linear" + assert ax.get_ylim() == (0.5, 100) and ax.get_xlim() == (2, 10) + finally: + close(r) + + +def test_figure_size_title_and_labels(df): + def m(s): + s["plot"].update(title="タイトル", fontSize=12) + s["plot"]["figure"].update(width=5, height=3) + s["axes"]["x"]["label"] = "X軸" + s["axes"]["y"]["label"] = "Y軸" + + r = build(df, mutator=m, series=[{"y": "__idx__1"}]) + try: + ax = r.fig.axes[0] + assert tuple(r.fig.get_size_inches()) == (5.0, 3.0) + assert ax.get_title() == "タイトル" and ax.title.get_fontsize() == 14 + assert ax.get_xlabel() == "X軸" and ax.get_ylabel() == "Y軸" and ax.xaxis.label.get_fontsize() == 12 + finally: + close(r) + + +def test_default_labels_come_from_columns(df): + r = build(df, series=[{"x": "__idx__0", "y": "__idx__1"}]) + try: + ax = r.fig.axes[0] + assert ax.get_xlabel() == "t" and ax.get_ylabel() == "a [1]" + finally: + close(r) + + +def test_ticks_inward(df): + r = build(df) + try: + ax = r.fig.axes[0] + assert ax.xaxis.get_major_ticks()[0]._pad is not None + assert not ax.xaxis.get_major_ticks()[0].tick2line.get_visible() # 上側のヒゲなし + assert not ax.yaxis.get_major_ticks()[0].tick2line.get_visible() # 右側のヒゲなし + finally: + close(r) + + +def test_margins_applied(df): + r = build(df, mutator=lambda s: s["plot"]["margins"].update(left=0.2, right=0.9)) + try: + assert r.fig.subplotpars.left == pytest.approx(0.2) and r.fig.subplotpars.right == pytest.approx(0.9) + finally: + close(r) + + +def test_skip_rows_excludes_leading_rows(): + d = pd.DataFrame({"a": [100.0, 200.0, 1.0, 2.0, 3.0]}) + r = build(d, mutator=lambda s: s["plot"].__setitem__("skipRows", 2)) + try: + assert r.skip_rows == 2 + assert list(lines(r.fig.axes[0])[0].get_ydata()) == [1.0, 2.0, 3.0] + finally: + close(r) + + +def test_skip_rows_too_large(df): + with pytest.raises(UserError) as info: + build(df, mutator=lambda s: s["plot"].__setitem__("skipRows", 20)) + assert "スキップ" in info.value.message + + +def test_no_plottable_data_raises_and_closes_figure(): + before = set(plt.get_fignums()) + with pytest.raises(UserError): + build(pd.DataFrame({"a": ["x", "y", "z"]}), series=[{"y": "__idx__0"}]) + assert set(plt.get_fignums()) == before # 失敗しても Figure は残らない + + +def test_column_out_of_range_is_user_error(df): + with pytest.raises(UserError) as info: + build(df, series=[{"y": "__idx__99"}]) + assert "99" in info.value.message + + +def test_duplicate_column_names_resolved_by_index(): + d = pd.DataFrame([[1, 10, 100], [2, 20, 200], [3, 30, 300]], columns=["温度", "温度", "値"]) + r = build(d, series=[{"y": "__idx__0"}, {"y": "__idx__1"}]) + try: + ys = [list(ln.get_ydata()) for ln in lines(r.fig.axes[0])] + assert ys == [[1, 2, 3], [10, 20, 30]] + finally: + close(r) + + +def test_auto_y_assigns_unused_numeric_columns(df): + r = build(df, series=[{}, {}]) + try: + ys = [list(ln.get_ydata()) for ln in lines(r.fig.axes[0])] + assert ys[0] == list(df["t"]) and ys[1] == list(df["a"]) # 数値列を順に割り当てる + finally: + close(r) + + +def test_legacy_helpers(df): + s = settings_with(series=[{"y": ""}, {"y": "__idx__2"}]) + legacy = series_to_legacy(s) + assert resolve_series_requests(df, legacy) == ["__idx__0", "__idx__2"] + entries, xlabel = get_per_series_xy_data(df, legacy) + assert xlabel == "index" and len(entries) == 2 and entries[1]["data_label"] == "b [2]" + x, xl, es = get_plot_data(df, legacy, "__idx__0") + assert xl == "t" and len(es) == 2 and list(x) == list(df["t"]) diff --git a/tests/unit/test_runner.py b/tests/unit/test_runner.py new file mode 100644 index 0000000..74147d8 --- /dev/null +++ b/tests/unit/test_runner.py @@ -0,0 +1,171 @@ +import pandas as pd +import pytest + +import matplotlib.pyplot as plt + +from mplgui.errors import UserError +from mplgui.runner import ( + DEFAULT_CUSTOM_PLOT_CODE, + build_filename, + figure_to_bytes, + figure_to_data_uri, + make_figure, + run_custom_code, +) +from mplgui.settings import default_settings, parse_settings + +pytestmark = pytest.mark.unit + + +@pytest.fixture +def fig(): + f, ax = plt.subplots() + ax.plot([0, 1, 2], [0, 1, 0]) + yield f + plt.close(f) + + +@pytest.fixture +def df(): + return pd.DataFrame({"t": [0.0, 1.0, 2.0, 3.0], "v": [1.0, 2.0, 3.0, 4.0], "w": [4.0, 3.0, 2.0, 1.0]}) + + +def settings(**plot): + raw = default_settings() + raw["plot"].update(plot) + return parse_settings(raw) + + +@pytest.mark.parametrize( + "fmt, magic, mime, ext", + [ + ("png", b"\x89PNG\r\n\x1a\n", "image/png", "png"), + ("jpg", b"\xff\xd8\xff", "image/jpeg", "jpg"), + ("svg", b"= 行数 + + +def test_custom_code_empty_figure_is_error(df): + before = set(plt.get_fignums()) + with pytest.raises(UserError): + run_custom_code("fig, ax = plt.subplots()", df, settings()) + assert set(plt.get_fignums()) == before + + +def test_make_figure_dispatch(df): + r = make_figure(df, settings()) + plt.close(r.fig) + assert r.plotted_count == 1 + r = make_figure(df, settings(), "fig, ax = plt.subplots()\nax.plot([0,1],[0,1])") + plt.close(r.fig) + assert r.plotted_count == 1 diff --git a/tests/unit/test_settings.py b/tests/unit/test_settings.py new file mode 100644 index 0000000..dfe1487 --- /dev/null +++ b/tests/unit/test_settings.py @@ -0,0 +1,215 @@ +import copy +import json + +import pytest + +from mplgui.errors import UserError +from mplgui.settings import ( + SCHEMA_VERSION, + default_settings, + parse_load_settings, + parse_settings, + to_legacy_dict, +) + +pytestmark = pytest.mark.unit + + +def test_defaults_are_complete_and_parse(): + d = default_settings() + assert d["version"] == SCHEMA_VERSION == 1 + assert set(d) == {"version", "load", "plot", "axes", "series", "save"} + assert set(d["axes"]) == {"x", "y", "y2"} + s = parse_settings(d) + assert s.plot.type == "line" and s.plot.font_size == 15 and s.plot.figure.width == 8 and s.plot.figure.height == 6 + assert s.load.has_header is True and s.load.delimiter == "" + assert len(s.series) == 1 and s.series[0].id == "s1" and s.series[0].color == "#FF4B00" + assert s.save.format == "png" and s.save.transparent is False + json.dumps(d) # JSON 互換 + + +def test_default_settings_returns_fresh_objects(): + a = default_settings() + a["series"][0]["color"] = "#000000" + assert default_settings()["series"][0]["color"] == "#FF4B00" + + +def test_missing_keys_are_filled_from_defaults(): + s = parse_settings({"version": 1, "plot": {"title": " T "}, "series": [{"y": "__idx__2"}]}) + assert s.plot.title == "T" # 前後の空白は取り除く + assert s.plot.font_size == 15 and s.plot.legend_location == "best" + assert s.series[0].y == "__idx__2" and s.series[0].line_width == 2 and s.series[0].id == "s1" + assert parse_settings({}).plot.type == "line" + assert parse_settings(None).series[0].x == "" + + +def test_series_without_id_get_unique_ids(): + s = parse_settings({"series": [{}, {}, {"id": "s1"}]}) + assert len({x.id for x in s.series}) == 3 + + +def test_numeric_strings_are_accepted(): + raw = default_settings() + raw["plot"]["fontSize"] = "12.5" + raw["plot"]["skipRows"] = "3" + raw["plot"]["figure"] = {"width": " 7 ", "height": 5} + raw["axes"]["x"]["min"] = "0" + raw["axes"]["x"]["max"] = "10.5" + raw["series"][0]["lineWidth"] = "1.5" + raw["series"][0]["markerSize"] = "4" + s = parse_settings(raw) + assert s.plot.font_size == 12.5 and s.plot.skip_rows == 3 + assert s.plot.figure.width == 7.0 + assert (s.axes.x.min, s.axes.x.max) == (0.0, 10.5) + assert s.series[0].line_width == 1.5 and s.series[0].marker_size == 4.0 + + +def test_empty_numbers_mean_default_or_none(): + raw = default_settings() + raw["plot"]["fontSize"] = None + raw["plot"]["skipRows"] = "" + raw["axes"]["y"]["min"] = "" + raw["series"][0]["lineWidth"] = None + s = parse_settings(raw) + assert s.plot.font_size == 15 and s.plot.skip_rows == 0 and s.axes.y.min is None and s.series[0].line_width == 2 + + +def _expect_error(mutator, *needles): + raw = default_settings() + mutator(raw) + with pytest.raises(UserError) as info: + parse_settings(raw) + msg = info.value.message + assert any("぀" <= ch <= "ヿ" or "一" <= ch <= "鿿" for ch in msg), f"日本語でない: {msg}" + assert msg.isascii() is False + for needle in needles: + assert needle in msg, f"{needle!r} が {msg!r} に含まれない" + return info.value + + +@pytest.mark.parametrize( + "mutator, needles", + [ + (lambda r: r["plot"].__setitem__("fontSize", "abc"), ("フォントサイズ", "数値")), + (lambda r: r["plot"].__setitem__("fontSize", 0), ("フォントサイズ", "0より大きい")), + (lambda r: r["plot"].__setitem__("fontSize", "invalid"), ("フォントサイズ",)), + (lambda r: r["plot"].__setitem__("fontSize", True), ("フォントサイズ",)), + (lambda r: r["plot"].__setitem__("fontSize", float("nan")), ("フォントサイズ",)), + (lambda r: r["plot"]["figure"].__setitem__("width", -1), ("図幅",)), + (lambda r: r["plot"]["figure"].__setitem__("height", "x"), ("図高さ",)), + (lambda r: r["plot"].__setitem__("skipRows", -1), ("除外する先頭行数", "0以上の整数")), + (lambda r: r["plot"].__setitem__("skipRows", "1.5"), ("除外する先頭行数", "整数")), + (lambda r: r["plot"].__setitem__("skipRows", "abc"), ("除外する先頭行数",)), + (lambda r: r["plot"].__setitem__("type", "pie"), ("プロット種別",)), + (lambda r: r["plot"]["legend"].__setitem__("location", "nowhere"), ("凡例位置",)), + (lambda r: r["plot"]["margins"].__setitem__("left", 1.5), ("余白 左(left)", "0〜1")), + (lambda r: r["plot"]["margins"].__setitem__("top", "zz"), ("余白 上(top)",)), + (lambda r: r["plot"]["margins"].update(left=0.9, right=0.1), ("left < right",)), + (lambda r: r["plot"]["margins"].update(bottom=0.9, top=0.1), ("bottom < top",)), + (lambda r: r["axes"]["x"].__setitem__("min", "q"), ("X軸の最小値",)), + (lambda r: r["axes"]["y"].__setitem__("max", "q"), ("Y軸の最大値",)), + (lambda r: r["axes"]["x"].update(min=5, max=1), ("X軸の範囲",)), + (lambda r: r["axes"]["y"].update(scale="log", min=0), ("Y軸", "対数")), + (lambda r: r["axes"]["x"].__setitem__("scale", "sqrt"), ("X軸スケール",)), + (lambda r: r["series"][0].__setitem__("lineWidth", -1), ("系列1の", "「線幅」", "0より大きい")), + (lambda r: r["series"][0].__setitem__("markerSize", -3), ("系列1の", "「点サイズ」", "0以上")), + (lambda r: r["series"][0].__setitem__("markerSize", "x"), ("「点サイズ」",)), + (lambda r: r["series"][0].__setitem__("lineStyle", "wavy"), ("「線種」",)), + (lambda r: r["series"][0].__setitem__("color", "notacolor"), ("「色」",)), + (lambda r: r["series"][0].__setitem__("secondaryAxis", "yes"), ("「第2軸を使用」",)), + (lambda r: r["series"].append({"lineWidth": "bad"}), ("系列2の", "「線幅」")), + (lambda r: r.__setitem__("series", []), ("描画系列",)), + (lambda r: r["save"].__setitem__("format", "gif"), ("保存形式",)), + (lambda r: r["save"].__setitem__("transparent", "yes"), ("背景を透過",)), + (lambda r: r["plot"].__setitem__("title", 5), ("タイトル",)), + ], +) +def test_validation_messages_are_japanese_and_name_the_field(mutator, needles): + _expect_error(mutator, *needles) + + +def test_y2_range_only_checked_when_secondary_axis_used(): + raw = default_settings() + raw["axes"]["y2"].update(min=5, max=1) + parse_settings(raw) # 第2軸を使う系列が無ければ検証しない + raw["series"][0]["secondaryAxis"] = True + with pytest.raises(UserError) as info: + parse_settings(raw) + assert "第2Y軸の範囲" in info.value.message + + +def test_unknown_version_rejected(): + raw = default_settings() + del raw["version"] + parse_settings(raw) # version 欠落は現行版として扱う + for bad in (2, 0, "1", None, True): + raw = default_settings() + raw["version"] = bad + with pytest.raises(UserError) as info: + parse_settings(raw) + assert "バージョン" in info.value.message + with pytest.raises(UserError): + parse_load_settings({"version": 99}) + + +def test_marker_size_auto_and_backstop(): + s = parse_settings(default_settings()).series[0] + assert s.marker_size is None + assert s.effective_marker_size("line") == 0 and s.effective_marker_size("scatter") == 24 + raw = default_settings() + raw["series"][0]["markerSize"] = 0 + zero = parse_settings(raw).series[0] + assert zero.effective_marker_size("line") == 0 + assert zero.effective_marker_size("scatter") == 24 # scatter で 0 以下なら 24 + raw["series"][0]["markerSize"] = 7 + assert parse_settings(raw).series[0].effective_marker_size("scatter") == 7 + + +def test_parse_load_settings_ignores_other_invalid_fields(): + raw = default_settings() + raw["plot"]["fontSize"] = "bad" + raw["load"].update(delimiter=";", hasHeader=False) + load = parse_load_settings(raw) + assert load.delimiter == ";" and load.has_header is False + + +def test_settings_are_frozen(): + s = parse_settings(default_settings()) + with pytest.raises(Exception): + s.plot.title = "x" + with pytest.raises(Exception): + s.series[0].color = "#000000" + + +def test_to_legacy_dict_shape(): + raw = default_settings() + raw["plot"].update(title="T", skipRows=2, xColumn="__idx__1") + raw["plot"]["margins"]["left"] = 0.1 + raw["axes"]["x"].update(label="X", min=0, max=5) + raw["series"][0].update(label="L", secondaryAxis=True, y="__idx__2") + d = to_legacy_dict(parse_settings(raw)) + expected_keys = { + "skip_rows", "plot_type", "title", "x_label", "y_label", "y2_label", "x_scale", "y_scale", "y2_scale", + "x_min", "x_max", "y_min", "y_max", "y2_min", "y2_max", "show_major_grid", "show_minor_grid", + "legend_location", "font_size", "fig_width", "fig_height", "subplot_margins", "subplot_left", + "subplot_right", "subplot_bottom", "subplot_top", "x_column_request", "series_settings", "series_settings_error", + } + assert set(d) == expected_keys + assert d["skip_rows"] == 2 and d["plot_type"] == "line" and d["title"] == "T" and d["x_label"] == "X" + assert d["x_min"] == 0 and d["x_max"] == 5 and d["subplot_left"] == 0.1 + assert d["subplot_margins"] == {"left": 0.1, "right": None, "bottom": None, "top": None} + assert d["x_column_request"] == "__idx__1" and d["font_size"] == 15 and d["fig_width"] == 8 + assert d["series_settings"] == [ + { + "y_request": "__idx__2", "x_request": "", "color": "#FF4B00", "line_width": 2.0, "line_style": "solid", + "marker_size": 0.0, "legend_name": "L", "use_secondary_axis": True, + } + ] + json.dumps(d) + + +def test_parse_does_not_mutate_input(): + raw = default_settings() + before = copy.deepcopy(raw) + parse_settings(raw) + assert raw == before From 3891a4ce900ddcd9ebc59a37b5a3eed658fcf365 Mon Sep 17 00:00:00 2001 From: mioupa Date: Wed, 7 Oct 2026 18:34:34 +0900 Subject: [PATCH 14/37] Add E2E test that changes drawing settings and checks each re-render Co-Authored-By: Claude Opus 5.5 --- tests/e2e/test_settings_flow.py | 89 +++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 tests/e2e/test_settings_flow.py diff --git a/tests/e2e/test_settings_flow.py b/tests/e2e/test_settings_flow.py new file mode 100644 index 0000000..661fcdf --- /dev/null +++ b/tests/e2e/test_settings_flow.py @@ -0,0 +1,89 @@ +"""描画設定を順に変えて、そのたびにエラーなく再描画されること(設定オブジェクト → Python 経路の確認)。""" +import pytest + +from helpers import ( + load_fixture, + plot_src, + status_kind, + status_text, + wait_app_ready, + wait_plot_changed, + wait_settled, +) + +pytestmark = pytest.mark.e2e + + +def _changed_ok(page, previous): + new = wait_plot_changed(page, previous) + wait_settled(page) + assert status_kind(page) == "ok", status_text(page) + assert status_text(page).startswith("描画に成功しました") + return new + + +def test_changing_settings_rerenders_without_error(page, app_url, fixtures_dir, console_log): + page.goto(app_url) + wait_app_ready(page) + load_fixture(page, fixtures_dir / "utf8.csv") + src = _changed_ok(page, "") + assert "1系列" in status_text(page) + + steps = [] + + # プロット種別: scatter + page.select_option("#plotType", "scatter") + steps.append("scatter") + src = _changed_ok(page, src) + assert page.eval_on_selector("#series-s1-marker-size", "e => e.value") == "24" # 点サイズは自動値を表示する + + # 系列の色 + page.click("#series-s1-color-trigger") + page.click(".series-color-chip[data-color='#005AFF']") + steps.append("color") + src = _changed_ok(page, src) + assert page.eval_on_selector("#seriesList .series-color-value", "e => e.textContent") == "#005AFF" + + # 系列追加(既定色は未使用のパレット色)+ 第2軸 + page.click("#addSeriesBtn") + assert page.eval_on_selector_all("#seriesList .series-item", "els => els.length") == 2 + assert page.eval_on_selector_all("#seriesList .series-color-value", "els => els.map(e => e.textContent)")[1] == "#FF4B00" + src = _changed_ok(page, src) + assert "2系列" in status_text(page) + assert not page.is_visible("#y2LabelGroup") + page.check("#seriesList .series-item:nth-child(2) .series-use-y2") + steps.append("secondary axis") + src = _changed_ok(page, src) + assert page.is_visible("#y2LabelGroup") and page.is_visible("#y2AxisSettingsGroup") + page.fill("#y2Label", "第2軸") + src = _changed_ok(page, src) + + # 軸の範囲 + page.fill("#xMin", "1") + page.fill("#xMax", "2") + steps.append("axis range") + src = _changed_ok(page, src) + + # 凡例位置 + page.select_option("#legendLocation", "upper left") + steps.append("legend") + src = _changed_ok(page, src) + page.select_option("#legendLocation", "none") + src = _changed_ok(page, src) + + # bar に切替(X列は共通) + page.select_option("#plotType", "bar") + assert page.is_visible("#globalXGroup") + src = _changed_ok(page, src) + + # 不正な入力は日本語で項目名つきのエラーになり、直すと復帰する + page.fill("#skipRows", "1.5") + page.wait_for_function("document.getElementById('status').dataset.kind === 'error'") + wait_settled(page) + assert "「除外する先頭行数」" in status_text(page) + assert plot_src(page) == src # 前の画像は残る + page.fill("#skipRows", "2") + src = _changed_ok(page, src) + assert "スキップ2行" in status_text(page) + + assert [e for e in console_log.events if e[2] == "pageerror"] == [] From e6de3e6ff4dbd49a632f338110a94645d79c9ff6 Mon Sep 17 00:00:00 2001 From: mioupa Date: Wed, 7 Oct 2026 18:39:36 +0900 Subject: [PATCH 15/37] Pin UI web font to fontsource 5.3.0, pin Noto CJK URL, add CDN pin scan test Co-Authored-By: Claude Opus 5.5 --- index.html | 7 +-- js/bridge.js | 2 +- tests/unit/test_cdn_pinning.py | 80 ++++++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 4 deletions(-) create mode 100644 tests/unit/test_cdn_pinning.py diff --git a/index.html b/index.html index 176ce2a..89cbc66 100644 --- a/index.html +++ b/index.html @@ -5,9 +5,10 @@ Matplotlib GUI - - - + + + + diff --git a/js/bridge.js b/js/bridge.js index 4b07121..e2ce4e5 100644 --- a/js/bridge.js +++ b/js/bridge.js @@ -9,7 +9,7 @@ import { setColumns } from "./ui/series.js"; const RENDER_DELAY_MS = 250; const LOAD_DELAY_MS = { file: 0, header: 0, delimiter: 450 }; -const JP_FONT_URL = "https://cdn.jsdelivr.net/gh/googlefonts/noto-cjk@main/Sans/OTF/Japanese/NotoSansCJKjp-Regular.otf"; +const JP_FONT_URL = "https://cdn.jsdelivr.net/gh/googlefonts/noto-cjk@Sans2.004/Sans/OTF/Japanese/NotoSansCJKjp-Regular.otf"; const root = document.documentElement; let api = null; // window.mplgui(Python 側が登録) diff --git a/tests/unit/test_cdn_pinning.py b/tests/unit/test_cdn_pinning.py new file mode 100644 index 0000000..41ecf72 --- /dev/null +++ b/tests/unit/test_cdn_pinning.py @@ -0,0 +1,80 @@ +"""外部 CDN の URL がすべてバージョン固定であること(@main / @latest / バージョン無し / Google Fonts css2 を禁止)。""" +import re +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.unit + +REPO = Path(__file__).resolve().parents[2] +URL_RE = re.compile(r"https?://[^\s\"'<>)`]+") + +# (ホスト, 固定済みとみなすパスのパターン) +PINNED = [ + ("pyscript.net", re.compile(r"^/releases/\d{4}\.\d+\.\d+/")), + ("cdn.jsdelivr.net", re.compile(r"^/npm/(@[\w.-]+/)?[\w.-]+@\d+\.\d+\.\d+(?:[-+.\w]*)?/")), + ("cdn.jsdelivr.net", re.compile(r"^/gh/[\w.-]+/[\w.-]+@(?!main\b|master\b|latest\b)[\w.-]*\d[\w.-]*/")), +] +NAMESPACE_HOSTS = {"www.w3.org"} # SVG などの名前空間 URI(取得されない) + + +def scan_files(): + files = [REPO / "index.html"] + for sub, pattern in (("js", "*.js"), ("css", "*.css"), ("py", "*.py")): + files += sorted((REPO / sub).rglob(pattern)) + return files + + +def check_url(url: str) -> str | None: + """問題があれば理由を返す。""" + m = re.match(r"https?://([^/]+)(/.*)?$", url) + host, path = m.group(1), m.group(2) or "/" + if host in NAMESPACE_HOSTS: + return None + if "fonts.googleapis.com" in host or "fonts.gstatic.com" in host: + return "Google Fonts は固定できない" + for pinned_host, pat in PINNED: + if host == pinned_host and pat.match(path): + return None + return "バージョン固定されていない(または未許可のホスト)" + + +def test_all_urls_are_pinned(): + problems = [] + seen = 0 + for f in scan_files(): + for url in URL_RE.findall(f.read_text(encoding="utf-8")): + seen += 1 + reason = check_url(url.rstrip(".,;")) + if reason: + problems.append(f"{f.relative_to(REPO)}: {url} ({reason})") + assert seen > 0 + assert not problems, "\n".join(problems) + + +@pytest.mark.parametrize( + "url", + [ + "https://cdn.jsdelivr.net/gh/googlefonts/noto-cjk@main/Sans/x.otf", + "https://cdn.jsdelivr.net/npm/@fontsource/noto-sans-jp@latest/400.css", + "https://cdn.jsdelivr.net/npm/@fontsource/noto-sans-jp/400.css", + "https://cdn.jsdelivr.net/gh/googlefonts/noto-cjk/Sans/x.otf", + "https://fonts.googleapis.com/css2?family=Noto+Sans+JP", + "https://pyscript.net/latest/core.js", + "https://example.com/x.js", + ], +) +def test_checker_rejects_unpinned(url): + assert check_url(url) + + +@pytest.mark.parametrize( + "url", + [ + "https://cdn.jsdelivr.net/gh/googlefonts/noto-cjk@Sans2.004/Sans/OTF/Japanese/NotoSansCJKjp-Regular.otf", + "https://cdn.jsdelivr.net/npm/@fontsource/noto-sans-jp@5.3.0/400.css", + "https://pyscript.net/releases/2026.7.3/core.js", + ], +) +def test_checker_accepts_pinned(url): + assert check_url(url) is None From 19fda50d6477d5c4aa70a219c5c611f93f99b897 Mon Sep 17 00:00:00 2001 From: mioupa Date: Wed, 7 Oct 2026 18:45:56 +0900 Subject: [PATCH 16/37] A1/A2: row numbers and skipped rows in data preview; reset file input, show file name and encoding Co-Authored-By: Claude Opus 5.5 --- js/ui/dataPreview.js | 23 ++++++++-- js/ui/fileInfo.js | 29 +++++++++++++ tests/e2e/test_data_and_file.py | 76 +++++++++++++++++++++++++++++++++ tests/unit/test_js_modules.py | 56 ++++++++++++++++++++++++ 4 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 js/ui/fileInfo.js create mode 100644 tests/e2e/test_data_and_file.py create mode 100644 tests/unit/test_js_modules.py diff --git a/js/ui/dataPreview.js b/js/ui/dataPreview.js index 3a04e54..b401031 100644 --- a/js/ui/dataPreview.js +++ b/js/ui/dataPreview.js @@ -5,7 +5,17 @@ const el = (tag, text) => { return node; }; -export const showPreview = (preview) => { +// skipRows として有効な値(正の整数)だけを行数にする。不正値・空は 0 とみなす +const effectiveSkipRows = (value) => (Number.isInteger(value) && value > 0 ? value : 0); + +// 描画から除外される先頭 skipRows 行(データ行。ヘッダは数えない)に .skipped-row を付ける。Python は呼ばない。 +export const applySkipRows = (skipRows) => { + const n = effectiveSkipRows(skipRows); + const rows = document.querySelectorAll("#dataArea tbody tr"); + rows.forEach((tr, idx) => tr.classList.toggle("skipped-row", idx < n)); +}; + +export const showPreview = (preview, skipRows = 0) => { const dataArea = document.getElementById("dataArea"); if (!dataArea) return; const truncated = preview.truncated ? `(先頭${preview.limit}行のみ表示)` : ""; @@ -16,18 +26,25 @@ export const showPreview = (preview) => { table.border = "0"; table.className = "dataframe data-table"; const headRow = el("tr"); + const numHead = el("th", "#"); + numHead.className = "row-number"; + headRow.appendChild(numHead); for (const name of preview.columns) headRow.appendChild(el("th", name)); table.appendChild(el("thead")).appendChild(headRow); const body = el("tbody"); - for (const row of preview.rows) { + preview.rows.forEach((row, idx) => { const tr = el("tr"); + const num = el("td", String(idx + 1)); // 1 始まりのデータ行番号 + num.className = "row-number"; + tr.appendChild(num); for (const cell of row) tr.appendChild(el("td", cell)); body.appendChild(tr); - } + }); table.appendChild(body); const wrap = el("div"); wrap.className = "table-wrap"; wrap.appendChild(table); dataArea.replaceChildren(meta, wrap); + applySkipRows(skipRows); }; diff --git a/js/ui/fileInfo.js b/js/ui/fileInfo.js new file mode 100644 index 0000000..ef8829d --- /dev/null +++ b/js/ui/fileInfo.js @@ -0,0 +1,29 @@ +// 選択中のファイル名と検出した文字コードの表示(#fileNameLabel / #encodingLabel)。 +const ENCODING_NAMES = { + "utf-8": "UTF-8", + "utf-8-sig": "UTF-8(BOM付き)", + cp932: "Shift_JIS(CP932)", + "euc-jp": "EUC-JP", +}; + +export const encodingDisplayName = (encoding) => (encoding === null || encoding === undefined ? "Excel" : ENCODING_NAMES[encoding] || String(encoding)); + +export const showFileName = (name) => { + const label = document.getElementById("fileNameLabel"); + if (label) label.textContent = name ? `選択中: ${name}` : ""; + showEncoding(undefined); +}; + +// encoding: undefined = 非表示(未読込)、null = xlsx +export const showEncoding = (encoding) => { + const label = document.getElementById("encodingLabel"); + if (!label) return; + if (encoding === undefined) { + label.textContent = ""; + label.dataset.encoding = ""; + return; + } + const name = encodingDisplayName(encoding); + label.textContent = encoding === null ? `形式: ${name}` : `文字コード: ${name}`; + label.dataset.encoding = encoding === null ? "excel" : String(encoding); +}; diff --git a/tests/e2e/test_data_and_file.py b/tests/e2e/test_data_and_file.py new file mode 100644 index 0000000..80e639c --- /dev/null +++ b/tests/e2e/test_data_and_file.py @@ -0,0 +1,76 @@ +"""A1(データ確認の行番号と除外行)と A2(ファイル入力のリセット・ファイル名/文字コード表示)。""" +import pytest + +from helpers import load_count, load_fixture, wait_app_ready, wait_load_count, wait_settled + +pytestmark = pytest.mark.e2e + + +def _make_csv(path, rows=150): + path.write_text("a,b\n" + "".join(f"{i},{i * 2}\n" for i in range(rows)), encoding="utf-8") + return path + + +def _skipped_flags(page): + return page.eval_on_selector_all("#dataArea tbody tr", "rows => rows.map(r => r.classList.contains('skipped-row'))") + + +def test_data_preview_row_numbers_and_skipped_rows(page, app_url, tmp_path, console_log): + page.goto(app_url) + wait_app_ready(page) + load_fixture(page, _make_csv(tmp_path / "many.csv")) + wait_settled(page) + page.click("#tabDataBtn") + + assert page.eval_on_selector("#dataArea thead th:first-child", "e => e.textContent") == "#" + nums = page.eval_on_selector_all("#dataArea tbody tr td:first-child", "els => els.map(e => e.textContent)") + assert nums == [str(i) for i in range(1, 101)] # 先頭100行、ヘッダは数えない + assert not any(_skipped_flags(page)) + + loads = load_count(page) + page.fill("#skipRows", "3") + page.wait_for_function("document.querySelectorAll('#dataArea tbody tr.skipped-row').length === 3") + flags = _skipped_flags(page) + assert flags[:3] == [True] * 3 and not any(flags[3:]) + # 灰色で表示される(通常行と背景色が違う) + bg = page.eval_on_selector_all("#dataArea tbody tr td:nth-child(2)", "els => [els[0], els[5]].map(e => getComputedStyle(e).backgroundColor)") + assert bg[0] != bg[1] + + page.fill("#skipRows", "1") + page.wait_for_function("document.querySelectorAll('#dataArea tbody tr.skipped-row').length === 1") + page.fill("#skipRows", "") + page.wait_for_function("document.querySelectorAll('#dataArea tbody tr.skipped-row').length === 0") + assert load_count(page) == loads # ファイルは再読込していない + # 再読込(ヘッダ設定変更)後も skipRows が反映される + page.fill("#skipRows", "2") + wait_settled(page) + page.uncheck("#hasHeader") + wait_load_count(page, loads + 1) + page.wait_for_function("document.querySelectorAll('#dataArea tbody tr.skipped-row').length === 2") + + +def test_file_input_reset_and_labels(page, app_url, fixtures_dir, console_log): + page.goto(app_url) + wait_app_ready(page) + src = fixtures_dir / "utf8.csv" + load_fixture(page, src) + wait_settled(page) + assert page.eval_on_selector("#fileInput", "e => e.value") == "" + assert "utf8.csv" in page.inner_text("#fileNameLabel") + assert "UTF-8" in page.inner_text("#encodingLabel") + assert load_count(page) == 1 + + # 同じファイルをもう一度選ぶと再読込される + page.set_input_files("#fileInput", str(src)) + wait_load_count(page, 2) + wait_settled(page) + assert page.eval_on_selector("#fileInput", "e => e.value") == "" + + # 区切り文字・ヘッダの変更でも、保持している File で再読込できる + page.uncheck("#hasHeader") + wait_load_count(page, 3) + wait_settled(page) + + page.set_input_files("#fileInput", str(fixtures_dir / "multi_sheet.xlsx")) + wait_load_count(page, 4) + assert "Excel" in page.inner_text("#encodingLabel") diff --git a/tests/unit/test_js_modules.py b/tests/unit/test_js_modules.py new file mode 100644 index 0000000..45dfc9c --- /dev/null +++ b/tests/unit/test_js_modules.py @@ -0,0 +1,56 @@ +"""純粋な JS モジュールを node で検証する(node が無ければ skip)。""" +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.unit +REPO = Path(__file__).resolve().parents[2] +needs_node = pytest.mark.skipif(shutil.which("node") is None, reason="node が無い") + + +def run_node(tmp_path, files: dict[str, str], script: str): + """files: {コピー先ファイル名: リポジトリ内の相対パス}。ES module として実行して JSON を返す。""" + (tmp_path / "package.json").write_text('{"type": "module"}', encoding="utf-8") + for name, rel in files.items(): + (tmp_path / name).write_text((REPO / rel).read_text(encoding="utf-8"), encoding="utf-8") + (tmp_path / "run.mjs").write_text(script, encoding="utf-8") + out = subprocess.run(["node", "run.mjs"], cwd=tmp_path, capture_output=True, text=True, check=True, timeout=30) + return json.loads(out.stdout) + + +@needs_node +def test_encoding_display_names(tmp_path): + script = """ + globalThis.document = { getElementById: () => null }; + import { encodingDisplayName } from "./fileInfo.mjs"; + console.log(JSON.stringify(["utf-8", "utf-8-sig", "cp932", "euc-jp", null].map(encodingDisplayName))); + """ + # import は先頭で巻き上げられるため、document の定義は不要(関数内でのみ使う) + res = run_node(tmp_path, {"fileInfo.mjs": "js/ui/fileInfo.js"}, script) + assert res == ["UTF-8", "UTF-8(BOM付き)", "Shift_JIS(CP932)", "EUC-JP", "Excel"] + + +@needs_node +def test_new_series_color_is_first_unused_palette_color(tmp_path): + script = """ + import { addSeries, getSettings, updateSeries, nextSeriesColor } from "./state.mjs"; + import { PALETTE } from "./defaults.js"; + const out = {}; + updateSeries("s1", { color: "#005AFF" }); // 先頭色が空く + out.first = nextSeriesColor(); // #FF4B00 + addSeries(); + out.second = nextSeriesColor(); // #03AF7A(#FF4B00 #005AFF は使用済み) + while (getSettings().series.length < PALETTE.length) addSeries(); + out.allUsed = new Set(getSettings().series.map((s) => s.color)).size; + out.cycled = nextSeriesColor(); // すべて使用済み → 系列数で循環 + out.cycledExpected = PALETTE[getSettings().series.length % PALETTE.length]; + console.log(JSON.stringify(out)); + """ + res = run_node(tmp_path, {"state.mjs": "js/state.js", "defaults.js": "js/defaults.js"}, script) + assert res["first"] == "#FF4B00" + assert res["second"] == "#03AF7A" + assert res["allUsed"] == 11 + assert res["cycled"] == res["cycledExpected"] From 0e83eb31e206bba43f1caaa48d01d5fc03287b0c Mon Sep 17 00:00:00 2001 From: mioupa Date: Wed, 7 Oct 2026 18:45:56 +0900 Subject: [PATCH 17/37] A6/B5: Cache Storage font loader with progress, single warning on failure Co-Authored-By: Claude Opus 5.5 --- js/font-cache.js | 96 ++++++++++++++++++++++++++++++++++++++++++ js/ui/progress.js | 19 +++++++++ tests/e2e/test_font.py | 58 +++++++++++++++++++++++++ 3 files changed, 173 insertions(+) create mode 100644 js/font-cache.js create mode 100644 js/ui/progress.js create mode 100644 tests/e2e/test_font.py diff --git a/js/font-cache.js b/js/font-cache.js new file mode 100644 index 0000000..f68c1d6 --- /dev/null +++ b/js/font-cache.js @@ -0,0 +1,96 @@ +// 日本語フォント(Noto Sans CJK JP)の取得。Cache Storage に保存し、再訪問ではダウンロードしない。 +// - セッション中の取得は最大1回(失敗しても再試行しない)。 +// - Cache Storage が使えない環境では通常の fetch にフォールバックする。 +export const FONT_URL = "https://cdn.jsdelivr.net/gh/googlefonts/noto-cjk@Sans2.004/Sans/OTF/Japanese/NotoSansCJKjp-Regular.otf"; +export const FONT_CACHE_NAME = "mplgui-fonts-v1"; + +let fontPromise = null; // 取得結果(Uint8Array | null)。一度作ったら使い回す + +const openCache = async () => { + try { + if (typeof caches === "undefined") return null; + return await caches.open(FONT_CACHE_NAME); + } catch (_) { + return null; + } +}; + +const readCached = async (cache) => { + if (!cache) return null; + try { + const hit = await cache.match(FONT_URL); + return hit ? new Uint8Array(await hit.arrayBuffer()) : null; + } catch (_) { + return null; + } +}; + +// Cache Storage に入っているか(ダウンロードを伴わない確認) +export const isFontCached = async () => { + const cache = await openCache(); + if (!cache) return false; + try { + return !!(await cache.match(FONT_URL)); + } catch (_) { + return false; + } +}; + +// ストリームで読み、進捗(0-100)を onProgress に通知する。content-length が圧縮後サイズのこともあるので 99 で頭打ちにする。 +const download = async (onProgress) => { + const response = await fetch(FONT_URL); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const total = Number(response.headers.get("content-length")) || 0; + if (!response.body || !response.body.getReader) { + const bytes = new Uint8Array(await response.arrayBuffer()); + onProgress(100); + return bytes; + } + const reader = response.body.getReader(); + const chunks = []; + let received = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + received += value.length; + if (total > 0) onProgress(Math.min(99, Math.floor((received / total) * 100))); + } + const bytes = new Uint8Array(received); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.length; + } + onProgress(100); + return bytes; +}; + +// 成功: Uint8Array、失敗: null(onProgress(percent) は 0〜100)。2回目以降は同じ結果を返す。 +export const loadFont = (onProgress = () => {}) => { + if (!fontPromise) { + fontPromise = (async () => { + const cache = await openCache(); + const cached = await readCached(cache); + if (cached) { + onProgress(100); + return cached; + } + try { + onProgress(0); + const bytes = await download(onProgress); + if (cache) { + try { + await cache.put(FONT_URL, new Response(bytes, { headers: { "content-type": "font/otf" } })); + } catch (_) { + // 保存に失敗しても今回のセッションでは使える + } + } + return bytes; + } catch (_) { + return null; + } + })(); + } + return fontPromise; +}; diff --git a/js/ui/progress.js b/js/ui/progress.js new file mode 100644 index 0000000..bbb098a --- /dev/null +++ b/js/ui/progress.js @@ -0,0 +1,19 @@ +// 起動・フォント取得の進捗バナー(#progress)と、描画中の表示(#renderBusy, aria-busy)。 +export const showProgress = (text) => { + const el = document.getElementById("progress"); + if (!el) return; + el.textContent = text || ""; + el.hidden = !text; +}; + +export const setBusy = (busy, text = "描画中…") => { + const overlay = document.getElementById("renderBusy"); + if (overlay) { + overlay.hidden = !busy; + overlay.textContent = text; + } + for (const id of ["plotPanel", "plotArea"]) { + const el = document.getElementById(id); + if (el) el.setAttribute("aria-busy", busy ? "true" : "false"); + } +}; diff --git a/tests/e2e/test_font.py b/tests/e2e/test_font.py new file mode 100644 index 0000000..6212eea --- /dev/null +++ b/tests/e2e/test_font.py @@ -0,0 +1,58 @@ +"""A6: 日本語フォントの取得(失敗時の警告は1回・再試行なし、Cache Storage からの再利用)。""" +import pytest + +from helpers import ( + load_fixture, + plot_src, + set_title, + status_kind, + status_warnings, + wait_app_ready, + wait_font_done, + wait_plot_changed, + wait_settled, +) + +pytestmark = pytest.mark.e2e + +FONT_GLOB = "**/NotoSansCJKjp-Regular.otf" + + +def test_font_failure_warns_once_and_plot_still_renders(page, app_url, fixtures_dir, console_log): + requests = [] + page.route(FONT_GLOB, lambda route: (requests.append(route.request.url), route.abort())) + page.goto(app_url) + wait_app_ready(page) + wait_font_done(page) + assert page.evaluate("document.documentElement.dataset.fontState") == "failed" + + load_fixture(page, fixtures_dir / "utf8.csv") + wait_settled(page) + assert plot_src(page).startswith("data:image/png") + warnings = status_warnings(page) + assert len(warnings) == 1 and "日本語フォント" in warnings[0] + assert status_kind(page) == "warning" + + src = plot_src(page) + set_title(page, "再描画") + wait_plot_changed(page, src) + wait_settled(page) + assert len(status_warnings(page)) == 1 # 増えない + assert len(requests) == 1 # 再試行しない + + +def test_font_is_served_from_cache_storage_on_reload(page, app_url, console_log): + requests = [] + page.on("request", lambda r: requests.append(r.url) if r.url.endswith("NotoSansCJKjp-Regular.otf") else None) + page.goto(app_url) + wait_app_ready(page) + wait_font_done(page) + assert page.evaluate("document.documentElement.dataset.fontState") == "ready" + assert len(requests) == 1 + assert page.evaluate("caches.open('mplgui-fonts-v1').then(c => c.keys()).then(k => k.length)") == 1 + + page.reload() + wait_app_ready(page) + wait_font_done(page) + assert page.evaluate("document.documentElement.dataset.fontState") == "ready" + assert len(requests) == 1 # 2回目はネットワークに出ない From b28939e310ad8615013cda7b7dd65ad2f15e3372 Mon Sep 17 00:00:00 2001 From: mioupa Date: Wed, 7 Oct 2026 18:45:56 +0900 Subject: [PATCH 18/37] A3/A4: coalesced render pipeline with generations; keep file and settings chosen during startup Co-Authored-By: Claude Opus 5.5 --- js/bridge.js | 234 ++++++++++++++++++++++-------- tests/e2e/test_render_pipeline.py | 116 +++++++++++++++ 2 files changed, 289 insertions(+), 61 deletions(-) create mode 100644 tests/e2e/test_render_pipeline.py diff --git a/js/bridge.js b/js/bridge.js index e2ce4e5..8787c98 100644 --- a/js/bridge.js +++ b/js/bridge.js @@ -1,15 +1,22 @@ // Python(window.mplgui)との唯一の窓口。設定は JSON 文字列で渡し、結果の JSON を UI へ反映する。 // - 再描画は設定変更の 250ms 後(デバウンス)、読込はファイル/ヘッダ 0ms・区切り文字 450ms。保存設定の変更では再描画しない。 -// - 描画の要求はすべてここを通る(世代番号・合体・起動待ちキューは Step 5 でここに足す)。 +// - 描画要求には世代番号を振る。同時に走る Python 描画は最大1つで、実行中の要求は「やり直し」の印だけ付け、 +// 終わったあと最新の設定で1回だけ描画する。古い世代の結果は画像・ステータスに反映しない。 +// - Python の起動前に選んだファイル・変えた設定は保持され、起動後に最新の内容で1回読込・描画する。 import { subscribe, toJson, getSettings } from "./state.js"; -import { setStatus } from "./ui/notify.js"; +import { addStickyWarning, setStatus, warningTexts } from "./ui/notify.js"; import { showPlot } from "./ui/plotView.js"; -import { showPreview } from "./ui/dataPreview.js"; +import { applySkipRows, showPreview } from "./ui/dataPreview.js"; import { setColumns } from "./ui/series.js"; +import { showEncoding, showFileName } from "./ui/fileInfo.js"; +import { setBusy, showProgress } from "./ui/progress.js"; +import { isFontCached, loadFont } from "./font-cache.js"; const RENDER_DELAY_MS = 250; const LOAD_DELAY_MS = { file: 0, header: 0, delimiter: 450 }; -const JP_FONT_URL = "https://cdn.jsdelivr.net/gh/googlefonts/noto-cjk@Sans2.004/Sans/OTF/Japanese/NotoSansCJKjp-Regular.otf"; +const FONT_FAILED_MESSAGE = "日本語フォントを取得できませんでした。日本語が正しく表示されない場合があります。"; +const PYTHON_LOADING_MESSAGE = "Python 実行環境を読み込み中…(初回は時間がかかります)"; +const FONT_LOADING_MESSAGE = "日本語フォントを取得中…"; const root = document.documentElement; let api = null; // window.mplgui(Python 側が登録) @@ -17,11 +24,17 @@ let selectedFile = null; let dataReady = false; let renderTimer = null; let loadTimer = null; -let renderGeneration = 0; -let loadGeneration = 0; +let latestGeneration = 0; // 最後に要求された描画の世代番号 +let renderRunning = false; +let renderDirty = false; +let loadSeq = 0; +let loadCount = 0; +let loadWarnings = []; let customCodeProvider = () => null; // 有効なカスタムコードの文字列、無効なら null let readyCallbacks = []; -let fontPromise = null; +let fontState = "idle"; // idle | loading | ready | failed +let fontPercent = null; +let fontTask = null; const setDataState = (state) => { dataReady = state === "ready"; @@ -29,6 +42,12 @@ const setDataState = (state) => { }; const setRenderState = (state) => { root.dataset.renderState = state; // idle | pending | rendering + const busy = state === "rendering"; + setBusy(busy, fontState === "loading" ? FONT_LOADING_MESSAGE : "描画中…"); +}; +const setFontState = (state) => { + fontState = state; + root.dataset.fontState = state; }; export const isPythonReady = () => api !== null; @@ -40,110 +59,193 @@ export const setCustomCodeProvider = (fn) => { const callPython = (name, ...args) => JSON.parse(api[name](...args)); const reportError = (error) => { - setStatus(error.message, true, error.detail || ""); + setStatus(error.message, "error", error.detail || ""); }; const reportException = (err, context) => { const detail = `context: ${context}\n${err && err.stack ? err.stack : String(err)}`; - setStatus("内部エラーが発生しました。もう一度操作してください。", true, detail); + setStatus("内部エラーが発生しました。もう一度操作してください。", "error", detail); +}; + +// 進捗バナー: Python 起動中はその旨、起動後はフォント取得の進捗、どちらも無ければ非表示 +const refreshProgress = () => { + if (!api) showProgress(PYTHON_LOADING_MESSAGE); + else if (fontState === "loading") showProgress(fontPercent === null ? FONT_LOADING_MESSAGE : `${FONT_LOADING_MESSAGE} ${fontPercent}%`); + else showProgress(""); }; -// 日本語フォントは初回の描画前に1回だけ取得する。失敗しても描画は継続する(A6 で font-cache.js に置き換える)。 -const ensureFont = () => { - if (!fontPromise) { - fontPromise = (async () => { +const nextPaint = () => + new Promise((resolve) => { + const timer = window.setTimeout(resolve, 60); // 非表示タブでは rAF が来ないため上限を設ける + window.requestAnimationFrame(() => { + window.clearTimeout(timer); + window.setTimeout(resolve, 0); + }); + }); + +// ---- 日本語フォント(A6)。取得はセッション中1回。失敗は警告を1度だけ出し、フォールバックフォントで続行する ---- +const startFont = () => { + if (fontTask) return fontTask; + setFontState("loading"); + fontPercent = null; + refreshProgress(); + fontTask = (async () => { + const bytes = await loadFont((p) => { + fontPercent = p; + refreshProgress(); + }); + if (!bytes) { + setFontState("failed"); + addStickyWarning(FONT_FAILED_MESSAGE); + } else { + await new Promise((resolve) => onPythonReady(resolve)); try { - const response = await fetch(JP_FONT_URL); - if (!response.ok) return; - const bytes = new Uint8Array(await response.arrayBuffer()); api.registerFont(bytes); + setFontState("ready"); } catch (_) { - // フォールバックフォントで継続する + setFontState("failed"); + addStickyWarning(FONT_FAILED_MESSAGE); } - })(); - } - return fontPromise; + } + fontPercent = null; + refreshProgress(); + setBusy(root.dataset.renderState === "rendering"); + })(); + return fontTask; }; -const doRender = async () => { - if (!api) return; - const generation = ++renderGeneration; - root.dataset.renderGeneration = String(generation); - setRenderState("rendering"); +const waitForFont = async () => { + if (fontState === "loading" && fontTask) await fontTask; +}; + +// ---- 描画 ---- +const executeRender = async (generation) => { + const stale = () => generation !== latestGeneration || !dataReady; try { - await ensureFont(); - if (generation !== renderGeneration) return; // 新しい要求が出ている + await waitForFont(); // 最初の描画だけがここで待つ(取得済みなら即通過) + if (stale()) return; + await nextPaint(); // 「描画中…」を先に描かせる(Python の実行は同期でメインスレッドを塞ぐ) + if (stale()) return; const result = callPython("render", toJson(), customCodeProvider()); - if (generation !== renderGeneration) return; + if (stale()) return; if (result.ok) { showPlot(result.image, generation); - setStatus(`描画に成功しました(${result.seriesCount}系列、スキップ${result.skipRows}行)。`); + const skipped = Number.isInteger(result.skipRows) ? result.skipRows : 0; + setStatus(`描画に成功しました(${result.seriesCount}系列、スキップ${skipped}行)。`, "ok", "", [ + ...loadWarnings, + ...warningTexts(result.warnings), + ]); } else { reportError(result.error); } } catch (err) { - if (generation === renderGeneration) reportException(err, "render"); + if (generation === latestGeneration) reportException(err, "render"); + } +}; + +const runRenderLoop = async () => { + renderRunning = true; + setRenderState("rendering"); + try { + do { + renderDirty = false; + await executeRender(latestGeneration); + } while (renderDirty); } finally { - if (generation === renderGeneration) setRenderState("idle"); + renderRunning = false; + setRenderState(renderTimer ? "pending" : "idle"); + } +}; + +// 描画を要求する。実行中なら「やり直し」の印だけ付け、最新の設定で終了後に1回だけ走る。 +const requestRender = () => { + if (!api || !dataReady) return; + latestGeneration += 1; + root.dataset.renderGeneration = String(latestGeneration); + if (renderRunning) { + renderDirty = true; + return; + } + runRenderLoop(); +}; + +export const scheduleRender = (delay = RENDER_DELAY_MS) => { + if (renderTimer) window.clearTimeout(renderTimer); + if (!renderRunning) setRenderState("pending"); + renderTimer = window.setTimeout(() => { + renderTimer = null; + if (api && dataReady) requestRender(); + else if (!renderRunning) setRenderState("idle"); + }, delay); +}; + +export const renderNow = () => { + if (api && dataReady) { + if (renderTimer) { + window.clearTimeout(renderTimer); + renderTimer = null; + } + requestRender(); } }; +// ---- 読込 ---- const doLoad = async () => { if (!api || !selectedFile) return; - const generation = ++loadGeneration; + const seq = ++loadSeq; + const file = selectedFile; setDataState("loading"); + if (renderRunning) { + // 実行中の描画は古いデータのものなので結果を捨てる + latestGeneration += 1; + root.dataset.renderGeneration = String(latestGeneration); + } try { - const bytes = new Uint8Array(await selectedFile.arrayBuffer()); - if (generation !== loadGeneration) return; + const bytes = new Uint8Array(await file.arrayBuffer()); + if (seq !== loadSeq) return; const loadJson = JSON.stringify({ version: getSettings().version, load: getSettings().load }); - const result = callPython("loadFile", selectedFile.name, bytes, loadJson); - if (generation !== loadGeneration) return; + const result = callPython("loadFile", file.name, bytes, loadJson); + if (seq !== loadSeq) return; if (!result.ok) { setDataState("error"); + loadWarnings = []; + showEncoding(undefined); reportError(result.error); return; } + loadWarnings = warningTexts(result.warnings); + showEncoding(result.encoding === undefined ? null : result.encoding); setColumns(result.columns); - showPreview(result.preview); + showPreview(result.preview, getSettings().plot.skipRows); setDataState("ready"); + loadCount += 1; + root.dataset.loadCount = String(loadCount); setStatus(""); - await doRender(); + renderNow(); } catch (err) { - if (generation === loadGeneration) { + if (seq === loadSeq) { setDataState("error"); reportException(err, "loadFile"); } } }; -export const scheduleRender = (delay = RENDER_DELAY_MS) => { - if (renderTimer) window.clearTimeout(renderTimer); - setRenderState("pending"); - renderTimer = window.setTimeout(() => { - renderTimer = null; - if (api && canAutoRender()) { - doRender(); - } else { - setRenderState("idle"); - } - }, delay); -}; - export const scheduleLoad = (delay = LOAD_DELAY_MS.file) => { if (!selectedFile) return; setDataState("loading"); // 読込が済むまで自動再描画は止める if (loadTimer) window.clearTimeout(loadTimer); + if (!api) return; // Python の起動後に handlePythonReady が読み込む loadTimer = window.setTimeout(() => { loadTimer = null; doLoad(); }, delay); }; -export const renderNow = () => { - if (api && canAutoRender()) doRender(); -}; - +// 同じファイルをもう一度選んだ場合も再読込する(入力欄は main.js が空に戻す) export const selectFile = (file) => { - selectedFile = file || null; + if (!file) return; + selectedFile = file; + loadWarnings = []; + showFileName(file.name); scheduleLoad(LOAD_DELAY_MS.file); }; @@ -160,14 +262,14 @@ const downloadDataUri = (filename, dataUri) => { export const saveNow = async () => { if (!api) return; try { - await ensureFont(); + await waitForFont(); const result = callPython("save", toJson(), customCodeProvider()); if (!result.ok) { reportError(result.error); return; } downloadDataUri(result.filename, result.dataUri); - setStatus(`描画データを保存しました: ${result.filename}`); + setStatus(`描画データを保存しました: ${result.filename}`, "ok", "", loadWarnings); } catch (err) { reportException(err, "save"); } @@ -183,16 +285,19 @@ const handlePythonReady = () => { if (api || !window.mplgui) return; api = window.mplgui; root.dataset.appState = "ready"; + startFont(); // 起動時のダウンロードと競合しないよう、Python の準備後に始める const callbacks = readyCallbacks; readyCallbacks = []; for (const fn of callbacks) fn(); - scheduleLoad(LOAD_DELAY_MS.file); // 起動前に選ばれたファイルがあれば読み込む + refreshProgress(); + scheduleLoad(LOAD_DELAY_MS.file); // 起動前に選ばれたファイルがあれば、最新の設定で読み込んで1回描画する }; -// 設定の変更を、読込・再描画・無視のどれにするか振り分ける +// 設定の変更を、読込・再描画・表示更新・無視のどれにするか振り分ける const routeChange = (_settings, change) => { if (change.origin !== "user") return; const path = change.kind === "path" ? change.path : "series"; + if (path === "plot.skipRows") applySkipRows(getSettings().plot.skipRows); // 表の灰色表示は Python を呼ばず即時更新 if (path.startsWith("load.")) { scheduleLoad(path === "load.delimiter" ? LOAD_DELAY_MS.delimiter : LOAD_DELAY_MS.header); } else if (!path.startsWith("save.")) { @@ -204,7 +309,14 @@ export const initBridge = () => { root.dataset.appState = root.dataset.appState || "starting"; setDataState("none"); setRenderState("idle"); + setFontState("idle"); + root.dataset.loadCount = "0"; + refreshProgress(); subscribe(routeChange); + // フォントが Cache Storage にあれば、Python の起動を待たずに読み出しだけ始める(ダウンロードは起こらない) + isFontCached().then((cached) => { + if (cached) startFont(); + }); if (window.mplgui) handlePythonReady(); else window.addEventListener("mplgui-ready", handlePythonReady, { once: true }); }; diff --git a/tests/e2e/test_render_pipeline.py b/tests/e2e/test_render_pipeline.py new file mode 100644 index 0000000..1ce0747 --- /dev/null +++ b/tests/e2e/test_render_pipeline.py @@ -0,0 +1,116 @@ +"""A3(描画の合体・世代)、A4(起動中の操作の保持)、B5(進捗・描画中表示)。""" +import time + +import pytest + +from helpers import ( + image_generation, + load_fixture, + plot_src, + render_generation, + status_kind, + status_text, + wait_app_ready, + wait_data_ready, + wait_settled, +) + +pytestmark = pytest.mark.e2e + +FINAL_TITLE = "最終タイトル" + + +def _reference_image(page, app_url, fixture, apply): + """リロードして、同じ最終設定を1段ずつ待ちながら作った画像(Agg の出力は決定的)。""" + page.goto(app_url) + wait_app_ready(page) + load_fixture(page, fixture) + wait_settled(page) + apply(page) + wait_settled(page) + return plot_src(page) + + +def test_rapid_changes_coalesce_and_final_image_matches_final_settings(page, app_url, fixtures_dir, console_log): + fixture = fixtures_dir / "utf8.csv" + page.goto(app_url) + wait_app_ready(page) + load_fixture(page, fixture) + wait_settled(page) + first_src = plot_src(page) + page.evaluate( + """() => { + window.__busy = []; + new MutationObserver(() => window.__busy.push(document.getElementById('plotPanel').getAttribute('aria-busy'))) + .observe(document.getElementById('plotPanel'), { attributes: true, attributeFilter: ['aria-busy'] }); + }""" + ) + + # 待たずに5回、続けて間隔をあけて5回(描画の最中にも変更が入る) + page.evaluate( + """async (finalTitle) => { + const el = document.getElementById('title'); + const set = (v) => { el.value = v; el.dispatchEvent(new Event('input', { bubbles: true })); }; + for (let i = 1; i <= 5; i++) set('T' + i); + for (let i = 1; i <= 4; i++) { set('S' + i); await new Promise((r) => setTimeout(r, 280)); } + set(finalTitle); + }""", + FINAL_TITLE, + ) + wait_settled(page) + assert status_kind(page) == "ok", status_text(page) + assert image_generation(page) == render_generation(page) # 表示中の画像は最新の要求のもの + final_src = plot_src(page) + assert final_src != first_src + busy = page.evaluate("window.__busy") + assert "true" in busy and busy[-1] == "false" # aria-busy が描画中に立ち、終わると戻る + + ref = _reference_image(page, app_url, fixture, lambda p: p.fill("#title", FINAL_TITLE)) + assert ref == final_src + + +def test_actions_during_startup_are_kept_and_applied_once(page, app_url, fixtures_dir, console_log): + fixture = fixtures_dir / "utf8.csv" + held = [] + page.route("**/pyscript.toml", lambda route: held.append(route)) # Python の起動を保留する + page.goto(app_url) + assert page.evaluate("document.documentElement.dataset.appState") == "starting" + assert "Python 実行環境を読み込み中" in page.inner_text("#progress") + + page.set_input_files("#fileInput", str(fixture)) + page.select_option("#plotType", "scatter") + page.fill("#title", "起動中に入力") + page.wait_for_timeout(600) # デバウンスが切れても、起動前は何も走らない + assert page.evaluate("document.documentElement.dataset.dataState") == "loading" + assert "utf8.csv" in page.inner_text("#fileNameLabel") + assert plot_src(page) == "" + + deadline = time.time() + 30 + while not held and time.time() < deadline: + page.wait_for_timeout(100) + assert held, "pyscript.toml の要求が来ていない" + for route in held: + route.continue_() + + wait_app_ready(page) + page.unroute("**/pyscript.toml") + wait_data_ready(page) + wait_settled(page) + assert status_kind(page) == "ok", status_text(page) + assert render_generation(page) == "1" and image_generation(page) == "1" # 起動後の描画は1回だけ + assert page.eval_on_selector("#series-s1-marker-size", "e => e.value") == "24" # scatter が反映されている + src = plot_src(page) + + def apply(p): + p.select_option("#plotType", "scatter") + wait_settled(p) + p.fill("#title", "起動中に入力") + + assert _reference_image(page, app_url, fixture, apply) == src + + +def test_progress_banner_hides_after_startup(page, app_url, console_log): + page.goto(app_url) + wait_app_ready(page) + page.wait_for_function("['ready','failed'].includes(document.documentElement.dataset.fontState)") + page.wait_for_function("document.getElementById('progress').hidden") From 246bafd7c41068a77d6d1f0758a2fe0c79b4cbb5 Mon Sep 17 00:00:00 2001 From: mioupa Date: Wed, 7 Oct 2026 18:45:56 +0900 Subject: [PATCH 19/37] Status kinds, ARIA tabs/labels (B6), render-busy state, B7 tests Co-Authored-By: Claude Opus 5.5 --- css/style.css | 106 ++++++++++++++++++++++++++++-- index.html | 28 ++++---- js/main.js | 6 +- js/ui/notify.js | 40 +++++++++-- js/ui/series.js | 5 +- js/ui/tabs.js | 23 +++++-- tests/e2e/helpers.py | 31 +++++++++ tests/e2e/test_a11y_and_series.py | 73 ++++++++++++++++++++ 8 files changed, 281 insertions(+), 31 deletions(-) create mode 100644 tests/e2e/test_a11y_and_series.py diff --git a/css/style.css b/css/style.css index f30b84e..13946ed 100644 --- a/css/style.css +++ b/css/style.css @@ -30,9 +30,13 @@ --error-text: #d20f39; --status-ok: #4c4f69; --status-error: #d20f39; + --status-warning: #8a5300; + --status-info: #5c5f77; + --warning-bg: rgba(223, 142, 29, 0.14); + --busy-bg: rgba(239, 241, 245, 0.72); --table-header-bg: #e6e9ef; - --table-skipped-bg: #dce0e8; - --table-skipped-text: #6c6f85; + --table-skipped-bg: #ccd0da; + --table-skipped-text: #8c8fa1; --table-rownum-bg: #e6e9ef; --table-rownum-text: #6c6f85; --series-item-bg: #e6e9ef; @@ -74,9 +78,13 @@ --error-text: #f5c2e7; --status-ok: #cdd6f4; --status-error: #f38ba8; + --status-warning: #f9e2af; + --status-info: #bac2de; + --warning-bg: rgba(249, 226, 175, 0.12); + --busy-bg: rgba(24, 24, 37, 0.7); --table-header-bg: #313244; - --table-skipped-bg: #181825; - --table-skipped-text: #a6adc8; + --table-skipped-bg: #0f0f17; + --table-skipped-text: #7f849c; --table-rownum-bg: #313244; --table-rownum-text: #a6adc8; --series-item-bg: #313244; @@ -252,6 +260,88 @@ button.secondary:hover { min-height: 22px; font-size: 13px; } +.status[data-kind="ok"] { + color: var(--status-ok); +} +.status[data-kind="warning"] { + color: var(--status-warning); +} +.status[data-kind="error"] { + color: var(--status-error); + font-weight: 600; +} +.status[data-kind="info"] { + color: var(--status-info); +} +.status[data-kind="ok"]::before { + content: "\2713 "; +} +.status[data-kind="error"]::before { + content: "\2715 "; +} +.status[data-kind="warning"] { + padding: 6px 8px; + border-left: 3px solid var(--status-warning); + border-radius: 4px; + background: var(--warning-bg); +} +.status-warning-item { + color: var(--status-warning); +} +.status-warning-item::before { + content: "\26A0 "; +} +.file-info { + margin-top: 4px; + font-size: 12px; + color: var(--text-muted); + word-break: break-all; +} +.file-info:empty { + display: none; +} +.progress-banner { + margin-bottom: 10px; + padding: 6px 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface); + color: var(--status-info); + font-size: 13px; +} +.progress-banner[hidden] { + display: none; +} +#plotPanel { + position: relative; +} +.render-busy { + position: absolute; + top: 20px; + right: 20px; + z-index: 5; + padding: 4px 12px; + border-radius: 999px; + background: var(--busy-bg); + border: 1px solid var(--line); + color: var(--text); + font-size: 13px; + font-weight: 700; + opacity: 0; + animation: busy-in 0.15s ease-out 0.2s forwards; /* 短い描画では出さない */ +} +.render-busy[hidden] { + display: none; +} +@keyframes busy-in { + to { + opacity: 1; + } +} +#plotPanel[aria-busy="true"] #plotArea { + opacity: 0.6; + transition: opacity 0.15s ease-out 0.2s; +} .status-detail-wrap { margin-top: 8px; border: 1px solid var(--error-border); @@ -434,9 +524,15 @@ button.secondary:hover { gap: 8px; margin-bottom: 8px; } -.series-toolbar label { +.series-toolbar label, +.series-toolbar .field-label { margin: 0; } +.field-label { + display: block; + font-size: 13px; + font-weight: 600; +} button.small-btn { width: auto; padding: 7px 10px; diff --git a/index.html b/index.html index 89cbc66..5621fb3 100644 --- a/index.html +++ b/index.html @@ -26,6 +26,8 @@

Matplotlib GUI

+
+
@@ -67,10 +69,10 @@

Matplotlib GUI

- + 描画系列
-
+
系列ごとにX/Y列・色・線幅・線種・点サイズ・第2軸使用を設定できます。
@@ -247,7 +249,7 @@

Matplotlib GUI

-
+