@matchstick-trading/primitives 0.0.10

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 (216) hide show
  1. package/AGENTS.md +44 -0
  2. package/CHANGELOG.md +19 -0
  3. package/LICENSE +201 -0
  4. package/README.md +636 -0
  5. package/dist/Chart.d.ts +18 -0
  6. package/dist/Chart.js +6 -0
  7. package/dist/ChartParts.d.ts +151 -0
  8. package/dist/ChartParts.js +389 -0
  9. package/dist/CsvDrop.d.ts +8 -0
  10. package/dist/CsvDrop.js +5 -0
  11. package/dist/CsvDropParts.d.ts +33 -0
  12. package/dist/CsvDropParts.js +77 -0
  13. package/dist/Cursor.d.ts +6 -0
  14. package/dist/Cursor.js +5 -0
  15. package/dist/CursorParts.d.ts +51 -0
  16. package/dist/CursorParts.js +40 -0
  17. package/dist/DepthProfile.d.ts +13 -0
  18. package/dist/DepthProfile.js +5 -0
  19. package/dist/DepthProfileParts.d.ts +45 -0
  20. package/dist/DepthProfileParts.js +96 -0
  21. package/dist/DiffTable.d.ts +13 -0
  22. package/dist/DiffTable.js +5 -0
  23. package/dist/DiffTableParts.d.ts +57 -0
  24. package/dist/DiffTableParts.js +103 -0
  25. package/dist/Drawings.d.ts +13 -0
  26. package/dist/Drawings.js +5 -0
  27. package/dist/DrawingsParts.d.ts +194 -0
  28. package/dist/DrawingsParts.js +775 -0
  29. package/dist/Fullscreen.d.ts +7 -0
  30. package/dist/Fullscreen.js +5 -0
  31. package/dist/FullscreenParts.d.ts +54 -0
  32. package/dist/FullscreenParts.js +250 -0
  33. package/dist/Ladder.d.ts +13 -0
  34. package/dist/Ladder.js +5 -0
  35. package/dist/LadderParts.d.ts +49 -0
  36. package/dist/LadderParts.js +56 -0
  37. package/dist/Menu.d.ts +11 -0
  38. package/dist/Menu.js +5 -0
  39. package/dist/MenuParts.d.ts +52 -0
  40. package/dist/MenuParts.js +202 -0
  41. package/dist/Notice.d.ts +18 -0
  42. package/dist/Notice.js +9 -0
  43. package/dist/Panels.d.ts +9 -0
  44. package/dist/Panels.js +5 -0
  45. package/dist/PanelsParts.d.ts +62 -0
  46. package/dist/PanelsParts.js +387 -0
  47. package/dist/Receipt.d.ts +11 -0
  48. package/dist/Receipt.js +5 -0
  49. package/dist/ReceiptParts.d.ts +45 -0
  50. package/dist/ReceiptParts.js +71 -0
  51. package/dist/Scrubber.d.ts +9 -0
  52. package/dist/Scrubber.js +5 -0
  53. package/dist/ScrubberParts.d.ts +74 -0
  54. package/dist/ScrubberParts.js +126 -0
  55. package/dist/Selection.d.ts +14 -0
  56. package/dist/Selection.js +7 -0
  57. package/dist/SelectionParts.d.ts +63 -0
  58. package/dist/SelectionParts.js +208 -0
  59. package/dist/Studies.d.ts +9 -0
  60. package/dist/Studies.js +5 -0
  61. package/dist/StudiesParts.d.ts +45 -0
  62. package/dist/StudiesParts.js +153 -0
  63. package/dist/Submenu.d.ts +7 -0
  64. package/dist/Submenu.js +4 -0
  65. package/dist/SymbolSearch.d.ts +10 -0
  66. package/dist/SymbolSearch.js +5 -0
  67. package/dist/SymbolSearchParts.d.ts +39 -0
  68. package/dist/SymbolSearchParts.js +86 -0
  69. package/dist/Tape.d.ts +15 -0
  70. package/dist/Tape.js +5 -0
  71. package/dist/TapeParts.d.ts +69 -0
  72. package/dist/TapeParts.js +139 -0
  73. package/dist/Toolbar.d.ts +8 -0
  74. package/dist/Toolbar.js +5 -0
  75. package/dist/ToolbarParts.d.ts +61 -0
  76. package/dist/ToolbarParts.js +122 -0
  77. package/dist/Transport.d.ts +15 -0
  78. package/dist/Transport.js +5 -0
  79. package/dist/TransportParts.d.ts +52 -0
  80. package/dist/TransportParts.js +47 -0
  81. package/dist/VolumeProfile.d.ts +7 -0
  82. package/dist/VolumeProfile.js +5 -0
  83. package/dist/VolumeProfileParts.d.ts +37 -0
  84. package/dist/VolumeProfileParts.js +57 -0
  85. package/dist/accessible-name.d.ts +26 -0
  86. package/dist/accessible-name.js +14 -0
  87. package/dist/attributes.d.ts +8 -0
  88. package/dist/attributes.js +9 -0
  89. package/dist/core/adapter.d.ts +38 -0
  90. package/dist/core/adapter.js +64 -0
  91. package/dist/core/attributes.d.ts +36 -0
  92. package/dist/core/attributes.js +22 -0
  93. package/dist/core/candles.d.ts +6 -0
  94. package/dist/core/candles.js +19 -0
  95. package/dist/core/chart-gesture.d.ts +109 -0
  96. package/dist/core/chart-gesture.js +161 -0
  97. package/dist/core/chart-scale.d.ts +141 -0
  98. package/dist/core/chart-scale.js +302 -0
  99. package/dist/core/csv-bars.d.ts +54 -0
  100. package/dist/core/csv-bars.js +325 -0
  101. package/dist/core/depth-profile.d.ts +43 -0
  102. package/dist/core/depth-profile.js +66 -0
  103. package/dist/core/diff-table.d.ts +57 -0
  104. package/dist/core/diff-table.js +77 -0
  105. package/dist/core/drawing-fields.d.ts +11 -0
  106. package/dist/core/drawing-fields.js +15 -0
  107. package/dist/core/drawings.d.ts +194 -0
  108. package/dist/core/drawings.js +403 -0
  109. package/dist/core/fullscreen.d.ts +32 -0
  110. package/dist/core/fullscreen.js +16 -0
  111. package/dist/core/index.d.ts +41 -0
  112. package/dist/core/index.js +23 -0
  113. package/dist/core/ladder-keys.d.ts +35 -0
  114. package/dist/core/ladder-keys.js +43 -0
  115. package/dist/core/ladder.d.ts +65 -0
  116. package/dist/core/ladder.js +93 -0
  117. package/dist/core/panels.d.ts +72 -0
  118. package/dist/core/panels.js +151 -0
  119. package/dist/core/receipt-rules.d.ts +42 -0
  120. package/dist/core/receipt-rules.js +20 -0
  121. package/dist/core/scrubber-gesture.d.ts +66 -0
  122. package/dist/core/scrubber-gesture.js +69 -0
  123. package/dist/core/selection.d.ts +24 -0
  124. package/dist/core/selection.js +33 -0
  125. package/dist/core/series.d.ts +161 -0
  126. package/dist/core/series.js +331 -0
  127. package/dist/core/studies.d.ts +80 -0
  128. package/dist/core/studies.js +237 -0
  129. package/dist/core/symbol-search.d.ts +89 -0
  130. package/dist/core/symbol-search.js +223 -0
  131. package/dist/core/tape.d.ts +77 -0
  132. package/dist/core/tape.js +136 -0
  133. package/dist/core/volume-profile.d.ts +47 -0
  134. package/dist/core/volume-profile.js +86 -0
  135. package/dist/index.d.ts +82 -0
  136. package/dist/index.js +42 -0
  137. package/dist/ladder-keys.d.ts +16 -0
  138. package/dist/ladder-keys.js +86 -0
  139. package/dist/panels-context.d.ts +47 -0
  140. package/dist/panels-context.js +3 -0
  141. package/dist/selection-context.d.ts +41 -0
  142. package/dist/selection-context.js +4 -0
  143. package/examples/csv-to-chart-frame.mjs +17 -0
  144. package/examples/studies.mjs +12 -0
  145. package/llms.txt +16 -0
  146. package/package.json +67 -0
  147. package/src/Chart.tsx +7 -0
  148. package/src/ChartParts.tsx +529 -0
  149. package/src/CsvDrop.tsx +6 -0
  150. package/src/CsvDropParts.tsx +128 -0
  151. package/src/Cursor.tsx +6 -0
  152. package/src/CursorParts.tsx +77 -0
  153. package/src/DepthProfile.tsx +6 -0
  154. package/src/DepthProfileParts.tsx +154 -0
  155. package/src/DiffTable.tsx +6 -0
  156. package/src/DiffTableParts.tsx +160 -0
  157. package/src/Drawings.tsx +6 -0
  158. package/src/DrawingsParts.tsx +1080 -0
  159. package/src/Fullscreen.tsx +6 -0
  160. package/src/FullscreenParts.tsx +318 -0
  161. package/src/Ladder.tsx +6 -0
  162. package/src/LadderParts.tsx +114 -0
  163. package/src/Menu.tsx +6 -0
  164. package/src/MenuParts.tsx +364 -0
  165. package/src/Notice.tsx +27 -0
  166. package/src/Panels.tsx +6 -0
  167. package/src/PanelsParts.tsx +454 -0
  168. package/src/Receipt.tsx +6 -0
  169. package/src/ReceiptParts.tsx +118 -0
  170. package/src/Scrubber.tsx +6 -0
  171. package/src/ScrubberParts.tsx +207 -0
  172. package/src/Selection.tsx +12 -0
  173. package/src/SelectionParts.tsx +251 -0
  174. package/src/Studies.tsx +6 -0
  175. package/src/StudiesParts.tsx +226 -0
  176. package/src/Submenu.tsx +5 -0
  177. package/src/SymbolSearch.tsx +6 -0
  178. package/src/SymbolSearchParts.tsx +166 -0
  179. package/src/Tape.tsx +6 -0
  180. package/src/TapeParts.tsx +208 -0
  181. package/src/Toolbar.tsx +6 -0
  182. package/src/ToolbarParts.tsx +179 -0
  183. package/src/Transport.tsx +6 -0
  184. package/src/TransportParts.tsx +128 -0
  185. package/src/VolumeProfile.tsx +6 -0
  186. package/src/VolumeProfileParts.tsx +106 -0
  187. package/src/accessible-name.tsx +34 -0
  188. package/src/attributes.ts +12 -0
  189. package/src/core/adapter.ts +91 -0
  190. package/src/core/attributes.ts +51 -0
  191. package/src/core/candles.ts +22 -0
  192. package/src/core/chart-gesture.ts +228 -0
  193. package/src/core/chart-scale.ts +368 -0
  194. package/src/core/csv-bars.ts +328 -0
  195. package/src/core/depth-profile.ts +84 -0
  196. package/src/core/diff-table.ts +105 -0
  197. package/src/core/drawing-fields.ts +17 -0
  198. package/src/core/drawings.ts +478 -0
  199. package/src/core/fullscreen.ts +32 -0
  200. package/src/core/index.ts +42 -0
  201. package/src/core/ladder-keys.ts +61 -0
  202. package/src/core/ladder.ts +156 -0
  203. package/src/core/panels.ts +177 -0
  204. package/src/core/receipt-rules.ts +45 -0
  205. package/src/core/scrubber-gesture.ts +102 -0
  206. package/src/core/selection.ts +41 -0
  207. package/src/core/series.ts +374 -0
  208. package/src/core/studies.ts +260 -0
  209. package/src/core/symbol-search.ts +256 -0
  210. package/src/core/tape.ts +159 -0
  211. package/src/core/volume-profile.ts +116 -0
  212. package/src/index.ts +83 -0
  213. package/src/ladder-keys.ts +83 -0
  214. package/src/panels-context.ts +46 -0
  215. package/src/selection-context.ts +38 -0
  216. package/src/tokens.css +235 -0
@@ -0,0 +1,775 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState } from 'react';
4
+ import { buttonFace } from './accessible-name.js';
5
+ import { primitiveAttributes } from './attributes.js';
6
+ import { useChart } from './ChartParts.js';
7
+ import { SelectionContext } from './selection-context.js';
8
+ import * as ToolbarPart from './ToolbarParts.js';
9
+ import { barField, barNote, fromPriceField, fromTimeField, parseBarField, toTimeField } from './core/drawing-fields.js';
10
+ import { ATTACH_LABELS, ATTACH_MODES, DRAWING_TOOLS, DRAWING_TOOL_LABELS, anchorCount, attachOf, barTime, barsFromLast, describeAnchor, describeDrawing, drawingGeometry, editDrawing, hitTest, moveAnchor, moveDrawing, nextDrawingId, nudgeAmount, resolveDrawingKey, snapToBar, timeOfBarFromLast, } from './core/drawings.js';
11
+ const Context = createContext(null);
12
+ /** The drawings state of the Drawings.Root above. */
13
+ export function useDrawings(part = 'part') {
14
+ const context = useContext(Context);
15
+ if (!context)
16
+ throw new Error(`Drawings.${part} needs a Drawings.Root above it.`);
17
+ return context;
18
+ }
19
+ /** State for the other parts. Each of drawings, tool and selection is controlled when given, and kept here when not. */
20
+ function Root({ drawings, onDrawingsChange, tool, onToolChange, selected, onSelectedChange, snap = 'off', undoWindow = 10000, children }) {
21
+ const [innerDrawings, setInnerDrawings] = useState([]);
22
+ const [innerTool, setInnerTool] = useState(null);
23
+ const [innerSelected, setInnerSelected] = useState(null);
24
+ const [draft, setDraft] = useState(null);
25
+ // Bar numbers only need the times, so a new array with the same length and ends is the same chart.
26
+ const [bars, setBarsState] = useState([]);
27
+ const setBars = useCallback((next) => {
28
+ setBarsState((prev) => (prev.length === next.length && prev[0]?.timestamp === next[0]?.timestamp && prev[prev.length - 1]?.timestamp === next[next.length - 1]?.timestamp ? prev : next));
29
+ }, []);
30
+ const [status, setStatus] = useState(null);
31
+ const plotRef = useRef(null);
32
+ const undoRegion = useRef(null);
33
+ const focusUndoNext = useRef(false);
34
+ const focusRowNext = useRef(false);
35
+ const list = drawings ?? innerDrawings;
36
+ const activeTool = tool !== undefined ? tool : innerTool;
37
+ // With a Selection.Root above, the selection lives there and a selected drawing is { kind: 'drawing' }; `selected` is then ignored.
38
+ const sel = useContext(SelectionContext);
39
+ const selNow = sel?.selection;
40
+ const activeSelected = sel ? (selNow && selNow.kind === 'drawing' ? selNow.id : null) : selected !== undefined ? selected : innerSelected;
41
+ const selSelect = sel?.select;
42
+ const selCurrent = sel?.current;
43
+ const selSetName = sel?.setName;
44
+ // The array this Root last handed out, so a list it did not make (a symbol switch) can be told from its own.
45
+ const emitted = useRef(null);
46
+ const setDrawings = useCallback((next) => {
47
+ emitted.current = next;
48
+ if (drawings === undefined)
49
+ setInnerDrawings(next);
50
+ onDrawingsChange?.(next);
51
+ }, [drawings, onDrawingsChange]);
52
+ const setTool = useCallback((next) => {
53
+ if (tool === undefined)
54
+ setInnerTool(next);
55
+ onToolChange?.(next);
56
+ }, [tool, onToolChange]);
57
+ const setSelected = useCallback((next) => {
58
+ if (selSelect && selCurrent) {
59
+ // Putting a drawing away must not take a selected series or study with it.
60
+ if (next !== null) {
61
+ // Name it first, so the live region says the drawing in words rather than its id.
62
+ const known = list.find((d) => d.id === next);
63
+ if (known && selSetName)
64
+ selSetName('drawing', next, describeDrawing(known, 2));
65
+ selSelect({ kind: 'drawing', id: next });
66
+ }
67
+ else if (selCurrent()?.kind === 'drawing')
68
+ selSelect(null);
69
+ }
70
+ else if (selected === undefined)
71
+ setInnerSelected(next);
72
+ onSelectedChange?.(next);
73
+ }, [selected, onSelectedChange, selSelect, selCurrent, selSetName, list]);
74
+ // The live region names the selected drawing in words.
75
+ const selectedDrawing = activeSelected !== null ? list.find((d) => d.id === activeSelected) : undefined;
76
+ useEffect(() => {
77
+ if (selSetName && selectedDrawing)
78
+ selSetName('drawing', selectedDrawing.id, describeDrawing(selectedDrawing, 2));
79
+ }, [selSetName, selectedDrawing]);
80
+ // The last removal, with where each drawing sat, so Undo can put it back. Only the last one is kept.
81
+ const [lastRemoval, setLastRemoval] = useState(null);
82
+ useEffect(() => {
83
+ if (!lastRemoval || !(undoWindow > 0))
84
+ return;
85
+ const timer = setTimeout(() => setLastRemoval(null), undoWindow);
86
+ return () => clearTimeout(timer);
87
+ }, [lastRemoval, undoWindow]);
88
+ // A pending Undo belongs to the list the removal made. When the host hands back another list (a symbol switch, or one
89
+ // that refused the removal), putting the drawing back would add it to a list it never came from. A later list the Root
90
+ // made itself, such as a new drawing that reuses a removed id, is the Root's own and keeps the Undo. The test is the array
91
+ // itself, not its ids: a new empty array after a removal is a different list even though its content is equal. A host that
92
+ // copies the array it was given (same drawings, same order) is the one exception.
93
+ useEffect(() => {
94
+ if (!lastRemoval || emitted.current === null)
95
+ return;
96
+ const mine = emitted.current;
97
+ const copy = list.length > 0 && list.length === mine.length && list.every((d, i) => d === mine[i]);
98
+ if (list !== mine && !copy)
99
+ setLastRemoval(null);
100
+ }, [list, lastRemoval]);
101
+ const removeWhere = useCallback((gone) => {
102
+ const items = list.flatMap((drawing, index) => (gone(drawing) ? [{ drawing, index }] : []));
103
+ if (items.length === 0)
104
+ return;
105
+ setDrawings(list.filter((d) => !gone(d)));
106
+ if (activeSelected !== null && items.some((i) => i.drawing.id === activeSelected))
107
+ setSelected(null);
108
+ setLastRemoval({ items });
109
+ setStatus(null);
110
+ }, [list, setDrawings, activeSelected, setSelected]);
111
+ const remove = useCallback((id) => removeWhere((d) => d.id === id && !d.locked), [removeWhere]);
112
+ const removeAll = useCallback(() => removeWhere((d) => !d.locked), [removeWhere]);
113
+ const setLocked = useCallback((id, locked) => {
114
+ setDrawings(list.map((d) => {
115
+ if (d.id !== id)
116
+ return d;
117
+ const { locked: _was, ...rest } = d;
118
+ return locked ? { ...rest, locked: true } : rest;
119
+ }));
120
+ }, [list, setDrawings]);
121
+ const undo = useCallback((options) => {
122
+ if (!lastRemoval)
123
+ return;
124
+ const next = [...list];
125
+ let restored = null;
126
+ // Ascending, so each index is still right after the ones before it went back in.
127
+ for (const { drawing, index } of lastRemoval.items) {
128
+ // A drawing added since may have taken the id; the restored one then gets a fresh id.
129
+ const back = next.some((d) => d.id === drawing.id) ? { ...drawing, id: nextDrawingId(next) } : drawing;
130
+ next.splice(Math.min(index, next.length), 0, back);
131
+ restored = back.id;
132
+ }
133
+ setDrawings(next);
134
+ if (lastRemoval.items.length === 1 && restored !== null)
135
+ setSelected(restored);
136
+ setLastRemoval(null);
137
+ const describe = options?.describe ?? ((d) => describeDrawing(d, 2));
138
+ const items = lastRemoval.items;
139
+ // The live region says what came back, so the silent return of a row is heard.
140
+ setStatus(items.length === 1 ? `Restored ${describe(items[0].drawing)}.` : `Restored ${items.length} drawings.`);
141
+ focusRowNext.current = !!options?.focusRow && items.length === 1;
142
+ }, [lastRemoval, list, setDrawings, setSelected]);
143
+ const removed = useMemo(() => (lastRemoval ? lastRemoval.items.map((i) => i.drawing) : null), [lastRemoval]);
144
+ // A focused Remove button may be gone after the removal (the selection it acted on is cleared): Undo takes focus instead.
145
+ useEffect(() => {
146
+ if (!lastRemoval || !focusUndoNext.current)
147
+ return;
148
+ focusUndoNext.current = false;
149
+ undoRegion.current?.querySelector("[data-part='drawing-undo-button']")?.focus();
150
+ }, [lastRemoval]);
151
+ // A half-placed drawing belongs to the tool that began it.
152
+ useEffect(() => {
153
+ setDraft((current) => (current && current.tool !== activeTool ? null : current));
154
+ }, [activeTool]);
155
+ const context = useMemo(() => ({
156
+ drawings: list, setDrawings, tool: activeTool, setTool, selected: activeSelected, setSelected, snap, draft, setDraft, remove, setLocked, removeAll, removed, undo, undoWindow, bars, setBars,
157
+ announce: setStatus, status, plotRef, undoRegion, focusUndoNext, focusRowNext,
158
+ }), [list, setDrawings, activeTool, setTool, activeSelected, setSelected, snap, draft, remove, setLocked, removeAll, removed, undo, undoWindow, bars, setBars, status, plotRef, undoRegion, focusUndoNext, focusRowNext]);
159
+ return _jsx(Context.Provider, { value: context, children: children });
160
+ }
161
+ /** How far from an anchor handle, in pixels, a touch still grabs it. */
162
+ const TOUCH_HANDLE_PX = 22;
163
+ /** A pointer travel, in pixels, past which a press-and-release counts as a drag rather than a click. */
164
+ const DRAG_PIXELS = 4;
165
+ /**
166
+ * Draws every drawing and the handles of the selected one. Put it inside Chart.Plot, after the candles.
167
+ *
168
+ * With a tool in use it adds a transparent `drawing-capture` rect over the plot: press to set the first point, move
169
+ * to preview, then release after a drag or click again to set the second. One-point tools place on the press. A new
170
+ * drawing is selected and the tool is put away. With no tool, pressing a drawing selects it and dragging moves it,
171
+ * or moves one anchor when a handle is grabbed; pressing anywhere else is left to the plot, which pans.
172
+ *
173
+ * From the keyboard, with a tool in use and the chart focused: Enter places a point at the crosshair (the bar the arrow keys
174
+ * moved to, at the crosshair price, which from the keyboard is that bar's close), Enter again places the second point of a
175
+ * two-point tool, and Escape drops the half-placed drawing. Each step is announced in Drawings.Undo's live region.
176
+ *
177
+ * `newText` is the words a new text note gets. `formatTime` prints times in those announcements (default HH:MM in UTC). The
178
+ * layer is hidden from assistive technology: the List is its text.
179
+ */
180
+ function Layer({ className, newText = 'Note', formatTime }) {
181
+ const ctx = useDrawings('Layer');
182
+ const chart = useChart('Drawings.Layer');
183
+ const { bars, frame, toChart, decimals, readoutId } = chart;
184
+ const groupRef = useRef(null);
185
+ const live = useRef({ ctx, chart, place: () => { } });
186
+ // Set after `commit` and `placeAtCrosshair` below, so the key handler always reaches the latest ones.
187
+ // The Edit form sits outside the chart, so the Layer hands it the bars to count from.
188
+ const { setBars, plotRef } = ctx;
189
+ // A host may fill the same array in place, so the layer publishes a copy and watches the length and last bar too.
190
+ const barCount = bars.length;
191
+ const lastBarTime = bars[barCount - 1]?.timestamp;
192
+ useEffect(() => { setBars(bars.slice()); return () => setBars([]); }, [bars, barCount, lastBarTime, setBars]);
193
+ const gesture = useRef(null);
194
+ // A Selection.Layer asks before it reads a press as a tap on clear chart: a drawing or an armed tool owns the press.
195
+ const selectionGuard = useContext(SelectionContext)?.guard;
196
+ useEffect(() => {
197
+ if (!selectionGuard)
198
+ return;
199
+ const guard = {
200
+ owns: (clientX, clientY, pointerType) => {
201
+ const { ctx: c, chart: ch } = live.current;
202
+ if (c.tool !== null)
203
+ return true;
204
+ const point = ch.toChart(clientX, clientY);
205
+ return !!point && hitTest(c.drawings, ch.bars, ch.frame, point, 6, pointerType === 'touch' ? TOUCH_HANDLE_PX : 6) !== null;
206
+ },
207
+ busy: () => live.current.ctx.tool !== null || live.current.ctx.draft !== null,
208
+ };
209
+ selectionGuard.current = guard;
210
+ return () => { if (selectionGuard.current === guard)
211
+ selectionGuard.current = null; };
212
+ }, [selectionGuard]);
213
+ // True after an arrow key moved the crosshair while a drawing was half placed: the preview then follows the crosshair.
214
+ const keyboardMove = useRef(false);
215
+ // The drawing (and anchor, or 'body') a pointer is moving now: it reads as active, and its anchor is marked.
216
+ const [moving, setMoving] = useState(null);
217
+ // Pointer and key handling for existing drawings runs on the plot's svg itself, ahead of the plot's own handlers,
218
+ // and only stops the event when it used it. That is how the chart keeps panning and moving its crosshair otherwise.
219
+ useEffect(() => {
220
+ const svg = groupRef.current?.ownerSVGElement;
221
+ if (!svg)
222
+ return;
223
+ plotRef.current = svg;
224
+ const touchActionBefore = svg.style.touchAction;
225
+ let drag = null;
226
+ // A tap on clear chart deselects; a touch or pen tap on an unselected drawing (select) selects it, only on release.
227
+ let tap = null;
228
+ const down = new Set();
229
+ const onDown = (event) => {
230
+ const { ctx: c, chart: ch } = live.current;
231
+ // A second finger joining means a pinch, not a tap: nothing pending may deselect.
232
+ if (event.isPrimary)
233
+ down.clear();
234
+ down.add(event.pointerId);
235
+ if (down.size > 1)
236
+ tap = null;
237
+ if (c.tool !== null || event.button !== 0)
238
+ return;
239
+ const point = ch.toChart(event.clientX, event.clientY);
240
+ if (!point)
241
+ return;
242
+ // Drawings are clipped to the plot area, so a press in a gutter cannot pick one up.
243
+ if (point.x < 0 || point.x >= ch.frame.plotWidth || point.y < 0 || point.y >= ch.frame.plotHeight)
244
+ return;
245
+ // A finger needs a bigger target than a mouse: handles grab within 22 px by touch, 6 px otherwise.
246
+ const hit = hitTest(c.drawings, ch.bars, ch.frame, point, 6, event.pointerType === 'touch' ? TOUCH_HANDLE_PX : 6);
247
+ if (!hit) {
248
+ // A tap on clear chart puts the selection away. A drag that starts here is the chart panning, so it keeps it.
249
+ // A press on a selectable study line is the Selection.Layer's: it selects the study instead.
250
+ if (event.target?.closest?.("[data-part='study-hit']"))
251
+ return;
252
+ if (c.selected !== null && down.size === 1)
253
+ tap = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, at: event.timeStamp };
254
+ return;
255
+ }
256
+ // A finger or pen on an unselected drawing may be the start of a page scroll (the plot is pan-y until something is selected),
257
+ // and the browser decides that after the press. So nothing is taken or changed now: a tap selects it on release.
258
+ if (event.pointerType !== 'mouse' && c.selected !== hit.id) {
259
+ if (down.size === 1)
260
+ tap = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, at: event.timeStamp, select: hit.id };
261
+ return;
262
+ }
263
+ event.stopPropagation();
264
+ c.setSelected(hit.id);
265
+ const start = c.drawings.find((d) => d.id === hit.id);
266
+ if (!start || start.locked)
267
+ return;
268
+ drag = { pointerId: event.pointerId, handle: hit.handle, start, index: ch.frame.indexAt(point.x), price: ch.frame.priceAt(point.y), frame: ch.frame, press: { ...point, clientX: event.clientX, clientY: event.clientY } };
269
+ svg.setPointerCapture?.(event.pointerId);
270
+ setMoving({ id: hit.id, handle: hit.handle });
271
+ };
272
+ const onMove = (event) => {
273
+ keyboardMove.current = false;
274
+ // Once the pointer has gone past the tap slop it is a pan, even if it comes back before release.
275
+ if (tap && tap.pointerId === event.pointerId && Math.hypot(event.clientX - tap.x, event.clientY - tap.y) > 6)
276
+ tap = null;
277
+ if (!drag || drag.pointerId !== event.pointerId)
278
+ return;
279
+ event.stopPropagation();
280
+ const { ctx: c, chart: ch } = live.current;
281
+ // Locked (or removed) while held, for example by a second finger on Lock: the drag ends and the drawing stays put.
282
+ const current = c.drawings.find((d) => d.id === drag.start.id);
283
+ if (!current || current.locked) {
284
+ drag = null;
285
+ setMoving(null);
286
+ return;
287
+ }
288
+ const point = { x: drag.press.x + (event.clientX - drag.press.clientX), y: drag.press.y + (event.clientY - drag.press.clientY) };
289
+ let next;
290
+ if (drag.handle === 'body') {
291
+ const shift = drag.frame.indexAt(point.x) - drag.index;
292
+ next = moveDrawing(drag.start, c.snap === 'magnet' ? Math.round(shift) : shift, drag.frame.priceAt(point.y) - drag.price, ch.bars);
293
+ }
294
+ else {
295
+ const anchor = snapToBar(point, ch.bars, drag.frame, c.snap);
296
+ if (!anchor)
297
+ return;
298
+ next = moveAnchor(drag.start, drag.handle, anchor, ch.bars);
299
+ }
300
+ c.setDrawings(c.drawings.map((d) => (d.id === next.id ? next : d)));
301
+ };
302
+ const onUp = (event) => {
303
+ down.delete(event.pointerId);
304
+ if (tap && tap.pointerId === event.pointerId) {
305
+ const t = tap;
306
+ tap = null;
307
+ if (event.type === 'pointerup' && Math.hypot(event.clientX - t.x, event.clientY - t.y) <= 6 && event.timeStamp - t.at < 600)
308
+ live.current.ctx.setSelected(t.select ?? null);
309
+ }
310
+ if (!drag || drag.pointerId !== event.pointerId)
311
+ return;
312
+ event.stopPropagation();
313
+ // The browser took the gesture (a scroll or a pinch): nothing it moved was meant, so the drawing goes back.
314
+ if (event.type === 'pointercancel') {
315
+ const { ctx: c } = live.current;
316
+ const d = drag.start;
317
+ if (c.drawings.some((x) => x.id === d.id))
318
+ c.setDrawings(c.drawings.map((x) => (x.id === d.id ? d : x)));
319
+ }
320
+ drag = null;
321
+ setMoving(null);
322
+ };
323
+ const onKey = (event) => {
324
+ const { ctx: c, chart: ch } = live.current;
325
+ // Ctrl or Cmd + Z puts the last removal back while it can still be undone.
326
+ if ((event.ctrlKey || event.metaKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'z' && c.removed) {
327
+ event.preventDefault();
328
+ event.stopPropagation();
329
+ c.undo();
330
+ return;
331
+ }
332
+ // Enter places a point at the crosshair while a tool is in use.
333
+ if (event.key === 'Enter' && !event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey && c.tool !== null) {
334
+ event.preventDefault();
335
+ event.stopPropagation();
336
+ live.current.place();
337
+ return;
338
+ }
339
+ if (c.draft && !event.altKey && !event.ctrlKey && !event.metaKey && /^(Arrow(Left|Right)|Home|End)$/.test(event.key))
340
+ keyboardMove.current = true;
341
+ const key = resolveDrawingKey(event);
342
+ if (!key)
343
+ return;
344
+ let handled = false;
345
+ if (key.kind === 'escape') {
346
+ if (c.draft) {
347
+ c.announce(`${DRAWING_TOOL_LABELS[c.draft.tool]}: cancelled.`);
348
+ c.setDraft(null);
349
+ handled = true;
350
+ }
351
+ else if (c.tool !== null) {
352
+ c.setTool(null);
353
+ handled = true;
354
+ }
355
+ else if (c.selected !== null) {
356
+ c.setSelected(null);
357
+ handled = true;
358
+ }
359
+ }
360
+ else if (!c.draft && c.tool === null) {
361
+ const current = c.drawings.find((d) => d.id === c.selected);
362
+ if (current) {
363
+ handled = true;
364
+ if (!current.locked) {
365
+ if (key.kind === 'remove')
366
+ c.remove(current.id);
367
+ else {
368
+ const step = nudgeAmount(key, ch.frame);
369
+ const moved = moveDrawing(current, step.bars, step.price, ch.bars);
370
+ c.setDrawings(c.drawings.map((d) => (d.id === moved.id ? moved : d)));
371
+ }
372
+ }
373
+ }
374
+ }
375
+ if (handled) {
376
+ event.preventDefault();
377
+ event.stopPropagation();
378
+ }
379
+ };
380
+ svg.addEventListener('pointerdown', onDown);
381
+ svg.addEventListener('pointermove', onMove);
382
+ svg.addEventListener('pointerup', onUp);
383
+ svg.addEventListener('pointercancel', onUp);
384
+ svg.addEventListener('keydown', onKey);
385
+ return () => {
386
+ svg.removeEventListener('pointerdown', onDown);
387
+ svg.removeEventListener('pointermove', onMove);
388
+ svg.removeEventListener('pointerup', onUp);
389
+ svg.removeEventListener('pointercancel', onUp);
390
+ svg.removeEventListener('keydown', onKey);
391
+ svg.style.touchAction = touchActionBefore;
392
+ if (plotRef.current === svg)
393
+ plotRef.current = null;
394
+ };
395
+ }, [plotRef]);
396
+ // The chart lets a vertical swipe scroll the page (touch-action pan-y), and the Layer keeps that while idle. While a tool is
397
+ // armed, a drawing is selected, or one is being placed or dragged, a vertical touch drag moves or sizes a drawing and the browser
398
+ // would cancel it to scroll, so the plot keeps the gesture (none). The browser reads touch-action at touchstart, so it follows state.
399
+ const keepsTouch = ctx.tool !== null || ctx.selected !== null || ctx.draft !== null || moving !== null;
400
+ useEffect(() => {
401
+ const svg = groupRef.current?.ownerSVGElement;
402
+ if (svg)
403
+ svg.style.touchAction = keepsTouch ? 'none' : 'pan-y';
404
+ }, [keepsTouch]);
405
+ const anchorAt = (event) => {
406
+ const point = toChart(event.clientX, event.clientY);
407
+ return point ? { point, anchor: snapToBar(point, bars, frame, ctx.snap) } : null;
408
+ };
409
+ const commit = (tool, anchors) => {
410
+ const drawing = { id: nextDrawingId(ctx.drawings), tool, anchors, ...(tool === 'text' ? { text: newText } : {}) };
411
+ ctx.setDrawings([...ctx.drawings, drawing]);
412
+ ctx.setDraft(null);
413
+ ctx.setSelected(drawing.id);
414
+ ctx.setTool(null);
415
+ return drawing;
416
+ };
417
+ /** The anchor under the crosshair: the active bar (the last one before the crosshair has been moved) at the crosshair price. */
418
+ const crosshairAnchor = () => {
419
+ const index = chart.active !== null && bars[chart.active] ? chart.active : frame.last;
420
+ const bar = bars[index];
421
+ if (!bar)
422
+ return null;
423
+ const price = chart.pointerPrice ?? bar.close;
424
+ const snapped = snapToBar({ x: frame.x(index), y: frame.y(price) }, bars, frame, ctx.snap);
425
+ return { timestamp: bar.timestamp, price: ctx.snap === 'magnet' && snapped ? snapped.price : price };
426
+ };
427
+ const placeAtCrosshair = () => {
428
+ const tool = ctx.tool;
429
+ const anchor = crosshairAnchor();
430
+ if (tool === null || !anchor)
431
+ return;
432
+ const name = DRAWING_TOOL_LABELS[tool];
433
+ if (chart.active === null)
434
+ chart.setActive(Math.min(bars.length - 1, Math.max(0, frame.last)));
435
+ const point = describeAnchor(anchor, decimals, formatTime);
436
+ if (anchorCount(tool) === 1) {
437
+ const drawing = commit(tool, [anchor]);
438
+ ctx.announce(`${name}: placed at ${point}. Added ${describeDrawing(drawing, decimals, formatTime)}.`);
439
+ return;
440
+ }
441
+ if (!ctx.draft) {
442
+ ctx.setDraft({ tool, anchors: [anchor], preview: anchor });
443
+ ctx.announce(`${name}: first point at ${point}. Move the crosshair, then press Enter for the second point.`);
444
+ return;
445
+ }
446
+ const first = ctx.draft.anchors[0];
447
+ if (first.timestamp === anchor.timestamp && first.price === anchor.price) {
448
+ ctx.announce(`${name}: the second point is the same as the first. Move the crosshair, then press Enter.`);
449
+ return;
450
+ }
451
+ const drawing = commit(tool, [first, anchor]);
452
+ ctx.announce(`${name}: second point at ${point}. Added ${describeDrawing(drawing, decimals, formatTime)}.`);
453
+ };
454
+ live.current = { ctx, chart, place: placeAtCrosshair };
455
+ // With a drawing half placed, an arrow key moves its preview to the crosshair, as a pointer would.
456
+ useEffect(() => {
457
+ if (!keyboardMove.current)
458
+ return;
459
+ keyboardMove.current = false;
460
+ const { ctx: c, chart: ch } = live.current;
461
+ if (!c.draft || ch.active === null)
462
+ return;
463
+ const bar = ch.bars[ch.active];
464
+ if (bar)
465
+ c.setDraft({ ...c.draft, preview: { timestamp: bar.timestamp, price: ch.pointerPrice ?? bar.close } });
466
+ }, [chart.active]);
467
+ const onCaptureDown = (event) => {
468
+ event.stopPropagation(); // the plot must not start a pan
469
+ if (event.button !== 0 || ctx.tool === null)
470
+ return;
471
+ const at = anchorAt(event);
472
+ if (!at?.anchor)
473
+ return;
474
+ const tool = ctx.tool;
475
+ if (anchorCount(tool) === 1) {
476
+ commit(tool, [at.anchor]);
477
+ return;
478
+ }
479
+ if (!ctx.draft) {
480
+ ctx.setDraft({ tool, anchors: [at.anchor], preview: at.anchor });
481
+ gesture.current = { pointerId: event.pointerId, x: at.point.x, y: at.point.y };
482
+ event.currentTarget.setPointerCapture?.(event.pointerId);
483
+ return;
484
+ }
485
+ const first = ctx.draft.anchors[0];
486
+ if (first.timestamp === at.anchor.timestamp && first.price === at.anchor.price)
487
+ return; // a zero-size drawing
488
+ commit(tool, [first, at.anchor]);
489
+ };
490
+ const onCaptureMove = (event) => {
491
+ if (!ctx.draft)
492
+ return;
493
+ const at = anchorAt(event);
494
+ if (at?.anchor)
495
+ ctx.setDraft({ ...ctx.draft, preview: at.anchor });
496
+ };
497
+ const onCaptureUp = (event) => {
498
+ event.stopPropagation();
499
+ const held = gesture.current;
500
+ if (!held || held.pointerId !== event.pointerId)
501
+ return;
502
+ gesture.current = null;
503
+ const at = anchorAt(event);
504
+ if (!at?.anchor || !ctx.draft || !ctx.tool)
505
+ return;
506
+ if (Math.hypot(at.point.x - held.x, at.point.y - held.y) < DRAG_PIXELS)
507
+ return; // a click: wait for the second one
508
+ commit(ctx.tool, [ctx.draft.anchors[0], at.anchor]);
509
+ };
510
+ const draftDrawing = ctx.draft && ctx.draft.preview
511
+ ? { id: 'draft', tool: ctx.draft.tool, anchors: [ctx.draft.anchors[0], ctx.draft.preview] }
512
+ : null;
513
+ const fmt = (n) => n.toFixed(decimals);
514
+ const signed = (n) => `${n >= 0 ? '+' : ''}${fmt(n)}`;
515
+ // The preview is drawn outside the list, so no drawing id (even "draft") can share its key.
516
+ const renderDrawing = (drawing, isDraft) => {
517
+ const g = drawingGeometry(drawing, bars, frame);
518
+ const isSelected = !isDraft && drawing.id === ctx.selected;
519
+ const isMoving = !isDraft && moving?.id === drawing.id;
520
+ // passive: nothing is happening to it; active: being drawn or moved; selected: picked, with its anchors showing.
521
+ const state = isDraft || isMoving ? 'active' : isSelected ? 'selected' : 'passive';
522
+ return (_jsxs("g", { "data-mt": "", "data-part": "drawing", "data-tool": drawing.tool, "data-state": state, "data-selected": isSelected ? '' : undefined, "data-draft": isDraft ? '' : undefined, "data-locked": drawing.locked ? '' : undefined, children: [g.boxes.map((b, i) => _jsx("rect", { "data-part": "drawing-box", x: b.x, y: b.y, width: b.width, height: b.height }, `box${i}`)), g.segments.map((s, i) => _jsx("line", { "data-part": "drawing-line", x1: s.x1, y1: s.y1, x2: s.x2, y2: s.y2 }, `line${i}`)), g.levels.map((l) => (_jsxs("g", { "data-part": "drawing-level", "data-ratio": l.ratio, children: [_jsx("line", { x1: l.x1, y1: l.y, x2: l.x2, y2: l.y }), _jsx("text", { x: l.x2, y: l.y - 3, textAnchor: "end", children: `${l.ratio} (${fmt(l.price)})` })] }, l.ratio))), g.label && _jsx("text", { "data-part": "drawing-text", x: g.label.x, y: g.label.y, children: g.label.text }), g.range && g.boxes[0] && (_jsx("text", { "data-part": "drawing-range", "data-change": g.range.change > 0 ? 'up' : g.range.change < 0 ? 'down' : 'flat', x: g.boxes[0].x + g.boxes[0].width / 2, y: g.boxes[0].y - 4, textAnchor: "middle", children: `${signed(g.range.change)}${g.range.percent === null ? '' : ` (${g.range.percent >= 0 ? '+' : ''}${g.range.percent.toFixed(2)}%)`}, ${g.range.bars} ${g.range.bars === 1 ? 'bar' : 'bars'}` })), isSelected && drawing.locked && g.handles[0] && (_jsxs("g", { "data-part": "drawing-lock-mark", transform: `translate(${g.handles[0].x + 8} ${g.handles[0].y - 20})`, children: [_jsx("path", { d: "M3 7V5a3 3 0 0 1 6 0v2", fill: "none" }), _jsx("rect", { x: "1", y: "7", width: "10", height: "8", rx: "1.5" })] })), isSelected && !drawing.locked && g.handles.map((h) => _jsx("circle", { "data-part": "drawing-handle", "data-handle": h.index, "data-dragging": isMoving && moving?.handle === h.index ? '' : undefined, cx: h.x, cy: h.y, r: 5 }, h.index))] }, isDraft ? undefined : drawing.id));
523
+ };
524
+ return (_jsxs("g", { ref: groupRef, className: className, "data-part": "drawings", clipPath: `url(#${readoutId}-plotarea)`, "aria-hidden": "true", children: [ctx.drawings.map((drawing) => renderDrawing(drawing, false)), draftDrawing && renderDrawing(draftDrawing, true), ctx.tool !== null && (_jsx("rect", { "data-part": "drawing-capture", "data-tool": ctx.tool, x: 0, y: 0, width: frame.plotWidth, height: frame.plotHeight, fill: "transparent", onPointerDown: onCaptureDown, onPointerMove: onCaptureMove, onPointerUp: onCaptureUp, onPointerCancel: () => { gesture.current = null; } }))] }));
525
+ }
526
+ /**
527
+ * One button per tool, built on Toolbar: one tab stop, the arrow keys, Home and End move between the buttons, and Tab
528
+ * leaves. A pressed button is the tool in use; pressing it again puts the tool away. Escape sends focus to the chart.
529
+ * Every button has its tool in words, even when an icon is all that shows.
530
+ */
531
+ function Toolbar({ className, 'aria-label': label = 'Drawing tools', tools = DRAWING_TOOLS, icon, labels, hideLabels = false }) {
532
+ const { tool, setTool, plotRef } = useDrawings('Toolbar');
533
+ const iconOf = (name) => (typeof icon === 'function' ? icon(name) : icon?.[name]);
534
+ return (_jsx(ToolbarPart.Root, { className: className, "aria-label": label, "data-part": "drawing-toolbar", returnFocusTo: () => plotRef.current, children: tools.map((name) => (_jsx(ToolbarPart.Button, { "data-part": "drawing-tool", "data-tool": name, pressed: tool === name, icon: iconOf(name), label: labels?.[name] ?? DRAWING_TOOL_LABELS[name], hideLabel: hideLabels, onClick: () => {
535
+ if (tool !== name && !plotRef.current)
536
+ console.warn('Drawings.Toolbar: a tool is on but no Drawings.Layer is mounted, so clicks on the chart will draw nothing. Put <Drawings.Layer /> inside Chart.Plot.');
537
+ setTool(tool === name ? null : name);
538
+ } }, name))) }));
539
+ }
540
+ /**
541
+ * The drawings in words, with a button to select and a button to remove each. The selected one has aria-current.
542
+ * Delete or Backspace on a drawing's line removes it, as on the chart, and so does its Remove button. Focus moves to the
543
+ * next line; after the last line is removed it goes to the Undo button when Drawings.Undo is showing it, else to the empty
544
+ * list (the "No drawings" line), never to the top of the page.
545
+ */
546
+ function List({ className, 'aria-label': label = 'Drawings', decimals = 2, formatTime, empty = 'No drawings' }) {
547
+ const { drawings, selected, setSelected, remove, setLocked, undoRegion, focusRowNext } = useDrawings('List');
548
+ const listRef = useRef(null);
549
+ const emptyRef = useRef(null);
550
+ // Set when a removal here took the last row: the row's buttons are gone by the next render, so focus is placed then.
551
+ const lostFocus = useRef(false);
552
+ useEffect(() => {
553
+ if (!lostFocus.current || drawings.length > 0)
554
+ return;
555
+ lostFocus.current = false;
556
+ // The Undo button if it is showing (it is the likely next move), else the empty list, so focus never falls to the page.
557
+ (undoRegion.current?.querySelector("[data-part='drawing-undo-button']") ?? emptyRef.current)?.focus();
558
+ }, [drawings.length, undoRegion]);
559
+ // After the Undo button put a row back, focus goes to that row (the Undo button is gone), so it lands on something visible.
560
+ useEffect(() => {
561
+ if (!focusRowNext.current || selected === null)
562
+ return;
563
+ const row = listRef.current?.querySelector("li[aria-current='true'] [data-part='drawing-select']");
564
+ if (!row)
565
+ return;
566
+ focusRowNext.current = false;
567
+ row.focus();
568
+ }, [drawings, selected, focusRowNext]);
569
+ /** Remove a row, first moving focus to the next row (or the one before) so it does not fall to the top of the page. */
570
+ const removeRow = (drawing, index) => {
571
+ if (drawing.locked)
572
+ return;
573
+ const buttons = listRef.current?.querySelectorAll("[data-part='drawing-select']");
574
+ const neighbour = buttons?.[index + 1] ?? buttons?.[index - 1];
575
+ if (neighbour)
576
+ neighbour.focus();
577
+ else
578
+ lostFocus.current = true;
579
+ remove(drawing.id);
580
+ };
581
+ const onSelectKey = (event, drawing, index) => {
582
+ if (event.key !== 'Delete' && event.key !== 'Backspace')
583
+ return;
584
+ event.preventDefault();
585
+ removeRow(drawing, index);
586
+ };
587
+ if (drawings.length === 0)
588
+ return _jsx("p", { ref: emptyRef, tabIndex: -1, className: className, "data-part": "drawing-list", "data-empty": "", children: empty });
589
+ return (_jsx("ul", { ref: listRef, className: className, "aria-label": label, "data-part": "drawing-list", ...primitiveAttributes({}), children: drawings.map((drawing, index) => {
590
+ const text = describeDrawing(drawing, decimals, formatTime);
591
+ return (_jsxs("li", { "data-tool": drawing.tool, "data-selected": drawing.id === selected ? '' : undefined, "data-locked": drawing.locked ? '' : undefined, "aria-current": drawing.id === selected ? 'true' : undefined, children: [_jsx("button", { type: "button", "data-part": "drawing-select", "data-mt": "", "data-touch-target": "", onClick: () => setSelected(drawing.id), onKeyDown: (event) => onSelectKey(event, drawing, index), children: text }), _jsx("button", { type: "button", "data-part": "drawing-lock-toggle", "data-mt": "", "data-touch-target": "", "aria-pressed": !!drawing.locked, "aria-label": `Lock ${text}`, onClick: () => setLocked(drawing.id, !drawing.locked), children: "Lock" }), _jsx("button", { type: "button", "data-part": "drawing-remove", "data-mt": "", "data-touch-target": "", "aria-label": `Remove ${text}`, disabled: drawing.locked, onClick: () => removeRow(drawing, index), children: "Remove" })] }, drawing.id));
592
+ }) }));
593
+ }
594
+ /**
595
+ * A button that removes the selected drawing, or with `all` every drawing that is not locked. It acts only on a
596
+ * selection that already exists, never on what was under a finger when it was pressed, and the removal can be undone
597
+ * (see Undo). With nothing to remove it is `aria-disabled` rather than disabled, so keyboard focus stays on it.
598
+ */
599
+ function Remove({ className, all = false, icon, label, hideLabel, children }) {
600
+ const { drawings, selected, remove, removeAll, focusUndoNext } = useDrawings('Remove');
601
+ const face = buttonFace(all ? 'Remove all' : 'Remove', { icon, label, hideLabel, children });
602
+ const target = all ? drawings.some((d) => !d.locked) : drawings.some((d) => d.id === selected && !d.locked);
603
+ return (_jsx("button", { type: "button", className: className, "data-part": all ? 'drawing-remove-all' : 'drawing-remove-selected', "data-mt": "", "data-touch-target": "", "aria-label": face['aria-label'], "aria-disabled": target ? undefined : true, onClick: (event) => { if (!target)
604
+ return; focusUndoNext.current = document.activeElement === event.currentTarget; if (all)
605
+ removeAll();
606
+ else if (selected !== null)
607
+ remove(selected); }, children: face.content }));
608
+ }
609
+ /**
610
+ * Says what the last removal took out and offers Undo for as long as the Root's undoWindow lasts (10 seconds by
611
+ * default). The region is always in the page and announced politely, so a screen reader hears each removal as it
612
+ * happens. It is also where the other drawing announcements land (a point placed from the keyboard, an applied Edit), in
613
+ * a `data-part='drawing-status'` line, so one live region carries everything. Ctrl or Cmd + Z does the same from the chart. Only the last removal can be undone. Undo says what it restored in the same region. Focus moves to the
614
+ * restored row of Drawings.List when one is showing, else to the region (which then holds that sentence), so the button going away does not drop it to the top of the page.
615
+ */
616
+ function Undo({ className, decimals = 2, formatTime, icon, label, hideLabel }) {
617
+ const { removed, undo, undoWindow, status, undoRegion } = useDrawings('Undo');
618
+ const face = buttonFace('Undo', { icon, label, hideLabel });
619
+ const region = useRef(null);
620
+ const buttonHadFocus = useRef(false);
621
+ const runs = useRef(0);
622
+ // One number per removal, so the timer line starts over even when the same drawing is removed twice.
623
+ const run = useMemo(() => ++runs.current, [removed]);
624
+ const text = !removed ? '' : removed.length === 1 ? `Removed ${describeDrawing(removed[0], decimals, formatTime)}.` : `Removed ${removed.length} drawings.`;
625
+ // When the window runs out under a focused Undo button, focus stays in the controls instead of dropping to the page.
626
+ useEffect(() => {
627
+ if (removed === null && buttonHadFocus.current) {
628
+ buttonHadFocus.current = false;
629
+ region.current?.focus();
630
+ }
631
+ }, [removed]);
632
+ return (_jsxs("div", { ref: (node) => { region.current = node; undoRegion.current = node; }, tabIndex: -1, className: className, role: "status", "aria-live": "polite", "data-part": "drawing-undo", "data-empty": removed || status ? undefined : '', children: [removed && _jsx("span", { "data-part": "drawing-undo-text", children: text }), status && _jsx("span", { "data-part": "drawing-status", children: status }), removed && (_jsx("button", { type: "button", "data-part": "drawing-undo-button", "data-mt": "", "data-touch-target": "", onFocus: () => { buttonHadFocus.current = true; }, onBlur: (event) => { if (event.currentTarget.isConnected)
633
+ buttonHadFocus.current = false; }, "aria-label": face['aria-label'], onClick: () => { buttonHadFocus.current = false; undo({ describe: (d) => describeDrawing(d, decimals, formatTime), focusRow: true }); region.current?.focus(); }, children: face.content })), removed && undoWindow > 0 && _jsx("span", { "aria-hidden": "true", "data-part": "drawing-undo-timer", style: { ['--mt-undo-window']: `${undoWindow}ms` } }, run)] }));
634
+ }
635
+ /**
636
+ * A toggle for the selected drawing's lock. Pressed means locked: it cannot be dragged, nudged, edited by typing or
637
+ * removed until it is unlocked. With nothing selected it is `aria-disabled` and does nothing.
638
+ */
639
+ function Lock({ className, icon, label, hideLabel, children }) {
640
+ const { drawings, selected, setLocked } = useDrawings('Lock');
641
+ const face = buttonFace('Lock', { icon, label, hideLabel, children });
642
+ const drawing = drawings.find((d) => d.id === selected);
643
+ return (_jsx("button", { type: "button", className: className, "data-part": "drawing-lock", "data-mt": "", "data-touch-target": "", "aria-label": face['aria-label'], "aria-pressed": drawing ? !!drawing.locked : false, "aria-disabled": drawing ? undefined : true, onClick: () => { if (drawing)
644
+ setLocked(drawing.id, !drawing.locked); }, children: face.content }));
645
+ }
646
+ /**
647
+ * Type exact values for the selected drawing: a price and/or a time (UTC) for each anchor, and the words of a text
648
+ * note. A closed "Edit" button opens a small form with only the fields that tool has (a horizontal line has just a
649
+ * price). Enter applies, Escape closes without applying, a bad value is named next to its field and nothing is half
650
+ * applied and "Updated ..." is announced in Drawings.Undo's live region. This is also the way to place a line at an exact
651
+ * price without a pointer. A locked drawing cannot be edited.
652
+ */
653
+ function Edit({ className, decimals: rawDecimals = 2, formatTime, icon, label, hideLabel, children }) {
654
+ const decimals = Number.isFinite(rawDecimals) ? Math.min(20, Math.max(0, Math.round(rawDecimals))) : 2; // toFixed throws outside 0 to 100
655
+ const { drawings, selected, setDrawings, bars, announce } = useDrawings('Edit');
656
+ const face = buttonFace('Edit', { icon, label, hideLabel, children });
657
+ const [open, setOpen] = useState(false);
658
+ const toggle = useRef(null);
659
+ const baseId = useId();
660
+ const drawing = drawings.find((d) => d.id === selected);
661
+ const canEdit = !!drawing && !drawing.locked;
662
+ useEffect(() => { if (open && !canEdit)
663
+ setOpen(false); }, [open, canEdit]);
664
+ const hint = !drawing ? 'Select a drawing to edit it.' : drawing.locked ? 'Unlock this drawing to edit it.' : undefined;
665
+ const close = () => { setOpen(false); toggle.current?.focus(); };
666
+ return (_jsxs("div", { className: className, "data-part": "drawing-edit", "data-open": open && canEdit ? '' : undefined, onKeyDown: (event) => { if (event.key === 'Escape' && open) {
667
+ event.stopPropagation();
668
+ close();
669
+ } }, children: [_jsx("button", { ref: toggle, type: "button", "data-part": "drawing-edit-toggle", "data-mt": "", "data-touch-target": "", "aria-expanded": open && canEdit, "aria-controls": `${baseId}-form`, "aria-disabled": canEdit ? undefined : true, "aria-describedby": hint ? `${baseId}-hint` : undefined, "aria-label": face['aria-label'], onClick: () => { if (canEdit)
670
+ setOpen((o) => !o); }, children: face.content }), hint && _jsx("span", { id: `${baseId}-hint`, "data-part": "drawing-edit-hint", hidden: true, children: hint }), open && drawing && canEdit && (_jsx(EditForm, { id: `${baseId}-form`, drawing: drawing, decimals: decimals, bars: bars, onApply: (next) => { setDrawings(drawings.map((d) => (d.id === next.id ? next : d))); announce(`Updated ${describeDrawing(next, decimals, formatTime)}.`); close(); }, onCancel: close }, `${drawing.id}:${JSON.stringify(drawing.anchors)}:${drawing.text ?? ''}`))] }));
671
+ }
672
+ function EditForm({ id, drawing, decimals, bars, onApply, onCancel }) {
673
+ const count = anchorCount(drawing.tool);
674
+ const showPrice = drawing.tool !== 'vertical-line';
675
+ const showTime = drawing.tool !== 'horizontal-line';
676
+ // A horizontal line holds its price and a vertical line its time by tool, so only the other tools pick an attach mode.
677
+ const showAttach = drawing.tool !== 'horizontal-line' && drawing.tool !== 'vertical-line';
678
+ const showBar = showTime && bars.length > 0;
679
+ // The fields show rounded values (price to `decimals`, time to the second). A field the person did not touch keeps the
680
+ // drawing's exact value, so applying a change to one field never rounds the others.
681
+ const [shownPrices] = useState(() => drawing.anchors.slice(0, count).map((a) => a.price.toFixed(decimals)));
682
+ const [shownTimes] = useState(() => drawing.anchors.slice(0, count).map((a) => toTimeField(a.timestamp)));
683
+ const [shownBars] = useState(() => drawing.anchors.slice(0, count).map((a) => barField(barsFromLast(bars, a.timestamp))));
684
+ const [shownAttach] = useState(() => drawing.anchors.slice(0, count).map((a) => attachOf(a)));
685
+ const [prices, setPrices] = useState(shownPrices);
686
+ const [times, setTimes] = useState(shownTimes);
687
+ const [barText, setBarText] = useState(shownBars);
688
+ const [attach, setAttach] = useState(shownAttach);
689
+ // Time and Bar are two views of one value: the one typed last is the one applied, and the other follows it on screen.
690
+ const [lastTyped, setLastTyped] = useState(() => shownAttach.map(() => 'time'));
691
+ const typed = (i, kind) => setLastTyped(lastTyped.map((v, j) => (j === i ? kind : v)));
692
+ // When bars arrive while the form is open, a count that follows a time is recounted against the new series, so the Bar
693
+ // and Time fields keep describing one position. A count the person typed is left as typed.
694
+ useEffect(() => {
695
+ if (bars.length === 0)
696
+ return;
697
+ setBarText((current) => current.map((v, j) => {
698
+ const at = lastTyped[j] === 'time' ? fromTimeField(times[j]) : NaN;
699
+ return Number.isFinite(at) ? barField(barsFromLast(bars, at)) : v;
700
+ }));
701
+ // A count typed and not yet applied keeps its number; the Time beside it moves to that count on the new series.
702
+ setTimes((current) => current.map((v, j) => {
703
+ const n = lastTyped[j] === 'bar' ? parseBarField(barText[j]) : null;
704
+ const at = n === null ? null : timeOfBarFromLast(bars, n);
705
+ return at === null ? v : toTimeField(at);
706
+ }));
707
+ // eslint-disable-next-line react-hooks/exhaustive-deps
708
+ }, [bars]);
709
+ const onTime = (i, value) => {
710
+ setTimes(times.map((v, j) => (j === i ? value : v)));
711
+ const at = fromTimeField(value);
712
+ if (bars.length > 0 && Number.isFinite(at))
713
+ setBarText(barText.map((v, j) => (j === i ? barField(barsFromLast(bars, at)) : v)));
714
+ typed(i, 'time');
715
+ };
716
+ const onBar = (i, value) => {
717
+ setBarText(barText.map((v, j) => (j === i ? value : v)));
718
+ const n = parseBarField(value);
719
+ const at = n === null ? null : timeOfBarFromLast(bars, n);
720
+ if (at !== null && Number.isFinite(at))
721
+ setTimes(times.map((v, j) => (j === i ? toTimeField(at) : v)));
722
+ typed(i, 'bar');
723
+ };
724
+ const [text, setText] = useState(drawing.text ?? '');
725
+ const [error, setError] = useState(null);
726
+ const form = useRef(null);
727
+ // Opening the form in a full-screen frame on a phone can leave it below the fold: bring it (and Apply, Cancel) into view.
728
+ useEffect(() => {
729
+ const el = form.current;
730
+ if (!el || typeof el.scrollIntoView !== 'function')
731
+ return;
732
+ const still = typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
733
+ el.scrollIntoView({ block: 'nearest', behavior: still ? 'auto' : 'smooth' });
734
+ }, []);
735
+ const name = DRAWING_TOOL_LABELS[drawing.tool];
736
+ const label = (what, i) => (count === 1 ? what : `${i === 0 ? 'First' : 'Second'} ${what.toLowerCase()}`);
737
+ const submit = (event) => {
738
+ event.preventDefault();
739
+ for (let i = 0; i < count; i += 1) {
740
+ if (showPrice && prices[i] !== shownPrices[i] && !Number.isFinite(fromPriceField(prices[i])))
741
+ return fail(`price-${i}`, `${label('Price', i)}: enter a number such as 101.25.`);
742
+ if (showBar && lastTyped[i] === 'bar' && barText[i] !== shownBars[i] && parseBarField(barText[i]) === null)
743
+ return fail(`bar-${i}`, `${label('Bar', i)}: enter a whole number such as -12 (0 is the last bar).`);
744
+ if (showBar && lastTyped[i] === 'bar' && barText[i] !== shownBars[i] && timeOfBarFromLast(bars, parseBarField(barText[i])) === null)
745
+ return fail(`bar-${i}`, `${label('Bar', i)}: that is too far from the chart. Try a smaller number.`);
746
+ if (showTime && !(showBar && lastTyped[i] === 'bar' && barText[i] !== shownBars[i]) && times[i] !== shownTimes[i] && !Number.isFinite(fromTimeField(times[i])))
747
+ return fail(`time-${i}`, `${label('Time', i)}: enter a date and time.`);
748
+ }
749
+ const next = editDrawing(drawing, {
750
+ anchors: Array.from({ length: count }, (_, i) => {
751
+ // A typed bar number is exact (a bar's own time); a typed time is read to the second. Whole-bar anchors sit on a bar.
752
+ const byBar = showBar && lastTyped[i] === 'bar' && barText[i] !== shownBars[i];
753
+ const typedTime = byBar ? timeOfBarFromLast(bars, parseBarField(barText[i])) : showTime && times[i] !== shownTimes[i] ? fromTimeField(times[i]) : undefined;
754
+ const mode = showAttach ? attach[i] : shownAttach[i];
755
+ const timestamp = mode === 'bar' && (typedTime !== undefined || mode !== shownAttach[i]) ? barTime(bars, typedTime ?? drawing.anchors[i].timestamp) : typedTime;
756
+ return {
757
+ ...(showPrice && prices[i] !== shownPrices[i] ? { price: fromPriceField(prices[i]) } : {}),
758
+ ...(timestamp !== undefined && timestamp !== null ? { timestamp } : {}),
759
+ ...(showAttach && attach[i] !== shownAttach[i] ? { attach: attach[i] } : {}),
760
+ };
761
+ }),
762
+ text,
763
+ });
764
+ if (next === drawing)
765
+ return fail('', 'That value cannot be used.');
766
+ onApply(next);
767
+ };
768
+ const fail = (field, message) => {
769
+ setError({ field, message });
770
+ if (field)
771
+ form.current?.querySelector(`[data-field='${field}']`)?.focus();
772
+ };
773
+ return (_jsxs("form", { ref: form, id: id, role: "group", "aria-label": `Edit ${name}`, "data-part": "drawing-edit-form", noValidate: true, onSubmit: submit, children: [drawing.anchors.slice(0, count).map((_, i) => (_jsxs("fieldset", { "data-part": "drawing-edit-anchor", "data-anchor": i, children: [count > 1 && _jsx("legend", { children: i === 0 ? 'First point' : 'Second point' }), showPrice && (_jsxs("label", { children: [_jsx("span", { children: "Price" }), _jsx("input", { type: "text", inputMode: "decimal", autoComplete: "off", "data-mt": "", "data-touch-target": "", "data-field": `price-${i}`, value: prices[i], "aria-invalid": error?.field === `price-${i}` || undefined, "aria-describedby": error?.field === `price-${i}` ? `${id}-error` : undefined, onChange: (e) => setPrices(prices.map((v, j) => (j === i ? e.target.value : v))) })] })), showTime && (_jsxs("label", { children: [_jsx("span", { children: "Time (UTC)" }), _jsx("input", { type: "datetime-local", step: 1, "data-mt": "", "data-touch-target": "", "data-field": `time-${i}`, value: times[i], "aria-invalid": error?.field === `time-${i}` || undefined, "aria-describedby": error?.field === `time-${i}` ? `${id}-error` : undefined, onChange: (e) => onTime(i, e.target.value) })] })), showBar && (_jsxs("label", { children: [_jsx("span", { children: "Bar (from last)" }), _jsx("input", { type: "text", autoComplete: "off", "data-mt": "", "data-touch-target": "", "data-field": `bar-${i}`, value: barText[i], "aria-invalid": error?.field === `bar-${i}` || undefined, "aria-describedby": `${id}-bar-note-${i}${error?.field === `bar-${i}` ? ` ${id}-error` : ''}`, onChange: (e) => onBar(i, e.target.value) }), _jsx("small", { id: `${id}-bar-note-${i}`, "data-part": "drawing-edit-bar-note", children: barNote(bars, lastTyped[i] === 'bar' ? parseBarField(barText[i]) : null, fromTimeField(times[i])) })] })), showAttach && (_jsxs("label", { children: [_jsx("span", { children: "Attach" }), _jsx("select", { "data-mt": "", "data-touch-target": "", "data-field": `attach-${i}`, value: attach[i], onChange: (e) => setAttach(attach.map((v, j) => (j === i ? e.target.value : v))), children: ATTACH_MODES.map((mode) => _jsx("option", { value: mode, children: ATTACH_LABELS[mode] }, mode)) })] }))] }, i))), drawing.tool === 'text' && (_jsxs("label", { children: [_jsx("span", { children: "Words" }), _jsx("input", { type: "text", "data-mt": "", "data-touch-target": "", "data-field": "text", value: text, onChange: (e) => setText(e.target.value) })] })), _jsx("p", { id: `${id}-error`, role: "alert", "data-part": "drawing-edit-error", hidden: !error, children: error?.message }), _jsxs("div", { "data-part": "drawing-edit-actions", children: [_jsx("button", { type: "submit", "data-part": "drawing-edit-apply", "data-mt": "", "data-touch-target": "", children: "Apply" }), _jsx("button", { type: "button", "data-part": "drawing-edit-cancel", "data-mt": "", "data-touch-target": "", onClick: onCancel, children: "Cancel" })] })] }));
774
+ }
775
+ export { Root, Layer, Toolbar, List, Remove, Undo, Lock, Edit };