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,56 @@
|
|
|
1
|
+
"""マウスの 7 つのモードのツール(gui/tools/*.py)と、PlotterApp にツールの名前を出す窓口。
|
|
2
|
+
|
|
3
|
+
ツールは状態を自分で持つ。PlotterApp は各ツールの EXPOSED_NAMES を同じ名前の属性として出し、読み書きをツールへ回す
|
|
4
|
+
(テスト・メニュー・ほかの mixin が今までの名前で使うため。monkeypatch もツールに届く)。
|
|
5
|
+
"""
|
|
6
|
+
from graphica.gui.tools.annotation import AnnotationTool
|
|
7
|
+
from graphica.gui.tools.cursor import CursorTool
|
|
8
|
+
from graphica.gui.tools.layout_edit import LayoutEditTool
|
|
9
|
+
from graphica.gui.tools.manager import MOUSE_MODES, MOUSE_MODES_BY_NAME, MouseMode, ToolManager
|
|
10
|
+
from graphica.gui.tools.peak_placement import PeakPlacementTool
|
|
11
|
+
from graphica.gui.tools.range_select import RangeSelectTool
|
|
12
|
+
from graphica.gui.tools.region_highlight import RegionHighlightTool
|
|
13
|
+
from graphica.gui.tools.slice_extraction import SliceExtractionTool
|
|
14
|
+
|
|
15
|
+
__all__ = ["MOUSE_MODES", "MOUSE_MODES_BY_NAME", "MouseMode", "ToolManager", "TOOL_CLASSES", "create_tools",
|
|
16
|
+
"expose_tool_names"]
|
|
17
|
+
|
|
18
|
+
# キーは MOUSE_MODES の name と同じ
|
|
19
|
+
TOOL_CLASSES = {
|
|
20
|
+
"cursor": CursorTool,
|
|
21
|
+
"annotation": AnnotationTool,
|
|
22
|
+
"layout_edit": LayoutEditTool,
|
|
23
|
+
"range_select": RangeSelectTool,
|
|
24
|
+
"peak_placement": PeakPlacementTool,
|
|
25
|
+
"slice_extraction": SliceExtractionTool,
|
|
26
|
+
"region_highlight": RegionHighlightTool,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def create_tools(app):
|
|
31
|
+
return {key: tool_class(app) for key, tool_class in TOOL_CLASSES.items()}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _ToolName:
|
|
35
|
+
"""PlotterApp の属性として、ツールの同じ名前の属性を読み書きする。"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, tool_key, name):
|
|
38
|
+
self._tool_key = tool_key
|
|
39
|
+
self._name = name
|
|
40
|
+
|
|
41
|
+
def __get__(self, app, owner=None):
|
|
42
|
+
if app is None:
|
|
43
|
+
# クラスから引いたとき(メニューの表の検査など)は、ツールのクラスの同じ名前を返す
|
|
44
|
+
return getattr(TOOL_CLASSES[self._tool_key], self._name, self)
|
|
45
|
+
return getattr(app.mouse_tools[self._tool_key], self._name)
|
|
46
|
+
|
|
47
|
+
def __set__(self, app, value):
|
|
48
|
+
setattr(app.mouse_tools[self._tool_key], self._name, value)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def expose_tool_names(app_class):
|
|
52
|
+
for key, tool_class in TOOL_CLASSES.items():
|
|
53
|
+
for name in tool_class.EXPOSED_NAMES:
|
|
54
|
+
if any(name in klass.__dict__ for klass in app_class.__mro__):
|
|
55
|
+
raise TypeError(f"{app_class.__name__}.{name} はツール {key} の名前とぶつかる")
|
|
56
|
+
setattr(app_class, name, _ToolName(key, name))
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""注釈モード: クリックでテキスト、ドラッグで矢印の注釈を置き、右クリックのメニューで色を変える・消す。
|
|
2
|
+
|
|
3
|
+
注釈は all_plot_settings[軸]['annotations'] に {'id', 'type', 'text', 'xy', 'xytext', 'color'} で持ち、プロジェクトと一緒に
|
|
4
|
+
保存される。矢印は 'arrow_style' と 'arrow_curvature' も持つ(無ければ既定値)。追加と削除は SetAnnotationsCommand で Undo できる。
|
|
5
|
+
"""
|
|
6
|
+
import uuid
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
from PySide6.QtGui import QColor, QCursor
|
|
10
|
+
from PySide6.QtWidgets import QDialog, QMenu, QMessageBox
|
|
11
|
+
|
|
12
|
+
from graphica.gui import app_settings, notify
|
|
13
|
+
from graphica.core.axis_settings import axis_setting
|
|
14
|
+
from graphica.core.commands import SetAnnotationsCommand
|
|
15
|
+
from graphica.gui.app_settings import DEFAULT_SNAP_GRID_INTERVAL_PX
|
|
16
|
+
from graphica.gui.color_history import get_color_with_history
|
|
17
|
+
from graphica.gui.dialogs import ArrowAnnotationDialog, TextAnnotationDialog
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
# これより動かなければクリック、動けばドラッグ
|
|
22
|
+
ANNOTATION_CLICK_THRESHOLD_PX = 5
|
|
23
|
+
ANNOTATION_DELETE_TOLERANCE_PX = 15
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AnnotationTool:
|
|
27
|
+
"""状態はこのツールが持ち、PlotterApp の同じ名前は窓口(gui/tools/__init__.py)。"""
|
|
28
|
+
|
|
29
|
+
# PlotterApp から同じ名前で読み書きできるもの(テスト・メニュー・ほかの mixin が使う)
|
|
30
|
+
EXPOSED_NAMES = (
|
|
31
|
+
'_add_annotation', '_annotation_drag_start', '_annotation_press_cid', '_annotation_release_cid',
|
|
32
|
+
'_change_annotation_color', '_choose_annotation_action', '_confirm_and_delete_annotation',
|
|
33
|
+
'_find_annotation_near', '_find_axis_index', '_on_annotation_context_menu', '_on_annotation_press',
|
|
34
|
+
'_on_annotation_release', '_snap_point_to_grid', '_toggle_annotation_mode', '_try_delete_annotation_near',
|
|
35
|
+
'annotation_mode_enabled',
|
|
36
|
+
'snap_grid_interval_px', 'snap_to_grid_enabled',
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
def __init__(self, app):
|
|
40
|
+
self._app = app
|
|
41
|
+
self.annotation_mode_enabled = False
|
|
42
|
+
self._annotation_press_cid = None
|
|
43
|
+
self._annotation_release_cid = None
|
|
44
|
+
self._annotation_drag_start = None # (ax, x, y)
|
|
45
|
+
self.snap_to_grid_enabled = app_settings.SNAP_TO_GRID_ENABLED.read(self._app.settings)
|
|
46
|
+
self.snap_grid_interval_px = app_settings.SNAP_GRID_INTERVAL_PX.read(self._app.settings)
|
|
47
|
+
|
|
48
|
+
def _toggle_annotation_mode(self, checked):
|
|
49
|
+
self.annotation_mode_enabled = checked
|
|
50
|
+
|
|
51
|
+
if checked:
|
|
52
|
+
self._app._deactivate_other_mouse_modes('annotation')
|
|
53
|
+
|
|
54
|
+
self._annotation_press_cid = self._app.canvas.mpl_connect(
|
|
55
|
+
'button_press_event', self._on_annotation_press
|
|
56
|
+
)
|
|
57
|
+
self._annotation_release_cid = self._app.canvas.mpl_connect(
|
|
58
|
+
'button_release_event', self._on_annotation_release
|
|
59
|
+
)
|
|
60
|
+
self._app.statusBar().showMessage(
|
|
61
|
+
"注釈モード: クリックでテキスト注釈、ドラッグで矢印注釈を追加します(右クリックで色の変更・削除)", 5000
|
|
62
|
+
)
|
|
63
|
+
else:
|
|
64
|
+
if self._annotation_press_cid is not None:
|
|
65
|
+
self._app.canvas.mpl_disconnect(self._annotation_press_cid)
|
|
66
|
+
self._annotation_press_cid = None
|
|
67
|
+
if self._annotation_release_cid is not None:
|
|
68
|
+
self._app.canvas.mpl_disconnect(self._annotation_release_cid)
|
|
69
|
+
self._annotation_release_cid = None
|
|
70
|
+
self._annotation_drag_start = None
|
|
71
|
+
|
|
72
|
+
def _snap_point_to_grid(self, ax, x, y):
|
|
73
|
+
"""有効なら、データ座標を画面のピクセルに直してグリッドの倍数に丸め、データ座標に戻す(整列は画面上の話なので)。"""
|
|
74
|
+
if not getattr(self, 'snap_to_grid_enabled', False):
|
|
75
|
+
return x, y
|
|
76
|
+
|
|
77
|
+
interval = getattr(self, 'snap_grid_interval_px', DEFAULT_SNAP_GRID_INTERVAL_PX)
|
|
78
|
+
if not interval or interval <= 0:
|
|
79
|
+
return x, y
|
|
80
|
+
|
|
81
|
+
px, py = ax.transData.transform((x, y))
|
|
82
|
+
snapped_px = round(px / interval) * interval
|
|
83
|
+
snapped_py = round(py / interval) * interval
|
|
84
|
+
data_x, data_y = ax.transData.inverted().transform((snapped_px, snapped_py))
|
|
85
|
+
return data_x, data_y
|
|
86
|
+
|
|
87
|
+
def _find_axis_index(self, ax):
|
|
88
|
+
"""all_axes / all_secondary_axes での番号。無ければ None。"""
|
|
89
|
+
if ax in self._app.all_axes:
|
|
90
|
+
return self._app.all_axes.index(ax)
|
|
91
|
+
if ax in self._app.all_secondary_axes:
|
|
92
|
+
return self._app.all_secondary_axes.index(ax)
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
def _on_annotation_press(self, event):
|
|
96
|
+
if not self.annotation_mode_enabled or event.inaxes is None or event.xdata is None:
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
if event.button == 3: # 右クリックは色の変更と削除のメニュー
|
|
100
|
+
self._on_annotation_context_menu(event)
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
self._annotation_drag_start = (event.inaxes, event.xdata, event.ydata)
|
|
104
|
+
|
|
105
|
+
def _on_annotation_release(self, event):
|
|
106
|
+
if not self.annotation_mode_enabled or self._annotation_drag_start is None:
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
start_ax, start_x, start_y = self._annotation_drag_start
|
|
110
|
+
self._annotation_drag_start = None
|
|
111
|
+
|
|
112
|
+
if event.inaxes is not start_ax or event.xdata is None:
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
axis_index = self._find_axis_index(start_ax)
|
|
116
|
+
if axis_index is None:
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
end_x, end_y = event.xdata, event.ydata
|
|
120
|
+
start_px = start_ax.transData.transform((start_x, start_y))
|
|
121
|
+
end_px = start_ax.transData.transform((end_x, end_y))
|
|
122
|
+
drag_distance_px = ((end_px[0] - start_px[0]) ** 2 + (end_px[1] - start_px[1]) ** 2) ** 0.5
|
|
123
|
+
|
|
124
|
+
if drag_distance_px < ANNOTATION_CLICK_THRESHOLD_PX:
|
|
125
|
+
dialog = TextAnnotationDialog(self._app, settings=self._app.settings)
|
|
126
|
+
if dialog.exec() != QDialog.DialogCode.Accepted:
|
|
127
|
+
return
|
|
128
|
+
text = dialog.get_text()
|
|
129
|
+
if not text:
|
|
130
|
+
return
|
|
131
|
+
snapped_x, snapped_y = self._snap_point_to_grid(start_ax, start_x, start_y)
|
|
132
|
+
self._add_annotation(axis_index, {
|
|
133
|
+
'type': 'text', 'text': text,
|
|
134
|
+
'xy': (snapped_x, snapped_y), 'xytext': (snapped_x, snapped_y),
|
|
135
|
+
'color': dialog.color(),
|
|
136
|
+
})
|
|
137
|
+
else:
|
|
138
|
+
dialog = ArrowAnnotationDialog(self._app, settings=self._app.settings)
|
|
139
|
+
if dialog.exec() != QDialog.DialogCode.Accepted:
|
|
140
|
+
return
|
|
141
|
+
text, arrow_style, arrow_curvature = dialog.get_settings()
|
|
142
|
+
snapped_end_x, snapped_end_y = self._snap_point_to_grid(start_ax, end_x, end_y)
|
|
143
|
+
snapped_start_x, snapped_start_y = self._snap_point_to_grid(start_ax, start_x, start_y)
|
|
144
|
+
self._add_annotation(axis_index, {
|
|
145
|
+
'type': 'arrow', 'text': text,
|
|
146
|
+
'xy': (snapped_end_x, snapped_end_y), 'xytext': (snapped_start_x, snapped_start_y),
|
|
147
|
+
'color': dialog.color(),
|
|
148
|
+
'arrow_style': arrow_style,
|
|
149
|
+
'arrow_curvature': arrow_curvature,
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
def _add_annotation(self, axis_index, annotation, description="注釈の追加"):
|
|
153
|
+
"""注釈を1つ足す。リストは差し替える(浅いコピーで共有されているリストを書き換えないように)。"""
|
|
154
|
+
annotation = dict(annotation)
|
|
155
|
+
annotation['id'] = uuid.uuid4().hex
|
|
156
|
+
|
|
157
|
+
settings = self._app.project.all_plot_settings[axis_index]
|
|
158
|
+
old_annotations = list(axis_setting(settings, 'annotations'))
|
|
159
|
+
new_annotations = old_annotations + [annotation]
|
|
160
|
+
|
|
161
|
+
command = SetAnnotationsCommand(
|
|
162
|
+
self._app.project, axis_index, old_annotations, new_annotations,
|
|
163
|
+
self._app._update_plot_appearance, description=description
|
|
164
|
+
)
|
|
165
|
+
self._app.undo_stack.push(command)
|
|
166
|
+
|
|
167
|
+
def _on_annotation_context_menu(self, event):
|
|
168
|
+
found = self._find_annotation_near(event)
|
|
169
|
+
if found is None:
|
|
170
|
+
return
|
|
171
|
+
axis_index, annotations, index = found
|
|
172
|
+
choice = self._choose_annotation_action(annotations[index])
|
|
173
|
+
if choice == 'color':
|
|
174
|
+
self._change_annotation_color(axis_index, annotations, index)
|
|
175
|
+
elif choice == 'delete':
|
|
176
|
+
self._confirm_and_delete_annotation(axis_index, annotations, index)
|
|
177
|
+
|
|
178
|
+
def _choose_annotation_action(self, annotation):
|
|
179
|
+
"""右クリックのメニューを出し、'color'・'delete'・None(閉じた)を返す。色を持てるのは文字と矢印だけ。"""
|
|
180
|
+
menu = QMenu(self._app)
|
|
181
|
+
color_action = menu.addAction("色を変更...") if annotation.get('type') in ('text', 'arrow') else None
|
|
182
|
+
delete_action = menu.addAction("削除...")
|
|
183
|
+
chosen = menu.exec(QCursor.pos())
|
|
184
|
+
if chosen is None:
|
|
185
|
+
return None
|
|
186
|
+
if color_action is not None and chosen is color_action:
|
|
187
|
+
return 'color'
|
|
188
|
+
if chosen is delete_action:
|
|
189
|
+
return 'delete'
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
def _change_annotation_color(self, axis_index, annotations, index):
|
|
193
|
+
current = annotations[index].get('color', '#000000')
|
|
194
|
+
color = get_color_with_history(self._app.settings, self._app, initial=QColor(current))
|
|
195
|
+
if not color.isValid() or color.name() == QColor(current).name():
|
|
196
|
+
return
|
|
197
|
+
new_list = list(annotations)
|
|
198
|
+
new_list[index] = dict(annotations[index], color=color.name())
|
|
199
|
+
command = SetAnnotationsCommand(
|
|
200
|
+
self._app.project, axis_index, annotations, new_list,
|
|
201
|
+
self._app._update_plot_appearance, description="注釈の色の変更"
|
|
202
|
+
)
|
|
203
|
+
self._app.undo_stack.push(command)
|
|
204
|
+
|
|
205
|
+
def _try_delete_annotation_near(self, event):
|
|
206
|
+
found = self._find_annotation_near(event)
|
|
207
|
+
if found is not None:
|
|
208
|
+
self._confirm_and_delete_annotation(*found)
|
|
209
|
+
|
|
210
|
+
def _find_annotation_near(self, event):
|
|
211
|
+
"""クリックした位置のいちばん近い注釈の (軸の番号, その軸の注釈, 番号)。近くに無ければ None。"""
|
|
212
|
+
axis_index = self._find_axis_index(event.inaxes)
|
|
213
|
+
if axis_index is None:
|
|
214
|
+
return None
|
|
215
|
+
|
|
216
|
+
settings = self._app.project.all_plot_settings[axis_index]
|
|
217
|
+
annotations = axis_setting(settings, 'annotations')
|
|
218
|
+
if not annotations:
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
ax = event.inaxes
|
|
222
|
+
click_px = ax.transData.transform((event.xdata, event.ydata))
|
|
223
|
+
|
|
224
|
+
best_index, best_distance = None, None
|
|
225
|
+
for i, ann in enumerate(annotations):
|
|
226
|
+
ann_type = ann.get('type')
|
|
227
|
+
if ann_type in ('vspan', 'hspan'):
|
|
228
|
+
# 領域の強調は領域強調モードの側で消す
|
|
229
|
+
continue
|
|
230
|
+
if ann_type == 'inset':
|
|
231
|
+
# 拡大図は xy を持たないので、角と大きさ(軸に対する座標)から中心を求めて当たり判定に使う
|
|
232
|
+
from graphica.gui.canvas import _INSET_CORNER_ORIGINS
|
|
233
|
+
x0, y0 = _INSET_CORNER_ORIGINS.get(ann.get('corner', '右上'), (0.55, 0.55))
|
|
234
|
+
size = ann.get('size', 0.4)
|
|
235
|
+
pos = (x0 + size / 2, y0 + size / 2)
|
|
236
|
+
transform = ax.transAxes
|
|
237
|
+
else:
|
|
238
|
+
pos = ann.get('xytext') or ann.get('xy')
|
|
239
|
+
if pos is None:
|
|
240
|
+
continue
|
|
241
|
+
# 統計値のラベルは軸に対する座標(0〜1)で位置を持つ
|
|
242
|
+
transform = ax.transAxes if ann_type == 'stat' else ax.transData
|
|
243
|
+
pos_px = transform.transform(pos)
|
|
244
|
+
distance = ((pos_px[0] - click_px[0]) ** 2 + (pos_px[1] - click_px[1]) ** 2) ** 0.5
|
|
245
|
+
if best_distance is None or distance < best_distance:
|
|
246
|
+
best_distance, best_index = distance, i
|
|
247
|
+
|
|
248
|
+
if best_index is None or best_distance > ANNOTATION_DELETE_TOLERANCE_PX:
|
|
249
|
+
return None
|
|
250
|
+
return axis_index, annotations, best_index
|
|
251
|
+
|
|
252
|
+
def _confirm_and_delete_annotation(self, axis_index, annotations, best_index):
|
|
253
|
+
target = annotations[best_index]
|
|
254
|
+
if target.get('type') == 'stat':
|
|
255
|
+
label = "統計値アンカーラベル"
|
|
256
|
+
elif target.get('type') == 'inset':
|
|
257
|
+
label = "インセット(拡大図)"
|
|
258
|
+
else:
|
|
259
|
+
label = target.get('text') or ("矢印注釈" if target.get('type') == 'arrow' else "テキスト注釈")
|
|
260
|
+
reply = notify.question(
|
|
261
|
+
self._app, "注釈の削除", f"この注釈を削除しますか?\n\n{label}",
|
|
262
|
+
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
263
|
+
QMessageBox.StandardButton.Yes
|
|
264
|
+
)
|
|
265
|
+
if reply != QMessageBox.StandardButton.Yes:
|
|
266
|
+
return
|
|
267
|
+
|
|
268
|
+
new_list = list(annotations)
|
|
269
|
+
del new_list[best_index]
|
|
270
|
+
|
|
271
|
+
command = SetAnnotationsCommand(
|
|
272
|
+
self._app.project, axis_index, annotations, new_list,
|
|
273
|
+
self._app._update_plot_appearance, description="注釈の削除"
|
|
274
|
+
)
|
|
275
|
+
self._app.undo_stack.push(command)
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""データカーソル(点をクリックして値を見る)、ホイールでのズーム、中ボタンでのパン、グラフの要素のクリックでの選択。"""
|
|
2
|
+
import logging
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
logger = logging.getLogger(__name__)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CursorTool:
|
|
9
|
+
"""状態はこのツールが持ち、PlotterApp の同じ名前は窓口(gui/tools/__init__.py)。"""
|
|
10
|
+
|
|
11
|
+
# PlotterApp から同じ名前で読み書きできるもの(テスト・メニュー・ほかの mixin が使う)
|
|
12
|
+
EXPOSED_NAMES = (
|
|
13
|
+
'_middle_pan_axes', '_middle_pan_start_data', '_middle_pan_start_xlim', '_middle_pan_start_ylim',
|
|
14
|
+
'_on_element_pick', '_on_middle_button_motion_pan', '_on_middle_button_press_pan',
|
|
15
|
+
'_on_middle_button_release_pan', '_on_mouse_move', '_on_pick', '_on_scroll_zoom', '_toggle_cursor_mode',
|
|
16
|
+
'cursor_annotation', 'cursor_connection_id', 'cursor_mode_enabled',
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
def __init__(self, app):
|
|
20
|
+
self._app = app
|
|
21
|
+
# マウス操作の各モードの状態(接続 ID は切るときに使う)
|
|
22
|
+
self.cursor_mode_enabled = False
|
|
23
|
+
self.cursor_connection_id = None
|
|
24
|
+
self.cursor_annotation = None
|
|
25
|
+
# 中ボタンでのパン。ドラッグ中かどうかは _middle_pan_axes が None かどうか
|
|
26
|
+
self._middle_pan_axes = None
|
|
27
|
+
self._middle_pan_start_data = None
|
|
28
|
+
self._middle_pan_start_xlim = None
|
|
29
|
+
self._middle_pan_start_ylim = None
|
|
30
|
+
|
|
31
|
+
def _on_scroll_zoom(self, event):
|
|
32
|
+
"""カーソルの位置を中心に、上で拡大、下で縮小する。"""
|
|
33
|
+
ax = event.inaxes
|
|
34
|
+
if ax is None or event.xdata is None or event.ydata is None:
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
base_scale = 1.2
|
|
38
|
+
scale_factor = (1 / base_scale) if event.button == 'up' else base_scale
|
|
39
|
+
|
|
40
|
+
xlim = ax.get_xlim()
|
|
41
|
+
ylim = ax.get_ylim()
|
|
42
|
+
xdata, ydata = event.xdata, event.ydata
|
|
43
|
+
|
|
44
|
+
new_width = (xlim[1] - xlim[0]) * scale_factor
|
|
45
|
+
new_height = (ylim[1] - ylim[0]) * scale_factor
|
|
46
|
+
|
|
47
|
+
# カーソルが新しい範囲でも同じ相対位置に来るよう、余白を左右と上下に配分する
|
|
48
|
+
old_width = xlim[1] - xlim[0]
|
|
49
|
+
old_height = ylim[1] - ylim[0]
|
|
50
|
+
relx = (xlim[1] - xdata) / old_width if old_width else 0.5
|
|
51
|
+
rely = (ylim[1] - ydata) / old_height if old_height else 0.5
|
|
52
|
+
|
|
53
|
+
ax.set_xlim([xdata - new_width * (1 - relx), xdata + new_width * relx])
|
|
54
|
+
ax.set_ylim([ydata - new_height * (1 - rely), ydata + new_height * rely])
|
|
55
|
+
self._app.canvas.draw_idle()
|
|
56
|
+
|
|
57
|
+
def _on_middle_button_press_pan(self, event):
|
|
58
|
+
"""押したときの範囲とカーソルのデータ座標を覚える。"""
|
|
59
|
+
if event.button != 2 or event.inaxes is None:
|
|
60
|
+
return
|
|
61
|
+
if event.xdata is None or event.ydata is None:
|
|
62
|
+
return
|
|
63
|
+
self._middle_pan_axes = event.inaxes
|
|
64
|
+
self._middle_pan_start_data = (event.xdata, event.ydata)
|
|
65
|
+
self._middle_pan_start_xlim = event.inaxes.get_xlim()
|
|
66
|
+
self._middle_pan_start_ylim = event.inaxes.get_ylim()
|
|
67
|
+
|
|
68
|
+
def _on_middle_button_motion_pan(self, event):
|
|
69
|
+
"""押したときの範囲を基準に、押した位置と今の位置の差だけずらす(差分を積み上げないので誤差がたまらない)。"""
|
|
70
|
+
axes = getattr(self, '_middle_pan_axes', None)
|
|
71
|
+
if axes is None or event.inaxes is not axes:
|
|
72
|
+
return
|
|
73
|
+
if event.xdata is None or event.ydata is None:
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
start_x, start_y = self._middle_pan_start_data
|
|
77
|
+
dx = start_x - event.xdata
|
|
78
|
+
dy = start_y - event.ydata
|
|
79
|
+
|
|
80
|
+
xlim = self._middle_pan_start_xlim
|
|
81
|
+
ylim = self._middle_pan_start_ylim
|
|
82
|
+
axes.set_xlim(xlim[0] + dx, xlim[1] + dx)
|
|
83
|
+
axes.set_ylim(ylim[0] + dy, ylim[1] + dy)
|
|
84
|
+
self._app.canvas.draw_idle()
|
|
85
|
+
|
|
86
|
+
def _on_middle_button_release_pan(self, event):
|
|
87
|
+
if event.button != 2:
|
|
88
|
+
return
|
|
89
|
+
self._middle_pan_axes = None
|
|
90
|
+
self._middle_pan_start_data = None
|
|
91
|
+
self._middle_pan_start_xlim = None
|
|
92
|
+
self._middle_pan_start_ylim = None
|
|
93
|
+
|
|
94
|
+
def _toggle_cursor_mode(self, checked: bool):
|
|
95
|
+
self.cursor_mode_enabled = checked
|
|
96
|
+
|
|
97
|
+
if checked:
|
|
98
|
+
self._app._deactivate_other_mouse_modes('cursor')
|
|
99
|
+
|
|
100
|
+
logger.debug("データカーソルモード ON")
|
|
101
|
+
self._app.coordinate_label.setText("クリックしてデータを選択")
|
|
102
|
+
|
|
103
|
+
self.cursor_connection_id = self._app.canvas.mpl_connect(
|
|
104
|
+
'pick_event', self._on_pick
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# 平滑化の曲線は元のデータの点と対応しないので、ピックできるようにしない
|
|
108
|
+
non_pickable_artists = {
|
|
109
|
+
ds.artist for ds in self._app.project.datasets
|
|
110
|
+
if ds.dataset_id in self._app.canvas._non_pickable_dataset_ids and ds.artist is not None
|
|
111
|
+
}
|
|
112
|
+
all_valid_axes = [ax for ax in self._app.all_axes + self._app.all_secondary_axes if ax is not None]
|
|
113
|
+
for ax in all_valid_axes:
|
|
114
|
+
for item in ax.get_lines() + ax.collections:
|
|
115
|
+
if item in non_pickable_artists:
|
|
116
|
+
continue
|
|
117
|
+
try:
|
|
118
|
+
item.set_picker(5)
|
|
119
|
+
except AttributeError:
|
|
120
|
+
logger.warning("オブジェクト %s は set_picker をサポートしていません。", item)
|
|
121
|
+
else:
|
|
122
|
+
logger.debug("データカーソルモード OFF")
|
|
123
|
+
self._app.coordinate_label.setText("X= ---, Y= ---")
|
|
124
|
+
|
|
125
|
+
if self.cursor_connection_id:
|
|
126
|
+
self._app.canvas.mpl_disconnect(self.cursor_connection_id)
|
|
127
|
+
self.cursor_connection_id = None
|
|
128
|
+
|
|
129
|
+
# set_picker(False) には戻さない。同じ Artist のピックはクリックでの選択(常に有効)も使っている
|
|
130
|
+
if self.cursor_annotation:
|
|
131
|
+
self.cursor_annotation.remove()
|
|
132
|
+
self.cursor_annotation = None
|
|
133
|
+
self._app.canvas.draw_idle()
|
|
134
|
+
|
|
135
|
+
def _on_mouse_move(self, event):
|
|
136
|
+
if event.inaxes:
|
|
137
|
+
ax = event.inaxes
|
|
138
|
+
x, y = event.xdata, event.ydata
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
ax_index = self._app.all_axes.index(ax)
|
|
142
|
+
ax_label = f"P{ax_index+1}: "
|
|
143
|
+
except ValueError:
|
|
144
|
+
# 第2軸は all_axes に無い
|
|
145
|
+
try:
|
|
146
|
+
sec_ax_index = self._app.all_secondary_axes.index(ax)
|
|
147
|
+
ax_label = f"P{sec_ax_index+1}(Y2): "
|
|
148
|
+
except ValueError:
|
|
149
|
+
ax_label = "?: "
|
|
150
|
+
|
|
151
|
+
self._app.coordinate_label.setText(f"{ax_label}X= {x:.4g}, Y= {y:.4g}")
|
|
152
|
+
|
|
153
|
+
else:
|
|
154
|
+
if not self.cursor_mode_enabled:
|
|
155
|
+
self._app.coordinate_label.setText("X= ---, Y= ---")
|
|
156
|
+
|
|
157
|
+
def _on_element_pick(self, event):
|
|
158
|
+
"""グラフの系列やタイトルをクリックして選ぶ(常に有効)。"""
|
|
159
|
+
# 注釈モードと自由配置の編集モードでは、クリックはそちらの操作に使う
|
|
160
|
+
if getattr(self._app, 'annotation_mode_enabled', False):
|
|
161
|
+
return
|
|
162
|
+
if getattr(self._app, 'layout_edit_mode_enabled', False):
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
artist = event.artist
|
|
166
|
+
|
|
167
|
+
# タイトルなら、そのサブプロットを編集対象にする
|
|
168
|
+
for ax_index, ax in enumerate(self._app.all_axes):
|
|
169
|
+
if artist is ax.title:
|
|
170
|
+
if self._app.active_axis_combo.currentIndex() != ax_index:
|
|
171
|
+
self._app.active_axis_combo.setCurrentIndex(ax_index)
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
# 系列なら、そのデータセットを一覧で選ぶ。Bar は BarContainer なので、patches に含まれるかで見る
|
|
175
|
+
owning_dataset = next(
|
|
176
|
+
(ds for ds in self._app.project.datasets
|
|
177
|
+
if ds.artist is artist or (hasattr(ds.artist, 'patches') and artist in ds.artist.patches)),
|
|
178
|
+
None
|
|
179
|
+
)
|
|
180
|
+
if owning_dataset is None:
|
|
181
|
+
return
|
|
182
|
+
|
|
183
|
+
item = self._app._get_dataset_tree_item(owning_dataset)
|
|
184
|
+
if item is not None and self._app.ui.dataset_list_widget.currentItem() is not item:
|
|
185
|
+
self._app.ui.dataset_list_widget.setCurrentItem(item)
|
|
186
|
+
|
|
187
|
+
def _on_pick(self, event):
|
|
188
|
+
if not self.cursor_mode_enabled: return
|
|
189
|
+
|
|
190
|
+
artist = event.artist
|
|
191
|
+
mouseevent = event.mouseevent
|
|
192
|
+
|
|
193
|
+
x, y = None, None
|
|
194
|
+
ind = None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
if hasattr(artist, 'get_offsets'):
|
|
198
|
+
if len(event.ind) > 0:
|
|
199
|
+
ind = event.ind[0]
|
|
200
|
+
x, y = artist.get_offsets()[ind]
|
|
201
|
+
|
|
202
|
+
elif hasattr(artist, 'get_xdata'):
|
|
203
|
+
xdata = artist.get_xdata()
|
|
204
|
+
ydata = artist.get_ydata()
|
|
205
|
+
|
|
206
|
+
# クリックの位置にいちばん近い点
|
|
207
|
+
distances = np.sqrt((xdata - mouseevent.xdata)**2 + (ydata - mouseevent.ydata)**2)
|
|
208
|
+
|
|
209
|
+
ind = np.argmin(distances)
|
|
210
|
+
x, y = xdata[ind], ydata[ind]
|
|
211
|
+
|
|
212
|
+
else:
|
|
213
|
+
return
|
|
214
|
+
|
|
215
|
+
if x is not None and y is not None:
|
|
216
|
+
# x, y はウォーターフォールのずらしが掛かった表示座標。矢印はそのままの位置に出し、
|
|
217
|
+
# 表示する値だけデータ座標に戻す
|
|
218
|
+
owning_dataset = next(
|
|
219
|
+
(ds for ds in self._app.project.datasets if ds.artist is artist), None
|
|
220
|
+
)
|
|
221
|
+
if owning_dataset is not None:
|
|
222
|
+
data_x, data_y = self._app.canvas.display_to_data(owning_dataset, x, y)
|
|
223
|
+
else:
|
|
224
|
+
data_x, data_y = x, y
|
|
225
|
+
|
|
226
|
+
if self.cursor_annotation:
|
|
227
|
+
self.cursor_annotation.remove()
|
|
228
|
+
self.cursor_annotation = None
|
|
229
|
+
|
|
230
|
+
ax = artist.axes
|
|
231
|
+
text = f"X: {data_x:.4g}\nY: {data_y:.4g}"
|
|
232
|
+
|
|
233
|
+
self.cursor_annotation = ax.annotate(text,
|
|
234
|
+
xy=(x, y),
|
|
235
|
+
xytext=(10, -10),
|
|
236
|
+
textcoords="offset points",
|
|
237
|
+
bbox=dict(boxstyle="round,pad=0.3", fc="yellow", alpha=0.7),
|
|
238
|
+
arrowprops=dict(arrowstyle="->", connectionstyle="arc3,rad=0.3")
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
self._app.canvas.draw_idle()
|
|
242
|
+
|
|
243
|
+
# データエディタが開いていれば、クリックした点の行を表でも選ぶ
|
|
244
|
+
if ind is not None and self._app.data_editor_dialog is not None:
|
|
245
|
+
if owning_dataset is not None and self._app.data_editor_dialog.dataset is owning_dataset:
|
|
246
|
+
try:
|
|
247
|
+
# ind は描いた点(visible_df、間引いていれば間引いた後)の位置。
|
|
248
|
+
# 間引いた系列は downsample_index_map で元の位置に戻してから visible_df.index を引く
|
|
249
|
+
index_map = self._app.canvas.downsample_index_map.get(owning_dataset.dataset_id)
|
|
250
|
+
if index_map is not None:
|
|
251
|
+
ind = index_map[ind]
|
|
252
|
+
master_index = owning_dataset.visible_df.index[ind]
|
|
253
|
+
# 選択の通知は止めてあるので、グラフ側の強調はここで更新する
|
|
254
|
+
self._app.data_editor_dialog.select_row_by_master_index(master_index)
|
|
255
|
+
self._app.canvas.set_highlighted_points(
|
|
256
|
+
owning_dataset, self._app.data_editor_dialog.get_selected_master_indices()
|
|
257
|
+
)
|
|
258
|
+
except IndexError:
|
|
259
|
+
pass
|