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
« 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
12# 注釈を「まだ一度も描いていない」を表す番兵。None だとキーが None のときに一致してしまう
13_NO_KEY = object()
15logger = logging.getLogger(__name__)
17# 目盛りの本数の上限(細かすぎる間隔で描画が固まらないように)。matplotlib の MAXTICKS=1000 に丸め誤差で届かない値
18MAX_TICKS_PER_AXIS = 500
20# ウォーターフォールのトレースと背景の zorder の範囲。目盛(約2.01)と枠線(2.5)より下に収めて、隠さないようにする
21WATERFALL_ZORDER_BASE = 0.1
22WATERFALL_ZORDER_TOP = 1.9
24# 立体風の縮小の下限。段が多いと振幅が潰れる・反転するので 0.2 倍で止める
25WATERFALL_DEPTH_SHRINK_MIN_SCALE = 0.2
28# 軸の位置(図に対する割合)がこれより動かなければ収まったとみなす。1500 画素の図で 0.0002 画素
29_LAYOUT_SETTLED_TOLERANCE = 1e-7
30_LAYOUT_MAX_PASSES = 15
33def fit_tight_layout(fig):
34 """tight_layout を、軸の位置が動かなくなるまで繰り返す。
36 tight_layout は今の配置から計算し直すので、注釈や目盛りの文字がはみ出す図では 1 回で収まらず、
37 描くたびに少しずつ動く(描いた回数で見た目が変わる)。例外はそのまま上げる。
38 """
39 def positions():
40 return [bound for ax in fig.axes for bound in ax.get_position().bounds]
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
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)
59def waterfall_offset_steps(index, mode):
60 """オフセットを何倍してずらすか。段ごとの間隔なら段の番号、そのままなら 1。"""
61 return 1 if mode == WATERFALL_OFFSET_ABSOLUTE else index
64class _WaterfallLayout(NamedTuple):
65 index: dict # dataset_id -> 段(0 が一番手前)
66 count: int
67 baseline: float # 奥のトレースを隠す背景を敷く下端
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)
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
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}
113# 矢印注釈の形 -> matplotlib の arrowstyle。キーの無い古い注釈は 'single'
114_ARROW_STYLE_MAP = {
115 'single': '->',
116 'double': '<->',
117 'bracket': ']-[',
118}
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}
128# 'Line' の点がこれを超えたら、表示では LTTB で約 TARGET 点に間引く(線の形を保つ方法なので、点の疎密が情報の散布図には使わない)
129LTTB_DOWNSAMPLE_THRESHOLD = 20000
130LTTB_DOWNSAMPLE_TARGET_POINTS = 3000
132# 2Dマップの1軸あたりの点数の上限。超えたら均等に間引く(エクスポートにも効く)
133GRID_2D_MAX_DISPLAY_POINTS_PER_AXIS = 500
135# 領域ハイライトに色・透明度が無いときの値。region_highlight_mixin.py が書き込む既定値と揃えておく
136REGION_HIGHLIGHT_DEFAULT_COLOR = '#F2A72B'
137REGION_HIGHLIGHT_DEFAULT_ALPHA = 0.18
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]
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
172# 統計値ラベルの見出し
173STAT_LABEL_TITLES = {
174 'r_squared': 'R²',
175 'mean': '平均',
176 'std': '標準偏差',
177 'max': '最大値',
178 'min': '最小値',
179}
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)
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} = (データセットなし)"
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}"
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}"
233class _SafeMultipleLocator(ticker.MultipleLocator):
234 """間隔 interval の目盛り。その時の表示範囲で MAX_TICKS_PER_AXIS 本を超えるなら、その本数まで粗くする。
236 範囲は描いた後もマウスの拡大・縮小や移動で変わるので、目盛りを作るたびに見る(範囲を戻せば設定の間隔に戻る)。
237 """
239 def __init__(self, interval):
240 super().__init__(interval)
241 self._interval = interval
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)
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)
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)
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)
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'))
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
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
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'
330# 凡例は軸の背景と同化しないよう、一段違う面色と濃い枠線にする
331DARK_LEGEND_FACECOLOR = DARK_TOKENS['surface_2']
332DARK_LEGEND_EDGECOLOR = DARK_TOKENS['border_strong']
333LIGHT_LEGEND_FACECOLOR = '#EEF0F3'
334LIGHT_LEGEND_EDGECOLOR = '#C9CDCB'
336DARK_GRID_COLOR = DARK_TOKENS['border_strong']
337LIGHT_GRID_COLOR = '#C9CDCB'