From 3a94e37e3adc76fed403b5a16cd7f894d2707d53 Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Sun, 21 Jun 2026 01:41:00 +0800 Subject: [PATCH 1/9] =?UTF-8?q?feat(chart-editor):=20=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E5=8C=96=E5=9B=BE=E8=A1=A8=E6=9C=AC=E5=9C=B0=E5=BE=AE=E8=B0=83?= =?UTF-8?q?=E4=B8=8E=E5=AE=9E=E6=97=B6=E6=B8=B2=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 借鉴 Sci-Data-Analyzer 的 chart_renderer 架构,为 nature-figure 新增 参数化绘图微调层,实现不经过 LLM 的本地 matplotlib 实时渲染。 整条链路三阶段: - 阶段一:移植 Python 绘图模板(line/bar/heatmap/dual_y)+ Rust preview_plot 命令,复用 uv 隔离 Python 环境在子进程渲染,返回 base64 图片 - 阶段二:ChartEditor 组件,左侧参数面板(配色/字号/线宽/图例/边框/序列) + 右侧防抖(400ms)实时预览 + SVG/PNG 导出 - 阶段三:修改 figure skill 导出 plot_spec/plot_data 参数契约,codex 生成 图后前端自动读取参数进入本地编辑器 同时新增 Vite alias mock 层,支持纯浏览器预览前端界面 --- .../static/fragments/backend/python.md | 52 ++ src-tauri/Cargo.lock | 2 + src-tauri/Cargo.toml | 3 +- .../chart_renderer/templates/bar_plot.py | 470 +++++++++++ .../chart_renderer/templates/dual_y_plot.py | 146 ++++ .../chart_renderer/templates/heatmap_plot.py | 101 +++ .../chart_renderer/templates/line_plot.py | 755 ++++++++++++++++++ src-tauri/src/lib.rs | 4 +- src-tauri/src/pyenv.rs | 2 +- src-tauri/src/renderer.rs | 164 ++++ src-tauri/tauri.conf.json | 3 + src/App.css | 49 ++ src/App.tsx | 63 +- src/api/plotApi.ts | 32 + src/components/ChartEditor.tsx | 262 ++++++ src/mock/data.ts | 244 ++++++ src/mock/dialog.ts | 38 + src/mock/fs.ts | 14 + src/mock/opener.ts | 15 + src/mock/tauri-core.ts | 216 +++++ src/types/plot.ts | 66 ++ vite.config.ts | 23 + 22 files changed, 2720 insertions(+), 4 deletions(-) create mode 100644 src-tauri/resources/chart_renderer/templates/bar_plot.py create mode 100644 src-tauri/resources/chart_renderer/templates/dual_y_plot.py create mode 100644 src-tauri/resources/chart_renderer/templates/heatmap_plot.py create mode 100644 src-tauri/resources/chart_renderer/templates/line_plot.py create mode 100644 src-tauri/src/renderer.rs create mode 100644 src/api/plotApi.ts create mode 100644 src/components/ChartEditor.tsx create mode 100644 src/mock/data.ts create mode 100644 src/mock/dialog.ts create mode 100644 src/mock/fs.ts create mode 100644 src/mock/opener.ts create mode 100644 src/mock/tauri-core.ts create mode 100644 src/types/plot.ts diff --git a/skills-bundled/nature-figure/static/fragments/backend/python.md b/skills-bundled/nature-figure/static/fragments/backend/python.md index 5395b8a..214b417 100644 --- a/skills-bundled/nature-figure/static/fragments/backend/python.md +++ b/skills-bundled/nature-figure/static/fragments/backend/python.md @@ -28,6 +28,58 @@ def save_pub_py(fig, filename, dpi=600): Use `text.usetex = True` only when LaTeX is installed and math-rich labels are required. +## Export plot parameters (mandatory) + +Every figure script must also emit `plot_spec.json` and `plot_data.json` alongside the rendered image. These two files let the Nature App chart editor adjust visual details (colors, fonts, line widths, axis ranges, legend, spines) in real time via local matplotlib re-rendering — no LLM round-trip needed. + +```python +import json + +def export_plot_params(spec: dict, data: dict, prefix: str = ""): + """Export structured parameters for the Nature App chart editor.""" + with open(f"{prefix}plot_spec.json", "w", encoding="utf-8") as f: + json.dump(spec, f, ensure_ascii=False, indent=2) + with open(f"{prefix}plot_data.json", "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) +``` + +Call `export_plot_params(spec, data)` at the end of the script, right before or after `save_pub_py`. The `spec` and `data` dicts must follow this schema (camelCase keys are optional in the Python dict; the editor reads snake_case as shown): + +**`plot_spec` — all visual parameters that can be fine-tuned:** + +```json +{ + "schema_version": 1, + "chart_type": "line", + "title": "Figure title", + "x_label": "Time", "y_label": "Absorbance", + "x_unit": "s", "y_unit": "a.u.", + "style": { + "figure_size": [8, 5], "dpi": 150, + "font_family": "serif", "font_size": 12, + "grid": true, "grid_alpha": 0.3, + "legend": {"enabled": true, "location": "best"}, + "spines": {"enabled": true, "width": 1.2, "color": "#333333"} + } +} +``` + +**`plot_data` — the actual plotted series:** + +```json +{ + "schema_version": 1, + "series": [ + {"name": "Sample A", "x": [0, 1, 2, 3], "y": [0.1, 0.5, 0.8, 0.3], + "color": "#1a1a1a", "line_width": 2.0, "visible": true}, + {"name": "Sample B", "x": [0, 1, 2, 3], "y": [0.2, 0.6, 0.9, 0.4], + "color": "#c73e3a", "line_width": 1.5, "visible": true} + ] +} +``` + +Set `chart_type` to `"line"`, `"bar"`, `"heatmap"`, or `"line_dual_y"` so the editor loads the correct render template. Extract `x` and `y` arrays from the same data you plotted. If a panel has many series, include only the major ones (the editor is for style fine-tuning, not data exploration). For multi-panel figures, export the parameters of the primary panel. + ## Going deeper - `references/api.md` — Python PALETTE, helper function signatures, validation rules. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7941786..8f6ada6 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2027,6 +2027,7 @@ dependencies = [ name = "nature-app" version = "0.1.0" dependencies = [ + "base64 0.22.1", "dashmap", "serde", "serde_json", @@ -2036,6 +2037,7 @@ dependencies = [ "tauri-plugin-dialog", "tauri-plugin-fs", "tauri-plugin-opener", + "tempfile", "uuid", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 543786d..6a31f26 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -27,4 +27,5 @@ serde_json = "1" serde_yaml = "0.9" uuid = { version = "1", features = ["v4"] } dashmap = "6" - +base64 = "0.22" +tempfile = "3" diff --git a/src-tauri/resources/chart_renderer/templates/bar_plot.py b/src-tauri/resources/chart_renderer/templates/bar_plot.py new file mode 100644 index 0000000..3c3221f --- /dev/null +++ b/src-tauri/resources/chart_renderer/templates/bar_plot.py @@ -0,0 +1,470 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations + +import json +from pathlib import Path + +import matplotlib + +# Force headless backend. The parent process also sets MPLBACKEND=Agg. +matplotlib.use("Agg") +import matplotlib.pyplot as plt # noqa: E402 +import numpy as np # noqa: E402 +import matplotlib.ticker as mticker # noqa: E402 +import math # noqa: E402 + + +def _label_with_unit(label: str, unit: str) -> str: + label = (label or "").strip() + unit = (unit or "").strip() + if not unit: + return label + if unit.startswith("(") and unit.endswith(")"): + return f"{label} {unit}".strip() + if not label: + return unit + return f"{label} ({unit})" + + +def _shorten_tick_label(value: object, *, max_chars: int = 12) -> str: + text = str(value or "") + max_chars = int(max(1, max_chars)) + if len(text) <= max_chars: + return text + if max_chars <= 3: + return text[:max_chars] + return text[: max_chars - 3] + "..." + + +def main() -> int: + base = Path(__file__).resolve().parent + spec = json.loads((base / "plot_spec.json").read_text(encoding="utf-8")) + data = json.loads((base / "plot_data.json").read_text(encoding="utf-8")) + + style = spec.get("style") or {} + fig_size = style.get("figure_size") or [10, 6] + dpi = int(style.get("dpi") or 150) + font_family = style.get("font_family") or "serif" + font_size = int(style.get("font_size") or 12) + grid = bool(style.get("grid") if style.get("grid") is not None else True) + grid_alpha = float(style.get("grid_alpha") or 0.3) + + text_cfg = style.get("text") or {} + title_text = text_cfg.get("title") if isinstance(text_cfg, dict) else {} + axis_text = text_cfg.get("axis_label") if isinstance(text_cfg, dict) else {} + + legend_cfg = style.get("legend") or {} + legend_enabled = bool(legend_cfg.get("enabled") if legend_cfg.get("enabled") is not None else True) + legend_location = str(legend_cfg.get("location") or "best").strip() or "best" + allowed_locs = { + "best", + "upper right", + "upper left", + "lower right", + "lower left", + "center right", + "center left", + "upper center", + "lower center", + "center", + } + if legend_location not in allowed_locs: + legend_location = "best" + + ticks_cfg = style.get("ticks") or {} + ticks_x = ticks_cfg.get("x") if isinstance(ticks_cfg, dict) else {} + ticks_y = ticks_cfg.get("y") if isinstance(ticks_cfg, dict) else {} + + axis_range_cfg = style.get("axis_range") if isinstance(style, dict) else None + + spines_cfg = style.get("spines") or {} + spines_enabled = bool(spines_cfg.get("enabled") if isinstance(spines_cfg, dict) and spines_cfg.get("enabled") is not None else True) + spines_width = None + spines_color = None + if isinstance(spines_cfg, dict): + if spines_cfg.get("width") is not None: + try: + spines_width = float(spines_cfg.get("width")) + except Exception: + spines_width = None + if spines_cfg.get("color") is not None: + spines_color = str(spines_cfg.get("color") or "").strip() or None + + plt.rcParams["font.family"] = font_family + plt.rcParams["font.size"] = font_size + plt.rcParams["font.weight"] = "bold" + plt.rcParams["axes.labelweight"] = "bold" + plt.rcParams["axes.titleweight"] = "bold" + plt.rcParams["svg.fonttype"] = "none" + + fmt = (spec.get("output_format") or "svg").lower() + out_path = base / f"out.{fmt}" + + fig, ax = plt.subplots(figsize=(float(fig_size[0]), float(fig_size[1])), dpi=dpi) + + series_list = (data.get("series") or []) + if not series_list: + raise RuntimeError("plot_data.series is empty") + + series_list = [s for s in series_list if s.get("visible") is not False] + if not series_list: + raise RuntimeError("no visible series to plot") + + # Single-series (default) or grouped bar (multi-series). + all_categories = list(series_list[0].get("x") or []) + if not all_categories: + raise RuntimeError("bar categories is empty") + + category_filter = style.get("category_filter") if isinstance(style, dict) else None + category_filter_enabled = bool(category_filter.get("enabled")) if isinstance(category_filter, dict) else False + include_raw = category_filter.get("include") if isinstance(category_filter, dict) else None + include_list = [str(v or "").strip() for v in include_raw] if isinstance(include_raw, list) else [] + include_set = {v for v in include_list if v} + keep_indices: list[int] = list(range(len(all_categories))) + if category_filter_enabled and include_set: + keep_indices = [idx for idx, c in enumerate(all_categories) if str(c or "").strip() in include_set] + if not keep_indices: + raise RuntimeError("category filter removed all categories") + categories = [all_categories[idx] for idx in keep_indices] + + categories_display = [_shorten_tick_label(v, max_chars=12) for v in categories] + value_scale_mode_raw = str(style.get("value_scale_mode") or "absolute").strip().lower() + value_scale_mode = "percentage" if value_scale_mode_raw == "percentage" else "absolute" + value_scale_scope_raw = str(style.get("value_scale_scope") or "global").strip().lower() + value_scale_scope = "per_category" if value_scale_scope_raw == "per_category" else "global" + + n_series = len(series_list) + if n_series == 1: + values = series_list[0].get("y") or [] + if len(values) != len(all_categories): + raise RuntimeError("x/y length mismatch") + values = [float(values[idx]) for idx in keep_indices] + if len(categories) != len(values): + raise RuntimeError("x/y length mismatch") + if value_scale_mode == "percentage" and values: + max_val = max(values) + if math.isfinite(max_val) and max_val != 0: + values = [v / max_val for v in values] + color = series_list[0].get("color") or "#1a1a1a" + base_x = np.arange(len(categories), dtype=float) + ax.bar(base_x, values, color=color) + ax.set_xticks(base_x) + ax.set_xticklabels(categories_display) + else: + base_x = np.arange(len(categories), dtype=float) + bar_width = 0.8 / float(n_series) + grouped_values = [] + for idx, series in enumerate(series_list): + sx = series.get("x") or [] + sy = series.get("y") or [] + if list(sx) != list(all_categories): + raise RuntimeError("grouped bar requires aligned categories across series") + if len(sy) != len(all_categories): + raise RuntimeError("x/y length mismatch") + sy = [float(sy[i]) for i in keep_indices] + if len(sy) != len(categories): + raise RuntimeError("x/y length mismatch") + color = series.get("color") or "#1a1a1a" + name = str(series.get("name") or f"series_{idx + 1}") + offset = (float(idx) - (float(n_series) - 1.0) / 2.0) * bar_width + grouped_values.append((sy, color, name, offset)) + + if value_scale_mode == "percentage": + if value_scale_scope == "per_category" and grouped_values: + category_max: list[float | None] = [] + for cat_idx in range(len(categories)): + col_vals = [item[0][cat_idx] for item in grouped_values] + col_max = max(col_vals) if col_vals else 0.0 + if math.isfinite(col_max) and col_max != 0: + category_max.append(col_max) + else: + category_max.append(None) + grouped_values = [ + ( + [ + (v / category_max[idx]) if category_max[idx] is not None else v + for idx, v in enumerate(sy) + ], + color, + name, + offset, + ) + for sy, color, name, offset in grouped_values + ] + else: + all_values = [v for item in grouped_values for v in item[0]] + if all_values: + max_val = max(all_values) + if math.isfinite(max_val) and max_val != 0: + grouped_values = [ + ([v / max_val for v in sy], color, name, offset) + for sy, color, name, offset in grouped_values + ] + + for sy, color, name, offset in grouped_values: + ax.bar(base_x + offset, sy, width=bar_width, color=color, label=name) + + ax.set_xticks(base_x) + ax.set_xticklabels(categories_display) + if legend_enabled: + legend = ax.legend(loc=legend_location, fontsize=max(8, font_size - 2)) + for text in legend.get_texts(): + text.set_fontweight("bold") + + # Apply spines style. + for spine in ax.spines.values(): + spine.set_visible(bool(spines_enabled)) + if spines_enabled: + if spines_width is not None: + spine.set_linewidth(float(spines_width)) + if spines_color: + spine.set_color(str(spines_color)) + + # Tick label styling (font size / rotation / numeric formatting). + try: + x_font_size = int(ticks_x.get("font_size")) if isinstance(ticks_x, dict) and ticks_x.get("font_size") is not None else None + except Exception: + x_font_size = None + try: + y_font_size = int(ticks_y.get("font_size")) if isinstance(ticks_y, dict) and ticks_y.get("font_size") is not None else None + except Exception: + y_font_size = None + try: + x_top_font_size = int(ticks_x.get("top_font_size")) if isinstance(ticks_x, dict) and ticks_x.get("top_font_size") is not None else None + except Exception: + x_top_font_size = None + try: + y_right_font_size = int(ticks_y.get("right_font_size")) if isinstance(ticks_y, dict) and ticks_y.get("right_font_size") is not None else None + except Exception: + y_right_font_size = None + if x_font_size is not None: + ax.tick_params(axis="x", labelsize=max(1, x_font_size)) + if y_font_size is not None: + ax.tick_params(axis="y", labelsize=max(1, y_font_size)) + + x_show_labels = bool(ticks_x.get("show_labels") if isinstance(ticks_x, dict) and ticks_x.get("show_labels") is not None else True) + x_show_ticks = bool(ticks_x.get("show_ticks") if isinstance(ticks_x, dict) and ticks_x.get("show_ticks") is not None else True) + y_show_labels = bool(ticks_y.get("show_labels") if isinstance(ticks_y, dict) and ticks_y.get("show_labels") is not None else True) + y_show_ticks = bool(ticks_y.get("show_ticks") if isinstance(ticks_y, dict) and ticks_y.get("show_ticks") is not None else True) + x_show_labels_bottom = bool(ticks_x.get("show_labels_bottom") if isinstance(ticks_x, dict) and ticks_x.get("show_labels_bottom") is not None else x_show_labels) + x_show_labels_top = bool(ticks_x.get("show_labels_top") if isinstance(ticks_x, dict) and ticks_x.get("show_labels_top") is not None else False) + x_show_ticks_bottom = bool(ticks_x.get("show_ticks_bottom") if isinstance(ticks_x, dict) and ticks_x.get("show_ticks_bottom") is not None else x_show_ticks) + x_show_ticks_top = bool(ticks_x.get("show_ticks_top") if isinstance(ticks_x, dict) and ticks_x.get("show_ticks_top") is not None else False) + y_show_labels_left = bool(ticks_y.get("show_labels_left") if isinstance(ticks_y, dict) and ticks_y.get("show_labels_left") is not None else y_show_labels) + y_show_labels_right = bool(ticks_y.get("show_labels_right") if isinstance(ticks_y, dict) and ticks_y.get("show_labels_right") is not None else False) + y_show_ticks_left = bool(ticks_y.get("show_ticks_left") if isinstance(ticks_y, dict) and ticks_y.get("show_ticks_left") is not None else y_show_ticks) + y_show_ticks_right = bool(ticks_y.get("show_ticks_right") if isinstance(ticks_y, dict) and ticks_y.get("show_ticks_right") is not None else False) + ax.tick_params( + axis="x", + labelbottom=x_show_labels_bottom, + labeltop=x_show_labels_top, + bottom=x_show_ticks_bottom, + top=x_show_ticks_top, + ) + ax.tick_params( + axis="y", + labelleft=y_show_labels_left, + labelright=y_show_labels_right, + left=y_show_ticks_left, + right=y_show_ticks_right, + ) + + x_tick_font_family = str(ticks_x.get("font_family") or "").strip() if isinstance(ticks_x, dict) else "" + y_tick_font_family = str(ticks_y.get("font_family") or "").strip() if isinstance(ticks_y, dict) else "" + x_top_tick_font_family = str(ticks_x.get("top_font_family") or x_tick_font_family).strip() if isinstance(ticks_x, dict) else "" + y_right_tick_font_family = str(ticks_y.get("right_font_family") or y_tick_font_family).strip() if isinstance(ticks_y, dict) else "" + if x_tick_font_family.lower() in {"", "auto", "inherit"}: + x_tick_font_family = "" + if y_tick_font_family.lower() in {"", "auto", "inherit"}: + y_tick_font_family = "" + if x_top_tick_font_family.lower() in {"", "auto", "inherit"}: + x_top_tick_font_family = "" + if y_right_tick_font_family.lower() in {"", "auto", "inherit"}: + y_right_tick_font_family = "" + + x_label_color = str(ticks_x.get("label_color") or "").strip() if isinstance(ticks_x, dict) else "" + y_label_color = str(ticks_y.get("label_color") or "").strip() if isinstance(ticks_y, dict) else "" + x_top_label_color = str(ticks_x.get("top_label_color") or x_label_color).strip() if isinstance(ticks_x, dict) else "" + y_right_label_color = str(ticks_y.get("right_label_color") or y_label_color).strip() if isinstance(ticks_y, dict) else "" + + x_tick_color = str(ticks_x.get("tick_color") or "").strip() if isinstance(ticks_x, dict) else "" + y_tick_color = str(ticks_y.get("tick_color") or "").strip() if isinstance(ticks_y, dict) else "" + x_tick_width = None + y_tick_width = None + x_tick_length = None + y_tick_length = None + if isinstance(ticks_x, dict) and ticks_x.get("tick_width") is not None: + try: + x_tick_width = float(ticks_x.get("tick_width")) + except Exception: + x_tick_width = None + if isinstance(ticks_y, dict) and ticks_y.get("tick_width") is not None: + try: + y_tick_width = float(ticks_y.get("tick_width")) + except Exception: + y_tick_width = None + if isinstance(ticks_x, dict) and ticks_x.get("tick_length") is not None: + try: + x_tick_length = float(ticks_x.get("tick_length")) + except Exception: + x_tick_length = None + if isinstance(ticks_y, dict) and ticks_y.get("tick_length") is not None: + try: + y_tick_length = float(ticks_y.get("tick_length")) + except Exception: + y_tick_length = None + if x_tick_color or x_tick_width is not None or x_tick_length is not None: + kwargs = {} + if x_tick_color: + kwargs["color"] = x_tick_color + if x_tick_width is not None: + kwargs["width"] = max(0.1, float(x_tick_width)) + if x_tick_length is not None: + kwargs["length"] = max(0.0, float(x_tick_length)) + ax.tick_params(axis="x", **kwargs) + if y_tick_color or y_tick_width is not None or y_tick_length is not None: + kwargs = {} + if y_tick_color: + kwargs["color"] = y_tick_color + if y_tick_width is not None: + kwargs["width"] = max(0.1, float(y_tick_width)) + if y_tick_length is not None: + kwargs["length"] = max(0.0, float(y_tick_length)) + ax.tick_params(axis="y", **kwargs) + + x_rotation = None + if isinstance(ticks_x, dict) and ticks_x.get("rotation") is not None: + try: + x_rotation = float(ticks_x.get("rotation")) + except Exception: + x_rotation = None + if x_rotation is not None: + for tick in ax.get_xticklabels(): + tick.set_rotation(x_rotation) + tick.set_ha("right" if x_rotation and abs(x_rotation) >= 15 else "center") + + if isinstance(ticks_y, dict): + y_fmt = str(ticks_y.get("format") or "auto").strip().lower() + if y_fmt in {"plain", "scientific"}: + formatter = mticker.ScalarFormatter(useMathText=False) + if y_fmt == "scientific": + formatter.set_scientific(True) + formatter.set_powerlimits((-3, 3)) + else: + formatter.set_scientific(False) + ax.yaxis.set_major_formatter(formatter) + + if len(categories) > 12: + for tick in ax.get_xticklabels(): + tick.set_rotation(45) + tick.set_ha("right") + + for tick in ax.xaxis.get_major_ticks(): + if x_font_size is not None: + tick.label1.set_fontsize(max(1, x_font_size)) + if x_top_font_size is not None: + tick.label2.set_fontsize(max(1, x_top_font_size)) + if x_tick_font_family: + tick.label1.set_fontfamily(x_tick_font_family) + if x_top_tick_font_family: + tick.label2.set_fontfamily(x_top_tick_font_family) + if x_label_color: + tick.label1.set_color(x_label_color) + if x_top_label_color: + tick.label2.set_color(x_top_label_color) + for tick in ax.yaxis.get_major_ticks(): + if y_font_size is not None: + tick.label1.set_fontsize(max(1, y_font_size)) + if y_right_font_size is not None: + tick.label2.set_fontsize(max(1, y_right_font_size)) + if y_tick_font_family: + tick.label1.set_fontfamily(y_tick_font_family) + if y_right_tick_font_family: + tick.label2.set_fontfamily(y_right_tick_font_family) + if y_label_color: + tick.label1.set_color(y_label_color) + if y_right_label_color: + tick.label2.set_color(y_right_label_color) + + # Axis ranges (optional): only y-axis makes sense for category bars. + if isinstance(axis_range_cfg, dict): + ry = axis_range_cfg.get("y") if isinstance(axis_range_cfg.get("y"), dict) else {} + try: + y_min = float(ry.get("min")) if ry.get("min") is not None else None + except Exception: + y_min = None + try: + y_max = float(ry.get("max")) if ry.get("max") is not None else None + except Exception: + y_max = None + if y_min is not None and not math.isfinite(y_min): + y_min = None + if y_max is not None and not math.isfinite(y_max): + y_max = None + if y_min is not None or y_max is not None: + ax.set_ylim(bottom=y_min, top=y_max) + + axis_font_family = str(axis_text.get("font_family") or "").strip() if isinstance(axis_text, dict) else "" + if axis_font_family.lower() in {"", "auto", "inherit"}: + axis_font_family = "" + axis_font_size = None + axis_color = None + if isinstance(axis_text, dict) and axis_text.get("font_size") is not None: + try: + axis_font_size = int(axis_text.get("font_size")) + except Exception: + axis_font_size = None + if isinstance(axis_text, dict) and axis_text.get("color") is not None: + axis_color = str(axis_text.get("color") or "").strip() or None + + axis_kwargs = {"fontweight": "bold"} + if axis_font_family: + axis_kwargs["fontfamily"] = axis_font_family + if axis_font_size is not None: + axis_kwargs["fontsize"] = max(1, axis_font_size) + if axis_color: + axis_kwargs["color"] = axis_color + + ax.set_xlabel(_label_with_unit(spec.get("x_label") or "Category", spec.get("x_unit") or ""), **axis_kwargs) + ax.set_ylabel(_label_with_unit(spec.get("y_label") or "Value", spec.get("y_unit") or ""), **axis_kwargs) + + title = spec.get("title") or "" + if title: + title_font_family = str(title_text.get("font_family") or "").strip() if isinstance(title_text, dict) else "" + if title_font_family.lower() in {"", "auto", "inherit"}: + title_font_family = "" + title_font_size = None + title_color = None + if isinstance(title_text, dict) and title_text.get("font_size") is not None: + try: + title_font_size = int(title_text.get("font_size")) + except Exception: + title_font_size = None + if isinstance(title_text, dict) and title_text.get("color") is not None: + title_color = str(title_text.get("color") or "").strip() or None + + title_kwargs = {"fontweight": "bold"} + if title_font_family: + title_kwargs["fontfamily"] = title_font_family + if title_font_size is not None: + title_kwargs["fontsize"] = max(1, title_font_size) + if title_color: + title_kwargs["color"] = title_color + ax.set_title(title, **title_kwargs) + + if grid: + ax.grid(True, axis="y", alpha=grid_alpha) + + for tick in list(ax.get_xticklabels()) + list(ax.get_yticklabels()): + tick.set_fontweight("bold") + + fig.tight_layout() + fig.savefig(out_path, format=fmt, bbox_inches="tight", facecolor="white") + if bool(spec.get("analysis_png_output")) and fmt != "png": + fig.savefig(base / "out.png", format="png", bbox_inches="tight", facecolor="white") + plt.close(fig) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src-tauri/resources/chart_renderer/templates/dual_y_plot.py b/src-tauri/resources/chart_renderer/templates/dual_y_plot.py new file mode 100644 index 0000000..63173d5 --- /dev/null +++ b/src-tauri/resources/chart_renderer/templates/dual_y_plot.py @@ -0,0 +1,146 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations + +import json +from pathlib import Path +from datetime import datetime + +import matplotlib + +# Force headless backend. The parent process also sets MPLBACKEND=Agg. +matplotlib.use("Agg") +import matplotlib.pyplot as plt # noqa: E402 +import matplotlib.dates as mdates # noqa: E402 + + +def _label_with_unit(label: str, unit: str) -> str: + label = (label or "").strip() + unit = (unit or "").strip() + if not unit: + return label + if unit.startswith("(") and unit.endswith(")"): + return f"{label} {unit}".strip() + if not label: + return unit + return f"{label} ({unit})" + + +def _merge_legend(ax_left, ax_right): + # Deterministic merge order: left-axis series first, then right-axis series. + handles_left, labels_left = ax_left.get_legend_handles_labels() + handles_right, labels_right = ax_right.get_legend_handles_labels() + handles = list(handles_left) + list(handles_right) + labels = list(labels_left) + list(labels_right) + if not handles: + return None + return handles, labels + + +def main() -> int: + base = Path(__file__).resolve().parent + spec = json.loads((base / "plot_spec.json").read_text(encoding="utf-8")) + data = json.loads((base / "plot_data.json").read_text(encoding="utf-8")) + + style = spec.get("style") or {} + fig_size = style.get("figure_size") or [10, 6] + dpi = int(style.get("dpi") or 150) + font_family = style.get("font_family") or "serif" + font_size = int(style.get("font_size") or 12) + grid = bool(style.get("grid") if style.get("grid") is not None else True) + grid_alpha = float(style.get("grid_alpha") or 0.3) + + plt.rcParams["font.family"] = font_family + plt.rcParams["font.size"] = font_size + plt.rcParams["font.weight"] = "bold" + plt.rcParams["axes.labelweight"] = "bold" + plt.rcParams["axes.titleweight"] = "bold" + plt.rcParams["svg.fonttype"] = "none" + + fmt = (spec.get("output_format") or "svg").lower() + out_path = base / f"out.{fmt}" + + fig, ax = plt.subplots(figsize=(float(fig_size[0]), float(fig_size[1])), dpi=dpi) + ax2 = ax.twinx() + + scales = spec.get("scales") or {} + x_scale = (scales.get("x") or "linear").lower() + series_axis = scales.get("series_axis") or {} + y2_label = scales.get("y2_label") or "Y2" + y2_unit = scales.get("y2_unit") or "" + + series_list = data.get("series") or [] + if not series_list: + raise RuntimeError("plot_data.series is empty") + + datetime_plotted = False + for idx, series in enumerate(series_list): + x = series.get("x") or [] + y = series.get("y") or [] + name = str(series.get("name") or f"series_{idx + 1}") + axis = str(series_axis.get(name) or "y").lower() + color = series.get("color") or "#1a1a1a" + lw = float(series.get("line_width") or style.get("line_width") or 1.5) + + if x_scale == "datetime": + parsed: list[datetime] = [] + ok = True + for v in x: + if not isinstance(v, str) or not v: + ok = False + break + try: + parsed.append(datetime.fromisoformat(v)) + except Exception: + ok = False + break + if ok: + x = parsed + datetime_plotted = True + + target_ax = ax2 if axis in {"y2", "right"} else ax + target_ax.plot(x, y, linewidth=lw, color=color, label=name) + + if datetime_plotted: + locator = mdates.AutoDateLocator() + ax.xaxis.set_major_locator(locator) + ax.xaxis.set_major_formatter(mdates.ConciseDateFormatter(locator)) + fig.autofmt_xdate() + + # Labels and title. + ax.set_xlabel(_label_with_unit(spec.get("x_label") or "X", spec.get("x_unit") or "")) + ax.set_ylabel(_label_with_unit(spec.get("y_label") or "Y", spec.get("y_unit") or "")) + ax2.set_ylabel(_label_with_unit(str(y2_label), str(y2_unit))) + ax.xaxis.label.set_fontweight("bold") + ax.yaxis.label.set_fontweight("bold") + ax2.yaxis.label.set_fontweight("bold") + + title = spec.get("title") or "" + if title: + ax.set_title(title, fontweight="bold") + + if grid: + ax.grid(True, alpha=grid_alpha) + + merged = _merge_legend(ax, ax2) + if merged is not None: + handles, labels = merged + if len(handles) > 1: + legend = ax.legend(handles, labels, loc="best", fontsize=max(8, font_size - 2)) + for text in legend.get_texts(): + text.set_fontweight("bold") + + for tick in list(ax.get_xticklabels()) + list(ax.get_yticklabels()): + tick.set_fontweight("bold") + for tick in list(ax2.get_yticklabels()): + tick.set_fontweight("bold") + + fig.tight_layout() + fig.savefig(out_path, format=fmt, bbox_inches="tight", facecolor="white") + if bool(spec.get("analysis_png_output")) and fmt != "png": + fig.savefig(base / "out.png", format="png", bbox_inches="tight", facecolor="white") + plt.close(fig) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src-tauri/resources/chart_renderer/templates/heatmap_plot.py b/src-tauri/resources/chart_renderer/templates/heatmap_plot.py new file mode 100644 index 0000000..a1e631d --- /dev/null +++ b/src-tauri/resources/chart_renderer/templates/heatmap_plot.py @@ -0,0 +1,101 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations + +import base64 +import io +import json +from pathlib import Path + +import matplotlib + +# Force headless backend. The parent process also sets MPLBACKEND=Agg. +matplotlib.use("Agg") +import matplotlib.pyplot as plt # noqa: E402 + + +def _label_with_unit(label: str, unit: str) -> str: + label = (label or "").strip() + unit = (unit or "").strip() + if not unit: + return label + if unit.startswith("(") and unit.endswith(")"): + return f"{label} {unit}".strip() + if not label: + return unit + return f"{label} ({unit})" + + +def main() -> int: + base = Path(__file__).resolve().parent + spec = json.loads((base / "plot_spec.json").read_text(encoding="utf-8")) + data = json.loads((base / "plot_data.json").read_text(encoding="utf-8")) + + style = spec.get("style") or {} + fig_size = style.get("figure_size") or [10, 6] + dpi = int(style.get("dpi") or 150) + font_family = style.get("font_family") or "serif" + font_size = int(style.get("font_size") or 12) + + plt.rcParams["font.family"] = font_family + plt.rcParams["font.size"] = font_size + plt.rcParams["font.weight"] = "bold" + plt.rcParams["axes.labelweight"] = "bold" + plt.rcParams["axes.titleweight"] = "bold" + plt.rcParams["svg.fonttype"] = "none" + + fmt = (spec.get("output_format") or "svg").lower() + out_path = base / f"out.{fmt}" + + grid = (data.get("grid") or {}) if isinstance(data, dict) else {} + mode = str(grid.get("mode") or "heatmap").strip().lower() + + fig, ax = plt.subplots(figsize=(float(fig_size[0]), float(fig_size[1])), dpi=dpi) + + png_b64 = grid.get("png_base64") + if isinstance(png_b64, str) and png_b64.strip(): + try: + from PIL import Image # pillow is part of backend deps + except Exception as exc: # pragma: no cover + raise RuntimeError(f"Pillow is required for image heatmap plotting: {exc}") from exc + + raw = base64.b64decode(png_b64.encode("utf-8")) + img = Image.open(io.BytesIO(raw)) + ax.imshow(img) + ax.axis("off") + else: + values = grid.get("values") or [] + cmap = grid.get("cmap") or "viridis" + origin = grid.get("origin") or "lower" + im = ax.imshow(values, cmap=cmap, origin=origin, interpolation="nearest", aspect="auto") + + x_label = _label_with_unit(spec.get("x_label") or "X", spec.get("x_unit") or "") + y_label = _label_with_unit(spec.get("y_label") or "Y", spec.get("y_unit") or "") + ax.set_xlabel(x_label) + ax.set_ylabel(y_label) + ax.xaxis.label.set_fontweight("bold") + ax.yaxis.label.set_fontweight("bold") + + if mode == "heatmap": + cbar = fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04) + z_label = str(grid.get("z_label") or "").strip() + if z_label: + cbar.set_label(z_label) + cbar.ax.yaxis.label.set_fontweight("bold") + + title = spec.get("title") or "" + if title: + ax.set_title(title, fontweight="bold") + + for tick in list(ax.get_xticklabels()) + list(ax.get_yticklabels()): + tick.set_fontweight("bold") + + fig.tight_layout() + fig.savefig(out_path, format=fmt, bbox_inches="tight", facecolor="white") + if bool(spec.get("analysis_png_output")) and fmt != "png": + fig.savefig(base / "out.png", format="png", bbox_inches="tight", facecolor="white") + plt.close(fig) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src-tauri/resources/chart_renderer/templates/line_plot.py b/src-tauri/resources/chart_renderer/templates/line_plot.py new file mode 100644 index 0000000..6df05c3 --- /dev/null +++ b/src-tauri/resources/chart_renderer/templates/line_plot.py @@ -0,0 +1,755 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations + +import json +from pathlib import Path +import sys +import math +from datetime import datetime + +import matplotlib + +# Force headless backend. The parent process also sets MPLBACKEND=Agg. +matplotlib.use("Agg") +import matplotlib.pyplot as plt # noqa: E402 +import matplotlib.dates as mdates # noqa: E402 +import matplotlib.ticker as mticker # noqa: E402 +import numpy as np # noqa: E402 + + +def _label_with_unit(label: str, unit: str) -> str: + label = (label or "").strip() + unit = (unit or "").strip() + if not unit: + return label + # Avoid double parentheses when user already provides "(unit)". + if unit.startswith("(") and unit.endswith(")"): + return f"{label} {unit}".strip() + if not label: + return unit + return f"{label} ({unit})" + + +def _as_finite_float(value: object) -> float | None: + if value is None: + return None + try: + v = float(value) + except Exception: + return None + if not math.isfinite(v): + return None + return float(v) + + +def _expand_xlim_for_inline_labels( + *, + x_left: float, + x_right: float, + label_positions: list[float], + pad: float, +) -> tuple[float, float] | None: + if not label_positions: + return None + if not (math.isfinite(x_left) and math.isfinite(x_right)): + return None + if not math.isfinite(pad) or pad < 0: + return None + pos_min = min(label_positions) + pos_max = max(label_positions) + if not (math.isfinite(pos_min) and math.isfinite(pos_max)): + return None + + x_lo = min(float(x_left), float(x_right)) + x_hi = max(float(x_left), float(x_right)) + new_lo = min(float(x_lo), float(pos_min - pad)) + new_hi = max(float(x_hi), float(pos_max + pad)) + if not (math.isfinite(new_lo) and math.isfinite(new_hi) and new_hi > new_lo): + return None + + if float(x_left) > float(x_right): + return float(new_hi), float(new_lo) + return float(new_lo), float(new_hi) + + +def main() -> int: + base = Path(__file__).resolve().parent + spec = json.loads((base / "plot_spec.json").read_text(encoding="utf-8")) + data = json.loads((base / "plot_data.json").read_text(encoding="utf-8")) + + style = spec.get("style") or {} + fig_size = style.get("figure_size") or [10, 6] + dpi = int(style.get("dpi") or 150) + font_family = style.get("font_family") or "serif" + font_size = int(style.get("font_size") or 12) + grid = bool(style.get("grid") if style.get("grid") is not None else True) + grid_alpha = float(style.get("grid_alpha") or 0.3) + + text_cfg = style.get("text") or {} + title_text = text_cfg.get("title") if isinstance(text_cfg, dict) else {} + axis_text = text_cfg.get("axis_label") if isinstance(text_cfg, dict) else {} + + legend_cfg = style.get("legend") or {} + legend_enabled = bool(legend_cfg.get("enabled") if legend_cfg.get("enabled") is not None else True) + legend_location = str(legend_cfg.get("location") or "best").strip() or "best" + allowed_locs = { + "best", + "upper right", + "upper left", + "lower right", + "lower left", + "center right", + "center left", + "upper center", + "lower center", + "center", + } + if legend_location not in allowed_locs: + legend_location = "best" + + ticks_cfg = style.get("ticks") or {} + ticks_x = ticks_cfg.get("x") if isinstance(ticks_cfg, dict) else {} + ticks_y = ticks_cfg.get("y") if isinstance(ticks_cfg, dict) else {} + + axis_range_cfg = style.get("axis_range") if isinstance(style, dict) else None + series_offset_cfg = style.get("series_offset") if isinstance(style, dict) else None + + spines_cfg = style.get("spines") or {} + spines_enabled = bool(spines_cfg.get("enabled") if isinstance(spines_cfg, dict) and spines_cfg.get("enabled") is not None else True) + spines_width = None + spines_color = None + if isinstance(spines_cfg, dict): + if spines_cfg.get("width") is not None: + try: + spines_width = float(spines_cfg.get("width")) + except Exception: + spines_width = None + if spines_cfg.get("color") is not None: + spines_color = str(spines_cfg.get("color") or "").strip() or None + + inline_cfg = style.get("inline_labels") or {} + inline_enabled = bool(inline_cfg.get("enabled") if inline_cfg.get("enabled") is not None else False) + dx_ratio = float(inline_cfg.get("dx_ratio") or 0.01) + if not math.isfinite(dx_ratio) or dx_ratio <= 0: + dx_ratio = 0.01 + dx_ratio = float(max(0.001, min(0.2, dx_ratio))) + + plt.rcParams["font.family"] = font_family + plt.rcParams["font.size"] = font_size + # Default to bold + Arial-like look for publication-ready outputs. + plt.rcParams["font.weight"] = "bold" + plt.rcParams["axes.labelweight"] = "bold" + plt.rcParams["axes.titleweight"] = "bold" + # Keep text as text in SVG so font-family/weight remain editable downstream. + plt.rcParams["svg.fonttype"] = "none" + + fmt = (spec.get("output_format") or "svg").lower() + out_path = base / f"out.{fmt}" + + fig, ax = plt.subplots(figsize=(float(fig_size[0]), float(fig_size[1])), dpi=dpi) + + scales = spec.get("scales") or {} + x_scale = (scales.get("x") or "linear").lower() + y_scale = (scales.get("y") or "linear").lower() + + # Apply spines style early so downstream layout includes it. + for spine in ax.spines.values(): + spine.set_visible(bool(spines_enabled)) + if spines_enabled: + if spines_width is not None: + spine.set_linewidth(float(spines_width)) + if spines_color: + spine.set_color(str(spines_color)) + + series_list = (data.get("series") or []) + if not series_list: + raise RuntimeError("plot_data.series is empty") + + # Optional: apply per-series y offsets (waterfall-like stacking) at render-time. + offset_enabled = False + offset_step = 0.0 + offset_mode = "none" + offset_ratio = 0.15 + if isinstance(series_offset_cfg, dict): + offset_enabled = bool(series_offset_cfg.get("enabled") if series_offset_cfg.get("enabled") is not None else False) + offset_mode = str(series_offset_cfg.get("mode") or ("auto" if offset_enabled else "none")).strip().lower() + if offset_mode not in {"none", "auto", "fixed", "relative"}: + offset_mode = "none" + try: + offset_ratio = float(series_offset_cfg.get("ratio") or 0.15) + except Exception: + offset_ratio = 0.15 + if not math.isfinite(offset_ratio) or offset_ratio <= 0: + offset_ratio = 0.15 + offset_ratio = float(max(0.001, min(50.0, offset_ratio))) + + if not offset_enabled or offset_mode == "none": + offset_enabled = False + elif offset_mode == "fixed": + try: + offset_step = float(series_offset_cfg.get("step")) + except Exception: + offset_step = 0.0 + if not math.isfinite(offset_step) or offset_step <= 0: + offset_enabled = False + else: + pooled = [] + spans = [] + for s in series_list: + if s.get("visible") is False: + continue + y_vals = s.get("y") or [] + try: + arr = np.asarray(y_vals, dtype=float) + except Exception: + continue + arr = arr[np.isfinite(arr)] + if arr.size: + pooled.append(arr) + try: + q05, q995 = np.quantile(arr, [0.05, 0.995]) + span = float(q995 - q05) + except Exception: + span = 0.0 + if not math.isfinite(span) or span <= 0.0: + try: + span = float(np.max(arr) - np.min(arr)) + except Exception: + span = 0.0 + if math.isfinite(span) and span > 0.0: + spans.append(span) + + y_span_from_cfg = None + if isinstance(axis_range_cfg, dict): + ry = axis_range_cfg.get("y") + if isinstance(ry, dict): + try: + y_min = float(ry.get("min")) if ry.get("min") is not None else None + except Exception: + y_min = None + try: + y_max = float(ry.get("max")) if ry.get("max") is not None else None + except Exception: + y_max = None + if y_min is not None and y_max is not None and math.isfinite(y_min) and math.isfinite(y_max) and y_max > y_min: + y_span_from_cfg = float(y_max - y_min) + + if pooled: + max_pool = 100_000 + per_series = max(1, int(max_pool // max(1, len(pooled)))) + sampled = [] + for arr in pooled: + if arr.size > per_series: + idx = np.linspace(0, arr.size - 1, num=per_series, dtype=int) + sampled.append(arr[idx]) + else: + sampled.append(arr) + all_y = sampled[0] if len(sampled) == 1 else np.concatenate(sampled) + if all_y.size > max_pool: + idx = np.linspace(0, all_y.size - 1, num=max_pool, dtype=int) + all_y = all_y[idx] + q05, q995 = np.quantile(all_y, [0.05, 0.995]) + robust = float(q995 - q05) + if not math.isfinite(robust) or robust <= 0.0: + span = float(np.max(all_y) - np.min(all_y)) + robust = span if math.isfinite(span) and span > 0.0 else 1.0 + + base = robust + if offset_mode == "relative": + base = y_span_from_cfg if y_span_from_cfg is not None else robust + else: + if spans: + spans_sorted = sorted(spans) + base = spans_sorted[len(spans_sorted) // 2] + offset_step = float(base) * float(offset_ratio) + else: + offset_step = 0.0 + if not math.isfinite(offset_step) or offset_step <= 0: + offset_enabled = False + + datetime_plotted = False + plotted_count = 0 + inline_points: list[dict[str, float | str | None]] = [] + numeric_x_min: float | None = None + numeric_x_max: float | None = None + for series in series_list: + if series.get("visible") is False: + continue + x = series.get("x") or [] + y = series.get("y") or [] + name = str(series.get("name") or f"series_{plotted_count + 1}") + color = series.get("color") or "#1a1a1a" + lw = float(series.get("line_width") or style.get("line_width") or 1.5) + if x_scale == "datetime": + parsed: list[datetime] = [] + ok = True + for v in x: + if not isinstance(v, str) or not v: + ok = False + break + try: + parsed.append(datetime.fromisoformat(v)) + except Exception: + ok = False + break + if ok: + x = parsed + datetime_plotted = True + else: + print("WARN: failed to parse datetime x; falling back to string plotting", file=sys.stderr) + + if offset_enabled: + try: + y = [float(v) + float(plotted_count) * float(offset_step) for v in y] + except Exception: + # If coercion fails, fall back to unshifted plotting. + pass + + ax.plot(x, y, linewidth=lw, color=color, label=name) + plotted_count += 1 + + if inline_enabled and (x_scale != "datetime") and not datetime_plotted: + try: + xs = [float(v) for v in x] + ys = [float(v) for v in y] + except Exception: + continue + if not xs or not ys: + continue + x_last = float(xs[-1]) + y_last = float(ys[-1]) + if not (math.isfinite(x_last) and math.isfinite(y_last)): + continue + xmin = float(min(xs)) + xmax = float(max(xs)) + if math.isfinite(xmin) and math.isfinite(xmax): + numeric_x_min = xmin if numeric_x_min is None else float(min(numeric_x_min, xmin)) + numeric_x_max = xmax if numeric_x_max is None else float(max(numeric_x_max, xmax)) + inline_points.append( + { + "label": str(name), + "color": str(color), + "x_last": x_last, + "y_last": y_last, + "label_x": _as_finite_float(series.get("label_x")), + "label_y": _as_finite_float(series.get("label_y")), + } + ) + + if datetime_plotted: + locator = mdates.AutoDateLocator() + ax.xaxis.set_major_locator(locator) + ax.xaxis.set_major_formatter(mdates.ConciseDateFormatter(locator)) + fig.autofmt_xdate() + else: + if x_scale == "log": + ax.set_xscale("log") + if y_scale == "log": + ax.set_yscale("log") + + # Axis ranges (optional). + if isinstance(axis_range_cfg, dict): + rx = axis_range_cfg.get("x") if isinstance(axis_range_cfg.get("x"), dict) else {} + ry = axis_range_cfg.get("y") if isinstance(axis_range_cfg.get("y"), dict) else {} + + x_min = rx.get("min") + x_max = rx.get("max") + if x_scale == "datetime": + if isinstance(x_min, str) and x_min: + try: + x_min = datetime.fromisoformat(x_min) + except Exception: + x_min = None + else: + x_min = None + if isinstance(x_max, str) and x_max: + try: + x_max = datetime.fromisoformat(x_max) + except Exception: + x_max = None + else: + x_max = None + else: + try: + x_min = float(x_min) if x_min is not None else None + except Exception: + x_min = None + try: + x_max = float(x_max) if x_max is not None else None + except Exception: + x_max = None + if x_scale == "log": + if x_min is not None and x_min <= 0: + x_min = None + if x_max is not None and x_max <= 0: + x_max = None + if x_min is not None or x_max is not None: + ax.set_xlim(left=x_min, right=x_max) + + try: + y_min = float(ry.get("min")) if ry.get("min") is not None else None + except Exception: + y_min = None + try: + y_max = float(ry.get("max")) if ry.get("max") is not None else None + except Exception: + y_max = None + if y_scale == "log": + if y_min is not None and y_min <= 0: + y_min = None + if y_max is not None and y_max <= 0: + y_max = None + if y_min is not None or y_max is not None: + ax.set_ylim(bottom=y_min, top=y_max) + + # Tick label styling (font size / rotation / numeric formatting). + try: + x_font_size = int(ticks_x.get("font_size")) if isinstance(ticks_x, dict) and ticks_x.get("font_size") is not None else None + except Exception: + x_font_size = None + try: + y_font_size = int(ticks_y.get("font_size")) if isinstance(ticks_y, dict) and ticks_y.get("font_size") is not None else None + except Exception: + y_font_size = None + try: + x_top_font_size = int(ticks_x.get("top_font_size")) if isinstance(ticks_x, dict) and ticks_x.get("top_font_size") is not None else None + except Exception: + x_top_font_size = None + try: + y_right_font_size = int(ticks_y.get("right_font_size")) if isinstance(ticks_y, dict) and ticks_y.get("right_font_size") is not None else None + except Exception: + y_right_font_size = None + if x_font_size is not None: + ax.tick_params(axis="x", labelsize=max(1, x_font_size)) + if y_font_size is not None: + ax.tick_params(axis="y", labelsize=max(1, y_font_size)) + + x_show_labels = bool(ticks_x.get("show_labels") if isinstance(ticks_x, dict) and ticks_x.get("show_labels") is not None else True) + x_show_ticks = bool(ticks_x.get("show_ticks") if isinstance(ticks_x, dict) and ticks_x.get("show_ticks") is not None else True) + y_show_labels = bool(ticks_y.get("show_labels") if isinstance(ticks_y, dict) and ticks_y.get("show_labels") is not None else True) + y_show_ticks = bool(ticks_y.get("show_ticks") if isinstance(ticks_y, dict) and ticks_y.get("show_ticks") is not None else True) + x_show_labels_bottom = bool(ticks_x.get("show_labels_bottom") if isinstance(ticks_x, dict) and ticks_x.get("show_labels_bottom") is not None else x_show_labels) + x_show_labels_top = bool(ticks_x.get("show_labels_top") if isinstance(ticks_x, dict) and ticks_x.get("show_labels_top") is not None else False) + x_show_ticks_bottom = bool(ticks_x.get("show_ticks_bottom") if isinstance(ticks_x, dict) and ticks_x.get("show_ticks_bottom") is not None else x_show_ticks) + x_show_ticks_top = bool(ticks_x.get("show_ticks_top") if isinstance(ticks_x, dict) and ticks_x.get("show_ticks_top") is not None else False) + y_show_labels_left = bool(ticks_y.get("show_labels_left") if isinstance(ticks_y, dict) and ticks_y.get("show_labels_left") is not None else y_show_labels) + y_show_labels_right = bool(ticks_y.get("show_labels_right") if isinstance(ticks_y, dict) and ticks_y.get("show_labels_right") is not None else False) + y_show_ticks_left = bool(ticks_y.get("show_ticks_left") if isinstance(ticks_y, dict) and ticks_y.get("show_ticks_left") is not None else y_show_ticks) + y_show_ticks_right = bool(ticks_y.get("show_ticks_right") if isinstance(ticks_y, dict) and ticks_y.get("show_ticks_right") is not None else False) + ax.tick_params( + axis="x", + labelbottom=x_show_labels_bottom, + labeltop=x_show_labels_top, + bottom=x_show_ticks_bottom, + top=x_show_ticks_top, + ) + ax.tick_params( + axis="y", + labelleft=y_show_labels_left, + labelright=y_show_labels_right, + left=y_show_ticks_left, + right=y_show_ticks_right, + ) + + x_tick_font_family = str(ticks_x.get("font_family") or "").strip() if isinstance(ticks_x, dict) else "" + y_tick_font_family = str(ticks_y.get("font_family") or "").strip() if isinstance(ticks_y, dict) else "" + x_top_tick_font_family = str(ticks_x.get("top_font_family") or x_tick_font_family).strip() if isinstance(ticks_x, dict) else "" + y_right_tick_font_family = str(ticks_y.get("right_font_family") or y_tick_font_family).strip() if isinstance(ticks_y, dict) else "" + if x_tick_font_family.lower() in {"", "auto", "inherit"}: + x_tick_font_family = "" + if y_tick_font_family.lower() in {"", "auto", "inherit"}: + y_tick_font_family = "" + if x_top_tick_font_family.lower() in {"", "auto", "inherit"}: + x_top_tick_font_family = "" + if y_right_tick_font_family.lower() in {"", "auto", "inherit"}: + y_right_tick_font_family = "" + + x_label_color = str(ticks_x.get("label_color") or "").strip() if isinstance(ticks_x, dict) else "" + y_label_color = str(ticks_y.get("label_color") or "").strip() if isinstance(ticks_y, dict) else "" + x_top_label_color = str(ticks_x.get("top_label_color") or x_label_color).strip() if isinstance(ticks_x, dict) else "" + y_right_label_color = str(ticks_y.get("right_label_color") or y_label_color).strip() if isinstance(ticks_y, dict) else "" + + x_tick_color = str(ticks_x.get("tick_color") or "").strip() if isinstance(ticks_x, dict) else "" + y_tick_color = str(ticks_y.get("tick_color") or "").strip() if isinstance(ticks_y, dict) else "" + x_tick_width = None + y_tick_width = None + x_tick_length = None + y_tick_length = None + if isinstance(ticks_x, dict) and ticks_x.get("tick_width") is not None: + try: + x_tick_width = float(ticks_x.get("tick_width")) + except Exception: + x_tick_width = None + if isinstance(ticks_y, dict) and ticks_y.get("tick_width") is not None: + try: + y_tick_width = float(ticks_y.get("tick_width")) + except Exception: + y_tick_width = None + if isinstance(ticks_x, dict) and ticks_x.get("tick_length") is not None: + try: + x_tick_length = float(ticks_x.get("tick_length")) + except Exception: + x_tick_length = None + if isinstance(ticks_y, dict) and ticks_y.get("tick_length") is not None: + try: + y_tick_length = float(ticks_y.get("tick_length")) + except Exception: + y_tick_length = None + if x_tick_color or x_tick_width is not None or x_tick_length is not None: + kwargs = {} + if x_tick_color: + kwargs["color"] = x_tick_color + if x_tick_width is not None: + kwargs["width"] = max(0.1, float(x_tick_width)) + if x_tick_length is not None: + kwargs["length"] = max(0.0, float(x_tick_length)) + ax.tick_params(axis="x", **kwargs) + if y_tick_color or y_tick_width is not None or y_tick_length is not None: + kwargs = {} + if y_tick_color: + kwargs["color"] = y_tick_color + if y_tick_width is not None: + kwargs["width"] = max(0.1, float(y_tick_width)) + if y_tick_length is not None: + kwargs["length"] = max(0.0, float(y_tick_length)) + ax.tick_params(axis="y", **kwargs) + + x_rotation = None + if isinstance(ticks_x, dict) and ticks_x.get("rotation") is not None: + try: + x_rotation = float(ticks_x.get("rotation")) + except Exception: + x_rotation = None + if x_rotation is not None: + for tick in ax.get_xticklabels(): + tick.set_rotation(x_rotation) + tick.set_ha("right" if x_rotation and abs(x_rotation) >= 15 else "center") + + def _apply_numeric_format(axis: str, fmt: str) -> None: + fmt = (fmt or "auto").strip().lower() + if fmt == "auto": + return + if fmt not in {"plain", "scientific"}: + return + # Skip formatting for datetime and log axes. + if axis == "x" and (x_scale == "datetime" or x_scale == "log"): + return + if axis == "y" and y_scale == "log": + return + # Keep scientific notation in normal text font (avoid mathtext font switching). + formatter = mticker.ScalarFormatter(useMathText=False) + if fmt == "scientific": + formatter.set_scientific(True) + formatter.set_powerlimits((-3, 3)) + else: + formatter.set_scientific(False) + if axis == "x": + ax.xaxis.set_major_formatter(formatter) + else: + ax.yaxis.set_major_formatter(formatter) + + if isinstance(ticks_x, dict): + _apply_numeric_format("x", str(ticks_x.get("format") or "auto")) + if isinstance(ticks_y, dict): + _apply_numeric_format("y", str(ticks_y.get("format") or "auto")) + + for tick in ax.xaxis.get_major_ticks(): + if x_font_size is not None: + tick.label1.set_fontsize(max(1, x_font_size)) + if x_top_font_size is not None: + tick.label2.set_fontsize(max(1, x_top_font_size)) + if x_tick_font_family: + tick.label1.set_fontfamily(x_tick_font_family) + if x_top_tick_font_family: + tick.label2.set_fontfamily(x_top_tick_font_family) + if x_label_color: + tick.label1.set_color(x_label_color) + if x_top_label_color: + tick.label2.set_color(x_top_label_color) + + for tick in ax.yaxis.get_major_ticks(): + if y_font_size is not None: + tick.label1.set_fontsize(max(1, y_font_size)) + if y_right_font_size is not None: + tick.label2.set_fontsize(max(1, y_right_font_size)) + if y_tick_font_family: + tick.label1.set_fontfamily(y_tick_font_family) + if y_right_tick_font_family: + tick.label2.set_fontfamily(y_right_tick_font_family) + if y_label_color: + tick.label1.set_color(y_label_color) + if y_right_label_color: + tick.label2.set_color(y_right_label_color) + + if inline_enabled: + # Inline labels are a legend replacement; keep the plot clean. + legend_enabled = False + + if inline_points: + dx = 0.0 + if ( + numeric_x_min is not None + and numeric_x_max is not None + and numeric_x_max > numeric_x_min + ): + dx = float((numeric_x_max - numeric_x_min) * dx_ratio) + if not math.isfinite(dx) or dx <= 0: + dx = 0.0 + + x_text_positions: list[float] = [] + for pt in inline_points: + x_pos_raw = pt.get("label_x") + x_last_raw = pt.get("x_last") + x_pos = _as_finite_float(x_pos_raw) + if x_pos is None: + x_last = _as_finite_float(x_last_raw) + if x_last is None: + continue + x_pos = float(x_last + dx) + if x_scale == "log" and x_pos <= 0: + continue + x_text_positions.append(float(x_pos)) + + if x_text_positions: + x_left, x_right = ax.get_xlim() + pad = float(abs(dx)) if dx > 0 else 0.0 + if pad <= 0 and numeric_x_min is not None and numeric_x_max is not None: + span = float(numeric_x_max - numeric_x_min) + if math.isfinite(span) and span > 0: + pad = float(span * 0.01) + if not math.isfinite(pad) or pad <= 0: + pad = 0.01 + new_xlim = _expand_xlim_for_inline_labels( + x_left=float(x_left), + x_right=float(x_right), + label_positions=x_text_positions, + pad=float(pad), + ) + if new_xlim is not None: + ax.set_xlim(new_xlim[0], new_xlim[1]) + + for pt in inline_points: + label = str(pt.get("label") or "") + color = str(pt.get("color") or "#1a1a1a") + x_last = _as_finite_float(pt.get("x_last")) + y_last = _as_finite_float(pt.get("y_last")) + if x_last is None or y_last is None: + continue + x_pos = _as_finite_float(pt.get("label_x")) + y_pos = _as_finite_float(pt.get("label_y")) + if x_pos is None: + x_pos = float(x_last + dx) + if y_pos is None: + y_pos = float(y_last) + if x_scale == "log" and x_pos <= 0: + continue + if y_scale == "log" and y_pos <= 0: + continue + if not (math.isfinite(x_pos) and math.isfinite(y_pos)): + continue + ax.text( + float(x_pos), + float(y_pos), + str(label), + color=str(color), + va="center", + ha="left", + fontsize=max(8, font_size - 2), + fontweight="bold", + clip_on=False, + ) + + annotations = spec.get("annotations") or [] + if x_scale != "datetime" and annotations: + y_min, y_max = ax.get_ylim() + y_span = max(1e-9, (y_max - y_min)) + y_text = y_max - 0.06 * y_span + for ann in annotations: + if str(ann.get("type") or "") != "peak_vline": + continue + x_val = ann.get("x") + if not isinstance(x_val, (int, float)): + continue + color = ann.get("color") or "#c73e3a" + text = str(ann.get("text") or f"{float(x_val):.2f}") + ax.axvline(float(x_val), color=color, linestyle="--", alpha=0.55, linewidth=1.0) + ax.text( + float(x_val), + y_text, + text, + rotation=90, + color=color, + va="top", + ha="right", + fontsize=max(8, font_size - 2), + ) + + if plotted_count > 1 and legend_enabled: + legend = ax.legend(loc=legend_location, fontsize=max(8, font_size - 2)) + for text in legend.get_texts(): + text.set_fontweight("bold") + + axis_font_family = str(axis_text.get("font_family") or "").strip() if isinstance(axis_text, dict) else "" + if axis_font_family.lower() in {"", "auto", "inherit"}: + axis_font_family = "" + axis_font_size = None + axis_color = None + if isinstance(axis_text, dict) and axis_text.get("font_size") is not None: + try: + axis_font_size = int(axis_text.get("font_size")) + except Exception: + axis_font_size = None + if isinstance(axis_text, dict) and axis_text.get("color") is not None: + axis_color = str(axis_text.get("color") or "").strip() or None + + axis_kwargs = {"fontweight": "bold"} + if axis_font_family: + axis_kwargs["fontfamily"] = axis_font_family + if axis_font_size is not None: + axis_kwargs["fontsize"] = max(1, axis_font_size) + if axis_color: + axis_kwargs["color"] = axis_color + + ax.set_xlabel(_label_with_unit(spec.get("x_label") or "X", spec.get("x_unit") or ""), **axis_kwargs) + ax.set_ylabel(_label_with_unit(spec.get("y_label") or "Y", spec.get("y_unit") or ""), **axis_kwargs) + + title = spec.get("title") or "" + if title: + title_font_family = str(title_text.get("font_family") or "").strip() if isinstance(title_text, dict) else "" + if title_font_family.lower() in {"", "auto", "inherit"}: + title_font_family = "" + title_font_size = None + title_color = None + if isinstance(title_text, dict) and title_text.get("font_size") is not None: + try: + title_font_size = int(title_text.get("font_size")) + except Exception: + title_font_size = None + if isinstance(title_text, dict) and title_text.get("color") is not None: + title_color = str(title_text.get("color") or "").strip() or None + + title_kwargs = {"fontweight": "bold"} + if title_font_family: + title_kwargs["fontfamily"] = title_font_family + if title_font_size is not None: + title_kwargs["fontsize"] = max(1, title_font_size) + if title_color: + title_kwargs["color"] = title_color + ax.set_title(title, **title_kwargs) + + if grid: + ax.grid(True, alpha=grid_alpha) + + for tick in list(ax.get_xticklabels()) + list(ax.get_yticklabels()): + tick.set_fontweight("bold") + + fig.tight_layout() + fig.savefig(out_path, format=fmt, bbox_inches="tight", facecolor="white") + if bool(spec.get("analysis_png_output")) and fmt != "png": + fig.savefig(base / "out.png", format="png", bbox_inches="tight", facecolor="white") + plt.close(fig) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 757e37c..9e252d5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ mod acsearch; mod engine; mod pyenv; +mod renderer; mod skills; use engine::{DomainEvent, EngineState, EngineStatus, TaskSpec}; @@ -213,7 +214,8 @@ pub fn run() { check_doctor, check_academic_search, register_academic_search, - get_setup_status + get_setup_status, + renderer::preview_plot ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/pyenv.rs b/src-tauri/src/pyenv.rs index 3dcb84e..4df522a 100644 --- a/src-tauri/src/pyenv.rs +++ b/src-tauri/src/pyenv.rs @@ -40,7 +40,7 @@ pub fn pyenv_dir() -> PathBuf { .join("pyenv") } -fn venv_python() -> PathBuf { +pub fn venv_python() -> PathBuf { pyenv_dir().join("bin").join("python") } diff --git a/src-tauri/src/renderer.rs b/src-tauri/src/renderer.rs new file mode 100644 index 0000000..38d6c7c --- /dev/null +++ b/src-tauri/src/renderer.rs @@ -0,0 +1,164 @@ +//! 图表参数化渲染器 —— 移植自 Sci-Data-Analyzer 的 chart_renderer 模块。 +//! +//! 接收前端传来的 plot_spec + plot_data JSON,注入预置模板渲染成 SVG/PNG。 +//! 全程不经过 LLM,纯本地 matplotlib 子进程,延迟百毫秒级。 + +use std::fs; +use std::path::PathBuf; +use std::process::Command; + +use base64::Engine as _; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use tauri::{AppHandle, Manager}; + +use crate::pyenv; + +/// 前端预览请求。 +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PreviewRequest { + /// 图表类型 → 决定使用哪个模板(line / bar / heatmap / line_dual_y) + pub chart_type: String, + /// 绘图参数(标题 / 坐标轴 / 样式 / 序列颜色等) + pub plot_spec: Value, + /// 绘图数据(series 数组,含 x / y / color / line_width / visible) + pub plot_data: Value, + /// 输出格式(svg | png) + #[serde(default = "default_format")] + pub format: String, +} + +fn default_format() -> String { + "svg".to_string() +} + +/// 渲染结果(前端转 data URL 显示)。 +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PreviewResponse { + pub image_base64: String, + pub image_format: String, + pub warnings: Vec, +} + +/// 图表类型 → 模板文件名。 +fn template_for(chart_type: &str) -> &str { + match chart_type { + "bar" => "bar_plot.py", + "heatmap" => "heatmap_plot.py", + "line_dual_y" | "dual_y" => "dual_y_plot.py", + _ => "line_plot.py", + } +} + +/// 定位模板文件:优先打包后的 resource 目录,回退开发时源码目录。 +fn resolve_template(app: &AppHandle, name: &str) -> Result { + let resource = app.path().resource_dir().ok(); + let dev = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let candidates: [Option; 2] = [ + resource.map(|d| d.join("chart_renderer/templates").join(name)), + Some(dev.join("resources/chart_renderer/templates").join(name)), + ]; + for c in candidates.into_iter().flatten() { + if c.exists() { + return Ok(c); + } + } + Err(format!("找不到绘图模板: {name}")) +} + +#[tauri::command] +pub async fn preview_plot( + app: AppHandle, + request: PreviewRequest, +) -> Result { + let fmt = match request.format.to_lowercase().as_str() { + "png" => "png", + _ => "svg", + } + .to_string(); + + // 1. 定位模板 + let template_name = template_for(&request.chart_type); + let template_path = resolve_template(&app, template_name)?; + + // 2. 临时工作目录(函数返回时自动清理) + let tmp = tempfile::tempdir().map_err(|e| format!("创建临时目录失败: {e}"))?; + let work = tmp.path().to_path_buf(); + + // 3. 写 plot_spec.json(注入 output_format / schema_version) + let mut spec = request.plot_spec; + if let Value::Object(ref mut map) = spec { + map.insert("output_format".into(), Value::String(fmt.clone())); + map.insert("schema_version".into(), json!(1)); + } + fs::write(work.join("plot_spec.json"), spec.to_string()) + .map_err(|e| format!("写 plot_spec.json 失败: {e}"))?; + + // 4. 写 plot_data.json + let mut data = request.plot_data; + if data.is_null() { + data = json!({ "schema_version": 1, "series": [] }); + } else if let Value::Object(ref mut map) = data { + map.insert("schema_version".into(), json!(1)); + } + fs::write(work.join("plot_data.json"), data.to_string()) + .map_err(|e| format!("写 plot_data.json 失败: {e}"))?; + + // 5. 复制模板为 plot.py(模板从脚本同目录读 JSON) + fs::copy(&template_path, work.join("plot.py")) + .map_err(|e| format!("复制模板失败: {e}"))?; + + // 6. 获取 python(优先 uv venv,回退系统 python3) + let venv_py = pyenv::venv_python(); + let python: PathBuf = if venv_py.exists() { + venv_py + } else { + PathBuf::from("python3") + }; + + // 7. 执行(清白环境 + headless matplotlib,在阻塞线程避免卡 async 运行时) + let py = python.clone(); + let script = work.join("plot.py"); + let cwd = work.clone(); + let output = tauri::async_runtime::spawn_blocking(move || { + Command::new(&py) + .arg(&script) + .env("MPLBACKEND", "Agg") + .env("HOME", &cwd) + .env("PYTHONUNBUFFERED", "1") + .current_dir(&cwd) + .output() + }) + .await + .map_err(|e| format!("渲染线程异常: {e}"))? + .map_err(|e| format!("启动 python 失败: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + return Err(format!( + "绘图失败(exit {})\n--- stderr ---\n{}\n--- stdout ---\n{}", + output.status, stderr, stdout + )); + } + + // 8. 读取输出图片 + let out_file = work.join(format!("out.{fmt}")); + let bytes = fs::read(&out_file) + .map_err(|e| format!("读取输出图片失败({}): {e}", out_file.display()))?; + + if bytes.len() > 20 * 1024 * 1024 { + return Err("输出图片过大(>20MB)".into()); + } + + // 9. base64 编码返回 + let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes); + + Ok(PreviewResponse { + image_base64: b64, + image_format: fmt, + warnings: vec![], + }) +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index a081c91..7b23144 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -29,6 +29,9 @@ "bundle": { "active": true, "targets": "all", + "resources": [ + "resources/chart_renderer/templates/*" + ], "icon": [ "icons/32x32.png", "icons/128x128.png", diff --git a/src/App.css b/src/App.css index 8082b49..d03e876 100644 --- a/src/App.css +++ b/src/App.css @@ -286,3 +286,52 @@ button.danger { background: #b64342; color: #fff; border-color: #b64342; } /* ---- figure 再改一版 ---- */ .refine { border-top: 1px dashed #d6d6da; padding-top: 10px; display: flex; flex-direction: column; gap: 6px; } .refine-input { flex: 1; font: inherit; font-size: 13px; padding: 6px 10px; border: 1px solid #ccc; border-radius: 8px; } + +/* ---- 图表微调编辑器 (ChartEditor) ---- */ +.chart-editor { display: flex; flex-direction: column; height: 100%; gap: 8px; overflow: hidden; } +.ce-header { display: flex; align-items: center; gap: 10px; } +.ce-header h2 { font-size: 16px; margin: 0; } +.ce-status { font-size: 12px; color: #888; } +.ce-status.loading { color: #0f4d92; font-weight: 600; } +.ce-body { display: flex; gap: 12px; flex: 1; min-height: 0; } +.ce-params { width: 400px; overflow-y: auto; padding-right: 4px; display: flex; flex-direction: column; gap: 8px; } +.ce-preview { + flex: 1; min-width: 0; background: #fff; border: 1px solid #c4c4c6; + border-radius: 10px; display: flex; align-items: center; justify-content: center; + overflow: hidden; padding: 16px; +} +.ce-preview-img { max-width: 100%; max-height: 100%; object-fit: contain; } +.ce-placeholder { color: #aaa; font-size: 14px; } +.ce-error { color: #b64342; font-size: 13px; background: #fef2f2; padding: 10px 14px; border-radius: 8px; margin-bottom: 8px; white-space: pre-wrap; word-break: break-word; } + +/* 参数面板分区 */ +.ce-section { background: #fff; border: 1px solid #e0e0e4; border-radius: 10px; overflow: hidden; } +.ce-section > summary { cursor: pointer; padding: 8px 12px; font-size: 13px; font-weight: 600; user-select: none; background: #f5f5f7; } +.ce-section-body { padding: 10px 12px; display: flex; flex-direction: column; gap: 8px; } + +/* 字段 */ +.ce-field { display: flex; flex-direction: column; gap: 3px; } +.ce-field-label { font-size: 11px; color: #888; } +.ce-field input, .ce-field select { font: inherit; font-size: 13px; padding: 5px 8px; border: 1px solid #ccc; border-radius: 6px; } +.ce-row { display: flex; gap: 8px; } +.ce-row .ce-field { flex: 1; } +.ce-check { display: flex; align-items: center; gap: 6px; font-size: 13px; cursor: pointer; } + +/* 颜色选择器 */ +.ce-color { display: flex; align-items: center; gap: 6px; } +.ce-swatches { display: flex; gap: 3px; flex-wrap: wrap; } +.ce-swatch { width: 18px; height: 18px; border-radius: 4px; border: 2px solid transparent; cursor: pointer; padding: 0; } +.ce-swatch.on { border-color: #0f4d92; } +.ce-color-input { width: 28px; height: 28px; border: 1px solid #ccc; border-radius: 6px; padding: 0; cursor: pointer; } +.ce-color-text { width: 70px; font: inherit; font-size: 12px; padding: 4px 6px; border: 1px solid #ccc; border-radius: 6px; } + +/* 序列块 */ +.ce-series { border: 1px solid #e8e8ec; border-radius: 8px; padding: 8px; display: flex; flex-direction: column; gap: 6px; } +.ce-series-head { display: flex; align-items: center; gap: 6px; } +.ce-series-name { flex: 1; font: inherit; font-size: 13px; font-weight: 600; padding: 3px 6px; border: 1px solid #ddd; border-radius: 6px; } + +/* 微调入口按钮 */ +.ce-entry { display: flex; gap: 8px; align-items: center; } +.ce-entry-hint { font-size: 11px; color: #888; } +button.ce-launch { background: #0f4d92; color: #fff; border: 1px solid #0f4d92; font-size: 13px; padding: 6px 14px; border-radius: 8px; cursor: pointer; } +button.ce-launch:hover { background: #0a3d75; } diff --git a/src/App.tsx b/src/App.tsx index 7361bc4..9c69ee8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,8 @@ import type { SkillDescriptor } from "./types/skill"; import DynamicForm, { type DynamicFormResult } from "./components/DynamicForm"; import ArtifactPreview from "./components/ArtifactPreview"; import Settings from "./components/Settings"; +import ChartEditor from "./components/ChartEditor"; +import type { PlotSpec, PlotData } from "./types/plot"; import "./App.css"; interface LogLine { @@ -32,9 +34,35 @@ const CHART_ATLAS: { file: string; label: string }[] = [ { file: "atlas-07-forest-interval.png", label: "forest 森林" }, { file: "atlas-08-area-stacked.png", label: "area 堆叠" }, { file: "atlas-09-image-plates.png", label: "image 图板" }, - { file: "atlas-10-network-matrix.png", label: "network 网络" }, + { file: "atlas-10-network-matrix.png", label: "network 网络" }, ]; +// 图表微调编辑器的演示数据(阶段三后将替换为 codex 生成的真实 plot_spec/plot_data)。 +const DEMO_XS = Array.from({ length: 21 }, (_, i) => +(i * 0.5).toFixed(1)); +const DEMO_PLOT_SPEC: PlotSpec = { + chart_type: "line", + title: "Sample Data Visualization", + x_label: "Time", + y_label: "Amplitude", + x_unit: "s", + style: { + figure_size: [8, 5], + dpi: 150, + font_family: "serif", + font_size: 12, + grid: true, + grid_alpha: 0.3, + legend: { enabled: true, location: "best" }, + spines: { enabled: true, width: 1.2, color: "#333" }, + }, +}; +const DEMO_PLOT_DATA: PlotData = { + series: [ + { name: "sin", x: DEMO_XS, y: DEMO_XS.map((x) => +Math.sin(x).toFixed(4)), color: "#1a1a1a", line_width: 2.0, visible: true }, + { name: "cos", x: DEMO_XS, y: DEMO_XS.map((x) => +Math.cos(x).toFixed(4)), color: "#c73e3a", line_width: 1.5, visible: true }, + ], +}; + function ChartAtlas({ skillDir, selected, @@ -189,9 +217,26 @@ function RunView({ const gotResultRef = useRef(false); const isFigure = skill.id === "nature-figure"; + const [showEditor, setShowEditor] = useState(false); + const [loadedSpec, setLoadedSpec] = useState(null); + const [loadedData, setLoadedData] = useState(null); // 最近一次生成的绘图脚本(用于"再改一版"回灌) const lastPy = [...artifacts].reverse().find((p) => p.endsWith(".py")) || null; + // 任务完成后,检测 codex 是否导出了 plot_spec/plot_data,有则用于图表微调 + async function tryLoadPlotParams() { + if (!workdir) return; + try { + const specStr = await readTextFile(`${workdir}/plot_spec.json`); + const dataStr = await readTextFile(`${workdir}/plot_data.json`); + setLoadedSpec(JSON.parse(specStr)); + setLoadedData(JSON.parse(dataStr)); + push("已加载绘图参数,可点击「图表微调」实时调整", "ok"); + } catch { + // 参数文件不存在(旧版产物或 mock 模式),保留演示数据 + } + } + function push(text: string, cls?: string) { setLog((prev) => [...prev, { t: new Date().toLocaleTimeString(), text, cls }]); } @@ -289,6 +334,7 @@ function RunView({ ); } setRunning(false); + if (ev.outcome === "success") tryLoadPlotParams(); break; } }; @@ -345,6 +391,13 @@ function RunView({ } return ( + showEditor ? ( + setShowEditor(false)} + /> + ) : (
@@ -369,6 +422,13 @@ function RunView({ {isFigure && } + {isFigure && ( +
+ + {loadedSpec ? "已加载生成参数,可实时微调" : "参数化编辑器:实时调整配色 / 字号 / 线宽,本地渲染无需重跑"} +
+ )} +
+ ) ); } diff --git a/src/api/plotApi.ts b/src/api/plotApi.ts new file mode 100644 index 0000000..90b7855 --- /dev/null +++ b/src/api/plotApi.ts @@ -0,0 +1,32 @@ +// 图表渲染 API 封装 —— 调用 Tauri 的 preview_plot 命令,参数→本地 matplotlib→图片。 + +import { invoke } from "@tauri-apps/api/core"; +import type { PlotSpec, PlotData, PreviewResponse } from "../types/plot"; + +/** + * 调用本地渲染器:把 plot_spec + plot_data 交给 Python 模板渲染成 SVG/PNG。 + * 全程不经过 LLM,延迟百毫秒级。 + */ +export async function previewPlot( + chartType: string, + plotSpec: PlotSpec, + plotData: PlotData, + format: string = "svg", +): Promise { + return invoke("preview_plot", { + request: { chartType, plotSpec, plotData, format }, + }); +} + +/** base64 → data URL,供 直接显示。 */ +export function toDataUrl(format: string, base64: string): string { + if (format === "svg") return `data:image/svg+xml;base64,${base64}`; + if (format === "png") return `data:image/png;base64,${base64}`; + return `data:application/octet-stream;base64,${base64}`; +} + +/** 颜色选择预设(科研期刊常用)。 */ +export const COLOR_PRESETS = [ + "#1a1a1a", "#c73e3a", "#3d5a5b", "#6b6b6b", + "#2563eb", "#059669", "#d97706", "#7c3aed", +]; diff --git a/src/components/ChartEditor.tsx b/src/components/ChartEditor.tsx new file mode 100644 index 0000000..99d573b --- /dev/null +++ b/src/components/ChartEditor.tsx @@ -0,0 +1,262 @@ +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { previewPlot, toDataUrl, COLOR_PRESETS } from "../api/plotApi"; +import type { PlotSpec, PlotStyle, PlotData, PlotSeries } from "../types/plot"; + +const CHART_TYPES = [ + { value: "line", label: "折线图 Line" }, + { value: "bar", label: "柱状图 Bar" }, + { value: "heatmap", label: "热图 Heatmap" }, + { value: "line_dual_y", label: "双 Y 轴 Dual-Y" }, +]; + +const FONTS = ["serif", "sans-serif", "Arial", "Times New Roman", "DejaVu Serif", "DejaVu Sans", "SimSun"]; + +const LEGEND_LOCS = ["best", "upper right", "upper left", "lower right", "lower left", "center"]; + +interface Props { + initialSpec: PlotSpec; + initialData: PlotData; + onBack: () => void; +} + +// ── 小工具组件 ── + +function Section({ title, children }: { title: string; children: ReactNode }) { + return ( +
+ {title} +
{children}
+
+ ); +} + +function Field({ label, children }: { label: string; children: ReactNode }) { + return ( + + ); +} + +function ColorPicker({ value, onChange }: { value: string; onChange: (v: string) => void }) { + return ( +
+
+ {COLOR_PRESETS.map((c) => ( +
+ onChange(e.target.value)} className="ce-color-input" /> + onChange(e.target.value)} className="ce-color-text" /> +
+ ); +} + +// ── 主组件 ── + +export default function ChartEditor({ initialSpec, initialData, onBack }: Props) { + const [spec, setSpec] = useState(initialSpec); + const [data, setData] = useState(initialData); + const [previewUrl, setPreviewUrl] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const payloadRef = useRef(""); + + const chartType = spec.chart_type || "line"; + const st: PlotStyle = spec.style ?? {}; + const series = data.series ?? []; + + const updateSpec = (patch: Partial) => setSpec((p) => ({ ...p, ...patch })); + const updateStyle = (patch: Partial) => + setSpec((p) => ({ ...p, style: { ...p.style, ...patch } })); + const updateSeries = (i: number, patch: Partial) => + setData((p) => ({ ...p, series: p.series.map((s, idx) => (idx === i ? { ...s, ...patch } : s)) })); + + // 防抖预览:参数变化 → 400ms → 本地渲染 + useEffect(() => { + const payload = JSON.stringify({ chartType, spec, data }); + if (payload === payloadRef.current) return; + + const timer = setTimeout(async () => { + payloadRef.current = payload; + setLoading(true); + setError(null); + try { + const resp = await previewPlot(chartType, spec, data, "svg"); + setPreviewUrl(toDataUrl(resp.imageFormat, resp.imageBase64)); + } catch (e) { + setError(String(e)); + } finally { + setLoading(false); + } + }, 400); + + return () => clearTimeout(timer); + }, [chartType, spec, data]); + + async function exportChart(fmt: "svg" | "png") { + try { + const resp = await previewPlot(chartType, spec, data, fmt); + const url = toDataUrl(resp.imageFormat, resp.imageBase64); + const a = document.createElement("a"); + a.href = url; + a.download = `chart.${fmt}`; + a.click(); + } catch (e) { + setError(String(e)); + } + } + + return ( +
+
+ +

图表微调

+
+ {loading ? "渲染中…" : error ? "出错" : "已同步"} + + +
+ +
+ {/* ── 左侧参数面板 ── */} +
+
+ + + + + updateSpec({ title: e.target.value })} /> + +
+ + updateSpec({ x_label: e.target.value })} /> + + + updateSpec({ x_unit: e.target.value })} /> + +
+
+ + updateSpec({ y_label: e.target.value })} /> + + + updateSpec({ y_unit: e.target.value })} /> + +
+
+ +
+ + + +
+ + updateStyle({ font_size: +e.target.value })} /> + + + updateStyle({ dpi: +e.target.value })} /> + +
+
+ + updateStyle({ figure_size: [+e.target.value, st.figure_size?.[1] ?? 6] })} /> + + + updateStyle({ figure_size: [st.figure_size?.[0] ?? 10, +e.target.value] })} /> + +
+ + {st.grid !== false && ( + + updateStyle({ grid_alpha: +e.target.value })} /> + + )} +
+ +
+ + {st.legend?.enabled !== false && ( + + + + )} + + {st.spines?.enabled !== false && ( +
+ + updateStyle({ spines: { ...st.spines, width: +e.target.value } })} /> + + + updateStyle({ spines: { ...st.spines, color: v } })} /> + +
+ )} +
+ +
+ {series.map((s, i) => ( +
+
+ updateSeries(i, { visible: e.target.checked })} /> + updateSeries(i, { name: e.target.value })} /> +
+ + updateSeries(i, { color: v })} /> + + + updateSeries(i, { line_width: +e.target.value })} /> + +
+ ))} + {series.length === 0 &&

无数据序列。

} +
+
+ + {/* ── 右侧预览 ── */} +
+ {error &&
{error}
} + {previewUrl && ( + chart preview + )} + {!previewUrl && !error &&
{loading ? "渲染中…" : "等待渲染"}
} +
+
+
+ ); +} diff --git a/src/mock/data.ts b/src/mock/data.ts new file mode 100644 index 0000000..efa7fb6 --- /dev/null +++ b/src/mock/data.ts @@ -0,0 +1,244 @@ +import type { SkillDescriptor } from "../types/skill"; +import type { EngineStatus, DoctorReport } from "../types/engine"; + +// ── Mock 数据:纯浏览器预览时使用,不连接 Tauri 后端 ── + +export const MOCK_SKILLS: SkillDescriptor[] = [ + { + id: "nature-figure", + name: "nature-figure", + description: + "投稿级 Nature/高影响力期刊配图工作流,支持 matplotlib/seaborn 与 ggplot2,输出期刊级 SVG/PDF/TIFF。从论文配图到多面板组合、出版级排版一站式完成。", + status: "stable", + version: "2.0.0", + formCapability: "axes", + hasManifest: true, + alwaysLoad: ["static/core/contract.md", "static/core/stance.md"], + axes: [ + { + name: "backend", + values: ["python", "r"], + multi: false, + blockingGate: true, + defaultValue: "python", + }, + ], + onDemand: [ + { condition: "matplotlib/seaborn 绘图", path: "references/python-recipes.md" }, + { condition: "ggplot2 绘图", path: "references/r-recipes.md" }, + ], + dir: "/mock/skills-bundled/nature-figure", + }, + { + id: "nature-polishing", + name: "nature-polishing", + description: + "将学术散文润色、重组或翻译为 Nature 风格英语,覆盖摘要、引言、结果、讨论等全部章节,含 LaTeX 排版修复。", + status: "stable", + version: "6.1.0", + formCapability: "axes", + hasManifest: true, + alwaysLoad: ["static/core/principles.md"], + axes: [ + { + name: "paper_type", + values: ["full", "abstract", "results", "discussion", "intro", "method"], + multi: false, + blockingGate: false, + defaultValue: null, + }, + ], + onDemand: [], + dir: "/mock/skills-bundled/nature-polishing", + }, + { + id: "nature-writing", + name: "nature-writing", + description: + "起草、重构或规划 Nature 风格的稿件章节,从作者提供的 claim、结果、笔记或中文草稿出发搭建论文骨架与论证。", + status: "stable", + version: "1.0.0", + formCapability: "axes", + hasManifest: true, + alwaysLoad: ["static/core/argument.md"], + axes: [ + { + name: "paper_type", + values: ["abstract", "intro", "related-work", "method", "experiments", "discussion", "conclusion", "title"], + multi: false, + blockingGate: false, + defaultValue: null, + }, + ], + onDemand: [], + dir: "/mock/skills-bundled/nature-writing", + }, + { + id: "nature-reader", + name: "nature-reader", + description: + "构建全文中英对照、图表感知、来源锚定的 Markdown 论文阅读器,支持 PDF/DOI/arXiv/HTML 多种输入。", + status: "stable", + version: "2.0.0", + formCapability: "axes", + hasManifest: true, + alwaysLoad: ["static/core/reader.md"], + axes: [ + { + name: "source_format", + values: ["pdf", "doi", "arxiv", "html", "text"], + multi: false, + blockingGate: true, + defaultValue: "pdf", + }, + ], + onDemand: [], + dir: "/mock/skills-bundled/nature-reader", + }, + { + id: "nature-paper2ppt", + name: "nature-paper2ppt", + description: + "从科研论文构建完整高效的 Nature 风格中文 PPTX 演示文稿,适用于组会、文献汇报、学术答辩等场景。", + status: "stable", + version: "2.0.0", + formCapability: "axes", + hasManifest: true, + alwaysLoad: ["static/core/pptx.md"], + axes: [ + { + name: "paper_type", + values: ["medical", "general-science", "biology", "chemistry", "cs"], + multi: false, + blockingGate: false, + defaultValue: null, + }, + ], + onDemand: [], + dir: "/mock/skills-bundled/nature-paper2ppt", + }, + { + id: "nature-paper-to-patent", + name: "nature-paper-to-patent", + description: + "将科研论文、学位论文或技术报告转化为有证据支撑的中国发明专利草案,包含权利要求书、说明书、摘要及配图。", + status: "beta", + version: "1.0.0", + formCapability: "axes", + hasManifest: true, + alwaysLoad: ["static/core/patent.md"], + axes: [ + { + name: "source_format", + values: ["paper", "thesis", "report", "code", "figures"], + multi: false, + blockingGate: false, + defaultValue: "paper", + }, + ], + onDemand: [], + dir: "/mock/skills-bundled/nature-paper-to-patent", + }, + { + id: "nature-academic-search", + name: "nature-academic-search", + description: + "多源文献检索(PubMed/CrossRef/arXiv/Scopus)、引文核对、MeSH 检索策略与引文文件管理(.nbib/.ris/.bib 转换)。", + status: "beta", + version: "2.0.0", + formCapability: "axes", + hasManifest: true, + alwaysLoad: ["static/core/search.md"], + axes: [ + { + name: "workflow", + values: ["search", "verify", "manage", "convert"], + multi: false, + blockingGate: false, + defaultValue: "search", + }, + ], + onDemand: [], + dir: "/mock/skills-bundled/nature-academic-search", + }, + { + id: "nature-citation", + name: "nature-citation", + description: + "为稿件正文添加严格的 Nature/CNS 系列引用:将长段落拆分为可引用片段,仅检索旗舰/子刊标题,导出文献管理器可用文件。", + status: "stable", + version: "2.0.0", + formCapability: "manifestNoAxes", + hasManifest: true, + alwaysLoad: ["static/core/principles.md", "static/core/workflow.md"], + axes: [], + onDemand: [{ condition: "脚本用法与批处理策略", path: "references/script-usage.md" }], + dir: "/mock/skills-bundled/nature-citation", + }, + { + id: "nature-data", + name: "nature-data", + description: + "撰写、审核或修订 Nature 级别的数据可用性声明、数据仓库方案、数据集引用与 FAIR 元数据清单。", + status: "stable", + version: "2.0.0", + formCapability: "manifestNoAxes", + hasManifest: true, + alwaysLoad: ["static/core/data-availability.md"], + axes: [], + onDemand: [], + dir: "/mock/skills-bundled/nature-data", + }, + { + id: "nature-response", + name: "nature-response", + description: + "起草、审核或修订逐点审稿回复信,覆盖大修/小修、rebuttal、点对点回复审稿意见等学术修回场景。", + status: "beta", + version: "1.0.0", + formCapability: "manifestNoAxes", + hasManifest: true, + alwaysLoad: ["static/core/response.md"], + axes: [], + onDemand: [], + dir: "/mock/skills-bundled/nature-response", + }, +]; + +export const MOCK_ENGINE: EngineStatus = { + bin: "/usr/local/bin/codex", + version: "codex 1.2.0 (mock)", + loggedIn: true, +}; + +export const MOCK_DOCTOR: DoctorReport = { + engine: MOCK_ENGINE, + pyenv: { + uv: "/Users/mock/.local/bin/uv", + ready: true, + venv: "/Users/mock/.codex/skills-venv", + python: "/Users/mock/.codex/skills-venv/bin/python", + }, + tools: [ + { name: "codex", ok: true, path: "/usr/local/bin/codex" }, + { name: "uv", ok: true, path: "/Users/mock/.local/bin/uv" }, + { name: "python3", ok: true, path: "/usr/bin/python3" }, + { name: "node", ok: true, path: "/usr/local/bin/node" }, + ], +}; + +export const MOCK_SETUP_STATUS = { + skills: "ok", + pyenv: "ok", +}; + +// 1x1 透明 PNG 的 data URI,用于 convertFileSrc 的占位图。 +const PLACEHOLDER_PNG = + "data:image/svg+xml;utf8," + + encodeURIComponent( + '[mock 产物预览]' + ); + +export function mockPlaceholder(_path: string): string { + return PLACEHOLDER_PNG; +} diff --git a/src/mock/dialog.ts b/src/mock/dialog.ts new file mode 100644 index 0000000..08165b9 --- /dev/null +++ b/src/mock/dialog.ts @@ -0,0 +1,38 @@ +// ── Mock 实现:@tauri-apps/plugin-dialog 的纯浏览器替身 ── + +// 浏览器里用原生 file input 模拟文件对话框。 +// 为了不阻塞 UI,返回假路径字符串。 +export async function open( + options?: unknown, +): Promise { + console.debug("[mock] dialog.open()", options); + + // 用原生 弹出系统选择器 + return new Promise((resolve) => { + const input = document.createElement("input"); + input.type = "file"; + input.multiple = true; + input.style.position = "fixed"; + input.style.left = "-9999px"; + + input.addEventListener("change", () => { + const files = input.files; + if (!files || files.length === 0) { + resolve(null); + return; + } + // 浏览器无法获取真实路径,用文件名构造假路径 + const paths = Array.from(files).map( + (f) => `/mock/uploads/${f.name}`, + ); + resolve(paths.length === 1 ? paths[0] : paths); + }); + + // 用户取消时 change 不会触发,用 window focus 检测取消 + input.addEventListener("cancel", () => resolve(null)); + + document.body.appendChild(input); + input.click(); + document.body.removeChild(input); + }); +} diff --git a/src/mock/fs.ts b/src/mock/fs.ts new file mode 100644 index 0000000..08dc749 --- /dev/null +++ b/src/mock/fs.ts @@ -0,0 +1,14 @@ +// ── Mock 实现:@tauri-apps/plugin-fs 的纯浏览器替身 ── + +const MOCK_FILE_CONTENTS: Record = { + "/mock/workdir/chart_v1.png": "# mock 产物脚本内容", +}; + +export async function readTextFile(path: string): Promise { + console.debug(`[mock] readTextFile("${path}")`); + return MOCK_FILE_CONTENTS[path] ?? `# mock: ${path}\n\n(这是 mock 模式下的占位文件内容)`; +} + +export async function exists(_path: string): Promise { + return true; +} diff --git a/src/mock/opener.ts b/src/mock/opener.ts new file mode 100644 index 0000000..efd0a68 --- /dev/null +++ b/src/mock/opener.ts @@ -0,0 +1,15 @@ +// ── Mock 实现:@tauri-apps/plugin-opener 的纯浏览器替身 ── + +export async function openPath(path: string): Promise { + console.debug(`[mock] openPath("${path}")`); + // 浏览器里无法打开系统资源管理器,仅打印日志 +} + +export async function openUrl(url: string): Promise { + console.debug(`[mock] openUrl("${url}")`); + window.open(url, "_blank"); +} + +export async function revealItemInDir(path: string): Promise { + console.debug(`[mock] revealItemInDir("${path}")`); +} diff --git a/src/mock/tauri-core.ts b/src/mock/tauri-core.ts new file mode 100644 index 0000000..b7bce63 --- /dev/null +++ b/src/mock/tauri-core.ts @@ -0,0 +1,216 @@ +// ── Mock 实现:@tauri-apps/api/core 的纯浏览器替身 ── +// 仅在非 Tauri 的浏览器 dev 环境下通过 Vite alias 生效。 +// 业务代码完全无需修改,import 路径不变。 + +import type { DomainEvent } from "../types/engine"; +import { + MOCK_SKILLS, + MOCK_ENGINE, + MOCK_DOCTOR, + MOCK_SETUP_STATUS, + mockPlaceholder, +} from "./data"; + +/** + * Mock Channel —— 对应 Tauri 的 Channel。 + * 业务代码:new Channel() 后设置 onmessage 回调,再作为 invoke 参数传入。 + * Mock invoke 拿到实例后通过 __send() 模拟后端推送事件。 + */ +export class Channel { + onmessage: (response: T) => void = () => {}; + __send(message: T): void { + // 异步派发,模拟真实跨进程通信 + setTimeout(() => this.onmessage(message), 50); + } +} + +/** Mock convertFileSrc —— 把本地文件路径转成可访问的 URL(这里返回占位图)。 */ +export function convertFileSrc(filePath: string, _protocol = "asset"): string { + return mockPlaceholder(filePath); +} + +function delay(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +/** +/** Mock SVG 生成:根据 plot_spec + plot_data 画一个简化预览,供浏览器验证 UI 交互。 */ +function mockPlotSvg(plotSpec: Record, plotData: Record): string { + const series = (plotData.series as Array>) ?? []; + const title = String(plotSpec.title ?? ""); + const chartType = String(plotSpec.chart_type ?? "line"); + const style = (plotSpec.style as Record) ?? {}; + const w = 480; + const h = 340; + const pad = 45; + const fontFamily = String(style.font_family ?? "serif"); + const fontSize = Number(style.font_size ?? 12); + + let allY: number[] = []; + for (const s of series) { + if (s.visible === false) continue; + allY = allY.concat((s.y as number[]) ?? []); + } + if (allY.length === 0) allY = [0, 1]; + const yMin = Math.min(...allY); + const yMax = Math.max(...allY); + const yRange = yMax - yMin || 1; + const xMap = (i: number, n: number) => pad + (i / Math.max(n - 1, 1)) * (w - 2 * pad); + const yMap = (v: number) => h - pad - ((v - yMin) / yRange) * (h - 2 * pad); + + let shapes = ""; + for (const s of series) { + if (s.visible === false) continue; + const xs = (s.x as number[]) ?? []; + const ys = (s.y as number[]) ?? []; + const n = Math.min(xs.length, ys.length); + if (n === 0) continue; + const color = String(s.color ?? "#1a1a1a"); + const lw = Number(s.line_width ?? 1.5); + if (chartType === "bar") { + const bw = ((w - 2 * pad) / n) * 0.7; + for (let i = 0; i < n; i++) { + const by = yMap(ys[i]); + shapes += ``; + } + } else { + let d = ""; + for (let i = 0; i < n; i++) d += (i === 0 ? "M" : "L") + `${xMap(i, n)},${yMap(ys[i])} `; + shapes += ``; + } + } + + const gridLines = + style.grid !== false + ? `` + : ""; + const xLabel = String(plotSpec.x_label ?? ""); + const yLabel = String(plotSpec.y_label ?? ""); + + return `${title ? `${title}` : ""}${gridLines}${shapes}${xLabel}${yLabel}`; +} + +/** +* Mock invoke —— 拦截所有 Tauri 命令,返回假数据或模拟事件流。 +* 任何未覆盖的命令返回 undefined 并打印警告。 +*/ +export async function invoke( + cmd: string, + args?: Record, +): Promise { + console.debug(`[mock] invoke("${cmd}")`, args ?? {}); + + switch (cmd) { + case "check_engine": + await delay(300); + return MOCK_ENGINE as unknown as T; + + case "preview_plot": { + const req = (args?.request as Record) ?? {}; + await delay(250); + const svg = mockPlotSvg( + (req.plotSpec as Record) ?? {}, + (req.plotData as Record) ?? {}, + ); + return { + imageBase64: btoa(unescape(encodeURIComponent(svg))), + imageFormat: String(req.format ?? "svg"), + warnings: [], + } as unknown as T; + } + case "list_skills": + await delay(500); + return MOCK_SKILLS as unknown as T; + + case "check_doctor": + await delay(400); + return MOCK_DOCTOR as unknown as T; + + case "get_setup_status": + await delay(300); + return MOCK_SETUP_STATUS as unknown as T; + + case "install_skills": + await delay(1200); + return 0 as unknown as T; + + case "check_academic_search": + await delay(200); + return false as unknown as T; + + case "register_academic_search": + await delay(1500); + return undefined as unknown as T; + + case "prepare_pyenv": + await delay(1000); + return undefined as unknown as T; + + case "cancel_task": + return undefined as unknown as T; + + case "run_skill_task": { + // 模拟完整的任务事件流:start → reasoning → message → artifact → finished + const channel = args?.onEvent as Channel | undefined; + const taskId = "mock-" + Date.now().toString(36); + + const send = (ev: DomainEvent) => { + if (channel) channel.__send(ev); + }; + + // 异步推送事件序列(不阻塞 invoke 的 Promise resolve) + (async () => { + await delay(200); + send({ kind: "started", taskId, argv: ["codex", "--mock"] }); + send({ kind: "threadStarted", threadId: "thread-mock-001" }); + send({ kind: "turnStarted" }); + + await delay(600); + send({ + kind: "reasoning", + text: "正在分析任务要求,确定要加载的 skill 片段……\n这是 mock 模式下的模拟推理过程,用于预览界面。", + }); + + await delay(800); + send({ + kind: "assistantMessage", + text: + "这是 mock 模式下的模拟回复。在真实环境中,这里会显示 codex 引擎的输出。\n\n你可以基于这个界面布局进行 UI 调整,改动会通过热更新实时生效。", + }); + + send({ kind: "progress", text: "生成产物中……" }); + + await delay(500); + send({ kind: "plan", steps: [{ step: "分析输入", status: "completed" }] }); + + await delay(700); + send({ + kind: "artifact", + path: "/mock/workdir/chart_v1.png", + changeKind: "created", + }); + + await delay(400); + send({ + kind: "finished", + outcome: "success", + exitCode: 0, + threadId: "thread-mock-001", + usage: { + input_tokens: 3200, + cached_input_tokens: 1800, + output_tokens: 950, + reasoning_output_tokens: 420, + }, + artifactCount: 1, + }); + })(); + + return taskId as unknown as T; + } + + default: + console.warn(`[mock] 未覆盖的 invoke 命令:"${cmd}",返回 undefined`); + return undefined as unknown as T; + } +} diff --git a/src/types/plot.ts b/src/types/plot.ts new file mode 100644 index 0000000..cb9535f --- /dev/null +++ b/src/types/plot.ts @@ -0,0 +1,66 @@ +// 参数化绘图的数据契约 —— 与 Rust 侧 renderer.rs 的 PreviewRequest 对应(camelCase)。 +// plot_spec / plot_data 的 schema 移植自 Sci-Data-Analyzer 的 chart_renderer/schemas.py。 + +/** 一条可绘制的数据序列。 */ +export interface PlotSeries { + name: string; + x: number[] | string[]; + y: number[]; + color?: string; + line_width?: number; + visible?: boolean; +} + +/** 绘图数据。 */ +export interface PlotData { + schema_version?: number; + series: PlotSeries[]; + grid?: Record; +} + +/** 图表全局样式。 */ +export interface PlotStyle { + figure_size?: [number, number]; + dpi?: number; + font_family?: string; + font_size?: number; + line_width?: number; + grid?: boolean; + grid_alpha?: number; + colors?: string[]; + legend?: { + enabled?: boolean; + location?: string; + }; + spines?: { + enabled?: boolean; + width?: number | null; + color?: string; + }; + text?: { + title?: { font_family?: string; font_size?: number | null; color?: string | null }; + axis_label?: { font_family?: string; font_size?: number | null; color?: string | null }; + }; +} + +/** 绘图参数 —— 所有可能被微调的视觉属性。 */ +export interface PlotSpec { + schema_version?: number; + chart_type?: string; + output_format?: string; + title?: string; + x_label?: string; + y_label?: string; + x_unit?: string; + y_unit?: string; + style?: PlotStyle; + scales?: Record; + annotations?: unknown[]; +} + +/** 预览渲染结果。 */ +export interface PreviewResponse { + imageBase64: string; + imageFormat: string; + warnings: unknown[]; +} diff --git a/vite.config.ts b/vite.config.ts index ddad22a..acbfe49 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,13 +1,36 @@ +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; + import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; // @ts-expect-error process is a nodejs global const host = process.env.TAURI_DEV_HOST; +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// 纯浏览器 dev(非 tauri dev)时,用 mock 替身替换 Tauri 原生模块, +// 这样在 http://localhost:1420 就能预览完整界面。 +// tauri dev 会设置 TAURI_ENV_PLATFORM 等环境变量,据此区分两种环境。 +const isBrowserOnlyDev = !process.env.TAURI_ENV_PLATFORM; + +const mockAliases = isBrowserOnlyDev + ? [ + { find: "@tauri-apps/api/core", replacement: resolve(__dirname, "src/mock/tauri-core.ts") }, + { find: "@tauri-apps/plugin-dialog", replacement: resolve(__dirname, "src/mock/dialog.ts") }, + { find: "@tauri-apps/plugin-fs", replacement: resolve(__dirname, "src/mock/fs.ts") }, + { find: "@tauri-apps/plugin-opener", replacement: resolve(__dirname, "src/mock/opener.ts") }, + ] + : []; + // https://vite.dev/config/ export default defineConfig(async () => ({ plugins: [react()], + resolve: { + alias: mockAliases, + }, + // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` // // 1. prevent Vite from obscuring rust errors From 5ae04636ca11ecd906ac2aa98b5b901b2b0438d6 Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Sun, 21 Jun 2026 01:53:26 +0800 Subject: [PATCH 2/9] =?UTF-8?q?feat(chart-editor):=20=E8=A1=A5=E5=85=A8=20?= =?UTF-8?q?heatmap=20grid=20=E7=BC=96=E8=BE=91=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - plot.ts: 新增 PlotGrid 接口(mode/values/cmap/origin/z_label)和 CMAP_PRESETS 常量(16 个常用 matplotlib colormap) - ChartEditor: heatmap 专属编辑面板,含 colormap 下拉、origin 选择、 colorbar 标签输入、values 二维矩阵编辑器(行列增删 + 单元格数值编辑) - 渲染时注入默认 grid(用户切入 heatmap 但无数据时不报错) - mock: heatmap 色块矩阵预览(viridis 近似色阶) - CSS: 矩阵编辑器样式 同时修正: 后端技术栈确认为 Rust(Tauri),Python 仅作 Rust spawn 的 子进程执行绘图模板,非独立后端 --- src/App.css | 9 ++++ src/components/ChartEditor.tsx | 76 ++++++++++++++++++++++++++++++++-- src/mock/tauri-core.ts | 32 ++++++++++++++ src/types/plot.ts | 19 ++++++++- 4 files changed, 132 insertions(+), 4 deletions(-) diff --git a/src/App.css b/src/App.css index d03e876..0589400 100644 --- a/src/App.css +++ b/src/App.css @@ -335,3 +335,12 @@ button.danger { background: #b64342; color: #fff; border-color: #b64342; } .ce-entry-hint { font-size: 11px; color: #888; } button.ce-launch { background: #0f4d92; color: #fff; border: 1px solid #0f4d92; font-size: 13px; padding: 6px 14px; border-radius: 8px; cursor: pointer; } button.ce-launch:hover { background: #0a3d75; } + +/* 热图矩阵编辑器 */ +.ce-matrix-toolbar { display: flex; align-items: center; gap: 6px; } +.ce-matrix-size { font-size: 11px; color: #888; } +.ce-matrix-toolbar button { font-size: 11px; padding: 2px 8px; } +.ce-matrix { overflow-x: auto; display: flex; flex-direction: column; gap: 2px; } +.ce-matrix-row { display: flex; gap: 2px; } +.ce-matrix-cell { width: 42px; font-size: 11px; padding: 3px 4px; border: 1px solid #ccc; border-radius: 4px; text-align: center; } +select { font: inherit; font-size: 13px; padding: 5px 8px; border: 1px solid #ccc; border-radius: 6px; } diff --git a/src/components/ChartEditor.tsx b/src/components/ChartEditor.tsx index 99d573b..ebeffb5 100644 --- a/src/components/ChartEditor.tsx +++ b/src/components/ChartEditor.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState, type ReactNode } from "react"; import { previewPlot, toDataUrl, COLOR_PRESETS } from "../api/plotApi"; -import type { PlotSpec, PlotStyle, PlotData, PlotSeries } from "../types/plot"; +import type { PlotSpec, PlotStyle, PlotData, PlotSeries, PlotGrid } from "../types/plot"; +import { CMAP_PRESETS } from "../types/plot"; const CHART_TYPES = [ { value: "line", label: "折线图 Line" }, @@ -78,6 +79,21 @@ export default function ChartEditor({ initialSpec, initialData, onBack }: Props) setSpec((p) => ({ ...p, style: { ...p.style, ...patch } })); const updateSeries = (i: number, patch: Partial) => setData((p) => ({ ...p, series: p.series.map((s, idx) => (idx === i ? { ...s, ...patch } : s)) })); + const updateGrid = (patch: Partial) => + setData((p) => ({ ...p, grid: { ...p.grid, mode: "heatmap", ...patch } })); + // 确保 heatmap 切入时有默认 grid 数据 + const grid: PlotGrid = data.grid ?? { mode: "heatmap", values: [[1, 2, 3], [4, 5, 6], [7, 8, 9]], cmap: "viridis", origin: "lower" }; + const matrix: number[][] = grid.values ?? []; + const setCell = (r: number, c: number, v: number) => { + const next = matrix.map((row) => [...row]); + if (next[r]) next[r][c] = v; + updateGrid({ values: next }); + }; + const addRow = () => updateGrid({ values: [...matrix, Array(matrix[0]?.length ?? 3).fill(0)] }); + const delRow = () => updateGrid({ values: matrix.slice(0, -1) }); + const addCol = () => updateGrid({ values: matrix.map((row) => [...row, 0]) }); + const delCol = () => updateGrid({ values: matrix.map((row) => row.slice(0, -1)) }); + // 防抖预览:参数变化 → 400ms → 本地渲染 useEffect(() => { @@ -88,8 +104,13 @@ export default function ChartEditor({ initialSpec, initialData, onBack }: Props) payloadRef.current = payload; setLoading(true); setError(null); + // heatmap 切入时如果 data.grid 不存在,注入默认矩阵 + const renderData = + chartType === "heatmap" && !data.grid + ? { ...data, grid: { mode: "heatmap" as const, values: [[1, 2, 3], [4, 5, 6], [7, 8, 9]], cmap: "viridis", origin: "lower" as const } } + : data; try { - const resp = await previewPlot(chartType, spec, data, "svg"); + const resp = await previewPlot(chartType, spec, renderData, "svg"); setPreviewUrl(toDataUrl(resp.imageFormat, resp.imageBase64)); } catch (e) { setError(String(e)); @@ -103,7 +124,11 @@ export default function ChartEditor({ initialSpec, initialData, onBack }: Props) async function exportChart(fmt: "svg" | "png") { try { - const resp = await previewPlot(chartType, spec, data, fmt); + const renderData = + chartType === "heatmap" && !data.grid + ? { ...data, grid: { mode: "heatmap" as const, values: [[1, 2, 3], [4, 5, 6], [7, 8, 9]], cmap: "viridis", origin: "lower" as const } } + : data; + const resp = await previewPlot(chartType, spec, renderData, fmt); const url = toDataUrl(resp.imageFormat, resp.imageBase64); const a = document.createElement("a"); a.href = url; @@ -246,6 +271,51 @@ export default function ChartEditor({ initialSpec, initialData, onBack }: Props) ))} {series.length === 0 &&

无数据序列。

} + + {chartType === "heatmap" && ( +
+
+ + + + + + +
+ + updateGrid({ z_label: e.target.value })} placeholder="Intensity" /> + +
+ {matrix.length}行 × {matrix[0]?.length ?? 0}列 +
+ + + + +
+
+ {matrix.map((row, r) => ( +
+ {row.map((val, c) => ( + setCell(r, c, +e.target.value)} + /> + ))} +
+ ))} +
+
+ )} {/* ── 右侧预览 ── */} diff --git a/src/mock/tauri-core.ts b/src/mock/tauri-core.ts index b7bce63..fa38169 100644 --- a/src/mock/tauri-core.ts +++ b/src/mock/tauri-core.ts @@ -59,6 +59,38 @@ function mockPlotSvg(plotSpec: Record, plotData: Record h - pad - ((v - yMin) / yRange) * (h - 2 * pad); let shapes = ""; + if (chartType === "heatmap") { + const grid = (plotData.grid as Record) ?? {}; + const values = (grid.values as number[][]) ?? []; + // 简化 colormap:用 viridis 近似色阶 + const viridis: [number, number, number][] = [ + [68, 1, 84], [72, 36, 117], [64, 67, 136], [52, 94, 141], [41, 120, 142], + [32, 144, 140], [34, 167, 132], [68, 190, 112], [121, 209, 81], [189, 222, 38], [253, 231, 36], + ]; + const pick = (t: number): string => { + const idx = Math.max(0, Math.min(viridis.length - 1, Math.floor(t * (viridis.length - 1)))); + const [r, g, b] = viridis[idx]; + return `rgb(${r},${g},${b})`; + }; + const rows = values.length; + const cols = values[0]?.length ?? 0; + if (rows > 0 && cols > 0) { + const allV = values.flat(); + const vMin = Math.min(...allV); + const vMax = Math.max(...allV); + const vRange = vMax - vMin || 1; + const cw = (w - 2 * pad) / cols; + const ch = (h - 2 * pad) / rows; + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + const t = (values[r][c] - vMin) / vRange; + shapes += ``; + } + } + } + const zLabel = String((grid as Record).z_label ?? ""); + return `${title ? `${title}` : ""}${shapes}${String(plotSpec.x_label ?? "")}${String(plotSpec.y_label ?? "")}${zLabel ? `${zLabel}` : ""}`; + } for (const s of series) { if (s.visible === false) continue; const xs = (s.x as number[]) ?? []; diff --git a/src/types/plot.ts b/src/types/plot.ts index cb9535f..5750bb3 100644 --- a/src/types/plot.ts +++ b/src/types/plot.ts @@ -15,7 +15,17 @@ export interface PlotSeries { export interface PlotData { schema_version?: number; series: PlotSeries[]; - grid?: Record; + grid?: PlotGrid; +} + +/** 热图网格数据。 */ +export interface PlotGrid { + mode?: "heatmap" | "image"; + values?: number[][]; + cmap?: string; + origin?: "upper" | "lower"; + z_label?: string; + png_base64?: string; } /** 图表全局样式。 */ @@ -64,3 +74,10 @@ export interface PreviewResponse { imageFormat: string; warnings: unknown[]; } + +/** 常用 matplotlib colormap。 */ +export const CMAP_PRESETS = [ + "viridis", "plasma", "inferno", "magma", "cividis", + "coolwarm", "RdBu", "bwr", "seismic", "jet", + "Blues", "Reds", "Greens", "Greys", "hot", "rainbow", +]; From a1a1ef73c574cf2c901cf55f5f2b64fa76b967c9 Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Sun, 21 Jun 2026 01:56:46 +0800 Subject: [PATCH 3/9] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20Codex=20review?= =?UTF-8?q?=20=E7=9A=84=203=20=E4=B8=AA=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1: renderer.rs 打包路径修正 — Tauri 打包后资源保留 resources/ 前缀, 原来少了这层导致发布版找不到模板(崩溃)。补上 resources/ 前缀。 P2: dual_y_plot.py 添加 visible 检查 — 编辑器隐藏序列时双 Y 轴模板 仍然渲染,现在与 line/bar 模板行为一致。 P2: renderer.rs Python 环境就绪检测 — 用 is_ready()(检查 python 存在 + marker 版本匹配)替代仅检查路径存在,避免依赖安装中途渲染失败 --- .../resources/chart_renderer/templates/dual_y_plot.py | 2 ++ src-tauri/src/renderer.rs | 9 ++++----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src-tauri/resources/chart_renderer/templates/dual_y_plot.py b/src-tauri/resources/chart_renderer/templates/dual_y_plot.py index 63173d5..8de498a 100644 --- a/src-tauri/resources/chart_renderer/templates/dual_y_plot.py +++ b/src-tauri/resources/chart_renderer/templates/dual_y_plot.py @@ -75,6 +75,8 @@ def main() -> int: datetime_plotted = False for idx, series in enumerate(series_list): x = series.get("x") or [] + if series.get("visible") is False: + continue y = series.get("y") or [] name = str(series.get("name") or f"series_{idx + 1}") axis = str(series_axis.get(name) or "y").lower() diff --git a/src-tauri/src/renderer.rs b/src-tauri/src/renderer.rs index 38d6c7c..116e26a 100644 --- a/src-tauri/src/renderer.rs +++ b/src-tauri/src/renderer.rs @@ -57,7 +57,7 @@ fn resolve_template(app: &AppHandle, name: &str) -> Result { let resource = app.path().resource_dir().ok(); let dev = PathBuf::from(env!("CARGO_MANIFEST_DIR")); let candidates: [Option; 2] = [ - resource.map(|d| d.join("chart_renderer/templates").join(name)), + resource.map(|d| d.join("resources/chart_renderer/templates").join(name)), Some(dev.join("resources/chart_renderer/templates").join(name)), ]; for c in candidates.into_iter().flatten() { @@ -110,10 +110,9 @@ pub async fn preview_plot( fs::copy(&template_path, work.join("plot.py")) .map_err(|e| format!("复制模板失败: {e}"))?; - // 6. 获取 python(优先 uv venv,回退系统 python3) - let venv_py = pyenv::venv_python(); - let python: PathBuf = if venv_py.exists() { - venv_py + // 6. 获取 python(优先已就绪的 uv venv,回退系统 python3) + let python: PathBuf = if pyenv::is_ready() { + pyenv::venv_python() } else { PathBuf::from("python3") }; From 635457cc001e5334753e8c4355ca05e3a95cd960 Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Sun, 21 Jun 2026 02:01:50 +0800 Subject: [PATCH 4/9] =?UTF-8?q?chore:=20=E8=A7=A6=E5=8F=91=20CI=20?= =?UTF-8?q?=E9=87=8D=E8=B7=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 0c4c026fd82ad24ab0063715abd6ec8e771fdff0 Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Sun, 21 Jun 2026 02:05:01 +0800 Subject: [PATCH 5/9] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E7=AB=9E=E6=80=81=E6=9D=A1=E4=BB=B6=20(Codex=20review=20P2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChartEditor 防抖预览存在竞态:用户快速连续调整参数时多个渲染请求 同时在途,旧的请求可能比新的晚返回并覆盖预览,导致显示过期参数 的结果。 修复:用递增 reqIdRef 跟踪每次请求,响应返回时检查是否仍为最新, 过期响应直接丢弃。 --- src/components/ChartEditor.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/ChartEditor.tsx b/src/components/ChartEditor.tsx index ebeffb5..4db3e1a 100644 --- a/src/components/ChartEditor.tsx +++ b/src/components/ChartEditor.tsx @@ -69,6 +69,7 @@ export default function ChartEditor({ initialSpec, initialData, onBack }: Props) const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const payloadRef = useRef(""); + const reqIdRef = useRef(0); const chartType = spec.chart_type || "line"; const st: PlotStyle = spec.style ?? {}; @@ -102,6 +103,7 @@ export default function ChartEditor({ initialSpec, initialData, onBack }: Props) const timer = setTimeout(async () => { payloadRef.current = payload; + const myId = ++reqIdRef.current; setLoading(true); setError(null); // heatmap 切入时如果 data.grid 不存在,注入默认矩阵 @@ -111,11 +113,13 @@ export default function ChartEditor({ initialSpec, initialData, onBack }: Props) : data; try { const resp = await previewPlot(chartType, spec, renderData, "svg"); + if (myId !== reqIdRef.current) return; // 丢弃过期响应 setPreviewUrl(toDataUrl(resp.imageFormat, resp.imageBase64)); } catch (e) { - setError(String(e)); + if (myId !== reqIdRef.current) return; + setError(String(e)); } finally { - setLoading(false); + if (myId === reqIdRef.current) setLoading(false); } }, 400); From 883db71052bbb34f5337c6e2ba8fea67ac7a9eb8 Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Sun, 21 Jun 2026 02:06:21 +0800 Subject: [PATCH 6/9] =?UTF-8?q?chore:=20=E8=A7=A6=E5=8F=91=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 3822df05c01a35d88f9de5cb14aae1d3458a88d9 Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Sun, 21 Jun 2026 02:14:28 +0800 Subject: [PATCH 7/9] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20Codex=20review?= =?UTF-8?q?=20=E7=AC=AC=E4=BA=8C=E8=BD=AE=203=20=E4=B8=AA=20P2=20=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. ChartEditor 竞态修复不完整: reqIdRef 递增应在参数变化时立即执行, 而非等 400ms 防抖后,否则旧请求仍有窗口通过 ID 检查。 2. App.tsx 旧参数未清除: 任务失败或切换工作目录时不清除 loadedSpec/loadedData,编辑器仍渲染过期数据。现在 catch 和 launch 开始时都清除。 3. renderer.rs HOME 覆盖: 回退系统 python3 时覆盖 HOME 导致 pip install --user 的包不可见。改用 MPLCONFIGDIR/XDG_CACHE_HOME 隔离缓存,保留 HOME。 --- src-tauri/src/renderer.rs | 5 ++++- src/App.tsx | 6 +++++- src/components/ChartEditor.tsx | 3 ++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/renderer.rs b/src-tauri/src/renderer.rs index 116e26a..cb3bad3 100644 --- a/src-tauri/src/renderer.rs +++ b/src-tauri/src/renderer.rs @@ -125,7 +125,10 @@ pub async fn preview_plot( Command::new(&py) .arg(&script) .env("MPLBACKEND", "Agg") - .env("HOME", &cwd) + // 用 MPLCONFIGDIR/XDG_CACHE_HOME 隔离缓存,不覆盖 HOME + // (否则回退到系统 python3 时,pip install --user 的包会找不到) + .env("MPLCONFIGDIR", cwd.join("mpl")) + .env("XDG_CACHE_HOME", cwd.join("cache")) .env("PYTHONUNBUFFERED", "1") .current_dir(&cwd) .output() diff --git a/src/App.tsx b/src/App.tsx index 9c69ee8..b585c86 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -233,7 +233,9 @@ function RunView({ setLoadedData(JSON.parse(dataStr)); push("已加载绘图参数,可点击「图表微调」实时调整", "ok"); } catch { - // 参数文件不存在(旧版产物或 mock 模式),保留演示数据 + // 参数文件不存在或解析失败,清除旧参数避免编辑器渲染过期数据 + setLoadedSpec(null); + setLoadedData(null); } } @@ -258,6 +260,8 @@ function RunView({ setArtifacts([]); setResult(null); setTokens({ in: 0, out: 0 }); + setLoadedSpec(null); + setLoadedData(null); setRunning(true); taskIdRef.current = null; gotResultRef.current = false; diff --git a/src/components/ChartEditor.tsx b/src/components/ChartEditor.tsx index 4db3e1a..27f4505 100644 --- a/src/components/ChartEditor.tsx +++ b/src/components/ChartEditor.tsx @@ -100,10 +100,11 @@ export default function ChartEditor({ initialSpec, initialData, onBack }: Props) useEffect(() => { const payload = JSON.stringify({ chartType, spec, data }); if (payload === payloadRef.current) return; + // 参数变化时立即使旧的在途请求失效(不等 400ms 防抖) + const myId = ++reqIdRef.current; const timer = setTimeout(async () => { payloadRef.current = payload; - const myId = ++reqIdRef.current; setLoading(true); setError(null); // heatmap 切入时如果 data.grid 不存在,注入默认矩阵 From 7edf6fca8caf16b2a330f14cc80916941fabf763 Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Sun, 21 Jun 2026 02:14:36 +0800 Subject: [PATCH 8/9] =?UTF-8?q?chore:=20=E8=A7=A6=E5=8F=91=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From a6832603c6e399df3142b2a1d509b8e23c057455 Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Sun, 21 Jun 2026 02:51:16 +0800 Subject: [PATCH 9/9] =?UTF-8?q?feat(datatype):=20=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E6=84=9F=E7=9F=A5=E7=BB=98=E5=9B=BE=20+=20pr?= =?UTF-8?q?ompt=20=E9=A2=86=E5=9F=9F=E7=9F=A5=E8=AF=86=E6=B3=A8=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 借鉴 Sci-Data-Analyzer 的数据导入和识别模块,保留 nature-app 已有的 prompt+图型选择流程,新增可选的数据类型选择能力。 后端(Rust): - 多级结构化数据类型注册模块 datatypes/:mod.rs 入口 + 四个大类文件 (spectroscopy/thermal/electrochem/general),12 个数据类型,含描述、 典型轴标签/单位、列名关键词。新增大类只需加一个子模块文件。 - 纯 Rust 数据文件解析器 reader.rs:读取 CSV/TXT,自动检测分隔符, 跳过表头,提取数值列,计算 min/max/单调性 - 两个 Tauri 命令:get_data_types(返回类型列表)、resolve_data(解析文件) - 删除原系统的自动识别评分引擎和 LLM 兜底(本项目用户手动选择) Skill: - manifest.yaml 新增 data_type axis(可选,非 blocking gate) - 11 个数据类型选项(FTIR/Raman/UV-Vis/XRD/XPS/PL/TGA/DSC/CV/EIS/通用) 前端: - DynamicForm 适配 data_type 选择项 + axisValueLabels 中文标签映射 - App.tsx prompt 注入:选了数据类型时,在 instruction 里注入领域知识 描述区块(如 FTIR 的 X 轴方向惯例、典型波数范围、官能团标注建议) - DynamicFormResult 新增 axisSelections 字段 - mock 适配 get_data_types / resolve_data --- skills-bundled/nature-figure/manifest.yaml | 25 ++- src-tauri/Cargo.lock | 22 +++ src-tauri/Cargo.toml | 1 + src-tauri/src/datatypes/electrochem.rs | 38 +++++ src-tauri/src/datatypes/general.rs | 30 ++++ src-tauri/src/datatypes/mod.rs | 114 ++++++++++++++ src-tauri/src/datatypes/reader.rs | 170 +++++++++++++++++++++ src-tauri/src/datatypes/spectroscopy.rs | 98 ++++++++++++ src-tauri/src/datatypes/thermal.rs | 39 +++++ src-tauri/src/lib.rs | 5 +- src/App.tsx | 29 +++- src/components/DynamicForm.tsx | 13 +- src/mock/data.ts | 7 + src/mock/tauri-core.ts | 33 +++- 14 files changed, 612 insertions(+), 12 deletions(-) create mode 100644 src-tauri/src/datatypes/electrochem.rs create mode 100644 src-tauri/src/datatypes/general.rs create mode 100644 src-tauri/src/datatypes/mod.rs create mode 100644 src-tauri/src/datatypes/reader.rs create mode 100644 src-tauri/src/datatypes/spectroscopy.rs create mode 100644 src-tauri/src/datatypes/thermal.rs diff --git a/skills-bundled/nature-figure/manifest.yaml b/skills-bundled/nature-figure/manifest.yaml index c831d3c..1e20e1b 100644 --- a/skills-bundled/nature-figure/manifest.yaml +++ b/skills-bundled/nature-figure/manifest.yaml @@ -25,8 +25,29 @@ axes: Once selected, the backend is exclusive for all drawing, previewing, exporting, and visual QA. values: - python: static/fragments/backend/python.md - r: static/fragments/backend/r.md + python: static/fragments/backend/python.md + r: static/fragments/backend/r.md + multi: false + + data_type: + detect: | + Optional. The user may specify the type of scientific data they are + plotting (e.g. FTIR, XRD, TGA, CV). If provided, use the domain-specific + guidance injected in the instruction to set correct axis directions, + units, annotations, and conventions for that data type. If not provided, + proceed with general plotting best practices. + values: + general: 通用数据 + spectroscopy.ftir: FTIR 红外光谱 + spectroscopy.raman: Raman 拉曼光谱 + spectroscopy.uvvis: UV-Vis 紫外可见光谱 + spectroscopy.xrd: XRD X射线衍射 + spectroscopy.xps: XPS 光电子能谱 + spectroscopy.pl: PL 光致发光光谱 + thermal.tga: TGA 热重分析 + thermal.dsc: DSC 差示扫描量热 + electrochem.cv: CV 循环伏安 + electrochem.eis: EIS 电化学阻抗 multi: false references: diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8f6ada6..8129bfc 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -600,6 +600,27 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + [[package]] name = "ctor" version = "0.8.0" @@ -2028,6 +2049,7 @@ name = "nature-app" version = "0.1.0" dependencies = [ "base64 0.22.1", + "csv", "dashmap", "serde", "serde_json", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6a31f26..2ddc7c3 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -29,3 +29,4 @@ uuid = { version = "1", features = ["v4"] } dashmap = "6" base64 = "0.22" tempfile = "3" +csv = "1.3" diff --git a/src-tauri/src/datatypes/electrochem.rs b/src-tauri/src/datatypes/electrochem.rs new file mode 100644 index 0000000..b44932a --- /dev/null +++ b/src-tauri/src/datatypes/electrochem.rs @@ -0,0 +1,38 @@ +//! 电化学类数据类型 —— CV 循环伏安 / EIS 阻抗。 + +use super::DataTypeDescriptor; + +pub fn types() -> Vec { + vec![ + DataTypeDescriptor { + id: "electrochem.cv".into(), + label: "CV 循环伏安".into(), + category: "electrochem".into(), + prompt_fragment: concat!( + "这是循环伏安法(CV)数据。横轴为电位(potential),单位 V (vs. 参比电极),纵轴为电流(current),单位 A 或 mA/cm²。", + "CV 曲线呈闭环特征(正向扫描和反向扫描),请确保两个方向的曲线在同一张图上用不同样式(实线/虚线或颜色)区分。", + "如有多个扫描速率的数据,用瀑布图或叠加图展示速率依赖性。标注氧化峰/还原峰电位和电流。", + ).into(), + typical_x_label: Some("Potential".into()), + typical_x_unit: Some("V".into()), + typical_y_label: Some("Current".into()), + typical_y_unit: Some("mA/cm²".into()), + x_column_keywords: ["potential", "voltage", "v", "电位", "电压"].to_vec(), + }, + DataTypeDescriptor { + id: "electrochem.eis".into(), + label: "EIS 电化学阻抗".into(), + category: "electrochem".into(), + prompt_fragment: concat!( + "这是电化学阻抗谱(EIS)数据,通常需要绘制 Nyquist 图(实部 Z' 为 X 轴,负虚部 -Z'' 为 Y 轴)。", + "如果数据包含频率列,也可以绘制 Bode 图(频率对模阻抗,频率对相角)。", + "Nyquist 图的 X 轴和 Y 轴应等比例(aspect ratio = 1),半圆弧可拟合等效电路。多组数据用不同颜色。", + ).into(), + typical_x_label: Some("Z'".into()), + typical_x_unit: Some("Ω".into()), + typical_y_label: Some("-Z''".into()), + typical_y_unit: Some("Ω".into()), + x_column_keywords: ["z_real", "z'", "re", "impedance", "frequency", "freq", "阻抗"].to_vec(), + }, + ] +} diff --git a/src-tauri/src/datatypes/general.rs b/src-tauri/src/datatypes/general.rs new file mode 100644 index 0000000..dfb289c --- /dev/null +++ b/src-tauri/src/datatypes/general.rs @@ -0,0 +1,30 @@ +//! 通用 / 未分类数据类型。 + +use super::DataTypeDescriptor; + +pub fn types() -> Vec { + vec![ + DataTypeDescriptor { + id: "general".into(), + label: "通用数据".into(), + category: "general".into(), + prompt_fragment: "这是一组通用 XY 数据。请根据数据特征选择合适的图型(折线/散点/柱状),自动推断坐标轴标签和单位。如果用户提供了多列 Y 值,考虑在同一图中用不同颜色绘制多条曲线并添加图例。".into(), + typical_x_label: None, + typical_x_unit: None, + typical_y_label: None, + typical_y_unit: None, + x_column_keywords: ["x", "time", "t", "index"].to_vec(), + }, + DataTypeDescriptor { + id: "general.timeseries".into(), + label: "时间序列".into(), + category: "general".into(), + prompt_fragment: "这是时间序列数据,横轴为时间。请绘制折线或面积图,注意时间轴格式(日期/时间戳),适当使用日期格式化。如果有多条序列,用不同颜色区分并加图例。时间轴标签应避免重叠,考虑旋转或稀疏化。".into(), + typical_x_label: Some("Time".into()), + typical_x_unit: Some("s".into()), + typical_y_label: None, + typical_y_unit: None, + x_column_keywords: ["time", "date", "timestamp", "t", "秒", "时间"].to_vec(), + }, + ] +} diff --git a/src-tauri/src/datatypes/mod.rs b/src-tauri/src/datatypes/mod.rs new file mode 100644 index 0000000..02d6433 --- /dev/null +++ b/src-tauri/src/datatypes/mod.rs @@ -0,0 +1,114 @@ +//! 数据类型注册模块 —— 多级结构化,按科研数据大类拆分。 +//! +//! 每个大类(spectroscopy / thermal / electrochem / general)一个子模块文件, +//! 子模块内定义该类下所有数据类型的描述、元数据和列名关键词。 +//! 新增大类时只需加一个子模块文件 + 在 `all_types()` 注册,避免单文件膨胀。 +//! +//! 借鉴自 Sci-Data-Analyzer 的 data_processor 模块,但: +//! - 删除自动识别评分引擎(本项目用户手动选择数据类型) +//! - 删除 LLM 兜底识别(codex 自身就是 LLM) +//! - 保留数据类型描述、典型轴标签、列名关键词等静态元数据 + +use serde::Serialize; + +pub mod electrochem; +pub mod general; +pub mod reader; +pub mod spectroscopy; +pub mod thermal; + +/// 一个数据类型的完整描述。 +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DataTypeDescriptor { + /// 类型 ID,如 "spectroscopy.ftir" + pub id: String, + /// 中文显示名 + pub label: String, + /// 大类 ID,如 "spectroscopy" + pub category: String, + /// 注入 codex prompt 的领域知识描述 + pub prompt_fragment: String, + /// 典型 X 轴标签 + pub typical_x_label: Option, + /// 典型 X 轴单位 + pub typical_x_unit: Option, + /// 典型 Y 轴标签 + pub typical_y_label: Option, + /// 典型 Y 轴单位 + pub typical_y_unit: Option, + /// 用于辅助判断的列名关键词(供文件解析时提示,不用于强制识别) + pub x_column_keywords: Vec<&'static str>, +} + +/// 所有数据大类的前端展示信息。 +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DataCategory { + pub id: String, + pub label: String, +} + +/// 注册所有数据大类(供前端展示分组)。 +pub fn all_categories() -> Vec { + vec![ + DataCategory { + id: "general".into(), + label: "通用 / 未分类".into(), + }, + DataCategory { + id: "spectroscopy".into(), + label: "光谱 Spectroscopy".into(), + }, + DataCategory { + id: "thermal".into(), + label: "热分析 Thermal".into(), + }, + DataCategory { + id: "electrochem".into(), + label: "电化学 Electrochemistry".into(), + }, + ] +} + +/// 注册所有数据类型(合并所有大类)。 +pub fn all_types() -> Vec { + let mut all = Vec::new(); + all.extend(general::types()); + all.extend(spectroscopy::types()); + all.extend(thermal::types()); + all.extend(electrochem::types()); + all +} + +/// 按 ID 查找数据类型描述。 +pub fn find(id: &str) -> Option { + all_types().into_iter().find(|t| t.id == id) +} + +/// 获取数据类型的 prompt 注入片段;找不到时返回空串。 +pub fn get_prompt_fragment(id: &str) -> String { + find(id) + .map(|t| t.prompt_fragment) + .unwrap_or_default() +} + +// ── Tauri 命令 ── + +use std::path::PathBuf; + +/// 前端获取所有数据类型(分组)和大类列表。 +#[tauri::command] +pub async fn get_data_types() -> Result<(Vec, Vec), String> { + Ok((all_categories(), all_types())) +} + +/// 解析用户选择的数据文件,返回数值列 + 统计信息。 +#[tauri::command] +pub async fn resolve_data(path: String) -> Result { + let p = PathBuf::from(&path); + if !p.exists() { + return Err(format!("文件不存在: {path}")); + } + reader::parse_file(&p) +} diff --git a/src-tauri/src/datatypes/reader.rs b/src-tauri/src/datatypes/reader.rs new file mode 100644 index 0000000..997075c --- /dev/null +++ b/src-tauri/src/datatypes/reader.rs @@ -0,0 +1,170 @@ +//! 数据文件解析器 —— 读取 CSV/TXT,提取数值列。 +//! +//! 不依赖 pandas,纯 Rust 实现。自动处理: +//! - 分隔符检测(逗号/制表符/空格/分号) +//! - 表头跳过(非数字行) +//! - 数值列提取(x + 1~N 个 y 列) +//! - min/max/单调性统计(供前端提示和 prompt 注入) + +use serde::Serialize; +use std::path::Path; + +/// 一列解析结果。 +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ParsedColumn { + pub name: String, + pub values: Vec, + pub min: f64, + pub max: f64, + /// true = 单调递增, false = 单调递减, null = 非单调 + pub monotonic: Option, +} + +/// 文件解析结果。 +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ParsedData { + pub columns: Vec, + pub row_count: usize, + /// 猜测的 X 列索引(第一列) + pub x_column_index: usize, + /// 猜测的 Y 列索引列表(第二列及之后) + pub y_column_indices: Vec, +} + +/// 检测分隔符:统计首行各种分隔符出现次数,取最多的。 +fn detect_delimiter(line: &str) -> char { + let candidates = [',', '\t', ';', ' ', '|']; + let mut best = ','; + let mut best_count = 0; + for c in candidates { + let count = line.matches(c).count(); + if count > best_count { + best = c; + best_count = count; + } + } + best +} + +/// 尝试把字符串解析为 f64,兼容科学计数法和各种小数点。 +fn try_parse_f64(s: &str) -> Option { + let s = s.trim().replace(',', "."); // 欧洲小数点兼容 + s.parse::().ok() +} + +/// 判断一行是否全是数字(用于跳过表头)。 +fn is_numeric_row(fields: &[&str]) -> bool { + fields.iter().filter(|f| !f.trim().is_empty()).all(|f| try_parse_f64(f).is_some()) +} + +/// 计算一列的单调性。 +fn check_monotonic(values: &[f64]) -> Option { + if values.len() < 2 { + return None; + } + let mut increasing = true; + let mut decreasing = true; + for i in 1..values.len() { + if values[i] > values[i - 1] { + decreasing = false; + } else if values[i] < values[i - 1] { + increasing = false; + } + } + if increasing { + Some(true) + } else if decreasing { + Some(false) + } else { + None + } +} + +/// 解析数据文件。 +pub fn parse_file(path: &Path) -> Result { + let content = std::fs::read_to_string(path) + .map_err(|e| format!("读取文件失败: {e}"))?; + + // 统一换行 + let content = content.replace("\r\n", "\n").replace('\r', "\n"); + let lines: Vec<&str> = content.lines().collect(); + if lines.is_empty() { + return Err("文件为空".into()); + } + + // 检测分隔符 + let delim = detect_delimiter(lines[0]); + + // 找到数据起始行(跳过表头/注释) + let mut header: Vec = Vec::new(); + let mut data_start = 0; + for (i, line) in lines.iter().enumerate() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + let fields: Vec<&str> = trimmed.split(delim).collect(); + if is_numeric_row(&fields) { + data_start = i; + break; + } + // 非数字行 → 当作表头 + if header.is_empty() { + header = fields.iter().map(|f| f.trim().to_string()).collect(); + } + data_start = i + 1; + } + + // 按列收集数据 + let mut col_data: Vec> = Vec::new(); + for line in &lines[data_start..] { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + let fields: Vec<&str> = trimmed.split(delim).collect(); + for (i, f) in fields.iter().enumerate() { + if let Some(v) = try_parse_f64(f) { + while col_data.len() <= i { + col_data.push(Vec::new()); + } + col_data[i].push(v); + } + } + } + + if col_data.is_empty() { + return Err("未找到数值数据".into()); + } + + let n_cols = col_data.len(); + let mut columns = Vec::with_capacity(n_cols); + for (i, values) in col_data.into_iter().enumerate() { + if values.is_empty() { + continue; + } + let min = values.iter().cloned().fold(f64::INFINITY, f64::min); + let max = values.iter().cloned().fold(f64::NEG_INFINITY, f64::max); + let monotonic = check_monotonic(&values); + let name = header.get(i).cloned().unwrap_or_else(|| format!("col_{}", i + 1)); + columns.push(ParsedColumn { + name, + values, + min, + max, + monotonic, + }); + } + + let row_count = columns.first().map(|c| c.values.len()).unwrap_or(0); + let y_column_indices: Vec = (1..columns.len()).collect(); + + Ok(ParsedData { + columns, + row_count, + x_column_index: 0, + y_column_indices, + }) +} diff --git a/src-tauri/src/datatypes/spectroscopy.rs b/src-tauri/src/datatypes/spectroscopy.rs new file mode 100644 index 0000000..013c49f --- /dev/null +++ b/src-tauri/src/datatypes/spectroscopy.rs @@ -0,0 +1,98 @@ +//! 光谱类数据类型 —— FTIR / Raman / UV-Vis / XRD / XPS / PL。 + +use super::DataTypeDescriptor; + +pub fn types() -> Vec { + vec![ + DataTypeDescriptor { + id: "spectroscopy.ftir".into(), + label: "FTIR 红外光谱".into(), + category: "spectroscopy".into(), + prompt_fragment: concat!( + "这是傅里叶变换红外光谱(FTIR)数据。横轴为波数(wavenumber),范围通常为 4000-400 cm⁻¹,**X 轴方向从高到低(从左到右递减)是学术界惯例**,请务必按此惯例绘制。纵轴为吸光度(absorbance,向上为峰)或透过率(transmittance,谷为吸收带)。", + "请在关键吸收带处标注官能团(如 O-H ~3400, C-H ~2900, C=O ~1700, C=C ~1600, C-O ~1100 cm⁻¹)。", + "如果有多条谱线(如不同样品),用不同颜色叠加并加图例。Y 轴若为透过率范围通常 0-100% T,若为吸光度则通常 0-2 左右。", + ).into(), + typical_x_label: Some("Wavenumber".into()), + typical_x_unit: Some("cm⁻¹".into()), + typical_y_label: Some("Absorbance".into()), + typical_y_unit: None, + x_column_keywords: ["wavenumber", "wave_number", "cm-1", "cm⁻¹", "波数"].to_vec(), + }, + DataTypeDescriptor { + id: "spectroscopy.raman".into(), + label: "Raman 拉曼光谱".into(), + category: "spectroscopy".into(), + prompt_fragment: concat!( + "这是拉曼光谱(Raman)数据。横轴为拉曼位移(Raman shift),单位 cm⁻¹,范围通常为 100-3200 cm⁻¹。纵轴为强度(intensity)。", + "请在特征峰处标注振动模式或化学键(如 D-band ~1350, G-band ~1580 cm⁻¹ for碳材料)。", + "多组数据用不同颜色叠加,图例标注样品信息。", + ).into(), + typical_x_label: Some("Raman Shift".into()), + typical_x_unit: Some("cm⁻¹".into()), + typical_y_label: Some("Intensity".into()), + typical_y_unit: Some("a.u.".into()), + x_column_keywords: ["raman", "shift", "cm-1", "cm⁻¹", "拉曼"].to_vec(), + }, + DataTypeDescriptor { + id: "spectroscopy.uvvis".into(), + label: "UV-Vis 紫外可见光谱".into(), + category: "spectroscopy".into(), + prompt_fragment: concat!( + "这是紫外-可见吸收光谱(UV-Vis)数据。横轴为波长(wavelength),单位 nm,范围通常为 200-800 nm。纵轴为吸光度(absorbance)或透过率。", + "请在吸收峰处标注对应的波长值。如果需要计算带隙(用 Tauc plot),请注意方法选择。", + "多组数据叠加时用不同颜色,图例标注样品。", + ).into(), + typical_x_label: Some("Wavelength".into()), + typical_x_unit: Some("nm".into()), + typical_y_label: Some("Absorbance".into()), + typical_y_unit: None, + x_column_keywords: ["wavelength", "nm", "波长", "uv", "vis"].to_vec(), + }, + DataTypeDescriptor { + id: "spectroscopy.xrd".into(), + label: "XRD X 射线衍射".into(), + category: "spectroscopy".into(), + prompt_fragment: concat!( + "这是 X 射线衍射(XRD)数据。横轴为衍射角 2θ(two-theta),单位度(°),范围通常为 5-90°。纵轴为强度(intensity)。", + "请在主要衍射峰处标注晶面指数(如 (111), (200) 等)或对应的 2θ 角度值。", + "如果有标准卡片(JCPDS/PDF)进行对比,用竖线或底部刻度标注标准峰位置。多组样品叠加时用不同颜色。", + ).into(), + typical_x_label: Some("2θ".into()), + typical_x_unit: Some("°".into()), + typical_y_label: Some("Intensity".into()), + typical_y_unit: Some("a.u.".into()), + x_column_keywords: ["2theta", "two_theta", "2θ", "angle", "deg", "角度"].to_vec(), + }, + DataTypeDescriptor { + id: "spectroscopy.xps".into(), + label: "XPS 光电子能谱".into(), + category: "spectroscopy".into(), + prompt_fragment: concat!( + "这是 X 射线光电子能谱(XPS)数据。横轴为结合能(binding energy),单位 eV。**X 轴方向从高到低(从左到右递减)是惯例**,请务必按此绘制。", + "纵轴为强度(counts 或 cps)。请在特征峰处标注元素和轨道(如 C 1s, O 1s, N 1s)。", + "如果做了分峰拟合,用不同颜色的拟合曲线和残差展示。C 1s 校准参考值通常为 284.8 eV。", + ).into(), + typical_x_label: Some("Binding Energy".into()), + typical_x_unit: Some("eV".into()), + typical_y_label: Some("Intensity".into()), + typical_y_unit: Some("cps".into()), + x_column_keywords: ["binding_energy", "energy", "ev", "eV", "结合能", "xps"].to_vec(), + }, + DataTypeDescriptor { + id: "spectroscopy.pl".into(), + label: "PL 光致发光光谱".into(), + category: "spectroscopy".into(), + prompt_fragment: concat!( + "这是光致发光光谱(PL)数据。横轴为波长(wavelength, nm)或能量(eV),纵轴为发光强度(PL intensity)。", + "请在发射峰处标注峰值波长或能量。如果是温度/功率依赖的 PL,考虑瀑布图或等高线图。", + "多组数据叠加时用不同颜色,图例标注条件。", + ).into(), + typical_x_label: Some("Wavelength".into()), + typical_x_unit: Some("nm".into()), + typical_y_label: Some("PL Intensity".into()), + typical_y_unit: Some("a.u.".into()), + x_column_keywords: ["wavelength", "nm", "pl", "emission", "发光", "波长"].to_vec(), + }, + ] +} diff --git a/src-tauri/src/datatypes/thermal.rs b/src-tauri/src/datatypes/thermal.rs new file mode 100644 index 0000000..84a5103 --- /dev/null +++ b/src-tauri/src/datatypes/thermal.rs @@ -0,0 +1,39 @@ +//! 热分析类数据类型 —— TGA / DSC。 + +use super::DataTypeDescriptor; + +pub fn types() -> Vec { + vec![ + DataTypeDescriptor { + id: "thermal.tga".into(), + label: "TGA 热重分析".into(), + category: "thermal".into(), + prompt_fragment: concat!( + "这是热重分析(TGA)数据。横轴为温度(temperature),单位 °C 或 K。左纵轴为质量百分比(weight %)或质量(mg)。", + "如果同时有 DTG(微商热重)曲线,右侧纵轴显示质量损失速率(%/°C 或 mg/min)。", + "请在主要失重台阶处标注温度范围和对应的失重百分比,并推测可能的热事件(如脱水、分解、氧化)。", + "多组样品叠加时用不同颜色,实线为 TGA、虚线为 DTG。", + ).into(), + typical_x_label: Some("Temperature".into()), + typical_x_unit: Some("°C".into()), + typical_y_label: Some("Weight".into()), + typical_y_unit: Some("%".into()), + x_column_keywords: ["temperature", "temp", "degc", "celsius", "°c", "温度"].to_vec(), + }, + DataTypeDescriptor { + id: "thermal.dsc".into(), + label: "DSC 差示扫描量热".into(), + category: "thermal".into(), + prompt_fragment: concat!( + "这是差示扫描量热(DSC)数据。横轴为温度(temperature),单位 °C。纵轴为热流(heat flow),单位 mW 或 W/g,向上通常为放热(exo)。", + "请在吸热/放热峰处标注对应的温度(峰值温度 Tonset/Tpeak)和焓变(ΔH,通过积分峰面积获得)。", + "标注玻璃化转变温度(Tg,表现为基线台阶)、熔融峰(Tm)、结晶峰(Tc)。多组样品叠加时用不同颜色。", + ).into(), + typical_x_label: Some("Temperature".into()), + typical_x_unit: Some("°C".into()), + typical_y_label: Some("Heat Flow".into()), + typical_y_unit: Some("mW".into()), + x_column_keywords: ["temperature", "temp", "degc", "celsius", "heatflow", "heat_flow", "温度"].to_vec(), + }, + ] +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9e252d5..07802dd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ mod acsearch; +mod datatypes; mod engine; mod pyenv; mod renderer; @@ -215,7 +216,9 @@ pub fn run() { check_academic_search, register_academic_search, get_setup_status, - renderer::preview_plot + renderer::preview_plot, + datatypes::get_data_types, + datatypes::resolve_data ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/App.tsx b/src/App.tsx index b585c86..5c8397a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -203,7 +203,7 @@ function RunView({ }) { const [workdir, setWorkdir] = useState(""); const [files, setFiles] = useState([]); - const [form, setForm] = useState({ instruction: "", valid: false, userInput: "" }); + const [form, setForm] = useState({ instruction: "", valid: false, userInput: "", axisSelections: {} }); const [needsNetwork, setNeedsNetwork] = useState(true); const [running, setRunning] = useState(false); const [log, setLog] = useState([]); @@ -220,6 +220,25 @@ function RunView({ const [showEditor, setShowEditor] = useState(false); const [loadedSpec, setLoadedSpec] = useState(null); const [loadedData, setLoadedData] = useState(null); + const [dataTypeLabels, setDataTypeLabels] = useState>({}); + const [dataTypePrompts, setDataTypePrompts] = useState>({}); + + // 加载数据类型标签(供 DynamicForm 的 chip 显示中文) + useEffect(() => { + if (!isFigure) return; + invoke<[unknown, Array<{ id: string; label: string; promptFragment: string }>]>("get_data_types") + .then(([, types]) => { + const labels: Record = {}; + const prompts: Record = {}; + for (const t of types) { + labels[t.id] = t.label; + prompts[t.id] = t.promptFragment; + } + setDataTypeLabels(labels); + setDataTypePrompts(prompts); + }) + .catch(() => {}); + }, [isFigure]); // 最近一次生成的绘图脚本(用于"再改一版"回灌) const lastPy = [...artifacts].reverse().find((p) => p.endsWith(".py")) || null; @@ -361,8 +380,12 @@ function RunView({ function run() { if (!form.valid) return; setOriginalInput(form.userInput); + // 提取用户选择的数据类型,注入领域知识描述 + const dt = form.axisSelections?.["data_type"]?.[0]; + const dtPrompt = isFigure && dt ? dataTypePrompts[dt] : null; + const dtBlock = dtPrompt ? `\n\n【数据类型:${dataTypeLabels[dt] ?? dt}】\n${dtPrompt}` : ""; const hint = isFigure && chartHint ? `\n参考图型(chart-atlas):${chartHint}` : ""; - launch(form.instruction + hint); + launch(form.instruction + dtBlock + hint); } // figure"再改一版":读上一版脚本 + 新要求,重跑生成新版本 @@ -423,7 +446,7 @@ function RunView({ {files.length ? `${files.length} 个文件` : "(可选)"} - + {isFigure && } {isFigure && ( diff --git a/src/components/DynamicForm.tsx b/src/components/DynamicForm.tsx index 29f1023..d302b9f 100644 --- a/src/components/DynamicForm.tsx +++ b/src/components/DynamicForm.tsx @@ -13,11 +13,15 @@ export interface DynamicFormResult { instruction: string; valid: boolean; userInput: string; + /** 各 axis 的选择值(axis name → value[]) */ + axisSelections: Record; } interface Props { skill: SkillDescriptor; files: string[]; + /** 可选:axis value 的显示标签映射(axis name → value → label) */ + axisValueLabels?: Record>; onChange: (r: DynamicFormResult) => void; } @@ -29,7 +33,7 @@ function initialSelections(skill: SkillDescriptor): Record { return sel; } -export default function DynamicForm({ skill, files, onChange }: Props) { +export default function DynamicForm({ skill, files, axisValueLabels, onChange }: Props) { const [sel, setSel] = useState>(() => initialSelections(skill)); const [userInput, setUserInput] = useState(""); @@ -61,8 +65,8 @@ export default function DynamicForm({ skill, files, onChange }: Props) { }, [skill, sel, userInput, files]); useEffect(() => { - onChange({ instruction, valid, userInput }); - }, [instruction, valid, userInput]); + onChange({ instruction, valid, userInput, axisSelections: sel }); + }, [instruction, valid, userInput, sel]); function toggle(ax: Axis, value: string) { setSel((prev) => { @@ -89,6 +93,7 @@ export default function DynamicForm({ skill, files, onChange }: Props) { {ax.values.map((v) => { const dis = disabledValue(skill.id, ax.name, v); const on = (sel[ax.name] ?? []).includes(v); + const label = axisValueLabels?.[ax.name]?.[v] ?? v; return ( ); })} diff --git a/src/mock/data.ts b/src/mock/data.ts index efa7fb6..d668dad 100644 --- a/src/mock/data.ts +++ b/src/mock/data.ts @@ -22,6 +22,13 @@ export const MOCK_SKILLS: SkillDescriptor[] = [ blockingGate: true, defaultValue: "python", }, + { + name: "data_type", + values: ["general", "spectroscopy.ftir", "spectroscopy.raman", "spectroscopy.uvvis", "spectroscopy.xrd", "spectroscopy.xps", "spectroscopy.pl", "thermal.tga", "thermal.dsc", "electrochem.cv", "electrochem.eis"], + multi: false, + blockingGate: false, + defaultValue: null, + }, ], onDemand: [ { condition: "matplotlib/seaborn 绘图", path: "references/python-recipes.md" }, diff --git a/src/mock/tauri-core.ts b/src/mock/tauri-core.ts index fa38169..e1fad5a 100644 --- a/src/mock/tauri-core.ts +++ b/src/mock/tauri-core.ts @@ -178,8 +178,37 @@ export async function invoke( await delay(1000); return undefined as unknown as T; - case "cancel_task": - return undefined as unknown as T; + case "cancel_task": + return undefined as unknown as T; + + case "get_data_types": + return [ + [ + { id: "general", label: "通用 / 未分类" }, + { id: "spectroscopy", label: "光谱 Spectroscopy" }, + { id: "thermal", label: "热分析 Thermal" }, + { id: "electrochem", label: "电化学 Electrochemistry" }, + ], + [ + { id: "general", label: "通用数据", category: "general", promptFragment: "这是一组通用 XY 数据。请根据数据特征选择合适的图型。" }, + { id: "spectroscopy.ftir", label: "FTIR 红外光谱", category: "spectroscopy", promptFragment: "这是傅里叶变换红外光谱(FTIR)数据。横轴为波数,范围 4000-400 cm⁻¹,X 轴从高到低为惯例。纵轴为吸光度或透过率。" }, + { id: "spectroscopy.xrd", label: "XRD X射线衍射", category: "spectroscopy", promptFragment: "这是 X 射线衍射(XRD)数据。横轴为衍射角 2θ,纵轴为强度。" }, + { id: "thermal.tga", label: "TGA 热重分析", category: "thermal", promptFragment: "这是热重分析(TGA)数据。横轴为温度,纵轴为质量百分比。" }, + { id: "electrochem.cv", label: "CV 循环伏安", category: "electrochem", promptFragment: "这是循环伏安法(CV)数据。横轴为电位,纵轴为电流。" }, + ], + ] as unknown as T; + + case "resolve_data": + await delay(200); + return { + columns: [ + { name: "wavenumber", values: [4000, 3500, 3000, 2500, 2000, 1500, 1000, 500], min: 400, max: 4000, monotonic: false }, + { name: "absorbance", values: [0.05, 0.12, 0.08, 0.15, 0.03, 0.22, 0.31, 0.1], min: 0.03, max: 0.31, monotonic: null }, + ], + rowCount: 8, + xColumnIndex: 0, + yColumnIndices: [1], + } as unknown as T; case "run_skill_task": { // 模拟完整的任务事件流:start → reasoning → message → artifact → finished