Coverage for graphica/gui/tools/view_navigation.py: 90.3%
348 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"""表示範囲のマウス操作。モードではなく常に有効(モードとぶつかる操作だけ、モードの最中は譲る)。
3- 軸の上(目盛りラベルの帯): ホイールでその軸だけ拡大縮小、左ドラッグでその軸の方向に移動、ダブルクリックでその軸をリセット
4- プロット内: どのモードも無いときの左ドラッグで矩形ズーム、右クリックで1つ前の矩形ズームの前に戻す、
5 ダブルクリックでサブプロットの X・Y・Y2 をリセット、中ボタンのドラッグでパン
7計算はピクセル基準にする(データ座標で差を取ると、範囲を変えるたびに基準の座標がずれる。対数・反転の軸もそのまま扱える)。
8リセットは描画と同じ apply_axis_range を通すので、設定の最小・最大か、自動なら全体の範囲になる。
9"""
10import logging
11import weakref
12from collections import namedtuple
14import numpy as np
15from matplotlib.transforms import Bbox
16from PySide6.QtCore import Qt
18from graphica.core.axis_settings import axis_inverted
19from graphica.gui import app_settings
20from graphica.gui.rendering.appearance import apply_axis_range, range_signature, waterfall_x_shift
21from graphica.gui.tools.manager import MOUSE_MODES_BY_NAME
22from graphica.gui.tools.pointer import legend_at
24logger = logging.getLogger(__name__)
26# 軸の上のホイールで、マウスのホイール1段あたりに何倍にするか(環境設定の「拡大の速さ」)
27WHEEL_ZOOM_BASES = {'slow': 1.1, 'normal': 1.2, 'fast': 1.4}
28# 1回の通知で変わる量の上限(トラックパッドを速く払っても一気に跳ばないように)
29MAX_WHEEL_ZOOM_PER_EVENT = 1.5
30# Qt の angleDelta でホイールの1段(1/8 度単位で 15 度)
31WHEEL_NOTCH_ANGLE = 120
32# 目盛りラベルを隠した軸(軸の共有など)でもつかめるよう、帯はプロットの枠から最低これだけ外へ取る
33AXIS_BAND_MIN_PX = 15
34# 矩形の幅か高さがこれ未満なら、ズームせずクリックとみなす(matplotlib のズームと同じ)
35RECT_ZOOM_MIN_PX = 5
37# axis_key は 'x' / 'y' / 'y2'。ax は範囲を変える Axes('y2' なら第2Y軸)
38AxisBand = namedtuple("AxisBand", "subplot_index axis_key ax bbox")
39# ドラッグの始めの変換を固定して持つ(動かしている最中の変換で戻すと、動かした分だけ基準がずれる)
40_PanTarget = namedtuple("_PanTarget", "ax which inverse span")
42_AXIS_CURSORS = {
43 'x': Qt.CursorShape.SizeHorCursor,
44 'y': Qt.CursorShape.SizeVerCursor,
45 'y2': Qt.CursorShape.SizeVerCursor,
46}
49class ViewNavigationTool:
51 def __init__(self, app):
52 self._app = app
53 self._pan = None # {'button', 'start', 'targets', 'subplot_index', 'axis_keys'}
54 self._rect_zoom = None # {'subplot_index', 'start', 'bbox'}
55 # サブプロットの主の Axes → 矩形ズームの前の範囲の列。描き直しで Axes が作り直されたら一緒に消える
56 self._zoom_history = weakref.WeakKeyDictionary()
57 self._bands = None
58 self._bands_key = None
59 self._cursor_shape = None
61 def connect(self):
62 canvas = self._app.canvas
63 canvas.mpl_connect('scroll_event', self.on_scroll)
64 canvas.mpl_connect('button_press_event', self.on_press)
65 canvas.mpl_connect('motion_notify_event', self.on_motion)
66 canvas.mpl_connect('button_release_event', self.on_release)
67 # 帯は目盛りラベルの大きさで変わるので、描くたびに測り直す
68 canvas.mpl_connect('draw_event', self._invalidate_bands)
70 # --- サブプロットと軸の帯 ---
72 def _subplot_axes(self, index):
73 canvas = self._app.canvas
74 secondary_axes = canvas.all_secondary_axes
75 secondary = secondary_axes[index] if index < len(secondary_axes) else None
76 return canvas.all_axes[index], secondary
78 def subplot_index_of(self, ax):
79 canvas = self._app.canvas
80 for index, primary in enumerate(canvas.all_axes):
81 if ax is primary:
82 return index
83 for index, secondary in enumerate(canvas.all_secondary_axes): 83 ↛ 86line 83 didn't jump to line 86 because the loop on line 83 didn't complete
84 if secondary is not None and ax is secondary: 84 ↛ 83line 84 didn't jump to line 83 because the condition on line 84 was always true
85 return index
86 return None
88 def _invalidate_bands(self, _event=None):
89 self._bands = None
90 # サブプロットが減ったら、もう無い番号の範囲は捨てる(あとで同じ番号が増えたときに古い範囲が出ないように)
91 overrides = self._app.canvas.view_overrides
92 for index in [i for i in overrides if i >= len(self._app.canvas.all_axes)]:
93 del overrides[index]
95 # --- マウスで変えた範囲を、描き直しても保つ(当て直すのは rendering/appearance.apply_view_override) ---
97 def remember_view(self, subplot_index, axis_keys):
98 settings_list = self._app.project.all_plot_settings
99 if subplot_index >= len(settings_list) or subplot_index >= len(self._app.canvas.all_axes): 99 ↛ 100line 99 didn't jump to line 100 because the condition on line 99 was never true
100 return
101 primary, secondary = self._subplot_axes(subplot_index)
102 entry = self._app.canvas.view_overrides.setdefault(subplot_index, {})
103 for axis_key in axis_keys:
104 ax = secondary if axis_key == 'y2' else primary
105 if ax is None:
106 continue
107 limits = ax.get_xlim() if axis_key == 'x' else ax.get_ylim()
108 entry[axis_key] = (tuple(float(v) for v in limits), range_signature(settings_list[subplot_index], axis_key))
109 self._view_changed()
111 def forget_view(self, subplot_index, axis_keys=('x', 'y', 'y2')):
112 entry = self._app.canvas.view_overrides.get(subplot_index)
113 if entry is None:
114 return
115 for axis_key in axis_keys:
116 entry.pop(axis_key, None)
117 if not entry:
118 del self._app.canvas.view_overrides[subplot_index]
119 self._view_changed()
121 def forget_all_views(self):
122 self._app.canvas.view_overrides.clear()
123 self._view_changed()
125 def _view_changed(self):
126 """書き出しのプレビューは表示中の範囲で描くので、範囲が変わったら描き直させる(見えていなければ何もしない)。"""
127 panel = getattr(self._app, 'export_preview_panel', None)
128 if panel is not None: 128 ↛ exitline 128 didn't return from function '_view_changed' because the condition on line 128 was always true
129 panel.refresh_preview()
131 def remember_x_of_every_subplot(self):
132 """ミニマップは全部の軸の X を変えるので、全部のサブプロットの X を覚える。"""
133 for index in range(len(self._app.canvas.all_axes)):
134 self.remember_view(index, ['x'])
136 def axis_bands(self):
137 canvas = self._app.canvas
138 key = tuple(
139 (id(ax), tuple(ax.bbox.bounds)) for ax in list(canvas.all_axes) + list(canvas.all_secondary_axes)
140 if ax is not None
141 )
142 if self._bands is None or self._bands_key != key:
143 self._bands = self._measure_bands()
144 self._bands_key = key
145 return self._bands
147 def _measure_bands(self):
148 canvas = self._app.canvas
149 renderer = canvas.get_renderer()
150 bands = []
151 for index, ax in enumerate(canvas.all_axes):
152 if not ax.get_visible(): 152 ↛ 153line 152 didn't jump to line 153 because the condition on line 152 was never true
153 continue
154 _, secondary = self._subplot_axes(index)
155 box = ax.bbox
156 bands.append(AxisBand(index, 'x', ax, _outer_band(box, ax.xaxis, renderer, 'x')))
157 bands.append(AxisBand(index, 'y', ax, _outer_band(box, ax.yaxis, renderer, 'y')))
158 if secondary is not None:
159 bands.append(AxisBand(index, 'y2', secondary, _outer_band(box, secondary.yaxis, renderer, 'y')))
160 return bands
162 def axis_band_at(self, x, y):
163 """プロットの外の (x, y)(Figure のピクセル、原点は左下)にある軸の帯。無ければ None。"""
164 if x is None or y is None: 164 ↛ 165line 164 didn't jump to line 165 because the condition on line 164 was never true
165 return None
166 for band in self.axis_bands():
167 if band.bbox.contains(x, y):
168 return band
169 return None
171 # --- 範囲の計算 ---
173 @staticmethod
174 def _pixel_span(ax, which):
175 box = ax.bbox
176 return (box.x0, box.x1) if which == 'x' else (box.y0, box.y1)
178 @staticmethod
179 def _set_limits_from_pixels(ax, which, pixels, inverse):
180 """ピクセルの (始め, 終わり) を inverse でデータ座標に戻して範囲にする。枠の始め側が範囲の始め側なので反転もそのまま残る。"""
181 box = ax.bbox
182 if which == 'x':
183 points = [(p, box.y0) for p in pixels]
184 else:
185 points = [(box.x0, p) for p in pixels]
186 values = inverse.transform(points)[:, 0 if which == 'x' else 1]
187 if not np.all(np.isfinite(values)) or values[0] == values[1]: 187 ↛ 188line 187 didn't jump to line 188 because the condition on line 187 was never true
188 return False
189 if which == 'x':
190 ax.set_xlim(values[0], values[1])
191 else:
192 ax.set_ylim(values[0], values[1])
193 return True
195 def zoom_axis(self, band, center_px, scale):
196 """band の軸を、ピクセル位置 center_px を中心に scale 倍(1 未満で拡大)にする。"""
197 which = 'x' if band.axis_key == 'x' else 'y'
198 p0, p1 = self._pixel_span(band.ax, which)
199 pixels = (center_px + (p0 - center_px) * scale, center_px + (p1 - center_px) * scale)
200 if not self._set_limits_from_pixels(band.ax, which, pixels, band.ax.transData.inverted()): 200 ↛ 201line 200 didn't jump to line 201 because the condition on line 200 was never true
201 return False
202 self.remember_view(band.subplot_index, [band.axis_key])
203 return True
205 def reset_axis(self, subplot_index, axis_key):
206 """設定どおりの範囲(最小・最大を決めていればそれ、自動なら全体)に戻す。"""
207 settings_list = self._app.project.all_plot_settings
208 if subplot_index >= len(settings_list): 208 ↛ 209line 208 didn't jump to line 209 because the condition on line 208 was never true
209 return
210 primary, secondary = self._subplot_axes(subplot_index)
211 ax = secondary if axis_key == 'y2' else primary
212 if ax is None: 212 ↛ 213line 212 didn't jump to line 213 because the condition on line 212 was never true
213 return
214 settings = settings_list[subplot_index]
215 is_category_x = self._app.canvas.axis_is_category_x
216 shift = waterfall_x_shift(self._app.canvas, subplot_index) if axis_key == 'x' else (0.0, 0.0)
217 apply_axis_range(ax, settings, axis_key,
218 subplot_index < len(is_category_x) and is_category_x[subplot_index], shift)
219 # set_xlim/set_ylim は反転を解くので、設定どおりに戻す
220 (ax.xaxis if axis_key == 'x' else ax.yaxis).set_inverted(axis_inverted(settings, axis_key))
221 self.forget_view(subplot_index, [axis_key])
223 def reset_subplot(self, subplot_index):
224 _, secondary = self._subplot_axes(subplot_index)
225 for axis_key in ('x', 'y', 'y2') if secondary is not None else ('x', 'y'):
226 self.reset_axis(subplot_index, axis_key)
227 primary, _ = self._subplot_axes(subplot_index)
228 self._zoom_history.pop(primary, None)
230 def _snapshot(self, subplot_index):
231 primary, secondary = self._subplot_axes(subplot_index)
232 return {
233 'x': primary.get_xlim(),
234 'y': primary.get_ylim(),
235 'y2': secondary.get_ylim() if secondary is not None else None,
236 # 戻したときに「設定どおりの範囲」だったのか「マウスで変えた範囲」だったのかも戻すため
237 'overrides': dict(self._app.canvas.view_overrides.get(subplot_index, {})),
238 }
240 def zoom_to_rect(self, subplot_index, start, end):
241 """ピクセルの2点で囲んだ範囲にする。幅か高さが RECT_ZOOM_MIN_PX 未満なら何もしない。ズームしたら True。"""
242 (x0, y0), (x1, y1) = start, end
243 if abs(x1 - x0) < RECT_ZOOM_MIN_PX or abs(y1 - y0) < RECT_ZOOM_MIN_PX:
244 return False
245 primary, secondary = self._subplot_axes(subplot_index)
246 before = self._snapshot(subplot_index)
247 xs, ys = sorted((x0, x1)), sorted((y0, y1))
248 self._set_limits_from_pixels(primary, 'x', xs, primary.transData.inverted())
249 self._set_limits_from_pixels(primary, 'y', ys, primary.transData.inverted())
250 if secondary is not None:
251 self._set_limits_from_pixels(secondary, 'y', ys, secondary.transData.inverted())
252 self._zoom_history.setdefault(primary, []).append(before)
253 self.remember_view(subplot_index, ['x', 'y', 'y2'])
254 return True
256 def zoom_back(self, subplot_index):
257 """1つ前の矩形ズームの前の範囲に戻す。戻せたら True。"""
258 primary, secondary = self._subplot_axes(subplot_index)
259 history = self._zoom_history.get(primary)
260 if not history:
261 return False
262 limits = history.pop()
263 primary.set_xlim(limits['x'])
264 primary.set_ylim(limits['y'])
265 if secondary is not None and limits['y2'] is not None: 265 ↛ 266line 265 didn't jump to line 266 because the condition on line 265 was never true
266 secondary.set_ylim(limits['y2'])
267 if limits['overrides']:
268 self._app.canvas.view_overrides[subplot_index] = dict(limits['overrides'])
269 else:
270 self._app.canvas.view_overrides.pop(subplot_index, None)
271 self._view_changed()
272 return True
274 def _start_pan(self, button, event, targets, subplot_index, axis_keys):
275 frozen = [
276 _PanTarget(ax, which, ax.transData.inverted().frozen(), self._pixel_span(ax, which))
277 for ax, which in targets
278 ]
279 self._pan = {'button': button, 'start': (event.x, event.y), 'targets': frozen,
280 'subplot_index': subplot_index, 'axis_keys': axis_keys}
282 def _pan_to(self, event):
283 start_x, start_y = self._pan['start']
284 for target in self._pan['targets']:
285 delta = (event.x - start_x) if target.which == 'x' else (event.y - start_y)
286 p0, p1 = target.span
287 self._set_limits_from_pixels(target.ax, target.which, (p0 - delta, p1 - delta), target.inverse)
289 # --- モードとの譲り合い ---
291 def _active_mode(self):
292 name = self._app._active_mouse_mode()
293 return MOUSE_MODES_BY_NAME.get(name) if name else None
295 # --- イベント ---
297 def on_scroll(self, event):
298 if event.inaxes is not None:
299 return
300 band = self.axis_band_at(event.x, event.y)
301 if band is None: 301 ↛ 302line 301 didn't jump to line 302 because the condition on line 301 was never true
302 return
303 notches = wheel_notches(event)
304 if not notches:
305 return
306 base = WHEEL_ZOOM_BASES.get(app_settings.WHEEL_ZOOM_SPEED.read(self._app.settings), WHEEL_ZOOM_BASES['normal'])
307 scale = min(max(base ** (-notches), 1 / MAX_WHEEL_ZOOM_PER_EVENT), MAX_WHEEL_ZOOM_PER_EVENT)
308 center = event.x if band.axis_key == 'x' else event.y
309 if self.zoom_axis(band, center, scale): 309 ↛ exitline 309 didn't return from function 'on_scroll' because the condition on line 309 was always true
310 self._app.canvas.draw_idle()
312 def on_press(self, event):
313 if self._pan is not None or self._rect_zoom is not None: 313 ↛ 314line 313 didn't jump to line 314 because the condition on line 313 was never true
314 return
315 if event.inaxes is None:
316 self._on_press_outside_plot(event)
317 return
318 index = self.subplot_index_of(event.inaxes)
319 if index is None: 319 ↛ 320line 319 didn't jump to line 320 because the condition on line 319 was never true
320 return
321 primary, secondary = self._subplot_axes(index)
322 mode = self._active_mode()
324 if event.button == 2:
325 targets = [(primary, 'x'), (primary, 'y')] + ([(secondary, 'y')] if secondary is not None else [])
326 self._start_pan(2, event, targets, index, ['x', 'y', 'y2'])
327 elif event.button == 3:
328 if (mode is None or not mode.uses_right_click) and self.zoom_back(index):
329 self._app.canvas.draw_idle()
330 elif event.button == 1: 330 ↛ exitline 330 didn't return from function 'on_press' because the condition on line 330 was always true
331 if legend_at(self._app.canvas, event) is not None:
332 return
333 if event.dblclick:
334 if mode is None or not mode.uses_left_click:
335 self.reset_subplot(index)
336 self._app.canvas.draw_idle()
337 elif mode is None:
338 self._rect_zoom = {'subplot_index': index, 'start': (event.x, event.y), 'bbox': primary.bbox}
340 def _on_press_outside_plot(self, event):
341 if event.button != 1: 341 ↛ 342line 341 didn't jump to line 342 because the condition on line 341 was never true
342 return
343 band = self.axis_band_at(event.x, event.y)
344 if band is None:
345 self._start_rect_zoom_from_margin(event)
346 return
347 # 枠の外へドラッグした凡例が帯に重なっていれば、凡例のドラッグに譲る
348 if legend_at(self._app.canvas, event) is not None:
349 return
350 if event.dblclick:
351 self.reset_axis(band.subplot_index, band.axis_key)
352 self._app.canvas.draw_idle()
353 return
354 self._start_pan(1, event, [(band.ax, 'x' if band.axis_key == 'x' else 'y')],
355 band.subplot_index, [band.axis_key])
357 def _start_rect_zoom_from_margin(self, event):
358 """軸の帯の外の余白から、いちばん近いサブプロットの矩形ズームを、その縁に合わせた位置から始める(縁ぴったりまで囲めるように)。"""
359 if event.dblclick or event.x is None or event.y is None or self._active_mode() is not None:
360 return
361 if legend_at(self._app.canvas, event) is not None: 361 ↛ 362line 361 didn't jump to line 362 because the condition on line 361 was never true
362 return
363 nearest = None
364 for index, ax in enumerate(self._app.canvas.all_axes):
365 if not ax.get_visible(): 365 ↛ 366line 365 didn't jump to line 366 because the condition on line 365 was never true
366 continue
367 box = ax.bbox
368 dx = max(box.x0 - event.x, 0, event.x - box.x1)
369 dy = max(box.y0 - event.y, 0, event.y - box.y1)
370 distance = dx * dx + dy * dy
371 if nearest is None or distance < nearest[0]: 371 ↛ 364line 371 didn't jump to line 364 because the condition on line 371 was always true
372 nearest = (distance, index, box)
373 if nearest is None: 373 ↛ 374line 373 didn't jump to line 374 because the condition on line 373 was never true
374 return
375 _, index, box = nearest
376 start = (min(max(event.x, box.x0), box.x1), min(max(event.y, box.y0), box.y1))
377 self._rect_zoom = {'subplot_index': index, 'start': start, 'bbox': box}
379 def on_motion(self, event):
380 if self._pan is not None:
381 if event.x is not None and event.y is not None: 381 ↛ 384line 381 didn't jump to line 384 because the condition on line 381 was always true
382 self._pan_to(event)
383 self._app.canvas.draw_idle()
384 return
385 if self._rect_zoom is not None:
386 if event.x is not None and event.y is not None: 386 ↛ 388line 386 didn't jump to line 388 because the condition on line 386 was always true
387 self._draw_rubberband(self._rect_zoom['start'], self._clamp_to_plot(event))
388 return
389 band = self.axis_band_at(event.x, event.y) if event.inaxes is None else None
390 self._set_cursor(_AXIS_CURSORS[band.axis_key] if band is not None else None)
392 def on_release(self, event):
393 if self._pan is not None:
394 if event.button == self._pan['button']: 394 ↛ 397line 394 didn't jump to line 397 because the condition on line 394 was always true
395 state, self._pan = self._pan, None
396 self.remember_view(state['subplot_index'], state['axis_keys'])
397 return
398 if self._rect_zoom is not None and event.button == 1:
399 state, self._rect_zoom = self._rect_zoom, None
400 self._remove_rubberband()
401 if event.x is None or event.y is None: 401 ↛ 402line 401 didn't jump to line 402 because the condition on line 401 was never true
402 return
403 if self.zoom_to_rect(state['subplot_index'], state['start'], self._clamp_to_plot(event, state['bbox'])):
404 self._app.canvas.draw_idle()
406 # --- 表示 ---
408 def _clamp_to_plot(self, event, bbox=None):
409 box = bbox if bbox is not None else self._rect_zoom['bbox']
410 return (min(max(event.x, box.x0), box.x1), min(max(event.y, box.y0), box.y1))
412 def _draw_rubberband(self, start, end):
413 canvas = self._app.canvas
414 # Qt の座標は原点が左上(matplotlib の NavigationToolbar2QT.draw_rubberband と同じ変換)
415 height = canvas.figure.bbox.height
416 x0, y0 = start
417 x1, y1 = end
418 y0, y1 = height - y0, height - y1
419 canvas.drawRectangle([int(v) for v in (x0, y0, x1 - x0, y1 - y0)])
421 def _remove_rubberband(self):
422 self._app.canvas.drawRectangle(None)
424 def _set_cursor(self, shape):
425 if shape == self._cursor_shape:
426 return
427 canvas = self._app.canvas
428 if shape is None:
429 canvas.unsetCursor()
430 else:
431 canvas.setCursor(shape)
432 self._cursor_shape = shape
435def wheel_notches(event):
436 """ホイールの動きを「マウスのホイール何段分か」で返す(上で正)。指を離した後の慣性の分は 0。
438 matplotlib の step は、OS がピクセル単位の量を渡すとき(macOS のトラックパッドや Magic Mouse)はピクセル数になり、
439 1回で何十にもなる。Qt の angleDelta はどの OS でも渡されるので、そちらを段の数に直す。
440 """
441 gui_event = getattr(event, 'guiEvent', None)
442 if gui_event is None or not hasattr(gui_event, 'angleDelta'):
443 return event.step
444 if hasattr(gui_event, 'phase') and gui_event.phase() == Qt.ScrollPhase.ScrollMomentum:
445 return 0.0
446 return gui_event.angleDelta().y() / WHEEL_NOTCH_ANGLE
449def _outer_band(box, axis, renderer, which):
450 """プロットの枠の外側の、axis の目盛りラベル(と軸ラベル)がある帯。帯の長さはプロットの枠の辺に合わせる。"""
451 try:
452 tight = axis.get_tightbbox(renderer)
453 except (ValueError, RuntimeError):
454 logger.debug("軸の大きさを測れませんでした", exc_info=True)
455 tight = None
456 label_side = axis.get_label_position()
457 if which == 'x':
458 if label_side == 'top': 458 ↛ 459line 458 didn't jump to line 459 because the condition on line 458 was never true
459 outer = max(box.y1 + AXIS_BAND_MIN_PX, tight.y1 if tight is not None else -np.inf)
460 return Bbox.from_extents(box.x0, box.y1, box.x1, outer)
461 outer = min(box.y0 - AXIS_BAND_MIN_PX, tight.y0 if tight is not None else np.inf)
462 return Bbox.from_extents(box.x0, outer, box.x1, box.y0)
463 if label_side == 'right':
464 outer = max(box.x1 + AXIS_BAND_MIN_PX, tight.x1 if tight is not None else -np.inf)
465 return Bbox.from_extents(box.x1, box.y0, outer, box.y1)
466 outer = min(box.x0 - AXIS_BAND_MIN_PX, tight.x0 if tight is not None else np.inf)
467 return Bbox.from_extents(outer, box.y0, box.x0, box.y1)