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
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-12 16:38 +0000
1import logging
3from PySide6.QtCore import QTimer
5from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
6from matplotlib.backends.backend_agg import FigureCanvasAgg
7from matplotlib.figure import Figure
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)
26logger = logging.getLogger(__name__)
29class _CanvasDrawingMixin:
30 """
31 描画の本体。Qt に依存しないので、画面用の MplCanvas(FigureCanvasQTAgg)と
32 一括エクスポート用の _HeadlessRenderCanvas(FigureCanvasAgg)の両方が使う。
33 """
35 def _init_drawing_state(self, width, height, dpi):
36 self.fig = Figure(figsize=(width, height), dpi=dpi)
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
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
83 # --- ウォーターフォールの表示座標 ⇔ データ座標 ---
85 def get_waterfall_transform(self, dataset_or_id):
86 """
87 データセットの今のウォーターフォール変換。無効または未描画なら None。
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)
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
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
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
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)
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 )
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)
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
173 is_secondary_visible_global = False
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)
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
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)
205 if self.all_secondary_axes[index] is not None:
206 is_secondary_visible_global = True
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
216 self._safe_draw()
217 return is_secondary_visible_global
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()
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)
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
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
268 ax = self.all_axes[axis_index]
269 ax.cla()
270 # cla() はデータの範囲(dataLim)を残すので、データが無くなった軸のオートスケールが前の範囲になる。
271 # 全部を描き直す経路(新しい軸)と同じ空の範囲から始める
272 ax.relim()
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)
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)
288 self._apply_shared_axis_tick_visibility(axis_index, rows, cols, share_x_axis, share_y_axis)
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()
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)
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)
323 self.draw_idle()
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
334 secondary = self.all_secondary_axes[removed_index]
335 if secondary is not None:
336 secondary.remove()
337 self.all_axes[removed_index].remove()
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()
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)
353 self.draw_idle()
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
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
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
385 self._safe_draw()
386 return is_secondary_visible_global
388 def _draw_panel_label(self, ax, index):
389 return annotations.draw_panel_label(self, ax, index)
391 @staticmethod
392 def _panel_label_for_index(index):
393 return annotations.panel_label_for_index(index)
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)
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)
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)
405 def _enable_element_picking(self, artist):
406 return data_1d.enable_element_picking(self, artist)
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)
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)
414 _VALID_MAP_DISPLAY_MODES = ('heatmap', 'contour', 'contour_filled', 'heatmap_contour')
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)
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)
422 def _record_x_axis_kind(self, axis_index, datasets):
423 return data_1d.record_x_axis_kind(self, axis_index, datasets)
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)
428 def _waterfall_shifted_points(self, ds, waterfall, is_category_x):
429 return data_1d.waterfall_shifted_points(self, ds, waterfall, is_category_x)
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)
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)
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)
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)
443 def set_highlighted_points(self, dataset, master_indices):
444 """
445 データエディタで選んだ行の点をグラフ上で丸く囲む。
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
458 if not master_indices:
459 self.draw_idle()
460 return
462 axis_index = dataset.subplot_target
463 if axis_index >= len(self.all_axes):
464 self.draw_idle()
465 return
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]
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 = []
481 if not positions:
482 self.draw_idle()
483 return
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()
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)
499 def _apply_appearance(self, ax, axis_index, settings):
500 return appearance.apply_appearance(self, ax, axis_index, settings)
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)
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)
508 def _axis_text_and_line_style(self, settings):
509 return appearance.axis_text_and_line_style(self, settings)
511 def _apply_titles_and_labels(self, ax, settings, style):
512 return appearance.apply_titles_and_labels(self, ax, settings, style)
514 def _apply_spines_and_tick_marks(self, ax, settings, style):
515 return appearance.apply_spines_and_tick_marks(self, ax, settings, style)
517 def _apply_legend(self, ax, secondary_ax, settings):
518 return appearance.apply_legend(self, ax, secondary_ax, settings)
520 def _apply_grid(self, ax, settings):
521 return appearance.apply_grid(self, ax, settings)
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)
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)
529 def _apply_colorbar(self, ax, axis_index, settings, style):
530 return appearance.apply_colorbar(self, ax, axis_index, settings, style)
533class MplCanvas(FigureCanvas, _CanvasDrawingMixin):
534 # 大きさを変えている間は待ち、止まってから余白を合わせ直す
535 RELAYOUT_DELAY_MS = 50
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)
546 def resizeEvent(self, event):
547 # 余白は図に対する割合で持つので、大きさが変わると目盛りの文字との釣り合いが崩れる
548 super().resizeEvent(event)
549 self._relayout_timer.start()
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()
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)