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.
Files changed (181) hide show
  1. graphica/Graphica.ico +0 -0
  2. graphica/__init__.py +1 -0
  3. graphica/__main__.py +111 -0
  4. graphica/assets/__init__.py +0 -0
  5. graphica/assets/icons/__init__.py +0 -0
  6. graphica/assets/icons/arrow-right.svg +21 -0
  7. graphica/assets/icons/bold.svg +20 -0
  8. graphica/assets/icons/calculator.svg +26 -0
  9. graphica/assets/icons/chart-histogram.svg +24 -0
  10. graphica/assets/icons/chart-line.svg +20 -0
  11. graphica/assets/icons/chevron-down.svg +19 -0
  12. graphica/assets/icons/chevron-right.svg +19 -0
  13. graphica/assets/icons/color-swatch.svg +22 -0
  14. graphica/assets/icons/column-insert-right.svg +21 -0
  15. graphica/assets/icons/column-remove.svg +21 -0
  16. graphica/assets/icons/copy.svg +20 -0
  17. graphica/assets/icons/download.svg +21 -0
  18. graphica/assets/icons/edit.svg +21 -0
  19. graphica/assets/icons/eye-off.svg +21 -0
  20. graphica/assets/icons/eye.svg +20 -0
  21. graphica/assets/icons/file-plus.svg +22 -0
  22. graphica/assets/icons/folder-plus.svg +21 -0
  23. graphica/assets/icons/folder.svg +19 -0
  24. graphica/assets/icons/highlight.svg +22 -0
  25. graphica/assets/icons/history.svg +20 -0
  26. graphica/assets/icons/italic.svg +21 -0
  27. graphica/assets/icons/layout-grid.svg +22 -0
  28. graphica/assets/icons/math-function.svg +22 -0
  29. graphica/assets/icons/message-2.svg +21 -0
  30. graphica/assets/icons/mountain.svg +20 -0
  31. graphica/assets/icons/palette.svg +22 -0
  32. graphica/assets/icons/pointer.svg +19 -0
  33. graphica/assets/icons/refresh.svg +20 -0
  34. graphica/assets/icons/row-insert-bottom.svg +21 -0
  35. graphica/assets/icons/row-remove.svg +21 -0
  36. graphica/assets/icons/search.svg +20 -0
  37. graphica/assets/icons/select-all.svg +35 -0
  38. graphica/assets/icons/subscript.svg +20 -0
  39. graphica/assets/icons/superscript.svg +20 -0
  40. graphica/assets/icons/table.svg +21 -0
  41. graphica/assets/icons/trash.svg +23 -0
  42. graphica/assets/icons/typography.svg +23 -0
  43. graphica/assets/icons/x.svg +20 -0
  44. graphica/core/__init__.py +0 -0
  45. graphica/core/analysis.py +1278 -0
  46. graphica/core/app_paths.py +35 -0
  47. graphica/core/axis_settings.py +128 -0
  48. graphica/core/caption_export.py +47 -0
  49. graphica/core/color_palettes.py +56 -0
  50. graphica/core/commands.py +226 -0
  51. graphica/core/cvd_simulation.py +49 -0
  52. graphica/core/dataset.py +443 -0
  53. graphica/core/diagnostics.py +105 -0
  54. graphica/core/excel_utils.py +58 -0
  55. graphica/core/fit_models.py +379 -0
  56. graphica/core/grid_data.py +128 -0
  57. graphica/core/i18n.py +44 -0
  58. graphica/core/json_utils.py +27 -0
  59. graphica/core/label_utils.py +21 -0
  60. graphica/core/methods_text.py +115 -0
  61. graphica/core/named_colors.py +126 -0
  62. graphica/core/plugin_api.py +515 -0
  63. graphica/core/plugin_context.py +167 -0
  64. graphica/core/plugin_install.py +82 -0
  65. graphica/core/plugin_manifest.py +67 -0
  66. graphica/core/plugin_testing.py +213 -0
  67. graphica/core/plugin_types.py +118 -0
  68. graphica/core/provenance.py +19 -0
  69. graphica/core/report_export.py +51 -0
  70. graphica/core/safe_eval.py +194 -0
  71. graphica/core/script_export.py +272 -0
  72. graphica/core/translations_en.py +530 -0
  73. graphica/core/unit_conversion.py +51 -0
  74. graphica/core/update_check.py +43 -0
  75. graphica/core/version.py +14 -0
  76. graphica/gui/__init__.py +0 -0
  77. graphica/gui/app_settings.py +119 -0
  78. graphica/gui/axis_bindings.py +173 -0
  79. graphica/gui/binding.py +135 -0
  80. graphica/gui/builders/__init__.py +1 -0
  81. graphica/gui/builders/axis_panel.py +431 -0
  82. graphica/gui/builders/canvas_area.py +176 -0
  83. graphica/gui/builders/common.py +120 -0
  84. graphica/gui/builders/dataset_panel.py +433 -0
  85. graphica/gui/builders/property_sections.py +208 -0
  86. graphica/gui/canvas.py +519 -0
  87. graphica/gui/color_history.py +47 -0
  88. graphica/gui/color_picker_widget.py +198 -0
  89. graphica/gui/crash_handler.py +77 -0
  90. graphica/gui/cvd_preview.py +23 -0
  91. graphica/gui/data_editor.py +761 -0
  92. graphica/gui/data_import_flow.py +355 -0
  93. graphica/gui/dataset_bindings.py +107 -0
  94. graphica/gui/dataset_style_icon.py +80 -0
  95. graphica/gui/datasets/__init__.py +1 -0
  96. graphica/gui/datasets/actions_menu.py +114 -0
  97. graphica/gui/datasets/colors.py +218 -0
  98. graphica/gui/datasets/fitting.py +70 -0
  99. graphica/gui/datasets/host.py +162 -0
  100. graphica/gui/datasets/operations/__init__.py +1 -0
  101. graphica/gui/datasets/operations/fitting.py +468 -0
  102. graphica/gui/datasets/operations/peaks.py +109 -0
  103. graphica/gui/datasets/operations/processing.py +659 -0
  104. graphica/gui/datasets/operations/runner.py +148 -0
  105. graphica/gui/datasets/operations/transfer.py +227 -0
  106. graphica/gui/datasets/order.py +310 -0
  107. graphica/gui/datasets/overlays.py +74 -0
  108. graphica/gui/datasets/peaks.py +21 -0
  109. graphica/gui/datasets/plugin_runs.py +99 -0
  110. graphica/gui/datasets/processing.py +68 -0
  111. graphica/gui/datasets/property_panel.py +538 -0
  112. graphica/gui/datasets/transfer.py +36 -0
  113. graphica/gui/detached_canvas_window.py +22 -0
  114. graphica/gui/dialogs/__init__.py +137 -0
  115. graphica/gui/dialogs/analysis.py +1322 -0
  116. graphica/gui/dialogs/app.py +1015 -0
  117. graphica/gui/dialogs/appearance.py +851 -0
  118. graphica/gui/dialogs/data_edit.py +577 -0
  119. graphica/gui/dialogs/data_import.py +679 -0
  120. graphica/gui/dialogs/export.py +433 -0
  121. graphica/gui/dock_layout.py +176 -0
  122. graphica/gui/export_preview_panel.py +321 -0
  123. graphica/gui/export_settings.py +16 -0
  124. graphica/gui/file_association.py +97 -0
  125. graphica/gui/icon_utils.py +44 -0
  126. graphica/gui/main_app_window.py +210 -0
  127. graphica/gui/main_window.py +1151 -0
  128. graphica/gui/mathtext_preview.py +168 -0
  129. graphica/gui/menu_bar.py +320 -0
  130. graphica/gui/minimap_widget.py +151 -0
  131. graphica/gui/mixins/__init__.py +0 -0
  132. graphica/gui/mixins/export_mixin.py +520 -0
  133. graphica/gui/mixins/help_mixin.py +127 -0
  134. graphica/gui/mixins/project_io_mixin.py +289 -0
  135. graphica/gui/mixins/quick_access_mixin.py +194 -0
  136. graphica/gui/mixins/ui_setup_mixin.py +206 -0
  137. graphica/gui/notify.py +61 -0
  138. graphica/gui/panels/__init__.py +43 -0
  139. graphica/gui/panels/axis_settings.py +605 -0
  140. graphica/gui/panels/dataset_tree.py +424 -0
  141. graphica/gui/plot_type_drawers.py +131 -0
  142. graphica/gui/plugin_context.py +119 -0
  143. graphica/gui/project_files.py +361 -0
  144. graphica/gui/provenance_panel.py +65 -0
  145. graphica/gui/rendering/__init__.py +1 -0
  146. graphica/gui/rendering/annotations.py +177 -0
  147. graphica/gui/rendering/appearance.py +368 -0
  148. graphica/gui/rendering/common.py +316 -0
  149. graphica/gui/rendering/data_1d.py +376 -0
  150. graphica/gui/rendering/data_2d.py +66 -0
  151. graphica/gui/residual_panel.py +62 -0
  152. graphica/gui/resources.py +16 -0
  153. graphica/gui/single_instance.py +141 -0
  154. graphica/gui/splash.py +83 -0
  155. graphica/gui/task_runner.py +41 -0
  156. graphica/gui/theme.py +864 -0
  157. graphica/gui/tools/__init__.py +56 -0
  158. graphica/gui/tools/annotation.py +275 -0
  159. graphica/gui/tools/cursor.py +259 -0
  160. graphica/gui/tools/layout_edit.py +285 -0
  161. graphica/gui/tools/manager.py +78 -0
  162. graphica/gui/tools/peak_placement.py +132 -0
  163. graphica/gui/tools/range_select.py +191 -0
  164. graphica/gui/tools/region_highlight.py +222 -0
  165. graphica/gui/tools/slice_extraction.py +200 -0
  166. graphica/gui/widget_translation.py +46 -0
  167. graphica/gui/workers.py +280 -0
  168. graphica/models/__init__.py +0 -0
  169. graphica/models/project.py +197 -0
  170. graphica/plugin/__init__.py +20 -0
  171. graphica/plugin/testing.py +57 -0
  172. graphica/sample_data/__init__.py +0 -0
  173. graphica/sample_data/cooling_curve_sample.csv +42 -0
  174. graphica/ui_main_window.py +581 -0
  175. graphica_plot-2.0.0.dist-info/METADATA +317 -0
  176. graphica_plot-2.0.0.dist-info/RECORD +181 -0
  177. graphica_plot-2.0.0.dist-info/WHEEL +5 -0
  178. graphica_plot-2.0.0.dist-info/entry_points.txt +2 -0
  179. graphica_plot-2.0.0.dist-info/licenses/LICENSE +30 -0
  180. graphica_plot-2.0.0.dist-info/licenses/THIRD_PARTY_LICENSES.md +91 -0
  181. 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