@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,207 @@
1
+ import { createContext, useCallback, useContext, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent, type ReactNode } from 'react';
2
+ import { clampFraction, fractionFromPointer, isActiveScrubPointer, isPrimaryScrubPointer, reduceScrubGesture, resolveScrubKey, type ScrubGestureEvent } from './core/scrubber-gesture.js';
3
+
4
+ /**
5
+ * Scrubber as parts: Root, Track, Thumb, Mark. Headless: the parts render the elements the evidence
6
+ * ruler always did, add `data-state` / `data-disabled`, and work in fractions of the track. The host owns what a
7
+ * fraction means (a place in its session), the copy, and the look.
8
+ *
9
+ * Root renders no DOM. It owns the preview while a pointer is down, so `useScrubber()` gives a readout the live
10
+ * fraction; nothing is committed until release, and then exactly once through `onCommit`.
11
+ */
12
+ interface ScrubberContext {
13
+ /** Where the thumb is drawn: the preview while dragging, else the committed fraction. */
14
+ fraction: number;
15
+ committedFraction: number;
16
+ dragging: boolean;
17
+ loading: boolean;
18
+ trackHandlers: {
19
+ onPointerDown: (event: PointerEvent<HTMLElement>) => void;
20
+ onPointerMove: (event: PointerEvent<HTMLElement>) => void;
21
+ onPointerUp: (event: PointerEvent<HTMLElement>) => void;
22
+ onPointerCancel: (event: PointerEvent<HTMLElement>) => void;
23
+ };
24
+ onThumbKeyDown: (event: KeyboardEvent<HTMLElement>) => void;
25
+ }
26
+
27
+ const Context = createContext<ScrubberContext | null>(null);
28
+
29
+ /** The live scrub state, for a readout that must follow the preview. Throws a plain message outside a `Scrubber.Root`. */
30
+ export function useScrubber(): Pick<ScrubberContext, 'fraction' | 'committedFraction' | 'dragging' | 'loading'> {
31
+ const context = useContext(Context);
32
+ if (!context) throw new Error('useScrubber needs a Scrubber.Root above it: there is no scrub state to read.');
33
+ const { fraction, committedFraction, dragging, loading } = context;
34
+ return { fraction, committedFraction, dragging, loading };
35
+ }
36
+
37
+ export interface ScrubberRootProps {
38
+ /** Where the host's cursor sits on the track, 0 to 1. */
39
+ fraction: number;
40
+ /** Another navigation is running: a new scrub does not start, keys do nothing, and a release does not commit. */
41
+ loading?: boolean;
42
+ onBeginScrub?: () => void;
43
+ onEndScrub?: () => void;
44
+ /** A release (or Home, End, PageUp, PageDown) resolved to this fraction. Called once per gesture. */
45
+ onCommit: (fraction: number) => void;
46
+ /** An arrow key: step one event in this direction. The host decides what "one event" is. */
47
+ onStep: (direction: -1 | 1) => void;
48
+ children?: ReactNode;
49
+ }
50
+
51
+ function Root({ fraction: committedInput, loading = false, onBeginScrub, onEndScrub, onCommit, onStep, children }: ScrubberRootProps) {
52
+ const committedFraction = committedInput;
53
+ const [dragFraction, setDragFraction] = useState<number | null>(null);
54
+ // Which pointer owns the drag: a ref, because the very next pointer event of the same gesture must read it
55
+ // before any state update has been applied.
56
+ const activePointerId = useRef<number | null>(null);
57
+ const live = useRef({ dragFraction, loading, onBeginScrub, onEndScrub, onCommit, onStep, committedFraction });
58
+ live.current = { dragFraction, loading, onBeginScrub, onEndScrub, onCommit, onStep, committedFraction };
59
+
60
+ const apply = useCallback((event: ScrubGestureEvent) => {
61
+ const now = live.current;
62
+ const result = reduceScrubGesture(event, now.dragFraction);
63
+ // The ref is updated here as well as at render, so two events in one frame still see the latest preview.
64
+ live.current = { ...now, dragFraction: result.dragFraction };
65
+ setDragFraction(result.dragFraction);
66
+ if (result.begin) now.onBeginScrub?.();
67
+ if (result.end) now.onEndScrub?.();
68
+ if (result.commit !== null) now.onCommit(result.commit);
69
+ }, []);
70
+
71
+ const fractionAt = (event: PointerEvent<HTMLElement>) => {
72
+ const rect = event.currentTarget.getBoundingClientRect();
73
+ return fractionFromPointer(event.clientX, rect.left, rect.width);
74
+ };
75
+
76
+ const trackHandlers = useMemo<ScrubberContext['trackHandlers']>(() => ({
77
+ onPointerDown(event) {
78
+ if (live.current.loading) return;
79
+ // Gate before capturing: a right-click must reach the browser's own menu, and a second finger must not start a scrub.
80
+ if (!isPrimaryScrubPointer(event)) return;
81
+ // Without this, a press after a text selection starts a native drag and the browser cancels the pointer.
82
+ event.preventDefault();
83
+ // preventDefault also stops the browser focusing what was pressed, so a press on the thumb focuses it here.
84
+ const pressed = event.target instanceof Element ? event.target.closest<HTMLElement>('[tabindex]') : null;
85
+ if (pressed && event.currentTarget.contains(pressed)) pressed.focus({ preventScroll: true });
86
+ activePointerId.current = event.pointerId;
87
+ event.currentTarget.setPointerCapture(event.pointerId);
88
+ apply({ type: 'down', fraction: fractionAt(event) });
89
+ },
90
+ onPointerMove(event) {
91
+ if (!isActiveScrubPointer(event.pointerId, activePointerId.current)) return;
92
+ apply({ type: 'move', fraction: fractionAt(event) });
93
+ },
94
+ onPointerUp(event) {
95
+ if (!isActiveScrubPointer(event.pointerId, activePointerId.current)) return;
96
+ activePointerId.current = null;
97
+ // The release's own coordinate, read the same way as a move; the last move may lag it.
98
+ apply({ type: 'up', fraction: fractionAt(event), loading: live.current.loading });
99
+ event.currentTarget.releasePointerCapture(event.pointerId);
100
+ },
101
+ onPointerCancel(event) {
102
+ if (!isActiveScrubPointer(event.pointerId, activePointerId.current)) return;
103
+ activePointerId.current = null;
104
+ apply({ type: 'cancel' });
105
+ },
106
+ }), [apply]);
107
+
108
+ const onThumbKeyDown = useCallback((event: KeyboardEvent<HTMLElement>) => {
109
+ const now = live.current;
110
+ if (now.loading) return;
111
+ const action = resolveScrubKey(event.key, now.committedFraction);
112
+ if (!action) return;
113
+ event.preventDefault();
114
+ if (action.type === 'step') {
115
+ // A host may also listen for arrows on the window; letting this bubble would step twice.
116
+ event.stopPropagation();
117
+ now.onStep(action.direction);
118
+ } else {
119
+ now.onCommit(action.fraction);
120
+ }
121
+ }, []);
122
+
123
+ const dragging = dragFraction !== null;
124
+ const context = useMemo<ScrubberContext>(() => ({
125
+ fraction: dragFraction ?? committedFraction,
126
+ committedFraction,
127
+ dragging,
128
+ loading,
129
+ trackHandlers,
130
+ onThumbKeyDown,
131
+ }), [dragFraction, committedFraction, dragging, loading, trackHandlers, onThumbKeyDown]);
132
+ return <Context.Provider value={context}>{children}</Context.Provider>;
133
+ }
134
+
135
+ function useContextOrThrow(part: string): ScrubberContext {
136
+ const context = useContext(Context);
137
+ if (!context) throw new Error(`Scrubber.${part} needs a Scrubber.Root above it.`);
138
+ return context;
139
+ }
140
+
141
+ /** The pressable area. Pointer handlers live here; marks and the thumb go inside it. */
142
+ function Track({ className, children }: { className?: string; children?: ReactNode }) {
143
+ const { trackHandlers, dragging, loading } = useContextOrThrow('Track');
144
+ return (
145
+ <div className={className} {...trackHandlers} data-part="scrubber-track" data-state={dragging ? 'dragging' : 'idle'} data-disabled={loading ? '' : undefined}>
146
+ {children}
147
+ </div>
148
+ );
149
+ }
150
+
151
+ export interface ScrubberThumbProps {
152
+ className?: string;
153
+ /** Accessible name. */
154
+ label?: string;
155
+ /** What the position means in words (the host's readout); announced with the value. */
156
+ valueText?: string;
157
+ /** Ids of elements that describe the thumb, such as the first few gap marks. */
158
+ describedBy?: string;
159
+ }
160
+
161
+ const percent = (fraction: number) => `${(clampFraction(fraction) * 100).toFixed(3)}%`;
162
+
163
+ /** The one tab stop: `role="slider"`, positioned at the live fraction. Arrows step, Home/End/PageUp/PageDown jump. */
164
+ function Thumb({ className, label = 'Session position', valueText, describedBy }: ScrubberThumbProps) {
165
+ const { fraction, loading, onThumbKeyDown } = useContextOrThrow('Thumb');
166
+ return (
167
+ <div
168
+ className={className}
169
+ data-part="scrubber-thumb"
170
+ role="slider"
171
+ tabIndex={0}
172
+ aria-label={label}
173
+ aria-describedby={describedBy}
174
+ aria-disabled={loading || undefined}
175
+ aria-valuemin={0}
176
+ aria-valuemax={100}
177
+ aria-valuenow={Math.round(clampFraction(fraction) * 100)}
178
+ aria-valuetext={valueText}
179
+ style={{ left: percent(fraction) }}
180
+ onKeyDown={onThumbKeyDown}
181
+ />
182
+ );
183
+ }
184
+
185
+ export interface ScrubberMarkProps {
186
+ /** Left edge on the track, 0 to 1. */
187
+ at: number;
188
+ /** Width on the track, 0 to 1; leave out for a point. */
189
+ span?: number;
190
+ /** Accessible name; the mark is an image, not a control, and never a tab stop. */
191
+ label: string;
192
+ /** What kind of mark: lets a host style gaps and checkpoints apart. */
193
+ kind?: string;
194
+ title?: string;
195
+ /** `-1` makes the mark focusable by pointer but never a tab stop; leave out for a plain image. */
196
+ tabIndex?: -1;
197
+ id?: string;
198
+ className?: string;
199
+ }
200
+
201
+ /** A position on the track that informs but cannot be activated (a declared gap, a verified checkpoint). */
202
+ function Mark({ at, span, label, kind, title, tabIndex, id, className }: ScrubberMarkProps) {
203
+ const style: CSSProperties = span === undefined ? { left: percent(at) } : { left: percent(at), width: percent(Math.max(0, span)) };
204
+ return <span id={id} className={className} data-part="scrubber-mark" data-kind={kind} style={style} role="img" tabIndex={tabIndex} title={title} aria-label={label} />;
205
+ }
206
+
207
+ export { Root, Track, Thumb, Mark };
@@ -0,0 +1,12 @@
1
+ import type { Series as StudySeries } from './core/studies.js';
2
+ import { Root, Layer, SeriesRoot, Select, Options } from './SelectionParts.js';
3
+
4
+ // The parts live in SelectionParts.js (a client module). The namespace objects are built here, outside it, so a server
5
+ // component can write `Selection.Root` and `Series.Root`: a property of an object exported from a client module is not
6
+ // reachable there.
7
+ export * from './SelectionParts.js';
8
+ export const Selection = { Root, Layer };
9
+ // `Series` is also the name of the study-values type in the core (`Series`, an array of numbers). A type and a value may
10
+ // share a name, so the type keeps its meaning for anyone who already imports it.
11
+ export type Series = StudySeries;
12
+ export const Series = { Root: SeriesRoot, Select, Options };
@@ -0,0 +1,251 @@
1
+ import { useCallback, useContext, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react';
2
+ import { buttonFace, type NameProps } from './accessible-name.js';
3
+ import { useChart } from './ChartParts.js';
4
+ import { SelectionContext, SeriesContext, type SelectionContextValue, type SelectionGuard, type SeriesContextValue } from './selection-context.js';
5
+ import { describeSelection, sameSelection, selectionAfterEscape, selectionAfterTap, type ChartSelection, type SelectionNames } from './core/selection.js';
6
+
7
+ /**
8
+ * Selection as parts: Selection.Root, Selection.Layer, and Series.Root, Series.Select, Series.Options. One thing is
9
+ * selected on a chart at a time: a drawing, the bar series or a study. Headless: the parts set `data-part` and
10
+ * `data-state='selected'`; the host gives them a look and fills the options panel with its own controls.
11
+ *
12
+ * Root holds the selection (controlled or not) and says each change in a polite live region. Everything is opt-in:
13
+ * without a Root above them, Drawings and Studies behave as they always did.
14
+ */
15
+
16
+ export interface SelectionRootProps {
17
+ /** Controlled selection; without it Root keeps its own. */
18
+ selection?: ChartSelection;
19
+ onSelectionChange?: (selection: ChartSelection) => void;
20
+ children?: ReactNode;
21
+ }
22
+
23
+ // Visually hidden, still announced. Inline so the live region works without a stylesheet.
24
+ const HIDDEN: CSSProperties = { position: 'absolute', width: 1, height: 1, margin: -1, padding: 0, overflow: 'hidden', clip: 'rect(0 0 0 0)', whiteSpace: 'nowrap', border: 0 };
25
+
26
+ /** Holds the selection and announces changes. Wrap the Chart, the Drawings.Root and the series and study controls in it. */
27
+ function Root({ selection, onSelectionChange, children }: SelectionRootProps) {
28
+ const [inner, setInner] = useState<ChartSelection>(null);
29
+ const current = selection !== undefined ? selection : inner;
30
+ // `latest` is the selection as the host supplied it, plus any change requested in the event being handled, so two parts
31
+ // agreeing on one tap report once. It goes back to the rendered value as soon as the event is over: a controlled host
32
+ // that declines a request is never left with a selection it did not accept.
33
+ const latest = useRef(current);
34
+ const rendered = useRef(current);
35
+ latest.current = current;
36
+ rendered.current = current;
37
+ const select = useCallback((next: ChartSelection) => {
38
+ if (sameSelection(latest.current, next)) return;
39
+ latest.current = next;
40
+ queueMicrotask(() => { latest.current = rendered.current; });
41
+ if (selection === undefined) setInner(next);
42
+ onSelectionChange?.(next);
43
+ }, [selection, onSelectionChange]);
44
+ const currentRef = useCallback(() => latest.current, []);
45
+
46
+ const [series, setSeries] = useState<{ id: string; label: string } | null>(null);
47
+ const selectRef = useRef(select);
48
+ selectRef.current = select;
49
+ const mounted = useRef<{ id: string; label: string } | null>(null);
50
+ const registerSeries = useCallback((next: { id: string; label: string }) => {
51
+ mounted.current = next;
52
+ setSeries(next);
53
+ return () => {
54
+ if (mounted.current === next) mounted.current = null;
55
+ setSeries((was) => (was && was.id === next.id ? null : was));
56
+ // A series that goes away cannot stay selected. A new label, or React replaying the effect, registers again in the
57
+ // same commit, so only a series still gone after that is removed.
58
+ queueMicrotask(() => {
59
+ const now = latest.current;
60
+ if (mounted.current?.id !== next.id && now && now.kind === 'series' && now.id === next.id) selectRef.current(null);
61
+ });
62
+ };
63
+ }, []);
64
+
65
+ const [names, setNames] = useState<SelectionNames>({});
66
+ const namesRef = useRef(names);
67
+ namesRef.current = names;
68
+ const setName = useCallback((kind: 'drawing' | 'series' | 'study', id: string, name: string) => {
69
+ setNames((was) => (was[kind]?.[id] === name ? was : { ...was, [kind]: { ...was[kind], [id]: name } }));
70
+ return () => {};
71
+ }, []);
72
+
73
+ const guard = useRef<SelectionGuard | null>(null);
74
+ const value = useMemo<SelectionContextValue>(
75
+ () => ({ selection: current, select, current: currentRef, series, registerSeries, setName, guard }),
76
+ [current, select, currentRef, series, registerSeries, setName],
77
+ );
78
+
79
+ const [status, setStatus] = useState('');
80
+ const seen = useRef(current === null ? '' : `${current.kind}:${current.id}`);
81
+ const usedId = useRef(false); // the last announcement had no name yet and said the id
82
+ const key = current === null ? '' : `${current.kind}:${current.id}`;
83
+ const nameNow = current === null ? undefined : names[current.kind]?.[current.id];
84
+ useEffect(() => {
85
+ if (seen.current === key && !(usedId.current && nameNow !== undefined)) return;
86
+ seen.current = key;
87
+ usedId.current = current !== null && nameNow === undefined;
88
+ setStatus(describeSelection(current, names));
89
+ }, [key, current, names, nameNow]);
90
+
91
+ return (
92
+ <SelectionContext.Provider value={value}>
93
+ {children}
94
+ <span data-part="selection-status" role="status" aria-live="polite" style={HIDDEN}>{status}</span>
95
+ </SelectionContext.Provider>
96
+ );
97
+ }
98
+
99
+ function useSelection(part: string): SelectionContextValue {
100
+ const context = useContext(SelectionContext);
101
+ if (!context) throw new Error(`${part} needs a Selection.Root above it.`);
102
+ return context;
103
+ }
104
+
105
+ const TAP_SLOP = 6;
106
+ const TAP_MS = 600;
107
+
108
+ /**
109
+ * The tap and Escape rules for the chart. Put it inside `Chart.Plot`, next to `Drawings.Layer` if there is one. It draws
110
+ * nothing. A tap (not a drag) on clear chart puts a selection away; with nothing selected and a `Series.Root` mounted
111
+ * it selects the series; a tap on a study's line or band (a `Study.Line` or `Study.Band` with an `id`) selects that study.
112
+ * Escape, with the chart focused, clears the selection (a half-placed drawing or an armed tool take the first Escape).
113
+ * A press on a drawing is Drawings.Layer's, and a drag is the chart panning, so neither counts.
114
+ */
115
+ function Layer() {
116
+ const ctx = useSelection('Selection.Layer');
117
+ const chart = useChart('Selection.Layer');
118
+ const ref = useRef<SVGGElement | null>(null);
119
+ const live = useRef({ ctx, chart });
120
+ live.current = { ctx, chart };
121
+
122
+ useEffect(() => {
123
+ const svg = ref.current?.ownerSVGElement;
124
+ if (!svg) return;
125
+ // `before` is the selection at the press: another listener on the same tap (Drawings.Layer putting a drawing away) may change it before the release.
126
+ let tap: { pointerId: number; x: number; y: number; at: number; hit: ChartSelection; before: ChartSelection; inMain: boolean } | null = null;
127
+ const down = new Set<number>();
128
+ const onDown = (event: PointerEvent) => {
129
+ if (event.isPrimary) down.clear();
130
+ down.add(event.pointerId);
131
+ // A second finger is a pinch, not a tap.
132
+ if (down.size > 1) { tap = null; return; }
133
+ if (event.button !== 0) return;
134
+ const { ctx: c, chart: ch } = live.current;
135
+ const point = ch.toChart(event.clientX, event.clientY);
136
+ if (!point || point.x < 0 || point.x >= ch.frame.plotWidth || point.y < 0 || point.y >= ch.plotBottom) return;
137
+ if (c.guard.current?.owns(event.clientX, event.clientY, event.pointerType)) return;
138
+ // A line of a study or of a series in a panel (data-select-id) is a hit; clear chart below the first panel is not the bar series'.
139
+ const hitEl = (event.target as Element | null)?.closest?.("[data-part='study-hit'],[data-part='series-hit']");
140
+ const hitId = hitEl?.getAttribute('data-select-id');
141
+ const hit: ChartSelection = hitId ? { kind: hitEl?.getAttribute('data-part') === 'series-hit' ? 'series' : 'study', id: hitId } : null;
142
+ tap = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, at: event.timeStamp, hit, before: c.current(), inMain: point.y < ch.frame.plotHeight };
143
+ };
144
+ const onMove = (event: PointerEvent) => {
145
+ if (tap && tap.pointerId === event.pointerId && Math.hypot(event.clientX - tap.x, event.clientY - tap.y) > TAP_SLOP) tap = null;
146
+ };
147
+ const onUp = (event: PointerEvent) => {
148
+ down.delete(event.pointerId);
149
+ if (!tap || tap.pointerId !== event.pointerId) return;
150
+ const t = tap;
151
+ tap = null;
152
+ if (event.type !== 'pointerup' || Math.hypot(event.clientX - t.x, event.clientY - t.y) > TAP_SLOP || event.timeStamp - t.at >= TAP_MS) return;
153
+ const { ctx: c } = live.current;
154
+ c.select(selectionAfterTap(t.before, t.hit, t.inMain ? c.series?.id ?? null : null));
155
+ };
156
+ const onKey = (event: KeyboardEvent) => {
157
+ if (event.key !== 'Escape' || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
158
+ const { ctx: c } = live.current;
159
+ if (c.guard.current?.busy()) return;
160
+ c.select(selectionAfterEscape(c.current()));
161
+ };
162
+ svg.addEventListener('pointerdown', onDown);
163
+ svg.addEventListener('pointermove', onMove);
164
+ svg.addEventListener('pointerup', onUp);
165
+ svg.addEventListener('pointercancel', onUp);
166
+ svg.addEventListener('keydown', onKey);
167
+ return () => {
168
+ svg.removeEventListener('pointerdown', onDown);
169
+ svg.removeEventListener('pointermove', onMove);
170
+ svg.removeEventListener('pointerup', onUp);
171
+ svg.removeEventListener('pointercancel', onUp);
172
+ svg.removeEventListener('keydown', onKey);
173
+ };
174
+ }, []);
175
+ return <g ref={ref} data-part="selection-layer" aria-hidden="true" />;
176
+ }
177
+
178
+ export interface SeriesRootProps {
179
+ /** The selection id of the series. Unique on the chart. */
180
+ id: string;
181
+ /** What the series is called: the button's name, the panel's label and the live-region text. */
182
+ label: string;
183
+ children?: ReactNode;
184
+ }
185
+
186
+ /**
187
+ * Says that a bar series exists, so a tap on clear chart can select it (with a Selection.Layer in the plot). It draws
188
+ * nothing. Put it above the chart and the Series.Select and Series.Options. The selected series shows
189
+ * `data-state='selected'` on its `Chart.Candles`.
190
+ */
191
+ function SeriesRoot({ id, label, children }: SeriesRootProps) {
192
+ const ctx = useSelection('Series.Root');
193
+ const { registerSeries, setName } = ctx;
194
+ useEffect(() => {
195
+ const undo = registerSeries({ id, label });
196
+ setName('series', id, label);
197
+ return undo;
198
+ }, [registerSeries, setName, id, label]);
199
+ const selected = ctx.selection !== null && ctx.selection.kind === 'series' && ctx.selection.id === id;
200
+ const value = useMemo<SeriesContextValue>(() => ({ id, label, selected }), [id, label, selected]);
201
+ return <SeriesContext.Provider value={value}>{children}</SeriesContext.Provider>;
202
+ }
203
+
204
+ function useSeries(part: string): SeriesContextValue {
205
+ const context = useContext(SeriesContext);
206
+ if (!context) throw new Error(`Series.${part} needs a Series.Root above it.`);
207
+ return context;
208
+ }
209
+
210
+ /**
211
+ * A button that selects the series, and puts the selection away when pressed again: the keyboard and touch path to what
212
+ * a tap on clear chart does. `aria-pressed` carries the state. Its name is the series label unless you give a `label`
213
+ * or children.
214
+ */
215
+ function Select({ className, icon, label, hideLabel, children }: NameProps & { className?: string; children?: ReactNode }) {
216
+ const ctx = useSelection('Series.Select');
217
+ const series = useSeries('Select');
218
+ const face = buttonFace(series.label, { icon, label, hideLabel, children });
219
+ return (
220
+ <button
221
+ type="button"
222
+ className={className}
223
+ data-mt=""
224
+ data-touch-target=""
225
+ data-part="series-select"
226
+ data-state={series.selected ? 'selected' : undefined}
227
+ aria-label={face['aria-label']}
228
+ aria-pressed={series.selected}
229
+ onClick={() => ctx.select(series.selected ? null : { kind: 'series', id: series.id })}
230
+ >
231
+ {face.content}
232
+ </button>
233
+ );
234
+ }
235
+
236
+ /**
237
+ * A headless panel for the series' options. It shows its children only while the series is selected. The primitives
238
+ * ship no option fields: put your own controls inside (an interval list, an instrument picker) and decide what a change
239
+ * means. `label` names the group; the default is the series label and "options".
240
+ */
241
+ function Options({ className, label, children }: { className?: string; label?: string; children?: ReactNode }) {
242
+ const series = useSeries('Options');
243
+ if (!series.selected) return null;
244
+ return (
245
+ <div className={className} role="group" aria-label={label ?? `${series.label} options`} data-mt="" data-part="series-options" data-series={series.id}>
246
+ {children}
247
+ </div>
248
+ );
249
+ }
250
+
251
+ export { Root, Layer, SeriesRoot, Select, Options, useSelection };
@@ -0,0 +1,6 @@
1
+ import { Root, Line, Band, Legend } from './StudiesParts.js';
2
+
3
+ // The parts live in StudiesParts.js (a client module). The namespace object is built here, outside it, so a server
4
+ // component can write `Study.Root`: a property of an object exported from a client module is not reachable there.
5
+ export * from './StudiesParts.js';
6
+ export const Study = { Root, Line, Band, Legend };