Coverage for graphica/core/script_export.py: 96.3%
177 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-12 16:38 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-12 16:38 +0000
1"""プロジェクトを matplotlib だけで動くスクリプトにする。GUI には依存しない。
3再現するのはデータ、組み込みの種類、2D マップ、色・線・マーカー、タイトル・軸ラベル・範囲・対数軸・凡例・グリッド・
4第2Y軸まで。グラデーション・ウォーターフォール・誤差・注釈などは対象外(生成したスクリプトの先頭にも書く)。
5プラグインの種類はスクリプトに持ち出せないので Line で代わりに出す。
6"""
8from graphica.core.axis_settings import axis_inverted, axis_setting, manual_axis_range
9from graphica.core.dataset import COLOR_BY_COLUMN_PLOT_TYPE, legend_text
10from graphica.core.marker_style import (
11 MARKER_EDGE_COLOR, line_marker_kwargs, mapped_scatter_marker_kwargs, scatter_marker_kwargs)
12from typing import TYPE_CHECKING, Any
13if TYPE_CHECKING:
14 from graphica.core.dataset import Dataset
15 from graphica.models.project import ProjectModel
18def _to_native(value: Any) -> Any:
19 """numpy 2 では np.float64 の repr が "np.float64(1.5)" になるので、Python の型にしてから repr する。"""
20 if hasattr(value, 'item'):
21 try:
22 return value.item()
23 except (ValueError, TypeError):
24 return value
25 return value
28def _format_array_literal(values: Any) -> str:
29 """数値のリストのリテラル。NaN は repr すると未定義の nan になるので float('nan') と書く。"""
30 parts = []
31 for v in values:
32 v = _to_native(v)
33 if isinstance(v, float) and v != v: # NaN
34 parts.append("float('nan')")
35 else:
36 parts.append(repr(v))
37 return "[" + ", ".join(parts) + "]"
40# 組み込みの種類(これ以外はプラグインのもの)
41_BUILTIN_PLOT_TYPES = ('Line', 'Scatter', 'Line+Scatter', 'Area', 'Bar', 'Step',
42 COLOR_BY_COLUMN_PLOT_TYPE)
45def _kwargs_text(kwargs: dict[str, Any]) -> str:
46 return "".join(f", {key}={_to_native(value)!r}" for key, value in kwargs.items())
49def _single_color_scatter(ax_var: str, ds: "Dataset") -> str:
50 style = scatter_marker_kwargs(ds)
51 # 線だけの形で枠線の色を指定すると color が変わる(描画の _markers と同じ)
52 color = style.pop('color', ds.color)
53 return (f"{ax_var}.scatter(x, y, marker={ds.marker!r}, s={ds.markersize!r} ** 2, "
54 f"color={color!r}, alpha={ds.alpha!r}, label={legend_text(ds)!r}{_kwargs_text(style)})")
57def _emit_dataset_plot_call(lines: list[str], ax_var: str, ds: "Dataset", mappable_var: str | None = None) -> bool:
58 """1次元の系列を描く呼び出しを書く。カラーバー用の mappable を mappable_var に入れたら True。"""
59 kwargs = f"color={ds.color!r}, alpha={ds.alpha!r}, label={legend_text(ds)!r}"
60 plot_type = ds.plot_type if ds.plot_type in _BUILTIN_PLOT_TYPES else None
62 if plot_type is None:
63 lines.append(
64 f"# plot_type {ds.plot_type!r} はプラグイン依存のため、Lineとして代替出力しています"
65 )
66 plot_type = 'Line'
68 if plot_type == COLOR_BY_COLUMN_PLOT_TYPE:
69 # scipy を使わない matplotlib だけの呼び出しなので、そのまま再現できる(Density Scatter は Line で代わりに出す)
70 z_values = ds.z_data
71 if z_values is None or len(z_values) != len(ds.x_data):
72 lines.append(
73 "# Z列が未設定/長さ不一致のため、単色のScatterとして出力しています"
74 )
75 lines.append(_single_color_scatter(ax_var, ds))
76 return False
77 style, hollow = mapped_scatter_marker_kwargs(ds)
78 artist_var = mappable_var or ("markers" if hollow else None)
79 assign = f"{artist_var} = " if artist_var else ""
80 lines.append(
81 f"{assign}{ax_var}.scatter(x, y, c=z, cmap={ds.colormap!r}, "
82 f"vmin={_to_native(ds.vmin)!r}, vmax={_to_native(ds.vmax)!r}, "
83 f"marker={ds.marker!r}, s={ds.markersize!r} ** 2, "
84 f"alpha={ds.alpha!r}, label={legend_text(ds)!r}{_kwargs_text(style)})"
85 )
86 if hollow:
87 # c と facecolors を一緒に渡すと c が勝つので、描いたあとに塗りを消す
88 lines.append(f"{artist_var}.set_facecolor('none')")
89 if ds.marker_edge != MARKER_EDGE_COLOR:
90 lines.append(f"{artist_var}.set_edgecolor(None)")
91 return bool(mappable_var)
93 if plot_type == 'Line':
94 lines.append(f"{ax_var}.plot(x, y, linestyle={ds.linestyle!r}, linewidth={ds.linewidth!r}, {kwargs})")
95 elif plot_type == 'Scatter':
96 lines.append(_single_color_scatter(ax_var, ds))
97 elif plot_type == 'Line+Scatter':
98 lines.append(
99 f"{ax_var}.plot(x, y, linestyle={ds.linestyle!r}, linewidth={ds.linewidth!r}, "
100 f"marker={ds.marker!r}, markersize={ds.markersize!r}, {kwargs}{_kwargs_text(line_marker_kwargs(ds))})"
101 )
102 elif plot_type == 'Area':
103 lines.append(f"{ax_var}.fill_between(x, y, 0, color={ds.color!r}, alpha={ds.alpha!r} * 0.4, label={legend_text(ds)!r})")
104 lines.append(f"{ax_var}.plot(x, y, linestyle={ds.linestyle!r}, linewidth={ds.linewidth!r}, color={ds.color!r}, alpha={ds.alpha!r})")
105 elif plot_type == 'Bar':
106 lines.append(f"{ax_var}.bar(x, y, {kwargs})")
107 elif plot_type == 'Step': 107 ↛ 109line 107 didn't jump to line 109 because the condition on line 107 was always true
108 lines.append(f"{ax_var}.plot(x, y, drawstyle='steps-post', linestyle={ds.linestyle!r}, linewidth={ds.linewidth!r}, {kwargs})")
109 return False
112_VALID_MAP_DISPLAY_MODES = ('heatmap', 'contour', 'contour_filled', 'heatmap_contour')
115def _emit_2d_dataset_plot_call(lines: list[str], ax_var: str, mesh_var: str, ds: "Dataset") -> bool:
116 """2D マップを pcolormesh / contour / contourf で書く。Dataset.z_grid の格子をそのまま埋め込む。
118 カラーバーの対象(塗りのあるモード)を mesh_var に作れたら True。線だけの contour や格子が作れないときは False。
119 """
120 grid = ds.z_grid
121 if grid is None:
122 lines.append(
123 f"# {ds.name!r} は2Dグリッドデータですが、有効なグリッドを構築できなかったため出力をスキップしています"
124 )
125 return False
126 x_grid, y_grid, z_grid = grid['x_grid'], grid['y_grid'], grid['z_grid']
127 lines.append(f"x = np.array({_format_array_literal(list(x_grid))})")
128 lines.append(f"y = np.array({_format_array_literal(list(y_grid))})")
129 z_rows = ", ".join(_format_array_literal(list(row)) for row in z_grid)
130 lines.append(f"z = np.array([{z_rows}])")
132 mode = ds.map_display_mode if ds.map_display_mode in _VALID_MAP_DISPLAY_MODES else 'heatmap'
133 vmin_kw = f", vmin={_to_native(ds.vmin)!r}" if ds.vmin is not None else ""
134 vmax_kw = f", vmax={_to_native(ds.vmax)!r}" if ds.vmax is not None else ""
135 has_mappable = False
137 if mode in ('heatmap', 'heatmap_contour'):
138 lines.append(
139 f"{mesh_var} = {ax_var}.pcolormesh(x, y, z, cmap={ds.colormap!r}, "
140 f"alpha={ds.alpha!r}, shading='auto'{vmin_kw}{vmax_kw})"
141 )
142 has_mappable = True
143 elif mode == 'contour_filled':
144 lines.append(
145 f"{mesh_var} = {ax_var}.contourf(x, y, z, levels={ds.contour_levels!r}, "
146 f"cmap={ds.colormap!r}, alpha={ds.alpha!r}{vmin_kw}{vmax_kw})"
147 )
148 has_mappable = True
149 if mode in ('contour', 'heatmap_contour'):
150 lines.append(
151 f"{ax_var}.contour(x, y, z, levels={ds.contour_levels!r}, colors={ds.color!r}, "
152 f"alpha={ds.alpha!r}, linewidths={ds.linewidth!r})"
153 )
154 return has_mappable
157def _emit_range_and_direction(lines: list[str], ax_var: str, settings: dict[str, Any], axis_key: str) -> None:
158 """画面と同じ判定(manual_axis_range / axis_inverted)で範囲と向きを書く。"""
159 which = 'x' if axis_key == 'x' else 'y'
160 manual = manual_axis_range(settings, axis_key)
161 if manual is not None:
162 lines.append(f"{ax_var}.set_{which}lim({manual[0]!r}, {manual[1]!r})")
163 if axis_inverted(settings, axis_key):
164 lines.append(f"{ax_var}.invert_{which}axis()")
167def _emit_secondary_y_calls(lines: list[str], ax_var: str, settings: dict[str, Any]) -> None:
168 """第 2 Y 軸のラベル・対数・範囲・反転(画面と同じ設定を使う)。"""
169 if axis_setting(settings, 'y2_label'):
170 lines.append(f"{ax_var}.set_ylabel({axis_setting(settings, 'y2_label')!r})")
171 if axis_setting(settings, 'y2_log'):
172 lines.append(f"{ax_var}.set_yscale('log')")
173 _emit_range_and_direction(lines, ax_var, settings, 'y2')
176def _emit_appearance_calls(lines: list[str], ax_var: str, settings: dict[str, Any], mesh_var: str | None = None) -> None:
177 if axis_setting(settings, 'title'):
178 lines.append(f"{ax_var}.set_title({settings['title']!r})")
179 if axis_setting(settings, 'x_label') and axis_setting(settings, 'x_label_visible'):
180 lines.append(f"{ax_var}.set_xlabel({settings['x_label']!r})")
181 if axis_setting(settings, 'y_label') and axis_setting(settings, 'y_label_visible'):
182 lines.append(f"{ax_var}.set_ylabel({settings['y_label']!r})")
183 if axis_setting(settings, 'x_log'):
184 lines.append(f"{ax_var}.set_xscale('log')")
185 if axis_setting(settings, 'y_log'):
186 lines.append(f"{ax_var}.set_yscale('log')")
187 _emit_range_and_direction(lines, ax_var, settings, 'x')
188 _emit_range_and_direction(lines, ax_var, settings, 'y')
189 if axis_setting(settings, 'grid_visible'): 189 ↛ 190line 189 didn't jump to line 190 because the condition on line 189 was never true
190 lines.append(f"{ax_var}.grid(True)")
191 if axis_setting(settings, 'legend_visible'): 191 ↛ 194line 191 didn't jump to line 194 because the condition on line 191 was always true
192 lines.append(f"{ax_var}.legend()")
194 if mesh_var is not None and axis_setting(settings, 'colorbar_enabled'):
195 position = axis_setting(settings, 'colorbar_position')
196 if position not in ('right', 'left', 'top', 'bottom'): 196 ↛ 197line 196 didn't jump to line 197 because the condition on line 196 was never true
197 position = 'right'
198 fraction = axis_setting(settings, 'colorbar_width_fraction')
199 lines.append(
200 f"cbar = fig.colorbar({mesh_var}, ax={ax_var}, location={position!r}, "
201 f"fraction={fraction!r}, pad=0.04)"
202 )
203 if axis_setting(settings, 'colorbar_label'):
204 lines.append(f"cbar.set_label({settings['colorbar_label']!r})")
207def generate_python_script(project: "ProjectModel") -> str:
208 """スクリプトのソースを返す。データは np.array としてスクリプトに埋め込む(1ファイルで再現できるが、点が多いと大きくなる)。"""
209 lines = [
210 '"""',
211 'Graphicaから書き出されたスタンドアロンのPythonスクリプト。',
212 'matplotlib/numpyのみで実行できます(Graphica本体は不要です)。',
213 '',
214 '既知の制限: グラデーション・ウォーターフォール・エラーバー・注釈・',
215 'パネルラベル・第2X軸の単位変換・グリッド線の詳細設定・日付軸/カテゴリ軸の',
216 '専用フォーマット・プラグイン提供の描画方式は再現されません',
217 '(未対応のplot_typeはLineとして代替出力されます)。',
218 '"""',
219 'import numpy as np',
220 'import matplotlib.pyplot as plt',
221 '',
222 ]
224 all_plot_settings = project.all_plot_settings or [{}]
225 layout_mode = getattr(project, 'layout_mode', 'grid')
227 if layout_mode == 'free':
228 subplot_count = len(all_plot_settings)
229 lines.append('fig = plt.figure(figsize=(10, 8))')
230 lines.append('axes = []')
231 for settings in all_plot_settings:
232 rect = axis_setting(settings, 'free_rect') or (0.1, 0.1, 0.8, 0.8)
233 lines.append(f'axes.append(fig.add_axes({tuple(rect)!r}))')
234 else:
235 rows = getattr(project, 'layout_rows', 1) or 1
236 cols = getattr(project, 'layout_cols', 1) or 1
237 subplot_count = rows * cols
238 lines.append(f'fig, axes_grid = plt.subplots({rows}, {cols}, figsize=({6 * cols}, {4 * rows}))')
239 lines.append('axes = list(np.atleast_1d(axes_grid).flatten())')
240 lines.append('')
242 visible_datasets = [ds for ds in project.datasets if getattr(ds, 'visible', True)]
243 secondary_axis_indices = sorted({
244 ds.subplot_target for ds in visible_datasets
245 if ds.use_secondary_y and ds.subplot_target < subplot_count
246 })
247 for idx in secondary_axis_indices:
248 lines.append(f'ax{idx}_secondary = axes[{idx}].twinx()')
249 if secondary_axis_indices:
250 lines.append('')
252 mesh_var_by_axis: dict[int, str | None] = {}
253 # 1つの軸にカラーバーは1つなので、2D マップがある軸では2D マップを優先する(画面と同じ)。
254 # 系列の順に書くので、先にこの集合を作らないと順序次第で画面と食い違う
255 axes_with_2d = {
256 d.subplot_target for d in visible_datasets if d.data_kind == '2d_grid'
257 }
258 for ds in visible_datasets:
259 if ds.subplot_target >= subplot_count: 259 ↛ 260line 259 didn't jump to line 260 because the condition on line 259 was never true
260 continue
261 ax_var = (
262 f'ax{ds.subplot_target}_secondary'
263 if (ds.use_secondary_y and ds.subplot_target in secondary_axis_indices)
264 else f'axes[{ds.subplot_target}]'
265 )
266 lines.append(f'# --- {ds.name} ---')
267 if ds.data_kind == '2d_grid':
268 mesh_var = f'mesh{ds.subplot_target}'
269 if _emit_2d_dataset_plot_call(lines, ax_var, mesh_var, ds):
270 mesh_var_by_axis[ds.subplot_target] = mesh_var
271 else:
272 lines.append(f'x = np.array({_format_array_literal(list(ds.x_data))})')
273 lines.append(f'y = np.array({_format_array_literal(list(ds.y_data))})')
274 # visible_df 経由なので、マスクした行があっても点と色がずれない
275 if ds.plot_type == COLOR_BY_COLUMN_PLOT_TYPE:
276 z_values = ds.z_data
277 if z_values is not None and len(z_values) == len(ds.x_data):
278 lines.append(f'z = np.array({_format_array_literal(list(z_values))})')
279 # 2D マップがある軸ではカラーバーを譲る(mesh{N} を上書きしないよう変数名も分ける)
280 wants_colorbar = ds.subplot_target not in axes_with_2d
281 scatter_mesh_var = f'scatter_mesh{ds.subplot_target}' if wants_colorbar else None
282 if _emit_dataset_plot_call(lines, ax_var, ds, mappable_var=scatter_mesh_var):
283 mesh_var_by_axis[ds.subplot_target] = scatter_mesh_var
284 lines.append('')
286 for i, settings in enumerate(all_plot_settings[:subplot_count]):
287 _emit_appearance_calls(lines, f'axes[{i}]', settings, mesh_var_by_axis.get(i))
288 if i in secondary_axis_indices:
289 _emit_secondary_y_calls(lines, f'ax{i}_secondary', settings)
290 lines.append('')
292 lines.append('plt.tight_layout()')
293 lines.append('plt.show()')
294 lines.append('')
296 return '\n'.join(lines)