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

1"""プロジェクトを matplotlib だけで動くスクリプトにする。GUI には依存しない。 

2 

3再現するのはデータ、組み込みの種類、2D マップ、色・線・マーカー、タイトル・軸ラベル・範囲・対数軸・凡例・グリッド・ 

4第2Y軸まで。グラデーション・ウォーターフォール・誤差・注釈などは対象外(生成したスクリプトの先頭にも書く)。 

5プラグインの種類はスクリプトに持ち出せないので Line で代わりに出す。 

6""" 

7 

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 

16 

17 

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 

26 

27 

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) + "]" 

38 

39 

40# 組み込みの種類(これ以外はプラグインのもの) 

41_BUILTIN_PLOT_TYPES = ('Line', 'Scatter', 'Line+Scatter', 'Area', 'Bar', 'Step', 

42 COLOR_BY_COLUMN_PLOT_TYPE) 

43 

44 

45def _kwargs_text(kwargs: dict[str, Any]) -> str: 

46 return "".join(f", {key}={_to_native(value)!r}" for key, value in kwargs.items()) 

47 

48 

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)})") 

55 

56 

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 

61 

62 if plot_type is None: 

63 lines.append( 

64 f"# plot_type {ds.plot_type!r} はプラグイン依存のため、Lineとして代替出力しています" 

65 ) 

66 plot_type = 'Line' 

67 

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) 

92 

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 

110 

111 

112_VALID_MAP_DISPLAY_MODES = ('heatmap', 'contour', 'contour_filled', 'heatmap_contour') 

113 

114 

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 の格子をそのまま埋め込む。 

117 

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}])") 

131 

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 

136 

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 

155 

156 

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()") 

165 

166 

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') 

174 

175 

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()") 

193 

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})") 

205 

206 

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 ] 

223 

224 all_plot_settings = project.all_plot_settings or [{}] 

225 layout_mode = getattr(project, 'layout_mode', 'grid') 

226 

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('') 

241 

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('') 

251 

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('') 

285 

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('') 

291 

292 lines.append('plt.tight_layout()') 

293 lines.append('plt.show()') 

294 lines.append('') 

295 

296 return '\n'.join(lines)