Coverage for graphica/gui/canvas.py: 98.6%

338 statements  

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

1import logging 

2 

3from PySide6.QtCore import QTimer 

4 

5from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas 

6from matplotlib.backends.backend_agg import FigureCanvasAgg 

7from matplotlib.figure import Figure 

8 

9from graphica.core.axis_settings import axis_setting 

10from graphica.gui.app_settings import DEFAULT_POINT_LABEL_MAX_POINTS 

11from graphica.gui.rendering import annotations, appearance, data_1d, data_2d 

12# 描画の定数と補助は rendering/common.py にある。テストとほかのモジュールがここから読むので同じ名前で出す 

13from graphica.gui.rendering.common import ( # noqa: F401 

14 _NO_KEY, MAX_TICKS_PER_AXIS, WATERFALL_ZORDER_BASE, WATERFALL_ZORDER_TOP, waterfall_offset_steps, 

15 WATERFALL_DEPTH_SHRINK_MIN_SCALE, _LAYOUT_SETTLED_TOLERANCE, _LAYOUT_MAX_PASSES, fit_tight_layout, 

16 _waterfall_depth_scale, _WaterfallLayout, _waterfall_layout, _AxisStyle, _LOG_MINOR_SUBS_PRESETS, 

17 _ARROW_STYLE_MAP, _INSET_CORNER_ORIGINS, LTTB_DOWNSAMPLE_THRESHOLD, LTTB_DOWNSAMPLE_TARGET_POINTS, 

18 GRID_2D_MAX_DISPLAY_POINTS_PER_AXIS, REGION_HIGHLIGHT_DEFAULT_COLOR, REGION_HIGHLIGHT_DEFAULT_ALPHA, 

19 _apply_legend_order, _apply_nan_policy, STAT_LABEL_TITLES, _legend_position_from_settings, 

20 _compute_stat_label_text, _safe_multiple_locator, _sci_each_formatter, _apply_tick_format_mode, 

21 _apply_tick_decimal_places, DEFAULT_MAJOR_TICK_LENGTH, MINOR_TICK_LENGTH_RATIO, MINOR_TICK_LENGTH_AUTO, 

22 _resolve_tick_lengths, DARK_FIGURE_FACECOLOR, DARK_AXES_FACECOLOR, DARK_TEXT_COLOR, LIGHT_FIGURE_FACECOLOR, 

23 LIGHT_AXES_FACECOLOR, LIGHT_TEXT_COLOR, DARK_LEGEND_FACECOLOR, DARK_LEGEND_EDGECOLOR, LIGHT_LEGEND_FACECOLOR, 

24 LIGHT_LEGEND_EDGECOLOR, DARK_GRID_COLOR, LIGHT_GRID_COLOR) 

25 

26logger = logging.getLogger(__name__) 

27 

28 

29class _CanvasDrawingMixin: 

30 """ 

31 描画の本体。Qt に依存しないので、画面用の MplCanvas(FigureCanvasQTAgg)と 

32 一括エクスポート用の _HeadlessRenderCanvas(FigureCanvasAgg)の両方が使う。 

33 """ 

34 

35 def _init_drawing_state(self, width, height, dpi): 

36 self.fig = Figure(figsize=(width, height), dpi=dpi) 

37 

38 self.all_axes = [] 

39 self.all_secondary_axes = [] 

40 # 軸ごとに、X が日時か(目盛りの付け方)・文字列カテゴリか(数値向けの設定を使わない) 

41 self.axis_is_date_x = [] 

42 self.axis_is_category_x = [] 

43 self.dark_mode = False 

44 self.point_label_max_points = DEFAULT_POINT_LABEL_MAX_POINTS 

45 # 軸ごとの描画済みの注釈。update_appearance_only は fig.clf() しないので、消してから描き直す 

46 self._annotation_artists = {} 

47 # 軸ごとに直近に描いた注釈の内容。同じなら描き直しを省く(_annotation_render_key) 

48 self._annotation_render_keys = {} 

49 # データエディタと連動する行のハイライト(dataset_id ごと) 

50 self._highlight_artists = {} 

51 # 間引いたデータセットの「描いた点の添字 -> visible_df の位置」(dataset_id ごと)。 

52 # データカーソルがクリックした点を正しい行に戻すのに使う。間引いていなければ入らない 

53 self.downsample_index_map = {} 

54 # 平滑化した曲線のデータセット。点が元の行と対応しないのでクリックで選ばせない。 

55 # データカーソルモードの一括 set_picker(cursor_mixin.py)もこれを見て除く 

56 self._non_pickable_dataset_ids = set() 

57 # 軸ごとのカラーバーの対象(2Dマップか値で色分けした散布図)。_apply_appearance に渡すため 

58 self._axis_2d_mappables = {} 

59 # マウスで変えた表示範囲。{サブプロットの番号: {'x'/'y'/'y2': (範囲, 範囲の設定の署名)}}。 

60 # 描き直しても保ち、その軸の範囲の設定が変わったら忘れる(rendering/appearance.apply_view_override) 

61 self.view_overrides = {} 

62 # ウォーターフォールの変換(dataset_id ごと)。トレースは 

63 # 表示X = データX + index * offset_x 

64 # 表示Y = データY * depth_scale + index * offset_y 

65 # に描かれるので、マウス位置をデータの行に対応づける側は逆変換(display_to_data)を通す。 

66 # 値は {'index', 'offset_x', 'offset_y', 'depth_scale', 'mode'}。ウォーターフォールでなければ入らない 

67 self._waterfall_transforms = {} 

68 # 軸の番号 -> ウォーターフォールでトレースを X にずらした幅 (左へ, 右へ)。手動の範囲をその分広げる 

69 self.waterfall_x_shift = {} 

70 # グリッドの配置か。自由配置は利用者が決めた位置なので tight_layout を掛けない 

71 self._uses_tight_layout = True 

72 

73 def _fit_layout(self): 

74 """余白を目盛りや軸ラベルに合わせ直す(グリッドの配置だけ)。""" 

75 if not self._uses_tight_layout: 

76 return 

77 # matplotlib の予備フォント(LastResortHE)が欠けた環境では FileNotFoundError になるので、配置を諦めて続ける 

78 try: 

79 fit_tight_layout(self.fig) 

80 except (ValueError, FileNotFoundError): 

81 pass 

82 

83 # --- ウォーターフォールの表示座標 ⇔ データ座標 --- 

84 

85 def get_waterfall_transform(self, dataset_or_id): 

86 """ 

87 データセットの今のウォーターフォール変換。無効または未描画なら None。 

88 

89 Args: 

90 dataset_or_id: Dataset オブジェクト、または dataset_id。 

91 """ 

92 dataset_id = getattr(dataset_or_id, 'dataset_id', dataset_or_id) 

93 return self._waterfall_transforms.get(dataset_id) 

94 

95 def data_to_display(self, dataset_or_id, x, y): 

96 """データ座標(dataset.x_data/y_data と同じ)を、描かれている位置へ変換する。スカラーでも配列でもよい。ウォーターフォールでなければそのまま返す。""" 

97 transform = self.get_waterfall_transform(dataset_or_id) 

98 if transform is None: 

99 return x, y 

100 steps = waterfall_offset_steps(transform['index'], transform.get('mode')) 

101 display_x = x if transform['offset_x'] == 0 else x + steps * transform['offset_x'] 

102 display_y = y * transform['depth_scale'] + steps * transform['offset_y'] 

103 return display_x, display_y 

104 

105 def display_to_data(self, dataset_or_id, x, y): 

106 """ 

107 data_to_display() の逆。マウス位置をデータの行に対応づける処理(範囲選択・データカーソル・ 

108 エディタ連動のハイライト・ピーク配置)は必ずこれを通す。通さないと積み重ねの2本目以降で位置がずれる。 

109 """ 

110 transform = self.get_waterfall_transform(dataset_or_id) 

111 if transform is None: 

112 return x, y 

113 steps = waterfall_offset_steps(transform['index'], transform.get('mode')) 

114 data_x = x if transform['offset_x'] == 0 else x - steps * transform['offset_x'] 

115 # depth_scale は 0.2 以上に抑えてあるので 0 で割ることはない 

116 data_y = (y - steps * transform['offset_y']) / transform['depth_scale'] 

117 return data_x, data_y 

118 

119 def _effective_text_color(self, configured_color): 

120 """ダークモードで既定の黒のままだと見えないので明るい色にする。利用者が選んだ別の色はそのまま。""" 

121 if self.dark_mode and configured_color == '#000000': 

122 return DARK_TEXT_COLOR 

123 return configured_color 

124 

125 def _default_free_rect(self, index): 

126 """自由配置で新しい軸に割り当てる初期の (left, bottom, width, height)。少しずつずらして重ならないようにする。""" 

127 offset = 0.04 * (index % 6) 

128 left = min(0.1 + offset, 0.55) 

129 bottom = min(0.55 - offset, 0.55) if index % 2 == 0 else min(0.1 + offset, 0.5) 

130 return (left, max(bottom, 0.08), 0.45, 0.38) 

131 

132 def _safe_draw(self): 

133 """ 

134 self.draw() の失敗をログに残す。直接の draw() は例外を投げても、呼び出し元のシグナルの経路によっては 

135 どこにも記録されず「何も反映されない」だけになる(draw_idle 側は matplotlib が標準エラーに出すだけで、 

136 画面版の exe ではそれも見えない)。 

137 """ 

138 try: 

139 self.draw() 

140 except Exception: 

141 logger.exception( 

142 "Figureの描画(self.draw())に失敗しました。" 

143 "グラフの表示が更新されていない可能性があります。" 

144 ) 

145 

146 def redraw_all(self, datasets, rows, cols, all_plot_settings, layout_mode='grid', panel_labels_enabled=False, 

147 share_x_axis=False, share_y_axis=False, full_resolution=False): 

148 """ 

149 Figure を作り直して全部の軸を描く。full_resolution=True なら間引かない(エクスポートの「フル解像度」)。 

150 非表示のデータセットも含めて _draw_data に渡すこと(ウォーターフォールの段の振り方のため。_draw_data 参照)。 

151 """ 

152 self.fig.clf() 

153 self.all_axes.clear() 

154 self.all_secondary_axes.clear() 

155 self.axis_is_date_x.clear() 

156 self.axis_is_category_x.clear() 

157 # fig.clf() で古い artist はすべて消えるので、参照を捨てるだけでよい 

158 self._annotation_artists.clear() 

159 self._annotation_render_keys.clear() 

160 self._highlight_artists.clear() 

161 self.downsample_index_map.clear() 

162 self._non_pickable_dataset_ids.clear() 

163 self._axis_2d_mappables.clear() 

164 self._waterfall_transforms.clear() 

165 self.fig.set_facecolor(DARK_FIGURE_FACECOLOR if self.dark_mode else LIGHT_FIGURE_FACECOLOR) 

166 

167 is_free_layout = layout_mode == 'free' 

168 self._uses_tight_layout = not is_free_layout 

169 subplot_count = len(all_plot_settings) if is_free_layout else rows * cols 

170 if subplot_count == 0: 

171 return False 

172 

173 is_secondary_visible_global = False 

174 

175 if is_free_layout: 

176 for i in range(subplot_count): 

177 rect = axis_setting(all_plot_settings[i], 'free_rect') or self._default_free_rect(i) 

178 ax = self.fig.add_axes(rect) 

179 self.all_axes.append(ax) 

180 self.all_secondary_axes.append(None) 

181 else: 

182 # 軸の共有は、行・列ごとではなく全部の軸を最初の軸に束ねる 

183 for i in range(subplot_count): 

184 share_x_target = self.all_axes[0] if (share_x_axis and self.all_axes) else None 

185 share_y_target = self.all_axes[0] if (share_y_axis and self.all_axes) else None 

186 ax = self.fig.add_subplot(rows, cols, i + 1, sharex=share_x_target, sharey=share_y_target) 

187 self.all_axes.append(ax) 

188 self.all_secondary_axes.append(None) 

189 

190 for index, ax in enumerate(self.all_axes): 

191 if index < len(all_plot_settings): 

192 settings = all_plot_settings[index] 

193 else: 

194 continue 

195 

196 self._draw_data(ax, index, datasets, full_resolution=full_resolution) 

197 self._apply_appearance(ax, index, settings) 

198 # _apply_appearance が目盛数値の表示を毎回設定し直すので、共有で隠すのはその後 

199 if not is_free_layout: 

200 self._apply_shared_axis_tick_visibility(index, rows, cols, share_x_axis, share_y_axis) 

201 self._draw_annotations(ax, index, settings, datasets=datasets, full_resolution=full_resolution) 

202 if panel_labels_enabled: 

203 self._draw_panel_label(ax, index) 

204 

205 if self.all_secondary_axes[index] is not None: 

206 is_secondary_visible_global = True 

207 

208 if not is_free_layout: 

209 # 自由配置は利用者が決めた位置を tight_layout が上書きするので、グリッドだけ。 

210 # matplotlib の予備フォント(LastResortHE)が欠けた環境では FileNotFoundError になるので、配置を諦めて続ける 

211 try: 

212 fit_tight_layout(self.fig) 

213 except (ValueError, FileNotFoundError): 

214 pass 

215 

216 self._safe_draw() 

217 return is_secondary_visible_global 

218 

219 def update_appearance_only(self, all_plot_settings, datasets=(), rows=1, cols=1, 

220 layout_mode='grid', share_x_axis=False, share_y_axis=False): 

221 """ 

222 データはそのままで見た目の設定だけを当て直す。datasets は統計値ラベルの計算にだけ使う 

223 (省略すると統計値ラベルが「データセットなし」になるだけ)。 

224 """ 

225 self.fig.set_facecolor(DARK_FIGURE_FACECOLOR if self.dark_mode else LIGHT_FIGURE_FACECOLOR) 

226 is_free_layout = layout_mode == 'free' 

227 for index, ax in enumerate(self.all_axes): 

228 if index < len(all_plot_settings): 

229 settings = all_plot_settings[index] 

230 self._apply_appearance(ax, index, settings) 

231 # redraw_all と同じく、共有で隠すのは _apply_appearance の後 

232 if not is_free_layout: 

233 self._apply_shared_axis_tick_visibility(index, rows, cols, share_x_axis, share_y_axis) 

234 # この経路だけ軸を cla() しないので、内容が同じなら注釈を使い回せる 

235 self._draw_annotations(ax, index, settings, datasets=datasets, 

236 allow_reuse=True) 

237 try: 

238 fit_tight_layout(self.fig) 

239 except (ValueError, FileNotFoundError): 

240 # フォントが欠けた環境の対策(redraw_all 参照) 

241 pass 

242 self._safe_draw() 

243 

244 def _apply_shared_axis_tick_visibility(self, axis_index, rows, cols, share_x_axis, share_y_axis): 

245 """軸の共有が有効なら、内側の目盛数値(最下行以外の X・最左列以外の Y)を隠す。自由配置(cols=0)では何もしない。""" 

246 if cols <= 0: 

247 return 

248 row_idx, col_idx = divmod(axis_index, cols) 

249 ax = self.all_axes[axis_index] 

250 if share_x_axis and row_idx != rows - 1: 

251 ax.tick_params(labelbottom=False) 

252 if share_y_axis and col_idx != 0: 

253 ax.tick_params(labelleft=False) 

254 

255 def _redraw_single_axis_no_draw(self, axis_index, datasets, settings, rows=1, cols=1, 

256 share_x_axis=False, share_y_axis=False, panel_labels_enabled=False, 

257 full_resolution=False): 

258 """update_single_axis() の、draw を呼ぶ手前まで(全軸をまとめて描き直すときに draw を1回で済ませるため)。""" 

259 if axis_index >= len(self.all_axes): 

260 return 

261 

262 # twinx() の第2軸は cla() では消えないので、取り除かないと呼ぶたびに積み重なる 

263 old_secondary = self.all_secondary_axes[axis_index] 

264 if old_secondary is not None: 

265 old_secondary.remove() 

266 self.all_secondary_axes[axis_index] = None 

267 

268 ax = self.all_axes[axis_index] 

269 ax.cla() 

270 # cla() はデータの範囲(dataLim)を残すので、データが無くなった軸のオートスケールが前の範囲になる。 

271 # 全部を描き直す経路(新しい軸)と同じ空の範囲から始める 

272 ax.relim() 

273 

274 # cla() で古い artist は消えているので、参照を捨てるだけ 

275 self._annotation_artists.pop(axis_index, None) 

276 self._annotation_render_keys.pop(axis_index, None) 

277 for dataset_id in [ds.dataset_id for ds in datasets if ds.subplot_target == axis_index]: 

278 self._highlight_artists.pop(dataset_id, None) 

279 self.downsample_index_map.pop(dataset_id, None) 

280 self._non_pickable_dataset_ids.discard(dataset_id) 

281 

282 self._draw_data(ax, axis_index, datasets, full_resolution=full_resolution) 

283 self._apply_appearance(ax, axis_index, settings) 

284 self._draw_annotations(ax, axis_index, settings, datasets=datasets, full_resolution=full_resolution) 

285 if panel_labels_enabled: 

286 self._draw_panel_label(ax, axis_index) 

287 

288 self._apply_shared_axis_tick_visibility(axis_index, rows, cols, share_x_axis, share_y_axis) 

289 

290 def update_single_axis(self, axis_index, datasets, settings, rows=1, cols=1, 

291 share_x_axis=False, share_y_axis=False, panel_labels_enabled=False, 

292 full_resolution=False): 

293 """ 

294 1つの軸だけを描き直す。Figure とほかの軸には触らない。データセットの見た目や描画先・第2Y軸の変更用 

295 (描画先の変更は旧・新の軸それぞれで呼ぶ)。軸の数や配置が変わるときは redraw_all() か 

296 add_free_axis()/remove_last_free_axis() を使う。 

297 """ 

298 self._redraw_single_axis_no_draw( 

299 axis_index, datasets, settings, rows=rows, cols=cols, 

300 share_x_axis=share_x_axis, share_y_axis=share_y_axis, 

301 panel_labels_enabled=panel_labels_enabled, full_resolution=full_resolution, 

302 ) 

303 # 目盛りの文字の幅(第2Y軸・範囲・対数)が変わると余白も変わる。ほかの軸も一緒に動く 

304 self._fit_layout() 

305 self.draw_idle() 

306 

307 def add_free_axis(self, datasets, settings, panel_labels_enabled=False): 

308 """自由配置の末尾に軸を1つ足す(ほかの軸には触らない)。足す軸はどのデータセットからも参照されていない。""" 

309 rect = axis_setting(settings, 'free_rect') or self._default_free_rect(len(self.all_axes)) 

310 ax = self.fig.add_axes(rect) 

311 self.all_axes.append(ax) 

312 self.all_secondary_axes.append(None) 

313 self.axis_is_date_x.append(False) 

314 self.axis_is_category_x.append(False) 

315 

316 axis_index = len(self.all_axes) - 1 

317 self._draw_data(ax, axis_index, datasets) 

318 self._apply_appearance(ax, axis_index, settings) 

319 self._draw_annotations(ax, axis_index, settings, datasets=datasets) 

320 if panel_labels_enabled: 

321 self._draw_panel_label(ax, axis_index) 

322 

323 self.draw_idle() 

324 

325 def remove_last_free_axis(self, datasets): 

326 """ 

327 自由配置の末尾の軸を消す(ほかの軸には触らない)。消す軸のデータセットは、呼び出し側が先に 

328 新しい末尾の軸へ付け替え、描き直しも update_single_axis() で行うこと。 

329 """ 

330 if not self.all_axes: 

331 return 

332 removed_index = len(self.all_axes) - 1 

333 

334 secondary = self.all_secondary_axes[removed_index] 

335 if secondary is not None: 

336 secondary.remove() 

337 self.all_axes[removed_index].remove() 

338 

339 self.all_axes.pop() 

340 self.all_secondary_axes.pop() 

341 if removed_index < len(self.axis_is_date_x): 341 ↛ 343line 341 didn't jump to line 343 because the condition on line 341 was always true

342 self.axis_is_date_x.pop() 

343 if removed_index < len(self.axis_is_category_x): 343 ↛ 346line 343 didn't jump to line 346 because the condition on line 343 was always true

344 self.axis_is_category_x.pop() 

345 

346 self._annotation_artists.pop(removed_index, None) 

347 self._annotation_render_keys.pop(removed_index, None) 

348 for dataset_id in [ds.dataset_id for ds in datasets if ds.subplot_target == removed_index]: 

349 self._highlight_artists.pop(dataset_id, None) 

350 self.downsample_index_map.pop(dataset_id, None) 

351 self._non_pickable_dataset_ids.discard(dataset_id) 

352 

353 self.draw_idle() 

354 

355 def update_all_axes_appearance_and_data(self, datasets, rows, cols, all_plot_settings, layout_mode='grid', 

356 panel_labels_enabled=False, share_x_axis=False, share_y_axis=False, 

357 full_resolution=False): 

358 """ 

359 軸の数と配置を変えずに、全部の軸のデータと見た目を描き直す(パネルラベルやダークモードの切り替え用)。 

360 fig.clf() しないので、軸の数や配置が変わるときには使えない。draw などの Figure 全体の処理は最後に1回だけ。 

361 """ 

362 is_free_layout = layout_mode == 'free' 

363 is_secondary_visible_global = False 

364 

365 for index in range(len(self.all_axes)): 

366 if index >= len(all_plot_settings): 

367 continue 

368 settings = all_plot_settings[index] 

369 self._redraw_single_axis_no_draw( 

370 index, datasets, settings, rows=rows, cols=cols, 

371 share_x_axis=share_x_axis, share_y_axis=share_y_axis, 

372 panel_labels_enabled=panel_labels_enabled, full_resolution=full_resolution, 

373 ) 

374 if self.all_secondary_axes[index] is not None: 

375 is_secondary_visible_global = True 

376 

377 self.fig.set_facecolor(DARK_FIGURE_FACECOLOR if self.dark_mode else LIGHT_FIGURE_FACECOLOR) 

378 if not is_free_layout: 

379 # 自由配置には tight_layout を掛けない・フォントが欠けた環境の対策(redraw_all 参照) 

380 try: 

381 fit_tight_layout(self.fig) 

382 except (ValueError, FileNotFoundError): 

383 pass 

384 

385 self._safe_draw() 

386 return is_secondary_visible_global 

387 

388 def _draw_panel_label(self, ax, index): 

389 return annotations.draw_panel_label(self, ax, index) 

390 

391 @staticmethod 

392 def _panel_label_for_index(index): 

393 return annotations.panel_label_for_index(index) 

394 

395 def _downsample_for_inset(self, x_data, y_data, full_resolution=False): 

396 return annotations.downsample_for_inset(self, x_data, y_data, full_resolution) 

397 

398 def _annotation_render_key(self, axis_index, settings, datasets, full_resolution, parent_ax=None): 

399 return annotations.annotation_render_key(self, axis_index, settings, datasets, full_resolution, parent_ax) 

400 

401 def _draw_annotations(self, ax, axis_index, settings, datasets=None, full_resolution=False, 

402 allow_reuse=False): 

403 return annotations.draw_annotations(self, ax, axis_index, settings, datasets, full_resolution, allow_reuse) 

404 

405 def _enable_element_picking(self, artist): 

406 return data_1d.enable_element_picking(self, artist) 

407 

408 def _add_gradient_line(self, ax, x, y, color1, color2, linewidth, alpha, linestyle, label=None): 

409 return data_1d.add_gradient_line(self, ax, x, y, color1, color2, linewidth, alpha, linestyle, label) 

410 

411 def _add_gradient_fill(self, ax, x, y, color1, color2, alpha, baseline=0.0): 

412 return data_1d.add_gradient_fill(self, ax, x, y, color1, color2, alpha, baseline) 

413 

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

415 

416 def _draw_2d_data(self, ax, axis_index, datasets_2d, full_resolution=False): 

417 return data_2d.draw_2d_data(self, ax, axis_index, datasets_2d, full_resolution) 

418 

419 def _draw_data(self, ax, axis_index, datasets, full_resolution=False): 

420 return data_1d.draw_data(self, ax, axis_index, datasets, full_resolution) 

421 

422 def _record_x_axis_kind(self, axis_index, datasets): 

423 return data_1d.record_x_axis_kind(self, axis_index, datasets) 

424 

425 def _draw_1d_dataset(self, target_ax, axis_index, ds, waterfall, is_category_x, full_resolution): 

426 return data_1d.draw_1d_dataset(self, target_ax, axis_index, ds, waterfall, is_category_x, full_resolution) 

427 

428 def _waterfall_shifted_points(self, ds, waterfall, is_category_x): 

429 return data_1d.waterfall_shifted_points(self, ds, waterfall, is_category_x) 

430 

431 def _downsample_for_display(self, ds, plot_x_data, plot_y_data, is_category_x, full_resolution): 

432 return data_1d.downsample_for_display(self, ds, plot_x_data, plot_y_data, is_category_x, full_resolution) 

433 

434 def _draw_smoothed(self, target_ax, ds, plot_x_data, plot_y_data, plot_kwargs): 

435 return data_1d.draw_smoothed(self, target_ax, ds, plot_x_data, plot_y_data, plot_kwargs) 

436 

437 def _draw_plot_type(self, target_ax, axis_index, ds, plot_x_data, plot_y_data, plot_kwargs): 

438 return data_1d.draw_plot_type(self, target_ax, axis_index, ds, plot_x_data, plot_y_data, plot_kwargs) 

439 

440 def _draw_error_display(self, target_ax, ds, plot_x_data, plot_y_data, downsample_indices): 

441 return data_1d.draw_error_display(self, target_ax, ds, plot_x_data, plot_y_data, downsample_indices) 

442 

443 def set_highlighted_points(self, dataset, master_indices): 

444 """ 

445 データエディタで選んだ行の点をグラフ上で丸く囲む。 

446 

447 Args: 

448 dataset (Dataset): 対象のデータセット。 

449 master_indices (list): dataset.df.index のラベル。空ならこのデータセットのハイライトを消す。 

450 """ 

451 old_artist = self._highlight_artists.pop(dataset.dataset_id, None) 

452 if old_artist is not None: 

453 try: 

454 old_artist.remove() 

455 except (ValueError, NotImplementedError): 

456 pass 

457 

458 if not master_indices: 

459 self.draw_idle() 

460 return 

461 

462 axis_index = dataset.subplot_target 

463 if axis_index >= len(self.all_axes): 

464 self.draw_idle() 

465 return 

466 

467 if dataset.use_secondary_y and axis_index < len(self.all_secondary_axes) \ 

468 and self.all_secondary_axes[axis_index] is not None: 

469 ax = self.all_secondary_axes[axis_index] 

470 else: 

471 ax = self.all_axes[axis_index] 

472 

473 try: 

474 # x_data/y_data は visible_df の並びなので、位置も visible_df.index で引く(除外した行は描かれていない) 

475 visible_index = dataset.visible_df.index 

476 positions = [visible_index.get_loc(idx) for idx in master_indices if idx in visible_index] 

477 except Exception: 

478 logger.exception("ハイライト対象の行インデックス変換に失敗しました。") 

479 positions = [] 

480 

481 if not positions: 

482 self.draw_idle() 

483 return 

484 

485 x_vals = dataset.x_data[positions] 

486 y_vals = dataset.y_data[positions] 

487 # ウォーターフォールでずらした位置に合わせる 

488 x_vals, y_vals = self.data_to_display(dataset, x_vals, y_vals) 

489 artist = ax.scatter( 

490 x_vals, y_vals, s=160, facecolors='none', edgecolors='#e6194b', 

491 linewidths=2.0, zorder=15 

492 ) 

493 self._highlight_artists[dataset.dataset_id] = artist 

494 self.draw_idle() 

495 

496 def _draw_point_labels(self, ax, ds, x_data=None, y_data=None, downsample_indices=None): 

497 return data_1d.draw_point_labels(self, ax, ds, x_data, y_data, downsample_indices) 

498 

499 def _apply_appearance(self, ax, axis_index, settings): 

500 return appearance.apply_appearance(self, ax, axis_index, settings) 

501 

502 def _apply_limits_and_scale(self, ax, settings, is_category_x, axis_index=None): 

503 return appearance.apply_limits_and_scale(self, ax, settings, is_category_x, axis_index) 

504 

505 def _apply_tick_locators(self, ax, settings, is_date_x, is_category_x): 

506 return appearance.apply_tick_locators(self, ax, settings, is_date_x, is_category_x) 

507 

508 def _axis_text_and_line_style(self, settings): 

509 return appearance.axis_text_and_line_style(self, settings) 

510 

511 def _apply_titles_and_labels(self, ax, settings, style): 

512 return appearance.apply_titles_and_labels(self, ax, settings, style) 

513 

514 def _apply_spines_and_tick_marks(self, ax, settings, style): 

515 return appearance.apply_spines_and_tick_marks(self, ax, settings, style) 

516 

517 def _apply_legend(self, ax, secondary_ax, settings): 

518 return appearance.apply_legend(self, ax, secondary_ax, settings) 

519 

520 def _apply_grid(self, ax, settings): 

521 return appearance.apply_grid(self, ax, settings) 

522 

523 def _apply_secondary_y_axis(self, ax, secondary_ax, settings, style): 

524 return appearance.apply_secondary_y_axis(self, ax, secondary_ax, settings, style) 

525 

526 def _apply_unit_conversion_x_axis(self, ax, settings, style, is_date_x, is_category_x): 

527 return appearance.apply_unit_conversion_x_axis(self, ax, settings, style, is_date_x, is_category_x) 

528 

529 def _apply_colorbar(self, ax, axis_index, settings, style): 

530 return appearance.apply_colorbar(self, ax, axis_index, settings, style) 

531 

532 

533class MplCanvas(FigureCanvas, _CanvasDrawingMixin): 

534 # 大きさを変えている間は待ち、止まってから余白を合わせ直す 

535 RELAYOUT_DELAY_MS = 50 

536 

537 def __init__(self, parent=None, width=5, height=4, dpi=100): 

538 self._init_drawing_state(width, height, dpi) 

539 super().__init__(self.fig) 

540 self.setParent(parent) 

541 self._relayout_timer = QTimer(self) 

542 self._relayout_timer.setSingleShot(True) 

543 self._relayout_timer.setInterval(self.RELAYOUT_DELAY_MS) 

544 self._relayout_timer.timeout.connect(self._relayout_after_resize) 

545 

546 def resizeEvent(self, event): 

547 # 余白は図に対する割合で持つので、大きさが変わると目盛りの文字との釣り合いが崩れる 

548 super().resizeEvent(event) 

549 self._relayout_timer.start() 

550 

551 def _relayout_after_resize(self): 

552 if not self.fig.axes: 552 ↛ 553line 552 didn't jump to line 553 because the condition on line 552 was never true

553 return 

554 self._fit_layout() 

555 self.draw_idle() 

556 

557 

558class _HeadlessRenderCanvas(FigureCanvasAgg, _CanvasDrawingMixin): 

559 """一括エクスポート用の Qt に依存しないキャンバス。QWidget ではないので GUI スレッドの外で描いてよい。""" 

560 def __init__(self, width=5, height=4, dpi=100): 

561 self._init_drawing_state(width, height, dpi) 

562 super().__init__(self.fig)