graphica-plot 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- graphica/Graphica.ico +0 -0
- graphica/__init__.py +1 -0
- graphica/__main__.py +111 -0
- graphica/assets/__init__.py +0 -0
- graphica/assets/icons/__init__.py +0 -0
- graphica/assets/icons/arrow-right.svg +21 -0
- graphica/assets/icons/bold.svg +20 -0
- graphica/assets/icons/calculator.svg +26 -0
- graphica/assets/icons/chart-histogram.svg +24 -0
- graphica/assets/icons/chart-line.svg +20 -0
- graphica/assets/icons/chevron-down.svg +19 -0
- graphica/assets/icons/chevron-right.svg +19 -0
- graphica/assets/icons/color-swatch.svg +22 -0
- graphica/assets/icons/column-insert-right.svg +21 -0
- graphica/assets/icons/column-remove.svg +21 -0
- graphica/assets/icons/copy.svg +20 -0
- graphica/assets/icons/download.svg +21 -0
- graphica/assets/icons/edit.svg +21 -0
- graphica/assets/icons/eye-off.svg +21 -0
- graphica/assets/icons/eye.svg +20 -0
- graphica/assets/icons/file-plus.svg +22 -0
- graphica/assets/icons/folder-plus.svg +21 -0
- graphica/assets/icons/folder.svg +19 -0
- graphica/assets/icons/highlight.svg +22 -0
- graphica/assets/icons/history.svg +20 -0
- graphica/assets/icons/italic.svg +21 -0
- graphica/assets/icons/layout-grid.svg +22 -0
- graphica/assets/icons/math-function.svg +22 -0
- graphica/assets/icons/message-2.svg +21 -0
- graphica/assets/icons/mountain.svg +20 -0
- graphica/assets/icons/palette.svg +22 -0
- graphica/assets/icons/pointer.svg +19 -0
- graphica/assets/icons/refresh.svg +20 -0
- graphica/assets/icons/row-insert-bottom.svg +21 -0
- graphica/assets/icons/row-remove.svg +21 -0
- graphica/assets/icons/search.svg +20 -0
- graphica/assets/icons/select-all.svg +35 -0
- graphica/assets/icons/subscript.svg +20 -0
- graphica/assets/icons/superscript.svg +20 -0
- graphica/assets/icons/table.svg +21 -0
- graphica/assets/icons/trash.svg +23 -0
- graphica/assets/icons/typography.svg +23 -0
- graphica/assets/icons/x.svg +20 -0
- graphica/core/__init__.py +0 -0
- graphica/core/analysis.py +1278 -0
- graphica/core/app_paths.py +35 -0
- graphica/core/axis_settings.py +128 -0
- graphica/core/caption_export.py +47 -0
- graphica/core/color_palettes.py +56 -0
- graphica/core/commands.py +226 -0
- graphica/core/cvd_simulation.py +49 -0
- graphica/core/dataset.py +443 -0
- graphica/core/diagnostics.py +105 -0
- graphica/core/excel_utils.py +58 -0
- graphica/core/fit_models.py +379 -0
- graphica/core/grid_data.py +128 -0
- graphica/core/i18n.py +44 -0
- graphica/core/json_utils.py +27 -0
- graphica/core/label_utils.py +21 -0
- graphica/core/methods_text.py +115 -0
- graphica/core/named_colors.py +126 -0
- graphica/core/plugin_api.py +515 -0
- graphica/core/plugin_context.py +167 -0
- graphica/core/plugin_install.py +82 -0
- graphica/core/plugin_manifest.py +67 -0
- graphica/core/plugin_testing.py +213 -0
- graphica/core/plugin_types.py +118 -0
- graphica/core/provenance.py +19 -0
- graphica/core/report_export.py +51 -0
- graphica/core/safe_eval.py +194 -0
- graphica/core/script_export.py +272 -0
- graphica/core/translations_en.py +530 -0
- graphica/core/unit_conversion.py +51 -0
- graphica/core/update_check.py +43 -0
- graphica/core/version.py +14 -0
- graphica/gui/__init__.py +0 -0
- graphica/gui/app_settings.py +119 -0
- graphica/gui/axis_bindings.py +173 -0
- graphica/gui/binding.py +135 -0
- graphica/gui/builders/__init__.py +1 -0
- graphica/gui/builders/axis_panel.py +431 -0
- graphica/gui/builders/canvas_area.py +176 -0
- graphica/gui/builders/common.py +120 -0
- graphica/gui/builders/dataset_panel.py +433 -0
- graphica/gui/builders/property_sections.py +208 -0
- graphica/gui/canvas.py +519 -0
- graphica/gui/color_history.py +47 -0
- graphica/gui/color_picker_widget.py +198 -0
- graphica/gui/crash_handler.py +77 -0
- graphica/gui/cvd_preview.py +23 -0
- graphica/gui/data_editor.py +761 -0
- graphica/gui/data_import_flow.py +355 -0
- graphica/gui/dataset_bindings.py +107 -0
- graphica/gui/dataset_style_icon.py +80 -0
- graphica/gui/datasets/__init__.py +1 -0
- graphica/gui/datasets/actions_menu.py +114 -0
- graphica/gui/datasets/colors.py +218 -0
- graphica/gui/datasets/fitting.py +70 -0
- graphica/gui/datasets/host.py +162 -0
- graphica/gui/datasets/operations/__init__.py +1 -0
- graphica/gui/datasets/operations/fitting.py +468 -0
- graphica/gui/datasets/operations/peaks.py +109 -0
- graphica/gui/datasets/operations/processing.py +659 -0
- graphica/gui/datasets/operations/runner.py +148 -0
- graphica/gui/datasets/operations/transfer.py +227 -0
- graphica/gui/datasets/order.py +310 -0
- graphica/gui/datasets/overlays.py +74 -0
- graphica/gui/datasets/peaks.py +21 -0
- graphica/gui/datasets/plugin_runs.py +99 -0
- graphica/gui/datasets/processing.py +68 -0
- graphica/gui/datasets/property_panel.py +538 -0
- graphica/gui/datasets/transfer.py +36 -0
- graphica/gui/detached_canvas_window.py +22 -0
- graphica/gui/dialogs/__init__.py +137 -0
- graphica/gui/dialogs/analysis.py +1322 -0
- graphica/gui/dialogs/app.py +1015 -0
- graphica/gui/dialogs/appearance.py +851 -0
- graphica/gui/dialogs/data_edit.py +577 -0
- graphica/gui/dialogs/data_import.py +679 -0
- graphica/gui/dialogs/export.py +433 -0
- graphica/gui/dock_layout.py +176 -0
- graphica/gui/export_preview_panel.py +321 -0
- graphica/gui/export_settings.py +16 -0
- graphica/gui/file_association.py +97 -0
- graphica/gui/icon_utils.py +44 -0
- graphica/gui/main_app_window.py +210 -0
- graphica/gui/main_window.py +1151 -0
- graphica/gui/mathtext_preview.py +168 -0
- graphica/gui/menu_bar.py +320 -0
- graphica/gui/minimap_widget.py +151 -0
- graphica/gui/mixins/__init__.py +0 -0
- graphica/gui/mixins/export_mixin.py +520 -0
- graphica/gui/mixins/help_mixin.py +127 -0
- graphica/gui/mixins/project_io_mixin.py +289 -0
- graphica/gui/mixins/quick_access_mixin.py +194 -0
- graphica/gui/mixins/ui_setup_mixin.py +206 -0
- graphica/gui/notify.py +61 -0
- graphica/gui/panels/__init__.py +43 -0
- graphica/gui/panels/axis_settings.py +605 -0
- graphica/gui/panels/dataset_tree.py +424 -0
- graphica/gui/plot_type_drawers.py +131 -0
- graphica/gui/plugin_context.py +119 -0
- graphica/gui/project_files.py +361 -0
- graphica/gui/provenance_panel.py +65 -0
- graphica/gui/rendering/__init__.py +1 -0
- graphica/gui/rendering/annotations.py +177 -0
- graphica/gui/rendering/appearance.py +368 -0
- graphica/gui/rendering/common.py +316 -0
- graphica/gui/rendering/data_1d.py +376 -0
- graphica/gui/rendering/data_2d.py +66 -0
- graphica/gui/residual_panel.py +62 -0
- graphica/gui/resources.py +16 -0
- graphica/gui/single_instance.py +141 -0
- graphica/gui/splash.py +83 -0
- graphica/gui/task_runner.py +41 -0
- graphica/gui/theme.py +864 -0
- graphica/gui/tools/__init__.py +56 -0
- graphica/gui/tools/annotation.py +275 -0
- graphica/gui/tools/cursor.py +259 -0
- graphica/gui/tools/layout_edit.py +285 -0
- graphica/gui/tools/manager.py +78 -0
- graphica/gui/tools/peak_placement.py +132 -0
- graphica/gui/tools/range_select.py +191 -0
- graphica/gui/tools/region_highlight.py +222 -0
- graphica/gui/tools/slice_extraction.py +200 -0
- graphica/gui/widget_translation.py +46 -0
- graphica/gui/workers.py +280 -0
- graphica/models/__init__.py +0 -0
- graphica/models/project.py +197 -0
- graphica/plugin/__init__.py +20 -0
- graphica/plugin/testing.py +57 -0
- graphica/sample_data/__init__.py +0 -0
- graphica/sample_data/cooling_curve_sample.csv +42 -0
- graphica/ui_main_window.py +581 -0
- graphica_plot-2.0.0.dist-info/METADATA +317 -0
- graphica_plot-2.0.0.dist-info/RECORD +181 -0
- graphica_plot-2.0.0.dist-info/WHEEL +5 -0
- graphica_plot-2.0.0.dist-info/entry_points.txt +2 -0
- graphica_plot-2.0.0.dist-info/licenses/LICENSE +30 -0
- graphica_plot-2.0.0.dist-info/licenses/THIRD_PARTY_LICENSES.md +91 -0
- graphica_plot-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""1 つの軸のデータの描画: 軸ごとの振り分けと、1D のデータセットを決まった手順で描く。"""
|
|
2
|
+
import logging
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pandas as pd
|
|
5
|
+
from graphica.core.analysis import (
|
|
6
|
+
calculate_gaussian_smooth, calculate_lttb_downsample, calculate_median_smooth, calculate_moving_average_smooth)
|
|
7
|
+
from graphica.gui.plot_type_drawers import BUILTIN_PLOT_TYPE_DRAWERS
|
|
8
|
+
from graphica.gui.rendering.common import (
|
|
9
|
+
DARK_AXES_FACECOLOR, LIGHT_AXES_FACECOLOR, LTTB_DOWNSAMPLE_TARGET_POINTS, LTTB_DOWNSAMPLE_THRESHOLD,
|
|
10
|
+
WATERFALL_ZORDER_BASE, WATERFALL_ZORDER_TOP, _apply_nan_policy, _waterfall_depth_scale, _waterfall_layout)
|
|
11
|
+
from matplotlib.collections import LineCollection
|
|
12
|
+
from matplotlib.colors import LinearSegmentedColormap, Normalize
|
|
13
|
+
from matplotlib.patches import Polygon
|
|
14
|
+
from scipy.interpolate import CubicSpline
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def draw_data(canvas, ax, axis_index, datasets, full_resolution=False):
|
|
20
|
+
"""
|
|
21
|
+
その軸のデータセットを描く。full_resolution=True なら表示用の間引きをしない(エクスポートの「フル解像度」)。
|
|
22
|
+
|
|
23
|
+
呼び出し側は非表示のデータセットも含めた全件を渡す。ウォーターフォールの段は非表示のものも数に入れて
|
|
24
|
+
振る(1本隠しても後ろの段が繰り上がらないように)ので、ここで絞る。
|
|
25
|
+
"""
|
|
26
|
+
on_this_axis = [ds for ds in datasets if ds.subplot_target == axis_index]
|
|
27
|
+
# 2Dマップは長形式の生の列を持つので、1D の経路(軸の種類の判定・ウォーターフォール・間引きなど)を通さない
|
|
28
|
+
datasets_2d = [ds for ds in on_this_axis if ds.data_kind == '2d_grid' and getattr(ds, 'visible', True)]
|
|
29
|
+
datasets_1d_all = [ds for ds in on_this_axis if ds.data_kind != '2d_grid']
|
|
30
|
+
shown_1d = [ds for ds in datasets_1d_all if getattr(ds, 'visible', True)]
|
|
31
|
+
# 背景として先に描き、1D のデータが上に重なるようにする
|
|
32
|
+
canvas._draw_2d_data(ax, axis_index, datasets_2d, full_resolution=full_resolution)
|
|
33
|
+
|
|
34
|
+
# ウォーターフォールの変換は描くたびに登録し直す
|
|
35
|
+
for ds in on_this_axis:
|
|
36
|
+
canvas._waterfall_transforms.pop(ds.dataset_id, None)
|
|
37
|
+
|
|
38
|
+
needs_secondary = any(ds.use_secondary_y for ds in shown_1d)
|
|
39
|
+
is_category_x = canvas._record_x_axis_kind(axis_index, shown_1d)
|
|
40
|
+
|
|
41
|
+
secondary_ax = None
|
|
42
|
+
if needs_secondary:
|
|
43
|
+
secondary_ax = ax.twinx()
|
|
44
|
+
canvas.all_secondary_axes[axis_index] = secondary_ax
|
|
45
|
+
|
|
46
|
+
waterfall = _waterfall_layout(datasets_1d_all)
|
|
47
|
+
for ds in shown_1d:
|
|
48
|
+
target_ax = secondary_ax if ds.use_secondary_y else ax
|
|
49
|
+
if target_ax is None:
|
|
50
|
+
continue
|
|
51
|
+
canvas._draw_1d_dataset(target_ax, axis_index, ds, waterfall, is_category_x, full_resolution)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def record_x_axis_kind(canvas, axis_index, datasets):
|
|
55
|
+
"""X 軸が日時か文字列カテゴリかを記録する(目盛りの付け方に使う)。カテゴリなら True を返す。"""
|
|
56
|
+
is_date_x = any(pd.api.types.is_datetime64_any_dtype(ds.df[ds.x_col_name]) for ds in datasets)
|
|
57
|
+
while len(canvas.axis_is_date_x) <= axis_index:
|
|
58
|
+
canvas.axis_is_date_x.append(False)
|
|
59
|
+
canvas.axis_is_date_x[axis_index] = is_date_x
|
|
60
|
+
|
|
61
|
+
is_category_x = (not is_date_x) and any(
|
|
62
|
+
not pd.api.types.is_numeric_dtype(ds.df[ds.x_col_name]) for ds in datasets
|
|
63
|
+
)
|
|
64
|
+
while len(canvas.axis_is_category_x) <= axis_index:
|
|
65
|
+
canvas.axis_is_category_x.append(False)
|
|
66
|
+
canvas.axis_is_category_x[axis_index] = is_category_x
|
|
67
|
+
return is_category_x
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def draw_1d_dataset(canvas, target_ax, axis_index, ds, waterfall, is_category_x, full_resolution):
|
|
71
|
+
plot_x_data, plot_y_data, plot_kwargs, occlusion_zorder = canvas._waterfall_shifted_points(
|
|
72
|
+
ds, waterfall, is_category_x)
|
|
73
|
+
|
|
74
|
+
# カテゴリ軸は下で文字列にするので対象外。
|
|
75
|
+
# 'drop' は配列を短くするため、大量データで間引きと併用するとデータカーソルの行の対応がずれうる(既知の制約)。
|
|
76
|
+
if not is_category_x and ds.nan_policy != 'gap':
|
|
77
|
+
plot_x_data, plot_y_data = _apply_nan_policy(plot_x_data, plot_y_data, ds.nan_policy)
|
|
78
|
+
|
|
79
|
+
if is_category_x:
|
|
80
|
+
# matplotlib のカテゴリ軸は全要素が文字列でないと例外になる(数値や NaN が混ざった列)
|
|
81
|
+
plot_x_data = np.array([v if isinstance(v, str) else str(v) for v in plot_x_data])
|
|
82
|
+
|
|
83
|
+
plot_x_data, plot_y_data, downsample_indices = canvas._downsample_for_display(
|
|
84
|
+
ds, plot_x_data, plot_y_data, is_category_x, full_resolution)
|
|
85
|
+
|
|
86
|
+
# 平滑化は線で結ぶ種別だけ(ほかの種別では平滑化した線がマーカー・棒・塗りを置き換えてしまう)
|
|
87
|
+
is_smoothed_artist = False
|
|
88
|
+
if (ds.smoothing and ds.plot_type in ('Line', 'Line+Scatter')
|
|
89
|
+
and len(plot_x_data) > 1 and not is_category_x):
|
|
90
|
+
is_smoothed_artist = canvas._draw_smoothed(target_ax, ds, plot_x_data, plot_y_data, plot_kwargs)
|
|
91
|
+
else:
|
|
92
|
+
ds.artist = canvas._draw_plot_type(target_ax, axis_index, ds, plot_x_data, plot_y_data, plot_kwargs)
|
|
93
|
+
|
|
94
|
+
# 手前のトレースが奥を隠すよう、トレースの下に軸の背景色を敷く。面は自分の塗りと重なるので除く
|
|
95
|
+
if (ds.waterfall_enabled and ds.waterfall_occlusion_enabled
|
|
96
|
+
and ds.plot_type != 'Area' and len(plot_x_data) > 0):
|
|
97
|
+
bg_color = DARK_AXES_FACECOLOR if canvas.dark_mode else LIGHT_AXES_FACECOLOR
|
|
98
|
+
target_ax.fill_between(
|
|
99
|
+
plot_x_data, plot_y_data, waterfall.baseline,
|
|
100
|
+
color=bg_color, alpha=1.0, zorder=occlusion_zorder, linewidth=0,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# 平滑化した曲線の点は元の行と対応しないので、クリックで選べないようにする。
|
|
104
|
+
# データカーソルモードの一括 set_picker からも外すため、_non_pickable_dataset_ids にも入れる
|
|
105
|
+
if is_smoothed_artist:
|
|
106
|
+
canvas._non_pickable_dataset_ids.add(ds.dataset_id)
|
|
107
|
+
else:
|
|
108
|
+
canvas._non_pickable_dataset_ids.discard(ds.dataset_id)
|
|
109
|
+
if ds.artist is not None and not is_smoothed_artist:
|
|
110
|
+
canvas._enable_element_picking(ds.artist)
|
|
111
|
+
|
|
112
|
+
canvas._draw_error_display(target_ax, ds, plot_x_data, plot_y_data, downsample_indices)
|
|
113
|
+
|
|
114
|
+
# 曲線フィットの信頼帯・予測帯。帯の列があるときだけ描ける
|
|
115
|
+
if ds.fit_band_display and 'y_lower' in ds.df.columns and 'y_upper' in ds.df.columns:
|
|
116
|
+
band_df = ds.visible_df
|
|
117
|
+
target_ax.fill_between(
|
|
118
|
+
band_df[ds.x_col_name], band_df['y_lower'], band_df['y_upper'],
|
|
119
|
+
color=ds.color, alpha=ds.alpha * 0.15, linewidth=0,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# 点数が上限を超えると描画で GUI が止まるので描かない。
|
|
123
|
+
# ラベルの値は間引く前の並びなので、点と同じ downsample_indices で揃える
|
|
124
|
+
if ds.show_point_labels and len(ds.visible_df) <= canvas.point_label_max_points:
|
|
125
|
+
canvas._draw_point_labels(
|
|
126
|
+
target_ax, ds, x_data=plot_x_data, y_data=plot_y_data,
|
|
127
|
+
downsample_indices=downsample_indices,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def waterfall_shifted_points(canvas, ds, waterfall, is_category_x):
|
|
132
|
+
"""
|
|
133
|
+
描く点列 (x, y, plot_kwargs, 背景を敷く zorder)。ウォーターフォールなら段の分だけずらし、
|
|
134
|
+
逆変換(display_to_data)用に変換を記録する。カテゴリ軸では X はずらせないので Y だけずらす。
|
|
135
|
+
"""
|
|
136
|
+
if not ds.waterfall_enabled:
|
|
137
|
+
return ds.x_data, ds.y_data, {}, None
|
|
138
|
+
w_idx = waterfall.index.get(ds.dataset_id, 0)
|
|
139
|
+
depth_scale = _waterfall_depth_scale(
|
|
140
|
+
w_idx, ds.waterfall_depth_shrink_enabled, ds.waterfall_depth_shrink_ratio)
|
|
141
|
+
canvas._waterfall_transforms[ds.dataset_id] = {
|
|
142
|
+
'index': w_idx,
|
|
143
|
+
'offset_x': 0.0 if is_category_x else ds.waterfall_offset_x,
|
|
144
|
+
'offset_y': ds.waterfall_offset_y,
|
|
145
|
+
'depth_scale': depth_scale,
|
|
146
|
+
}
|
|
147
|
+
plot_x_data = ds.x_data if is_category_x else ds.x_data + w_idx * ds.waterfall_offset_x
|
|
148
|
+
plot_y_data = ds.y_data * depth_scale + w_idx * ds.waterfall_offset_y
|
|
149
|
+
# 手前(段が小さい)ほど上に重ねる。段の数によらず枠線・目盛(zorder 2.01〜2.5)より下に収める
|
|
150
|
+
step = (WATERFALL_ZORDER_TOP - WATERFALL_ZORDER_BASE) / (waterfall.count + 1)
|
|
151
|
+
zorder = WATERFALL_ZORDER_BASE + (waterfall.count - w_idx) * step
|
|
152
|
+
return plot_x_data, plot_y_data, {'zorder': zorder}, zorder - step / 2
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def downsample_for_display(canvas, ds, plot_x_data, plot_y_data, is_category_x, full_resolution):
|
|
156
|
+
"""
|
|
157
|
+
点が多い 'Line' だけ LTTB で間引く(散布図などは点の疎密自体が情報)。X が昇順でないと
|
|
158
|
+
LTTB が形を変えてしまうので、そのときは間引かない。戻り値の3つ目は間引きに使った添字(無ければ None)。
|
|
159
|
+
"""
|
|
160
|
+
if not (ds.plot_type == 'Line' and not full_resolution and not is_category_x
|
|
161
|
+
and len(plot_x_data) > LTTB_DOWNSAMPLE_THRESHOLD
|
|
162
|
+
and np.all(np.diff(plot_x_data) >= 0)):
|
|
163
|
+
return plot_x_data, plot_y_data, None
|
|
164
|
+
indices = calculate_lttb_downsample(plot_x_data, plot_y_data, LTTB_DOWNSAMPLE_TARGET_POINTS)
|
|
165
|
+
if len(indices) >= len(plot_x_data):
|
|
166
|
+
return plot_x_data, plot_y_data, None
|
|
167
|
+
# データカーソルが、間引いた後の添字を visible_df の行に戻すのに使う
|
|
168
|
+
canvas.downsample_index_map[ds.dataset_id] = indices
|
|
169
|
+
return plot_x_data[indices], plot_y_data[indices], indices
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def draw_smoothed(canvas, target_ax, ds, plot_x_data, plot_y_data, plot_kwargs):
|
|
173
|
+
"""平滑化した曲線を描く。平滑化できなければ元の点のまま線で結び、False を返す。"""
|
|
174
|
+
sort_indices = np.argsort(plot_x_data)
|
|
175
|
+
x_sorted = plot_x_data[sort_indices]
|
|
176
|
+
y_sorted = plot_y_data[sort_indices]
|
|
177
|
+
use_line_gradient = ds.gradient_enabled and ds.gradient_target in ('line', 'both')
|
|
178
|
+
smoothing_method = getattr(ds, 'smoothing_method', 'cubic_spline')
|
|
179
|
+
try:
|
|
180
|
+
# cubic_spline は200点に補間して滑らかにする。ほかはノイズを減らすのが目的なので点数はそのまま
|
|
181
|
+
if smoothing_method == 'moving_average':
|
|
182
|
+
x_smooth, y_smooth = calculate_moving_average_smooth(x_sorted, y_sorted)
|
|
183
|
+
elif smoothing_method == 'median':
|
|
184
|
+
x_smooth, y_smooth = calculate_median_smooth(x_sorted, y_sorted)
|
|
185
|
+
elif smoothing_method == 'gaussian':
|
|
186
|
+
x_smooth, y_smooth = calculate_gaussian_smooth(x_sorted, y_sorted)
|
|
187
|
+
else:
|
|
188
|
+
f = CubicSpline(x_sorted, y_sorted)
|
|
189
|
+
x_smooth = np.linspace(x_sorted.min(), x_sorted.max(), 200)
|
|
190
|
+
y_smooth = f(x_smooth)
|
|
191
|
+
if use_line_gradient:
|
|
192
|
+
ds.artist = canvas._add_gradient_line(
|
|
193
|
+
target_ax, x_smooth, y_smooth, ds.color, ds.gradient_color2,
|
|
194
|
+
ds.linewidth, ds.alpha, ds.linestyle, label=ds.name
|
|
195
|
+
)
|
|
196
|
+
else:
|
|
197
|
+
(artist_line,) = target_ax.plot(x_smooth, y_smooth, color=ds.color, linestyle=ds.linestyle, linewidth=ds.linewidth, alpha=ds.alpha, label=ds.name, **plot_kwargs)
|
|
198
|
+
ds.artist = artist_line
|
|
199
|
+
if ds.plot_type == 'Line+Scatter':
|
|
200
|
+
target_ax.scatter(plot_x_data, plot_y_data, color=ds.color, marker=ds.marker, s=ds.markersize**2, alpha=ds.alpha, **plot_kwargs)
|
|
201
|
+
return True
|
|
202
|
+
except ValueError:
|
|
203
|
+
if use_line_gradient:
|
|
204
|
+
ds.artist = canvas._add_gradient_line(
|
|
205
|
+
target_ax, plot_x_data, plot_y_data, ds.color, ds.gradient_color2,
|
|
206
|
+
ds.linewidth, ds.alpha, ds.linestyle, label=ds.name
|
|
207
|
+
)
|
|
208
|
+
else:
|
|
209
|
+
(artist,) = target_ax.plot(plot_x_data, plot_y_data, color=ds.color, linestyle=ds.linestyle, linewidth=ds.linewidth, alpha=ds.alpha, label=ds.name, **plot_kwargs)
|
|
210
|
+
ds.artist = artist
|
|
211
|
+
return False
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def draw_plot_type(canvas, target_ax, axis_index, ds, plot_x_data, plot_y_data, plot_kwargs):
|
|
215
|
+
"""
|
|
216
|
+
plot_type の描画関数で描き、ds.artist にするものを返す。組み込みに無ければプラグインの種類を探し、
|
|
217
|
+
それも無ければ線で描く。プラグインの描画にはウォーターフォールの zorder などは渡らない(既知の制限)。
|
|
218
|
+
"""
|
|
219
|
+
drawer = BUILTIN_PLOT_TYPE_DRAWERS.get(ds.plot_type)
|
|
220
|
+
if drawer is not None:
|
|
221
|
+
return drawer(canvas, target_ax, ds, plot_x_data, plot_y_data, plot_kwargs, axis_index)
|
|
222
|
+
|
|
223
|
+
from graphica.core.plugin_api import get_plugin_api
|
|
224
|
+
api = get_plugin_api()
|
|
225
|
+
plugin_plot_type = api.get_plot_type(ds.plot_type) if api is not None else None
|
|
226
|
+
if plugin_plot_type is None:
|
|
227
|
+
logger.warning("未知のplot_type '%s' です。Lineとして描画します。", ds.plot_type)
|
|
228
|
+
(artist,) = target_ax.plot(plot_x_data, plot_y_data, color=ds.color, linestyle=ds.linestyle, linewidth=ds.linewidth, alpha=ds.alpha, label=ds.name, **plot_kwargs)
|
|
229
|
+
return artist
|
|
230
|
+
try:
|
|
231
|
+
artist = plugin_plot_type.drawer(ds, target_ax, plot_x_data, plot_y_data)
|
|
232
|
+
except Exception as e:
|
|
233
|
+
logger.warning(
|
|
234
|
+
"[plugin:%s] plot_type '%s' の描画に失敗しました: %s",
|
|
235
|
+
plugin_plot_type.plugin_name, ds.plot_type, e,
|
|
236
|
+
)
|
|
237
|
+
return ds.artist
|
|
238
|
+
return artist if artist is not None else ds.artist
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def draw_error_display(canvas, target_ax, ds, plot_x_data, plot_y_data, downsample_indices):
|
|
242
|
+
"""誤差棒と誤差の帯を、描いた点(ずらし・間引きの後)の位置に重ねる。帯は Y の誤差だけ。"""
|
|
243
|
+
if not (ds.x_err_col_name or ds.y_err_col_name):
|
|
244
|
+
return
|
|
245
|
+
# 誤差列は間引く前の長さなので、点と同じ添字で揃える(揃えないと長さ違いで例外)
|
|
246
|
+
x_err = ds.x_err_data
|
|
247
|
+
y_err_full = ds.y_err_data
|
|
248
|
+
if downsample_indices is not None:
|
|
249
|
+
if x_err is not None:
|
|
250
|
+
x_err = x_err[downsample_indices]
|
|
251
|
+
if y_err_full is not None:
|
|
252
|
+
y_err_full = y_err_full[downsample_indices]
|
|
253
|
+
|
|
254
|
+
if ds.error_display in ('bar', 'both'):
|
|
255
|
+
target_ax.errorbar(
|
|
256
|
+
plot_x_data, plot_y_data,
|
|
257
|
+
xerr=x_err, yerr=y_err_full,
|
|
258
|
+
fmt='none', ecolor=ds.color, elinewidth=ds.linewidth, alpha=ds.alpha, capsize=3
|
|
259
|
+
)
|
|
260
|
+
if ds.error_display in ('band', 'both') and y_err_full is not None:
|
|
261
|
+
y_arr = np.asarray(plot_y_data)
|
|
262
|
+
y_err = np.asarray(y_err_full)
|
|
263
|
+
target_ax.fill_between(
|
|
264
|
+
plot_x_data, y_arr - y_err, y_arr + y_err,
|
|
265
|
+
color=ds.color, alpha=ds.alpha * 0.25, linewidth=0,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def draw_point_labels(canvas, ax, ds, x_data=None, y_data=None, downsample_indices=None):
|
|
270
|
+
"""
|
|
271
|
+
各点の脇に Y 値(point_label_col_name があればその列の値)を書く。
|
|
272
|
+
x_data/y_data を渡せばその位置に書く(ウォーターフォールでずらした位置)。
|
|
273
|
+
downsample_indices は点の間引きに使った添字で、ラベルの値も同じく間引く
|
|
274
|
+
(揃えないと zip が短い方で切れ、点と無関係な行のラベルが付く)。
|
|
275
|
+
"""
|
|
276
|
+
if x_data is None:
|
|
277
|
+
x_data = ds.x_data
|
|
278
|
+
if y_data is None:
|
|
279
|
+
y_data = ds.y_data
|
|
280
|
+
|
|
281
|
+
if ds.point_label_col_name and ds.point_label_col_name in ds.df.columns:
|
|
282
|
+
# 点と同じ行になるよう visible_df から取る
|
|
283
|
+
label_values = ds.visible_df[ds.point_label_col_name].values
|
|
284
|
+
else:
|
|
285
|
+
label_values = ds.y_data
|
|
286
|
+
if downsample_indices is not None:
|
|
287
|
+
label_values = label_values[downsample_indices]
|
|
288
|
+
|
|
289
|
+
for x, y, label_value in zip(x_data, y_data, label_values):
|
|
290
|
+
if pd.isna(x) or pd.isna(y):
|
|
291
|
+
continue
|
|
292
|
+
if isinstance(label_value, (int, float, np.floating, np.integer)) and not isinstance(label_value, bool):
|
|
293
|
+
text = f"{label_value:.4g}" if not pd.isna(label_value) else ""
|
|
294
|
+
else:
|
|
295
|
+
text = "" if pd.isna(label_value) else str(label_value)
|
|
296
|
+
if not text:
|
|
297
|
+
continue
|
|
298
|
+
ax.annotate(
|
|
299
|
+
text, (x, y), textcoords="offset points", xytext=(5, 5),
|
|
300
|
+
fontsize=8, color=ds.color, alpha=ds.alpha,
|
|
301
|
+
# Text の既定は clip_on=False で、範囲外の点のラベルだけが枠の外に残り SVG/PDF に出てしまう
|
|
302
|
+
clip_on=True,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def enable_element_picking(canvas, artist):
|
|
307
|
+
"""クリックで選べるようにする。棒グラフは Rectangle の集まりなので、1本ずつ設定する。"""
|
|
308
|
+
try:
|
|
309
|
+
if hasattr(artist, 'patches'): # BarContainer
|
|
310
|
+
for patch in artist.patches:
|
|
311
|
+
patch.set_picker(5)
|
|
312
|
+
else:
|
|
313
|
+
artist.set_picker(5)
|
|
314
|
+
except AttributeError:
|
|
315
|
+
pass
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def add_gradient_line(canvas, ax, x, y, color1, color2, linewidth, alpha, linestyle, label=None):
|
|
319
|
+
"""
|
|
320
|
+
線を区間に分けて、始点の色から終点の色へ変わる LineCollection として描く。
|
|
321
|
+
add_collection は軸の表示範囲を広げないことがあるので、データ範囲を明示的に足しておく。
|
|
322
|
+
"""
|
|
323
|
+
x = np.asarray(x, dtype=float)
|
|
324
|
+
y = np.asarray(y, dtype=float)
|
|
325
|
+
|
|
326
|
+
if len(x) < 2:
|
|
327
|
+
# 区間が作れないので普通の線にする
|
|
328
|
+
(line,) = ax.plot(x, y, color=color1, linestyle=linestyle, linewidth=linewidth, alpha=alpha, label=label)
|
|
329
|
+
return line
|
|
330
|
+
|
|
331
|
+
points = np.array([x, y]).T.reshape(-1, 1, 2)
|
|
332
|
+
segments = np.concatenate([points[:-1], points[1:]], axis=1)
|
|
333
|
+
|
|
334
|
+
cmap = LinearSegmentedColormap.from_list('graphica_line_gradient', [color1, color2])
|
|
335
|
+
lc = LineCollection(
|
|
336
|
+
segments, cmap=cmap, norm=Normalize(0, 1),
|
|
337
|
+
linewidths=linewidth, linestyles=linestyle, alpha=alpha, label=label,
|
|
338
|
+
zorder=2,
|
|
339
|
+
)
|
|
340
|
+
# 区間ごとに始点からの位置(0〜1)を割り当てる
|
|
341
|
+
lc.set_array(np.linspace(0, 1, len(segments)))
|
|
342
|
+
ax.add_collection(lc)
|
|
343
|
+
ax.update_datalim(np.column_stack([x, y]))
|
|
344
|
+
return lc
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def add_gradient_fill(canvas, ax, x, y, color1, color2, alpha, baseline=0.0):
|
|
348
|
+
"""fill_between と同じ形の多角形で、imshow のグラデーション画像を切り抜いて塗る。"""
|
|
349
|
+
x = np.asarray(x, dtype=float)
|
|
350
|
+
y = np.asarray(y, dtype=float)
|
|
351
|
+
|
|
352
|
+
# origin='lower' で先頭行が下端になるので、上端が color1 になるよう色を逆に並べる
|
|
353
|
+
gradient = np.linspace(0, 1, 256).reshape(-1, 1)
|
|
354
|
+
cmap = LinearSegmentedColormap.from_list('graphica_fill_gradient', [color2, color1])
|
|
355
|
+
|
|
356
|
+
x_min, x_max = float(np.nanmin(x)), float(np.nanmax(x))
|
|
357
|
+
y_min = float(min(np.nanmin(y), baseline))
|
|
358
|
+
y_max = float(max(np.nanmax(y), baseline))
|
|
359
|
+
# 全点が同じ座標だと extent が潰れるので幅を持たせる
|
|
360
|
+
if x_min == x_max:
|
|
361
|
+
x_min, x_max = x_min - 0.5, x_max + 0.5
|
|
362
|
+
if y_min == y_max:
|
|
363
|
+
y_min, y_max = y_min - 0.5, y_max + 0.5
|
|
364
|
+
|
|
365
|
+
im = ax.imshow(
|
|
366
|
+
gradient, cmap=cmap, aspect='auto', origin='lower',
|
|
367
|
+
extent=(x_min, x_max, y_min, y_max), alpha=alpha, zorder=1,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
verts = list(zip(x, y)) + [(x[-1], baseline), (x[0], baseline)]
|
|
371
|
+
clip_poly = Polygon(verts, closed=True, transform=ax.transData)
|
|
372
|
+
im.set_clip_path(clip_poly)
|
|
373
|
+
|
|
374
|
+
# imshow は軸の範囲を広げないので、明示的に足す
|
|
375
|
+
ax.update_datalim(np.array([[x_min, y_min], [x_max, y_max]]))
|
|
376
|
+
return im
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""2D マップ(ヒートマップ・等高線)の描画。"""
|
|
2
|
+
import logging
|
|
3
|
+
import numpy as np
|
|
4
|
+
from graphica.gui.rendering.common import GRID_2D_MAX_DISPLAY_POINTS_PER_AXIS
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def draw_2d_data(canvas, ax, axis_index, datasets_2d, full_resolution=False):
|
|
10
|
+
"""
|
|
11
|
+
2Dマップを描く。規則格子でも補間した格子でも同じ形の z_grid なので、等間隔を前提にする imshow ではなく
|
|
12
|
+
pcolormesh を使う。map_display_mode: 'heatmap' / 'contour'(線だけ、色と太さはデータセットの線) /
|
|
13
|
+
'contour_filled' / 'heatmap_contour'。カラーバーの対象は塗りのある方式だけ(線だけの等高線には付けない慣習)。
|
|
14
|
+
"""
|
|
15
|
+
canvas._axis_2d_mappables.pop(axis_index, None)
|
|
16
|
+
for ds in datasets_2d:
|
|
17
|
+
grid = ds.z_grid
|
|
18
|
+
if grid is None:
|
|
19
|
+
continue
|
|
20
|
+
x_grid, y_grid, z_grid = grid['x_grid'], grid['y_grid'], grid['z_grid']
|
|
21
|
+
|
|
22
|
+
if not full_resolution and len(x_grid) > GRID_2D_MAX_DISPLAY_POINTS_PER_AXIS:
|
|
23
|
+
step = int(np.ceil(len(x_grid) / GRID_2D_MAX_DISPLAY_POINTS_PER_AXIS))
|
|
24
|
+
x_grid = x_grid[::step]
|
|
25
|
+
z_grid = z_grid[:, ::step]
|
|
26
|
+
if not full_resolution and len(y_grid) > GRID_2D_MAX_DISPLAY_POINTS_PER_AXIS:
|
|
27
|
+
step = int(np.ceil(len(y_grid) / GRID_2D_MAX_DISPLAY_POINTS_PER_AXIS))
|
|
28
|
+
y_grid = y_grid[::step]
|
|
29
|
+
z_grid = z_grid[::step, :]
|
|
30
|
+
|
|
31
|
+
vmin = ds.vmin if ds.vmin is not None else (
|
|
32
|
+
float(np.nanmin(z_grid)) if np.any(~np.isnan(z_grid)) else None
|
|
33
|
+
)
|
|
34
|
+
vmax = ds.vmax if ds.vmax is not None else (
|
|
35
|
+
float(np.nanmax(z_grid)) if np.any(~np.isnan(z_grid)) else None
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
mode = ds.map_display_mode if ds.map_display_mode in canvas._VALID_MAP_DISPLAY_MODES else 'heatmap'
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
mappable = None
|
|
42
|
+
contour_set = None
|
|
43
|
+
# label は付けない。QuadMesh/ContourSet は凡例に載らず、凡例を作るたびに警告が出る
|
|
44
|
+
if mode in ('heatmap', 'heatmap_contour'):
|
|
45
|
+
mappable = ax.pcolormesh(
|
|
46
|
+
x_grid, y_grid, z_grid, cmap=ds.colormap, vmin=vmin, vmax=vmax,
|
|
47
|
+
shading='auto', alpha=ds.alpha,
|
|
48
|
+
)
|
|
49
|
+
elif mode == 'contour_filled':
|
|
50
|
+
mappable = ax.contourf(
|
|
51
|
+
x_grid, y_grid, z_grid, levels=ds.contour_levels, cmap=ds.colormap,
|
|
52
|
+
vmin=vmin, vmax=vmax, alpha=ds.alpha,
|
|
53
|
+
)
|
|
54
|
+
if mode in ('contour', 'heatmap_contour'):
|
|
55
|
+
contour_set = ax.contour(
|
|
56
|
+
x_grid, y_grid, z_grid, levels=ds.contour_levels, colors=ds.color,
|
|
57
|
+
alpha=ds.alpha, linewidths=ds.linewidth,
|
|
58
|
+
)
|
|
59
|
+
except ValueError as e:
|
|
60
|
+
# 知らないカラーマップ名など。このデータセットだけ飛ばす
|
|
61
|
+
logger.warning("2Dマップの描画に失敗しました(%s): %s", ds.name, e)
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
ds.artist = mappable if mappable is not None else contour_set
|
|
65
|
+
if mappable is not None:
|
|
66
|
+
canvas._axis_2d_mappables[axis_index] = mappable
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""選んだデータセットのフィットの残差を出すドック。dataset.fit_result の残差をそのまま描く(計算し直さない)。
|
|
2
|
+
|
|
3
|
+
メインのグラフを上下2段に分けないのは、サブプロットの数・配置・自由配置・第2Y軸などの前提と絡み合うため。
|
|
4
|
+
"""
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
|
|
8
|
+
from matplotlib.figure import Figure
|
|
9
|
+
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel
|
|
10
|
+
|
|
11
|
+
from graphica.gui import theme
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ResidualPanel(QWidget):
|
|
17
|
+
def __init__(self, parent=None, dpi=100):
|
|
18
|
+
super().__init__(parent)
|
|
19
|
+
layout = QVBoxLayout(self)
|
|
20
|
+
layout.setContentsMargins(4, 4, 4, 4)
|
|
21
|
+
|
|
22
|
+
self.placeholder_label = QLabel(
|
|
23
|
+
"曲線フィットの結果を持つデータセットを選択すると、\n"
|
|
24
|
+
"ここに残差(実測値 - フィット値)が表示されます。"
|
|
25
|
+
)
|
|
26
|
+
self.placeholder_label.setWordWrap(True)
|
|
27
|
+
self.placeholder_label.setStyleSheet("color: gray; padding: 12px;")
|
|
28
|
+
layout.addWidget(self.placeholder_label)
|
|
29
|
+
|
|
30
|
+
self.fig = Figure(figsize=(4, 2.2), dpi=dpi, tight_layout=True)
|
|
31
|
+
self.canvas = FigureCanvas(self.fig)
|
|
32
|
+
self.canvas.setMinimumHeight(160)
|
|
33
|
+
self.ax = self.fig.add_subplot(111)
|
|
34
|
+
layout.addWidget(self.canvas)
|
|
35
|
+
self.canvas.setVisible(False)
|
|
36
|
+
|
|
37
|
+
def refresh(self, dataset):
|
|
38
|
+
"""fit_result が無いか残差が空なら案内の文を出す。"""
|
|
39
|
+
fit_result = dataset.fit_result if dataset is not None else None
|
|
40
|
+
residual_x = fit_result.get('residual_x') if fit_result else None
|
|
41
|
+
residuals = fit_result.get('residuals') if fit_result else None
|
|
42
|
+
|
|
43
|
+
if not residual_x or not residuals:
|
|
44
|
+
self.canvas.setVisible(False)
|
|
45
|
+
self.placeholder_label.setVisible(True)
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
self.placeholder_label.setVisible(False)
|
|
49
|
+
self.canvas.setVisible(True)
|
|
50
|
+
|
|
51
|
+
tokens = theme.current_tokens()
|
|
52
|
+
self.fig.set_facecolor(tokens['surface'])
|
|
53
|
+
self.ax.cla()
|
|
54
|
+
self.ax.set_facecolor(tokens['surface'])
|
|
55
|
+
self.ax.axhline(0, color=tokens['border_strong'], linewidth=0.8, linestyle='--')
|
|
56
|
+
self.ax.scatter(residual_x, residuals, s=14, color='#1F6F78')
|
|
57
|
+
self.ax.set_xlabel("X", fontsize=8, color=tokens['text_primary'])
|
|
58
|
+
self.ax.set_ylabel("残差", fontsize=8, color=tokens['text_primary'])
|
|
59
|
+
self.ax.tick_params(labelsize=7, colors=tokens['text_primary'])
|
|
60
|
+
for spine in self.ax.spines.values():
|
|
61
|
+
spine.set_color(tokens['border_strong'])
|
|
62
|
+
self.canvas.draw_idle()
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""同梱のリソースの場所。"""
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def resource_path(relative_path):
|
|
7
|
+
"""同梱のリソースの絶対パス。凍結時は sys._MEIPASS、ソースからは graphica/ を基準にする。
|
|
8
|
+
|
|
9
|
+
カレントディレクトリは使わない(起動の仕方次第でアイコンなどが読めなくなる)。
|
|
10
|
+
"""
|
|
11
|
+
try:
|
|
12
|
+
base_path = sys._MEIPASS
|
|
13
|
+
except AttributeError:
|
|
14
|
+
base_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
15
|
+
|
|
16
|
+
return os.path.join(base_path, relative_path)
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""起動中の Graphica にファイルを渡す(ファイルをダブルクリックしたとき、新しいウィンドウではなく新しいタブで開くため)。
|
|
2
|
+
|
|
3
|
+
最初に起動したものがローカルサーバーを開き、あとから起動したものはそこへパスを送って終わる。起動画面より前に使うので、
|
|
4
|
+
ここでは PySide6 だけを読む。
|
|
5
|
+
"""
|
|
6
|
+
import getpass
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
from PySide6.QtCore import QEvent, QObject, Signal
|
|
13
|
+
from PySide6.QtNetwork import QLocalServer, QLocalSocket
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
# 起動中のアプリがほかの受け渡しを処理している間は待たされることがある
|
|
18
|
+
_CONNECT_TIMEOUT_MS = 2000
|
|
19
|
+
_WRITE_TIMEOUT_MS = 1000
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def server_name():
|
|
23
|
+
"""利用者ごとに別の名前(同じ PC の別の利用者のアプリには渡さない)。"""
|
|
24
|
+
try:
|
|
25
|
+
user = getpass.getuser()
|
|
26
|
+
except (OSError, KeyError, ImportError): # 利用者名が取れない環境でも起動は止めない
|
|
27
|
+
user = "user"
|
|
28
|
+
return "Graphica-" + re.sub(r"[^A-Za-z0-9_.-]", "_", user)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def files_to_open(argv):
|
|
32
|
+
"""起動の引数のうち、開くファイル(オプション以外で、実在するもの)。"""
|
|
33
|
+
return [os.path.abspath(arg) for arg in argv[1:] if not arg.startswith("-") and os.path.isfile(arg)]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def instance_running(name=None):
|
|
37
|
+
"""ほかの Graphica が受け渡しの窓口を開いているか。"""
|
|
38
|
+
socket = QLocalSocket()
|
|
39
|
+
socket.connectToServer(name or server_name())
|
|
40
|
+
running = socket.waitForConnected(_CONNECT_TIMEOUT_MS)
|
|
41
|
+
if running:
|
|
42
|
+
socket.disconnectFromServer()
|
|
43
|
+
return running
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def send_to_running_instance(paths, name=None):
|
|
47
|
+
"""起動中のアプリがあればパスを渡して True。無ければ False(自分が起動を続ける)。"""
|
|
48
|
+
socket = QLocalSocket()
|
|
49
|
+
socket.connectToServer(name or server_name())
|
|
50
|
+
if not socket.waitForConnected(_CONNECT_TIMEOUT_MS):
|
|
51
|
+
return False
|
|
52
|
+
socket.write((json.dumps({"open": list(paths)}) + "\n").encode("utf-8"))
|
|
53
|
+
socket.flush()
|
|
54
|
+
# Windows のパイプは書き込みが後から終わる。切断が済むまで待たないと、すぐ終わるこのプロセスと一緒に消える
|
|
55
|
+
socket.disconnectFromServer()
|
|
56
|
+
if socket.state() != QLocalSocket.LocalSocketState.UnconnectedState:
|
|
57
|
+
socket.waitForDisconnected(_WRITE_TIMEOUT_MS)
|
|
58
|
+
return socket.state() == QLocalSocket.LocalSocketState.UnconnectedState and socket.bytesToWrite() == 0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class InstanceServer(QObject):
|
|
62
|
+
"""あとから起動したアプリが送ってきたパスを open_requested で知らせる。"""
|
|
63
|
+
|
|
64
|
+
open_requested = Signal(list)
|
|
65
|
+
|
|
66
|
+
def __init__(self, name=None, parent=None):
|
|
67
|
+
super().__init__(parent)
|
|
68
|
+
self._name = name or server_name()
|
|
69
|
+
self._server = QLocalServer(self)
|
|
70
|
+
self._server.newConnection.connect(self._on_new_connection)
|
|
71
|
+
self._buffers = {}
|
|
72
|
+
|
|
73
|
+
def listen(self):
|
|
74
|
+
"""開けなければ False。前回の異常終了で残った名前(Unix のソケットファイル)は消して開き直す。"""
|
|
75
|
+
if self._server.listen(self._name):
|
|
76
|
+
return True
|
|
77
|
+
QLocalServer.removeServer(self._name)
|
|
78
|
+
if self._server.listen(self._name):
|
|
79
|
+
return True
|
|
80
|
+
logger.warning("起動中のアプリへのファイルの受け渡しを開けませんでした: %s", self._server.errorString())
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
def close(self):
|
|
84
|
+
self._server.close()
|
|
85
|
+
|
|
86
|
+
def _on_new_connection(self):
|
|
87
|
+
# 接続を掴むラムダは使わない(接続が消えたあとに呼ばれると落ちる)。送り手は sender() で引く
|
|
88
|
+
while self._server.hasPendingConnections():
|
|
89
|
+
socket = self._server.nextPendingConnection()
|
|
90
|
+
self._buffers[socket] = b""
|
|
91
|
+
socket.readyRead.connect(self._on_ready_read)
|
|
92
|
+
socket.disconnected.connect(self._on_disconnected)
|
|
93
|
+
|
|
94
|
+
def _on_ready_read(self):
|
|
95
|
+
socket = self.sender()
|
|
96
|
+
if socket is None:
|
|
97
|
+
return
|
|
98
|
+
self._buffers[socket] = self._buffers.get(socket, b"") + bytes(socket.readAll())
|
|
99
|
+
self._emit_complete_lines(socket)
|
|
100
|
+
|
|
101
|
+
def _on_disconnected(self):
|
|
102
|
+
socket = self.sender()
|
|
103
|
+
if socket is None:
|
|
104
|
+
return
|
|
105
|
+
self._buffers[socket] = self._buffers.get(socket, b"") + bytes(socket.readAll())
|
|
106
|
+
self._emit_complete_lines(socket, final=True)
|
|
107
|
+
self._buffers.pop(socket, None)
|
|
108
|
+
socket.deleteLater()
|
|
109
|
+
|
|
110
|
+
def _emit_complete_lines(self, socket, final=False):
|
|
111
|
+
data = self._buffers.get(socket, b"")
|
|
112
|
+
*lines, rest = data.split(b"\n")
|
|
113
|
+
if final and rest:
|
|
114
|
+
lines.append(rest)
|
|
115
|
+
rest = b""
|
|
116
|
+
self._buffers[socket] = rest
|
|
117
|
+
for line in lines:
|
|
118
|
+
if not line.strip():
|
|
119
|
+
continue
|
|
120
|
+
try:
|
|
121
|
+
paths = json.loads(line.decode("utf-8")).get("open", [])
|
|
122
|
+
except (ValueError, AttributeError):
|
|
123
|
+
logger.warning("起動中のアプリへの受け渡しの中身が読めませんでした: %r", line[:200])
|
|
124
|
+
continue
|
|
125
|
+
paths = [p for p in paths if isinstance(p, str)]
|
|
126
|
+
if paths:
|
|
127
|
+
self.open_requested.emit(paths)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class FileOpenEventFilter(QObject):
|
|
131
|
+
"""macOS で Finder から開いたファイル(QFileOpenEvent。起動中でも届く)を open_requested で知らせる。"""
|
|
132
|
+
|
|
133
|
+
open_requested = Signal(list)
|
|
134
|
+
|
|
135
|
+
def eventFilter(self, watched, event):
|
|
136
|
+
if event.type() == QEvent.Type.FileOpen:
|
|
137
|
+
path = event.file()
|
|
138
|
+
if path:
|
|
139
|
+
self.open_requested.emit([path])
|
|
140
|
+
return True
|
|
141
|
+
return super().eventFilter(watched, event)
|