@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,226 @@
1
+ import { createContext, useCallback, useContext, useEffect, useId, useMemo, useState, type ReactNode } from 'react';
2
+ import { useChart } from './ChartParts.js';
3
+ import { resolveSeries, type SeriesPoint } from './core/series.js';
4
+ import { SelectionContext } from './selection-context.js';
5
+
6
+ /**
7
+ * Study as parts: Root, Line, Band, Legend. Line and Band go inside `Chart.Plot` and draw a study's values on the
8
+ * price scale; they read the frame through useChart(). A Legend outside the SVG lists each study and its value at the
9
+ * crosshair bar, so the numbers are text and not only a picture.
10
+ *
11
+ * Wrap `Chart.Plot` and the Legend in one `Study.Root`: the lines tell it their name and values, and the Legend reads
12
+ * them back. Without a Root the lines still draw and the Legend is empty. Keep a study's values in a `useMemo`; a
13
+ * new array each render only costs a re-registration, never a loop.
14
+ */
15
+ interface Entry {
16
+ name: string;
17
+ /** One series for a line; [lower, upper] for a band. */
18
+ series: ReadonlyArray<ReadonlyArray<number | null>>;
19
+ /** The selection id, when the study has one and a Selection.Root is above. */
20
+ selectId?: string;
21
+ /** What is wrong with the series, in words; the Legend shows it. */
22
+ problems?: readonly string[];
23
+ }
24
+ interface Registry {
25
+ register: (id: string, entry: Entry) => void;
26
+ unregister: (id: string) => void;
27
+ }
28
+ const RegistryContext = createContext<Registry | null>(null);
29
+ const EntriesContext = createContext<ReadonlyMap<string, Entry>>(new Map());
30
+
31
+ /** Keeps the studies' names and values so the Legend can list them. Draws nothing. */
32
+ function Root({ children }: { children?: ReactNode }) {
33
+ const [entries, setEntries] = useState<ReadonlyMap<string, Entry>>(() => new Map());
34
+ const register = useCallback((id: string, entry: Entry) => {
35
+ setEntries((current) => {
36
+ const next = new Map(current);
37
+ next.set(id, entry);
38
+ return next;
39
+ });
40
+ }, []);
41
+ const unregister = useCallback((id: string) => {
42
+ setEntries((current) => {
43
+ if (!current.has(id)) return current;
44
+ const next = new Map(current);
45
+ next.delete(id);
46
+ return next;
47
+ });
48
+ }, []);
49
+ const registry = useMemo<Registry>(() => ({ register, unregister }), [register, unregister]);
50
+ return (
51
+ <RegistryContext.Provider value={registry}>
52
+ <EntriesContext.Provider value={entries}>{children}</EntriesContext.Provider>
53
+ </RegistryContext.Provider>
54
+ );
55
+ }
56
+
57
+ function useRegistration(name: string, series: Entry['series'], selectId?: string, problems?: readonly string[]) {
58
+ const registry = useContext(RegistryContext);
59
+ const id = useId();
60
+ useEffect(() => {
61
+ registry?.register(id, { name, series, selectId, problems });
62
+ return () => registry?.unregister(id);
63
+ }, [registry, id, name, series, selectId, problems]);
64
+ }
65
+
66
+ /** Wide enough for a finger: the transparent hit stroke is --mt-touch-target (44px) across. */
67
+ const HIT_WIDTH = 44;
68
+
69
+ /**
70
+ * Selection for a study with an `id`: whether it is selected, and the extra id the Legend needs. Without a Selection.Root
71
+ * above, or without an id, nothing is selectable and the study draws exactly as it did.
72
+ */
73
+ function useStudySelection(id: string | undefined, name: string) {
74
+ const selection = useContext(SelectionContext);
75
+ const setName = selection?.setName;
76
+ useEffect(() => {
77
+ if (setName && id !== undefined && name) setName('study', id, name);
78
+ }, [setName, id, name]);
79
+ const selectId = selection && id !== undefined ? id : undefined;
80
+ const sel = selection?.selection;
81
+ const selected = selectId !== undefined && !!sel && sel.kind === 'study' && sel.id === selectId;
82
+ return { selectId, selected };
83
+ }
84
+
85
+ const num = (n: number) => n.toFixed(2);
86
+
87
+ /** One run of consecutive bars first..last, as SVG path segments. `pick` returns a point for a bar or null to break the run. */
88
+ function runs(first: number, last: number, pick: (i: number) => number | null): number[][] {
89
+ const out: number[][] = [];
90
+ let run: number[] = [];
91
+ for (let i = first; i <= last; i++) {
92
+ if (pick(i) === null) {
93
+ if (run.length) out.push(run);
94
+ run = [];
95
+ } else run.push(i);
96
+ }
97
+ if (run.length) out.push(run);
98
+ return out;
99
+ }
100
+
101
+ const valid = (v: number | null | undefined): v is number => v !== null && v !== undefined && Number.isFinite(v);
102
+
103
+ export interface StudyLineProps {
104
+ /** The line on its own clock, placed on the bars by exact time. A bar with no point on it has no value there; a bar's gap kind (closed, missing, ...) is not drawn. */
105
+ points?: ReadonlyArray<SeriesPoint>;
106
+ /**
107
+ * One value per bar by position, null where the study has none. A study computed from these bars (`sma(bars, 20)`) is
108
+ * already by position. Give `points` for a series that comes from elsewhere. Neither given draws nothing.
109
+ * @deprecated Give `points` (`pointsFromValues(bars, sma(bars, 20))` makes them). `values` is removed in 0.0.7.
110
+ */
111
+ values?: ReadonlyArray<number | null>;
112
+ /** Shown in the Legend and carried as `data-study`. */
113
+ name: string;
114
+ /** Makes the study selectable (with a Selection.Root above): by a tap on the line and by its Legend button. Unique on the chart. */
115
+ id?: string;
116
+ className?: string;
117
+ }
118
+
119
+ /** A line through the study's values in view. It lifts the pen at every null, so a warm-up gap stays a gap. */
120
+ const NO_VALUES: ReadonlyArray<number | null> = [];
121
+
122
+ function Line({ values: given, points, name, id, className }: StudyLineProps) {
123
+ const { frame, readoutId, bars } = useChart('Study.Line');
124
+ // Points are placed on the bars by exact time; a study's own values are already one per bar.
125
+ const resolved = useMemo(() => {
126
+ if (points === undefined) {
127
+ if (given !== undefined) return { values: given, problems: [] as string[] };
128
+ return { values: NO_VALUES, problems: [`"${name}" has neither points nor values, so there is nothing to draw.`] };
129
+ }
130
+ const r = resolveSeries(bars.map((b) => b.timestamp), { id: id ?? name, label: name, points });
131
+ return { values: r.values, problems: r.problems };
132
+ }, [points, given, bars, id, name]);
133
+ const values = resolved.values;
134
+ const series = useMemo(() => [values], [values]);
135
+ const { selectId, selected } = useStudySelection(id, name);
136
+ useRegistration(name, series, selectId, resolved.problems);
137
+ let d = '';
138
+ for (const run of runs(frame.first, frame.last, (i) => (valid(values[i]) ? 0 : null))) {
139
+ if (run.length < 2) continue;
140
+ d += run.map((i, k) => `${k === 0 ? 'M' : 'L'}${num(frame.x(i))},${num(frame.y(values[i] as number))}`).join('');
141
+ }
142
+ if (!d) return null;
143
+ const line = <path className={className} data-part="study-line" data-state={selected ? 'selected' : undefined} clipPath={`url(#${readoutId}-price)`} data-study={name} d={d} fill="none" aria-hidden="true" />;
144
+ if (selectId === undefined) return line;
145
+ // A wide transparent stroke on top, so a finger can hit a thin line. The Selection.Layer reads the tap from `data-select-id`.
146
+ return (
147
+ <>
148
+ {line}
149
+ <path data-part="study-hit" data-select-id={selectId} clipPath={`url(#${readoutId}-price)`} d={d} fill="none" stroke="transparent" strokeWidth={HIT_WIDTH} strokeLinecap="round" strokeLinejoin="round" pointerEvents="stroke" aria-hidden="true" />
150
+ </>
151
+ );
152
+ }
153
+
154
+ export interface StudyBandProps {
155
+ upper: ReadonlyArray<number | null>;
156
+ lower: ReadonlyArray<number | null>;
157
+ /** Shown in the Legend as "lower - upper" and carried as `data-study`. Without a name the band is not listed. */
158
+ name?: string;
159
+ /** Makes the study selectable (with a Selection.Root above): by a tap on the band and, when it has a name, by its Legend button. Unique on the chart. */
160
+ id?: string;
161
+ className?: string;
162
+ }
163
+
164
+ /** The space between two series, filled. It stops where either series has no value. */
165
+ function Band({ upper: givenUpper, lower: givenLower, name = '', id, className }: StudyBandProps) {
166
+ const upper = givenUpper ?? NO_VALUES; // a side that is not computed yet leaves the band empty
167
+ const lower = givenLower ?? NO_VALUES;
168
+ const { frame, readoutId } = useChart('Study.Band');
169
+ const series = useMemo(() => [lower, upper], [lower, upper]);
170
+ const { selectId, selected } = useStudySelection(id, name);
171
+ useRegistration(name, series, selectId);
172
+ let d = '';
173
+ const both = (i: number) => (valid(upper[i]) && valid(lower[i]) ? 0 : null);
174
+ for (const run of runs(frame.first, frame.last, both)) {
175
+ if (run.length < 2) continue;
176
+ const top = run.map((i, k) => `${k === 0 ? 'M' : 'L'}${num(frame.x(i))},${num(frame.y(upper[i] as number))}`).join('');
177
+ const bottom = [...run].reverse().map((i) => `L${num(frame.x(i))},${num(frame.y(lower[i] as number))}`).join('');
178
+ d += `${top}${bottom}Z`;
179
+ }
180
+ if (!d) return null;
181
+ const band = <path className={className} data-part="study-band" data-state={selected ? 'selected' : undefined} clipPath={`url(#${readoutId}-price)`} data-study={name || undefined} d={d} aria-hidden="true" />;
182
+ if (selectId === undefined) return band;
183
+ return (
184
+ <>
185
+ {band}
186
+ <path data-part="study-hit" data-select-id={selectId} clipPath={`url(#${readoutId}-price)`} d={d} fill="transparent" stroke="transparent" strokeWidth={HIT_WIDTH} strokeLinejoin="round" pointerEvents="all" aria-hidden="true" />
187
+ </>
188
+ );
189
+ }
190
+
191
+ export interface StudyLegendProps {
192
+ className?: string;
193
+ /** Prints one value. Default: the chart's decimals. */
194
+ format?: (value: number) => string;
195
+ }
196
+
197
+ /**
198
+ * The studies and their values at the crosshair bar (the last bar in view while there is no crosshair). Put it outside
199
+ * the SVG. `data-active` is present while the values belong to a crosshair bar. A study with no value there shows a dash.
200
+ */
201
+ function Legend({ className, format }: StudyLegendProps) {
202
+ const { active, frame, decimals } = useChart('Study.Legend');
203
+ const entries = useContext(EntriesContext);
204
+ const selection = useContext(SelectionContext);
205
+ const index = active ?? frame.last;
206
+ const text = (v: number | null | undefined) => (valid(v) ? (format ? format(v) : v.toFixed(decimals)) : '–');
207
+ const items: ReactNode[] = [];
208
+ entries.forEach((entry, id) => {
209
+ if (!entry.name) return;
210
+ const selectId = selection ? entry.selectId : undefined;
211
+ const selected = selectId !== undefined && selection!.selection?.kind === 'study' && selection!.selection.id === selectId;
212
+ const name = <span data-part="study-name">{entry.name}</span>;
213
+ items.push(
214
+ <li key={id} data-part="study-legend-item" data-study={entry.name} data-state={selected ? 'selected' : undefined}>
215
+ {selectId === undefined ? name : (
216
+ <button type="button" data-mt="" data-touch-target="" data-part="study-select" aria-pressed={selected} onClick={() => selection!.select(selected ? null : { kind: 'study', id: selectId })}>{name}</button>
217
+ )}{' '}
218
+ <span data-part="study-value" data-mt-numeric>{entry.series.map((s) => text(s[index])).join(' – ')}</span>
219
+ {(entry.problems ?? []).map((p) => <span key={p} data-part="study-problem" role="note"> {p}</span>)}
220
+ </li>,
221
+ );
222
+ });
223
+ return <ul className={className} data-part="study-legend" data-active={active !== null ? '' : undefined} aria-label="Study values">{items}</ul>;
224
+ }
225
+
226
+ export { Root, Line, Band, Legend };
@@ -0,0 +1,5 @@
1
+ import { List, SubmenuRoot, SubmenuTrigger } from './MenuParts.js';
2
+
3
+ // A submenu is a menu inside a menu. Its list and items are the Menu ones (Menu.List, Menu.Item); only the root and the
4
+ // trigger differ. Built here, outside the client module, for the same reason as Menu.
5
+ export const Submenu = { Root: SubmenuRoot, Trigger: SubmenuTrigger, List };
@@ -0,0 +1,6 @@
1
+ import { Root, Input, List, Option, Count } from './SymbolSearchParts.js';
2
+
3
+ // The parts live in SymbolSearchParts.js (a client module). The namespace object is built here, outside it, so a server
4
+ // component can write `SymbolSearch.Root`: a property of an object exported from a client module is not reachable there.
5
+ export * from './SymbolSearchParts.js';
6
+ export const SymbolSearch = { Root, Input, List, Option, Count };
@@ -0,0 +1,166 @@
1
+ import { createContext, useCallback, useContext, useId, useMemo, useState, type ChangeEvent, type KeyboardEvent, type ReactNode } from 'react';
2
+ import { buildSymbolIndex, nextSymbolIndex, resolveSymbolKey, searchSymbols, type Instrument, type SymbolMatch } from './core/symbol-search.js';
3
+
4
+ /**
5
+ * SymbolSearch as parts: Root, Input, List, Option, Count. Headless; a combobox over a list the host
6
+ * supplies. The host owns the instruments (and what a venue or a contract roll means); the parts own matching,
7
+ * keys and what a screen reader hears. Typing never throws, whatever the characters.
8
+ *
9
+ * Keys in the input: ArrowDown/ArrowUp move through the rows (they stop at the ends), Enter picks the highlighted
10
+ * row, Escape closes the list and a second Escape clears the text. The best match is highlighted as you type, so
11
+ * Enter alone takes it. The count is announced politely; each row says why it matched (symbol, alias or name).
12
+ */
13
+ interface SearchContext {
14
+ query: string;
15
+ setQuery: (text: string) => void;
16
+ open: boolean;
17
+ setOpen: (open: boolean) => void;
18
+ rows: SymbolMatch[];
19
+ more: boolean;
20
+ active: number;
21
+ setActive: (index: number) => void;
22
+ pick: (index: number) => void;
23
+ listId: string;
24
+ countId: string;
25
+ optionId: (index: number) => string;
26
+ }
27
+ const Context = createContext<SearchContext | null>(null);
28
+
29
+ function useSearch(part: string): SearchContext {
30
+ const context = useContext(Context);
31
+ if (!context) throw new Error(`SymbolSearch.${part} needs a SymbolSearch.Root above it.`);
32
+ return context;
33
+ }
34
+
35
+ export interface SymbolSearchRootProps {
36
+ instruments: readonly Instrument[];
37
+ /** Rows shown, 1 to 200; default 20. */
38
+ limit?: number;
39
+ venue?: string;
40
+ kind?: string;
41
+ /** True when the instrument list is synthetic; sets `data-synthetic` on the root. */
42
+ synthetic?: boolean;
43
+ onPick?: (instrument: Instrument) => void;
44
+ className?: string;
45
+ children?: ReactNode;
46
+ }
47
+
48
+ function Root({ instruments, limit = 20, venue, kind, synthetic, onPick, className, children }: SymbolSearchRootProps) {
49
+ const index = useMemo(() => buildSymbolIndex(instruments), [instruments]);
50
+ const [query, setQueryState] = useState('');
51
+ const [open, setOpen] = useState(false);
52
+ const [active, setActive] = useState(-1);
53
+ const id = useId();
54
+ const shown = Math.min(200, Math.max(1, Math.floor(limit) || 20));
55
+ const found = useMemo(() => searchSymbols(index, query, { limit: shown + 1, venue, kind }), [index, query, shown, venue, kind]);
56
+ const rows = found.slice(0, shown);
57
+ const more = found.length > shown;
58
+ const setQuery = useCallback((text: string) => {
59
+ setQueryState(text);
60
+ setOpen(text.trim() !== '');
61
+ setActive(text.trim() === '' ? -1 : 0); // the best match is highlighted, so Enter alone takes it
62
+ }, []);
63
+ const pick = useCallback((i: number) => {
64
+ const row = rows[i];
65
+ if (!row) return;
66
+ setQueryState(row.instrument.symbol);
67
+ setOpen(false);
68
+ setActive(-1);
69
+ onPick?.(row.instrument);
70
+ }, [rows, onPick]);
71
+ const context = useMemo<SearchContext>(() => ({
72
+ query, setQuery, open, setOpen, rows, more, active: active >= rows.length ? rows.length - 1 : active, setActive, pick,
73
+ listId: `${id}-list`, countId: `${id}-count`, optionId: (i) => `${id}-opt-${i}`,
74
+ }), [query, setQuery, open, rows, more, active, pick, id]);
75
+ return (
76
+ <Context.Provider value={context}>
77
+ <div className={className} data-synthetic={synthetic ? '' : undefined}>{children}</div>
78
+ </Context.Provider>
79
+ );
80
+ }
81
+
82
+ /** The text box. One tab stop; it owns the keys. */
83
+ function Input({ className, label = 'Search instruments', placeholder }: { className?: string; label?: string; placeholder?: string }) {
84
+ const { query, setQuery, open, setOpen, rows, active, setActive, pick, listId, countId, optionId } = useSearch('Input');
85
+ const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
86
+ const action = resolveSymbolKey(event.nativeEvent, { open, hasText: query !== '' });
87
+ if (!action) return;
88
+ if (action === 'pick' && rows.length === 0) return;
89
+ event.preventDefault();
90
+ event.stopPropagation(); // these keys are not the page's transport shortcuts while the box has focus
91
+ if (action === 'next' || action === 'previous') {
92
+ if (!open && query.trim() !== '') setOpen(true);
93
+ setActive(nextSymbolIndex(rows.length, active, action));
94
+ } else if (action === 'pick') pick(Math.max(0, active));
95
+ else if (action === 'close') setOpen(false);
96
+ else setQuery('');
97
+ };
98
+ return (
99
+ <input
100
+ className={className}
101
+ type="text"
102
+ role="combobox"
103
+ aria-label={label}
104
+ aria-autocomplete="list"
105
+ aria-expanded={open && rows.length > 0}
106
+ aria-controls={listId}
107
+ aria-describedby={countId}
108
+ aria-activedescendant={open && active >= 0 && active < rows.length ? optionId(active) : undefined}
109
+ autoComplete="off"
110
+ autoCapitalize="characters"
111
+ spellCheck={false}
112
+ placeholder={placeholder}
113
+ value={query}
114
+ onChange={(event: ChangeEvent<HTMLInputElement>) => setQuery(event.target.value)}
115
+ onBlur={() => setOpen(false)}
116
+ onKeyDown={onKeyDown}
117
+ />
118
+ );
119
+ }
120
+
121
+ /** The result list, given the rows to draw. Hidden while closed or empty. */
122
+ function List({ className, children }: { className?: string; children: (rows: SymbolMatch[]) => ReactNode }) {
123
+ const { rows, open, listId } = useSearch('List');
124
+ return (
125
+ <ul id={listId} className={className} role="listbox" aria-label="Matching instruments" hidden={!open || rows.length === 0}>
126
+ {open ? children(rows) : null}
127
+ </ul>
128
+ );
129
+ }
130
+
131
+ /** One row. `data-matched` says why it is here; `data-venue` keeps the same symbol on two venues apart. */
132
+ function Option({ row, index, className, children }: { row: SymbolMatch; index: number; className?: string; children?: ReactNode }) {
133
+ const { active, pick, optionId } = useSearch('Option');
134
+ const { instrument } = row;
135
+ return (
136
+ <li
137
+ id={optionId(index)}
138
+ className={className}
139
+ role="option"
140
+ aria-selected={index === active}
141
+ data-matched={row.matchedOn}
142
+ data-venue={instrument.venue}
143
+ onMouseDown={(event) => event.preventDefault()} // keep focus in the box
144
+ onClick={() => pick(index)}
145
+ >
146
+ {children ?? (
147
+ <>
148
+ <span data-mt-numeric="">{instrument.symbol}</span> <span>{instrument.venue}</span>
149
+ {instrument.name && <> <span>{instrument.name}</span></>}
150
+ {row.matchedOn !== 'symbol' && <> <span>(matched {row.matchedOn})</span></>}
151
+ </>
152
+ )}
153
+ </li>
154
+ );
155
+ }
156
+
157
+ /** The count, announced politely: "3 results", "No instrument matches". Nothing while the box is empty. */
158
+ function Count({ className }: { className?: string }) {
159
+ const { query, rows, more, countId } = useSearch('Count');
160
+ const text = query.trim() === '' ? ''
161
+ : rows.length === 0 ? `No instrument matches “${query.trim()}”.`
162
+ : `${rows.length}${more ? '+' : ''} ${rows.length === 1 && !more ? 'result' : 'results'}${more ? ', first rows shown' : ''}.`;
163
+ return <output id={countId} className={className} aria-live="polite">{text}</output>;
164
+ }
165
+
166
+ export { Root, Input, List, Option, Count };
package/src/Tape.tsx ADDED
@@ -0,0 +1,6 @@
1
+ import { Root, Table, Header, Row, Time, Price, Size, Side, Summary, Status } from './TapeParts.js';
2
+
3
+ // The parts live in TapeParts.js (a client module). The namespace object is built here, outside it, so a server
4
+ // component can write `Tape.Root`: a property of an object exported from a client module is not reachable there.
5
+ export * from './TapeParts.js';
6
+ export const Tape = { Root, Table, Header, Row, Time, Price, Size, Side, Summary, Status };
@@ -0,0 +1,208 @@
1
+ import { createContext, useContext, useEffect, useMemo, useRef, useState, type FocusEvent, type KeyboardEvent, type ReactNode } from 'react';
2
+ import { TAPE_SIDE_LABEL, holdTape, newestFirst, nextTapeIndex, readTape, resolveTapeKey, tapeSummary, type TapeIssue, type TapeRow, type TapeTrade } from './core/tape.js';
3
+
4
+ /**
5
+ * Tape as parts: Root, Table, Header, Row, Time, Price, Size, Side, Summary, Status. Headless: the parts are real
6
+ * table elements and add `data-tape-side` (buy in the bid colour with ▲, sell in the ask colour with ▼) and `data-paused`; the host
7
+ * chooses the columns.
8
+ *
9
+ * Newest first. While the pointer or focus is on the tape the rows hold their place and new trades are counted, not
10
+ * inserted, so a row never moves under a finger. Keys: arrows move between rows, Home is the newest row and End the
11
+ * oldest, Enter or Space picks the row. The Summary part is one polite line on a timer, never one announcement per
12
+ * trade. A row that breaks a rule is listed by Status and is not drawn.
13
+ */
14
+ interface TapeContext {
15
+ issues: TapeIssue[];
16
+ paused: boolean;
17
+ waiting: number;
18
+ summary: string;
19
+ activeId: string | null;
20
+ firstId: string | null;
21
+ setActive: (id: string) => void;
22
+ }
23
+ const Context = createContext<TapeContext | null>(null);
24
+
25
+ function useTape(part: string): TapeContext {
26
+ const context = useContext(Context);
27
+ if (!context) throw new Error(`Tape.${part} needs a Tape.Root above it.`);
28
+ return context;
29
+ }
30
+
31
+ export interface TapeRootProps {
32
+ /** The trades, oldest first. Anything that breaks a rule is reported by `Tape.Status` and left out. */
33
+ trades: readonly TapeTrade[];
34
+ /** How many of the newest rows to show. Default 50. */
35
+ limit?: number;
36
+ /** How often the summary line may change, in milliseconds. Default 5000. */
37
+ summaryEveryMs?: number;
38
+ /** Enter or Space on a focused row. */
39
+ onSelect?: (row: TapeRow) => void;
40
+ className?: string;
41
+ /** The parts, given the rows to show (newest first, held while the pointer or focus is on the tape). */
42
+ children: (rows: TapeRow[]) => ReactNode;
43
+ }
44
+
45
+ function Root({ trades, limit = 50, summaryEveryMs = 5000, onSelect, className, children }: TapeRootProps) {
46
+ const read = useMemo(() => readTape(trades), [trades]);
47
+ const [pointer, setPointer] = useState(false);
48
+ const [focus, setFocus] = useState(false);
49
+ const [held, setHeld] = useState<string[] | null>(null);
50
+ const [activeId, setActiveId] = useState<string | null>(null);
51
+ const root = useRef<HTMLDivElement>(null);
52
+
53
+ const live = useMemo(() => newestFirst(read.rows).slice(0, Math.max(0, limit)), [read.rows, limit]);
54
+ const paused = pointer || focus;
55
+ const holding = paused && held ? holdTape(held, read.rows) : null;
56
+ const shown = holding ? holding.rows : live;
57
+ const waiting = holding ? holding.waiting : 0;
58
+ const summary = useMemo(() => tapeSummary(read.rows), [read.rows]);
59
+ const firstId = shown.length ? shown[0].id : null;
60
+ // If the row last focused has left the shown rows, the tab stop goes back to the newest row.
61
+ const stopId = activeId !== null && shown.some((r) => r.id === activeId) ? activeId : null;
62
+
63
+ const freeze = () => setHeld((current) => current ?? live.map((r) => r.id));
64
+ const release = (next: boolean) => { if (!next) setHeld(null); };
65
+
66
+ const focusRow = (id: string) => {
67
+ setActiveId(id);
68
+ for (const tr of root.current?.querySelectorAll<HTMLElement>('tr[data-tape-row]') ?? []) {
69
+ if (tr.dataset.tapeRow === id) { tr.focus(); return; }
70
+ }
71
+ };
72
+
73
+ const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
74
+ const action = resolveTapeKey(event.nativeEvent);
75
+ if (!action) return;
76
+ const onRow = (event.target as HTMLElement).closest('tr[data-tape-row]') as HTMLElement | null;
77
+ if (!onRow) return;
78
+ if (action.type === 'select') {
79
+ if (event.target !== onRow) return; // a control inside the row keeps its own Enter and Space
80
+ const row = shown.find((r) => r.id === onRow.dataset.tapeRow);
81
+ if (!row || !onSelect) return;
82
+ event.preventDefault();
83
+ onSelect(row);
84
+ return;
85
+ }
86
+ event.preventDefault();
87
+ event.stopPropagation(); // these keys are also page shortcuts
88
+ const index = shown.findIndex((r) => r.id === onRow.dataset.tapeRow);
89
+ const to = nextTapeIndex(shown.length, index, action.to);
90
+ if (to >= 0) focusRow(shown[to].id);
91
+ };
92
+
93
+ const onBlur = (event: FocusEvent<HTMLDivElement>) => {
94
+ if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
95
+ setFocus(false);
96
+ release(pointer);
97
+ };
98
+
99
+ // A focused row that the host's window drops leaves the page without a blur event, so let go of the hold here.
100
+ useEffect(() => {
101
+ if (focus && root.current && !root.current.contains(document.activeElement)) {
102
+ setFocus(false);
103
+ if (!pointer) setHeld(null);
104
+ }
105
+ }, [shown, focus, pointer]);
106
+
107
+ // The summary changes on a timer, and not at all while someone is reading rows.
108
+ const latest = useRef(summary);
109
+ latest.current = summary;
110
+ const pausedRef = useRef(paused);
111
+ pausedRef.current = paused;
112
+ const [announced, setAnnounced] = useState(summary);
113
+ useEffect(() => {
114
+ const id = setInterval(() => { if (!pausedRef.current) setAnnounced(latest.current); }, Math.max(1000, summaryEveryMs));
115
+ return () => clearInterval(id);
116
+ }, [summaryEveryMs]);
117
+
118
+ const context = useMemo<TapeContext>(
119
+ () => ({ issues: read.issues, paused, waiting, summary: announced, activeId: stopId, firstId, setActive: setActiveId }),
120
+ [read.issues, paused, waiting, announced, stopId, firstId],
121
+ );
122
+ return (
123
+ <Context.Provider value={context}>
124
+ <div
125
+ ref={root}
126
+ className={className}
127
+ data-mt=""
128
+ data-paused={paused ? '' : undefined}
129
+ onPointerEnter={() => { setPointer(true); freeze(); }}
130
+ onPointerLeave={() => { setPointer(false); release(focus); }}
131
+ onFocus={() => { setFocus(true); freeze(); }}
132
+ onBlur={onBlur}
133
+ onKeyDown={onKeyDown}
134
+ >
135
+ {children(shown)}
136
+ </div>
137
+ </Context.Provider>
138
+ );
139
+ }
140
+
141
+ /** The table. Give it a caption through `caption`; the rows go inside `tbody`. */
142
+ function Table({ caption, className, captionClassName, children }: { caption: string; className?: string; captionClassName?: string; children: ReactNode }) {
143
+ return (
144
+ <table className={className}>
145
+ <caption className={captionClassName}>{caption}</caption>
146
+ {children}
147
+ </table>
148
+ );
149
+ }
150
+
151
+ /** The header row. Put the `<th scope="col">` cells inside. */
152
+ function Header({ className, children }: { className?: string; children: ReactNode }) {
153
+ return <thead><tr className={className}>{children}</tr></thead>;
154
+ }
155
+
156
+ /** One trade. A single tab stop for the tape: the active row, or the newest. Holding the pointer does not move it. */
157
+ function Row({ row, selected, className, children }: { row: TapeRow; selected?: boolean; className?: string; children: ReactNode }) {
158
+ const { activeId, firstId, setActive } = useTape('Row');
159
+ const stop = activeId === null ? row.id === firstId : row.id === activeId;
160
+ return (
161
+ <tr
162
+ className={className}
163
+ data-tape-row={row.id}
164
+ aria-selected={selected === undefined ? undefined : selected}
165
+ tabIndex={stop ? 0 : -1}
166
+ onFocus={() => setActive(row.id)}
167
+ >
168
+ {children}
169
+ </tr>
170
+ );
171
+ }
172
+
173
+ /** The time as recorded, UTC. */
174
+ function Time({ row, className }: { row: TapeRow; className?: string }) {
175
+ return <th scope="row" className={className} data-mt-numeric=""><time dateTime={row.t}>{row.time}</time></th>;
176
+ }
177
+
178
+ function Price({ row, className }: { row: TapeRow; className?: string }) {
179
+ return <td className={className} data-mt-numeric="">{row.price}</td>;
180
+ }
181
+
182
+ function Size({ row, className }: { row: TapeRow; className?: string }) {
183
+ return <td className={className} data-mt-numeric="">{row.size}</td>;
184
+ }
185
+
186
+ /** The side in words; tokens.css adds the mark so it never rests on colour alone. An unknown side has no colour. */
187
+ function Side({ row, className, label }: { row: TapeRow; className?: string; label?: string }) {
188
+ return <td className={className} data-mt="" data-tape-side={row.side === 'unknown' ? undefined : row.side} title={row.side === 'unknown' ? 'The feed did not say which side was the aggressor' : undefined}>{label ?? TAPE_SIDE_LABEL[row.side]}</td>;
189
+ }
190
+
191
+ /** The polite live line: a summary of the tape on a timer. Render it once, outside the table. */
192
+ function Summary({ className }: { className?: string }) {
193
+ const { summary } = useTape('Summary');
194
+ return <p className={className} role="status" aria-live="polite" aria-atomic="true">{summary}</p>;
195
+ }
196
+
197
+ /** What the tape is doing that the rows do not say: paused with N new waiting, and rows left out for breaking a rule. */
198
+ function Status({ className, children }: { className?: string; children?: (state: { paused: boolean; waiting: number; issues: TapeIssue[] }) => ReactNode }) {
199
+ const { paused, waiting, issues } = useTape('Status');
200
+ if (children) return <div className={className}>{children({ paused, waiting, issues })}</div>;
201
+ const parts: string[] = [];
202
+ if (paused) parts.push(waiting > 0 ? `Paused. ${waiting} new ${waiting === 1 ? 'trade' : 'trades'} waiting.` : 'Paused while you read.');
203
+ if (issues.length) parts.push(`${issues.length} ${issues.length === 1 ? 'row' : 'rows'} left out: ${issues.slice(0, 3).map((i) => `row ${i.index + 1}, ${i.message}`).join('; ')}${issues.length > 3 ? '; …' : ''}.`);
204
+ if (!parts.length) return null;
205
+ return <p className={className} data-paused={paused ? '' : undefined}>{parts.join(' ')}</p>;
206
+ }
207
+
208
+ export { Root, Table, Header, Row, Time, Price, Size, Side, Summary, Status };
@@ -0,0 +1,6 @@
1
+ import { Root, Button, Separator } from './ToolbarParts.js';
2
+
3
+ // The parts live in ToolbarParts.js (a client module). The namespace object is built here, outside it, so a server
4
+ // component can write `Toolbar.Root`: a property of an object exported from a client module is not reachable there.
5
+ export * from './ToolbarParts.js';
6
+ export const Toolbar = { Root, Button, Separator };