@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,194 @@
1
+ import { type CandleBar, type ChartFrame } from './chart-scale.js';
2
+ /**
3
+ * The model, geometry and keys of chart drawings as plain functions. A drawing is a tool plus anchors, and an anchor
4
+ * 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:
5
+ * the pixels are worked out fresh from the frame each time. Nothing here throws on bad input; a drawing it cannot
6
+ * read has no geometry and is never hit.
7
+ */
8
+ export type DrawingTool = 'trend-line' | 'ray' | 'extended-line' | 'horizontal-line' | 'vertical-line' | 'rectangle' | 'fib-retracement' | 'price-range' | 'text';
9
+ /**
10
+ * How an anchor moves. 'free' (the default, and what an absent value means) moves in both directions. 'price' holds
11
+ * the price: a drag or nudge moves only the time. 'time' holds the time: it moves only the price. 'bar' keeps the time
12
+ * on a bar, moving in whole bars; the price stays a plain number. A value this library does not know reads as 'free'.
13
+ */
14
+ export type AttachMode = 'free' | 'price' | 'time' | 'bar';
15
+ export interface DrawingAnchor {
16
+ /** Unix milliseconds; may sit between bars or beyond the last one. */
17
+ timestamp: number;
18
+ price: number;
19
+ /** How the anchor moves. Absent means 'free'. */
20
+ attach?: AttachMode;
21
+ }
22
+ export interface Drawing {
23
+ id: string;
24
+ tool: DrawingTool;
25
+ anchors: DrawingAnchor[];
26
+ /** The words of a text drawing. */
27
+ text?: string;
28
+ /** A locked drawing can be selected but not moved or deleted from the keyboard. */
29
+ locked?: boolean;
30
+ }
31
+ export type SnapMode = 'off' | 'magnet';
32
+ /** Every attach mode, in the order a select lists them. */
33
+ export declare const ATTACH_MODES: readonly AttachMode[];
34
+ /** Plain-English names, for a select and the text list. */
35
+ export declare const ATTACH_LABELS: Readonly<Record<AttachMode, string>>;
36
+ /** The anchor's attach mode; anything that is not a known mode reads as 'free'. */
37
+ export declare function attachOf(anchor: Pick<DrawingAnchor, 'attach'> | null | undefined): AttachMode;
38
+ /** The time of the bar nearest the timestamp. With no bars the timestamp comes back as it was. */
39
+ export declare function barTime(bars: readonly CandleBar[], timestamp: number): number;
40
+ /**
41
+ * Where a timestamp sits, counted from the last bar: 0 is the last bar, -1 the one before it, and a positive number is
42
+ * past the data. Fractional when the time falls between bars. Null with no bars.
43
+ */
44
+ export declare function barsFromLast(bars: readonly CandleBar[], timestamp: number): number | null;
45
+ /** The same place counted from the first bar, starting at 1 (bar 1 is the first bar). Null with no bars. */
46
+ export declare function barsFromFirst(bars: readonly CandleBar[], timestamp: number): number | null;
47
+ /** 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. */
48
+ export declare function timeOfBarFromLast(bars: readonly CandleBar[], count: number): number | null;
49
+ /** Every tool, in the order a toolbar lists them. */
50
+ export declare const DRAWING_TOOLS: readonly DrawingTool[];
51
+ /** Plain-English names, for toolbar buttons and the text list. */
52
+ export declare const DRAWING_TOOL_LABELS: Readonly<Record<DrawingTool, string>>;
53
+ /** The fib levels, as shares of the move: 0 sits on the second anchor and 1 on the first. */
54
+ export declare const FIB_RATIOS: readonly number[];
55
+ /** How far, in pixels, the magnet reaches to a bar's open, high, low or close. */
56
+ export declare const MAGNET_REACH = 12;
57
+ /** A rough width of one text character in viewBox pixels, used only to size the click area of a text note. */
58
+ export declare const TEXT_CHAR_WIDTH = 7;
59
+ /** How many anchors a tool needs: one for horizontal and vertical lines and text, two for the rest. */
60
+ export declare function anchorCount(tool: DrawingTool): 1 | 2;
61
+ export interface DrawingSegment {
62
+ x1: number;
63
+ y1: number;
64
+ x2: number;
65
+ y2: number;
66
+ }
67
+ export interface DrawingBox {
68
+ x: number;
69
+ y: number;
70
+ width: number;
71
+ height: number;
72
+ }
73
+ export interface DrawingHandle {
74
+ index: number;
75
+ x: number;
76
+ y: number;
77
+ }
78
+ export interface DrawingLevel {
79
+ ratio: number;
80
+ price: number;
81
+ y: number;
82
+ x1: number;
83
+ x2: number;
84
+ }
85
+ export interface DrawingRange {
86
+ /** Second anchor price minus the first. */
87
+ change: number;
88
+ /** Change as a percent of the first price; null when the first price is 0. */
89
+ percent: number | null;
90
+ /** Whole bars between the anchors. */
91
+ bars: number;
92
+ }
93
+ /** What to draw for one drawing, in viewBox pixels. Parts that do not apply to the tool are empty or null. */
94
+ export interface DrawingGeometry {
95
+ segments: DrawingSegment[];
96
+ boxes: DrawingBox[];
97
+ levels: DrawingLevel[];
98
+ handles: DrawingHandle[];
99
+ label: {
100
+ x: number;
101
+ y: number;
102
+ text: string;
103
+ } | null;
104
+ range: DrawingRange | null;
105
+ }
106
+ /**
107
+ * Where a drawing goes on screen. A ray runs from the first anchor through the second to the frame edge, an extended
108
+ * line runs both ways to the edges, a rectangle and a price range are boxes, and a fib retracement has seven level
109
+ * lines between the anchors plus the line joining them. Handles are the anchors in pixels.
110
+ */
111
+ export declare function drawingGeometry(drawing: Drawing, bars: readonly CandleBar[], frame: ChartFrame): DrawingGeometry;
112
+ /** The prices a set of drawings needs on screen, as [low, high] pairs for Chart.Root's `include`. */
113
+ export declare function drawingPriceRanges(drawings: readonly Drawing[]): [number, number][];
114
+ export interface DrawingHit {
115
+ id: string;
116
+ /** The anchor index of a handle that was hit, or 'body'. */
117
+ handle: number | 'body';
118
+ }
119
+ /**
120
+ * The drawing under a point, in viewBox pixels. The last drawing wins, so what was drawn on top is picked first. An
121
+ * anchor handle beats the body of the same drawing. `handleTolerancePx` is the grab radius of a handle (a finger needs
122
+ * more than a mouse); it defaults to the line tolerance. Rectangles and price ranges count inside as well as on the edge.
123
+ */
124
+ export declare function hitTest(drawings: readonly Drawing[], bars: readonly CandleBar[], frame: ChartFrame, point: {
125
+ x: number;
126
+ y: number;
127
+ }, tolerancePx?: number, handleTolerancePx?: number): DrawingHit | null;
128
+ /**
129
+ * A pointer position as an anchor. 'off' keeps the time and price under the pointer. 'magnet' always puts the time on
130
+ * 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.
131
+ * Null for a point that is not a number.
132
+ */
133
+ export declare function snapToBar(point: {
134
+ x: number;
135
+ y: number;
136
+ }, bars: readonly CandleBar[], frame: ChartFrame, mode?: SnapMode): DrawingAnchor | null;
137
+ /** 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. */
138
+ export declare function moveDrawing(drawing: Drawing, deltaIndex: number, deltaPrice: number, bars: readonly CandleBar[]): Drawing;
139
+ /**
140
+ * A copy of the drawing with one anchor moved to a new place. The anchor keeps its attach mode and what that holds:
141
+ * 'price' keeps its price, 'time' keeps its time, and 'bar' puts the time on the nearest bar (pass the `bars`). An
142
+ * index that is not an anchor, or a locked drawing, changes nothing.
143
+ */
144
+ export declare function moveAnchor(drawing: Drawing, anchorIndex: number, to: DrawingAnchor, bars?: readonly CandleBar[]): Drawing;
145
+ /**
146
+ * A copy of the drawing with typed values: each given anchor's time and/or price replaced, and the words of a text
147
+ * drawing. A locked drawing, an unreadable one, an anchor index that does not exist or a value that is not a finite
148
+ * number changes nothing, so the whole edit is refused rather than half applied.
149
+ */
150
+ export declare function editDrawing(drawing: Drawing, edit: {
151
+ anchors?: readonly (Partial<DrawingAnchor> | undefined)[];
152
+ text?: string;
153
+ }): Drawing;
154
+ /** An id not used by any drawing yet: drawing-1, drawing-2 and so on. */
155
+ export declare function nextDrawingId(drawings: readonly Drawing[]): string;
156
+ export type DrawingKeyAction = {
157
+ kind: 'remove';
158
+ } | {
159
+ kind: 'escape';
160
+ }
161
+ /** dx is bars (left is -1) and dy is price steps (up is +1), each times `scale` (1, or 10 with Shift). */
162
+ | {
163
+ kind: 'nudge';
164
+ dx: -1 | 0 | 1;
165
+ dy: -1 | 0 | 1;
166
+ scale: 1 | 10;
167
+ };
168
+ /**
169
+ * Drawing keys: Delete and Backspace remove the selection, Escape cancels the drawing in progress (else the tool, else
170
+ * the selection), and the arrows nudge the selection by one bar or 1% of the visible price range, ten times that with
171
+ * Shift. Tab is never taken. Whether a key applies (is anything selected?) is for the caller to decide.
172
+ */
173
+ export declare function resolveDrawingKey(event: {
174
+ key: string;
175
+ altKey: boolean;
176
+ ctrlKey: boolean;
177
+ metaKey: boolean;
178
+ shiftKey: boolean;
179
+ }): DrawingKeyAction | null;
180
+ /** The bars and price a nudge moves, given the visible price range. */
181
+ export declare function nudgeAmount(action: Extract<DrawingKeyAction, {
182
+ kind: 'nudge';
183
+ }>, frame: Pick<ChartFrame, 'priceMin' | 'priceMax'>): {
184
+ bars: number;
185
+ price: number;
186
+ };
187
+ /** One point in words: the price, then the time, for example "100.40, 19:03". Times are UTC unless `formatTime` is given. */
188
+ export declare function describeAnchor(anchor: DrawingAnchor, decimals?: number, formatTime?: (timestamp: number) => string): string;
189
+ /**
190
+ * A drawing in words, for the text list. When an anchor is not free the words end with how it moves, for example
191
+ * "Trend line from 101.20 at 14:05 to 103.80 at 15:20 (first point: price held)".
192
+ */
193
+ export declare function describeDrawing(drawing: Drawing, decimals?: number, formatTime?: (timestamp: number) => string): string;
194
+ //# sourceMappingURL=drawings.d.ts.map
@@ -0,0 +1,403 @@
1
+ import { indexOfTime, timeOfIndex } from './chart-scale.js';
2
+ /** Every attach mode, in the order a select lists them. */
3
+ export const ATTACH_MODES = ['free', 'price', 'time', 'bar'];
4
+ /** Plain-English names, for a select and the text list. */
5
+ export const ATTACH_LABELS = {
6
+ free: 'Free',
7
+ price: 'Price held',
8
+ time: 'Time held',
9
+ bar: 'Whole bars',
10
+ };
11
+ /** The anchor's attach mode; anything that is not a known mode reads as 'free'. */
12
+ export function attachOf(anchor) {
13
+ const mode = anchor?.attach;
14
+ return mode === 'price' || mode === 'time' || mode === 'bar' ? mode : 'free';
15
+ }
16
+ /** The time of the bar nearest the timestamp. With no bars the timestamp comes back as it was. */
17
+ export function barTime(bars, timestamp) {
18
+ if (!bars || bars.length === 0 || !Number.isFinite(timestamp))
19
+ return timestamp;
20
+ return timeOfIndex(bars, Math.round(indexOfTime(bars, timestamp)));
21
+ }
22
+ /**
23
+ * Where a timestamp sits, counted from the last bar: 0 is the last bar, -1 the one before it, and a positive number is
24
+ * past the data. Fractional when the time falls between bars. Null with no bars.
25
+ */
26
+ export function barsFromLast(bars, timestamp) {
27
+ if (!bars || bars.length === 0 || !Number.isFinite(timestamp))
28
+ return null;
29
+ return indexOfTime(bars, timestamp) - (bars.length - 1);
30
+ }
31
+ /** The same place counted from the first bar, starting at 1 (bar 1 is the first bar). Null with no bars. */
32
+ export function barsFromFirst(bars, timestamp) {
33
+ if (!bars || bars.length === 0 || !Number.isFinite(timestamp))
34
+ return null;
35
+ return indexOfTime(bars, timestamp) + 1;
36
+ }
37
+ /** 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. */
38
+ export function timeOfBarFromLast(bars, count) {
39
+ if (!bars || bars.length === 0 || !Number.isInteger(count))
40
+ return null;
41
+ const at = timeOfIndex(bars, bars.length - 1 + count);
42
+ // Beyond the range a Date can hold, there is no time to show.
43
+ return Number.isFinite(at) && Math.abs(at) <= 8.64e15 ? at : null;
44
+ }
45
+ /** Every tool, in the order a toolbar lists them. */
46
+ export const DRAWING_TOOLS = [
47
+ 'trend-line', 'ray', 'extended-line', 'horizontal-line', 'vertical-line', 'rectangle', 'fib-retracement', 'price-range', 'text',
48
+ ];
49
+ /** Plain-English names, for toolbar buttons and the text list. */
50
+ export const DRAWING_TOOL_LABELS = {
51
+ 'trend-line': 'Trend line',
52
+ 'ray': 'Ray',
53
+ 'extended-line': 'Extended line',
54
+ 'horizontal-line': 'Horizontal line',
55
+ 'vertical-line': 'Vertical line',
56
+ 'rectangle': 'Rectangle',
57
+ 'fib-retracement': 'Fib retracement',
58
+ 'price-range': 'Price range',
59
+ 'text': 'Text note',
60
+ };
61
+ /** The fib levels, as shares of the move: 0 sits on the second anchor and 1 on the first. */
62
+ export const FIB_RATIOS = [0, 0.236, 0.382, 0.5, 0.618, 0.786, 1];
63
+ /** How far, in pixels, the magnet reaches to a bar's open, high, low or close. */
64
+ export const MAGNET_REACH = 12;
65
+ /** A rough width of one text character in viewBox pixels, used only to size the click area of a text note. */
66
+ export const TEXT_CHAR_WIDTH = 7;
67
+ const TEXT_HEIGHT = 14;
68
+ /** How many anchors a tool needs: one for horizontal and vertical lines and text, two for the rest. */
69
+ export function anchorCount(tool) {
70
+ return tool === 'horizontal-line' || tool === 'vertical-line' || tool === 'text' ? 1 : 2;
71
+ }
72
+ const isNumber = (n) => typeof n === 'number' && Number.isFinite(n);
73
+ const validAnchor = (a) => !!a && isNumber(a.timestamp) && isNumber(a.price);
74
+ const empty = () => ({ segments: [], boxes: [], levels: [], handles: [], label: null, range: null });
75
+ /** The line through (x1,y1) and (x2,y2) between parameters t0 and t1, cut to the frame (Liang-Barsky). */
76
+ function clipLine(x1, y1, x2, y2, t0, t1, width, height) {
77
+ const dx = x2 - x1;
78
+ const dy = y2 - y1;
79
+ let lo = t0;
80
+ let hi = t1;
81
+ const edges = [[-dx, x1], [dx, width - x1], [-dy, y1], [dy, height - y1]];
82
+ for (const [p, q] of edges) {
83
+ if (p === 0) {
84
+ if (q < 0)
85
+ return null;
86
+ continue;
87
+ }
88
+ const r = q / p;
89
+ if (p < 0) {
90
+ if (r > hi)
91
+ return null;
92
+ if (r > lo)
93
+ lo = r;
94
+ }
95
+ else {
96
+ if (r < lo)
97
+ return null;
98
+ if (r < hi)
99
+ hi = r;
100
+ }
101
+ }
102
+ if (!Number.isFinite(lo) || !Number.isFinite(hi))
103
+ return null;
104
+ return { x1: x1 + lo * dx, y1: y1 + lo * dy, x2: x1 + hi * dx, y2: y1 + hi * dy };
105
+ }
106
+ /**
107
+ * Where a drawing goes on screen. A ray runs from the first anchor through the second to the frame edge, an extended
108
+ * line runs both ways to the edges, a rectangle and a price range are boxes, and a fib retracement has seven level
109
+ * lines between the anchors plus the line joining them. Handles are the anchors in pixels.
110
+ */
111
+ export function drawingGeometry(drawing, bars, frame) {
112
+ const out = empty();
113
+ if (!drawing || !Array.isArray(drawing.anchors))
114
+ return out;
115
+ const need = anchorCount(drawing.tool);
116
+ if (drawing.anchors.length < need || !drawing.anchors.slice(0, need).every(validAnchor))
117
+ return out;
118
+ const index = drawing.anchors.slice(0, need).map((a) => indexOfTime(bars, a.timestamp));
119
+ const px = drawing.anchors.slice(0, need).map((a, i) => ({ x: frame.x(index[i]), y: frame.y(a.price) }));
120
+ if (!px.every((p) => Number.isFinite(p.x) && Number.isFinite(p.y)))
121
+ return out;
122
+ out.handles = px.map((p, i) => ({ index: i, x: p.x, y: p.y }));
123
+ const [a, b] = px;
124
+ const width = frame.plotWidth;
125
+ const height = frame.plotHeight;
126
+ switch (drawing.tool) {
127
+ case 'horizontal-line':
128
+ out.segments.push({ x1: 0, y1: a.y, x2: width, y2: a.y });
129
+ break;
130
+ case 'vertical-line':
131
+ out.segments.push({ x1: a.x, y1: 0, x2: a.x, y2: height });
132
+ break;
133
+ case 'text':
134
+ out.label = { x: a.x, y: a.y, text: typeof drawing.text === 'string' ? drawing.text : '' };
135
+ break;
136
+ case 'trend-line':
137
+ out.segments.push({ x1: a.x, y1: a.y, x2: b.x, y2: b.y });
138
+ break;
139
+ case 'ray':
140
+ case 'extended-line': {
141
+ if (a.x === b.x && a.y === b.y) {
142
+ out.segments.push({ x1: a.x, y1: a.y, x2: b.x, y2: b.y });
143
+ break;
144
+ }
145
+ const cut = clipLine(a.x, a.y, b.x, b.y, drawing.tool === 'ray' ? 0 : -Infinity, Infinity, width, height);
146
+ if (cut)
147
+ out.segments.push(cut);
148
+ break;
149
+ }
150
+ case 'rectangle':
151
+ case 'price-range': {
152
+ 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) });
153
+ if (drawing.tool === 'price-range') {
154
+ const first = drawing.anchors[0].price;
155
+ const change = drawing.anchors[1].price - first;
156
+ out.range = { change, percent: first === 0 ? null : (change / Math.abs(first)) * 100, bars: Math.round(Math.abs(index[1] - index[0])) };
157
+ }
158
+ break;
159
+ }
160
+ case 'fib-retracement': {
161
+ const x1 = Math.min(a.x, b.x);
162
+ const x2 = Math.max(a.x, b.x);
163
+ const from = drawing.anchors[0].price;
164
+ const to = drawing.anchors[1].price;
165
+ out.segments.push({ x1: a.x, y1: a.y, x2: b.x, y2: b.y });
166
+ for (const ratio of FIB_RATIOS) {
167
+ const price = to + (from - to) * ratio;
168
+ out.levels.push({ ratio, price, y: frame.y(price), x1, x2 });
169
+ }
170
+ break;
171
+ }
172
+ default:
173
+ return empty();
174
+ }
175
+ return out;
176
+ }
177
+ /** The prices a set of drawings needs on screen, as [low, high] pairs for Chart.Root's `include`. */
178
+ export function drawingPriceRanges(drawings) {
179
+ const out = [];
180
+ for (const d of drawings ?? []) {
181
+ const prices = (d?.anchors ?? []).filter(validAnchor).map((a) => a.price);
182
+ if (prices.length > 0)
183
+ out.push([Math.min(...prices), Math.max(...prices)]);
184
+ }
185
+ return out;
186
+ }
187
+ function distanceToSegment(px, py, s) {
188
+ const dx = s.x2 - s.x1;
189
+ const dy = s.y2 - s.y1;
190
+ const lengthSquared = dx * dx + dy * dy;
191
+ const t = lengthSquared === 0 ? 0 : Math.min(1, Math.max(0, ((px - s.x1) * dx + (py - s.y1) * dy) / lengthSquared));
192
+ return Math.hypot(px - (s.x1 + t * dx), py - (s.y1 + t * dy));
193
+ }
194
+ /**
195
+ * The drawing under a point, in viewBox pixels. The last drawing wins, so what was drawn on top is picked first. An
196
+ * anchor handle beats the body of the same drawing. `handleTolerancePx` is the grab radius of a handle (a finger needs
197
+ * more than a mouse); it defaults to the line tolerance. Rectangles and price ranges count inside as well as on the edge.
198
+ */
199
+ export function hitTest(drawings, bars, frame, point, tolerancePx = 6, handleTolerancePx = tolerancePx) {
200
+ if (!point || !isNumber(point.x) || !isNumber(point.y) || !Array.isArray(drawings))
201
+ return null;
202
+ const tol = isNumber(tolerancePx) ? Math.max(0, tolerancePx) : 6;
203
+ const handleTol = isNumber(handleTolerancePx) ? Math.max(0, handleTolerancePx) : tol;
204
+ for (let i = drawings.length - 1; i >= 0; i--) {
205
+ const drawing = drawings[i];
206
+ const g = drawingGeometry(drawing, bars, frame);
207
+ // The nearest handle inside the grab radius wins, so a wide touch radius never picks the first of two close anchors.
208
+ let nearest = null;
209
+ for (const h of g.handles) {
210
+ const distance = Math.hypot(point.x - h.x, point.y - h.y);
211
+ if (distance <= handleTol && (!nearest || distance < nearest.distance))
212
+ nearest = { index: h.index, distance };
213
+ }
214
+ if (nearest)
215
+ return { id: drawing.id, handle: nearest.index };
216
+ const onLine = g.segments.some((s) => distanceToSegment(point.x, point.y, s) <= tol)
217
+ || g.levels.some((l) => distanceToSegment(point.x, point.y, { x1: l.x1, y1: l.y, x2: l.x2, y2: l.y }) <= tol);
218
+ 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);
219
+ const onLabel = g.label !== null
220
+ && point.x >= g.label.x - tol && point.x <= g.label.x + Math.max(1, g.label.text.length) * TEXT_CHAR_WIDTH + tol
221
+ && point.y >= g.label.y - TEXT_HEIGHT - tol && point.y <= g.label.y + tol;
222
+ if (onLine || inBox || onLabel)
223
+ return { id: drawing.id, handle: 'body' };
224
+ }
225
+ return null;
226
+ }
227
+ /**
228
+ * A pointer position as an anchor. 'off' keeps the time and price under the pointer. 'magnet' always puts the time on
229
+ * 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.
230
+ * Null for a point that is not a number.
231
+ */
232
+ export function snapToBar(point, bars, frame, mode = 'off') {
233
+ if (!point || !isNumber(point.x) || !isNumber(point.y))
234
+ return null;
235
+ const index = frame.indexAt(point.x);
236
+ const free = frame.priceAt(point.y);
237
+ if (mode !== 'magnet' || bars.length === 0)
238
+ return { timestamp: timeOfIndex(bars, index), price: free };
239
+ const bar = bars[Math.min(bars.length - 1, Math.max(0, Math.round(index)))];
240
+ let price = free;
241
+ let best = MAGNET_REACH;
242
+ for (const candidate of [bar.open, bar.high, bar.low, bar.close]) {
243
+ if (!isNumber(candidate))
244
+ continue;
245
+ const gap = Math.abs(frame.y(candidate) - point.y);
246
+ if (gap <= best) {
247
+ best = gap;
248
+ price = candidate;
249
+ }
250
+ }
251
+ return { timestamp: bar.timestamp, price };
252
+ }
253
+ /** 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. */
254
+ export function moveDrawing(drawing, deltaIndex, deltaPrice, bars) {
255
+ if (!drawing || drawing.locked || !Array.isArray(drawing.anchors))
256
+ return drawing;
257
+ const di = isNumber(deltaIndex) ? deltaIndex : 0;
258
+ const dp = isNumber(deltaPrice) ? deltaPrice : 0;
259
+ return {
260
+ ...drawing,
261
+ // 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.
262
+ anchors: drawing.anchors.map((a) => {
263
+ if (!validAnchor(a))
264
+ return a;
265
+ const mode = attachOf(a);
266
+ const time = bars.length === 0 || mode === 'time'
267
+ ? a.timestamp
268
+ : mode === 'bar' ? timeOfIndex(bars, Math.round(indexOfTime(bars, a.timestamp) + di)) : timeOfIndex(bars, indexOfTime(bars, a.timestamp) + di);
269
+ return { ...a, timestamp: time, price: mode === 'price' ? a.price : a.price + dp };
270
+ }),
271
+ };
272
+ }
273
+ /**
274
+ * A copy of the drawing with one anchor moved to a new place. The anchor keeps its attach mode and what that holds:
275
+ * 'price' keeps its price, 'time' keeps its time, and 'bar' puts the time on the nearest bar (pass the `bars`). An
276
+ * index that is not an anchor, or a locked drawing, changes nothing.
277
+ */
278
+ export function moveAnchor(drawing, anchorIndex, to, bars = []) {
279
+ if (!drawing || drawing.locked || !Array.isArray(drawing.anchors) || !validAnchor(to))
280
+ return drawing;
281
+ if (!Number.isInteger(anchorIndex) || anchorIndex < 0 || anchorIndex >= drawing.anchors.length)
282
+ return drawing;
283
+ return {
284
+ ...drawing,
285
+ anchors: drawing.anchors.map((a, i) => {
286
+ if (i !== anchorIndex)
287
+ return a;
288
+ const mode = attachOf(a);
289
+ return { ...a, timestamp: mode === 'time' ? a.timestamp : mode === 'bar' ? barTime(bars, to.timestamp) : to.timestamp, price: mode === 'price' ? a.price : to.price };
290
+ }),
291
+ };
292
+ }
293
+ /**
294
+ * A copy of the drawing with typed values: each given anchor's time and/or price replaced, and the words of a text
295
+ * drawing. A locked drawing, an unreadable one, an anchor index that does not exist or a value that is not a finite
296
+ * number changes nothing, so the whole edit is refused rather than half applied.
297
+ */
298
+ export function editDrawing(drawing, edit) {
299
+ if (!drawing || drawing.locked || !Array.isArray(drawing.anchors))
300
+ return drawing;
301
+ const given = edit.anchors ?? [];
302
+ if (given.length > drawing.anchors.length)
303
+ return drawing;
304
+ // An attach value that is not one of the four modes refuses the whole edit. 'free' is stored as no key at all.
305
+ if (given.some((g) => g?.attach !== undefined && !ATTACH_MODES.includes(g.attach)))
306
+ return drawing;
307
+ const anchors = drawing.anchors.map((a, i) => {
308
+ const { attach: held, ...rest } = a;
309
+ const attach = given[i]?.attach ?? held;
310
+ return { ...rest, timestamp: given[i]?.timestamp ?? a.timestamp, price: given[i]?.price ?? a.price, ...(attach && attach !== 'free' ? { attach } : {}) };
311
+ });
312
+ if (!anchors.every(validAnchor))
313
+ return drawing;
314
+ return { ...drawing, anchors, ...(drawing.tool === 'text' && typeof edit.text === 'string' ? { text: edit.text } : {}) };
315
+ }
316
+ /** An id not used by any drawing yet: drawing-1, drawing-2 and so on. */
317
+ export function nextDrawingId(drawings) {
318
+ let highest = 0;
319
+ for (const d of drawings ?? []) {
320
+ const match = /^drawing-(\d+)$/.exec(d?.id ?? '');
321
+ if (match)
322
+ highest = Math.max(highest, Number(match[1]));
323
+ }
324
+ return `drawing-${highest + 1}`;
325
+ }
326
+ /**
327
+ * Drawing keys: Delete and Backspace remove the selection, Escape cancels the drawing in progress (else the tool, else
328
+ * the selection), and the arrows nudge the selection by one bar or 1% of the visible price range, ten times that with
329
+ * Shift. Tab is never taken. Whether a key applies (is anything selected?) is for the caller to decide.
330
+ */
331
+ export function resolveDrawingKey(event) {
332
+ if (!event || event.altKey || event.ctrlKey || event.metaKey)
333
+ return null;
334
+ const scale = event.shiftKey ? 10 : 1;
335
+ switch (event.key) {
336
+ case 'Delete':
337
+ case 'Backspace': return { kind: 'remove' };
338
+ case 'Escape': return { kind: 'escape' };
339
+ case 'ArrowLeft': return { kind: 'nudge', dx: -1, dy: 0, scale };
340
+ case 'ArrowRight': return { kind: 'nudge', dx: 1, dy: 0, scale };
341
+ case 'ArrowUp': return { kind: 'nudge', dx: 0, dy: 1, scale };
342
+ case 'ArrowDown': return { kind: 'nudge', dx: 0, dy: -1, scale };
343
+ default: return null;
344
+ }
345
+ }
346
+ /** The bars and price a nudge moves, given the visible price range. */
347
+ export function nudgeAmount(action, frame) {
348
+ const range = frame.priceMax - frame.priceMin;
349
+ return { bars: action.dx * action.scale, price: Number.isFinite(range) ? action.dy * action.scale * range * 0.01 : 0 };
350
+ }
351
+ const pad = (n) => String(n).padStart(2, '0');
352
+ const clock = (t) => {
353
+ const d = new Date(t);
354
+ return Number.isNaN(d.getTime()) ? '?' : `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
355
+ };
356
+ const day = (t) => {
357
+ const d = new Date(t);
358
+ return Number.isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 10);
359
+ };
360
+ /**
361
+ * 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
362
+ * UTC, with the date added when the anchors fall on different days. `formatTime` replaces the default.
363
+ */
364
+ function describeBase(drawing, decimals, formatTime) {
365
+ const name = DRAWING_TOOL_LABELS[drawing?.tool] ?? 'Drawing';
366
+ const anchors = Array.isArray(drawing?.anchors) ? drawing.anchors.filter(validAnchor) : [];
367
+ const places = Number.isInteger(decimals) && decimals >= 0 && decimals <= 20 ? decimals : 2;
368
+ const days = new Set(anchors.map((a) => day(a.timestamp)));
369
+ const time = (t) => (formatTime ? formatTime(t) : days.size > 1 ? `${day(t).slice(5)} ${clock(t)}` : clock(t));
370
+ const price = (n) => n.toFixed(places);
371
+ const need = anchorCount(drawing?.tool);
372
+ if (anchors.length < need)
373
+ return `${name}, incomplete`;
374
+ const [a, b] = anchors;
375
+ switch (drawing.tool) {
376
+ case 'horizontal-line': return `${name} at ${price(a.price)}`;
377
+ case 'vertical-line': return `${name} at ${time(a.timestamp)}`;
378
+ case 'text': return `${name} "${drawing.text ?? ''}" at ${price(a.price)}, ${time(a.timestamp)}`;
379
+ default: {
380
+ const span = `${name} from ${price(a.price)} at ${time(a.timestamp)} to ${price(b.price)} at ${time(b.timestamp)}`;
381
+ if (drawing.tool !== 'price-range')
382
+ return span;
383
+ const change = b.price - a.price;
384
+ const percent = a.price === 0 ? '' : `, ${change >= 0 ? '+' : ''}${((change / Math.abs(a.price)) * 100).toFixed(2)}%`;
385
+ return `${span}, change ${change >= 0 ? '+' : ''}${price(change)}${percent}`;
386
+ }
387
+ }
388
+ }
389
+ /** One point in words: the price, then the time, for example "100.40, 19:03". Times are UTC unless `formatTime` is given. */
390
+ export function describeAnchor(anchor, decimals = 2, formatTime) {
391
+ const places = Number.isInteger(decimals) && decimals >= 0 && decimals <= 20 ? decimals : 2;
392
+ return `${anchor.price.toFixed(places)}, ${formatTime ? formatTime(anchor.timestamp) : clock(anchor.timestamp)}`;
393
+ }
394
+ /**
395
+ * A drawing in words, for the text list. When an anchor is not free the words end with how it moves, for example
396
+ * "Trend line from 101.20 at 14:05 to 103.80 at 15:20 (first point: price held)".
397
+ */
398
+ export function describeDrawing(drawing, decimals = 2, formatTime) {
399
+ const base = describeBase(drawing, decimals, formatTime);
400
+ const anchors = Array.isArray(drawing?.anchors) ? drawing.anchors.slice(0, anchorCount(drawing?.tool)) : [];
401
+ 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);
402
+ return notes.length === 0 ? base : `${base} (${notes.join('; ')})`;
403
+ }
@@ -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
+ /** What the live region says on entering and leaving full screen. */
6
+ export declare const FULLSCREEN_MESSAGE: {
7
+ readonly entered: "Full screen";
8
+ readonly exited: "Exited full screen";
9
+ };
10
+ /** The Toggle's default accessible names: the action it will do, not the state it is in. */
11
+ export declare const FULLSCREEN_LABEL: {
12
+ readonly enter: "Full screen";
13
+ readonly exit: "Exit full screen";
14
+ };
15
+ /** Who handles an Escape pressed while the frame is expanded. */
16
+ export type FullscreenEscape = 'exit' | 'chart';
17
+ /** What the parts around the focused chart still hold that an Escape should release first. */
18
+ export interface FullscreenEscapeInput {
19
+ /** Focus is on the chart's plot. */
20
+ inPlot: boolean;
21
+ /** The crosshair is showing, or something in the plot is selected (a drawing, the series, a study). */
22
+ plotHasMark: boolean;
23
+ /** A drawing tool is armed, or a drawing is half placed. These take the first Escape from anywhere in the frame. */
24
+ drawingBusy: boolean;
25
+ }
26
+ /**
27
+ * The Escape rule. Anything that handles Escape itself goes first: an open menu or the Edit form (they stop the key
28
+ * before it gets here), a drawing tool that is armed or a drawing half placed, and, with focus on the chart, the
29
+ * crosshair or a selection of any kind. With none of those, Escape leaves full screen.
30
+ */
31
+ export declare function resolveFullscreenEscape(input: FullscreenEscapeInput): FullscreenEscape;
32
+ //# sourceMappingURL=fullscreen.d.ts.map
@@ -0,0 +1,16 @@
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
+ /** What the live region says on entering and leaving full screen. */
6
+ export const FULLSCREEN_MESSAGE = { entered: 'Full screen', exited: 'Exited full screen' };
7
+ /** The Toggle's default accessible names: the action it will do, not the state it is in. */
8
+ export const FULLSCREEN_LABEL = { enter: 'Full screen', exit: 'Exit full screen' };
9
+ /**
10
+ * The Escape rule. Anything that handles Escape itself goes first: an open menu or the Edit form (they stop the key
11
+ * before it gets here), a drawing tool that is armed or a drawing half placed, and, with focus on the chart, the
12
+ * crosshair or a selection of any kind. With none of those, Escape leaves full screen.
13
+ */
14
+ export function resolveFullscreenEscape(input) {
15
+ return input.drawingBusy || (input.inPlot && input.plotHasMark) ? 'chart' : 'exit';
16
+ }