@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,1080 @@
1
+ import { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type MutableRefObject, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react';
2
+ import { buttonFace, type NameProps } from './accessible-name.js';
3
+ import { primitiveAttributes } from './attributes.js';
4
+ import { useChart } from './ChartParts.js';
5
+ import { SelectionContext } from './selection-context.js';
6
+ import * as ToolbarPart from './ToolbarParts.js';
7
+ import type { CandleBar, ChartFrame } from './core/chart-scale.js';
8
+ import { barField, barNote, fromPriceField, fromTimeField, parseBarField, toTimeField } from './core/drawing-fields.js';
9
+ import {
10
+ ATTACH_LABELS, ATTACH_MODES, DRAWING_TOOLS, DRAWING_TOOL_LABELS, anchorCount, attachOf, barTime, barsFromLast, describeAnchor, describeDrawing, drawingGeometry,
11
+ editDrawing, hitTest, moveAnchor, moveDrawing, nextDrawingId, nudgeAmount, resolveDrawingKey, snapToBar, timeOfBarFromLast,
12
+ type AttachMode, type Drawing, type DrawingAnchor, type DrawingTool, type SnapMode,
13
+ } from './core/drawings.js';
14
+
15
+ /**
16
+ * Drawings as parts: Root, Layer, Toolbar, List, Remove, Undo. Headless: parts draw SVG and add `data-part`, `data-tool`,
17
+ * `data-selected` and `data-draft`; the host gives them colour. Drawings are anchored to a time and a price, so they
18
+ * stay put when the chart pans, zooms or gets new bars.
19
+ *
20
+ * Root only holds state, with no element of its own, so put it above both the Chart and the Toolbar and List. Layer
21
+ * goes inside Chart.Plot. It takes no tab stop of its own: it listens for keys on the plot's own tab stop and only
22
+ * answers when a drawing is selected or a tool is in use, so the chart's keys work as before the rest of the time.
23
+ */
24
+
25
+ /** A drawing being placed: the anchors set so far and where the pointer is now. */
26
+ interface Draft {
27
+ tool: DrawingTool;
28
+ anchors: DrawingAnchor[];
29
+ preview: DrawingAnchor | null;
30
+ }
31
+
32
+ interface DrawingsContext {
33
+ drawings: readonly Drawing[];
34
+ setDrawings: (next: Drawing[]) => void;
35
+ tool: DrawingTool | null;
36
+ setTool: (tool: DrawingTool | null) => void;
37
+ selected: string | null;
38
+ setSelected: (id: string | null) => void;
39
+ snap: SnapMode;
40
+ draft: Draft | null;
41
+ setDraft: (draft: Draft | null) => void;
42
+ remove: (id: string) => void;
43
+ /** Lock or unlock one drawing. A locked drawing keeps its place: no drag, nudge, typed edit or removal. */
44
+ setLocked: (id: string, locked: boolean) => void;
45
+ /** Remove every drawing that is not locked. Undo puts them back. */
46
+ removeAll: () => void;
47
+ /** What the last removal took out, or null once it expired or was undone. */
48
+ removed: readonly Drawing[] | null;
49
+ /** Put the last removal back where it was. */
50
+ /** Puts the last removal back and says so in the live region. `describe` prints a drawing (default two decimals, UTC time); `focusRow` moves focus to the restored row of a List. */
51
+ undo: (options?: { describe?: (drawing: Drawing) => string; focusRow?: boolean }) => void;
52
+ /** How long a removal can be undone, in milliseconds; 0 means until the next removal. */
53
+ undoWindow: number;
54
+ /** The bars of the chart the Layer sits in (empty until a Layer mounts); the Edit form counts bars from them. */
55
+ bars: readonly CandleBar[];
56
+ setBars: (bars: readonly CandleBar[]) => void;
57
+ /** Say something in the polite live region of Drawings.Undo, for example "Updated Trend line ...". */
58
+ announce: (text: string) => void;
59
+ /** The last thing announced, or null. */
60
+ status: string | null;
61
+ /** The chart's plot element once a Layer mounts: where Escape in the toolbar sends focus. */
62
+ plotRef: MutableRefObject<SVGSVGElement | null>;
63
+ /** The Drawings.Undo region once it mounts, so a list that lost its last row can put focus on its Undo button. */
64
+ undoRegion: MutableRefObject<HTMLElement | null>;
65
+ /** Set by the Undo button: the List then focuses the restored row once it is back. */
66
+ focusRowNext: MutableRefObject<boolean>;
67
+ /** Set by a button that is about to remove drawings while focused: the Undo button takes focus once it shows. */
68
+ focusUndoNext: MutableRefObject<boolean>;
69
+ }
70
+ const Context = createContext<DrawingsContext | null>(null);
71
+
72
+ /** The drawings state of the Drawings.Root above. */
73
+ export function useDrawings(part = 'part'): DrawingsContext {
74
+ const context = useContext(Context);
75
+ if (!context) throw new Error(`Drawings.${part} needs a Drawings.Root above it.`);
76
+ return context;
77
+ }
78
+
79
+ export interface DrawingsRootProps {
80
+ /** Controlled drawings; without it Root keeps its own list. */
81
+ drawings?: readonly Drawing[];
82
+ onDrawingsChange?: (drawings: Drawing[]) => void;
83
+ /** Controlled tool in use, or null for none. */
84
+ tool?: DrawingTool | null;
85
+ onToolChange?: (tool: DrawingTool | null) => void;
86
+ /** Controlled selected drawing id, or null. */
87
+ selected?: string | null;
88
+ onSelectedChange?: (id: string | null) => void;
89
+ /** 'magnet' puts new points on the nearest bar and its open, high, low or close. Default 'off'. */
90
+ snap?: SnapMode;
91
+ /** How long the last removal can be undone, in milliseconds. 0 keeps it until the next removal. Default 10 000. */
92
+ undoWindow?: number;
93
+ children?: ReactNode;
94
+ }
95
+
96
+ /** State for the other parts. Each of drawings, tool and selection is controlled when given, and kept here when not. */
97
+ function Root({ drawings, onDrawingsChange, tool, onToolChange, selected, onSelectedChange, snap = 'off', undoWindow = 10_000, children }: DrawingsRootProps) {
98
+ const [innerDrawings, setInnerDrawings] = useState<readonly Drawing[]>([]);
99
+ const [innerTool, setInnerTool] = useState<DrawingTool | null>(null);
100
+ const [innerSelected, setInnerSelected] = useState<string | null>(null);
101
+ const [draft, setDraft] = useState<Draft | null>(null);
102
+ // Bar numbers only need the times, so a new array with the same length and ends is the same chart.
103
+ const [bars, setBarsState] = useState<readonly CandleBar[]>([]);
104
+ const setBars = useCallback((next: readonly CandleBar[]) => {
105
+ setBarsState((prev) => (prev.length === next.length && prev[0]?.timestamp === next[0]?.timestamp && prev[prev.length - 1]?.timestamp === next[next.length - 1]?.timestamp ? prev : next));
106
+ }, []);
107
+ const [status, setStatus] = useState<string | null>(null);
108
+ const plotRef = useRef<SVGSVGElement | null>(null);
109
+ const undoRegion = useRef<HTMLElement | null>(null);
110
+ const focusUndoNext = useRef(false);
111
+ const focusRowNext = useRef(false);
112
+ const list = drawings ?? innerDrawings;
113
+ const activeTool = tool !== undefined ? tool : innerTool;
114
+ // With a Selection.Root above, the selection lives there and a selected drawing is { kind: 'drawing' }; `selected` is then ignored.
115
+ const sel = useContext(SelectionContext);
116
+ const selNow = sel?.selection;
117
+ const activeSelected = sel ? (selNow && selNow.kind === 'drawing' ? selNow.id : null) : selected !== undefined ? selected : innerSelected;
118
+ const selSelect = sel?.select;
119
+ const selCurrent = sel?.current;
120
+ const selSetName = sel?.setName;
121
+
122
+ // The array this Root last handed out, so a list it did not make (a symbol switch) can be told from its own.
123
+ const emitted = useRef<readonly Drawing[] | null>(null);
124
+ const setDrawings = useCallback((next: Drawing[]) => {
125
+ emitted.current = next;
126
+ if (drawings === undefined) setInnerDrawings(next);
127
+ onDrawingsChange?.(next);
128
+ }, [drawings, onDrawingsChange]);
129
+ const setTool = useCallback((next: DrawingTool | null) => {
130
+ if (tool === undefined) setInnerTool(next);
131
+ onToolChange?.(next);
132
+ }, [tool, onToolChange]);
133
+ const setSelected = useCallback((next: string | null) => {
134
+ if (selSelect && selCurrent) {
135
+ // Putting a drawing away must not take a selected series or study with it.
136
+ if (next !== null) {
137
+ // Name it first, so the live region says the drawing in words rather than its id.
138
+ const known = list.find((d) => d.id === next);
139
+ if (known && selSetName) selSetName('drawing', next, describeDrawing(known, 2));
140
+ selSelect({ kind: 'drawing', id: next });
141
+ }
142
+ else if (selCurrent()?.kind === 'drawing') selSelect(null);
143
+ } else if (selected === undefined) setInnerSelected(next);
144
+ onSelectedChange?.(next);
145
+ }, [selected, onSelectedChange, selSelect, selCurrent, selSetName, list]);
146
+ // The live region names the selected drawing in words.
147
+ const selectedDrawing = activeSelected !== null ? list.find((d) => d.id === activeSelected) : undefined;
148
+ useEffect(() => {
149
+ if (selSetName && selectedDrawing) selSetName('drawing', selectedDrawing.id, describeDrawing(selectedDrawing, 2));
150
+ }, [selSetName, selectedDrawing]);
151
+ // The last removal, with where each drawing sat, so Undo can put it back. Only the last one is kept.
152
+ const [lastRemoval, setLastRemoval] = useState<{ items: { drawing: Drawing; index: number }[] } | null>(null);
153
+ useEffect(() => {
154
+ if (!lastRemoval || !(undoWindow > 0)) return;
155
+ const timer = setTimeout(() => setLastRemoval(null), undoWindow);
156
+ return () => clearTimeout(timer);
157
+ }, [lastRemoval, undoWindow]);
158
+ // A pending Undo belongs to the list the removal made. When the host hands back another list (a symbol switch, or one
159
+ // that refused the removal), putting the drawing back would add it to a list it never came from. A later list the Root
160
+ // 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
161
+ // itself, not its ids: a new empty array after a removal is a different list even though its content is equal. A host that
162
+ // copies the array it was given (same drawings, same order) is the one exception.
163
+ useEffect(() => {
164
+ if (!lastRemoval || emitted.current === null) return;
165
+ const mine = emitted.current;
166
+ const copy = list.length > 0 && list.length === mine.length && list.every((d, i) => d === mine[i]);
167
+ if (list !== mine && !copy) setLastRemoval(null);
168
+ }, [list, lastRemoval]);
169
+ const removeWhere = useCallback((gone: (d: Drawing) => boolean) => {
170
+ const items = list.flatMap((drawing, index) => (gone(drawing) ? [{ drawing, index }] : []));
171
+ if (items.length === 0) return;
172
+ setDrawings(list.filter((d) => !gone(d)));
173
+ if (activeSelected !== null && items.some((i) => i.drawing.id === activeSelected)) setSelected(null);
174
+ setLastRemoval({ items });
175
+ setStatus(null);
176
+ }, [list, setDrawings, activeSelected, setSelected]);
177
+ const remove = useCallback((id: string) => removeWhere((d) => d.id === id && !d.locked), [removeWhere]);
178
+ const removeAll = useCallback(() => removeWhere((d) => !d.locked), [removeWhere]);
179
+ const setLocked = useCallback((id: string, locked: boolean) => {
180
+ setDrawings(list.map((d) => {
181
+ if (d.id !== id) return d;
182
+ const { locked: _was, ...rest } = d;
183
+ return locked ? { ...rest, locked: true } : rest;
184
+ }));
185
+ }, [list, setDrawings]);
186
+ const undo = useCallback((options?: { describe?: (drawing: Drawing) => string; focusRow?: boolean }) => {
187
+ if (!lastRemoval) return;
188
+ const next = [...list];
189
+ let restored: string | null = null;
190
+ // Ascending, so each index is still right after the ones before it went back in.
191
+ for (const { drawing, index } of lastRemoval.items) {
192
+ // A drawing added since may have taken the id; the restored one then gets a fresh id.
193
+ const back = next.some((d) => d.id === drawing.id) ? { ...drawing, id: nextDrawingId(next) } : drawing;
194
+ next.splice(Math.min(index, next.length), 0, back);
195
+ restored = back.id;
196
+ }
197
+ setDrawings(next);
198
+ if (lastRemoval.items.length === 1 && restored !== null) setSelected(restored);
199
+ setLastRemoval(null);
200
+ const describe = options?.describe ?? ((d: Drawing) => describeDrawing(d, 2));
201
+ const items = lastRemoval.items;
202
+ // The live region says what came back, so the silent return of a row is heard.
203
+ setStatus(items.length === 1 ? `Restored ${describe(items[0].drawing)}.` : `Restored ${items.length} drawings.`);
204
+ focusRowNext.current = !!options?.focusRow && items.length === 1;
205
+ }, [lastRemoval, list, setDrawings, setSelected]);
206
+ const removed = useMemo(() => (lastRemoval ? lastRemoval.items.map((i) => i.drawing) : null), [lastRemoval]);
207
+
208
+ // A focused Remove button may be gone after the removal (the selection it acted on is cleared): Undo takes focus instead.
209
+ useEffect(() => {
210
+ if (!lastRemoval || !focusUndoNext.current) return;
211
+ focusUndoNext.current = false;
212
+ undoRegion.current?.querySelector<HTMLElement>("[data-part='drawing-undo-button']")?.focus();
213
+ }, [lastRemoval]);
214
+ // A half-placed drawing belongs to the tool that began it.
215
+ useEffect(() => {
216
+ setDraft((current) => (current && current.tool !== activeTool ? null : current));
217
+ }, [activeTool]);
218
+
219
+ const context = useMemo<DrawingsContext>(() => ({
220
+ drawings: list, setDrawings, tool: activeTool, setTool, selected: activeSelected, setSelected, snap, draft, setDraft, remove, setLocked, removeAll, removed, undo, undoWindow, bars, setBars,
221
+ announce: setStatus, status, plotRef, undoRegion, focusUndoNext, focusRowNext,
222
+ }), [list, setDrawings, activeTool, setTool, activeSelected, setSelected, snap, draft, remove, setLocked, removeAll, removed, undo, undoWindow, bars, setBars, status, plotRef, undoRegion, focusUndoNext, focusRowNext]);
223
+ return <Context.Provider value={context}>{children}</Context.Provider>;
224
+ }
225
+
226
+ /** What a drag in progress holds on to: the drawing as it was, so each move is measured from the start. */
227
+ interface Drag {
228
+ pointerId: number;
229
+ handle: number | 'body';
230
+ start: Drawing;
231
+ index: number;
232
+ price: number;
233
+ /** The chart frame at the press. The axis rescales to fit a moved drawing, so a drag reads pixels through this one. */
234
+ frame: ChartFrame;
235
+ /** Where the press was, in chart pixels and on screen. A move is measured from here, so a host that reflows during the drag cannot make the drawing jump. */
236
+ press: { x: number; y: number; clientX: number; clientY: number };
237
+ }
238
+
239
+ /** How far from an anchor handle, in pixels, a touch still grabs it. */
240
+ const TOUCH_HANDLE_PX = 22;
241
+
242
+ /** A pointer travel, in pixels, past which a press-and-release counts as a drag rather than a click. */
243
+ const DRAG_PIXELS = 4;
244
+
245
+ /**
246
+ * Draws every drawing and the handles of the selected one. Put it inside Chart.Plot, after the candles.
247
+ *
248
+ * With a tool in use it adds a transparent `drawing-capture` rect over the plot: press to set the first point, move
249
+ * to preview, then release after a drag or click again to set the second. One-point tools place on the press. A new
250
+ * drawing is selected and the tool is put away. With no tool, pressing a drawing selects it and dragging moves it,
251
+ * or moves one anchor when a handle is grabbed; pressing anywhere else is left to the plot, which pans.
252
+ *
253
+ * From the keyboard, with a tool in use and the chart focused: Enter places a point at the crosshair (the bar the arrow keys
254
+ * moved to, at the crosshair price, which from the keyboard is that bar's close), Enter again places the second point of a
255
+ * two-point tool, and Escape drops the half-placed drawing. Each step is announced in Drawings.Undo's live region.
256
+ *
257
+ * `newText` is the words a new text note gets. `formatTime` prints times in those announcements (default HH:MM in UTC). The
258
+ * layer is hidden from assistive technology: the List is its text.
259
+ */
260
+ function Layer({ className, newText = 'Note', formatTime }: { className?: string; newText?: string; formatTime?: (timestamp: number) => string }) {
261
+ const ctx = useDrawings('Layer');
262
+ const chart = useChart('Drawings.Layer');
263
+ const { bars, frame, toChart, decimals, readoutId } = chart;
264
+ const groupRef = useRef<SVGGElement | null>(null);
265
+ const live = useRef<{ ctx: DrawingsContext; chart: ReturnType<typeof useChart>; place: () => void }>({ ctx, chart, place: () => {} });
266
+ // Set after `commit` and `placeAtCrosshair` below, so the key handler always reaches the latest ones.
267
+ // The Edit form sits outside the chart, so the Layer hands it the bars to count from.
268
+ const { setBars, plotRef } = ctx;
269
+ // A host may fill the same array in place, so the layer publishes a copy and watches the length and last bar too.
270
+ const barCount = bars.length;
271
+ const lastBarTime = bars[barCount - 1]?.timestamp;
272
+ useEffect(() => { setBars(bars.slice()); return () => setBars([]); }, [bars, barCount, lastBarTime, setBars]);
273
+ const gesture = useRef<{ pointerId: number; x: number; y: number } | null>(null);
274
+ // A Selection.Layer asks before it reads a press as a tap on clear chart: a drawing or an armed tool owns the press.
275
+ const selectionGuard = useContext(SelectionContext)?.guard;
276
+ useEffect(() => {
277
+ if (!selectionGuard) return;
278
+ const guard = {
279
+ owns: (clientX: number, clientY: number, pointerType: string) => {
280
+ const { ctx: c, chart: ch } = live.current;
281
+ if (c.tool !== null) return true;
282
+ const point = ch.toChart(clientX, clientY);
283
+ return !!point && hitTest(c.drawings, ch.bars, ch.frame, point, 6, pointerType === 'touch' ? TOUCH_HANDLE_PX : 6) !== null;
284
+ },
285
+ busy: () => live.current.ctx.tool !== null || live.current.ctx.draft !== null,
286
+ };
287
+ selectionGuard.current = guard;
288
+ return () => { if (selectionGuard.current === guard) selectionGuard.current = null; };
289
+ }, [selectionGuard]);
290
+ // True after an arrow key moved the crosshair while a drawing was half placed: the preview then follows the crosshair.
291
+ const keyboardMove = useRef(false);
292
+ // The drawing (and anchor, or 'body') a pointer is moving now: it reads as active, and its anchor is marked.
293
+ const [moving, setMoving] = useState<{ id: string; handle: number | 'body' } | null>(null);
294
+
295
+ // Pointer and key handling for existing drawings runs on the plot's svg itself, ahead of the plot's own handlers,
296
+ // and only stops the event when it used it. That is how the chart keeps panning and moving its crosshair otherwise.
297
+ useEffect(() => {
298
+ const svg = groupRef.current?.ownerSVGElement;
299
+ if (!svg) return;
300
+ plotRef.current = svg;
301
+ const touchActionBefore = svg.style.touchAction;
302
+ let drag: Drag | null = null;
303
+ // A tap on clear chart deselects; a touch or pen tap on an unselected drawing (select) selects it, only on release.
304
+ let tap: { pointerId: number; x: number; y: number; at: number; select?: string } | null = null;
305
+
306
+ const down = new Set<number>();
307
+ const onDown = (event: PointerEvent) => {
308
+ const { ctx: c, chart: ch } = live.current;
309
+ // A second finger joining means a pinch, not a tap: nothing pending may deselect.
310
+ if (event.isPrimary) down.clear();
311
+ down.add(event.pointerId);
312
+ if (down.size > 1) tap = null;
313
+ if (c.tool !== null || event.button !== 0) return;
314
+ const point = ch.toChart(event.clientX, event.clientY);
315
+ if (!point) return;
316
+ // Drawings are clipped to the plot area, so a press in a gutter cannot pick one up.
317
+ if (point.x < 0 || point.x >= ch.frame.plotWidth || point.y < 0 || point.y >= ch.frame.plotHeight) return;
318
+ // A finger needs a bigger target than a mouse: handles grab within 22 px by touch, 6 px otherwise.
319
+ const hit = hitTest(c.drawings, ch.bars, ch.frame, point, 6, event.pointerType === 'touch' ? TOUCH_HANDLE_PX : 6);
320
+ if (!hit) {
321
+ // A tap on clear chart puts the selection away. A drag that starts here is the chart panning, so it keeps it.
322
+ // A press on a selectable study line is the Selection.Layer's: it selects the study instead.
323
+ if ((event.target as Element | null)?.closest?.("[data-part='study-hit']")) return;
324
+ if (c.selected !== null && down.size === 1) tap = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, at: event.timeStamp };
325
+ return;
326
+ }
327
+ // 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),
328
+ // and the browser decides that after the press. So nothing is taken or changed now: a tap selects it on release.
329
+ if (event.pointerType !== 'mouse' && c.selected !== hit.id) {
330
+ if (down.size === 1) tap = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, at: event.timeStamp, select: hit.id };
331
+ return;
332
+ }
333
+ event.stopPropagation();
334
+ c.setSelected(hit.id);
335
+ const start = c.drawings.find((d) => d.id === hit.id);
336
+ if (!start || start.locked) return;
337
+ 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 } };
338
+ svg.setPointerCapture?.(event.pointerId);
339
+ setMoving({ id: hit.id, handle: hit.handle });
340
+ };
341
+ const onMove = (event: PointerEvent) => {
342
+ keyboardMove.current = false;
343
+ // Once the pointer has gone past the tap slop it is a pan, even if it comes back before release.
344
+ if (tap && tap.pointerId === event.pointerId && Math.hypot(event.clientX - tap.x, event.clientY - tap.y) > 6) tap = null;
345
+ if (!drag || drag.pointerId !== event.pointerId) return;
346
+ event.stopPropagation();
347
+ const { ctx: c, chart: ch } = live.current;
348
+ // Locked (or removed) while held, for example by a second finger on Lock: the drag ends and the drawing stays put.
349
+ const current = c.drawings.find((d) => d.id === drag!.start.id);
350
+ if (!current || current.locked) { drag = null; setMoving(null); return; }
351
+ const point = { x: drag.press.x + (event.clientX - drag.press.clientX), y: drag.press.y + (event.clientY - drag.press.clientY) };
352
+ let next: Drawing;
353
+ if (drag.handle === 'body') {
354
+ const shift = drag.frame.indexAt(point.x) - drag.index;
355
+ next = moveDrawing(drag.start, c.snap === 'magnet' ? Math.round(shift) : shift, drag.frame.priceAt(point.y) - drag.price, ch.bars);
356
+ } else {
357
+ const anchor = snapToBar(point, ch.bars, drag.frame, c.snap);
358
+ if (!anchor) return;
359
+ next = moveAnchor(drag.start, drag.handle, anchor, ch.bars);
360
+ }
361
+ c.setDrawings(c.drawings.map((d) => (d.id === next.id ? next : d)));
362
+ };
363
+ const onUp = (event: PointerEvent) => {
364
+ down.delete(event.pointerId);
365
+ if (tap && tap.pointerId === event.pointerId) {
366
+ const t = tap;
367
+ tap = null;
368
+ if (event.type === 'pointerup' && Math.hypot(event.clientX - t.x, event.clientY - t.y) <= 6 && event.timeStamp - t.at < 600) live.current.ctx.setSelected(t.select ?? null);
369
+ }
370
+ if (!drag || drag.pointerId !== event.pointerId) return;
371
+ event.stopPropagation();
372
+ // The browser took the gesture (a scroll or a pinch): nothing it moved was meant, so the drawing goes back.
373
+ if (event.type === 'pointercancel') {
374
+ const { ctx: c } = live.current;
375
+ const d = drag.start;
376
+ if (c.drawings.some((x) => x.id === d.id)) c.setDrawings(c.drawings.map((x) => (x.id === d.id ? d : x)));
377
+ }
378
+ drag = null;
379
+ setMoving(null);
380
+ };
381
+ const onKey = (event: KeyboardEvent) => {
382
+ const { ctx: c, chart: ch } = live.current;
383
+ // Ctrl or Cmd + Z puts the last removal back while it can still be undone.
384
+ if ((event.ctrlKey || event.metaKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'z' && c.removed) {
385
+ event.preventDefault();
386
+ event.stopPropagation();
387
+ c.undo();
388
+ return;
389
+ }
390
+ // Enter places a point at the crosshair while a tool is in use.
391
+ if (event.key === 'Enter' && !event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey && c.tool !== null) {
392
+ event.preventDefault();
393
+ event.stopPropagation();
394
+ live.current.place();
395
+ return;
396
+ }
397
+ if (c.draft && !event.altKey && !event.ctrlKey && !event.metaKey && /^(Arrow(Left|Right)|Home|End)$/.test(event.key)) keyboardMove.current = true;
398
+ const key = resolveDrawingKey(event);
399
+ if (!key) return;
400
+ let handled = false;
401
+ if (key.kind === 'escape') {
402
+ if (c.draft) { c.announce(`${DRAWING_TOOL_LABELS[c.draft.tool]}: cancelled.`); c.setDraft(null); handled = true; }
403
+ else if (c.tool !== null) { c.setTool(null); handled = true; }
404
+ else if (c.selected !== null) { c.setSelected(null); handled = true; }
405
+ } else if (!c.draft && c.tool === null) {
406
+ const current = c.drawings.find((d) => d.id === c.selected);
407
+ if (current) {
408
+ handled = true;
409
+ if (!current.locked) {
410
+ if (key.kind === 'remove') c.remove(current.id);
411
+ else {
412
+ const step = nudgeAmount(key, ch.frame);
413
+ const moved = moveDrawing(current, step.bars, step.price, ch.bars);
414
+ c.setDrawings(c.drawings.map((d) => (d.id === moved.id ? moved : d)));
415
+ }
416
+ }
417
+ }
418
+ }
419
+ if (handled) {
420
+ event.preventDefault();
421
+ event.stopPropagation();
422
+ }
423
+ };
424
+
425
+ svg.addEventListener('pointerdown', onDown);
426
+ svg.addEventListener('pointermove', onMove);
427
+ svg.addEventListener('pointerup', onUp);
428
+ svg.addEventListener('pointercancel', onUp);
429
+ svg.addEventListener('keydown', onKey);
430
+ return () => {
431
+ svg.removeEventListener('pointerdown', onDown);
432
+ svg.removeEventListener('pointermove', onMove);
433
+ svg.removeEventListener('pointerup', onUp);
434
+ svg.removeEventListener('pointercancel', onUp);
435
+ svg.removeEventListener('keydown', onKey);
436
+ svg.style.touchAction = touchActionBefore;
437
+ if (plotRef.current === svg) plotRef.current = null;
438
+ };
439
+ }, [plotRef]);
440
+
441
+ // The chart lets a vertical swipe scroll the page (touch-action pan-y), and the Layer keeps that while idle. While a tool is
442
+ // armed, a drawing is selected, or one is being placed or dragged, a vertical touch drag moves or sizes a drawing and the browser
443
+ // would cancel it to scroll, so the plot keeps the gesture (none). The browser reads touch-action at touchstart, so it follows state.
444
+ const keepsTouch = ctx.tool !== null || ctx.selected !== null || ctx.draft !== null || moving !== null;
445
+ useEffect(() => {
446
+ const svg = groupRef.current?.ownerSVGElement;
447
+ if (svg) svg.style.touchAction = keepsTouch ? 'none' : 'pan-y';
448
+ }, [keepsTouch]);
449
+
450
+ const anchorAt = (event: ReactPointerEvent<SVGRectElement>) => {
451
+ const point = toChart(event.clientX, event.clientY);
452
+ return point ? { point, anchor: snapToBar(point, bars, frame, ctx.snap) } : null;
453
+ };
454
+ const commit = (tool: DrawingTool, anchors: DrawingAnchor[]): Drawing => {
455
+ const drawing: Drawing = { id: nextDrawingId(ctx.drawings), tool, anchors, ...(tool === 'text' ? { text: newText } : {}) };
456
+ ctx.setDrawings([...ctx.drawings, drawing]);
457
+ ctx.setDraft(null);
458
+ ctx.setSelected(drawing.id);
459
+ ctx.setTool(null);
460
+ return drawing;
461
+ };
462
+ /** The anchor under the crosshair: the active bar (the last one before the crosshair has been moved) at the crosshair price. */
463
+ const crosshairAnchor = (): DrawingAnchor | null => {
464
+ const index = chart.active !== null && bars[chart.active] ? chart.active : frame.last;
465
+ const bar = bars[index];
466
+ if (!bar) return null;
467
+ const price = chart.pointerPrice ?? bar.close;
468
+ const snapped = snapToBar({ x: frame.x(index), y: frame.y(price) }, bars, frame, ctx.snap);
469
+ return { timestamp: bar.timestamp, price: ctx.snap === 'magnet' && snapped ? snapped.price : price };
470
+ };
471
+ const placeAtCrosshair = () => {
472
+ const tool = ctx.tool;
473
+ const anchor = crosshairAnchor();
474
+ if (tool === null || !anchor) return;
475
+ const name = DRAWING_TOOL_LABELS[tool];
476
+ if (chart.active === null) chart.setActive(Math.min(bars.length - 1, Math.max(0, frame.last)));
477
+ const point = describeAnchor(anchor, decimals, formatTime);
478
+ if (anchorCount(tool) === 1) {
479
+ const drawing = commit(tool, [anchor]);
480
+ ctx.announce(`${name}: placed at ${point}. Added ${describeDrawing(drawing, decimals, formatTime)}.`);
481
+ return;
482
+ }
483
+ if (!ctx.draft) {
484
+ ctx.setDraft({ tool, anchors: [anchor], preview: anchor });
485
+ ctx.announce(`${name}: first point at ${point}. Move the crosshair, then press Enter for the second point.`);
486
+ return;
487
+ }
488
+ const first = ctx.draft.anchors[0];
489
+ if (first.timestamp === anchor.timestamp && first.price === anchor.price) {
490
+ ctx.announce(`${name}: the second point is the same as the first. Move the crosshair, then press Enter.`);
491
+ return;
492
+ }
493
+ const drawing = commit(tool, [first, anchor]);
494
+ ctx.announce(`${name}: second point at ${point}. Added ${describeDrawing(drawing, decimals, formatTime)}.`);
495
+ };
496
+ live.current = { ctx, chart, place: placeAtCrosshair };
497
+ // With a drawing half placed, an arrow key moves its preview to the crosshair, as a pointer would.
498
+ useEffect(() => {
499
+ if (!keyboardMove.current) return;
500
+ keyboardMove.current = false;
501
+ const { ctx: c, chart: ch } = live.current;
502
+ if (!c.draft || ch.active === null) return;
503
+ const bar = ch.bars[ch.active];
504
+ if (bar) c.setDraft({ ...c.draft, preview: { timestamp: bar.timestamp, price: ch.pointerPrice ?? bar.close } });
505
+ }, [chart.active]);
506
+ const onCaptureDown = (event: ReactPointerEvent<SVGRectElement>) => {
507
+ event.stopPropagation(); // the plot must not start a pan
508
+ if (event.button !== 0 || ctx.tool === null) return;
509
+ const at = anchorAt(event);
510
+ if (!at?.anchor) return;
511
+ const tool = ctx.tool;
512
+ if (anchorCount(tool) === 1) { commit(tool, [at.anchor]); return; }
513
+ if (!ctx.draft) {
514
+ ctx.setDraft({ tool, anchors: [at.anchor], preview: at.anchor });
515
+ gesture.current = { pointerId: event.pointerId, x: at.point.x, y: at.point.y };
516
+ event.currentTarget.setPointerCapture?.(event.pointerId);
517
+ return;
518
+ }
519
+ const first = ctx.draft.anchors[0];
520
+ if (first.timestamp === at.anchor.timestamp && first.price === at.anchor.price) return; // a zero-size drawing
521
+ commit(tool, [first, at.anchor]);
522
+ };
523
+ const onCaptureMove = (event: ReactPointerEvent<SVGRectElement>) => {
524
+ if (!ctx.draft) return;
525
+ const at = anchorAt(event);
526
+ if (at?.anchor) ctx.setDraft({ ...ctx.draft, preview: at.anchor });
527
+ };
528
+ const onCaptureUp = (event: ReactPointerEvent<SVGRectElement>) => {
529
+ event.stopPropagation();
530
+ const held = gesture.current;
531
+ if (!held || held.pointerId !== event.pointerId) return;
532
+ gesture.current = null;
533
+ const at = anchorAt(event);
534
+ if (!at?.anchor || !ctx.draft || !ctx.tool) return;
535
+ if (Math.hypot(at.point.x - held.x, at.point.y - held.y) < DRAG_PIXELS) return; // a click: wait for the second one
536
+ commit(ctx.tool, [ctx.draft.anchors[0], at.anchor]);
537
+ };
538
+
539
+ const draftDrawing: Drawing | null = ctx.draft && ctx.draft.preview
540
+ ? { id: 'draft', tool: ctx.draft.tool, anchors: [ctx.draft.anchors[0], ctx.draft.preview] }
541
+ : null;
542
+ const fmt = (n: number) => n.toFixed(decimals);
543
+ const signed = (n: number) => `${n >= 0 ? '+' : ''}${fmt(n)}`;
544
+
545
+ // The preview is drawn outside the list, so no drawing id (even "draft") can share its key.
546
+ const renderDrawing = (drawing: Drawing, isDraft: boolean) => {
547
+ const g = drawingGeometry(drawing, bars, frame);
548
+ const isSelected = !isDraft && drawing.id === ctx.selected;
549
+ const isMoving = !isDraft && moving?.id === drawing.id;
550
+ // passive: nothing is happening to it; active: being drawn or moved; selected: picked, with its anchors showing.
551
+ const state = isDraft || isMoving ? 'active' : isSelected ? 'selected' : 'passive';
552
+ return (
553
+ <g
554
+ key={isDraft ? undefined : drawing.id}
555
+ data-mt=""
556
+ data-part="drawing"
557
+ data-tool={drawing.tool}
558
+ data-state={state}
559
+ data-selected={isSelected ? '' : undefined}
560
+ data-draft={isDraft ? '' : undefined}
561
+ data-locked={drawing.locked ? '' : undefined}
562
+ >
563
+ {g.boxes.map((b, i) => <rect key={`box${i}`} data-part="drawing-box" x={b.x} y={b.y} width={b.width} height={b.height} />)}
564
+ {g.segments.map((s, i) => <line key={`line${i}`} data-part="drawing-line" x1={s.x1} y1={s.y1} x2={s.x2} y2={s.y2} />)}
565
+ {g.levels.map((l) => (
566
+ <g key={l.ratio} data-part="drawing-level" data-ratio={l.ratio}>
567
+ <line x1={l.x1} y1={l.y} x2={l.x2} y2={l.y} />
568
+ <text x={l.x2} y={l.y - 3} textAnchor="end">{`${l.ratio} (${fmt(l.price)})`}</text>
569
+ </g>
570
+ ))}
571
+ {g.label && <text data-part="drawing-text" x={g.label.x} y={g.label.y}>{g.label.text}</text>}
572
+ {g.range && g.boxes[0] && (
573
+ <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">
574
+ {`${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'}`}
575
+ </text>
576
+ )}
577
+ {isSelected && drawing.locked && g.handles[0] && (
578
+ <g data-part="drawing-lock-mark" transform={`translate(${g.handles[0].x + 8} ${g.handles[0].y - 20})`}>
579
+ <path d="M3 7V5a3 3 0 0 1 6 0v2" fill="none" />
580
+ <rect x="1" y="7" width="10" height="8" rx="1.5" />
581
+ </g>
582
+ )}
583
+ {isSelected && !drawing.locked && g.handles.map((h) => <circle key={h.index} data-part="drawing-handle" data-handle={h.index} data-dragging={isMoving && moving?.handle === h.index ? '' : undefined} cx={h.x} cy={h.y} r={5} />)}
584
+ </g>
585
+ );
586
+ };
587
+
588
+ return (
589
+ <g ref={groupRef} className={className} data-part="drawings" clipPath={`url(#${readoutId}-plotarea)`} aria-hidden="true">
590
+ {ctx.drawings.map((drawing) => renderDrawing(drawing, false))}
591
+ {draftDrawing && renderDrawing(draftDrawing, true)}
592
+ {ctx.tool !== null && (
593
+ <rect
594
+ data-part="drawing-capture"
595
+ data-tool={ctx.tool}
596
+ x={0}
597
+ y={0}
598
+ width={frame.plotWidth}
599
+ height={frame.plotHeight}
600
+ fill="transparent"
601
+ onPointerDown={onCaptureDown}
602
+ onPointerMove={onCaptureMove}
603
+ onPointerUp={onCaptureUp}
604
+ onPointerCancel={() => { gesture.current = null; }}
605
+ />
606
+ )}
607
+ </g>
608
+ );
609
+ }
610
+
611
+ export interface DrawingsToolbarProps {
612
+ className?: string;
613
+ 'aria-label'?: string;
614
+ /** Which tools to offer, in order. Default all nine. */
615
+ tools?: readonly DrawingTool[];
616
+ /**
617
+ * A graphic for each tool, drawn before its words and hidden from assistive technology: an object by tool, or a
618
+ * function of the tool. No icons ship with the package. With `hideLabels` the icon is all that shows.
619
+ */
620
+ icon?: Partial<Record<DrawingTool, ReactNode>> | ((tool: DrawingTool) => ReactNode);
621
+ /** Each tool's words (its accessible name), by tool. Default: "Trend line", "Ray" and so on. */
622
+ labels?: Partial<Record<DrawingTool, string>>;
623
+ /** Show only the icons; the labels become each button's `aria-label`. Needs `icon`. Default false. */
624
+ hideLabels?: boolean;
625
+ }
626
+
627
+ /**
628
+ * One button per tool, built on Toolbar: one tab stop, the arrow keys, Home and End move between the buttons, and Tab
629
+ * leaves. A pressed button is the tool in use; pressing it again puts the tool away. Escape sends focus to the chart.
630
+ * Every button has its tool in words, even when an icon is all that shows.
631
+ */
632
+ function Toolbar({ className, 'aria-label': label = 'Drawing tools', tools = DRAWING_TOOLS, icon, labels, hideLabels = false }: DrawingsToolbarProps) {
633
+ const { tool, setTool, plotRef } = useDrawings('Toolbar');
634
+ const iconOf = (name: DrawingTool): ReactNode => (typeof icon === 'function' ? icon(name) : icon?.[name]);
635
+ return (
636
+ <ToolbarPart.Root className={className} aria-label={label} data-part="drawing-toolbar" returnFocusTo={() => plotRef.current}>
637
+ {tools.map((name) => (
638
+ <ToolbarPart.Button
639
+ key={name}
640
+ data-part="drawing-tool"
641
+ data-tool={name}
642
+ pressed={tool === name}
643
+ icon={iconOf(name)}
644
+ label={labels?.[name] ?? DRAWING_TOOL_LABELS[name]}
645
+ hideLabel={hideLabels}
646
+ onClick={() => {
647
+ if (tool !== name && !plotRef.current) 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.');
648
+ setTool(tool === name ? null : name);
649
+ }}
650
+ />
651
+ ))}
652
+ </ToolbarPart.Root>
653
+ );
654
+ }
655
+
656
+ export interface DrawingsListProps {
657
+ className?: string;
658
+ 'aria-label'?: string;
659
+ /** Decimals for printed prices. Default 2. */
660
+ decimals?: number;
661
+ /** Prints a time. Default HH:MM in UTC, with the date when a drawing spans days. */
662
+ formatTime?: (timestamp: number) => string;
663
+ /** Shown when there are no drawings. */
664
+ empty?: string;
665
+ }
666
+
667
+ /**
668
+ * The drawings in words, with a button to select and a button to remove each. The selected one has aria-current.
669
+ * Delete or Backspace on a drawing's line removes it, as on the chart, and so does its Remove button. Focus moves to the
670
+ * next line; after the last line is removed it goes to the Undo button when Drawings.Undo is showing it, else to the empty
671
+ * list (the "No drawings" line), never to the top of the page.
672
+ */
673
+ function List({ className, 'aria-label': label = 'Drawings', decimals = 2, formatTime, empty = 'No drawings' }: DrawingsListProps) {
674
+ const { drawings, selected, setSelected, remove, setLocked, undoRegion, focusRowNext } = useDrawings('List');
675
+ const listRef = useRef<HTMLUListElement | null>(null);
676
+ const emptyRef = useRef<HTMLParagraphElement | null>(null);
677
+ // Set when a removal here took the last row: the row's buttons are gone by the next render, so focus is placed then.
678
+ const lostFocus = useRef(false);
679
+ useEffect(() => {
680
+ if (!lostFocus.current || drawings.length > 0) return;
681
+ lostFocus.current = false;
682
+ // The Undo button if it is showing (it is the likely next move), else the empty list, so focus never falls to the page.
683
+ (undoRegion.current?.querySelector<HTMLElement>("[data-part='drawing-undo-button']") ?? emptyRef.current)?.focus();
684
+ }, [drawings.length, undoRegion]);
685
+ // After the Undo button put a row back, focus goes to that row (the Undo button is gone), so it lands on something visible.
686
+ useEffect(() => {
687
+ if (!focusRowNext.current || selected === null) return;
688
+ const row = listRef.current?.querySelector<HTMLElement>("li[aria-current='true'] [data-part='drawing-select']");
689
+ if (!row) return;
690
+ focusRowNext.current = false;
691
+ row.focus();
692
+ }, [drawings, selected, focusRowNext]);
693
+ /** 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. */
694
+ const removeRow = (drawing: Drawing, index: number) => {
695
+ if (drawing.locked) return;
696
+ const buttons = listRef.current?.querySelectorAll<HTMLButtonElement>("[data-part='drawing-select']");
697
+ const neighbour = buttons?.[index + 1] ?? buttons?.[index - 1];
698
+ if (neighbour) neighbour.focus();
699
+ else lostFocus.current = true;
700
+ remove(drawing.id);
701
+ };
702
+ const onSelectKey = (event: ReactKeyboardEvent<HTMLButtonElement>, drawing: Drawing, index: number) => {
703
+ if (event.key !== 'Delete' && event.key !== 'Backspace') return;
704
+ event.preventDefault();
705
+ removeRow(drawing, index);
706
+ };
707
+ if (drawings.length === 0) return <p ref={emptyRef} tabIndex={-1} className={className} data-part="drawing-list" data-empty="">{empty}</p>;
708
+ return (
709
+ <ul ref={listRef} className={className} aria-label={label} data-part="drawing-list" {...primitiveAttributes({})}>
710
+ {drawings.map((drawing, index) => {
711
+ const text = describeDrawing(drawing, decimals, formatTime);
712
+ return (
713
+ <li key={drawing.id} data-tool={drawing.tool} data-selected={drawing.id === selected ? '' : undefined} data-locked={drawing.locked ? '' : undefined} aria-current={drawing.id === selected ? 'true' : undefined}>
714
+ <button type="button" data-part="drawing-select" data-mt="" data-touch-target="" onClick={() => setSelected(drawing.id)} onKeyDown={(event) => onSelectKey(event, drawing, index)}>{text}</button>
715
+ <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)}>Lock</button>
716
+ <button type="button" data-part="drawing-remove" data-mt="" data-touch-target="" aria-label={`Remove ${text}`} disabled={drawing.locked} onClick={() => removeRow(drawing, index)}>Remove</button>
717
+ </li>
718
+ );
719
+ })}
720
+ </ul>
721
+ );
722
+ }
723
+
724
+ export interface DrawingsRemoveProps extends NameProps {
725
+ className?: string;
726
+ /** Remove every drawing that is not locked, not only the selected one. */
727
+ all?: boolean;
728
+ /** The button's words. Default "Remove", or "Remove all" with `all`. */
729
+ children?: ReactNode;
730
+ }
731
+
732
+ /**
733
+ * A button that removes the selected drawing, or with `all` every drawing that is not locked. It acts only on a
734
+ * selection that already exists, never on what was under a finger when it was pressed, and the removal can be undone
735
+ * (see Undo). With nothing to remove it is `aria-disabled` rather than disabled, so keyboard focus stays on it.
736
+ */
737
+ function Remove({ className, all = false, icon, label, hideLabel, children }: DrawingsRemoveProps) {
738
+ const { drawings, selected, remove, removeAll, focusUndoNext } = useDrawings('Remove');
739
+ const face = buttonFace(all ? 'Remove all' : 'Remove', { icon, label, hideLabel, children });
740
+ const target = all ? drawings.some((d) => !d.locked) : drawings.some((d) => d.id === selected && !d.locked);
741
+ return (
742
+ <button
743
+ type="button"
744
+ className={className}
745
+ data-part={all ? 'drawing-remove-all' : 'drawing-remove-selected'}
746
+ data-mt=""
747
+ data-touch-target=""
748
+ aria-label={face['aria-label']}
749
+ aria-disabled={target ? undefined : true}
750
+ onClick={(event) => { if (!target) return; focusUndoNext.current = document.activeElement === event.currentTarget; if (all) removeAll(); else if (selected !== null) remove(selected); }}
751
+ >
752
+ {face.content}
753
+ </button>
754
+ );
755
+ }
756
+
757
+ export interface DrawingsUndoProps extends NameProps {
758
+ className?: string;
759
+ /** Decimals for printed prices. Default 2. */
760
+ decimals?: number;
761
+ formatTime?: (timestamp: number) => string;
762
+ }
763
+
764
+ /**
765
+ * Says what the last removal took out and offers Undo for as long as the Root's undoWindow lasts (10 seconds by
766
+ * default). The region is always in the page and announced politely, so a screen reader hears each removal as it
767
+ * happens. It is also where the other drawing announcements land (a point placed from the keyboard, an applied Edit), in
768
+ * 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
769
+ * 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.
770
+ */
771
+ function Undo({ className, decimals = 2, formatTime, icon, label, hideLabel }: DrawingsUndoProps) {
772
+ const { removed, undo, undoWindow, status, undoRegion } = useDrawings('Undo');
773
+ const face = buttonFace('Undo', { icon, label, hideLabel });
774
+ const region = useRef<HTMLDivElement | null>(null);
775
+ const buttonHadFocus = useRef(false);
776
+ const runs = useRef(0);
777
+ // One number per removal, so the timer line starts over even when the same drawing is removed twice.
778
+ const run = useMemo(() => ++runs.current, [removed]);
779
+ const text = !removed ? '' : removed.length === 1 ? `Removed ${describeDrawing(removed[0], decimals, formatTime)}.` : `Removed ${removed.length} drawings.`;
780
+ // When the window runs out under a focused Undo button, focus stays in the controls instead of dropping to the page.
781
+ useEffect(() => {
782
+ if (removed === null && buttonHadFocus.current) {
783
+ buttonHadFocus.current = false;
784
+ region.current?.focus();
785
+ }
786
+ }, [removed]);
787
+ return (
788
+ <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 : ''}>
789
+ {removed && <span data-part="drawing-undo-text">{text}</span>}
790
+ {status && <span data-part="drawing-status">{status}</span>}
791
+ {removed && (
792
+ <button
793
+ type="button"
794
+ data-part="drawing-undo-button"
795
+ data-mt=""
796
+ data-touch-target=""
797
+ onFocus={() => { buttonHadFocus.current = true; }}
798
+ onBlur={(event) => { if (event.currentTarget.isConnected) buttonHadFocus.current = false; }}
799
+ aria-label={face['aria-label']}
800
+ onClick={() => { buttonHadFocus.current = false; undo({ describe: (d) => describeDrawing(d, decimals, formatTime), focusRow: true }); region.current?.focus(); }}
801
+ >
802
+ {face.content}
803
+ </button>
804
+ )}
805
+ {/* The time left, as a line that drains over the window. A new removal is a new element, so it starts again. */}
806
+ {removed && undoWindow > 0 && <span key={run} aria-hidden="true" data-part="drawing-undo-timer" style={{ ['--mt-undo-window' as string]: `${undoWindow}ms` }} />}
807
+ </div>
808
+ );
809
+ }
810
+
811
+ export interface DrawingsLockProps extends NameProps {
812
+ className?: string;
813
+ /** The button's words. Default "Lock". The state is carried by aria-pressed, so the words do not change. */
814
+ children?: ReactNode;
815
+ }
816
+
817
+ /**
818
+ * A toggle for the selected drawing's lock. Pressed means locked: it cannot be dragged, nudged, edited by typing or
819
+ * removed until it is unlocked. With nothing selected it is `aria-disabled` and does nothing.
820
+ */
821
+ function Lock({ className, icon, label, hideLabel, children }: DrawingsLockProps) {
822
+ const { drawings, selected, setLocked } = useDrawings('Lock');
823
+ const face = buttonFace('Lock', { icon, label, hideLabel, children });
824
+ const drawing = drawings.find((d) => d.id === selected);
825
+ return (
826
+ <button
827
+ type="button"
828
+ className={className}
829
+ data-part="drawing-lock"
830
+ data-mt=""
831
+ data-touch-target=""
832
+ aria-label={face['aria-label']}
833
+ aria-pressed={drawing ? !!drawing.locked : false}
834
+ aria-disabled={drawing ? undefined : true}
835
+ onClick={() => { if (drawing) setLocked(drawing.id, !drawing.locked); }}
836
+ >
837
+ {face.content}
838
+ </button>
839
+ );
840
+ }
841
+
842
+ export interface DrawingsEditProps extends NameProps {
843
+ className?: string;
844
+ /** Decimals shown in the price fields. Default 2. */
845
+ decimals?: number;
846
+ /** Prints a time in the "Updated ..." announcement. Default HH:MM in UTC. */
847
+ formatTime?: (timestamp: number) => string;
848
+ /** The toggle button's words. Default "Edit". */
849
+ children?: ReactNode;
850
+ }
851
+
852
+ /**
853
+ * Type exact values for the selected drawing: a price and/or a time (UTC) for each anchor, and the words of a text
854
+ * note. A closed "Edit" button opens a small form with only the fields that tool has (a horizontal line has just a
855
+ * price). Enter applies, Escape closes without applying, a bad value is named next to its field and nothing is half
856
+ * applied and "Updated ..." is announced in Drawings.Undo's live region. This is also the way to place a line at an exact
857
+ * price without a pointer. A locked drawing cannot be edited.
858
+ */
859
+ function Edit({ className, decimals: rawDecimals = 2, formatTime, icon, label, hideLabel, children }: DrawingsEditProps) {
860
+ const decimals = Number.isFinite(rawDecimals) ? Math.min(20, Math.max(0, Math.round(rawDecimals))) : 2; // toFixed throws outside 0 to 100
861
+ const { drawings, selected, setDrawings, bars, announce } = useDrawings('Edit');
862
+ const face = buttonFace('Edit', { icon, label, hideLabel, children });
863
+ const [open, setOpen] = useState(false);
864
+ const toggle = useRef<HTMLButtonElement | null>(null);
865
+ const baseId = useId();
866
+ const drawing = drawings.find((d) => d.id === selected);
867
+ const canEdit = !!drawing && !drawing.locked;
868
+ useEffect(() => { if (open && !canEdit) setOpen(false); }, [open, canEdit]);
869
+ const hint = !drawing ? 'Select a drawing to edit it.' : drawing.locked ? 'Unlock this drawing to edit it.' : undefined;
870
+ const close = () => { setOpen(false); toggle.current?.focus(); };
871
+ return (
872
+ <div className={className} data-part="drawing-edit" data-open={open && canEdit ? '' : undefined} onKeyDown={(event) => { if (event.key === 'Escape' && open) { event.stopPropagation(); close(); } }}>
873
+ <button
874
+ ref={toggle}
875
+ type="button"
876
+ data-part="drawing-edit-toggle"
877
+ data-mt=""
878
+ data-touch-target=""
879
+ aria-expanded={open && canEdit}
880
+ aria-controls={`${baseId}-form`}
881
+ aria-disabled={canEdit ? undefined : true}
882
+ aria-describedby={hint ? `${baseId}-hint` : undefined}
883
+ aria-label={face['aria-label']}
884
+ onClick={() => { if (canEdit) setOpen((o) => !o); }}
885
+ >
886
+ {face.content}
887
+ </button>
888
+ {hint && <span id={`${baseId}-hint`} data-part="drawing-edit-hint" hidden>{hint}</span>}
889
+ {open && drawing && canEdit && (
890
+ <EditForm
891
+ key={`${drawing.id}:${JSON.stringify(drawing.anchors)}:${drawing.text ?? ''}`}
892
+ id={`${baseId}-form`}
893
+ drawing={drawing}
894
+ decimals={decimals}
895
+ bars={bars}
896
+ onApply={(next) => { setDrawings(drawings.map((d) => (d.id === next.id ? next : d))); announce(`Updated ${describeDrawing(next, decimals, formatTime)}.`); close(); }}
897
+ onCancel={close}
898
+ />
899
+ )}
900
+ </div>
901
+ );
902
+ }
903
+
904
+ function EditForm({ id, drawing, decimals, bars, onApply, onCancel }: { id: string; drawing: Drawing; decimals: number; bars: readonly CandleBar[]; onApply: (next: Drawing) => void; onCancel: () => void }) {
905
+ const count = anchorCount(drawing.tool);
906
+ const showPrice = drawing.tool !== 'vertical-line';
907
+ const showTime = drawing.tool !== 'horizontal-line';
908
+ // A horizontal line holds its price and a vertical line its time by tool, so only the other tools pick an attach mode.
909
+ const showAttach = drawing.tool !== 'horizontal-line' && drawing.tool !== 'vertical-line';
910
+ const showBar = showTime && bars.length > 0;
911
+ // The fields show rounded values (price to `decimals`, time to the second). A field the person did not touch keeps the
912
+ // drawing's exact value, so applying a change to one field never rounds the others.
913
+ const [shownPrices] = useState(() => drawing.anchors.slice(0, count).map((a) => a.price.toFixed(decimals)));
914
+ const [shownTimes] = useState(() => drawing.anchors.slice(0, count).map((a) => toTimeField(a.timestamp)));
915
+ const [shownBars] = useState(() => drawing.anchors.slice(0, count).map((a) => barField(barsFromLast(bars, a.timestamp))));
916
+ const [shownAttach] = useState(() => drawing.anchors.slice(0, count).map((a) => attachOf(a)));
917
+ const [prices, setPrices] = useState(shownPrices);
918
+ const [times, setTimes] = useState(shownTimes);
919
+ const [barText, setBarText] = useState(shownBars);
920
+ const [attach, setAttach] = useState<AttachMode[]>(shownAttach);
921
+ // Time and Bar are two views of one value: the one typed last is the one applied, and the other follows it on screen.
922
+ const [lastTyped, setLastTyped] = useState<('time' | 'bar')[]>(() => shownAttach.map(() => 'time'));
923
+ const typed = (i: number, kind: 'time' | 'bar') => setLastTyped(lastTyped.map((v, j) => (j === i ? kind : v)));
924
+ // When bars arrive while the form is open, a count that follows a time is recounted against the new series, so the Bar
925
+ // and Time fields keep describing one position. A count the person typed is left as typed.
926
+ useEffect(() => {
927
+ if (bars.length === 0) return;
928
+ setBarText((current) => current.map((v, j) => {
929
+ const at = lastTyped[j] === 'time' ? fromTimeField(times[j]) : NaN;
930
+ return Number.isFinite(at) ? barField(barsFromLast(bars, at)) : v;
931
+ }));
932
+ // A count typed and not yet applied keeps its number; the Time beside it moves to that count on the new series.
933
+ setTimes((current) => current.map((v, j) => {
934
+ const n = lastTyped[j] === 'bar' ? parseBarField(barText[j]) : null;
935
+ const at = n === null ? null : timeOfBarFromLast(bars, n);
936
+ return at === null ? v : toTimeField(at);
937
+ }));
938
+ // eslint-disable-next-line react-hooks/exhaustive-deps
939
+ }, [bars]);
940
+ const onTime = (i: number, value: string) => {
941
+ setTimes(times.map((v, j) => (j === i ? value : v)));
942
+ const at = fromTimeField(value);
943
+ if (bars.length > 0 && Number.isFinite(at)) setBarText(barText.map((v, j) => (j === i ? barField(barsFromLast(bars, at)) : v)));
944
+ typed(i, 'time');
945
+ };
946
+ const onBar = (i: number, value: string) => {
947
+ setBarText(barText.map((v, j) => (j === i ? value : v)));
948
+ const n = parseBarField(value);
949
+ const at = n === null ? null : timeOfBarFromLast(bars, n);
950
+ if (at !== null && Number.isFinite(at)) setTimes(times.map((v, j) => (j === i ? toTimeField(at) : v)));
951
+ typed(i, 'bar');
952
+ };
953
+ const [text, setText] = useState(drawing.text ?? '');
954
+ const [error, setError] = useState<{ field: string; message: string } | null>(null);
955
+ const form = useRef<HTMLFormElement | null>(null);
956
+ // Opening the form in a full-screen frame on a phone can leave it below the fold: bring it (and Apply, Cancel) into view.
957
+ useEffect(() => {
958
+ const el = form.current;
959
+ if (!el || typeof el.scrollIntoView !== 'function') return;
960
+ const still = typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
961
+ el.scrollIntoView({ block: 'nearest', behavior: still ? 'auto' : 'smooth' });
962
+ }, []);
963
+ const name = DRAWING_TOOL_LABELS[drawing.tool];
964
+ const label = (what: string, i: number) => (count === 1 ? what : `${i === 0 ? 'First' : 'Second'} ${what.toLowerCase()}`);
965
+ const submit = (event: { preventDefault: () => void }) => {
966
+ event.preventDefault();
967
+ for (let i = 0; i < count; i += 1) {
968
+ if (showPrice && prices[i] !== shownPrices[i] && !Number.isFinite(fromPriceField(prices[i]))) return fail(`price-${i}`, `${label('Price', i)}: enter a number such as 101.25.`);
969
+ if (showBar && lastTyped[i] === 'bar' && barText[i] !== shownBars[i] && parseBarField(barText[i]) === null) return fail(`bar-${i}`, `${label('Bar', i)}: enter a whole number such as -12 (0 is the last bar).`);
970
+ if (showBar && lastTyped[i] === 'bar' && barText[i] !== shownBars[i] && timeOfBarFromLast(bars, parseBarField(barText[i]) as number) === null) return fail(`bar-${i}`, `${label('Bar', i)}: that is too far from the chart. Try a smaller number.`);
971
+ if (showTime && !(showBar && lastTyped[i] === 'bar' && barText[i] !== shownBars[i]) && times[i] !== shownTimes[i] && !Number.isFinite(fromTimeField(times[i]))) return fail(`time-${i}`, `${label('Time', i)}: enter a date and time.`);
972
+ }
973
+ const next = editDrawing(drawing, {
974
+ anchors: Array.from({ length: count }, (_, i) => {
975
+ // 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.
976
+ const byBar = showBar && lastTyped[i] === 'bar' && barText[i] !== shownBars[i];
977
+ const typedTime = byBar ? timeOfBarFromLast(bars, parseBarField(barText[i]) as number) : showTime && times[i] !== shownTimes[i] ? fromTimeField(times[i]) : undefined;
978
+ const mode = showAttach ? attach[i] : shownAttach[i];
979
+ const timestamp = mode === 'bar' && (typedTime !== undefined || mode !== shownAttach[i]) ? barTime(bars, typedTime ?? drawing.anchors[i].timestamp) : typedTime;
980
+ return {
981
+ ...(showPrice && prices[i] !== shownPrices[i] ? { price: fromPriceField(prices[i]) } : {}),
982
+ ...(timestamp !== undefined && timestamp !== null ? { timestamp } : {}),
983
+ ...(showAttach && attach[i] !== shownAttach[i] ? { attach: attach[i] } : {}),
984
+ };
985
+ }),
986
+ text,
987
+ });
988
+ if (next === drawing) return fail('', 'That value cannot be used.');
989
+ onApply(next);
990
+ };
991
+ const fail = (field: string, message: string) => {
992
+ setError({ field, message });
993
+ if (field) form.current?.querySelector<HTMLInputElement>(`[data-field='${field}']`)?.focus();
994
+ };
995
+ return (
996
+ <form
997
+ ref={form}
998
+ id={id}
999
+ role="group"
1000
+ aria-label={`Edit ${name}`}
1001
+ data-part="drawing-edit-form"
1002
+ noValidate
1003
+ onSubmit={submit}
1004
+ >
1005
+ {drawing.anchors.slice(0, count).map((_, i) => (
1006
+ <fieldset key={i} data-part="drawing-edit-anchor" data-anchor={i}>
1007
+ {count > 1 && <legend>{i === 0 ? 'First point' : 'Second point'}</legend>}
1008
+ {showPrice && (
1009
+ <label>
1010
+ <span>Price</span>
1011
+ <input
1012
+ type="text"
1013
+ inputMode="decimal"
1014
+ autoComplete="off"
1015
+ data-mt="" data-touch-target=""
1016
+ data-field={`price-${i}`}
1017
+ value={prices[i]}
1018
+ aria-invalid={error?.field === `price-${i}` || undefined}
1019
+ aria-describedby={error?.field === `price-${i}` ? `${id}-error` : undefined}
1020
+ onChange={(e) => setPrices(prices.map((v, j) => (j === i ? e.target.value : v)))}
1021
+ />
1022
+ </label>
1023
+ )}
1024
+ {showTime && (
1025
+ <label>
1026
+ <span>Time (UTC)</span>
1027
+ <input
1028
+ type="datetime-local"
1029
+ step={1}
1030
+ data-mt="" data-touch-target=""
1031
+ data-field={`time-${i}`}
1032
+ value={times[i]}
1033
+ aria-invalid={error?.field === `time-${i}` || undefined}
1034
+ aria-describedby={error?.field === `time-${i}` ? `${id}-error` : undefined}
1035
+ onChange={(e) => onTime(i, e.target.value)}
1036
+ />
1037
+ </label>
1038
+ )}
1039
+ {showBar && (
1040
+ <label>
1041
+ <span>Bar (from last)</span>
1042
+ <input
1043
+ type="text"
1044
+ autoComplete="off"
1045
+ data-mt="" data-touch-target=""
1046
+ data-field={`bar-${i}`}
1047
+ value={barText[i]}
1048
+ aria-invalid={error?.field === `bar-${i}` || undefined}
1049
+ aria-describedby={`${id}-bar-note-${i}${error?.field === `bar-${i}` ? ` ${id}-error` : ''}`}
1050
+ onChange={(e) => onBar(i, e.target.value)}
1051
+ />
1052
+ <small id={`${id}-bar-note-${i}`} data-part="drawing-edit-bar-note">{barNote(bars, lastTyped[i] === 'bar' ? parseBarField(barText[i]) : null, fromTimeField(times[i]))}</small>
1053
+ </label>
1054
+ )}
1055
+ {showAttach && (
1056
+ <label>
1057
+ <span>Attach</span>
1058
+ <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 as AttachMode) : v)))}>
1059
+ {ATTACH_MODES.map((mode) => <option key={mode} value={mode}>{ATTACH_LABELS[mode]}</option>)}
1060
+ </select>
1061
+ </label>
1062
+ )}
1063
+ </fieldset>
1064
+ ))}
1065
+ {drawing.tool === 'text' && (
1066
+ <label>
1067
+ <span>Words</span>
1068
+ <input type="text" data-mt="" data-touch-target="" data-field="text" value={text} onChange={(e) => setText(e.target.value)} />
1069
+ </label>
1070
+ )}
1071
+ <p id={`${id}-error`} role="alert" data-part="drawing-edit-error" hidden={!error}>{error?.message}</p>
1072
+ <div data-part="drawing-edit-actions">
1073
+ <button type="submit" data-part="drawing-edit-apply" data-mt="" data-touch-target="">Apply</button>
1074
+ <button type="button" data-part="drawing-edit-cancel" data-mt="" data-touch-target="" onClick={onCancel}>Cancel</button>
1075
+ </div>
1076
+ </form>
1077
+ );
1078
+ }
1079
+
1080
+ export { Root, Layer, Toolbar, List, Remove, Undo, Lock, Edit };