Coverage for graphica/gui/rendering/data_1d.py: 97.0%
215 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"""1 つの軸のデータの描画: 軸ごとの振り分けと、1D のデータセットを決まった手順で描く。"""
2import logging
3import numpy as np
4import pandas as pd
5from graphica.core.analysis import (
6 calculate_gaussian_smooth, calculate_lttb_downsample, calculate_median_smooth, calculate_moving_average_smooth)
7from graphica.core.dataset import legend_text
8from graphica.core.marker_style import scatter_marker_kwargs
9from graphica.gui.plot_type_drawers import BUILTIN_PLOT_TYPE_DRAWERS
10from graphica.gui.rendering.common import (
11 DARK_AXES_FACECOLOR, LIGHT_AXES_FACECOLOR, LTTB_DOWNSAMPLE_TARGET_POINTS, LTTB_DOWNSAMPLE_THRESHOLD,
12 WATERFALL_ZORDER_BASE, WATERFALL_ZORDER_TOP, _apply_nan_policy, _waterfall_depth_scale, _waterfall_layout,
13 waterfall_offset_steps)
14from matplotlib.collections import LineCollection
15from matplotlib.colors import LinearSegmentedColormap, Normalize
16from matplotlib.patches import Polygon
17from scipy.interpolate import CubicSpline
19logger = logging.getLogger(__name__)
22def draw_data(canvas, ax, axis_index, datasets, full_resolution=False):
23 """
24 その軸のデータセットを描く。full_resolution=True なら表示用の間引きをしない(エクスポートの「フル解像度」)。
26 呼び出し側は非表示のデータセットも含めた全件を渡す。ウォーターフォールの段は非表示のものも数に入れて
27 振る(1本隠しても後ろの段が繰り上がらないように)ので、ここで絞る。
28 """
29 on_this_axis = [ds for ds in datasets if ds.subplot_target == axis_index]
30 # 2Dマップは長形式の生の列を持つので、1D の経路(軸の種類の判定・ウォーターフォール・間引きなど)を通さない
31 datasets_2d = [ds for ds in on_this_axis if ds.data_kind == '2d_grid' and getattr(ds, 'visible', True)]
32 datasets_1d_all = [ds for ds in on_this_axis if ds.data_kind != '2d_grid']
33 shown_1d = [ds for ds in datasets_1d_all if getattr(ds, 'visible', True)]
34 # 背景として先に描き、1D のデータが上に重なるようにする
35 canvas._draw_2d_data(ax, axis_index, datasets_2d, full_resolution=full_resolution)
37 # ウォーターフォールの変換は描くたびに登録し直す
38 for ds in on_this_axis:
39 canvas._waterfall_transforms.pop(ds.dataset_id, None)
41 needs_secondary = any(ds.use_secondary_y for ds in shown_1d)
42 is_category_x = canvas._record_x_axis_kind(axis_index, shown_1d)
44 secondary_ax = None
45 if needs_secondary:
46 secondary_ax = ax.twinx()
47 canvas.all_secondary_axes[axis_index] = secondary_ax
49 waterfall = _waterfall_layout(datasets_1d_all)
50 canvas.waterfall_x_shift[axis_index] = _waterfall_x_shift(shown_1d, waterfall, is_category_x)
51 for ds in shown_1d:
52 target_ax = secondary_ax if ds.use_secondary_y else ax
53 if target_ax is None: 53 ↛ 54line 53 didn't jump to line 54 because the condition on line 53 was never true
54 continue
55 canvas._draw_1d_dataset(target_ax, axis_index, ds, waterfall, is_category_x, full_resolution)
58def _waterfall_x_shift(datasets, waterfall, is_category_x):
59 """見えているトレースを X にずらした量の (最小, 最大)。0 を含める(手前のトレースはずらさない)。カテゴリ軸ではずらさない。"""
60 if is_category_x:
61 return (0.0, 0.0)
62 shifts = [waterfall_offset_steps(waterfall.index.get(ds.dataset_id, 0), ds.waterfall_offset_mode) * ds.waterfall_offset_x
63 for ds in datasets if ds.waterfall_enabled]
64 return (min([0.0, *shifts]), max([0.0, *shifts]))
67def record_x_axis_kind(canvas, axis_index, datasets):
68 """X 軸が日時か文字列カテゴリかを記録する(目盛りの付け方に使う)。カテゴリなら True を返す。"""
69 is_date_x = any(pd.api.types.is_datetime64_any_dtype(ds.df[ds.x_col_name]) for ds in datasets)
70 while len(canvas.axis_is_date_x) <= axis_index:
71 canvas.axis_is_date_x.append(False)
72 canvas.axis_is_date_x[axis_index] = is_date_x
74 is_category_x = (not is_date_x) and any(
75 not pd.api.types.is_numeric_dtype(ds.df[ds.x_col_name]) for ds in datasets
76 )
77 while len(canvas.axis_is_category_x) <= axis_index:
78 canvas.axis_is_category_x.append(False)
79 canvas.axis_is_category_x[axis_index] = is_category_x
80 return is_category_x
83def draw_1d_dataset(canvas, target_ax, axis_index, ds, waterfall, is_category_x, full_resolution):
84 plot_x_data, plot_y_data, plot_kwargs, occlusion_zorder = canvas._waterfall_shifted_points(
85 ds, waterfall, is_category_x)
87 # カテゴリ軸は下で文字列にするので対象外。
88 # 'drop' は配列を短くするため、大量データで間引きと併用するとデータカーソルの行の対応がずれうる(既知の制約)。
89 if not is_category_x and ds.nan_policy != 'gap':
90 plot_x_data, plot_y_data = _apply_nan_policy(plot_x_data, plot_y_data, ds.nan_policy)
92 if is_category_x:
93 # matplotlib のカテゴリ軸は全要素が文字列でないと例外になる(数値や NaN が混ざった列)
94 plot_x_data = np.array([v if isinstance(v, str) else str(v) for v in plot_x_data])
96 plot_x_data, plot_y_data, downsample_indices = canvas._downsample_for_display(
97 ds, plot_x_data, plot_y_data, is_category_x, full_resolution)
99 # 平滑化は線で結ぶ種別だけ(ほかの種別では平滑化した線がマーカー・棒・塗りを置き換えてしまう)
100 is_smoothed_artist = False
101 if (ds.smoothing and ds.plot_type in ('Line', 'Line+Scatter')
102 and len(plot_x_data) > 1 and not is_category_x):
103 is_smoothed_artist = canvas._draw_smoothed(target_ax, ds, plot_x_data, plot_y_data, plot_kwargs)
104 else:
105 ds.artist = canvas._draw_plot_type(target_ax, axis_index, ds, plot_x_data, plot_y_data, plot_kwargs)
107 # 手前のトレースが奥を隠すよう、トレースの下に軸の背景色を敷く。面は自分の塗りと重なるので除く
108 if (ds.waterfall_enabled and ds.waterfall_occlusion_enabled
109 and ds.plot_type != 'Area' and len(plot_x_data) > 0):
110 bg_color = DARK_AXES_FACECOLOR if canvas.dark_mode else LIGHT_AXES_FACECOLOR
111 target_ax.fill_between(
112 plot_x_data, plot_y_data, waterfall.baseline,
113 color=bg_color, alpha=1.0, zorder=occlusion_zorder, linewidth=0,
114 )
116 # 平滑化した曲線の点は元の行と対応しないので、クリックで選べないようにする。
117 # データカーソルモードの一括 set_picker からも外すため、_non_pickable_dataset_ids にも入れる
118 if is_smoothed_artist:
119 canvas._non_pickable_dataset_ids.add(ds.dataset_id)
120 else:
121 canvas._non_pickable_dataset_ids.discard(ds.dataset_id)
122 if ds.artist is not None and not is_smoothed_artist:
123 canvas._enable_element_picking(ds.artist)
125 canvas._draw_error_display(target_ax, ds, plot_x_data, plot_y_data, downsample_indices)
127 # 曲線フィットの信頼帯・予測帯。帯の列があるときだけ描ける
128 if ds.fit_band_display and 'y_lower' in ds.df.columns and 'y_upper' in ds.df.columns:
129 band_df = ds.visible_df
130 target_ax.fill_between(
131 band_df[ds.x_col_name], band_df['y_lower'], band_df['y_upper'],
132 color=ds.color, alpha=ds.alpha * 0.15, linewidth=0,
133 )
135 # 点数が上限を超えると描画で GUI が止まるので描かない。
136 # ラベルの値は間引く前の並びなので、点と同じ downsample_indices で揃える
137 if ds.show_point_labels and len(ds.visible_df) <= canvas.point_label_max_points:
138 canvas._draw_point_labels(
139 target_ax, ds, x_data=plot_x_data, y_data=plot_y_data,
140 downsample_indices=downsample_indices,
141 )
144def waterfall_shifted_points(canvas, ds, waterfall, is_category_x):
145 """
146 描く点列 (x, y, plot_kwargs, 背景を敷く zorder)。ウォーターフォールなら段の分だけずらし、
147 逆変換(display_to_data)用に変換を記録する。カテゴリ軸では X はずらせないので Y だけずらす。
148 """
149 if not ds.waterfall_enabled:
150 return ds.x_data, ds.y_data, {}, None
151 w_idx = waterfall.index.get(ds.dataset_id, 0)
152 depth_scale = _waterfall_depth_scale(
153 w_idx, ds.waterfall_depth_shrink_enabled, ds.waterfall_depth_shrink_ratio)
154 canvas._waterfall_transforms[ds.dataset_id] = {
155 'index': w_idx,
156 'offset_x': 0.0 if is_category_x else ds.waterfall_offset_x,
157 'offset_y': ds.waterfall_offset_y,
158 'depth_scale': depth_scale,
159 'mode': ds.waterfall_offset_mode,
160 }
161 steps = waterfall_offset_steps(w_idx, ds.waterfall_offset_mode)
162 plot_x_data = ds.x_data if is_category_x else ds.x_data + steps * ds.waterfall_offset_x
163 plot_y_data = ds.y_data * depth_scale + steps * ds.waterfall_offset_y
164 # 手前(段が小さい)ほど上に重ねる。段の数によらず枠線・目盛(zorder 2.01〜2.5)より下に収める
165 step = (WATERFALL_ZORDER_TOP - WATERFALL_ZORDER_BASE) / (waterfall.count + 1)
166 zorder = WATERFALL_ZORDER_BASE + (waterfall.count - w_idx) * step
167 return plot_x_data, plot_y_data, {'zorder': zorder}, zorder - step / 2
170def downsample_for_display(canvas, ds, plot_x_data, plot_y_data, is_category_x, full_resolution):
171 """
172 点が多い 'Line' だけ LTTB で間引く(散布図などは点の疎密自体が情報)。X が昇順でないと
173 LTTB が形を変えてしまうので、そのときは間引かない。戻り値の3つ目は間引きに使った添字(無ければ None)。
174 """
175 if not (ds.plot_type == 'Line' and not full_resolution and not is_category_x
176 and len(plot_x_data) > LTTB_DOWNSAMPLE_THRESHOLD
177 and np.all(np.diff(plot_x_data) >= 0)):
178 return plot_x_data, plot_y_data, None
179 indices = calculate_lttb_downsample(plot_x_data, plot_y_data, LTTB_DOWNSAMPLE_TARGET_POINTS)
180 if len(indices) >= len(plot_x_data): 180 ↛ 181line 180 didn't jump to line 181 because the condition on line 180 was never true
181 return plot_x_data, plot_y_data, None
182 # データカーソルが、間引いた後の添字を visible_df の行に戻すのに使う
183 canvas.downsample_index_map[ds.dataset_id] = indices
184 return plot_x_data[indices], plot_y_data[indices], indices
187def draw_smoothed(canvas, target_ax, ds, plot_x_data, plot_y_data, plot_kwargs):
188 """平滑化した曲線を描く。平滑化できなければ元の点のまま線で結び、False を返す。"""
189 sort_indices = np.argsort(plot_x_data)
190 x_sorted = plot_x_data[sort_indices]
191 y_sorted = plot_y_data[sort_indices]
192 use_line_gradient = ds.gradient_enabled and ds.gradient_target in ('line', 'both')
193 smoothing_method = getattr(ds, 'smoothing_method', 'cubic_spline')
194 try:
195 # cubic_spline は200点に補間して滑らかにする。ほかはノイズを減らすのが目的なので点数はそのまま
196 if smoothing_method == 'moving_average':
197 x_smooth, y_smooth = calculate_moving_average_smooth(x_sorted, y_sorted)
198 elif smoothing_method == 'median':
199 x_smooth, y_smooth = calculate_median_smooth(x_sorted, y_sorted)
200 elif smoothing_method == 'gaussian':
201 x_smooth, y_smooth = calculate_gaussian_smooth(x_sorted, y_sorted)
202 else:
203 f = CubicSpline(x_sorted, y_sorted)
204 x_smooth = np.linspace(x_sorted.min(), x_sorted.max(), 200)
205 y_smooth = f(x_smooth)
206 if use_line_gradient:
207 ds.artist = canvas._add_gradient_line(
208 target_ax, x_smooth, y_smooth, ds.color, ds.gradient_color2,
209 ds.linewidth, ds.alpha, ds.linestyle, label=legend_text(ds)
210 )
211 else:
212 (artist_line,) = target_ax.plot(x_smooth, y_smooth, color=ds.color, linestyle=ds.linestyle, linewidth=ds.linewidth, alpha=ds.alpha, label=legend_text(ds), **plot_kwargs)
213 ds.artist = artist_line
214 if ds.plot_type == 'Line+Scatter':
215 target_ax.scatter(plot_x_data, plot_y_data, marker=ds.marker, s=ds.markersize**2, alpha=ds.alpha,
216 **{'color': ds.color, **scatter_marker_kwargs(ds)}, **plot_kwargs)
217 return True
218 except ValueError:
219 if use_line_gradient:
220 ds.artist = canvas._add_gradient_line(
221 target_ax, plot_x_data, plot_y_data, ds.color, ds.gradient_color2,
222 ds.linewidth, ds.alpha, ds.linestyle, label=legend_text(ds)
223 )
224 else:
225 (artist,) = target_ax.plot(plot_x_data, plot_y_data, color=ds.color, linestyle=ds.linestyle, linewidth=ds.linewidth, alpha=ds.alpha, label=legend_text(ds), **plot_kwargs)
226 ds.artist = artist
227 return False
230def draw_plot_type(canvas, target_ax, axis_index, ds, plot_x_data, plot_y_data, plot_kwargs):
231 """
232 plot_type の描画関数で描き、ds.artist にするものを返す。組み込みに無ければプラグインの種類を探し、
233 それも無ければ線で描く。プラグインの描画にはウォーターフォールの zorder などは渡らない(既知の制限)。
234 """
235 drawer = BUILTIN_PLOT_TYPE_DRAWERS.get(ds.plot_type)
236 if drawer is not None:
237 return drawer(canvas, target_ax, ds, plot_x_data, plot_y_data, plot_kwargs, axis_index)
239 from graphica.core.plugin_api import get_plugin_api
240 api = get_plugin_api()
241 plugin_plot_type = api.get_plot_type(ds.plot_type) if api is not None else None
242 if plugin_plot_type is None:
243 logger.warning("未知のplot_type '%s' です。Lineとして描画します。", ds.plot_type)
244 (artist,) = target_ax.plot(plot_x_data, plot_y_data, color=ds.color, linestyle=ds.linestyle, linewidth=ds.linewidth, alpha=ds.alpha, label=legend_text(ds), **plot_kwargs)
245 return artist
246 try:
247 artist = plugin_plot_type.drawer(ds, target_ax, plot_x_data, plot_y_data)
248 except Exception as e:
249 logger.warning(
250 "[plugin:%s] plot_type '%s' の描画に失敗しました: %s",
251 plugin_plot_type.plugin_name, ds.plot_type, e,
252 )
253 return ds.artist
254 return artist if artist is not None else ds.artist
257def draw_error_display(canvas, target_ax, ds, plot_x_data, plot_y_data, downsample_indices):
258 """誤差棒と誤差の帯を、描いた点(ずらし・間引きの後)の位置に重ねる。帯は Y の誤差だけ。"""
259 if not (ds.x_err_col_name or ds.y_err_col_name):
260 return
261 # 誤差列は間引く前の長さなので、点と同じ添字で揃える(揃えないと長さ違いで例外)
262 x_err = ds.x_err_data
263 y_err_full = ds.y_err_data
264 if downsample_indices is not None:
265 if x_err is not None: 265 ↛ 266line 265 didn't jump to line 266 because the condition on line 265 was never true
266 x_err = x_err[downsample_indices]
267 if y_err_full is not None: 267 ↛ 270line 267 didn't jump to line 270 because the condition on line 267 was always true
268 y_err_full = y_err_full[downsample_indices]
270 if ds.error_display in ('bar', 'both'):
271 target_ax.errorbar(
272 plot_x_data, plot_y_data,
273 xerr=x_err, yerr=y_err_full,
274 fmt='none', ecolor=ds.color, elinewidth=ds.linewidth, alpha=ds.alpha, capsize=3
275 )
276 if ds.error_display in ('band', 'both') and y_err_full is not None:
277 y_arr = np.asarray(plot_y_data)
278 y_err = np.asarray(y_err_full)
279 target_ax.fill_between(
280 plot_x_data, y_arr - y_err, y_arr + y_err,
281 color=ds.color, alpha=ds.alpha * 0.25, linewidth=0,
282 )
285def draw_point_labels(canvas, ax, ds, x_data=None, y_data=None, downsample_indices=None):
286 """
287 各点の脇に Y 値(point_label_col_name があればその列の値)を書く。
288 x_data/y_data を渡せばその位置に書く(ウォーターフォールでずらした位置)。
289 downsample_indices は点の間引きに使った添字で、ラベルの値も同じく間引く
290 (揃えないと zip が短い方で切れ、点と無関係な行のラベルが付く)。
291 """
292 if x_data is None:
293 x_data = ds.x_data
294 if y_data is None:
295 y_data = ds.y_data
297 if ds.point_label_col_name and ds.point_label_col_name in ds.df.columns:
298 # 点と同じ行になるよう visible_df から取る
299 label_values = ds.visible_df[ds.point_label_col_name].values
300 else:
301 label_values = ds.y_data
302 if downsample_indices is not None:
303 label_values = label_values[downsample_indices]
305 for x, y, label_value in zip(x_data, y_data, label_values):
306 if pd.isna(x) or pd.isna(y):
307 continue
308 if isinstance(label_value, (int, float, np.floating, np.integer)) and not isinstance(label_value, bool):
309 text = f"{label_value:.4g}" if not pd.isna(label_value) else ""
310 else:
311 text = "" if pd.isna(label_value) else str(label_value)
312 if not text:
313 continue
314 ax.annotate(
315 text, (x, y), textcoords="offset points", xytext=(5, 5),
316 fontsize=8, color=ds.color, alpha=ds.alpha,
317 # Text の既定は clip_on=False で、範囲外の点のラベルだけが枠の外に残り SVG/PDF に出てしまう
318 clip_on=True,
319 )
322def enable_element_picking(canvas, artist):
323 """クリックで選べるようにする。棒グラフは Rectangle の集まりなので、1本ずつ設定する。"""
324 try:
325 if hasattr(artist, 'patches'): # BarContainer
326 for patch in artist.patches:
327 patch.set_picker(5)
328 else:
329 artist.set_picker(5)
330 except AttributeError:
331 pass
334def add_gradient_line(canvas, ax, x, y, color1, color2, linewidth, alpha, linestyle, label=None):
335 """
336 線を区間に分けて、始点の色から終点の色へ変わる LineCollection として描く。
337 add_collection は軸の表示範囲を広げないことがあるので、データ範囲を明示的に足しておく。
338 """
339 x = np.asarray(x, dtype=float)
340 y = np.asarray(y, dtype=float)
342 if len(x) < 2:
343 # 区間が作れないので普通の線にする
344 (line,) = ax.plot(x, y, color=color1, linestyle=linestyle, linewidth=linewidth, alpha=alpha, label=label)
345 return line
347 points = np.array([x, y]).T.reshape(-1, 1, 2)
348 segments = np.concatenate([points[:-1], points[1:]], axis=1)
350 cmap = LinearSegmentedColormap.from_list('graphica_line_gradient', [color1, color2])
351 lc = LineCollection(
352 segments, cmap=cmap, norm=Normalize(0, 1),
353 linewidths=linewidth, linestyles=linestyle, alpha=alpha, label=label,
354 zorder=2,
355 )
356 # 区間ごとに始点からの位置(0〜1)を割り当てる
357 lc.set_array(np.linspace(0, 1, len(segments)))
358 ax.add_collection(lc)
359 ax.update_datalim(np.column_stack([x, y]))
360 return lc
363def add_gradient_fill(canvas, ax, x, y, color1, color2, alpha, baseline=0.0):
364 """fill_between と同じ形の多角形で、imshow のグラデーション画像を切り抜いて塗る。"""
365 x = np.asarray(x, dtype=float)
366 y = np.asarray(y, dtype=float)
368 # origin='lower' で先頭行が下端になるので、上端が color1 になるよう色を逆に並べる
369 gradient = np.linspace(0, 1, 256).reshape(-1, 1)
370 cmap = LinearSegmentedColormap.from_list('graphica_fill_gradient', [color2, color1])
372 x_min, x_max = float(np.nanmin(x)), float(np.nanmax(x))
373 y_min = float(min(np.nanmin(y), baseline))
374 y_max = float(max(np.nanmax(y), baseline))
375 # 全点が同じ座標だと extent が潰れるので幅を持たせる
376 if x_min == x_max:
377 x_min, x_max = x_min - 0.5, x_max + 0.5
378 if y_min == y_max:
379 y_min, y_max = y_min - 0.5, y_max + 0.5
381 im = ax.imshow(
382 gradient, cmap=cmap, aspect='auto', origin='lower',
383 extent=(x_min, x_max, y_min, y_max), alpha=alpha, zorder=1,
384 )
386 verts = list(zip(x, y)) + [(x[-1], baseline), (x[0], baseline)]
387 clip_poly = Polygon(verts, closed=True, transform=ax.transData)
388 im.set_clip_path(clip_poly)
390 # imshow は軸の範囲を広げないので、明示的に足す
391 ax.update_datalim(np.array([[x_min, y_min], [x_max, y_max]]))
392 return im