dynamic-systems-testing-framework 1.0.1__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 (185) hide show
  1. dstf/__init__.py +41 -0
  2. dstf/__main__.py +10 -0
  3. dstf/authoring/__init__.py +13 -0
  4. dstf/authoring/builtin/__init__.py +32 -0
  5. dstf/authoring/builtin/accept.py +205 -0
  6. dstf/authoring/builtin/edit_criteria.py +479 -0
  7. dstf/authoring/builtin/edit_family.py +228 -0
  8. dstf/authoring/builtin/edit_on_plot.py +1354 -0
  9. dstf/authoring/builtin/edit_tree.py +1399 -0
  10. dstf/authoring/builtin/enqueue.py +144 -0
  11. dstf/authoring/capabilities.py +463 -0
  12. dstf/authoring/errors.py +49 -0
  13. dstf/authoring/family_rows.py +526 -0
  14. dstf/authoring/forms.py +631 -0
  15. dstf/authoring/layout.py +104 -0
  16. dstf/authoring/live_rows.py +337 -0
  17. dstf/authoring/live_runs.py +385 -0
  18. dstf/authoring/needle.py +127 -0
  19. dstf/authoring/picker_rows.py +92 -0
  20. dstf/authoring/queue_rows.py +354 -0
  21. dstf/authoring/session.py +3016 -0
  22. dstf/authoring/shell/__init__.py +9 -0
  23. dstf/authoring/shell/actions.py +702 -0
  24. dstf/authoring/shell/app.py +53 -0
  25. dstf/authoring/shell/drawer_controls.py +190 -0
  26. dstf/authoring/shell/family_fold.py +468 -0
  27. dstf/authoring/shell/figure_hook.py +249 -0
  28. dstf/authoring/shell/grips.js +1000 -0
  29. dstf/authoring/shell/header.py +783 -0
  30. dstf/authoring/shell/live_view.py +93 -0
  31. dstf/authoring/shell/picker.py +267 -0
  32. dstf/authoring/shell/placing.py +122 -0
  33. dstf/authoring/shell/plot_card.py +1171 -0
  34. dstf/authoring/shell/queue_panel.py +433 -0
  35. dstf/authoring/shell/rail.py +593 -0
  36. dstf/authoring/shell/shell.py +528 -0
  37. dstf/authoring/shell/stats_card.py +501 -0
  38. dstf/authoring/shell/strip.py +204 -0
  39. dstf/authoring/shell/style.py +721 -0
  40. dstf/authoring/shell/suite.py +277 -0
  41. dstf/authoring/shell/suite_grid.js +414 -0
  42. dstf/authoring/shell/suite_grid.py +373 -0
  43. dstf/authoring/shell/tab_theme.py +110 -0
  44. dstf/authoring/shell/tabstate.py +146 -0
  45. dstf/authoring/shell/test_list.py +179 -0
  46. dstf/authoring/shell/test_page.py +1404 -0
  47. dstf/authoring/shell/tree_card.py +1171 -0
  48. dstf/authoring/shell/variables_drawer.py +589 -0
  49. dstf/authoring/shell/widgets.py +188 -0
  50. dstf/authoring/situation.py +287 -0
  51. dstf/authoring/spec_document.py +215 -0
  52. dstf/authoring/spec_preview.py +83 -0
  53. dstf/authoring/suite_rows.py +99 -0
  54. dstf/authoring/tree_move.py +516 -0
  55. dstf/authoring/tree_pointer.py +33 -0
  56. dstf/authoring/tree_text.py +61 -0
  57. dstf/authoring/typed_number.js +64 -0
  58. dstf/authoring/typed_number.py +76 -0
  59. dstf/authoring/unscorable.py +141 -0
  60. dstf/authoring/variable_rows.py +216 -0
  61. dstf/backends/__init__.py +283 -0
  62. dstf/backends/base.py +1117 -0
  63. dstf/backends/common/__init__.py +1 -0
  64. dstf/backends/common/json_driver.py +484 -0
  65. dstf/backends/common/live_mat.py +369 -0
  66. dstf/backends/common/mat_reader.py +632 -0
  67. dstf/backends/common/proc_kill.py +238 -0
  68. dstf/backends/common/proc_output.py +20 -0
  69. dstf/backends/common/result_files.py +109 -0
  70. dstf/backends/common/result_variables.py +329 -0
  71. dstf/backends/common/statistics.py +39 -0
  72. dstf/backends/cross_backend.py +193 -0
  73. dstf/backends/dymola/__init__.py +5 -0
  74. dstf/backends/dymola/backend.py +776 -0
  75. dstf/backends/dymola/interface_loader.py +241 -0
  76. dstf/backends/dymola/log_parser.py +396 -0
  77. dstf/backends/dymola/persistent_backend.py +949 -0
  78. dstf/backends/fmpy/__init__.py +11 -0
  79. dstf/backends/fmpy/backend.py +443 -0
  80. dstf/backends/julia/__init__.py +27 -0
  81. dstf/backends/julia/backend.py +156 -0
  82. dstf/backends/julia/persistent_backend.py +541 -0
  83. dstf/backends/julia/run_persistent.jl +167 -0
  84. dstf/backends/julia/run_test.jl +146 -0
  85. dstf/backends/openmodelica/__init__.py +21 -0
  86. dstf/backends/openmodelica/backend.py +368 -0
  87. dstf/backends/openmodelica/init_xml.py +76 -0
  88. dstf/backends/openmodelica/log_parser.py +119 -0
  89. dstf/backends/openmodelica/mos_generator.py +228 -0
  90. dstf/backends/openmodelica/persistent_backend.py +1045 -0
  91. dstf/backends/openmodelica/session_loader.py +63 -0
  92. dstf/backends/persistent.py +1811 -0
  93. dstf/backends/pool.py +602 -0
  94. dstf/backends/progress.py +395 -0
  95. dstf/backends/python/__init__.py +11 -0
  96. dstf/backends/python/backend.py +141 -0
  97. dstf/backends/python/run_test.py +141 -0
  98. dstf/backends/run_metadata.py +103 -0
  99. dstf/backends/types.py +310 -0
  100. dstf/cli/__init__.py +12 -0
  101. dstf/cli/author_cmd.py +61 -0
  102. dstf/cli/baseline_cmds.py +341 -0
  103. dstf/cli/common.py +231 -0
  104. dstf/cli/diag_cmds.py +350 -0
  105. dstf/cli/main.py +561 -0
  106. dstf/cli/review.py +398 -0
  107. dstf/cli/run_cmds.py +589 -0
  108. dstf/cli/spec_cmds.py +123 -0
  109. dstf/comparison/__init__.py +13 -0
  110. dstf/comparison/algorithms.py +2127 -0
  111. dstf/comparison/builtins.py +291 -0
  112. dstf/comparison/comparator.py +1189 -0
  113. dstf/comparison/geometry.py +164 -0
  114. dstf/comparison/handles.py +1194 -0
  115. dstf/comparison/metric_tree.py +332 -0
  116. dstf/comparison/mode_schema.py +176 -0
  117. dstf/comparison/modes.py +788 -0
  118. dstf/comparison/registry.py +230 -0
  119. dstf/comparison/seeds.py +281 -0
  120. dstf/comparison/sides.py +158 -0
  121. dstf/comparison/snap.py +62 -0
  122. dstf/comparison/strict_keys.py +246 -0
  123. dstf/comparison/summaries.py +354 -0
  124. dstf/comparison/tree_eval.py +868 -0
  125. dstf/comparison/tree_spec.py +678 -0
  126. dstf/comparison/types.py +194 -0
  127. dstf/comparison/validator.py +232 -0
  128. dstf/config.py +815 -0
  129. dstf/discovery/__init__.py +0 -0
  130. dstf/discovery/json_recognizer.py +702 -0
  131. dstf/discovery/library_state.py +248 -0
  132. dstf/discovery/mo_parser.py +794 -0
  133. dstf/discovery/patch_apply.py +308 -0
  134. dstf/discovery/patch_envelope.py +63 -0
  135. dstf/discovery/plots.py +318 -0
  136. dstf/discovery/recognizer.py +103 -0
  137. dstf/discovery/scan_cache.py +154 -0
  138. dstf/discovery/spec_parser.py +538 -0
  139. dstf/discovery/spec_shape.py +122 -0
  140. dstf/discovery/spec_update.py +493 -0
  141. dstf/discovery/test_registry.py +488 -0
  142. dstf/discovery/unit_tests.py +95 -0
  143. dstf/figures/__init__.py +14 -0
  144. dstf/figures/assemble.py +48 -0
  145. dstf/figures/decimate.py +150 -0
  146. dstf/figures/families.py +289 -0
  147. dstf/figures/palette.py +170 -0
  148. dstf/figures/plotly.py +1413 -0
  149. dstf/figures/view.py +233 -0
  150. dstf/pipeline.py +435 -0
  151. dstf/plugins.py +104 -0
  152. dstf/reporting/__init__.py +0 -0
  153. dstf/reporting/console_report.py +187 -0
  154. dstf/reporting/context_builder.py +979 -0
  155. dstf/reporting/dashboard_render.py +534 -0
  156. dstf/reporting/junit_report.py +192 -0
  157. dstf/reporting/overlay_loader.py +708 -0
  158. dstf/reporting/report_suite.py +1059 -0
  159. dstf/reporting/rows.py +458 -0
  160. dstf/reporting/schema_export.py +413 -0
  161. dstf/reporting/stats_rows.py +321 -0
  162. dstf/reporting/status.py +388 -0
  163. dstf/reporting/templates/axes.js +61 -0
  164. dstf/reporting/templates/dashboard.html +284 -0
  165. dstf/reporting/templates/dashboard.js +604 -0
  166. dstf/reporting/templates/filters.js +100 -0
  167. dstf/reporting/templates/interactive.html +420 -0
  168. dstf/reporting/templates/report.js +116 -0
  169. dstf/reporting/templates/vendor/plotly.min.js +8 -0
  170. dstf/reporting/theme.py +324 -0
  171. dstf/reporting/tree_rows.py +73 -0
  172. dstf/reporting/verdicts.py +124 -0
  173. dstf/storage/__init__.py +0 -0
  174. dstf/storage/accept.py +216 -0
  175. dstf/storage/baseline.py +43 -0
  176. dstf/storage/batch_manifest.py +204 -0
  177. dstf/storage/ref_index.py +295 -0
  178. dstf/storage/reference_store.py +661 -0
  179. dstf/storage/roles.py +576 -0
  180. dstf/storage/run_stats.py +85 -0
  181. dynamic_systems_testing_framework-1.0.1.dist-info/METADATA +266 -0
  182. dynamic_systems_testing_framework-1.0.1.dist-info/RECORD +185 -0
  183. dynamic_systems_testing_framework-1.0.1.dist-info/WHEEL +4 -0
  184. dynamic_systems_testing_framework-1.0.1.dist-info/entry_points.txt +2 -0
  185. dynamic_systems_testing_framework-1.0.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1000 @@
1
+ // The Test page's tolerance grips (D147): the arrows just outside each side
2
+ // of the open criterion's tolerance are hidden at rest and shown while the
3
+ // pointer is near their checkpoint, event or peak, and through a drag. And
4
+ // the plot's pointer: every shape no editor moves is made inert.
5
+ //
6
+ // WHICH arrows show is decided by CLUSTERS. Each group — one checkpoint,
7
+ // event or peak: its ring and its arrows — has a hover area, a rectangle
8
+ // around its ring and its arrows' anchors, those of them that are ON the
9
+ // plot (`placed`): a group zoomed off it has none. Groups whose areas
10
+ // overlap show TOGETHER: they are one cluster, and with the pointer
11
+ // anywhere in a cluster's areas every arrow of every member is shown;
12
+ // outside every area, none. No two clusters' areas overlap, so the pointer
13
+ // is in at most one and there is nothing to choose between: no nearest
14
+ // group, nothing kept from where the pointer was. (ONE group was shown at
15
+ // a time before,
16
+ // chosen by the nearest anchor, then by the arrows' drawn tips too, then
17
+ // with an advantage for the shown one: at some spacings a facing arrow
18
+ // still could not be reached, the display flickered between two groups,
19
+ // and a press where a hidden arrow belonged moved the neighbour's ring.)
20
+ //
21
+ // WHERE a shown arrow is drawn is its figure's to say, but for two arrows of
22
+ // one cluster that would be drawn over each other: those are STAGGERED, each
23
+ // moved a step across its own axis (`stagger`), so that every arrow shown
24
+ // can be seen and pressed.
25
+ //
26
+ // The figure never changes with the hover: a drag names a shape by INDEX, and
27
+ // a figure changed mid-drag loses it. Hiding is one static rule of the shell's
28
+ // stylesheet (style.py) over the paths this module marks —
29
+ // .js-plotly-plot .shapelayer path[data-grip]:not([data-grip-on])
30
+ // — `display: none`, because Plotly writes inline `opacity: 1; pointer-events:
31
+ // all` on every editable shape: hidden by `visibility` or `opacity` a grip is
32
+ // still grabbed. Where there is no hover (a touch screen) the same stylesheet
33
+ // shows every marked grip, and nothing here is needed. And an arrow whose
34
+ // edge is OFF the plot (`data-grip-off`) is not displayed at all, by the
35
+ // same stylesheet: one rule for an edge that is not on the plot — no arrow.
36
+ //
37
+ // WHICH shape is a grip, of which group, and which shapes may be dragged at
38
+ // all is the SERVER's to say: it rides in the figure itself
39
+ // (`layout.meta.dstf`, figures/plotly.py `POINTER`), so it is read off the
40
+ // very figure whose paths are being marked — the two cannot be out of step —
41
+ // and nothing here knows a metric's id pattern. A shape the server does not
42
+ // name draggable gets `data-inert` (`pointer-events: none`, style.py): a
43
+ // press on a box, a window or a result line is the plot's own.
44
+ //
45
+ // One instance per plot card (`DstfGrips.install(id)` / `.destroy(id)`, the
46
+ // card's `ui.plotly` element id — plot_card.py): its own clusters, its own
47
+ // pointer, its own latch.
48
+ //
49
+ // And what the pointer SAYS to the server beside Plotly's own events (D149),
50
+ // since this module already holds the press, the travel and the ranges:
51
+ //
52
+ // * a CLICK on the plot (`dstf_plot_click`) — where, in the axes of the
53
+ // panel under the pointer, what those axes showed, and the modifier keys.
54
+ // Plotly's own click event carries neither a position nor a key through
55
+ // NiceGUI. The server decides what a click is: Ctrl+click adds a row, a
56
+ // plain one sets an armed field;
57
+ // * a handle HELD (`dstf_grip_move`) — where its release would put it, a
58
+ // few times a second, so the editor's fields follow the pointer. Plotly
59
+ // says nothing during a shape drag;
60
+ // * Esc while a field is armed (`dstf_disarm`).
61
+ (() => {
62
+ if (window.DstfGrips) return;
63
+
64
+ // How far a group's hover area reaches outside its ring and its arrows'
65
+ // anchors, px: an arrow is drawn 10 to 17 px outside its anchor
66
+ // (figures/plotly.py `_GRIP_PATHS`), whole inside the area.
67
+ const MARGIN = 25;
68
+ // How near a shown arrow, or the ring its group stands around, the plot's
69
+ // own hover label gives way, px.
70
+ const HUSH = 20;
71
+ // How far outside the plot area an anchor still counts as ON the plot,
72
+ // px. A zoom lands an edge within a fraction of a pixel of the border it
73
+ // was dragged to, on either side of it: that edge is the plot's own, and
74
+ // its arrow — all in the margin — is the one the un-clip is for.
75
+ const EDGE = 0.5;
76
+ // Two arrows COLLIDE when what is painted of them shares more than this
77
+ // on BOTH axes, px. An arrow is 12 px across and its 1 px outline stands
78
+ // half a pixel outside it: two value arrows 12 px apart touch corner to
79
+ // corner and share that one pixel of outline. At 0 that is a collision:
80
+ // of thirty checkpoints 12 px apart every value arrow was moved, left
81
+ // and right by turns, each ONTO its neighbour — 28 pairs colliding where
82
+ // none had.
83
+ const OVERLAP = 1;
84
+ // How far a colliding arrow is moved ACROSS its own axis, px a step, and
85
+ // the steps it may take, in the order it tries them. One step each way
86
+ // parts a pair by 18 px, 5 px clear of each other; a third on the same
87
+ // spot clears both at three. The stylesheet (style.py) turns the steps
88
+ // into pixels: the same 9.
89
+ const STEP = 9;
90
+ const LEVELS = [-1, 1, -3, 3];
91
+ // How far across its axis an arrow must already stand from those it
92
+ // collides with to be moved THAT way first, px: a pair on a slope parts
93
+ // the way it leans.
94
+ const LEAN = 0.5;
95
+ // How far the pointer must travel along an axis, press to release, for
96
+ // the release to move a shape on it, px — Plotly's own threshold (its
97
+ // `MINDRAG`): under it Plotly moves nothing on that axis.
98
+ const MOVED = 8;
99
+ // How far the pointer may travel, press to click, for the click to be a
100
+ // click, px: a Ctrl+drag (Plotly's zoom box under a held key) ends in a
101
+ // real click where it is let go.
102
+ const CLICKED = 4;
103
+ // The least time between two moves of a held handle sent to the server,
104
+ // ms: at most 15 a second, each a preview the server computes.
105
+ const PACE = 66;
106
+ // Every move and every release is numbered, rising, page-wide: the
107
+ // server drops a move numbered under a release it has heard (a preview
108
+ // must not land after what the release staged). From the clock, so a
109
+ // reloaded page's numbers are above the last page's.
110
+ let seq = Date.now();
111
+
112
+ const own = (map, name) =>
113
+ map && Object.prototype.hasOwnProperty.call(map, name) ? map[name] : null;
114
+ const dict = (v) => (v !== null && typeof v === "object" && !Array.isArray(v) ? v : null);
115
+
116
+ // What the server says of the figure `gd` holds — its pointer map — or
117
+ // null for a figure that says nothing: no `meta`, another's, a `dstf`
118
+ // that is no dict. Then nothing is marked and every shape is left as
119
+ // Plotly draws it. A map is read key by key, each with a default: a
120
+ // figure's `meta` crosses Plotly, where an empty dict need not survive,
121
+ // and a `draggable` iterated as it came threw in the redraw's observer
122
+ // when it was no list — after which nothing was marked at all.
123
+ const said = (gd) => {
124
+ const meta = dict(gd.layout && gd.layout.meta);
125
+ const info = meta && dict(own(meta, "dstf"));
126
+ if (!info) return null;
127
+ return {
128
+ grips: dict(info.grips) || {},
129
+ targets: dict(info.targets) || {},
130
+ draggable: new Set(Array.isArray(info.draggable) ? info.draggable : []),
131
+ };
132
+ };
133
+ // What a map says of the grip shape named `name` — a dict naming its
134
+ // group — or null; and of the ring named `name`, its group or null.
135
+ const gripOf = (info, name) => {
136
+ const grip = info ? dict(own(info.grips, name)) : null;
137
+ return grip && typeof grip.group === "string" ? grip : null;
138
+ };
139
+ const ringOf = (info, name) => {
140
+ const group = info ? own(info.targets, name) : null;
141
+ return typeof group === "string" ? group : null;
142
+ };
143
+
144
+ const instances = new Map();
145
+
146
+ // Where the pointer is on the PAGE — viewport px, or null once it has
147
+ // left the window — kept once, for every plot: a plot hears the moves
148
+ // over itself alone, and one wheeled under a pointer that had never
149
+ // moved over it had no pointer to test (`scrolled`).
150
+ let pointer = null;
151
+ const tracked = (e) => {
152
+ pointer = [e.clientX, e.clientY];
153
+ };
154
+ document.addEventListener("mousemove", tracked, true);
155
+ document.documentElement.addEventListener("mouseleave", () => {
156
+ pointer = null;
157
+ });
158
+
159
+ // Esc while a field is armed disarms it. The armed field is the
160
+ // server's to know — its ⌖ says so in `aria-pressed`, which the server
161
+ // renders — so this only asks; Esc with nothing armed is everyone
162
+ // else's.
163
+ document.addEventListener("keydown", (e) => {
164
+ if (e.key === "Escape" && document.querySelector('.dstf-arm[aria-pressed="true"]')) {
165
+ emitEvent("dstf_disarm");
166
+ }
167
+ }, true);
168
+
169
+ function create(gd, id) {
170
+ const st = {
171
+ gd, clusters: [], on: null, latched: false, xy: null, ranges: {}, press: null,
172
+ travel: null, failed: false, frame: 0, held: null, ended: null,
173
+ };
174
+
175
+ // An arrow says what it sets — a tooltip, the SVG `title` child — in
176
+ // the server's words, and carries the axis it moves on for the
177
+ // stylesheet's cursor.
178
+ const SVG = "http://www.w3.org/2000/svg";
179
+ const tell = (el, grip) => {
180
+ el.setAttribute("data-grip-axis", grip.axis);
181
+ let title = el.querySelector("title");
182
+ if (!title) title = el.appendChild(document.createElementNS(SVG, "title"));
183
+ if (title.textContent !== grip.label) title.textContent = grip.label || "";
184
+ };
185
+ const untell = (el) => {
186
+ el.removeAttribute("data-grip-axis");
187
+ for (const title of el.querySelectorAll("title")) title.remove();
188
+ };
189
+
190
+ // Whether the grip path `el` is of the shown cluster.
191
+ const shown = (el) => !!st.on && st.on.groups.has(el.getAttribute("data-grip"));
192
+
193
+ // Where the shape `s` — a ring or an arrow, sized in pixels about its
194
+ // anchor — is anchored, in pixels of the graph div, as the figure is
195
+ // drawn NOW (`_fullLayout`: the axes' ranges of this very redraw, a
196
+ // pan's every step too), or null where it is on no axis of the figure.
197
+ // An arrow anchored to the PAPER in y (an event's or a peak's window
198
+ // spans its panel) is drawn at a height of the paper, `y`, and stands
199
+ // for no value there: it is `paper`, and it reaches from the panel's
200
+ // `top` to its `bottom`.
201
+ //
202
+ // And whether that anchor is OFF the plot: outside the plot area by
203
+ // more than `allowed` px on either axis. For an arrow that is EDGE px
204
+ // (the default), on the axis it moves on (its edge is past the end of
205
+ // the axis) or the other (its checkpoint is); a ring is allowed its
206
+ // radius (`areas`).
207
+ const placed = (s, fl, allowed = EDGE) => {
208
+ const xa = fl["xaxis" + (s.xref || "x").slice(1)];
209
+ if (!xa) return null;
210
+ const paper = s.yref === "paper";
211
+ const ya = fl["yaxis" + (paper ? xa.anchor : s.yref || "y").slice(1)];
212
+ if (!ya) return null;
213
+ const within = (p, axis) => p >= -allowed && p <= axis._length + allowed;
214
+ const px = xa.d2p(s.xanchor);
215
+ const py = paper ? null : ya.d2p(s.yanchor);
216
+ const size = fl._size;
217
+ return {
218
+ x: xa._offset + px,
219
+ y: paper ? size.t + size.h * (1 - s.yanchor) : ya._offset + py,
220
+ paper,
221
+ top: ya._offset,
222
+ bottom: ya._offset + ya._length,
223
+ off: !(within(px, xa) && (paper || within(py, ya))),
224
+ };
225
+ };
226
+
227
+ // The points of the path the figure sizes shape `s` by, in pixels
228
+ // about its anchor (positive y is UP) — an arrow's corners, a ring's
229
+ // arcs with their control points.
230
+ const points = (s) => {
231
+ const n = (typeof s.path === "string" && s.path.match(/-?\d*\.?\d+/g)) || [];
232
+ const out = [];
233
+ for (let k = 0; k + 1 < n.length; k += 2) out.push([+n[k], +n[k + 1]]);
234
+ return out;
235
+ };
236
+
237
+ // What is PAINTED of the arrow `s` anchored at `at` (`placed`), as a
238
+ // box in pixels of the graph div: its path about that anchor, and the
239
+ // half of its outline's width that stands outside it. Or null for a
240
+ // path that says no point.
241
+ const painted = (s, at) => {
242
+ const half = ((dict(s.line) || {}).width || 0) / 2;
243
+ const box = { l: Infinity, r: -Infinity, t: Infinity, b: -Infinity };
244
+ for (const [dx, dy] of points(s)) {
245
+ box.l = Math.min(box.l, at.x + dx - half);
246
+ box.r = Math.max(box.r, at.x + dx + half);
247
+ box.t = Math.min(box.t, at.y - dy - half);
248
+ box.b = Math.max(box.b, at.y - dy + half);
249
+ }
250
+ return box.l <= box.r ? box : null;
251
+ };
252
+
253
+ // How far the ring `s` reaches from its anchor, px: its radius.
254
+ const radius = (s) => Math.max(0, ...points(s).flat().map(Math.abs));
255
+
256
+ // Whether the LINE `s` — a bound an editor moves — is off the plot
257
+ // whole: both its ends past the same end of an axis, by more than EDGE
258
+ // px. Plotly clips what it draws of a line and not the wide helper
259
+ // that takes its drag: a scalar's bound zoomed 3 or 12 px below the
260
+ // plot was still what the pointer met in the bottom margin, with the
261
+ // `move` cursor, and a drag from there staged a tolerance.
262
+ const gone = (s, fl) => {
263
+ if (s.type !== "line" || !fl) return false;
264
+ const past = (key, v0, v1) => {
265
+ const ref = s[key + "ref"] || key;
266
+ const axis = /^[xy]\d*$/.test(ref) ? fl[key + "axis" + ref.slice(1)] : null;
267
+ if (!axis) return false;
268
+ const p = [axis.d2p(v0), axis.d2p(v1)];
269
+ return Math.max(...p) < -EDGE || Math.min(...p) > axis._length + EDGE;
270
+ };
271
+ return past("x", s.x0, s.x1) || past("y", s.y0, s.y1);
272
+ };
273
+
274
+ // Plotly replaces every shape path on a react or a newPlot: the marks go
275
+ // with them, and are put back from what the figure says of its shapes.
276
+ // A shape is three elements of one `data-index` — its group, its path
277
+ // and, for a line, the wide helper that takes its drag: all three inert
278
+ // — a shape no editor moves, and a line one does that is off the plot
279
+ // (`gone`).
280
+ //
281
+ // The mark on a grip's path is also what takes Plotly's clip off it:
282
+ // an arrow stands 10 to 17 px OUTSIDE its edge, in the margin when that
283
+ // edge is the plot's, and the stylesheet's rules over `data-grip`
284
+ // (style.py) un-clip the path and its group. Nothing here touches a
285
+ // `clip-path`.
286
+ //
287
+ // An arrow whose anchor is OFF the plot (`placed`) is marked
288
+ // `data-grip-off`, and the stylesheet does not display it, shown group
289
+ // or not. Un-clipped like the rest, the arrows of a checkpoint zoomed
290
+ // just off the plot were painted in the margin, about a ring that was
291
+ // not drawn, and took a press there; left Plotly's clip and no more,
292
+ // an arrow drawn INWARD from an edge 2 px off the panel (the
293
+ // spectrum's lower one, next to 0 Hz) was painted whole inside it,
294
+ // where the pointer could not come, and one beside a border its
295
+ // checkpoint was just past left a sliver on the border. The mark is
296
+ // this pass's, like the others: of the figure as this redraw draws it.
297
+ //
298
+ // Returns the arrows that are ON the plot, each with what is painted
299
+ // of it (`painted`), for `stagger`.
300
+ const mark = () => {
301
+ const shapes = (gd.layout && gd.layout.shapes) || [];
302
+ const info = said(gd);
303
+ const fl = gd._fullLayout;
304
+ const arrows = [];
305
+ for (const el of gd.querySelectorAll(".shapelayer [data-index]")) {
306
+ const shape = shapes[+el.getAttribute("data-index")] || {};
307
+ el.toggleAttribute(
308
+ "data-inert",
309
+ !!info && (!info.draggable.has(shape.name) || gone(shape, fl)),
310
+ );
311
+ if (el.tagName !== "path") continue;
312
+ const grip = gripOf(info, shape.name);
313
+ if (grip) {
314
+ const at = fl ? placed(shape, fl) : null;
315
+ el.setAttribute("data-grip", grip.group);
316
+ el.toggleAttribute("data-grip-on", shown(el));
317
+ el.toggleAttribute("data-grip-off", !!at && at.off);
318
+ el.removeAttribute("data-grip-shift");
319
+ tell(el, grip);
320
+ const box = at && !at.off ? painted(shape, at) : null;
321
+ if (box) {
322
+ arrows.push({
323
+ el, group: grip.group, axis: grip.axis,
324
+ index: +el.getAttribute("data-index"), x: at.x, y: at.y, ...box,
325
+ });
326
+ }
327
+ } else if (el.hasAttribute("data-grip")) {
328
+ el.removeAttribute("data-grip");
329
+ el.removeAttribute("data-grip-on");
330
+ el.removeAttribute("data-grip-off");
331
+ el.removeAttribute("data-grip-shift");
332
+ untell(el);
333
+ }
334
+ }
335
+ return arrows;
336
+ };
337
+
338
+ // The marks, taken off: every shape as Plotly draws it, clipped to
339
+ // the plot area.
340
+ const unmark = () => {
341
+ for (const el of gd.querySelectorAll("[data-grip]")) {
342
+ el.removeAttribute("data-grip");
343
+ el.removeAttribute("data-grip-on");
344
+ el.removeAttribute("data-grip-off");
345
+ el.removeAttribute("data-grip-shift");
346
+ untell(el);
347
+ }
348
+ for (const el of gd.querySelectorAll("[data-inert]")) {
349
+ el.removeAttribute("data-inert");
350
+ }
351
+ gd.removeAttribute("data-grip-near");
352
+ };
353
+
354
+ // A pass over the plot that throws must not throw where it runs — in
355
+ // the observer of every redraw, in the page's own mouse events — and
356
+ // again on every redraw and every move after: it is said ONCE on the
357
+ // console, and every shape is left as Plotly draws it, none inert and
358
+ // none hidden, until a pass goes through.
359
+ const guarded = (pass) => (...args) => {
360
+ try {
361
+ return pass(...args);
362
+ } catch (err) {
363
+ st.on = null;
364
+ st.clusters = [];
365
+ try {
366
+ unmark();
367
+ } catch (_) {
368
+ // What could not be read cannot be unmarked either.
369
+ }
370
+ if (!st.failed) {
371
+ st.failed = true;
372
+ console.error(
373
+ `DstfGrips: plot ${gd.id}: its shapes could not be marked and are ` +
374
+ `left as Plotly draws them — ${err}`,
375
+ );
376
+ }
377
+ return undefined;
378
+ }
379
+ };
380
+
381
+ const show = (cluster) => {
382
+ if (cluster === st.on) return;
383
+ st.on = cluster;
384
+ for (const el of gd.querySelectorAll(".shapelayer path[data-grip]")) {
385
+ el.toggleAttribute("data-grip-on", shown(el));
386
+ }
387
+ };
388
+
389
+ // Each group's hover area, in pixels of the graph div: the bounding box
390
+ // of the ring it stands around and of its grips' anchors, grown by
391
+ // MARGIN on every side — with the rings, for the hover label (`hush`).
392
+ // A grip anchored to the PAPER in y (an event's or a peak's window
393
+ // spans its panel) has no y of its own: it reaches over the panel's
394
+ // whole height. A ring whose group has no grip has no area.
395
+ //
396
+ // Of the members that are ON the plot (`placed`) alone: an arrow whose
397
+ // anchor is off it is not displayed (`mark`). A RING is on the plot
398
+ // while any of it is drawn — its centre within its own radius of the
399
+ // plot area: Plotly clips it to the area, and what is left of it has
400
+ // the `move` cursor and drags; by its centre alone, a ring 3 px past
401
+ // the border showed 4 px, dragged, and showed no arrow. A group
402
+ // partly on the plot has the area of what is on it — and shows the
403
+ // arrows whose own edges are; one that is off it whole — its ring and
404
+ // every arrow — has none, and nothing shows for it wherever the
405
+ // pointer is.
406
+ const areas = () => {
407
+ const fl = gd._fullLayout;
408
+ const out = new Map();
409
+ if (!fl) return out;
410
+ const info = said(gd);
411
+ const gripped = new Set();
412
+ const rings = [];
413
+ // Group `g`'s area, grown to hold x from t to b.
414
+ const reach = (g, x, t, b) => {
415
+ const a = out.get(g) || { l: x, r: x, t, b, rings: [] };
416
+ a.l = Math.min(a.l, x);
417
+ a.r = Math.max(a.r, x);
418
+ a.t = Math.min(a.t, t);
419
+ a.b = Math.max(a.b, b);
420
+ out.set(g, a);
421
+ return a;
422
+ };
423
+ for (const s of (gd.layout && gd.layout.shapes) || []) {
424
+ if (s.visible === false) continue;
425
+ const grip = gripOf(info, s.name);
426
+ const g = grip ? grip.group : ringOf(info, s.name);
427
+ if (g === null) continue;
428
+ const at = placed(s, fl, grip ? EDGE : radius(s));
429
+ if (!at) continue;
430
+ if (grip) gripped.add(g);
431
+ if (at.off) continue;
432
+ if (!grip) {
433
+ if (!at.paper) rings.push([g, at.x, at.y]);
434
+ } else if (at.paper) {
435
+ reach(g, at.x, at.top, at.bottom);
436
+ } else {
437
+ reach(g, at.x, at.y, at.y);
438
+ }
439
+ }
440
+ for (const [g, x, y] of rings) {
441
+ if (gripped.has(g)) reach(g, x, y, y).rings.push([x, y]);
442
+ }
443
+ // To the hundredth of a pixel, the precision Plotly places a shape
444
+ // at (its `l2p`): two areas that touch on the screen touch here too,
445
+ // whatever an axis' offset adds in the last bit of a sum.
446
+ const px = (v) => Math.round(v * 100) / 100;
447
+ for (const a of out.values()) {
448
+ a.l = px(a.l - MARGIN);
449
+ a.r = px(a.r + MARGIN);
450
+ a.t = px(a.t - MARGIN);
451
+ a.b = px(a.b + MARGIN);
452
+ }
453
+ return out;
454
+ };
455
+
456
+ // The clusters: groups whose areas overlap — touching counts — are of
457
+ // one, and so on through every group between (three in a row, each
458
+ // overlapping the next, are one). A cluster's own area is the UNION of
459
+ // its members' rectangles, never their bounding box: an L of two shows
460
+ // nothing from its empty corner. In PIXELS of the figure as it is
461
+ // drawn, so they are found again wherever the shapes are drawn again
462
+ // (`remark`) — a zoom parts a cluster, a dragged anchor joins two —
463
+ // and never on a move of the pointer. `hover` is `areas()`'s.
464
+ const clustered = (hover) => {
465
+ const meet = (p, q) => p.l <= q.r && q.l <= p.r && p.t <= q.b && q.t <= p.b;
466
+ let clusters = [];
467
+ for (const [g, area] of hover) {
468
+ const joined = { groups: new Set([g]), areas: [area], rings: [...area.rings] };
469
+ const apart = [];
470
+ // Every cluster the new area overlaps becomes one with it.
471
+ for (const c of clusters) {
472
+ if (!c.areas.some((other) => meet(other, area))) {
473
+ apart.push(c);
474
+ continue;
475
+ }
476
+ for (const member of c.groups) joined.groups.add(member);
477
+ joined.areas.push(...c.areas);
478
+ joined.rings.push(...c.rings);
479
+ }
480
+ clusters = [...apart, joined];
481
+ }
482
+ return clusters;
483
+ };
484
+
485
+ // STAGGER. A cluster shows all its members' arrows at once, and two of
486
+ // them can be drawn on one spot: with the facing edges of two
487
+ // neighbours 20 to 34 px apart at one height — two event windows
488
+ // always are at one — the `after` arrow of the first and the `before`
489
+ // of the second were one triangle on the screen, the later-drawn took
490
+ // the press, and a press on what looked like the first's arrow moved
491
+ // the second's side. The rule:
492
+ //
493
+ // 1. Two arrows COLLIDE when both are on the plot, are of DIFFERENT
494
+ // groups of ONE cluster, and what is painted of them (`painted`)
495
+ // shares more than OVERLAP px on both axes. (Arrows of two clusters
496
+ // are never shown together, and never meet: by 4 each is inside its
497
+ // own group's area, and two clusters' areas do not overlap.)
498
+ // 2. An arrow that collides is moved ACROSS its own axis — a time or a
499
+ // frequency arrow up or down, a value arrow left or right — by
500
+ // whole steps of STEP px. In order along their axis (then as
501
+ // drawn), each takes the first of LEVELS — one step up (left), one
502
+ // down (right), three up, three down — at which it clears every
503
+ // arrow already placed: those that collide with nothing, where the
504
+ // figure draws them, and those moved before it. One that already
505
+ // stands LEAN px or more below (right of) the arrows it collides
506
+ // with tries down (right) first: a pair parts the way it leans.
507
+ // 3. Found here, after every redraw, with the marks and the clusters
508
+ // (`remark`), and never on a move of the pointer: what is drawn
509
+ // with the pointer near is what was there before it came.
510
+ // 4. A moved arrow stays REACHABLE: as drawn it lies inside its own
511
+ // group's hover area, so the pointer on its way from the ring to
512
+ // it never leaves the area and hides it. A level that would put any
513
+ // of it outside is not one it may take: three steps is 33.5 px from
514
+ // the arrow's own line and an area reaches 25 px past its group's
515
+ // anchors, so a group that is flat across the arrow's axis — a
516
+ // checkpoint whose value tolerance is under 8.5 px, for a time
517
+ // arrow — has one step each way and no more.
518
+ // 5. An arrow that clears at no level it may take is left at the first
519
+ // it tried, still colliding: the third arrow on one spot where its
520
+ // group is flat, the fifth on one spot anywhere.
521
+ //
522
+ // The move is an ATTRIBUTE of the path, `data-grip-shift`, the steps
523
+ // as a number; the stylesheet (style.py) makes it a `transform`. So
524
+ // the path Plotly drags is the one it drew: held, it follows the
525
+ // pointer by the pointer's own travel, and a release says its edge
526
+ // where the pointer took it, never the step it is drawn aside by.
527
+ // `arrows` is `mark()`'s, `hover` `areas()`'s.
528
+ const stagger = (arrows, hover) => {
529
+ // To the hundredth of a pixel, as an area's edge is (`areas`).
530
+ const shared = (lo, hi) => Math.round((hi - lo) * 100) / 100;
531
+ const collide = (p, q) =>
532
+ shared(Math.max(p.l, q.l), Math.min(p.r, q.r)) > OVERLAP &&
533
+ shared(Math.max(p.t, q.t), Math.min(p.b, q.b)) > OVERLAP;
534
+ const across = (a) => (a.axis === "x" ? a.y : a.x);
535
+ const along = (a) => (a.axis === "x" ? a.x : a.y);
536
+ // Arrow `a` as it is painted `k` steps across its axis.
537
+ const moved = (a, k) =>
538
+ a.axis === "x"
539
+ ? { ...a, t: a.t + k * STEP, b: a.b + k * STEP }
540
+ : { ...a, l: a.l + k * STEP, r: a.r + k * STEP };
541
+ const inside = (box, area) =>
542
+ box.l >= area.l && box.r <= area.r && box.t >= area.t && box.b <= area.b;
543
+ for (const cluster of st.clusters) {
544
+ const members = arrows.filter((a) => cluster.groups.has(a.group));
545
+ // Per colliding arrow, those it collides with.
546
+ const rivals = new Map();
547
+ const rival = (a, other) => rivals.set(a, [...(rivals.get(a) || []), other]);
548
+ members.forEach((p, i) => {
549
+ for (const q of members.slice(i + 1)) {
550
+ if (p.group === q.group || !collide(p, q)) continue;
551
+ rival(p, q);
552
+ rival(q, p);
553
+ }
554
+ });
555
+ const set = members.filter((a) => !rivals.has(a));
556
+ const moving = [...rivals.keys()];
557
+ moving.sort((p, q) => along(p) - along(q) || p.index - q.index);
558
+ for (const a of moving) {
559
+ const others = rivals.get(a);
560
+ const lean = across(a) - others.reduce((sum, o) => sum + across(o), 0) / others.length;
561
+ const levels = lean >= LEAN ? LEVELS.map((k) => -k) : LEVELS;
562
+ const area = hover.get(a.group);
563
+ const clears = (k) => {
564
+ const there = moved(a, k);
565
+ return inside(there, area) && !set.some((o) => collide(o, there));
566
+ };
567
+ const level = levels.find(clears);
568
+ const taken = level === undefined ? levels[0] : level;
569
+ set.push(moved(a, taken));
570
+ a.el.setAttribute("data-grip-shift", String(taken));
571
+ }
572
+ }
573
+ };
574
+
575
+ // The cluster whose area holds the pointer (viewport px), or null: at
576
+ // most one does.
577
+ const under = (cx, cy) => {
578
+ const box = gd.getBoundingClientRect();
579
+ const x = cx - box.left;
580
+ const y = cy - box.top;
581
+ const holds = (a) => x >= a.l && x <= a.r && y >= a.t && y <= a.b;
582
+ return st.clusters.find((c) => c.areas.some(holds)) || null;
583
+ };
584
+
585
+ // How far the pointer (viewport px) is from the nearest SHOWN arrow's
586
+ // box — 0 on it, Infinity with none shown. The box is the path's own,
587
+ // as drawn: whichever way its arrow points, and wherever it is
588
+ // staggered to. An arrow off the plot is of a shown group and is not
589
+ // displayed (style.py): it has no box, and is passed over.
590
+ const arrow = (cx, cy) => {
591
+ let nearest = Infinity;
592
+ for (const el of gd.querySelectorAll(".shapelayer path[data-grip-on]")) {
593
+ const b = el.getBoundingClientRect();
594
+ if (!b.width && !b.height) continue;
595
+ const dx = cx < b.left ? b.left - cx : cx > b.right ? cx - b.right : 0;
596
+ const dy = cy < b.top ? b.top - cy : cy > b.bottom ? cy - b.bottom : 0;
597
+ nearest = Math.min(nearest, Math.hypot(dx, dy));
598
+ }
599
+ return nearest;
600
+ };
601
+
602
+ // Within HUSH px of a shown arrow, or of a ring a shown group stands
603
+ // around, the plot's own hover label is not shown (`data-grip-near`,
604
+ // style.py): the unified label and its spike line were drawn over
605
+ // them. Only there: hidden for as long as arrows were shown, the curve
606
+ // could not be read anywhere in an event's window — the panel's whole
607
+ // height — while its criterion was open. An ATTRIBUTE of the graph
608
+ // div, not a class: a server update of the element's classes wipes one
609
+ // the page set, and keeps an attribute.
610
+ const hush = (xy) => {
611
+ let near = false;
612
+ if (xy && st.on) {
613
+ near = arrow(xy[0], xy[1]) <= HUSH;
614
+ if (!near) {
615
+ const box = gd.getBoundingClientRect();
616
+ const x = xy[0] - box.left;
617
+ const y = xy[1] - box.top;
618
+ near = st.on.rings.some(([px, py]) => Math.hypot(x - px, y - py) <= HUSH);
619
+ }
620
+ }
621
+ gd.toggleAttribute("data-grip-near", near);
622
+ };
623
+
624
+ // Where the pointer is now — viewport px, or null off this plot: the
625
+ // cluster it is in shows. Held, nothing changes.
626
+ const here = (xy) => {
627
+ st.xy = xy;
628
+ if (st.latched) return;
629
+ show(xy ? under(xy[0], xy[1]) : null);
630
+ hush(xy);
631
+ };
632
+
633
+ const move = guarded((e) => here([e.clientX, e.clientY]));
634
+ const leave = guarded(() => here(null));
635
+ // Once a drag starts the graph div gets one `mouseleave` and no
636
+ // `mousemove` (Plotly's drag cover takes the pointer): what is shown is
637
+ // held from the press to the release.
638
+ const down = guarded((e) => {
639
+ if (!gd.contains(e.target)) return;
640
+ st.latched = true;
641
+ st.press = [e.clientX, e.clientY];
642
+ st.travel = null;
643
+ // The ranges the drag is made in (`released`): the release's own
644
+ // relayout autoranges the axes over the dragged shape before anyone
645
+ // hears of it.
646
+ st.ranges = {};
647
+ for (const [key, axis] of Object.entries(gd._fullLayout || {})) {
648
+ if (/^[xy]axis\d*$/.test(key) && axis && axis.range) {
649
+ st.ranges[key] = axis.range.slice();
650
+ }
651
+ }
652
+ // The handle this press holds, if it is on one (`follow`): the
653
+ // shape an editor moves, where the figure has it NOW — Plotly moves
654
+ // the drawn path under the pointer and writes the figure only at
655
+ // the release. Not under Ctrl: Plotly takes that press as a
656
+ // right-click.
657
+ st.held = st.ended = null;
658
+ const el = e.target instanceof Element ? e.target.closest(".shapelayer [data-index]") : null;
659
+ if (el && e.button === 0 && !e.ctrlKey && !el.hasAttribute("data-inert")) {
660
+ const index = +el.getAttribute("data-index");
661
+ const s = ((gd.layout && gd.layout.shapes) || [])[index];
662
+ const info = said(gd);
663
+ if (s && info && info.draggable.has(s.name)) {
664
+ st.held = { index, drag: ++seq, last: 0, pending: false, to: null };
665
+ }
666
+ }
667
+ });
668
+ const up = guarded((e) => {
669
+ if (!st.latched) return;
670
+ st.latched = false;
671
+ // How far the pointer went, per axis (`released`): heard here, in
672
+ // the capture phase, before Plotly ends its drag and relayouts.
673
+ st.travel = st.press && {
674
+ x: Math.abs(e.clientX - st.press[0]),
675
+ y: Math.abs(e.clientY - st.press[1]),
676
+ };
677
+ // The held handle is let go: no move of it is sent from here on,
678
+ // and the release is numbered past every one that was (`released`).
679
+ // Where it is let go is said ONCE MORE first: a move is sent a frame
680
+ // late and PACE ms apart, so the last one the fields showed could be
681
+ // short of the release — and stayed so for as long as the server
682
+ // took to stage it. Under MOVED px on both axes Plotly moves nothing
683
+ // and says nothing: the drag ends here, and the server is told so.
684
+ if (st.held) {
685
+ const held = st.held;
686
+ st.held = null;
687
+ const unmoved = st.travel && st.travel.x < MOVED && st.travel.y < MOVED;
688
+ held.to = [e.clientX, e.clientY];
689
+ const update = unmoved ? null : would(held);
690
+ if (update) emitEvent("dstf_grip_move", { plot: id, seq: ++seq, update });
691
+ st.ended = { index: held.index, seq: ++seq };
692
+ if (unmoved) {
693
+ emitEvent("dstf_grip_move", { plot: id, seq: st.ended.seq, end: true });
694
+ }
695
+ }
696
+ const box = gd.getBoundingClientRect();
697
+ const over =
698
+ e.clientX >= box.left && e.clientX <= box.right &&
699
+ e.clientY >= box.top && e.clientY <= box.bottom;
700
+ here(over ? [e.clientX, e.clientY] : null);
701
+ });
702
+ // A release outside the window never arrives.
703
+ const blur = guarded(() => {
704
+ st.latched = false;
705
+ if (st.held) {
706
+ st.held = null;
707
+ emitEvent("dstf_grip_move", { plot: id, seq: ++seq, end: true });
708
+ }
709
+ here(null);
710
+ });
711
+
712
+ // What the axes of shape `s` SHOWED at the press, as spans: what the
713
+ // server snaps a placed value by (`comparison/snap.py`). An axis the
714
+ // shape is not on in data — an arrow anchored to the paper — has none.
715
+ const visible = (s) => {
716
+ const span = (letter, ref) => {
717
+ const range = /^[xy]\d*$/.test(ref) ? st.ranges[letter + "axis" + ref.slice(1)] : null;
718
+ return range ? Math.abs(range[1] - range[0]) : null;
719
+ };
720
+ return { x: span("x", s.xref || "x"), y: span("y", s.yref || "y") };
721
+ };
722
+
723
+ // Where the release of the held handle would put it, as the relayout
724
+ // that release would send: the figure's position at the press plus
725
+ // the pointer's travel, an axis the pointer went under MOVED px along
726
+ // not moved (Plotly's own rule, and `released`'s) — measured in
727
+ // Chromium to be the release's own number, bit for bit — and held to
728
+ // the range of the press, as `released` holds a release. A bound line
729
+ // moves in y, both its ends; a ring or an arrow by its anchor.
730
+ const would = (held) => {
731
+ const s = ((gd.layout && gd.layout.shapes) || [])[held.index];
732
+ const fl = gd._fullLayout;
733
+ if (!s || !fl || !st.press || !held.to) return null;
734
+ const out = {};
735
+ const put = (letter, key, from) => {
736
+ const ref = s[letter + "ref"] || letter;
737
+ if (!/^[xy]\d*$/.test(ref) || typeof from !== "number") return;
738
+ const axis = fl[letter + "axis" + ref.slice(1)];
739
+ const range = st.ranges[letter + "axis" + ref.slice(1)];
740
+ if (!axis || !range) return;
741
+ const travel = held.to[letter === "x" ? 0 : 1] - st.press[letter === "x" ? 0 : 1];
742
+ let value = null;
743
+ if (Math.abs(travel) >= MOVED) {
744
+ value = axis.p2d(axis.d2p(from) + travel);
745
+ value = Math.min(Math.max(value, Math.min(...range)), Math.max(...range));
746
+ }
747
+ out[`shapes[${held.index}].${key}`] = value;
748
+ };
749
+ if (s.type === "line") {
750
+ put("y", "y0", s.y0);
751
+ put("y", "y1", s.y1);
752
+ } else {
753
+ put("x", "xanchor", s.xanchor);
754
+ put("y", "yanchor", s.yanchor);
755
+ }
756
+ out[`shapes[${held.index}].visible`] = visible(s);
757
+ out[`shapes[${held.index}].drag`] = held.drag;
758
+ return out;
759
+ };
760
+
761
+ // A handle is HELD and the pointer moved: its would-be position goes
762
+ // to the server, once a frame at the most and PACE ms apart, the last
763
+ // one always sent. Heard on the document, in the capture phase:
764
+ // Plotly's drag cover takes every move from the plot itself.
765
+ const send = guarded((held) => {
766
+ held.pending = false;
767
+ if (st.held !== held) return; // let go meanwhile
768
+ const wait = PACE - (performance.now() - held.last);
769
+ if (wait > 0) {
770
+ held.pending = true;
771
+ setTimeout(() => requestAnimationFrame(() => send(held)), wait);
772
+ return;
773
+ }
774
+ const update = would(held);
775
+ if (!update) return;
776
+ held.last = performance.now();
777
+ emitEvent("dstf_grip_move", { plot: id, seq: ++seq, update });
778
+ });
779
+ const follow = (e) => {
780
+ const held = st.held;
781
+ if (!held) return;
782
+ held.to = [e.clientX, e.clientY];
783
+ if (held.pending) return;
784
+ held.pending = true;
785
+ requestAnimationFrame(() => send(held));
786
+ };
787
+
788
+ // The panel under the pointer (viewport px) and the pointer in its
789
+ // axes — a panel is a pair of axes that overlays no other: the time
790
+ // plot's, the spectrum's — with what each axis shows; null outside
791
+ // every panel's plot area.
792
+ const spot = (cx, cy) => {
793
+ const fl = gd._fullLayout;
794
+ if (!fl || !fl._plots) return null;
795
+ const box = gd.getBoundingClientRect();
796
+ for (const plot of Object.values(fl._plots)) {
797
+ const xa = plot.xaxis;
798
+ const ya = plot.yaxis;
799
+ if (!xa || !ya || xa.overlaying || ya.overlaying) continue;
800
+ const px = cx - box.left - xa._offset;
801
+ const py = cy - box.top - ya._offset;
802
+ if (px < 0 || px > xa._length || py < 0 || py > ya._length) continue;
803
+ return {
804
+ xaxis: xa._id, yaxis: ya._id, x: xa.p2d(px), y: ya.p2d(py),
805
+ xrange: xa.range.slice(), yrange: ya.range.slice(),
806
+ };
807
+ }
808
+ return null;
809
+ };
810
+
811
+ // A CLICK on the plot, said to the server with where it is. Not a
812
+ // double-click (it resets the axes: `detail` counts the clicks); not
813
+ // the click that ends a drag — a Ctrl+drag draws Plotly's zoom box
814
+ // and ends in a real click where it is let go; not one on a shape an
815
+ // editor moves (a ring, an arrow, a bound: a press there is a drag's);
816
+ // not one outside every panel — where the toolbar and the legend
817
+ // stand, so a click on either is none of the plot's. Heard
818
+ // in the capture phase: a plain click here is one Plotly makes itself
819
+ // after its own handling, and it is still this plot's.
820
+ const clicked = guarded((e) => {
821
+ if (e.detail >= 2 || !(e.target instanceof Element)) return;
822
+ if (st.press && Math.hypot(e.clientX - st.press[0], e.clientY - st.press[1]) > CLICKED) {
823
+ return;
824
+ }
825
+ const shape = e.target.closest(".shapelayer [data-index]");
826
+ if (shape && !shape.hasAttribute("data-inert")) return;
827
+ const at = spot(e.clientX, e.clientY);
828
+ if (!at) return;
829
+ emitEvent("dstf_plot_click", {
830
+ plot: id, ...at,
831
+ ctrl: e.ctrlKey, shift: e.shiftKey, alt: e.altKey, meta: e.metaKey,
832
+ });
833
+ });
834
+ // A scroll moves the plot under a pointer at rest, and no mouse event
835
+ // says so: the arrows of a checkpoint wheeled out from under the
836
+ // pointer stayed shown until it moved, and one wheeled under it showed
837
+ // none. The pointer is where it was on the screen (the page's
838
+ // `pointer`) and the plot is not: the same test as a move, against
839
+ // the plot where it is now — if the pointer is over this plot at all,
840
+ // by what a press there would land on. Once per frame, however many
841
+ // scroll events one brings.
842
+ const rehere = guarded(() => {
843
+ const under = pointer && document.elementFromPoint(pointer[0], pointer[1]);
844
+ here(under && gd.contains(under) ? pointer : null);
845
+ });
846
+ const scrolled = () => {
847
+ if (st.frame) return;
848
+ st.frame = requestAnimationFrame(() => {
849
+ st.frame = 0;
850
+ rehere();
851
+ });
852
+ };
853
+
854
+ // What a release sends of the shapes it moved: `update` is a relayout's
855
+ // (`shapes[N].xanchor`, `shapes[N].y0`, ...), read against the figure
856
+ // this plot holds; a copy is returned.
857
+ //
858
+ // A shape let go OUTSIDE the plot area — on the page above it, off the
859
+ // spectrum's left end — was released where the axis would be out
860
+ // there, and that number was written (an arrow's side at 2.5 on an
861
+ // axis ending at 1.3; a ring's time at 1.289 on a run ending at 1.0).
862
+ // Every position of a shape an editor moves — an arrow's anchor, a
863
+ // ring's, a bound line's ends — is held to the range that was VISIBLE
864
+ // through the drag, the press's (`down`): it lands on the plot's
865
+ // edge, where it was last drawn. A position on the paper, and the
866
+ // pixel offsets of a shape sized in pixels, are no data to hold.
867
+ //
868
+ // And an axis the pointer barely moved along is not moved. Under
869
+ // MOVED px of travel along an axis Plotly zeroes that travel, moves
870
+ // the shape on the other axis alone and re-snaps the position it did
871
+ // not move: a value side's arrow dragged sideways came back 0.0800462
872
+ // for 0.08, a ring dragged sideways wrote its value 0.5 as
873
+ // 0.500009668842, a bound line 1.0 as 1.0000266 — each staged. For
874
+ // EVERY shape an editor moves, a release says no position (null) on
875
+ // an axis the pointer went under MOVED px along, press to release:
876
+ // the editor reads no move there — under MOVED px on both, no move at
877
+ // all — and the redraw that answers every release puts the shape
878
+ // back.
879
+ const released = (update) => {
880
+ const shapes = (gd.layout && gd.layout.shapes) || [];
881
+ const info = said(gd);
882
+ const travel = st.travel;
883
+ st.travel = null;
884
+ const ended = st.ended;
885
+ st.ended = null;
886
+ const out = { ...update };
887
+ if (!info) return out;
888
+ for (const [key, value] of Object.entries(update)) {
889
+ const m = /^shapes\[(\d+)\]\.([xy])(anchor|0|1)$/.exec(key);
890
+ if (!m || typeof value !== "number") continue;
891
+ const s = shapes[+m[1]];
892
+ if (!s || !info.draggable.has(s.name)) continue;
893
+ // What its axes showed, for the server's snap, and the release's
894
+ // number: past every move of the drag it ends.
895
+ out[`shapes[${m[1]}].visible`] = visible(s);
896
+ out[`shapes[${m[1]}].seq`] = ended ? ended.seq : ++seq;
897
+ if (travel && travel[m[2]] < MOVED) {
898
+ out[key] = null;
899
+ continue;
900
+ }
901
+ if (m[3] !== "anchor" && s[m[2] + "sizemode"] === "pixel") continue;
902
+ const ref = m[2] === "x" ? s.xref || "x" : s.yref || "y";
903
+ const range = /^[xy]\d*$/.test(ref) ? st.ranges[m[2] + "axis" + ref.slice(1)] : null;
904
+ if (!range) continue;
905
+ out[key] = Math.min(Math.max(value, Math.min(...range)), Math.max(...range));
906
+ }
907
+ return out;
908
+ };
909
+ // A release that cannot be read goes on as it came.
910
+ st.released = (update) => guarded(released)(update) || update;
911
+
912
+ gd.addEventListener("mousemove", move);
913
+ gd.addEventListener("mouseleave", leave);
914
+ gd.addEventListener("click", clicked, true);
915
+ document.addEventListener("mousemove", follow, true);
916
+ document.addEventListener("mousedown", down, true);
917
+ document.addEventListener("mouseup", up, true);
918
+ window.addEventListener("blur", blur);
919
+ // On the window, as it is captured: a scroll does not bubble, and
920
+ // whichever ancestor of the plot scrolls is heard on the way down.
921
+ window.addEventListener("scroll", scrolled, { capture: true, passive: true });
922
+
923
+ // The marks, the clusters, the colliding arrows' moves, and which
924
+ // cluster the pointer is in: after every redraw — the one after a
925
+ // release too, shown if the pointer is still there. Whatever moves a
926
+ // shape's pixels draws it again: a zoom, a pan, a reset, a resize of
927
+ // the window, a trace hidden from the legend, the server's own update
928
+ // are each a relayout or a replot, and Plotly draws every shape anew
929
+ // in each (`shapes.draw`).
930
+ const remark = guarded(() => {
931
+ const arrows = mark();
932
+ const hover = areas();
933
+ st.clusters = clustered(hover);
934
+ stagger(arrows, hover);
935
+ here(st.xy);
936
+ });
937
+
938
+ // Before paint, so no frame shows an unmarked grip. A `plotly_afterplot`
939
+ // listener would be purged by the next newPlot.
940
+ const observer = new MutationObserver((records) => {
941
+ for (const record of records) {
942
+ for (const node of record.addedNodes) {
943
+ if (
944
+ node.nodeType === 1 &&
945
+ (node.hasAttribute("data-index") ||
946
+ node.classList.contains("shapelayer") ||
947
+ node.querySelector(".shapelayer"))
948
+ ) {
949
+ remark();
950
+ return;
951
+ }
952
+ }
953
+ }
954
+ });
955
+ observer.observe(gd, { childList: true, subtree: true });
956
+
957
+ st.destroy = () => {
958
+ gd.removeEventListener("mousemove", move);
959
+ gd.removeEventListener("mouseleave", leave);
960
+ gd.removeEventListener("click", clicked, true);
961
+ document.removeEventListener("mousemove", follow, true);
962
+ document.removeEventListener("mousedown", down, true);
963
+ st.held = null;
964
+ document.removeEventListener("mouseup", up, true);
965
+ window.removeEventListener("blur", blur);
966
+ window.removeEventListener("scroll", scrolled, true);
967
+ cancelAnimationFrame(st.frame);
968
+ observer.disconnect();
969
+ unmark();
970
+ };
971
+ remark();
972
+ return st;
973
+ }
974
+
975
+ window.DstfGrips = {
976
+ instances,
977
+ // The element may not be in the page yet: NiceGUI's update that creates
978
+ // it and this call travel separately.
979
+ install(id, tries = 300) {
980
+ const gd = document.getElementById("c" + id);
981
+ if (!gd) {
982
+ if (tries > 0) requestAnimationFrame(() => this.install(id, tries - 1));
983
+ return;
984
+ }
985
+ this.destroy(id);
986
+ instances.set(id, create(gd, id));
987
+ },
988
+ destroy(id) {
989
+ const st = instances.get(id);
990
+ if (st) st.destroy();
991
+ instances.delete(id);
992
+ },
993
+ // A relayout of plot `id` on its way to the server (plot_card.py):
994
+ // what that plot's instance says of a release.
995
+ released(id, update) {
996
+ const st = instances.get(id);
997
+ return st && update && typeof update === "object" ? st.released(update) : update;
998
+ },
999
+ };
1000
+ })();