Coverage for graphica/gui/minimap_widget.py: 93.9%

92 statements  

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

1"""グラフの下の小さな全体図。ドラッグで選んだ範囲を range_selected で知らせる(軸に当てるのは呼び出し側)。 

2 

3メインのキャンバスとは別の Figure なので、redraw_all() では描き直されない。データが変わったら refresh() を呼ぶこと。 

4""" 

5import logging 

6 

7import numpy as np 

8from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas 

9from matplotlib.figure import Figure 

10from matplotlib.widgets import SpanSelector 

11from PySide6.QtCore import Signal 

12 

13from graphica.core.analysis import calculate_lttb_downsample 

14from graphica.core.dataset import COLOR_BY_COLUMN_PLOT_TYPE 

15from graphica.gui import theme 

16 

17logger = logging.getLogger(__name__) 

18 

19# 点で描く種類。Line+Scatter は線が主なので含めない 

20MINIMAP_POINT_PLOT_TYPES = frozenset({'Scatter', 'Density Scatter', COLOR_BY_COLUMN_PLOT_TYPE}) 

21 

22MINIMAP_HEIGHT_PX = 70 

23 

24# 間引く。SpanSelector の背景は全体を描いた画像で、refresh() のたびに作り直すので、点が多いとドラッグが重くなる。 

25# 幅が数百 px しかないので、canvas の LTTB より少ない点でよい 

26MINIMAP_DOWNSAMPLE_THRESHOLD = 2000 

27MINIMAP_DOWNSAMPLE_TARGET_POINTS = 500 

28 

29 

30 

31def minimap_colors(dark_mode): 

32 """(図の地, 軸の地, 線と選択範囲) の色。地はカードの面、軸の地は一段くぼんだ面、線と選択範囲はテーマカラー。""" 

33 tokens = theme.tokens_for(dark_mode, theme.current_theme_color()) 

34 return tokens['surface'], tokens['surface_2'], tokens['accent'] 

35 

36 

37class MinimapWidget(FigureCanvas): 

38 range_selected = Signal(float, float) 

39 

40 def __init__(self, parent=None, dpi=100): 

41 self.fig = Figure(figsize=(5, 0.7), dpi=dpi) 

42 super().__init__(self.fig) 

43 self.setParent(parent) 

44 self.setFixedHeight(MINIMAP_HEIGHT_PX) 

45 

46 self.ax = self.fig.add_subplot(111) 

47 self.dark_mode = False 

48 self._span_selector = None 

49 

50 self._configure_axes_style() 

51 self._create_span_selector() 

52 self._apply_theme_colors() 

53 

54 

55 def _configure_axes_style(self): 

56 self.ax.set_yticks([]) 

57 self.ax.tick_params(axis='x', labelsize=6) 

58 for spine in ('top', 'right', 'left'): 

59 self.ax.spines[spine].set_visible(False) 

60 self.fig.subplots_adjust(left=0.02, right=0.98, top=0.92, bottom=0.28) 

61 

62 def _create_span_selector(self): 

63 """SpanSelector を作り直す(ax.cla() が選択の矩形も消すので、refresh() のたびに)。 

64 

65 古いものの接続は切る。matplotlib は cla() でも切らないので、残すと溜まって range_selected が重複して出る。 

66 """ 

67 if getattr(self, '_span_selector', None) is not None: 

68 self._span_selector.disconnect_events() 

69 span_color = minimap_colors(self.dark_mode)[2] 

70 self._span_selector = SpanSelector( 

71 self.ax, 

72 self._on_select, 

73 'horizontal', 

74 useblit=True, 

75 props=dict(alpha=0.3, facecolor=span_color), 

76 interactive=True, 

77 drag_from_anywhere=True, 

78 ) 

79 

80 def _apply_theme_colors(self): 

81 fig_face, axes_face, _ = minimap_colors(self.dark_mode) 

82 self.fig.set_facecolor(fig_face) 

83 self.ax.set_facecolor(axes_face) 

84 

85 def _on_select(self, xmin, xmax): 

86 if xmin == xmax: 

87 # クリックだけ(ドラッグなし)は選択とみなさない 

88 return 

89 self.range_selected.emit(xmin, xmax) 

90 

91 @staticmethod 

92 def _downsample_for_overview(x, y): 

93 """点を減らす。X が昇順なら LTTB、そうでなければ等間隔に間引く(LTTB は昇順が前提)。""" 

94 n = len(x) 

95 if n <= MINIMAP_DOWNSAMPLE_THRESHOLD: 

96 return x, y 

97 if np.all(np.diff(x) >= 0): 

98 idx = calculate_lttb_downsample(x, y, MINIMAP_DOWNSAMPLE_TARGET_POINTS) 

99 else: 

100 step = max(1, n // MINIMAP_DOWNSAMPLE_TARGET_POINTS) 

101 idx = np.arange(0, n, step) 

102 return x[idx], y[idx] 

103 

104 

105 def refresh(self, datasets, dark_mode=False): 

106 """各データセットの X/Y を薄い線か点で重ねるだけの簡単な全体図にする。""" 

107 self.dark_mode = dark_mode 

108 self.ax.cla() 

109 self._configure_axes_style() 

110 self._apply_theme_colors() 

111 

112 line_color = minimap_colors(dark_mode)[2] 

113 has_data = False 

114 for ds in datasets: 

115 # メインと同じく隠した系列は出さない 

116 if not getattr(ds, 'visible', True): 

117 continue 

118 # 2D マップの x/y は測定点の列なので、線にすると意味が無い。描かない 

119 if getattr(ds, 'data_kind', '1d') == '2d_grid': 

120 continue 

121 try: 

122 x = ds.x_data 

123 y = ds.y_data 

124 except Exception: 

125 logger.debug("ミニマップ用のデータを取り出せません: %s", ds.name, exc_info=True) 

126 continue 

127 if x is None or len(x) == 0: 127 ↛ 128line 127 didn't jump to line 128 because the condition on line 127 was never true

128 continue 

129 try: 

130 x, y = self._downsample_for_overview(x, y) 

131 if getattr(ds, 'plot_type', 'Line') in MINIMAP_POINT_PLOT_TYPES: 

132 # 点の種類は点で描く(線にするとピーク検出の結果がピーク同士を結ぶ線になる) 

133 self.ax.plot(x, y, linestyle='None', marker='o', markersize=1.8, 

134 color=line_color, alpha=0.8) 

135 else: 

136 self.ax.plot(x, y, color=line_color, linewidth=0.7, alpha=0.6) 

137 has_data = True 

138 except Exception: 

139 # 全体図なので、描けなくても止めない 

140 logger.debug("ミニマップへのデータセット描画に失敗しました", exc_info=True) 

141 

142 if has_data: 

143 self.ax.relim() 

144 self.ax.autoscale_view() 

145 

146 self._create_span_selector() 

147 self.draw_idle()