@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,478 @@
1
+ import { indexOfTime, timeOfIndex, type CandleBar, type ChartFrame } from './chart-scale.js';
2
+
3
+ /**
4
+ * The model, geometry and keys of chart drawings as plain functions. A drawing is a tool plus anchors, and an anchor
5
+ * is a time and a price, not a pixel. That keeps a drawing on its place when the chart pans, zooms or gets new bars:
6
+ * the pixels are worked out fresh from the frame each time. Nothing here throws on bad input; a drawing it cannot
7
+ * read has no geometry and is never hit.
8
+ */
9
+
10
+ export type DrawingTool =
11
+ | 'trend-line'
12
+ | 'ray'
13
+ | 'extended-line'
14
+ | 'horizontal-line'
15
+ | 'vertical-line'
16
+ | 'rectangle'
17
+ | 'fib-retracement'
18
+ | 'price-range'
19
+ | 'text';
20
+
21
+ /**
22
+ * How an anchor moves. 'free' (the default, and what an absent value means) moves in both directions. 'price' holds
23
+ * the price: a drag or nudge moves only the time. 'time' holds the time: it moves only the price. 'bar' keeps the time
24
+ * on a bar, moving in whole bars; the price stays a plain number. A value this library does not know reads as 'free'.
25
+ */
26
+ export type AttachMode = 'free' | 'price' | 'time' | 'bar';
27
+
28
+ export interface DrawingAnchor {
29
+ /** Unix milliseconds; may sit between bars or beyond the last one. */
30
+ timestamp: number;
31
+ price: number;
32
+ /** How the anchor moves. Absent means 'free'. */
33
+ attach?: AttachMode;
34
+ }
35
+
36
+ export interface Drawing {
37
+ id: string;
38
+ tool: DrawingTool;
39
+ anchors: DrawingAnchor[];
40
+ /** The words of a text drawing. */
41
+ text?: string;
42
+ /** A locked drawing can be selected but not moved or deleted from the keyboard. */
43
+ locked?: boolean;
44
+ }
45
+
46
+ export type SnapMode = 'off' | 'magnet';
47
+
48
+ /** Every attach mode, in the order a select lists them. */
49
+ export const ATTACH_MODES: readonly AttachMode[] = ['free', 'price', 'time', 'bar'];
50
+
51
+ /** Plain-English names, for a select and the text list. */
52
+ export const ATTACH_LABELS: Readonly<Record<AttachMode, string>> = {
53
+ free: 'Free',
54
+ price: 'Price held',
55
+ time: 'Time held',
56
+ bar: 'Whole bars',
57
+ };
58
+
59
+ /** The anchor's attach mode; anything that is not a known mode reads as 'free'. */
60
+ export function attachOf(anchor: Pick<DrawingAnchor, 'attach'> | null | undefined): AttachMode {
61
+ const mode = anchor?.attach;
62
+ return mode === 'price' || mode === 'time' || mode === 'bar' ? mode : 'free';
63
+ }
64
+
65
+ /** The time of the bar nearest the timestamp. With no bars the timestamp comes back as it was. */
66
+ export function barTime(bars: readonly CandleBar[], timestamp: number): number {
67
+ if (!bars || bars.length === 0 || !Number.isFinite(timestamp)) return timestamp;
68
+ return timeOfIndex(bars, Math.round(indexOfTime(bars, timestamp)));
69
+ }
70
+
71
+ /**
72
+ * Where a timestamp sits, counted from the last bar: 0 is the last bar, -1 the one before it, and a positive number is
73
+ * past the data. Fractional when the time falls between bars. Null with no bars.
74
+ */
75
+ export function barsFromLast(bars: readonly CandleBar[], timestamp: number): number | null {
76
+ if (!bars || bars.length === 0 || !Number.isFinite(timestamp)) return null;
77
+ return indexOfTime(bars, timestamp) - (bars.length - 1);
78
+ }
79
+
80
+ /** The same place counted from the first bar, starting at 1 (bar 1 is the first bar). Null with no bars. */
81
+ export function barsFromFirst(bars: readonly CandleBar[], timestamp: number): number | null {
82
+ if (!bars || bars.length === 0 || !Number.isFinite(timestamp)) return null;
83
+ return indexOfTime(bars, timestamp) + 1;
84
+ }
85
+
86
+ /** The timestamp of the bar `count` bars from the last one (0 is the last bar, -1 the one before it). Null with no bars or a count that is not a whole number. */
87
+ export function timeOfBarFromLast(bars: readonly CandleBar[], count: number): number | null {
88
+ if (!bars || bars.length === 0 || !Number.isInteger(count)) return null;
89
+ const at = timeOfIndex(bars, bars.length - 1 + count);
90
+ // Beyond the range a Date can hold, there is no time to show.
91
+ return Number.isFinite(at) && Math.abs(at) <= 8.64e15 ? at : null;
92
+ }
93
+
94
+ /** Every tool, in the order a toolbar lists them. */
95
+ export const DRAWING_TOOLS: readonly DrawingTool[] = [
96
+ 'trend-line', 'ray', 'extended-line', 'horizontal-line', 'vertical-line', 'rectangle', 'fib-retracement', 'price-range', 'text',
97
+ ];
98
+
99
+ /** Plain-English names, for toolbar buttons and the text list. */
100
+ export const DRAWING_TOOL_LABELS: Readonly<Record<DrawingTool, string>> = {
101
+ 'trend-line': 'Trend line',
102
+ 'ray': 'Ray',
103
+ 'extended-line': 'Extended line',
104
+ 'horizontal-line': 'Horizontal line',
105
+ 'vertical-line': 'Vertical line',
106
+ 'rectangle': 'Rectangle',
107
+ 'fib-retracement': 'Fib retracement',
108
+ 'price-range': 'Price range',
109
+ 'text': 'Text note',
110
+ };
111
+
112
+ /** The fib levels, as shares of the move: 0 sits on the second anchor and 1 on the first. */
113
+ export const FIB_RATIOS: readonly number[] = [0, 0.236, 0.382, 0.5, 0.618, 0.786, 1];
114
+
115
+ /** How far, in pixels, the magnet reaches to a bar's open, high, low or close. */
116
+ export const MAGNET_REACH = 12;
117
+
118
+ /** A rough width of one text character in viewBox pixels, used only to size the click area of a text note. */
119
+ export const TEXT_CHAR_WIDTH = 7;
120
+ const TEXT_HEIGHT = 14;
121
+
122
+ /** How many anchors a tool needs: one for horizontal and vertical lines and text, two for the rest. */
123
+ export function anchorCount(tool: DrawingTool): 1 | 2 {
124
+ return tool === 'horizontal-line' || tool === 'vertical-line' || tool === 'text' ? 1 : 2;
125
+ }
126
+
127
+ export interface DrawingSegment { x1: number; y1: number; x2: number; y2: number }
128
+ export interface DrawingBox { x: number; y: number; width: number; height: number }
129
+ export interface DrawingHandle { index: number; x: number; y: number }
130
+ export interface DrawingLevel { ratio: number; price: number; y: number; x1: number; x2: number }
131
+ export interface DrawingRange {
132
+ /** Second anchor price minus the first. */
133
+ change: number;
134
+ /** Change as a percent of the first price; null when the first price is 0. */
135
+ percent: number | null;
136
+ /** Whole bars between the anchors. */
137
+ bars: number;
138
+ }
139
+
140
+ /** What to draw for one drawing, in viewBox pixels. Parts that do not apply to the tool are empty or null. */
141
+ export interface DrawingGeometry {
142
+ segments: DrawingSegment[];
143
+ boxes: DrawingBox[];
144
+ levels: DrawingLevel[];
145
+ handles: DrawingHandle[];
146
+ label: { x: number; y: number; text: string } | null;
147
+ range: DrawingRange | null;
148
+ }
149
+
150
+ const isNumber = (n: unknown): n is number => typeof n === 'number' && Number.isFinite(n);
151
+ const validAnchor = (a: DrawingAnchor | undefined): a is DrawingAnchor => !!a && isNumber(a.timestamp) && isNumber(a.price);
152
+
153
+ const empty = (): DrawingGeometry => ({ segments: [], boxes: [], levels: [], handles: [], label: null, range: null });
154
+
155
+ /** The line through (x1,y1) and (x2,y2) between parameters t0 and t1, cut to the frame (Liang-Barsky). */
156
+ function clipLine(x1: number, y1: number, x2: number, y2: number, t0: number, t1: number, width: number, height: number): DrawingSegment | null {
157
+ const dx = x2 - x1;
158
+ const dy = y2 - y1;
159
+ let lo = t0;
160
+ let hi = t1;
161
+ const edges: [number, number][] = [[-dx, x1], [dx, width - x1], [-dy, y1], [dy, height - y1]];
162
+ for (const [p, q] of edges) {
163
+ if (p === 0) {
164
+ if (q < 0) return null;
165
+ continue;
166
+ }
167
+ const r = q / p;
168
+ if (p < 0) {
169
+ if (r > hi) return null;
170
+ if (r > lo) lo = r;
171
+ } else {
172
+ if (r < lo) return null;
173
+ if (r < hi) hi = r;
174
+ }
175
+ }
176
+ if (!Number.isFinite(lo) || !Number.isFinite(hi)) return null;
177
+ return { x1: x1 + lo * dx, y1: y1 + lo * dy, x2: x1 + hi * dx, y2: y1 + hi * dy };
178
+ }
179
+
180
+ /**
181
+ * Where a drawing goes on screen. A ray runs from the first anchor through the second to the frame edge, an extended
182
+ * line runs both ways to the edges, a rectangle and a price range are boxes, and a fib retracement has seven level
183
+ * lines between the anchors plus the line joining them. Handles are the anchors in pixels.
184
+ */
185
+ export function drawingGeometry(drawing: Drawing, bars: readonly CandleBar[], frame: ChartFrame): DrawingGeometry {
186
+ const out = empty();
187
+ if (!drawing || !Array.isArray(drawing.anchors)) return out;
188
+ const need = anchorCount(drawing.tool);
189
+ if (drawing.anchors.length < need || !drawing.anchors.slice(0, need).every(validAnchor)) return out;
190
+ const index = drawing.anchors.slice(0, need).map((a) => indexOfTime(bars, a.timestamp));
191
+ const px = drawing.anchors.slice(0, need).map((a, i) => ({ x: frame.x(index[i]), y: frame.y(a.price) }));
192
+ if (!px.every((p) => Number.isFinite(p.x) && Number.isFinite(p.y))) return out;
193
+ out.handles = px.map((p, i) => ({ index: i, x: p.x, y: p.y }));
194
+ const [a, b] = px;
195
+ const width = frame.plotWidth;
196
+ const height = frame.plotHeight;
197
+
198
+ switch (drawing.tool) {
199
+ case 'horizontal-line':
200
+ out.segments.push({ x1: 0, y1: a.y, x2: width, y2: a.y });
201
+ break;
202
+ case 'vertical-line':
203
+ out.segments.push({ x1: a.x, y1: 0, x2: a.x, y2: height });
204
+ break;
205
+ case 'text':
206
+ out.label = { x: a.x, y: a.y, text: typeof drawing.text === 'string' ? drawing.text : '' };
207
+ break;
208
+ case 'trend-line':
209
+ out.segments.push({ x1: a.x, y1: a.y, x2: b.x, y2: b.y });
210
+ break;
211
+ case 'ray':
212
+ case 'extended-line': {
213
+ if (a.x === b.x && a.y === b.y) {
214
+ out.segments.push({ x1: a.x, y1: a.y, x2: b.x, y2: b.y });
215
+ break;
216
+ }
217
+ const cut = clipLine(a.x, a.y, b.x, b.y, drawing.tool === 'ray' ? 0 : -Infinity, Infinity, width, height);
218
+ if (cut) out.segments.push(cut);
219
+ break;
220
+ }
221
+ case 'rectangle':
222
+ case 'price-range': {
223
+ out.boxes.push({ x: Math.min(a.x, b.x), y: Math.min(a.y, b.y), width: Math.abs(b.x - a.x), height: Math.abs(b.y - a.y) });
224
+ if (drawing.tool === 'price-range') {
225
+ const first = drawing.anchors[0].price;
226
+ const change = drawing.anchors[1].price - first;
227
+ out.range = { change, percent: first === 0 ? null : (change / Math.abs(first)) * 100, bars: Math.round(Math.abs(index[1] - index[0])) };
228
+ }
229
+ break;
230
+ }
231
+ case 'fib-retracement': {
232
+ const x1 = Math.min(a.x, b.x);
233
+ const x2 = Math.max(a.x, b.x);
234
+ const from = drawing.anchors[0].price;
235
+ const to = drawing.anchors[1].price;
236
+ out.segments.push({ x1: a.x, y1: a.y, x2: b.x, y2: b.y });
237
+ for (const ratio of FIB_RATIOS) {
238
+ const price = to + (from - to) * ratio;
239
+ out.levels.push({ ratio, price, y: frame.y(price), x1, x2 });
240
+ }
241
+ break;
242
+ }
243
+ default:
244
+ return empty();
245
+ }
246
+ return out;
247
+ }
248
+
249
+ /** The prices a set of drawings needs on screen, as [low, high] pairs for Chart.Root's `include`. */
250
+ export function drawingPriceRanges(drawings: readonly Drawing[]): [number, number][] {
251
+ const out: [number, number][] = [];
252
+ for (const d of drawings ?? []) {
253
+ const prices = (d?.anchors ?? []).filter(validAnchor).map((a) => a.price);
254
+ if (prices.length > 0) out.push([Math.min(...prices), Math.max(...prices)]);
255
+ }
256
+ return out;
257
+ }
258
+
259
+ export interface DrawingHit {
260
+ id: string;
261
+ /** The anchor index of a handle that was hit, or 'body'. */
262
+ handle: number | 'body';
263
+ }
264
+
265
+ function distanceToSegment(px: number, py: number, s: DrawingSegment): number {
266
+ const dx = s.x2 - s.x1;
267
+ const dy = s.y2 - s.y1;
268
+ const lengthSquared = dx * dx + dy * dy;
269
+ const t = lengthSquared === 0 ? 0 : Math.min(1, Math.max(0, ((px - s.x1) * dx + (py - s.y1) * dy) / lengthSquared));
270
+ return Math.hypot(px - (s.x1 + t * dx), py - (s.y1 + t * dy));
271
+ }
272
+
273
+ /**
274
+ * The drawing under a point, in viewBox pixels. The last drawing wins, so what was drawn on top is picked first. An
275
+ * anchor handle beats the body of the same drawing. `handleTolerancePx` is the grab radius of a handle (a finger needs
276
+ * more than a mouse); it defaults to the line tolerance. Rectangles and price ranges count inside as well as on the edge.
277
+ */
278
+ export function hitTest(drawings: readonly Drawing[], bars: readonly CandleBar[], frame: ChartFrame, point: { x: number; y: number }, tolerancePx = 6, handleTolerancePx = tolerancePx): DrawingHit | null {
279
+ if (!point || !isNumber(point.x) || !isNumber(point.y) || !Array.isArray(drawings)) return null;
280
+ const tol = isNumber(tolerancePx) ? Math.max(0, tolerancePx) : 6;
281
+ const handleTol = isNumber(handleTolerancePx) ? Math.max(0, handleTolerancePx) : tol;
282
+ for (let i = drawings.length - 1; i >= 0; i--) {
283
+ const drawing = drawings[i];
284
+ const g = drawingGeometry(drawing, bars, frame);
285
+ // The nearest handle inside the grab radius wins, so a wide touch radius never picks the first of two close anchors.
286
+ let nearest: { index: number; distance: number } | null = null;
287
+ for (const h of g.handles) {
288
+ const distance = Math.hypot(point.x - h.x, point.y - h.y);
289
+ if (distance <= handleTol && (!nearest || distance < nearest.distance)) nearest = { index: h.index, distance };
290
+ }
291
+ if (nearest) return { id: drawing.id, handle: nearest.index };
292
+ const onLine = g.segments.some((s) => distanceToSegment(point.x, point.y, s) <= tol)
293
+ || g.levels.some((l) => distanceToSegment(point.x, point.y, { x1: l.x1, y1: l.y, x2: l.x2, y2: l.y }) <= tol);
294
+ const inBox = g.boxes.some((b) => point.x >= b.x - tol && point.x <= b.x + b.width + tol && point.y >= b.y - tol && point.y <= b.y + b.height + tol);
295
+ const onLabel = g.label !== null
296
+ && point.x >= g.label.x - tol && point.x <= g.label.x + Math.max(1, g.label.text.length) * TEXT_CHAR_WIDTH + tol
297
+ && point.y >= g.label.y - TEXT_HEIGHT - tol && point.y <= g.label.y + tol;
298
+ if (onLine || inBox || onLabel) return { id: drawing.id, handle: 'body' };
299
+ }
300
+ return null;
301
+ }
302
+
303
+ /**
304
+ * A pointer position as an anchor. 'off' keeps the time and price under the pointer. 'magnet' always puts the time on
305
+ * the nearest bar, and puts the price on that bar's open, high, low or close when one is within 12 pixels up or down.
306
+ * Null for a point that is not a number.
307
+ */
308
+ export function snapToBar(point: { x: number; y: number }, bars: readonly CandleBar[], frame: ChartFrame, mode: SnapMode = 'off'): DrawingAnchor | null {
309
+ if (!point || !isNumber(point.x) || !isNumber(point.y)) return null;
310
+ const index = frame.indexAt(point.x);
311
+ const free = frame.priceAt(point.y);
312
+ if (mode !== 'magnet' || bars.length === 0) return { timestamp: timeOfIndex(bars, index), price: free };
313
+ const bar = bars[Math.min(bars.length - 1, Math.max(0, Math.round(index)))];
314
+ let price = free;
315
+ let best = MAGNET_REACH;
316
+ for (const candidate of [bar.open, bar.high, bar.low, bar.close]) {
317
+ if (!isNumber(candidate)) continue;
318
+ const gap = Math.abs(frame.y(candidate) - point.y);
319
+ if (gap <= best) {
320
+ best = gap;
321
+ price = candidate;
322
+ }
323
+ }
324
+ return { timestamp: bar.timestamp, price };
325
+ }
326
+
327
+ /** A copy of the drawing moved by whole or fractional bars and by a price. A locked or unreadable drawing comes back as it was. */
328
+ export function moveDrawing(drawing: Drawing, deltaIndex: number, deltaPrice: number, bars: readonly CandleBar[]): Drawing {
329
+ if (!drawing || drawing.locked || !Array.isArray(drawing.anchors)) return drawing;
330
+ const di = isNumber(deltaIndex) ? deltaIndex : 0;
331
+ const dp = isNumber(deltaPrice) ? deltaPrice : 0;
332
+ return {
333
+ ...drawing,
334
+ // With no bars there is no interval to move by, so times stay as they were. A held coordinate stays; 'bar' moves in whole bars.
335
+ anchors: drawing.anchors.map((a) => {
336
+ if (!validAnchor(a)) return a;
337
+ const mode = attachOf(a);
338
+ const time = bars.length === 0 || mode === 'time'
339
+ ? a.timestamp
340
+ : mode === 'bar' ? timeOfIndex(bars, Math.round(indexOfTime(bars, a.timestamp) + di)) : timeOfIndex(bars, indexOfTime(bars, a.timestamp) + di);
341
+ return { ...a, timestamp: time, price: mode === 'price' ? a.price : a.price + dp };
342
+ }),
343
+ };
344
+ }
345
+
346
+ /**
347
+ * A copy of the drawing with one anchor moved to a new place. The anchor keeps its attach mode and what that holds:
348
+ * 'price' keeps its price, 'time' keeps its time, and 'bar' puts the time on the nearest bar (pass the `bars`). An
349
+ * index that is not an anchor, or a locked drawing, changes nothing.
350
+ */
351
+ export function moveAnchor(drawing: Drawing, anchorIndex: number, to: DrawingAnchor, bars: readonly CandleBar[] = []): Drawing {
352
+ if (!drawing || drawing.locked || !Array.isArray(drawing.anchors) || !validAnchor(to)) return drawing;
353
+ if (!Number.isInteger(anchorIndex) || anchorIndex < 0 || anchorIndex >= drawing.anchors.length) return drawing;
354
+ return {
355
+ ...drawing,
356
+ anchors: drawing.anchors.map((a, i) => {
357
+ if (i !== anchorIndex) return a;
358
+ const mode = attachOf(a);
359
+ return { ...a, timestamp: mode === 'time' ? a.timestamp : mode === 'bar' ? barTime(bars, to.timestamp) : to.timestamp, price: mode === 'price' ? a.price : to.price };
360
+ }),
361
+ };
362
+ }
363
+
364
+ /**
365
+ * A copy of the drawing with typed values: each given anchor's time and/or price replaced, and the words of a text
366
+ * drawing. A locked drawing, an unreadable one, an anchor index that does not exist or a value that is not a finite
367
+ * number changes nothing, so the whole edit is refused rather than half applied.
368
+ */
369
+ export function editDrawing(drawing: Drawing, edit: { anchors?: readonly (Partial<DrawingAnchor> | undefined)[]; text?: string }): Drawing {
370
+ if (!drawing || drawing.locked || !Array.isArray(drawing.anchors)) return drawing;
371
+ const given = edit.anchors ?? [];
372
+ if (given.length > drawing.anchors.length) return drawing;
373
+ // An attach value that is not one of the four modes refuses the whole edit. 'free' is stored as no key at all.
374
+ if (given.some((g) => g?.attach !== undefined && !ATTACH_MODES.includes(g.attach))) return drawing;
375
+ const anchors = drawing.anchors.map((a, i) => {
376
+ const { attach: held, ...rest } = a;
377
+ const attach = given[i]?.attach ?? held;
378
+ return { ...rest, timestamp: given[i]?.timestamp ?? a.timestamp, price: given[i]?.price ?? a.price, ...(attach && attach !== 'free' ? { attach } : {}) };
379
+ });
380
+ if (!anchors.every(validAnchor)) return drawing;
381
+ return { ...drawing, anchors, ...(drawing.tool === 'text' && typeof edit.text === 'string' ? { text: edit.text } : {}) };
382
+ }
383
+
384
+ /** An id not used by any drawing yet: drawing-1, drawing-2 and so on. */
385
+ export function nextDrawingId(drawings: readonly Drawing[]): string {
386
+ let highest = 0;
387
+ for (const d of drawings ?? []) {
388
+ const match = /^drawing-(\d+)$/.exec(d?.id ?? '');
389
+ if (match) highest = Math.max(highest, Number(match[1]));
390
+ }
391
+ return `drawing-${highest + 1}`;
392
+ }
393
+
394
+ export type DrawingKeyAction =
395
+ | { kind: 'remove' }
396
+ | { kind: 'escape' }
397
+ /** dx is bars (left is -1) and dy is price steps (up is +1), each times `scale` (1, or 10 with Shift). */
398
+ | { kind: 'nudge'; dx: -1 | 0 | 1; dy: -1 | 0 | 1; scale: 1 | 10 };
399
+
400
+ /**
401
+ * Drawing keys: Delete and Backspace remove the selection, Escape cancels the drawing in progress (else the tool, else
402
+ * the selection), and the arrows nudge the selection by one bar or 1% of the visible price range, ten times that with
403
+ * Shift. Tab is never taken. Whether a key applies (is anything selected?) is for the caller to decide.
404
+ */
405
+ export function resolveDrawingKey(event: { key: string; altKey: boolean; ctrlKey: boolean; metaKey: boolean; shiftKey: boolean }): DrawingKeyAction | null {
406
+ if (!event || event.altKey || event.ctrlKey || event.metaKey) return null;
407
+ const scale = event.shiftKey ? 10 : 1;
408
+ switch (event.key) {
409
+ case 'Delete': case 'Backspace': return { kind: 'remove' };
410
+ case 'Escape': return { kind: 'escape' };
411
+ case 'ArrowLeft': return { kind: 'nudge', dx: -1, dy: 0, scale };
412
+ case 'ArrowRight': return { kind: 'nudge', dx: 1, dy: 0, scale };
413
+ case 'ArrowUp': return { kind: 'nudge', dx: 0, dy: 1, scale };
414
+ case 'ArrowDown': return { kind: 'nudge', dx: 0, dy: -1, scale };
415
+ default: return null;
416
+ }
417
+ }
418
+
419
+ /** The bars and price a nudge moves, given the visible price range. */
420
+ export function nudgeAmount(action: Extract<DrawingKeyAction, { kind: 'nudge' }>, frame: Pick<ChartFrame, 'priceMin' | 'priceMax'>): { bars: number; price: number } {
421
+ const range = frame.priceMax - frame.priceMin;
422
+ return { bars: action.dx * action.scale, price: Number.isFinite(range) ? action.dy * action.scale * range * 0.01 : 0 };
423
+ }
424
+
425
+ const pad = (n: number) => String(n).padStart(2, '0');
426
+ const clock = (t: number) => {
427
+ const d = new Date(t);
428
+ return Number.isNaN(d.getTime()) ? '?' : `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
429
+ };
430
+ const day = (t: number) => {
431
+ const d = new Date(t);
432
+ return Number.isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 10);
433
+ };
434
+
435
+ /**
436
+ * A drawing in words, for the text list, for example "Trend line from 101.20 at 14:05 to 103.80 at 15:20". Times are
437
+ * UTC, with the date added when the anchors fall on different days. `formatTime` replaces the default.
438
+ */
439
+ function describeBase(drawing: Drawing, decimals: number, formatTime?: (timestamp: number) => string): string {
440
+ const name = DRAWING_TOOL_LABELS[drawing?.tool] ?? 'Drawing';
441
+ const anchors = Array.isArray(drawing?.anchors) ? drawing.anchors.filter(validAnchor) : [];
442
+ const places = Number.isInteger(decimals) && decimals >= 0 && decimals <= 20 ? decimals : 2;
443
+ const days = new Set(anchors.map((a) => day(a.timestamp)));
444
+ const time = (t: number) => (formatTime ? formatTime(t) : days.size > 1 ? `${day(t).slice(5)} ${clock(t)}` : clock(t));
445
+ const price = (n: number) => n.toFixed(places);
446
+ const need = anchorCount(drawing?.tool);
447
+ if (anchors.length < need) return `${name}, incomplete`;
448
+ const [a, b] = anchors;
449
+ switch (drawing.tool) {
450
+ case 'horizontal-line': return `${name} at ${price(a.price)}`;
451
+ case 'vertical-line': return `${name} at ${time(a.timestamp)}`;
452
+ case 'text': return `${name} "${drawing.text ?? ''}" at ${price(a.price)}, ${time(a.timestamp)}`;
453
+ default: {
454
+ const span = `${name} from ${price(a.price)} at ${time(a.timestamp)} to ${price(b.price)} at ${time(b.timestamp)}`;
455
+ if (drawing.tool !== 'price-range') return span;
456
+ const change = b.price - a.price;
457
+ const percent = a.price === 0 ? '' : `, ${change >= 0 ? '+' : ''}${((change / Math.abs(a.price)) * 100).toFixed(2)}%`;
458
+ return `${span}, change ${change >= 0 ? '+' : ''}${price(change)}${percent}`;
459
+ }
460
+ }
461
+ }
462
+
463
+ /** One point in words: the price, then the time, for example "100.40, 19:03". Times are UTC unless `formatTime` is given. */
464
+ export function describeAnchor(anchor: DrawingAnchor, decimals = 2, formatTime?: (timestamp: number) => string): string {
465
+ const places = Number.isInteger(decimals) && decimals >= 0 && decimals <= 20 ? decimals : 2;
466
+ return `${anchor.price.toFixed(places)}, ${formatTime ? formatTime(anchor.timestamp) : clock(anchor.timestamp)}`;
467
+ }
468
+
469
+ /**
470
+ * A drawing in words, for the text list. When an anchor is not free the words end with how it moves, for example
471
+ * "Trend line from 101.20 at 14:05 to 103.80 at 15:20 (first point: price held)".
472
+ */
473
+ export function describeDrawing(drawing: Drawing, decimals = 2, formatTime?: (timestamp: number) => string): string {
474
+ const base = describeBase(drawing, decimals, formatTime);
475
+ const anchors = Array.isArray(drawing?.anchors) ? drawing.anchors.slice(0, anchorCount(drawing?.tool)) : [];
476
+ const notes = anchors.map((a) => attachOf(a)).map((mode, i) => (mode === 'free' ? '' : `${anchors.length > 1 ? `${i === 0 ? 'first' : 'second'} point: ` : ''}${ATTACH_LABELS[mode].toLowerCase()}`)).filter(Boolean);
477
+ return notes.length === 0 ? base : `${base} (${notes.join('; ')})`;
478
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Full-screen rules with no DOM in them. The Frame and Toggle parts do the browser work; these are the words and the one
3
+ * decision that needs care: who gets Escape.
4
+ */
5
+
6
+ /** What the live region says on entering and leaving full screen. */
7
+ export const FULLSCREEN_MESSAGE = { entered: 'Full screen', exited: 'Exited full screen' } as const;
8
+
9
+ /** The Toggle's default accessible names: the action it will do, not the state it is in. */
10
+ export const FULLSCREEN_LABEL = { enter: 'Full screen', exit: 'Exit full screen' } as const;
11
+
12
+ /** Who handles an Escape pressed while the frame is expanded. */
13
+ export type FullscreenEscape = 'exit' | 'chart';
14
+
15
+ /** What the parts around the focused chart still hold that an Escape should release first. */
16
+ export interface FullscreenEscapeInput {
17
+ /** Focus is on the chart's plot. */
18
+ inPlot: boolean;
19
+ /** The crosshair is showing, or something in the plot is selected (a drawing, the series, a study). */
20
+ plotHasMark: boolean;
21
+ /** A drawing tool is armed, or a drawing is half placed. These take the first Escape from anywhere in the frame. */
22
+ drawingBusy: boolean;
23
+ }
24
+
25
+ /**
26
+ * The Escape rule. Anything that handles Escape itself goes first: an open menu or the Edit form (they stop the key
27
+ * before it gets here), a drawing tool that is armed or a drawing half placed, and, with focus on the chart, the
28
+ * crosshair or a selection of any kind. With none of those, Escape leaves full screen.
29
+ */
30
+ export function resolveFullscreenEscape(input: FullscreenEscapeInput): FullscreenEscape {
31
+ return input.drawingBusy || (input.inPlot && input.plotHasMark) ? 'chart' : 'exit';
32
+ }
@@ -0,0 +1,42 @@
1
+ // The plain-TypeScript core of the primitives: geometry, state rules, studies and key maps with no React and no
2
+ // other import. A framework layer (the React parts beside this folder) imports from here; so can any other code.
3
+ export { numericWidthValue, primitiveAttributes } from './attributes.js';
4
+ export type { Change, CursorMark, DiffStatus, PrimitiveAttributes, PrimitiveState, Quality, Side } from './attributes.js';
5
+ export { isRowShown, LADDER_ROW_ATTRIBUTE, nextLadderRow, resolveLadderKey } from './ladder-keys.js';
6
+ export type { LadderKeyAction, LadderKeyInput } from './ladder-keys.js';
7
+ export { clampFraction, fractionFromPointer, isActiveScrubPointer, isPrimaryScrubPointer, reduceScrubGesture, resolveScrubKey } from './scrubber-gesture.js';
8
+ export type { ScrubGestureEvent, ScrubGestureResult, ScrubKeyAction, ScrubPointer } from './scrubber-gesture.js';
9
+ export { DEPTH_HEIGHT, DEPTH_WIDTH, depthGeometry, depthLevelId, nextDepthIndex, resolveDepthKey } from './depth-profile.js';
10
+ export type { DepthGeometry, DepthKeyAction, DepthLevel } from './depth-profile.js';
11
+ export { DIFF_CHANGE_LABEL, compareExact, diffRowId, holdDiffOrder, nextDiffIndex, nextDiffSort, resolveDiffKey, signedDelta, sortDiffRows } from './diff-table.js';
12
+ export type { DiffChange, DiffKeyAction, DiffMove, DiffSort, DiffTableRow } from './diff-table.js';
13
+ export { TAPE_SIDE_LABEL, holdTape, newestFirst, nextTapeIndex, readTape, resolveTapeKey, tapeSummary, tapeTime } from './tape.js';
14
+ export type { TapeIssue, TapeKeyAction, TapeMove, TapeRead, TapeRow, TapeSide, TapeTrade } from './tape.js';
15
+ export { buildLadder } from './ladder.js';
16
+ export type { LadderBuilt, LadderInput, LadderIssue, LadderLevelIn, LadderRow, LadderSide } from './ladder.js';
17
+ export { receiptHash, receiptVerdict, resolveReceiptKey } from './receipt-rules.js';
18
+ export type { ReceiptData, ReceiptHashOf, ReceiptVerdict } from './receipt-rules.js';
19
+ export { buildSymbolIndex, nextSymbolIndex, normalizeSymbolText, resolveSymbolKey, searchSymbols } from './symbol-search.js';
20
+ export type { Instrument, MatchHow, MatchedOn, SearchOptions, SymbolIndex, SymbolKeyAction, SymbolMatch } from './symbol-search.js';
21
+ export { barInterval, chartFrame, clampPriceRange, dataPriceSpan, growViewport, indexOfTime, lastBars, panPriceRange, panViewport, priceTicks, resolveChartKey, tickDecimals, timeOfIndex, zoomPriceRange, zoomViewport } from './chart-scale.js';
22
+ export type { CandleBar, ChartFrame, ChartFrameOptions, ChartInclude, PriceSpan, ChartKeyAction, ChartSize, ChartViewport } from './chart-scale.js';
23
+ export { INITIAL_CHART_GESTURE, isChartGestureActive, reduceChartGesture } from './chart-gesture.js';
24
+ export type { ChartGestureEvent, ChartGestureResult, ChartGestureState, ChartGestureView, ChartPoint } from './chart-gesture.js';
25
+ export { atr, bollinger, ema, rsi, sma, sourcePrice, studyRange, visibleIndexes, vwap, wma } from './studies.js';
26
+ export type { BollingerBands, Series, StudySource, VwapBand, VwapOptions, VwapResult } from './studies.js';
27
+ export { indexRangeOfTime, volumeProfile } from './volume-profile.js';
28
+ export type { ProfileRow, VolumeProfileOptions, VolumeProfileResult } from './volume-profile.js';
29
+ export { ATTACH_LABELS, ATTACH_MODES, DRAWING_TOOLS, DRAWING_TOOL_LABELS, FIB_RATIOS, anchorCount, attachOf, barTime, barsFromFirst, barsFromLast, timeOfBarFromLast, describeAnchor, describeDrawing, drawingGeometry, drawingPriceRanges, editDrawing, hitTest, moveAnchor, moveDrawing, nextDrawingId, nudgeAmount, resolveDrawingKey, snapToBar } from './drawings.js';
30
+ export type { AttachMode, Drawing, DrawingAnchor, DrawingGeometry, DrawingHit, DrawingKeyAction, DrawingTool, SnapMode } from './drawings.js';
31
+ export { describeBarsCsv, parseBarsCsv } from './csv-bars.js';
32
+ export type { CsvBarsError, CsvBarsNote, CsvBarsOptions, CsvBarsResult, CsvExtraColumn } from './csv-bars.js';
33
+ export { formatBarTime, heikinAshi } from './candles.js';
34
+ export { barField, barNote, fromPriceField, fromTimeField, parseBarField, toTimeField } from './drawing-fields.js';
35
+ export { describeSelection, sameSelection, selectionAfterClearTap, selectionAfterEscape, selectionAfterTap } from './selection.js';
36
+ export type { ChartSelection, SelectionNames } from './selection.js';
37
+ export { AXIS_BOTTOM, MAIN_PANEL, describeMove, moveSeries, moveTargets, normalizeLayout, panelBands, panelOf, reconcileLayout } from './panels.js';
38
+ export type { MoveDestination, MoveTarget, PanelBand, PanelLayout, PanelSeriesRequest, PanelSpec } from './panels.js';
39
+ export { POINT_KINDS, alignSeries, duplicateIds, pointsFromValues, report, resolveSeries, seriesSetSchema, validateSeriesSet } from './series.js';
40
+ export type { AlignMode, AlignOptions, AlignedSeries, AlignedState, DataSeries, FindingKind, PointKind, ResolvedSeries, SeriesCalendar, SeriesFinding, SeriesInput, SeriesIssue, SeriesPoint, SeriesSet, SeriesValidation } from './series.js';
41
+ export { csvAdapter, toSeriesSet } from './adapter.js';
42
+ export type { AdapterIssue, AdapterResult, CsvAdapterOptions, DataAdapter } from './adapter.js';
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Ladder keyboard contract. Read-only: no key places or changes an order.
3
+ *
4
+ * | Key | Action |
5
+ * |---------------|----------------------------------------------------------------|
6
+ * | ArrowDown | focus the next row down |
7
+ * | ArrowUp | focus the next row up |
8
+ * | Home | focus the best bid |
9
+ * | End | focus the best ask |
10
+ * | Enter / Space | seek to when this level last changed (when the host supplies it) |
11
+ *
12
+ * One row is the tab stop (roving tabindex), so Tab leaves the ladder in one press. Home and End are also the
13
+ * global transport "start" and "end" keys; inside the ladder they mean best bid and best ask, and the ladder
14
+ * stops them there.
15
+ */
16
+ export type LadderKeyAction = 'next' | 'previous' | 'best-bid' | 'best-ask';
17
+
18
+ /** The fields of a keyboard event the contract reads; a DOM or React KeyboardEvent fits. */
19
+ export interface LadderKeyInput { key: string; altKey: boolean; ctrlKey: boolean; metaKey: boolean; shiftKey: boolean }
20
+
21
+ export function resolveLadderKey(event: LadderKeyInput): LadderKeyAction | null {
22
+ if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return null;
23
+ switch (event.key) {
24
+ case 'ArrowDown':
25
+ return 'next';
26
+ case 'ArrowUp':
27
+ return 'previous';
28
+ case 'Home':
29
+ return 'best-bid';
30
+ case 'End':
31
+ return 'best-ask';
32
+ default:
33
+ return null;
34
+ }
35
+ }
36
+
37
+ /** Marks a row for the contract. `best` rows are what Home and End jump to. */
38
+ export const LADDER_ROW_ATTRIBUTE = 'data-ladder-row';
39
+
40
+ /**
41
+ * On screen: it has a box and no closed `<details>` above it. Chromium still reports boxes for the contents of a
42
+ * closed disclosure, so the box test alone is not enough.
43
+ */
44
+ export function isRowShown(row: HTMLElement): boolean {
45
+ for (let el: HTMLElement | null = row.parentElement; el; el = el.parentElement) {
46
+ if (el instanceof HTMLDetailsElement && !el.open) return false;
47
+ }
48
+ return row.getClientRects().length > 0;
49
+ }
50
+
51
+ /** The row to focus, or null when the key does nothing here. `rows` are in screen order, top to bottom. */
52
+ export function nextLadderRow(rows: readonly HTMLElement[], current: HTMLElement | null, action: LadderKeyAction): HTMLElement | null {
53
+ if (!rows.length) return null;
54
+ if (action === 'best-bid') return rows.find((row) => row.dataset.best === 'bid') ?? null;
55
+ if (action === 'best-ask') return rows.find((row) => row.dataset.best === 'ask') ?? null;
56
+ const at = current ? rows.indexOf(current) : -1;
57
+ if (at < 0) return rows[0] ?? null;
58
+ const to = action === 'next' ? at + 1 : at - 1;
59
+ return rows[to] ?? null; // stops at the ends; never wraps
60
+ }
61
+