diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..bf33ba4 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,63 @@ +name: tests + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + unit: + name: unit + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + with: + enable-cache: true + - run: uv sync --locked + - run: uv run pytest + + e2e: + name: e2e + # push では実行しない(pull_request と手動実行のみ) + if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # GitHub Pages (legacy build) と同じ Jekyll ビルドで _site を作る + - uses: actions/jekyll-build-pages@44a6e6beabd48582f863aeeb6cb2151cc1716697 # v1.0.13 + with: + source: ./ + destination: ./_site + - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + with: + enable-cache: true + - run: uv sync --locked + - run: uv run playwright install --with-deps chromium + # Pages と同じ /matplotlib_gui/ サブパス配下で配信して検証する + - name: Run E2E tests + env: + E2E_SERVE_DIR: _site + E2E_BASE_PATH: /matplotlib_gui/ + run: uv run pytest tests/e2e --tracing retain-on-failure --output test-results + - name: Upload Playwright traces + if: failure() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: test-results + path: test-results/ + if-no-files-found: ignore + retention-days: 7 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ee916cd --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.venv/ +__pycache__/ +.pytest_cache/ +test-results/ +*.pyc +.DS_Store +# ベンチマークの出力・生成データ +tests/perf/out/ +tests/perf/.tmp/ +_site/ 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/AGENTS.md b/AGENTS.md index 5788562..45186fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,122 +1,139 @@ -# matplotlibをブラウザGUIで操作するための実装方針(引き継ぎ用・詳細) - -## 1. ゴール -- `index.html` をブラウザで開くだけで `matplotlib` 描画をGUI操作する。 -- サーバー(FastAPI等)は使わず、ブラウザ内Python実行(PyScript / Pyodide)で完結する。 -- 入力ファイルは `.xlsx / .csv / .txt` に対応する。 - -## 2. 現在の実装構成 -- 実装ファイルは **`index.html` 単体**。 -- 構成要素: - - HTML/CSS: 画面UI(設定フォーム、タブ、データ表、色オーバーレイ) - - JavaScript: 自動読込/自動描画の制御、系列カードUI、色選択UI、エラー抑制 - - Python(``内): - - 読み込み処理: `pandas` - - 描画処理: `matplotlib` - - DOM連携: `js` / `pyodide.ffi.create_proxy` -- `py-config` のパッケージ: - - `pandas` - - `matplotlib` - - `openpyxl` - -## 3. 画面仕様(現在) -### 3.1 データ読み込み -- 入力ファイル選択(`.xlsx/.csv/.txt`) -- 区切り文字 -- ヘッダ有無 -- 読み込みボタンは廃止済み(自動読込) - -### 3.2 描画設定 -- プロット種別: `line / scatter / bar` -- スキップ行数: 「描画設定」先頭に配置 -- 系列はカード方式(`+ 系列を追加`)で複数追加可能 - - 各系列: X列、Y列、色、線幅、線種、点サイズ、凡例名(任意)、第2軸使用 -- タイトル、X/Yラベル -- 第2Yラベル(第2軸系列が1つでもある時のみ表示) -- フォントサイズ(既定15) -- major/minor目盛線の表示切替 -- 凡例位置(日本語ラベルで選択) -- 図幅/図高さ(既定 8x6) -- 描画ボタンは廃止済み(自動描画) - -### 3.3 保存設定 -- 保存ファイル名(空欄時は `plot.<拡張子>`) -- 保存形式: `png / jpg / svg / pdf` -- 保存ボタンでダウンロード - -### 3.4 表示エリア -- タブ切替: `プロット / データ確認` -- データ確認は先頭100行を表示 -- 行番号列 `#` を表示 -- `skipRows` 対象の先頭行はグレーアウト(描画で除外される行を視覚化) - -## 4. 自動処理の仕様 -- ファイル選択・区切り文字変更・ヘッダ変更時: 自動で再読込。 -- 描画設定変更時: 自動で再描画。 -- 成功時はステータスに成功メッセージを表示。 -- エラー時のみエラーメッセージを表示。 - -## 5. 系列と描画ロジック -- 同名列対応のため列指定値は `__idx__<列番号>` 形式を維持。 -- `line` と `scatter` は系列ごとにX列指定可能。 -- `bar` は共通X列を使用。 -- `line` でも点表示可(点サイズ > 0 で表示)。 -- 点サイズの既定: - - `line`: 0 - - `scatter`: 24 -- `scatter`選択時、点サイズが0以下なら自動で24に補正。 -- `scatter` から `line` に戻す際、24のままなら0へ戻す。 -- 線幅の既定は2。 -- 各系列で第2軸を有効化可能(`twinx`)。 - -## 6. 色指定UI -- UDカラーセットを既定候補として提供: - - `#FF4B00`, `#005AFF`, `#03AF7A`, `#4DC4FF`, `#F6AA00`, `#FFF100`, `#990099`, `#84919E`, `#000000`, `#804000`, `#FF8082` -- 色選択はオーバーレイパネル表示。 -- 色チップは正方形固定。 -- 「カスタム」ボタンは1クリックでカラーピッカーを開く。 -- パネル外クリックでオーバーレイを閉じる。 - -## 7. 軸・目盛り・凡例 -- 目盛りは内向き(`direction='in'`)。 -- 不要側のヒゲは表示しない: - - 主軸: 下・左 - - 第2軸: 右 -- major/minor目盛線は個別ON/OFF可能。 -- 凡例位置は選択可能。`none` で非表示。 -- 第2軸あり時は主軸・第2軸のハンドルをマージして凡例表示。 - -## 8. 日本語フォント対応 -- CSSは `Noto Sans JP` を既定フォントに設定。 -- matplotlib側は `Noto Sans JP / Noto Sans CJK JP` を優先。 -- 必要時はNoto CJKフォントをWeb取得して `font_manager.addfont` で登録。 -- オフライン時や取得失敗時はフォールバックフォントで継続。 - -## 9. エラー/警告抑制(重要) -- `parentNode` 系例外は JS/Python 両側で抑制・回復。 -- `PyodideTask exception was never retrieved` は `_spawn_task` で回収。 -- pandas `pyarrow` DeprecationWarning は抑制。 -- 日本語glyph不足のUserWarningは抑制。 -- ただし、ユーザー入力や設定不整合による本来のエラーは表示する。 - -## 10. 主なPython関数 -- `_read_upload`: ブラウザ選択ファイルをbytes取得 -- `_load_dataframe`: 拡張子別にDataFrame化 -- `_resolve_column` / `_parse_column_index` / `_get_series_from_request`: 列解決 -- `_collect_series_settings`: 系列カードから設定収集 -- `_get_plot_data` / `_get_per_series_xy_data`: 描画データ整形 -- `_create_plot_figure`: 描画本体(line/scatter/bar + 二軸 + grid + 凡例) -- `_render_data_preview`: データ確認テーブル描画(skipRows可視化込み) -- `_render_plot_image`: 図をPNG data URIで表示 -- `on_load_columns`, `on_render`, `on_save_plot`: 主処理 - -## 11. 引き継ぎ時の注意 -- 「ブラウザ完結(サーバーなし)」前提を崩さないこと。 -- 同名列対応のため、列指定は必ず列番号ベースを維持すること。 -- `parentNode` 系の抑制・回復ロジックは削除しないこと。 -- 自動読込/自動描画のUX(手動ボタンなし)を維持すること。 -- データ確認タブの「skipRowsグレーアウト可視化」を維持すること。 +# matplotlib GUI 引き継ぎ資料(AI エージェント向け) + +要件の正は `docs/REQUIREMENTS.md`。このファイルと食い違う場合は REQUIREMENTS.md を優先する。 +この資料は Phase 1(基盤の刷新と不具合修正)完了時点の実装を説明する。各 Phase の完了時に実装に合わせて更新すること(要件 A11)。 + +## 1. ゴールと前提 + +- CSV / TXT / XLSX のデータから、ブラウザ上の GUI だけで matplotlib の図を作る。 +- 利用形態は GitHub Pages 配信(公開 URL: https://mioupa.github.io/matplotlib_gui/)。`file://` で直接開く使い方は非対応。 +- サーバー処理を持たない。計算はすべてブラウザ内の Python(PyScript / Pyodide)で行う。GitHub Pages は静的配信のみに使う。 +- 読み込んだデータは外部に送信しない。外部への通信はライブラリとフォントの取得だけ。 +- 列の指定は列番号(`__idx__N`)で行う。同名の列があっても区別できる。 +- 自動読込・自動描画を保つ(「読み込む」「描画する」ボタンは置かない)。 +- データ確認タブは、描画から除外される先頭行(`skipRows`)をグレー(`.skipped-row`)で表示する。 +- CDN の URL はすべてバージョン固定にする。`tests/unit/test_cdn_pinning.py` が `index.html`・`js/`・`css/`・`py/` の全 URL を走査して強制する。 + +## 2. ファイル構成 + +| パス | 役割 | +|---|---| +| `index.html` | 画面のマークアップのみ。スクリプト読み込み失敗時の案内(`onerror`)を含む | +| `pyscript.toml` | PyScript 設定(パッケージ、`packages_cache`、`[files]` による Python ファイルの配置) | +| `css/style.css` | スタイル | +| `js/main.js` | エントリ。UI を設定オブジェクトに結び付ける | +| `js/defaults.js` | 設定の既定値(`DEFAULT_SETTINGS`)、色パレット(UD カラー) | +| `js/state.js` | 設定オブジェクトの保持・更新・購読(`getSettings`、`setPath`、`updateSeries`、`addSeries`、`removeSeries`、`subscribe`、`toJson`) | +| `js/bridge.js` | Python(`window.mplgui`)の唯一の窓口。読込・描画・保存の制御、世代管理、フォント取得の起動 | +| `js/startup-progress.js` | Python 起動中の段階表示。Resource Timing(`PerformanceObserver`、取得済みエントリを含む)から Pyodide 本体(`pyodide.asm.wasm`・`python_stdlib.zip`)→ パッケージ wheel(pandas / matplotlib)→ 初期化(`/py/main.py` の取得)を推定する | +| `js/font-cache.js` | 日本語フォントの取得と Cache Storage への保存 | +| `js/ui/*.js` | 画面部品(`colorPicker`、`customCode`、`dataPreview`、`fileInfo`、`forms`、`notify`、`plotView`、`progress`、`saveFormat`、`series`、`tabs`) | +| `py/main.py` | PyScript のエントリ。JS と `mplgui` の橋渡しだけを書く(`window.mplgui` の登録、Excel 用ライブラリの遅延導入) | +| `py/mplgui/settings.py` | 設定スキーマ、既定値、検証、日本語エラー(凍結 dataclass へ変換) | +| `py/mplgui/loader.py` | バイト列から DataFrame(文字コード判定、区切り文字、プレビュー) | +| `py/mplgui/plotting.py` | 描画本体(暫定。Phase 2 で codegen に置き換える) | +| `py/mplgui/runner.py` | Figure の生成と、画像(PNG/JPG/SVG/PDF)への書き出し、カスタムコードの実行 | +| `py/mplgui/fonts.py` | 日本語フォントの登録と rcParams | +| `py/mplgui/errors.py` | `UserError`(利用者が直せるエラー) | +| `py/mplgui/api.py` | JS から呼ばれる JSON 入出力の関数 | +| `py/mplgui/runtime.py` | 警告フィルタ | +| `tests/unit/` | 単体テスト(CPython、Agg) | +| `tests/e2e/` | E2E テスト(pytest-playwright、Chromium) | +| `tests/fixtures/` | 合成テストデータ(`make_fixtures.py` が生成) | +| `tests/perf/bench.py` | 起動・描画の計測。結果は `docs/PERFORMANCE.md` | +| `.github/workflows/tests.yml` | CI | +| `_config.yml` | GitHub Pages(Jekyll)の include / exclude | + +`py/mplgui/*` は `js` / `pyodide` に依存しない純粋なモジュールにする(CPython で単体テストできるようにするため)。DOM に触れるのは JS だけ。 + +## 3. 設定オブジェクト + +- スキーマのバージョンは 1(`version`)。JSON のキーは camelCase。 +- 唯一の正は JS 側(`js/state.js`)の設定オブジェクト。Python は DOM を読まず、渡された JSON を `settings.parse_settings` / `parse_load_settings` で凍結 dataclass に変換して使う。 +- JS の既定値(`js/defaults.js`)と Python の既定値(`settings.default_settings()`)は一致させる。`tests/unit/test_defaults_parity.py` が比較する(node が無いと skip)。 +- 主な構造: `load`(`delimiter`、`hasHeader`)、`plot`(`type`、`skipRows`、`xColumn`、`title`、`fontSize`、`figure`、`legend`、`grid`、`margins`)、`axes`(`x` / `y` / `y2`: `label`、`scale`、`min`、`max`)、`series[]`、`save`(`filename`、`format`、`transparent`)。 +- 系列: `id`、`x`、`y`、`color`、`lineWidth`、`lineStyle`、`markerSize`、`label`、`secondaryAxis`。`markerSize: null` は「自動」(line は 0、scatter は 24)。ユーザーが数値を入れたら自動切替の対象外。 +- カスタムコードの本文と「使用する」は設定オブジェクトには入れず、`render` / `save` の別引数で渡す。 + +## 4. Python ⇄ JS の API(`window.mplgui`) + +Python が登録し、登録後に `mplgui-ready` イベントを送る。引数・戻り値は JSON 文字列(ファイルとフォントはバイト列)。 + +| 関数 | 引数 | 戻り値(`ok: true` のとき) | +|---|---|---| +| `ensureExcel()` | なし(非同期) | `{ok}`。初回の .xlsx 読込前に openpyxl を導入する | +| `loadFile(name, bytes, loadJson)` | ファイル名、内容、`{version, load}` | `{encoding, columns, preview, warnings}`(.xlsx は `encoding: null`) | +| `render(settingsJson, customCode)` | 設定 JSON、コード文字列または null | `{image, seriesCount, skipRows, warnings}`(`image` は PNG の data URI) | +| `save(settingsJson, customCode)` | 同上 | `{filename, mime, dataUri}` | +| `registerFont(bytes)` | フォントのバイト列 | `{registered}` | +| `fontStatus()` | なし | `{registered}` | +| `defaultCustomCode()` | なし | 文字列(JSON ではない) | + +- `warnings` は `{series, message}` の配列(読込時は文字列配列の場合もある。JS の `warningTexts` が吸収する)。 +- 失敗: `{ok: false, error: {message, field?, detail?}}`。 + - 利用者が直せる原因は `UserError`。`message` は日本語で、どの項目が悪いかを含める(`field`)。 + - それ以外の例外は内部エラー。短い日本語メッセージ(`INTERNAL_ERROR_MESSAGE`)に、`detail` として traceback を付ける(画面の「内部エラー詳細」に出る)。 +- `loadFile` が失敗したら読込済みデータを破棄する。`render` は読込済みデータが無いと「先にファイルを読み込んでください。」を返す。 + +## 5. 描画パイプライン + +- 描画は設定変更の 250 ms 後(デバウンス)。読込は、ファイル選択・ヘッダ変更で 0 ms、区切り文字の変更で 450 ms 後。保存設定(`save.*`)の変更では再描画しない。`skipRows` の変更は Python を呼ばず、データ確認表のグレー表示だけ即時に更新する。 +- 起動中の `#progress`(`aria-live="polite"`)は段階表示: 「Python 実行環境を読み込み中…」→「ライブラリを読み込み中…(pandas ✓, matplotlib …)」→「ライブラリを初期化中…」→ 準備完了で消える(フォント取得の表示が続く場合あり)。`` の `data-startup-stage`(`runtime` / `packages` / `init` / `ready`)に現在の段階が入る。新しい id は無い。読込失敗の案内(`data-load-failed`)は上書きしない。 +- 描画要求には世代番号を付ける。Python の描画は同時に最大1つ。実行中に新しい要求が来たら「やり直し」の印だけ付け、終了後に最新の設定で1回だけ描く(合流)。古い世代の結果は画像にもステータスにも反映しない。 +- Python の起動前に選んだファイルや変えた設定は保持し、起動後に最新の内容で1回読み込んで描く(起動キュー)。 +- E2E が使う状態は `` の data 属性: `data-app-state`(`starting` / `ready` / `failed`)、`data-data-state`(`none` / `loading` / `ready` / `error`)、`data-render-state`(`idle` / `pending` / `rendering`)、`data-render-generation`、`data-font-state`(`idle` / `loading` / `ready` / `failed`)、`data-load-count`。表示中の画像は `#plotArea img` の `data-generation` を持つ。ステータスは `#status` の `data-kind`。 + +## 6. 起動とフォント + +- PyScript 2026.7.3(Pyodide 314.0.3 / Python 3.14)。`pyscript.toml` は `packages = ["pandas", "matplotlib"]`、`packages_cache = "passthrough"`(micropip を使わない)。 +- openpyxl と et_xmlfile は Pyodide 同梱ではないので、初めて .xlsx を読むときに `py/main.py` が、バージョン固定の wheel URL(`files.pythonhosted.org`)を `pyodide_js.loadPackage` に渡して導入する。起動時には導入しない。 +- matplotlib のバックエンドは Agg(`py/main.py` で `matplotlib.use("Agg")`。pyplot を import する前に指定する)。 +- 日本語フォントは固定 URL の jsDelivr(`noto-cjk@Sans2.004` の `NotoSansCJKjp-Regular.otf`、約16 MB)から取得し、Cache Storage(キャッシュ名 `mplgui-fonts-v1`)に保存する。2回目以降の訪問ではダウンロードしない。取得はセッション中1回で、失敗しても再試行せず、警告を1度だけ出してフォールバックフォントで続行する。 +- フォント取得は、進みが 20 秒止まる(ヘッダが届かない、または本文が進まない)と失敗扱いにする(`FONT_STALL_MS`)。 +- 最初の描画はフォント取得を最大 8 秒待つ(`FONT_FIRST_RENDER_WAIT_MS`)。超えたらフォールバックフォントで描き、取得が終わったら現在の設定で1回だけ描き直す。 +- テスト専用に、`window.__MPLGUI_TEST_OVERRIDES__ = {fontStallMs, fontFirstRenderWaitMs}` で上の時間を短縮できる。 + +## 7. 文字コード判定・数値変換警告・ステータス + +- 文字コード(`loader.detect_encoding`): BOM 付きなら utf-8-sig → UTF-8 → CP932 と EUC-JP。両方でデコードできるときは、半角カナ・私用領域・制御文字などの「文字化けらしい文字」が少ないほうを採用し、同数なら CP932。どれでも読めなければ日本語のエラーにする。判定結果は `#encodingLabel` に表示する。 +- 数値に変換できず除外した値は、系列ごとに警告にする(`plotting.dropped_warning`)。 +- ステータス(`#status`)の種別は `ok` / `warning` / `error`。警告は成功メッセージに添えて表示し、`error` のときは `data-kind="error"`。日本語フォント取得失敗のような常時有効な警告は、以後の表示にも添える。 + +## 8. 警告フィルタ + +`runtime.configure_warnings` が無視するのは、次の2つだけ。それ以外の警告は無視しない(A10)。 + +- pandas の pyarrow 関連の `DeprecationWarning` +- matplotlib のグリフ不足の `UserWarning` + +## 9. parentNode エラー + +旧実装は `parentNode` を含むエラーを JS / Python の両側で抑制・回復していた。Phase 1 でオーナーの承認を得て削除した。原因は、旧 `matplotlib_pyodide` バックエンドが、表示されなかった Figure の破棄時に起こしていたこと。PyScript 2026.7.3 と Agg の組み合わせでは発生しない。エラーを一括で握りつぶす処理は再導入しないこと(再現した場合は、原因を調べて範囲を絞る)。 + +## 10. テスト + +```sh +uv sync +uv run playwright install chromium +uv run pytest # 単体テスト(tests/unit) +uv run pytest tests/e2e # E2E(Chromium とローカルの静的サーバー) +``` + +- 新しい機能には単体テストを必ず追加する。 +- E2E は要素を id だけで選ぶ。`index.html` の id は安定させ、変えるときはテストも直す。アプリ固有の待機・操作は `tests/e2e/helpers.py` にまとめる。 +- テストデータは `tests/fixtures/` の合成データだけ(公開リポジトリなので実データを置かない)。 +- テスト用サーバー(`tests/e2e/server.py`)は listen backlog を 128 に広げている。既定の 5 だと、Chromium が ES モジュールを一斉に取得したときに接続が切れて、アプリが起動しないことがある。 +- 環境変数 `E2E_BASE_PATH=/matplotlib_gui/` を付けると、Pages と同じサブパス配下で配信して検証する(絶対パス参照の検出)。`E2E_SERVE_DIR` で配信ディレクトリを変えられる(CI は Jekyll のビルド結果 `_site`)。 + +## 11. CI と GitHub Pages + +- CI(`.github/workflows/tests.yml`): push で単体テスト、プルリクエストで E2E。E2E は Jekyll でビルドした `_site` を `/matplotlib_gui/` 配下で配信して検証する。 +- Jekyll は `_` で始まるファイルを既定で配信しない。`__init__.py` は `_config.yml` の `include` で明示している。開発用ファイル(`docs/`、`tests/`、`AGENTS.md`、`pyproject.toml`、`uv.lock`、`.github/` など)は `exclude`。 +- 新しい Python モジュールを追加したら、`pyscript.toml` の `[files]` に必ず追加する(`tests/unit/test_pages_publish.py` が参照ファイルの公開を確認する)。 +- matplotlib 3.10 では `tight_layout()` 後に `PlaceHolderLayoutEngine` が残り、`savefig` が余分に全体を再描画する。`runner.drop_placeholder_layout_engine` がこれを外している(出力は同一)。 ## 12. 運用ルール(必須) -- このプロジェクトでは、`/Users/miura/Documents/projects/matplolib_gui` 配下のみを対象に作業する。 -- このディレクトリ外のファイルには一切触れない。 + +- このリポジトリ配下のみを対象に作業する。リポジトリ外のファイルには触れない。 +- push やプルリクエストの作成は、オーナーの確認を取ってから行う。 +- ローカル環境のパスを、リポジトリ内の文書やコードに書かない。 diff --git a/README.md b/README.md index 78f7274..6c60376 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,57 @@ -# matplotlib browser GUI +# Matplotlib GUI -`index.html`をブラウザで開くだけで、`matplotlib`をGUI操作できます。 +CSV / TXT / XLSX のデータから、ブラウザ上の操作だけで matplotlib の図を作るツールです。サーバーを使わず、Python(PyScript / Pyodide)をブラウザの中で実行します。 ## 使い方 -1. `index.html` をブラウザで開く -2. `.xlsx/.csv/.txt` を選択 -3. 必要なら区切り文字や列を指定 -4. `描画する` を押す +https://mioupa.github.io/matplotlib_gui/ を開いてください。`index.html` を `file://` で直接開く使い方には対応していません。 -## 注意 +1. 「入力ファイル」で `.csv` / `.txt` / `.xlsx` を選びます。 +2. 読み込みと描画は自動で行われます(「描画する」ボタンはありません)。設定を変えると、すぐ再描画されます。 +3. 「描画系列」でX列・Y列・色などを選び、必要なら系列を追加します。 +4. 「保存設定」でファイル名と形式を選び、「保存する」で書き出します。 -- 初回はブラウザ側のPythonパッケージ読込で時間がかかることがあります。 -- 完全ブラウザ実行のため、サーバー起動は不要です。 +対応する文字コードは UTF-8、UTF-8(BOM付き)、Shift_JIS(CP932)、EUC-JP です。判定した文字コードは、読み込み後に画面へ表示されます。 + +## 主な機能 + +- 系列カード(複数系列、X/Y列、色、線幅、線種、点サイズ、凡例名)と、第2Y軸 +- プロット種別: line / scatter / bar +- 軸の対数表示と範囲指定、余白指定、主目盛線・副目盛線、凡例位置 +- 保存: png / jpg / svg / pdf、背景透過 +- データ確認タブ: 先頭100行を表示し、描画から除外される先頭行をグレーで表示 +- Pythonコード(beta)タブ: 描画用コードの確認と実行(Phase 2 で作り替え予定) +- 数値に変換できず除外した値の警告、日本語のエラーメッセージ + +## プライバシー + +読み込んだデータの処理はすべてブラウザの中で行い、外部には送信しません。外部への通信は、ライブラリ(PyScript / Pyodide / pandas / matplotlib など)とフォントの取得だけです。通信先は次のとおりです。 + +| ホスト | 取得するもの | +|---|---| +| `pyscript.net` | PyScript 本体 | +| `cdn.jsdelivr.net` | Pyodide 本体と同梱パッケージ(pandas / matplotlib など)、画面用フォント(Noto Sans JP)、グラフ用の日本語フォント(Noto Sans CJK JP) | +| `files.pythonhosted.org` | openpyxl の wheel(`.xlsx` を初めて開くときだけ) | + +初回の訪問では、Pyodide とライブラリ(数十 MB)と、日本語フォント(約 16 MB)をダウンロードするため、時間がかかります。2回目以降はキャッシュを使います(フォントはブラウザの Cache Storage に保存し、再ダウンロードしません)。 + +## 開発 + +開発用の依存は [uv](https://docs.astral.sh/uv/) で管理します(実行時には使いません)。 + +```sh +uv sync +uv run playwright install chromium +uv run pytest # 単体テスト +uv run pytest tests/e2e # E2E テスト(Chromium で実際に動かす) +uv run python -m http.server # ローカルで確認: http://localhost:8000/ +uv run python tests/perf/bench.py --reps 5 # 起動・描画の計測(結果は docs/PERFORMANCE.md) +``` + +CI(GitHub Actions)は、push のたびに単体テストを、プルリクエストのたびに E2E テストを実行します。E2E は Pages と同じ Jekyll ビルドの結果を `/matplotlib_gui/` 配下で配信して検証します。 + +要件は `docs/REQUIREMENTS.md`、AI エージェント向けの引き継ぎ情報は `AGENTS.md` にあります。 + +## ライセンス + +[LICENSE.md](LICENSE.md)(MIT License)を参照してください。 diff --git a/_config.yml b/_config.yml new file mode 100644 index 0000000..998a717 --- /dev/null +++ b/_config.yml @@ -0,0 +1,13 @@ +# GitHub Pages (Jekyll) の配信設定 +# Jekyll は "_" で始まるファイルを既定で配信しないため、Python パッケージの __init__.py だけ明示的に含める +include: + - __init__.py +# 開発用のファイルはリポジトリには置くが、Pages では配信しない +exclude: + - docs/ + - AGENTS.md + - tests/ + - pyproject.toml + - uv.lock + - .python-version + - .github/ diff --git a/css/style.css b/css/style.css new file mode 100644 index 0000000..13946ed --- /dev/null +++ b/css/style.css @@ -0,0 +1,678 @@ +:root { + color-scheme: light; + --bg: #eff1f5; + --bg-gradient-start: #dce0e8; + --bg-gradient-end: #eff1f5; + --card: #eff1f5; + --surface: #e6e9ef; + --line: #bcc0cc; + --line-soft: #ccd0da; + --text: #4c4f69; + --text-muted: #5c5f77; + --accent: #1e66f5; + --accent-contrast: #ffffff; + --button-primary: #1e66f5; + --button-primary-hover: #7287fd; + --button-secondary: #179299; + --button-secondary-hover: #209fb5; + --button-ghost: #7c7f93; + --button-ghost-hover: #6c6f85; + --button-reset: #d20f39; + --button-reset-hover: #e64553; + --button-text: #ffffff; + --checkbox-size: 20px; + --file-button-bg: #4c4f69; + --file-button-text: #ffffff; + --color-border: #bcc0cc; + --swatch-border: #8c8fa1; + --error-border: #d20f39; + --error-bg: rgba(210, 15, 57, 0.08); + --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: #ccd0da; + --table-skipped-text: #8c8fa1; + --table-rownum-bg: #e6e9ef; + --table-rownum-text: #6c6f85; + --series-item-bg: #e6e9ef; + --chip-border: #bcc0cc; + --chip-active: #1e66f5; + --chip-active-ring: rgba(30, 102, 245, 0.16); + --plot-preview-bg: #e6e9ef; + --shadow-lg: 0 10px 24px rgba(92, 95, 119, 0.16); +} +@media (prefers-color-scheme: dark) { + :root { + color-scheme: dark; + --bg: #1e1e2e; + --bg-gradient-start: #11111b; + --bg-gradient-end: #1e1e2e; + --card: #181825; + --surface: #313244; + --line: #45475a; + --line-soft: #585b70; + --text: #cdd6f4; + --text-muted: #bac2de; + --accent: #89b4fa; + --accent-contrast: #1e1e2e; + --button-primary: #89b4fa; + --button-primary-hover: #b4befe; + --button-secondary: #94e2d5; + --button-secondary-hover: #74c7ec; + --button-ghost: #9399b2; + --button-ghost-hover: #bac2de; + --button-reset: #f38ba8; + --button-reset-hover: #eba0ac; + --button-text: #1e1e2e; + --file-button-bg: #45475a; + --file-button-text: #cdd6f4; + --color-border: #585b70; + --swatch-border: #9399b2; + --error-border: #f38ba8; + --error-bg: rgba(243, 139, 168, 0.14); + --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: #0f0f17; + --table-skipped-text: #7f849c; + --table-rownum-bg: #313244; + --table-rownum-text: #a6adc8; + --series-item-bg: #313244; + --chip-border: #585b70; + --chip-active: #89b4fa; + --chip-active-ring: rgba(137, 180, 250, 0.22); + --plot-preview-bg: #181825; + --shadow-lg: 0 12px 28px rgba(17, 17, 27, 0.48); + } +} +* { + box-sizing: border-box; +} +html, +body { + height: 100%; +} +body { + margin: 0; + color: var(--text); + font-family: "Noto Sans JP", "Hiragino Sans", "Yu Gothic", sans-serif; + background: linear-gradient(180deg, var(--bg-gradient-start) 0%, var(--bg-gradient-end) 100%); + overflow: hidden; +} +.layout { + display: grid; + grid-template-columns: 360px 1fr; + height: 100vh; + min-height: 100vh; +} +.panel { + padding: 20px; + background: var(--card); + border-right: 1px solid var(--line); + max-height: 100vh; + overflow-y: auto; + overscroll-behavior: contain; +} +.viewer { + display: flex; + flex-direction: column; + padding: 20px; + min-height: 0; + overflow: hidden; +} +h1 { + margin: 0 0 16px; + font-size: 20px; +} +.group { + margin-bottom: 12px; +} +.row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} +label { + display: block; + margin-bottom: 6px; + font-size: 13px; + font-weight: 600; +} +input, +select, +button { + width: 100%; + padding: 9px 10px; + border-radius: 8px; + border: 1px solid var(--line); + font-size: 14px; + background: var(--card); + color: var(--text); +} +input[type="file"] { + padding: 6px; + background: var(--surface); +} +input[type="file"]::file-selector-button { + border: none; + border-radius: 6px; + padding: 8px 12px; + margin-right: 10px; + background: var(--file-button-bg); + color: var(--file-button-text); + font-weight: 700; + cursor: pointer; +} +input[type="file"]::-webkit-file-upload-button { + border: none; + border-radius: 6px; + padding: 8px 12px; + margin-right: 10px; + background: var(--file-button-bg); + color: var(--file-button-text); + font-weight: 700; + cursor: pointer; +} +input[type="color"] { + height: 42px; + padding: 2px; + border: 2px solid var(--color-border); + border-radius: 10px; + background: var(--card); +} +input[type="color"]::-webkit-color-swatch-wrapper { + padding: 0; +} +input[type="color"]::-webkit-color-swatch { + border: none; + border-radius: 7px; +} +input[type="color"]::-moz-color-swatch { + border: none; + border-radius: 7px; +} +.color-value { + margin-top: 6px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; + color: var(--text-muted); +} +.inline { + display: flex; + align-items: center; + gap: 10px; + font-size: 13px; +} +input[type="checkbox"] { + width: var(--checkbox-size); + height: var(--checkbox-size); + margin: 0; + flex: 0 0 var(--checkbox-size); + cursor: pointer; + accent-color: var(--accent); +} +.inline input { + width: auto; +} +.inline label { + margin: 0; + font-weight: 500; + cursor: pointer; + user-select: none; + padding: 4px 2px; +} +label.check-label { + display: inline-flex; + align-items: center; + gap: 10px; + width: auto; +} +button { + cursor: pointer; + border: none; + color: var(--button-text); + background: var(--button-primary); + font-weight: 700; + transition: background-color 0.15s ease; +} +button:disabled { + cursor: not-allowed; + opacity: 0.6; +} +button.secondary { + background: var(--button-secondary); +} +button.secondary:hover { + background: var(--button-secondary-hover); +} +.status { + margin-top: 10px; + 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); + border-radius: 8px; + background: var(--error-bg); +} +.status-detail-wrap.hidden { + display: none; +} +.status-detail-wrap summary { + padding: 8px 10px; + cursor: pointer; + font-size: 12px; + font-weight: 700; + color: var(--error-text); +} +.status-detail { + margin: 0; + padding: 0 10px 10px; + white-space: pre-wrap; + word-break: break-word; + font-size: 12px; + line-height: 1.45; + color: var(--error-text); +} +.canvas-wrap { + background: var(--card); + border: 1px solid var(--line); + border-radius: 12px; + padding: 12px; + min-height: 420px; +} +.tabs { + display: flex; + gap: 8px; + margin-bottom: 10px; +} +.tab-btn { + width: auto; + min-width: 120px; + padding: 8px 12px; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--card); + color: var(--text); + font-weight: 700; + cursor: pointer; +} +.tab-btn.active { + background: var(--accent); + color: var(--accent-contrast); + border-color: var(--accent); +} +.tab-panel.hidden { + display: none; +} +.viewer .tab-panel { + flex: 1; + min-height: 0; +} +#plotArea { + width: 100%; + background: var(--plot-preview-bg); + border: 1px solid var(--line-soft); + border-radius: 10px; + padding: 8px; +} +#dataArea { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} +#plotArea img, +#plotArea svg, +#plotArea canvas { + max-width: 100%; + height: auto; +} +.table-wrap { + width: 100%; + overflow: auto; + max-height: none; + min-height: 0; + flex: 1; + background: var(--card); + border: 1px solid var(--line); + border-radius: 10px; +} +.table-wrap table { + border-collapse: collapse; + width: 100%; + font-size: 13px; +} +.table-wrap th, +.table-wrap td { + border: 1px solid var(--line-soft); + padding: 6px 8px; + white-space: nowrap; + text-align: left; +} +.table-wrap thead th { + position: sticky; + top: 0; + background: var(--table-header-bg); + z-index: 1; +} +.table-wrap tbody tr.skipped-row td { + background: var(--table-skipped-bg); + color: var(--table-skipped-text); +} +.table-wrap .row-number { + background: var(--table-rownum-bg); + color: var(--table-rownum-text); + text-align: right; + width: 68px; +} +.data-meta { + margin-bottom: 8px; + font-size: 13px; +} +.code-editor-wrap { + display: flex; + flex-direction: column; + gap: 10px; + height: 100%; + min-height: 0; +} +.code-toolbar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} +.code-toolbar .inline { + margin-right: auto; +} +.code-toolbar button { + width: auto; +} +#customPyCode { + width: 100%; + height: 100%; + min-height: 360px; + resize: vertical; + border: 1px solid var(--line); + border-radius: 10px; + padding: 12px; + background: var(--surface); + color: var(--text); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 13px; + line-height: 1.5; +} +.help-text { + margin-top: 6px; + font-size: 12px; + color: var(--text-muted); +} +.section-header { + margin: 14px 0 10px; + padding-top: 10px; + border-top: 1px solid var(--line); +} +.section-header:first-of-type { + margin-top: 4px; + padding-top: 0; + border-top: none; +} +.section-title { + font-size: 12px; + font-weight: 700; + letter-spacing: 0.06em; + color: var(--text-muted); +} +.series-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 8px; +} +.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; + font-size: 12px; +} +button.ghost-btn { + background: var(--button-ghost); +} +button.ghost-btn:hover { + background: var(--button-ghost-hover); +} +#applyCustomCodeBtn { + background: var(--button-secondary); +} +#applyCustomCodeBtn:hover { + background: var(--button-secondary-hover); +} +#resetCustomCodeBtn { + background: var(--button-reset); +} +#resetCustomCodeBtn:hover { + background: var(--button-reset-hover); +} +.series-list { + display: flex; + flex-direction: column; + gap: 10px; +} +.series-item { + border: 1px solid var(--line); + border-radius: 10px; + padding: 10px; + background: var(--series-item-bg); +} +.series-item-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 8px; +} +.series-item-title { + font-size: 13px; + font-weight: 700; +} +.series-item .group { + margin-bottom: 8px; +} +.series-item .group:last-child { + margin-bottom: 0; +} +.series-color-picker { + position: relative; +} +.series-color-trigger { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 8px 10px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--card); + color: var(--text); + font-weight: 600; +} +.series-color-swatch-inline { + width: 16px; + height: 16px; + border-radius: 4px; + border: 1px solid var(--swatch-border); + flex: 0 0 auto; +} +.series-color-panel { + position: absolute; + top: calc(100% + 6px); + left: 0; + z-index: 30; + min-width: 244px; + padding: 8px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--card); + box-shadow: var(--shadow-lg); +} +.series-color-panel.hidden { + display: none; +} +.series-color-grid { + display: grid; + grid-template-columns: repeat(6, 30px); + gap: 6px; + justify-content: start; +} +button.series-color-chip { + width: 30px; + height: 30px; + padding: 0; + border: 2px solid var(--chip-border); + border-radius: 6px; + color: transparent; +} +button.series-color-chip.active { + border-color: var(--chip-active); + box-shadow: 0 0 0 2px var(--chip-active-ring); +} +.series-color-custom-toggle { + margin-top: 8px; +} +.series-color-custom.hidden { + display: none; +} +.series-x-wrap { + display: none; +} +@media (max-width: 960px) { + body { + overflow: auto; + } + .layout { + grid-template-columns: 1fr; + height: auto; + } + .panel { + border-right: none; + border-bottom: 1px solid var(--line); + max-height: none; + overflow: visible; + overscroll-behavior: auto; + } + .viewer { + display: block; + overflow: visible; + } + #dataArea { + display: block; + height: auto; + } + .table-wrap { + max-height: 520px; + flex: none; + } +} diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md new file mode 100644 index 0000000..334c603 --- /dev/null +++ b/docs/PERFORMANCE.md @@ -0,0 +1,113 @@ +# パフォーマンス計測記録 + +計測スクリプト: `tests/perf/bench.py`(Playwright 同期 API。pytest では収集されない)。 +再実行: `uv run python tests/perf/bench.py --reps 5 --json tests/perf/out/result.json` +(`--headed` でヘッドあり実行、環境変数 `E2E_SERVE_DIR` で配信ディレクトリを切替。出力先 `tests/perf/out/` は git 管理外) + +## 変更前(ベースライン) + +### 計測条件 + +| 項目 | 内容 | +|---|---| +| 日付 | 2026-10-07 | +| 対象 | `index.html` は `795d2f8` 時点から未変更(計測スクリプトを含む HEAD は `26bfdb9`)。PyScript 2024.11.1 | +| マシン | Mac16,10 / Apple M4 / メモリ 24 GB | +| OS | macOS 27.0.1 | +| ブラウザ | Chromium 153.0.8010.12(Playwright、ヘッドレス) | +| ネットワーク | 実ネットワーク(CDN からのダウンロードを含む。初回は約37 MB 取得: pyscript.net / cdn.jsdelivr.net / PyPI など) | +| 配信 | ローカルの静的 HTTP サーバー(127.0.0.1) | +| 反復 | 各項目 N=5 | + +### 計測方法 + +- **コールド起動**: 反復ごとに新規ブラウザコンテキスト(HTTP キャッシュ空)で `goto` し、Python 側が `window.requestRender` を登録して操作可能になるまで。 +- **ウォーム起動**: 同一コンテキストで1回読み込んでキャッシュを温めた後、反復ごとに新規ページで再ナビゲーション(HTTP キャッシュ有効)。Python/Pyodide の初期化は毎回走るので、短縮されるのはダウンロード分のみ。 +- **描画**: 実行時に生成した 10,000 行(X 1列 + Y 5列)の CSV を使用(コミットしない)。系列カード5つ(X=x列、Y=y1〜y5)を設定。キャッシュを温めたコンテキストで反復ごとに新規ページ。 + - 初回: ファイル選択 → 自動読込 → 最初のプロット表示まで。 + - 設定変更: タイトル入力の `input` イベント発行から、`#plotArea` に新しい画像が表示されるまで(ページ内 `performance.now()`)。250ms のデバウンスを含む。 + - 保存: 保存ボタンのクリックからダウンロード開始まで(PNG)。 + +### 結果(ミリ秒) + +| 項目 | 中央値 | 最小 | 最大 | +|---|---:|---:|---:| +| コールド起動 | 3,424 | 3,411 | 6,003 | +| ウォーム起動 | 2,881 | 2,877 | 2,884 | +| 初回ファイル読込 → 最初のプロット | 417 | 405 | 422 | +| 設定変更 → プロット更新(デバウンス込み) | 413 | 401 | 422 | +| 同上(デバウンス 250ms 除く) | 163 | 151 | 172 | +| 保存(PNG、クリック → ダウンロード開始) | 206 | 201 | 214 | + +備考: コールド起動の最大値 6,003 は1回目の反復のみ(CDN 側/OS 側のキャッシュが冷えていたため)で、2回目以降は約3.4秒。 + +## PyScript 2026.7.3 移行直後 + +計測条件はベースラインと同じ(同一マシン・Chromium 153.0.8010.12・実ネットワーク・N=5)。 +対象コミット: `30aee0d`(PyScript 2026.7.3 / Pyodide 314.0.3 = Python 3.14.2、pandas 3.0.2、matplotlib 3.10.8、`pyscript.toml` 使用)。 +計測日: 2026-10-07。 + +| 項目 | 中央値 | 最小 | 最大 | ベースライン中央値 | 比 | +|---|---:|---:|---:|---:|---:| +| コールド起動 | 4,076 | 4,015 | 6,420 | 3,424 | +19% | +| ウォーム起動 | 3,623 | 3,573 | 3,726 | 2,881 | +26% | +| 初回ファイル読込 → 最初のプロット | 422 | 389 | 435 | 417 | +1% | +| 設定変更 → プロット更新(デバウンス込み) | 556 | 550 | 566 | 413 | +35% | +| 同上(デバウンス 250ms 除く) | 306 | 300 | 316 | 163 | +88% | +| 保存(PNG、クリック → ダウンロード開始) | 330 | 323 | 349 | 206 | +60% | + +備考: +- 起動・再描画・保存のいずれも悪化した。起動時間は要件(8章: F6 の現状値より悪くしない)を満たしていないため、後続フェーズでの改善対象(フォント取得の遅延、キャッシュ、描画の無駄の削減など)とする。 +- 再描画の増加は、matplotlib 3.5.2 → 3.10.8 と Python 3.12 → 3.14、pandas 2 → 3 の差によるとみられる(未切り分け)。 +- 起動時のコンソールエラー `Failed to load resource: net::ERR_INVALID_URL` は移行前の `` が原因だった。`` の中身が HTML としてパースされ、Python 文字列中の `plot` が実際の `` 要素になり、src が不正な URL として読み込まれていた。` - - - packages = ["pandas", "matplotlib", "openpyxl"] - + + + + + + + + + +
@@ -610,6 +49,8 @@

Matplotlib GUI

+
+
@@ -651,10 +92,10 @@

Matplotlib GUI

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

Matplotlib GUI

-
+