Coverage for graphica/gui/rendering/annotations.py: 99.4%
219 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"""注釈(文字・矢印・統計ラベル・範囲の強調・拡大図)とパネルラベルの描画。作り直しを省く鍵もここで作る。"""
2import json
3import logging
4import numpy as np
5from graphica.core.analysis import calculate_lttb_downsample
6from graphica.core.axis_settings import axis_setting
7from graphica.gui.mathtext_preview import JP_CAPABLE_FONT_FAMILIES, families_for_text
8from graphica.gui.rendering.common import (
9 DARK_TEXT_COLOR, LIGHT_TEXT_COLOR, LTTB_DOWNSAMPLE_TARGET_POINTS, LTTB_DOWNSAMPLE_THRESHOLD,
10 REGION_HIGHLIGHT_DEFAULT_ALPHA, REGION_HIGHLIGHT_DEFAULT_COLOR, _ARROW_STYLE_MAP, _INSET_CORNER_ORIGINS,
11 _NO_KEY, _compute_stat_label_text)
12from mpl_toolkits.axes_grid1.inset_locator import mark_inset
14logger = logging.getLogger(__name__)
17def draw_panel_label(canvas, ax, index):
18 """軸の左上に (a)(b)(c)… を描く。文字は保存せず並び順から毎回決めるので、並べ替えても振り直される。"""
19 label = canvas._panel_label_for_index(index)
20 text_color = DARK_TEXT_COLOR if canvas.dark_mode else LIGHT_TEXT_COLOR
21 ax.text(
22 -0.12, 1.08, f"({label})", transform=ax.transAxes,
23 fontsize=12, fontweight='bold', color=text_color,
24 ha='left', va='top', zorder=10,
25 )
28def panel_label_for_index(index):
29 """0->a, …, 25->z, 26->aa, 27->ab, …(Excel の列名と同じ)"""
30 letters = []
31 n = index
32 while True:
33 n, remainder = divmod(n, 26)
34 letters.append(chr(ord('a') + remainder))
35 if n == 0:
36 break
37 n -= 1
38 return ''.join(reversed(letters))
41def downsample_for_inset(canvas, x_data, y_data, full_resolution=False):
42 """
43 拡大図の点列を本体と同じく LTTB で間引く(注釈は描き直しのたびに作り直すので、大きなデータで重くなる)。
44 拡大図は種類に関わらず線で描くので、散布図を除く本体の条件は当てはまらない。X が昇順のときだけ間引く。
45 """
46 if full_resolution or len(x_data) <= LTTB_DOWNSAMPLE_THRESHOLD:
47 return x_data, y_data
48 if not np.all(np.diff(x_data) >= 0):
49 return x_data, y_data
50 indices = calculate_lttb_downsample(x_data, y_data, LTTB_DOWNSAMPLE_TARGET_POINTS)
51 return x_data[indices], y_data[indices]
54def fit_inset_y_range(ax, inset_ax, zoom_y_range=None):
55 """
56 拡大図の Y 範囲。指定があればそのまま使う。無ければ自動の範囲を親の軸の Y 範囲に収める
57 (親に印す四角は拡大図の表示範囲そのものなので、収めないと親の軸の外へはみ出す)。
58 収まっているときは触らない(自動の範囲のまま)。拡大範囲の点がすべて親の範囲外なら、点が見えるよう自動のまま。
59 """
60 if zoom_y_range is not None:
61 lo, hi = zoom_y_range
62 inset_ax.set_ylim(min(lo, hi), max(lo, hi))
63 return
64 lo, hi = inset_ax.get_ylim()
65 parent_lo, parent_hi = sorted(ax.get_ylim())
66 clipped_lo, clipped_hi = max(lo, parent_lo), min(hi, parent_hi)
67 if (clipped_lo, clipped_hi) == (lo, hi) or clipped_lo >= clipped_hi:
68 return
69 inset_ax.set_ylim(clipped_lo, clipped_hi)
72def _bbox_corners(x0, y0, x1, y1):
73 """mark_inset の角の番号(1 右上・2 左上・3 左下・4 右下)-> 点。"""
74 return {1: (x1, y1), 2: (x0, y1), 3: (x0, y0), 4: (x1, y0)}
77def choose_inset_connector_corners(box, inset):
78 """
79 引き出し線の角(loc1, loc2)。box は親に印す四角、inset は拡大図で、どちらも親の軸に対する座標の (x0, y0, x1, y1)(x0 < x1, y0 < y1)。
80 同じ番号の角どうしを結ぶ線のうち、両方の四角が線の片側に収まる(=どちらの四角も横切らない)角を使う。
81 今までの (2, 4) が使えるならそれを、次に (1, 3) を選ぶ。四角が重なっていて2つ見つからなければ (2, 4)。
82 """
83 box_corners, inset_corners = _bbox_corners(*box), _bbox_corners(*inset)
84 points = list(box_corners.values()) + list(inset_corners.values())
85 usable = []
86 for loc in (1, 2, 3, 4):
87 (px, py), (qx, qy) = box_corners[loc], inset_corners[loc]
88 dx, dy = qx - px, qy - py
89 if dx == 0 and dy == 0: 89 ↛ 90line 89 didn't jump to line 90 because the condition on line 89 was never true
90 continue
91 sides = [dx * (ty - py) - dy * (tx - px) for tx, ty in points]
92 tolerance = 1e-9 * max(abs(dx), abs(dy))
93 if all(s >= -tolerance for s in sides) or all(s <= tolerance for s in sides):
94 usable.append(loc)
95 for pair in ((2, 4), (1, 3)):
96 if all(loc in usable for loc in pair):
97 return pair
98 if len(usable) >= 2:
99 return usable[0], usable[1]
100 return 2, 4
103def inset_connector_corners(ax, inset_ax, inset_rect):
104 """
105 拡大図の今の表示範囲(親に印す四角)と拡大図の位置から、引き出し線の角を見た目の位置で選ぶ。inset_rect は親の軸に対する (x0, y0, x1, y1)。
106 """
107 (x_lo, x_hi), (y_lo, y_hi) = inset_ax.get_xlim(), inset_ax.get_ylim()
108 to_axes = ax.transData + ax.transAxes.inverted()
109 (bx0, by0), (bx1, by1) = to_axes.transform([(x_lo, y_lo), (x_hi, y_hi)])
110 box = (min(bx0, bx1), min(by0, by1), max(bx0, bx1), max(by0, by1))
111 return choose_inset_connector_corners(box, inset_rect)
114# mark_inset の四角は拡大図の表示範囲の向きで角を数えるので、拡大図を反転すると見た目の角と番号が入れ替わる
115_MIRROR_X_CORNER = {1: 2, 2: 1, 3: 4, 4: 3}
116_MIRROR_Y_CORNER = {1: 4, 4: 1, 2: 3, 3: 2}
119def _box_corner_label(loc, inset_ax):
120 """見た目で loc の位置にある、親に印す四角の角の番号。"""
121 if inset_ax.xaxis_inverted():
122 loc = _MIRROR_X_CORNER[loc]
123 if inset_ax.yaxis_inverted():
124 loc = _MIRROR_Y_CORNER[loc]
125 return loc
128def _secondary_axis(canvas, axis_index):
129 secondary_axes = canvas.all_secondary_axes
130 return secondary_axes[axis_index] if axis_index < len(secondary_axes) else None
133def _map_between_y_axes(ax, from_ax, to_ax, y):
134 """from_ax の Y の値 -> 親の図で同じ高さにある to_ax の Y の値(目盛りの種類・反転を含む)。"""
135 y = np.asarray(y, dtype=float)
136 # X は捨てるが、対数の X で 0 を変換すると nan が Y に混ざるので、軸の範囲内の値を使う
137 x = np.full_like(y, ax.get_xlim()[0])
138 display = from_ax.transData.transform(np.column_stack([x, y]))
139 return to_ax.transData.inverted().transform(display)[:, 1]
142def _inset_points(canvas, ds, x_min, x_max, full_resolution):
143 """拡大範囲の点(間引いたもの)。範囲内に点が無ければ None。"""
144 tx = np.asarray(ds.x_data, dtype=float)
145 ty = np.asarray(ds.y_data, dtype=float)
146 in_range = (tx >= x_min) & (tx <= x_max)
147 if not in_range.any():
148 return None
149 return canvas._downsample_for_inset(tx[in_range], ty[in_range], full_resolution=full_resolution)
152def _make_inset_secondary(ax, inset_ax, bounds, parent_secondary):
153 """
154 拡大図の右側の第2 Y 軸。twinx() は Figure に軸を足すので、軸だけ描き直す経路(cla())で消えずに残る。
155 そのため親の子の軸として同じ場所に重ね、X を拡大図と共有する。
156 """
157 secondary = ax.inset_axes(bounds, sharex=inset_ax)
158 secondary.patch.set_visible(False)
159 secondary.xaxis.set_visible(False)
160 secondary.yaxis.tick_right()
161 if parent_secondary.get_yscale() != 'linear':
162 secondary.set_yscale(parent_secondary.get_yscale())
163 return secondary
166def draw_inset(canvas, ax, axis_index, ann, datasets, full_resolution):
167 """
168 拡大図を描いて、作った artist を返す。種類やグラデーションは再現せず、範囲内の点を線で結ぶだけの概観。
169 目盛りの種類・反転は親と同じにし、第2 Y 軸のデータセットは右側の第2 Y 軸に、親の図と同じ高さで描く。
170 """
171 x0, y0 = _INSET_CORNER_ORIGINS.get(ann.get('corner', '右上'), (0.55, 0.55))
172 size = ann.get('size', 0.4)
173 x_min, x_max = ann.get('zoom_x_range', (0, 1))
174 color = canvas._effective_text_color(ann.get('color', '#000000'))
175 bounds = (x0, y0, size, size)
176 inset_ax = ax.inset_axes(bounds)
177 artists = [inset_ax]
178 # set_xscale は 'linear' でも目盛りを既定に戻すので、対数のときだけ呼ぶ
179 if ax.get_xscale() != 'linear':
180 inset_ax.set_xscale(ax.get_xscale())
181 if ax.get_yscale() != 'linear':
182 inset_ax.set_yscale(ax.get_yscale())
184 parent_secondary = _secondary_axis(canvas, axis_index)
185 secondary_lines = []
186 for target_ds in (datasets or ()):
187 if target_ds.subplot_target != axis_index or not target_ds.visible:
188 continue
189 points = _inset_points(canvas, target_ds, x_min, x_max, full_resolution)
190 if points is None:
191 continue
192 style = dict(color=target_ds.color, linewidth=target_ds.linewidth, alpha=target_ds.alpha)
193 if target_ds.use_secondary_y and parent_secondary is not None:
194 secondary_lines.append((points, style))
195 else:
196 inset_ax.plot(*points, **style)
198 inset_secondary = None
199 if secondary_lines:
200 inset_secondary = _make_inset_secondary(ax, inset_ax, bounds, parent_secondary)
201 artists.append(inset_secondary)
202 for (sx, sy), style in secondary_lines:
203 inset_secondary.plot(sx, sy, **style)
204 # 拡大図の Y 範囲(=親に印す四角)は第1 Y 軸の値なので、親の図で同じ高さになる値に直して入れる
205 inset_ax.update_datalim(np.column_stack([sx, _map_between_y_axes(ax, parent_secondary, ax, sy)]))
206 inset_ax.autoscale_view(scalex=False)
208 inset_ax.set_xlim(x_min, x_max)
209 fit_inset_y_range(ax, inset_ax, ann.get('zoom_y_range'))
210 if ax.xaxis_inverted():
211 inset_ax.xaxis.set_inverted(True)
212 if ax.yaxis_inverted():
213 inset_ax.yaxis.set_inverted(True)
214 inset_ax.tick_params(labelsize=7)
215 if inset_secondary is not None:
216 # 右側の範囲は、拡大図の範囲を親の左右の軸の対応で読み替えたもの(向きもこれで親と同じになる)
217 inset_secondary.set_ylim(*_map_between_y_axes(ax, ax, parent_secondary, inset_ax.get_ylim()))
218 inset_secondary.tick_params(labelsize=7)
220 if 'loc1' in ann or 'loc2' in ann:
221 corners = [(ann.get('loc1', 2), None), (ann.get('loc2', 4), None)]
222 else:
223 pair = inset_connector_corners(ax, inset_ax, (x0, y0, x0 + size, y0 + size))
224 corners = [(loc, _box_corner_label(loc, inset_ax)) for loc in pair]
225 pp, p1, p2 = mark_inset(ax, inset_ax, loc1=corners[0][0], loc2=corners[1][0], fc="none", ec=color)
226 p1.loc2, p2.loc2 = corners[0][1], corners[1][1]
227 return artists + [pp, p1, p2]
230def annotation_render_key(canvas, axis_index, settings, datasets, full_resolution, parent_ax=None):
231 """
232 注釈の描き直しを省くかどうかのキー。描いた結果に影響するものを全部入れる:
233 ダークモード(色が変わる)、統計値ラベルの計算後の文字列、拡大図が描くデータセットの色・線幅・透明度・表示、
234 拡大図があれば親の軸(第2 Y 軸も)の範囲と目盛りの種類(拡大図の範囲・向き・引き出し線の角がこれで決まる)。
235 拡大図のデータそのものは入れない。これを使う update_appearance_only はデータが変わっていない前提の経路
236 (データが変わる操作は cla() する別の経路を通り、そこでは使い回さない)。
237 """
238 annotations = axis_setting(settings, 'annotations')
239 parts = [bool(canvas.dark_mode), bool(full_resolution), len(annotations)]
240 if not annotations:
241 return json.dumps(parts, default=str)
243 if parent_ax is not None and any(ann.get('type') == 'inset' for ann in annotations):
244 parts.append([list(parent_ax.get_xlim()), list(parent_ax.get_ylim()),
245 parent_ax.get_xscale(), parent_ax.get_yscale()])
246 secondary = _secondary_axis(canvas, axis_index)
247 if secondary is not None:
248 parts.append([list(secondary.get_ylim()), secondary.get_yscale()])
249 datasets_by_id = {ds.dataset_id: ds for ds in (datasets or ())}
250 for ann in annotations:
251 parts.append(json.dumps(ann, sort_keys=True, default=str))
252 ann_type = ann.get('type')
253 if ann_type == 'stat':
254 dataset = datasets_by_id.get(ann.get('dataset_id'))
255 parts.append(_compute_stat_label_text(dataset, ann.get('stat')))
256 elif ann_type == 'inset':
257 for target_ds in (datasets or ()):
258 if target_ds.subplot_target != axis_index or not target_ds.visible:
259 continue
260 parts.append((target_ds.dataset_id, target_ds.color,
261 target_ds.linewidth, target_ds.alpha))
262 return json.dumps(parts, default=str)
265def draw_annotations(canvas, ax, axis_index, settings, datasets=None, full_resolution=False,
266 allow_reuse=False):
267 """
268 軸の注釈(テキスト・矢印・領域ハイライト・統計値ラベル・拡大図)を、前回の分を消してから描く。
270 allow_reuse=True なら、内容が前回と同じとき描き直しを省く(拡大図はデータを描き直すので重い)。
271 使ってよいのは update_appearance_only() だけ。ほかの経路は前回の artist が既に消えている
272 (fig.clf() / ax.cla() / 新しい軸)ので、使い回すと注釈が消える。既定は False。
273 datasets は統計値ラベルと拡大図が使う(省略すると「データセットなし」)。
274 """
275 render_key = canvas._annotation_render_key(axis_index, settings, datasets, full_resolution, parent_ax=ax)
276 if allow_reuse and canvas._annotation_render_keys.get(axis_index, _NO_KEY) == render_key:
277 return
279 for artist in canvas._annotation_artists.get(axis_index, []):
280 try:
281 artist.remove()
282 except (ValueError, NotImplementedError):
283 pass
285 datasets_by_id = {ds.dataset_id: ds for ds in (datasets or ())}
287 new_artists = []
288 for ann in axis_setting(settings, 'annotations'):
289 ann_type = ann.get('type')
290 text = ann.get('text', '')
291 try:
292 if ann_type == 'arrow':
293 color = canvas._effective_text_color(ann.get('color', '#000000'))
294 arrowstyle = _ARROW_STYLE_MAP.get(ann.get('arrow_style', 'single'), '->')
295 curvature = ann.get('arrow_curvature', 0.0)
296 arrow_props = dict(arrowstyle=arrowstyle, color=color)
297 if curvature:
298 arrow_props['connectionstyle'] = f'arc3,rad={curvature}'
299 artist = ax.annotate(
300 text, xy=ann['xy'], xytext=ann['xytext'],
301 arrowprops=arrow_props,
302 color=color, fontsize=9, family=families_for_text(text, JP_CAPABLE_FONT_FAMILIES)
303 )
304 elif ann_type in ('vspan', 'hspan'):
305 # 利用者が選んだ色をそのまま使う(ダークモードの読み替えはしない)
306 lo, hi = ann.get('range', (0, 0))
307 color = ann.get('color', REGION_HIGHLIGHT_DEFAULT_COLOR)
308 alpha = ann.get('alpha', REGION_HIGHLIGHT_DEFAULT_ALPHA)
309 if ann_type == 'vspan':
310 artist = ax.axvspan(lo, hi, color=color, alpha=alpha, zorder=0.5)
311 else:
312 artist = ax.axhspan(lo, hi, color=color, alpha=alpha, zorder=0.5)
313 elif ann_type == 'inset':
314 new_artists.extend(draw_inset(canvas, ax, axis_index, ann, datasets, full_resolution))
315 artist = None
316 elif ann_type == 'stat':
317 # 軸に対する位置なので、拡大・移動しても同じ場所に留まる
318 color = canvas._effective_text_color(ann.get('color', '#000000'))
319 xy = ann.get('xy', (0.05, 0.95))
320 dataset = datasets_by_id.get(ann.get('dataset_id'))
321 label_text = _compute_stat_label_text(dataset, ann.get('stat'))
322 artist = ax.text(
323 xy[0], xy[1], label_text, transform=ax.transAxes,
324 color=color, fontsize=9, family=families_for_text(label_text, JP_CAPABLE_FONT_FAMILIES),
325 va='top', ha='left', zorder=10,
326 )
327 else:
328 color = canvas._effective_text_color(ann.get('color', '#000000'))
329 xy = ann.get('xy', (0, 0))
330 artist = ax.text(xy[0], xy[1], text, color=color, fontsize=9,
331 family=families_for_text(text, JP_CAPABLE_FONT_FAMILIES))
332 if artist is not None:
333 new_artists.append(artist)
334 except Exception:
335 logger.exception("注釈の描画に失敗しました: %s", ann)
336 canvas._annotation_artists[axis_index] = new_artists
337 # 使い回さない経路でもキーは更新する(直後の update_appearance_only が無駄に描き直さないように)
338 canvas._annotation_render_keys[axis_index] = render_key