Coverage for graphica/gui/rendering/common.py: 99.2%

186 statements  

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

1"""描画で共有する定数と補助(色・目盛り・ウォーターフォールの並び・統計ラベルなど)。canvas からも同じ名前で読める。""" 

2import logging 

3import matplotlib.ticker as ticker 

4import numpy as np 

5import pandas as pd 

6from graphica.core.analysis import sample_standard_deviation 

7from graphica.core.axis_settings import AXIS_SETTING_DEFAULTS, axis_setting 

8from graphica.core.dataset import WATERFALL_OFFSET_ABSOLUTE 

9from graphica.gui.theme import DARK_TOKENS 

10from typing import NamedTuple 

11 

12# 注釈を「まだ一度も描いていない」を表す番兵。None だとキーが None のときに一致してしまう 

13_NO_KEY = object() 

14 

15logger = logging.getLogger(__name__) 

16 

17# 目盛りの本数の上限(細かすぎる間隔で描画が固まらないように)。matplotlib の MAXTICKS=1000 に丸め誤差で届かない値 

18MAX_TICKS_PER_AXIS = 500 

19 

20# ウォーターフォールのトレースと背景の zorder の範囲。目盛(約2.01)と枠線(2.5)より下に収めて、隠さないようにする 

21WATERFALL_ZORDER_BASE = 0.1 

22WATERFALL_ZORDER_TOP = 1.9 

23 

24# 立体風の縮小の下限。段が多いと振幅が潰れる・反転するので 0.2 倍で止める 

25WATERFALL_DEPTH_SHRINK_MIN_SCALE = 0.2 

26 

27 

28# 軸の位置(図に対する割合)がこれより動かなければ収まったとみなす。1500 画素の図で 0.0002 画素 

29_LAYOUT_SETTLED_TOLERANCE = 1e-7 

30_LAYOUT_MAX_PASSES = 15 

31 

32 

33def fit_tight_layout(fig): 

34 """tight_layout を、軸の位置が動かなくなるまで繰り返す。 

35 

36 tight_layout は今の配置から計算し直すので、注釈や目盛りの文字がはみ出す図では 1 回で収まらず、 

37 描くたびに少しずつ動く(描いた回数で見た目が変わる)。例外はそのまま上げる。 

38 """ 

39 def positions(): 

40 return [bound for ax in fig.axes for bound in ax.get_position().bounds] 

41 

42 before = positions() 

43 for _ in range(_LAYOUT_MAX_PASSES): 

44 fig.tight_layout() 

45 after = positions() 

46 if len(after) == len(before) and all(abs(a - b) <= _LAYOUT_SETTLED_TOLERANCE 

47 for a, b in zip(after, before)): 

48 return 

49 before = after 

50 

51 

52def _waterfall_depth_scale(w_idx, enabled, shrink_ratio): 

53 """段 w_idx のトレースの Y に掛ける倍率。奥ほど縮めて立体風にする(無効なら 1.0)。""" 

54 if not enabled: 

55 return 1.0 

56 return max(WATERFALL_DEPTH_SHRINK_MIN_SCALE, 1.0 - shrink_ratio * w_idx) 

57 

58 

59def waterfall_offset_steps(index, mode): 

60 """オフセットを何倍してずらすか。段ごとの間隔なら段の番号、そのままなら 1。""" 

61 return 1 if mode == WATERFALL_OFFSET_ABSOLUTE else index 

62 

63 

64class _WaterfallLayout(NamedTuple): 

65 index: dict # dataset_id -> 段(0 が一番手前) 

66 count: int 

67 baseline: float # 奥のトレースを隠す背景を敷く下端 

68 

69 

70def _waterfall_layout(datasets): 

71 """ 

72 ウォーターフォールを有効にしたデータセットに、並び順で段を振る。非表示のものも数に入れる 

73 (隠しても後ろの段が繰り上がって図が変わらないように)。下端は、ずらした後の全トレースの最小値から少し下。 

74 """ 

75 waterfall_datasets = [ds for ds in datasets if ds.waterfall_enabled] 

76 baseline = 0.0 

77 shifted_mins, shifted_maxs = [], [] 

78 for i, wds in enumerate(waterfall_datasets): 

79 if len(wds.y_data) == 0: 

80 continue 

81 depth_scale = _waterfall_depth_scale( 

82 i, wds.waterfall_depth_shrink_enabled, wds.waterfall_depth_shrink_ratio) 

83 y_shift = waterfall_offset_steps(i, wds.waterfall_offset_mode) * wds.waterfall_offset_y 

84 shifted_mins.append(float(np.nanmin(wds.y_data)) * depth_scale + y_shift) 

85 shifted_maxs.append(float(np.nanmax(wds.y_data)) * depth_scale + y_shift) 

86 if shifted_mins: 

87 y_min_all, y_max_all = min(shifted_mins), max(shifted_maxs) 

88 margin = (y_max_all - y_min_all) * 0.05 if y_max_all > y_min_all else 1.0 

89 baseline = y_min_all - margin 

90 return _WaterfallLayout( 

91 {ds.dataset_id: i for i, ds in enumerate(waterfall_datasets)}, len(waterfall_datasets), baseline) 

92 

93 

94class _AxisStyle(NamedTuple): 

95 tick_font: dict 

96 label_font: dict 

97 tick_color: str 

98 label_color: str 

99 spine_width: float 

100 spine_color: str 

101 tick_width: float 

102 major_tick_length: float 

103 minor_tick_length: float 

104 

105# 対数軸の補助目盛りの設定値 -> LogLocator の subs(補助目盛りを打つ仮数)。'auto' は表示の桁数に応じて間引く 

106_LOG_MINOR_SUBS_PRESETS = { 

107 'auto': 'auto', 

108 'all': (2, 3, 4, 5, 6, 7, 8, 9), 

109 'few': (2, 5), 

110 'one': (5,), 

111} 

112 

113# 矢印注釈の形 -> matplotlib の arrowstyle。キーの無い古い注釈は 'single' 

114_ARROW_STYLE_MAP = { 

115 'single': '->', 

116 'double': '<->', 

117 'bracket': ']-[', 

118} 

119 

120# 拡大図の置き場所 -> 軸の中での左下の位置(軸に対する割合) 

121_INSET_CORNER_ORIGINS = { 

122 '右上': (0.55, 0.55), 

123 '左上': (0.05, 0.55), 

124 '右下': (0.55, 0.05), 

125 '左下': (0.05, 0.05), 

126} 

127 

128# 'Line' の点がこれを超えたら、表示では LTTB で約 TARGET 点に間引く(線の形を保つ方法なので、点の疎密が情報の散布図には使わない) 

129LTTB_DOWNSAMPLE_THRESHOLD = 20000 

130LTTB_DOWNSAMPLE_TARGET_POINTS = 3000 

131 

132# 2Dマップの1軸あたりの点数の上限。超えたら均等に間引く(エクスポートにも効く) 

133GRID_2D_MAX_DISPLAY_POINTS_PER_AXIS = 500 

134 

135# 領域ハイライトに色・透明度が無いときの値。region_highlight_mixin.py が書き込む既定値と揃えておく 

136REGION_HIGHLIGHT_DEFAULT_COLOR = '#F2A72B' 

137REGION_HIGHLIGHT_DEFAULT_ALPHA = 0.18 

138 

139 

140def _apply_legend_order(lines, labels, order): 

141 """凡例を order(ラベルの並び)の順にする。order に無いラベルは元の順のまま最後に付ける。""" 

142 if not order: 

143 return lines, labels 

144 order_index = {name: i for i, name in enumerate(order)} 

145 indices = sorted( 

146 range(len(labels)), 

147 key=lambda i: (0, order_index[labels[i]]) if labels[i] in order_index else (1, i) 

148 ) 

149 return [lines[i] for i in indices], [labels[i] for i in indices] 

150 

151 

152def _apply_nan_policy(x_data, y_data, policy): 

153 """ 

154 欠損値の方針を描く直前の点列に当てる(データ自体は変えない)。 

155 'gap' は何もしない(matplotlib が NaN で線を切る)。'ffill' は直前の値で埋める(先頭の NaN は残る)。 

156 'drop' は X か Y が欠けた点を除いて前後をつなぐ。知らない値は 'gap' と同じ。 

157 """ 

158 if policy == 'ffill': 

159 return ( 

160 pd.Series(x_data).ffill().to_numpy(), 

161 pd.Series(y_data).ffill().to_numpy(), 

162 ) 

163 if policy == 'drop': 

164 # np.isnan は日時(datetime64)を扱えないので pandas で判定する 

165 x_series = pd.Series(x_data) 

166 y_series = pd.Series(y_data) 

167 valid = (~x_series.isna()) & (~y_series.isna()) 

168 return x_series[valid].to_numpy(), y_series[valid].to_numpy() 

169 return x_data, y_data 

170 

171 

172# 統計値ラベルの見出し 

173STAT_LABEL_TITLES = { 

174 'r_squared': 'R²', 

175 'mean': '平均', 

176 'std': '標準偏差', 

177 'max': '最大値', 

178 'min': '最小値', 

179} 

180 

181 

182def _legend_position_from_settings(settings): 

183 """ 

184 settings['legend_position'](ドラッグで動かした凡例の位置、[x, y])を 

185 legend の loc に渡せるタプルにする。未設定・壊れた値は None。 

186 """ 

187 position = axis_setting(settings, 'legend_position') 

188 if not isinstance(position, (list, tuple)) or len(position) != 2: 

189 return None 

190 try: 

191 x, y = float(position[0]), float(position[1]) 

192 except (TypeError, ValueError): 

193 return None 

194 if not (np.isfinite(x) and np.isfinite(y)): 

195 return None 

196 return (x, y) 

197 

198 

199def _compute_stat_label_text(dataset, stat): 

200 """ 

201 統計値ラベルの文字列を、描くたびにデータとフィット結果から計算する(データを変えると追従する)。 

202 データセットが消えた・まだ計算できないときは、何を待っているかが分かる文字列にする。 

203 """ 

204 title = STAT_LABEL_TITLES.get(stat, stat) 

205 if dataset is None: 

206 return f"{title} = (データセットなし)" 

207 

208 if stat == 'r_squared': 

209 fit_result = dataset.fit_result 

210 if not fit_result or fit_result.get('r_squared') is None: 

211 return f"{title} = (フィット未実行)" 

212 return f"{title} = {fit_result['r_squared']:.4g}" 

213 

214 y = np.asarray(dataset.y_data, dtype=float) 

215 y = y[~np.isnan(y)] 

216 if len(y) == 0: 

217 return f"{title} = (データなし)" 

218 if stat == 'mean': 

219 value = np.mean(y) 

220 elif stat == 'std': 

221 if len(y) < 2: 

222 return f"{title} = (データ不足)" 

223 value = sample_standard_deviation(y) 

224 elif stat == 'max': 

225 value = np.max(y) 

226 elif stat == 'min': 

227 value = np.min(y) 

228 else: 

229 return f"{title} = (未対応の統計値)" 

230 return f"{title} = {value:.4g}" 

231 

232 

233class _SafeMultipleLocator(ticker.MultipleLocator): 

234 """間隔 interval の目盛り。その時の表示範囲で MAX_TICKS_PER_AXIS 本を超えるなら、その本数まで粗くする。 

235 

236 範囲は描いた後もマウスの拡大・縮小や移動で変わるので、目盛りを作るたびに見る(範囲を戻せば設定の間隔に戻る)。 

237 """ 

238 

239 def __init__(self, interval): 

240 super().__init__(interval) 

241 self._interval = interval 

242 

243 def tick_values(self, vmin, vmax): 

244 span = abs(vmax - vmin) 

245 if span > 0 and span / self._interval > MAX_TICKS_PER_AXIS: 

246 return ticker.MultipleLocator(span / MAX_TICKS_PER_AXIS).tick_values(vmin, vmax) 

247 return super().tick_values(vmin, vmax) 

248 

249 

250def _safe_multiple_locator(interval, axis_min, axis_max): 

251 """間隔 interval の目盛り(_SafeMultipleLocator)。今の範囲で細かすぎるなら、粗くして描くことをログに残す。""" 

252 axis_range = abs(axis_max - axis_min) 

253 if axis_range > 0 and interval > 0 and axis_range / interval > MAX_TICKS_PER_AXIS: 

254 logger.warning( 

255 "目盛り間隔 %.6g は軸範囲に対して細かすぎるため、%.6g に調整しました。", 

256 interval, axis_range / MAX_TICKS_PER_AXIS 

257 ) 

258 return _SafeMultipleLocator(interval) 

259 

260 

261def _sci_each_formatter(): 

262 """目盛りごとに指数で書く(例: 1.0×10^10)。""" 

263 def _fmt(value, pos=None): 

264 if value == 0: 

265 return "0" 

266 exponent = int(np.floor(np.log10(abs(value)))) 

267 mantissa = value / (10 ** exponent) 

268 if abs(mantissa) >= 9.995: # 丸めると 10.0 になるので桁を上げる 

269 mantissa /= 10 

270 exponent += 1 

271 return rf"${mantissa:.1f}\times10^{{{exponent}}}$" 

272 return ticker.FuncFormatter(_fmt) 

273 

274 

275def _apply_tick_format_mode(axis, mode): 

276 """ 

277 mode: 0=自動(matplotlib のまま) / 1=指数を軸端にまとめる(×10^n) / 

278 2=目盛りごとに指数 / 3=常に小数で書く 

279 """ 

280 if mode == 1: 

281 formatter = ticker.ScalarFormatter(useMathText=True) 

282 formatter.set_powerlimits((0, 0)) 

283 axis.set_major_formatter(formatter) 

284 elif mode == 2: 

285 axis.set_major_formatter(_sci_each_formatter()) 

286 elif mode == 3: 

287 formatter = ticker.ScalarFormatter(useOffset=False, useMathText=True) 

288 formatter.set_scientific(False) 

289 axis.set_major_formatter(formatter) 

290 

291 

292def _apply_tick_decimal_places(axis, decimals): 

293 """小数点以下の桁数を指定したら、指数表記の設定より優先して固定小数点で書く。負値・None は自動(何もしない)。""" 

294 if decimals is None or decimals < 0: 

295 return 

296 axis.set_major_formatter(ticker.FormatStrFormatter(f'%.{decimals}f')) 

297 

298 

299# 目盛線の長さは軸の大きさに対する割合ではなく pt。文字や線の太さも pt なので、割合にすると 

300# 大きく書き出したときや多パネル図で目盛りだけ長さがちぐはぐになる 

301DEFAULT_MAJOR_TICK_LENGTH = AXIS_SETTING_DEFAULTS['major_tick_length'] 

302# 補助目盛が「自動」のときの主目盛に対する長さ(matplotlib の既定 2.0 / 3.5 と同じ) 

303MINOR_TICK_LENGTH_RATIO = 2.0 / 3.5 

304# 欄で補助目盛の長さ「自動」を表す値。判定は「負値なら自動」なので、この値に頼るのは欄だけ 

305MINOR_TICK_LENGTH_AUTO = -0.5 

306 

307 

308def _resolve_tick_lengths(settings): 

309 """(主目盛, 補助目盛) の長さを pt で返す。補助目盛が未指定・負値なら主目盛 × MINOR_TICK_LENGTH_RATIO。""" 

310 major = axis_setting(settings, 'major_tick_length') 

311 if major is None or major < 0: 311 ↛ 312line 311 didn't jump to line 312 because the condition on line 311 was never true

312 major = DEFAULT_MAJOR_TICK_LENGTH 

313 minor = axis_setting(settings, 'minor_tick_length') 

314 if minor is None or minor < 0: 

315 minor = major * MINOR_TICK_LENGTH_RATIO 

316 return major, minor 

317 

318 

319# ダークのグラフの配色は gui/theme.py のトークンから取る。Figure も Axes も surface にするのは、キャンバスを 

320# 囲む Qt 側の面(surface)との間に色の継ぎ目を作らないため 

321DARK_FIGURE_FACECOLOR = DARK_TOKENS['surface'] 

322DARK_AXES_FACECOLOR = DARK_TOKENS['surface'] 

323DARK_TEXT_COLOR = DARK_TOKENS['text_primary'] 

324# ライトは書き出す図の既定の見た目なので、値を固定して UI のトークンと切り離す(UI の配色を変えても、 

325# 書き出した図が変わらないように)。値は GUI 刷新の前のトークンのまま 

326LIGHT_FIGURE_FACECOLOR = '#FFFFFF' 

327LIGHT_AXES_FACECOLOR = '#FFFFFF' 

328LIGHT_TEXT_COLOR = '#1B1F1E' 

329 

330# 凡例は軸の背景と同化しないよう、一段違う面色と濃い枠線にする 

331DARK_LEGEND_FACECOLOR = DARK_TOKENS['surface_2'] 

332DARK_LEGEND_EDGECOLOR = DARK_TOKENS['border_strong'] 

333LIGHT_LEGEND_FACECOLOR = '#EEF0F3' 

334LIGHT_LEGEND_EDGECOLOR = '#C9CDCB' 

335 

336DARK_GRID_COLOR = DARK_TOKENS['border_strong'] 

337LIGHT_GRID_COLOR = '#C9CDCB'