Coverage for graphica/gui/rendering/data_1d.py: 97.0%

215 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-12 16:38 +0000

1"""1 つの軸のデータの描画: 軸ごとの振り分けと、1D のデータセットを決まった手順で描く。""" 

2import logging 

3import numpy as np 

4import pandas as pd 

5from graphica.core.analysis import ( 

6 calculate_gaussian_smooth, calculate_lttb_downsample, calculate_median_smooth, calculate_moving_average_smooth) 

7from graphica.core.dataset import legend_text 

8from graphica.core.marker_style import scatter_marker_kwargs 

9from graphica.gui.plot_type_drawers import BUILTIN_PLOT_TYPE_DRAWERS 

10from graphica.gui.rendering.common import ( 

11 DARK_AXES_FACECOLOR, LIGHT_AXES_FACECOLOR, LTTB_DOWNSAMPLE_TARGET_POINTS, LTTB_DOWNSAMPLE_THRESHOLD, 

12 WATERFALL_ZORDER_BASE, WATERFALL_ZORDER_TOP, _apply_nan_policy, _waterfall_depth_scale, _waterfall_layout, 

13 waterfall_offset_steps) 

14from matplotlib.collections import LineCollection 

15from matplotlib.colors import LinearSegmentedColormap, Normalize 

16from matplotlib.patches import Polygon 

17from scipy.interpolate import CubicSpline 

18 

19logger = logging.getLogger(__name__) 

20 

21 

22def draw_data(canvas, ax, axis_index, datasets, full_resolution=False): 

23 """ 

24 その軸のデータセットを描く。full_resolution=True なら表示用の間引きをしない(エクスポートの「フル解像度」)。 

25 

26 呼び出し側は非表示のデータセットも含めた全件を渡す。ウォーターフォールの段は非表示のものも数に入れて 

27 振る(1本隠しても後ろの段が繰り上がらないように)ので、ここで絞る。 

28 """ 

29 on_this_axis = [ds for ds in datasets if ds.subplot_target == axis_index] 

30 # 2Dマップは長形式の生の列を持つので、1D の経路(軸の種類の判定・ウォーターフォール・間引きなど)を通さない 

31 datasets_2d = [ds for ds in on_this_axis if ds.data_kind == '2d_grid' and getattr(ds, 'visible', True)] 

32 datasets_1d_all = [ds for ds in on_this_axis if ds.data_kind != '2d_grid'] 

33 shown_1d = [ds for ds in datasets_1d_all if getattr(ds, 'visible', True)] 

34 # 背景として先に描き、1D のデータが上に重なるようにする 

35 canvas._draw_2d_data(ax, axis_index, datasets_2d, full_resolution=full_resolution) 

36 

37 # ウォーターフォールの変換は描くたびに登録し直す 

38 for ds in on_this_axis: 

39 canvas._waterfall_transforms.pop(ds.dataset_id, None) 

40 

41 needs_secondary = any(ds.use_secondary_y for ds in shown_1d) 

42 is_category_x = canvas._record_x_axis_kind(axis_index, shown_1d) 

43 

44 secondary_ax = None 

45 if needs_secondary: 

46 secondary_ax = ax.twinx() 

47 canvas.all_secondary_axes[axis_index] = secondary_ax 

48 

49 waterfall = _waterfall_layout(datasets_1d_all) 

50 canvas.waterfall_x_shift[axis_index] = _waterfall_x_shift(shown_1d, waterfall, is_category_x) 

51 for ds in shown_1d: 

52 target_ax = secondary_ax if ds.use_secondary_y else ax 

53 if target_ax is None: 53 ↛ 54line 53 didn't jump to line 54 because the condition on line 53 was never true

54 continue 

55 canvas._draw_1d_dataset(target_ax, axis_index, ds, waterfall, is_category_x, full_resolution) 

56 

57 

58def _waterfall_x_shift(datasets, waterfall, is_category_x): 

59 """見えているトレースを X にずらした量の (最小, 最大)。0 を含める(手前のトレースはずらさない)。カテゴリ軸ではずらさない。""" 

60 if is_category_x: 

61 return (0.0, 0.0) 

62 shifts = [waterfall_offset_steps(waterfall.index.get(ds.dataset_id, 0), ds.waterfall_offset_mode) * ds.waterfall_offset_x 

63 for ds in datasets if ds.waterfall_enabled] 

64 return (min([0.0, *shifts]), max([0.0, *shifts])) 

65 

66 

67def record_x_axis_kind(canvas, axis_index, datasets): 

68 """X 軸が日時か文字列カテゴリかを記録する(目盛りの付け方に使う)。カテゴリなら True を返す。""" 

69 is_date_x = any(pd.api.types.is_datetime64_any_dtype(ds.df[ds.x_col_name]) for ds in datasets) 

70 while len(canvas.axis_is_date_x) <= axis_index: 

71 canvas.axis_is_date_x.append(False) 

72 canvas.axis_is_date_x[axis_index] = is_date_x 

73 

74 is_category_x = (not is_date_x) and any( 

75 not pd.api.types.is_numeric_dtype(ds.df[ds.x_col_name]) for ds in datasets 

76 ) 

77 while len(canvas.axis_is_category_x) <= axis_index: 

78 canvas.axis_is_category_x.append(False) 

79 canvas.axis_is_category_x[axis_index] = is_category_x 

80 return is_category_x 

81 

82 

83def draw_1d_dataset(canvas, target_ax, axis_index, ds, waterfall, is_category_x, full_resolution): 

84 plot_x_data, plot_y_data, plot_kwargs, occlusion_zorder = canvas._waterfall_shifted_points( 

85 ds, waterfall, is_category_x) 

86 

87 # カテゴリ軸は下で文字列にするので対象外。 

88 # 'drop' は配列を短くするため、大量データで間引きと併用するとデータカーソルの行の対応がずれうる(既知の制約)。 

89 if not is_category_x and ds.nan_policy != 'gap': 

90 plot_x_data, plot_y_data = _apply_nan_policy(plot_x_data, plot_y_data, ds.nan_policy) 

91 

92 if is_category_x: 

93 # matplotlib のカテゴリ軸は全要素が文字列でないと例外になる(数値や NaN が混ざった列) 

94 plot_x_data = np.array([v if isinstance(v, str) else str(v) for v in plot_x_data]) 

95 

96 plot_x_data, plot_y_data, downsample_indices = canvas._downsample_for_display( 

97 ds, plot_x_data, plot_y_data, is_category_x, full_resolution) 

98 

99 # 平滑化は線で結ぶ種別だけ(ほかの種別では平滑化した線がマーカー・棒・塗りを置き換えてしまう) 

100 is_smoothed_artist = False 

101 if (ds.smoothing and ds.plot_type in ('Line', 'Line+Scatter') 

102 and len(plot_x_data) > 1 and not is_category_x): 

103 is_smoothed_artist = canvas._draw_smoothed(target_ax, ds, plot_x_data, plot_y_data, plot_kwargs) 

104 else: 

105 ds.artist = canvas._draw_plot_type(target_ax, axis_index, ds, plot_x_data, plot_y_data, plot_kwargs) 

106 

107 # 手前のトレースが奥を隠すよう、トレースの下に軸の背景色を敷く。面は自分の塗りと重なるので除く 

108 if (ds.waterfall_enabled and ds.waterfall_occlusion_enabled 

109 and ds.plot_type != 'Area' and len(plot_x_data) > 0): 

110 bg_color = DARK_AXES_FACECOLOR if canvas.dark_mode else LIGHT_AXES_FACECOLOR 

111 target_ax.fill_between( 

112 plot_x_data, plot_y_data, waterfall.baseline, 

113 color=bg_color, alpha=1.0, zorder=occlusion_zorder, linewidth=0, 

114 ) 

115 

116 # 平滑化した曲線の点は元の行と対応しないので、クリックで選べないようにする。 

117 # データカーソルモードの一括 set_picker からも外すため、_non_pickable_dataset_ids にも入れる 

118 if is_smoothed_artist: 

119 canvas._non_pickable_dataset_ids.add(ds.dataset_id) 

120 else: 

121 canvas._non_pickable_dataset_ids.discard(ds.dataset_id) 

122 if ds.artist is not None and not is_smoothed_artist: 

123 canvas._enable_element_picking(ds.artist) 

124 

125 canvas._draw_error_display(target_ax, ds, plot_x_data, plot_y_data, downsample_indices) 

126 

127 # 曲線フィットの信頼帯・予測帯。帯の列があるときだけ描ける 

128 if ds.fit_band_display and 'y_lower' in ds.df.columns and 'y_upper' in ds.df.columns: 

129 band_df = ds.visible_df 

130 target_ax.fill_between( 

131 band_df[ds.x_col_name], band_df['y_lower'], band_df['y_upper'], 

132 color=ds.color, alpha=ds.alpha * 0.15, linewidth=0, 

133 ) 

134 

135 # 点数が上限を超えると描画で GUI が止まるので描かない。 

136 # ラベルの値は間引く前の並びなので、点と同じ downsample_indices で揃える 

137 if ds.show_point_labels and len(ds.visible_df) <= canvas.point_label_max_points: 

138 canvas._draw_point_labels( 

139 target_ax, ds, x_data=plot_x_data, y_data=plot_y_data, 

140 downsample_indices=downsample_indices, 

141 ) 

142 

143 

144def waterfall_shifted_points(canvas, ds, waterfall, is_category_x): 

145 """ 

146 描く点列 (x, y, plot_kwargs, 背景を敷く zorder)。ウォーターフォールなら段の分だけずらし、 

147 逆変換(display_to_data)用に変換を記録する。カテゴリ軸では X はずらせないので Y だけずらす。 

148 """ 

149 if not ds.waterfall_enabled: 

150 return ds.x_data, ds.y_data, {}, None 

151 w_idx = waterfall.index.get(ds.dataset_id, 0) 

152 depth_scale = _waterfall_depth_scale( 

153 w_idx, ds.waterfall_depth_shrink_enabled, ds.waterfall_depth_shrink_ratio) 

154 canvas._waterfall_transforms[ds.dataset_id] = { 

155 'index': w_idx, 

156 'offset_x': 0.0 if is_category_x else ds.waterfall_offset_x, 

157 'offset_y': ds.waterfall_offset_y, 

158 'depth_scale': depth_scale, 

159 'mode': ds.waterfall_offset_mode, 

160 } 

161 steps = waterfall_offset_steps(w_idx, ds.waterfall_offset_mode) 

162 plot_x_data = ds.x_data if is_category_x else ds.x_data + steps * ds.waterfall_offset_x 

163 plot_y_data = ds.y_data * depth_scale + steps * ds.waterfall_offset_y 

164 # 手前(段が小さい)ほど上に重ねる。段の数によらず枠線・目盛(zorder 2.01〜2.5)より下に収める 

165 step = (WATERFALL_ZORDER_TOP - WATERFALL_ZORDER_BASE) / (waterfall.count + 1) 

166 zorder = WATERFALL_ZORDER_BASE + (waterfall.count - w_idx) * step 

167 return plot_x_data, plot_y_data, {'zorder': zorder}, zorder - step / 2 

168 

169 

170def downsample_for_display(canvas, ds, plot_x_data, plot_y_data, is_category_x, full_resolution): 

171 """ 

172 点が多い 'Line' だけ LTTB で間引く(散布図などは点の疎密自体が情報)。X が昇順でないと 

173 LTTB が形を変えてしまうので、そのときは間引かない。戻り値の3つ目は間引きに使った添字(無ければ None)。 

174 """ 

175 if not (ds.plot_type == 'Line' and not full_resolution and not is_category_x 

176 and len(plot_x_data) > LTTB_DOWNSAMPLE_THRESHOLD 

177 and np.all(np.diff(plot_x_data) >= 0)): 

178 return plot_x_data, plot_y_data, None 

179 indices = calculate_lttb_downsample(plot_x_data, plot_y_data, LTTB_DOWNSAMPLE_TARGET_POINTS) 

180 if len(indices) >= len(plot_x_data): 180 ↛ 181line 180 didn't jump to line 181 because the condition on line 180 was never true

181 return plot_x_data, plot_y_data, None 

182 # データカーソルが、間引いた後の添字を visible_df の行に戻すのに使う 

183 canvas.downsample_index_map[ds.dataset_id] = indices 

184 return plot_x_data[indices], plot_y_data[indices], indices 

185 

186 

187def draw_smoothed(canvas, target_ax, ds, plot_x_data, plot_y_data, plot_kwargs): 

188 """平滑化した曲線を描く。平滑化できなければ元の点のまま線で結び、False を返す。""" 

189 sort_indices = np.argsort(plot_x_data) 

190 x_sorted = plot_x_data[sort_indices] 

191 y_sorted = plot_y_data[sort_indices] 

192 use_line_gradient = ds.gradient_enabled and ds.gradient_target in ('line', 'both') 

193 smoothing_method = getattr(ds, 'smoothing_method', 'cubic_spline') 

194 try: 

195 # cubic_spline は200点に補間して滑らかにする。ほかはノイズを減らすのが目的なので点数はそのまま 

196 if smoothing_method == 'moving_average': 

197 x_smooth, y_smooth = calculate_moving_average_smooth(x_sorted, y_sorted) 

198 elif smoothing_method == 'median': 

199 x_smooth, y_smooth = calculate_median_smooth(x_sorted, y_sorted) 

200 elif smoothing_method == 'gaussian': 

201 x_smooth, y_smooth = calculate_gaussian_smooth(x_sorted, y_sorted) 

202 else: 

203 f = CubicSpline(x_sorted, y_sorted) 

204 x_smooth = np.linspace(x_sorted.min(), x_sorted.max(), 200) 

205 y_smooth = f(x_smooth) 

206 if use_line_gradient: 

207 ds.artist = canvas._add_gradient_line( 

208 target_ax, x_smooth, y_smooth, ds.color, ds.gradient_color2, 

209 ds.linewidth, ds.alpha, ds.linestyle, label=legend_text(ds) 

210 ) 

211 else: 

212 (artist_line,) = target_ax.plot(x_smooth, y_smooth, color=ds.color, linestyle=ds.linestyle, linewidth=ds.linewidth, alpha=ds.alpha, label=legend_text(ds), **plot_kwargs) 

213 ds.artist = artist_line 

214 if ds.plot_type == 'Line+Scatter': 

215 target_ax.scatter(plot_x_data, plot_y_data, marker=ds.marker, s=ds.markersize**2, alpha=ds.alpha, 

216 **{'color': ds.color, **scatter_marker_kwargs(ds)}, **plot_kwargs) 

217 return True 

218 except ValueError: 

219 if use_line_gradient: 

220 ds.artist = canvas._add_gradient_line( 

221 target_ax, plot_x_data, plot_y_data, ds.color, ds.gradient_color2, 

222 ds.linewidth, ds.alpha, ds.linestyle, label=legend_text(ds) 

223 ) 

224 else: 

225 (artist,) = target_ax.plot(plot_x_data, plot_y_data, color=ds.color, linestyle=ds.linestyle, linewidth=ds.linewidth, alpha=ds.alpha, label=legend_text(ds), **plot_kwargs) 

226 ds.artist = artist 

227 return False 

228 

229 

230def draw_plot_type(canvas, target_ax, axis_index, ds, plot_x_data, plot_y_data, plot_kwargs): 

231 """ 

232 plot_type の描画関数で描き、ds.artist にするものを返す。組み込みに無ければプラグインの種類を探し、 

233 それも無ければ線で描く。プラグインの描画にはウォーターフォールの zorder などは渡らない(既知の制限)。 

234 """ 

235 drawer = BUILTIN_PLOT_TYPE_DRAWERS.get(ds.plot_type) 

236 if drawer is not None: 

237 return drawer(canvas, target_ax, ds, plot_x_data, plot_y_data, plot_kwargs, axis_index) 

238 

239 from graphica.core.plugin_api import get_plugin_api 

240 api = get_plugin_api() 

241 plugin_plot_type = api.get_plot_type(ds.plot_type) if api is not None else None 

242 if plugin_plot_type is None: 

243 logger.warning("未知のplot_type '%s' です。Lineとして描画します。", ds.plot_type) 

244 (artist,) = target_ax.plot(plot_x_data, plot_y_data, color=ds.color, linestyle=ds.linestyle, linewidth=ds.linewidth, alpha=ds.alpha, label=legend_text(ds), **plot_kwargs) 

245 return artist 

246 try: 

247 artist = plugin_plot_type.drawer(ds, target_ax, plot_x_data, plot_y_data) 

248 except Exception as e: 

249 logger.warning( 

250 "[plugin:%s] plot_type '%s' の描画に失敗しました: %s", 

251 plugin_plot_type.plugin_name, ds.plot_type, e, 

252 ) 

253 return ds.artist 

254 return artist if artist is not None else ds.artist 

255 

256 

257def draw_error_display(canvas, target_ax, ds, plot_x_data, plot_y_data, downsample_indices): 

258 """誤差棒と誤差の帯を、描いた点(ずらし・間引きの後)の位置に重ねる。帯は Y の誤差だけ。""" 

259 if not (ds.x_err_col_name or ds.y_err_col_name): 

260 return 

261 # 誤差列は間引く前の長さなので、点と同じ添字で揃える(揃えないと長さ違いで例外) 

262 x_err = ds.x_err_data 

263 y_err_full = ds.y_err_data 

264 if downsample_indices is not None: 

265 if x_err is not None: 265 ↛ 266line 265 didn't jump to line 266 because the condition on line 265 was never true

266 x_err = x_err[downsample_indices] 

267 if y_err_full is not None: 267 ↛ 270line 267 didn't jump to line 270 because the condition on line 267 was always true

268 y_err_full = y_err_full[downsample_indices] 

269 

270 if ds.error_display in ('bar', 'both'): 

271 target_ax.errorbar( 

272 plot_x_data, plot_y_data, 

273 xerr=x_err, yerr=y_err_full, 

274 fmt='none', ecolor=ds.color, elinewidth=ds.linewidth, alpha=ds.alpha, capsize=3 

275 ) 

276 if ds.error_display in ('band', 'both') and y_err_full is not None: 

277 y_arr = np.asarray(plot_y_data) 

278 y_err = np.asarray(y_err_full) 

279 target_ax.fill_between( 

280 plot_x_data, y_arr - y_err, y_arr + y_err, 

281 color=ds.color, alpha=ds.alpha * 0.25, linewidth=0, 

282 ) 

283 

284 

285def draw_point_labels(canvas, ax, ds, x_data=None, y_data=None, downsample_indices=None): 

286 """ 

287 各点の脇に Y 値(point_label_col_name があればその列の値)を書く。 

288 x_data/y_data を渡せばその位置に書く(ウォーターフォールでずらした位置)。 

289 downsample_indices は点の間引きに使った添字で、ラベルの値も同じく間引く 

290 (揃えないと zip が短い方で切れ、点と無関係な行のラベルが付く)。 

291 """ 

292 if x_data is None: 

293 x_data = ds.x_data 

294 if y_data is None: 

295 y_data = ds.y_data 

296 

297 if ds.point_label_col_name and ds.point_label_col_name in ds.df.columns: 

298 # 点と同じ行になるよう visible_df から取る 

299 label_values = ds.visible_df[ds.point_label_col_name].values 

300 else: 

301 label_values = ds.y_data 

302 if downsample_indices is not None: 

303 label_values = label_values[downsample_indices] 

304 

305 for x, y, label_value in zip(x_data, y_data, label_values): 

306 if pd.isna(x) or pd.isna(y): 

307 continue 

308 if isinstance(label_value, (int, float, np.floating, np.integer)) and not isinstance(label_value, bool): 

309 text = f"{label_value:.4g}" if not pd.isna(label_value) else "" 

310 else: 

311 text = "" if pd.isna(label_value) else str(label_value) 

312 if not text: 

313 continue 

314 ax.annotate( 

315 text, (x, y), textcoords="offset points", xytext=(5, 5), 

316 fontsize=8, color=ds.color, alpha=ds.alpha, 

317 # Text の既定は clip_on=False で、範囲外の点のラベルだけが枠の外に残り SVG/PDF に出てしまう 

318 clip_on=True, 

319 ) 

320 

321 

322def enable_element_picking(canvas, artist): 

323 """クリックで選べるようにする。棒グラフは Rectangle の集まりなので、1本ずつ設定する。""" 

324 try: 

325 if hasattr(artist, 'patches'): # BarContainer 

326 for patch in artist.patches: 

327 patch.set_picker(5) 

328 else: 

329 artist.set_picker(5) 

330 except AttributeError: 

331 pass 

332 

333 

334def add_gradient_line(canvas, ax, x, y, color1, color2, linewidth, alpha, linestyle, label=None): 

335 """ 

336 線を区間に分けて、始点の色から終点の色へ変わる LineCollection として描く。 

337 add_collection は軸の表示範囲を広げないことがあるので、データ範囲を明示的に足しておく。 

338 """ 

339 x = np.asarray(x, dtype=float) 

340 y = np.asarray(y, dtype=float) 

341 

342 if len(x) < 2: 

343 # 区間が作れないので普通の線にする 

344 (line,) = ax.plot(x, y, color=color1, linestyle=linestyle, linewidth=linewidth, alpha=alpha, label=label) 

345 return line 

346 

347 points = np.array([x, y]).T.reshape(-1, 1, 2) 

348 segments = np.concatenate([points[:-1], points[1:]], axis=1) 

349 

350 cmap = LinearSegmentedColormap.from_list('graphica_line_gradient', [color1, color2]) 

351 lc = LineCollection( 

352 segments, cmap=cmap, norm=Normalize(0, 1), 

353 linewidths=linewidth, linestyles=linestyle, alpha=alpha, label=label, 

354 zorder=2, 

355 ) 

356 # 区間ごとに始点からの位置(0〜1)を割り当てる 

357 lc.set_array(np.linspace(0, 1, len(segments))) 

358 ax.add_collection(lc) 

359 ax.update_datalim(np.column_stack([x, y])) 

360 return lc 

361 

362 

363def add_gradient_fill(canvas, ax, x, y, color1, color2, alpha, baseline=0.0): 

364 """fill_between と同じ形の多角形で、imshow のグラデーション画像を切り抜いて塗る。""" 

365 x = np.asarray(x, dtype=float) 

366 y = np.asarray(y, dtype=float) 

367 

368 # origin='lower' で先頭行が下端になるので、上端が color1 になるよう色を逆に並べる 

369 gradient = np.linspace(0, 1, 256).reshape(-1, 1) 

370 cmap = LinearSegmentedColormap.from_list('graphica_fill_gradient', [color2, color1]) 

371 

372 x_min, x_max = float(np.nanmin(x)), float(np.nanmax(x)) 

373 y_min = float(min(np.nanmin(y), baseline)) 

374 y_max = float(max(np.nanmax(y), baseline)) 

375 # 全点が同じ座標だと extent が潰れるので幅を持たせる 

376 if x_min == x_max: 

377 x_min, x_max = x_min - 0.5, x_max + 0.5 

378 if y_min == y_max: 

379 y_min, y_max = y_min - 0.5, y_max + 0.5 

380 

381 im = ax.imshow( 

382 gradient, cmap=cmap, aspect='auto', origin='lower', 

383 extent=(x_min, x_max, y_min, y_max), alpha=alpha, zorder=1, 

384 ) 

385 

386 verts = list(zip(x, y)) + [(x[-1], baseline), (x[0], baseline)] 

387 clip_poly = Polygon(verts, closed=True, transform=ax.transData) 

388 im.set_clip_path(clip_poly) 

389 

390 # imshow は軸の範囲を広げないので、明示的に足す 

391 ax.update_datalim(np.array([[x_min, y_min], [x_max, y_max]])) 

392 return im