@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,179 @@
1
+ import { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, type ButtonHTMLAttributes, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, type RefObject } from 'react';
2
+ import { buttonFace, type NameProps } from './accessible-name.js';
3
+
4
+ /**
5
+ * A toolbar as parts: Root, Button and Separator. Headless: parts add `data-part` and `data-mt`; the host gives them looks.
6
+ *
7
+ * The toolbar is ONE tab stop (roving tabindex). Inside it the arrow keys along its orientation move between the items,
8
+ * Home and End jump to the ends, and Escape sends focus to `returnFocusTo` (the chart, for a drawing toolbar). An item
9
+ * is a Toolbar.Button (a plain button, or a toggle with `pressed`) or a Menu.Trigger.
10
+ */
11
+
12
+ type Orientation = 'horizontal' | 'vertical';
13
+
14
+ interface ToolbarContext {
15
+ orientation: Orientation;
16
+ stop: string | null;
17
+ setStop: (id: string) => void;
18
+ register: (id: string, element: HTMLElement) => void;
19
+ unregister: (id: string) => void;
20
+ /** Ask the root to re-check which item is the tab stop (an item became enabled or disabled). */
21
+ refresh: () => void;
22
+ }
23
+ const Context = createContext<ToolbarContext | null>(null);
24
+
25
+ /** The toolbar a part sits in, or null outside one. Menu.Trigger uses it to join the roving tab stop. */
26
+ export function useToolbarContext(): ToolbarContext | null {
27
+ return useContext(Context);
28
+ }
29
+
30
+ /** Makes `element` an item of the toolbar above it, if any; returns the tabIndex and focus handler to put on it. */
31
+ export function useToolbarItem(element: RefObject<HTMLElement | null>, disabled?: boolean): { tabIndex: 0 | -1 | undefined; onFocus: (() => void) | undefined } {
32
+ const toolbar = useContext(Context);
33
+ const id = useId();
34
+ const register = toolbar?.register;
35
+ const unregister = toolbar?.unregister;
36
+ useEffect(() => {
37
+ if (!register || !unregister || !element.current) return;
38
+ register(id, element.current);
39
+ return () => unregister(id);
40
+ }, [register, unregister, id, element]);
41
+ // A disabled item cannot be the tab stop: tell the root when that changes, since the root may not re-render on its own.
42
+ const refresh = toolbar?.refresh;
43
+ useEffect(() => { refresh?.(); }, [refresh, disabled]);
44
+ const setStop = toolbar?.setStop;
45
+ const onFocus = useCallback(() => setStop?.(id), [setStop, id]);
46
+ if (!toolbar) return { tabIndex: undefined, onFocus: undefined };
47
+ return { tabIndex: toolbar.stop === id ? 0 : -1, onFocus };
48
+ }
49
+
50
+ type DataAttributes = { [attribute: `data-${string}`]: string | number | boolean | undefined };
51
+
52
+ export interface ToolbarRootProps extends DataAttributes {
53
+ className?: string;
54
+ /** Names the toolbar. Default "Toolbar"; give it the real name ("Drawing tools"). */
55
+ 'aria-label'?: string;
56
+ /** Which arrow keys move between items. Default 'horizontal' (Left and Right); 'vertical' uses Up and Down. */
57
+ orientation?: Orientation;
58
+ /**
59
+ * Where Escape sends focus: an element ref, or a function that returns the element (it is read when Escape is pressed,
60
+ * so the chart need not exist yet). Without it Escape does nothing here.
61
+ */
62
+ returnFocusTo?: RefObject<HTMLElement | SVGElement | null> | (() => HTMLElement | SVGElement | null);
63
+ children?: ReactNode;
64
+ }
65
+
66
+ /** role="toolbar" with one tab stop. Put Toolbar.Button and Menu.Trigger inside it, in any wrapper. */
67
+ function Root({ className, 'aria-label': label = 'Toolbar', orientation = 'horizontal', returnFocusTo, children, ...rest }: ToolbarRootProps) {
68
+ const items = useRef(new Map<string, HTMLElement>());
69
+ const [stop, setStop] = useState<string | null>(null);
70
+ const [, setVersion] = useState(0);
71
+ const register = useCallback((id: string, element: HTMLElement) => { items.current.set(id, element); setVersion((v) => v + 1); }, []);
72
+ const unregister = useCallback((id: string) => { items.current.delete(id); setVersion((v) => v + 1); }, []);
73
+
74
+ /** The enabled items in document order. */
75
+ const ordered = () => [...items.current.entries()]
76
+ .filter(([, el]) => el.isConnected && !(el as HTMLButtonElement).disabled)
77
+ .sort(([, a], [, b]) => (a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1));
78
+
79
+ // One item must always be the tab stop: when none is (first render, or the one that was removed), a pressed item is the
80
+ // stop, else the first. This runs after every render because an item can change `pressed` or `disabled` without telling us.
81
+ useEffect(() => {
82
+ const list = ordered();
83
+ if (list.length === 0 || (stop !== null && list.some(([id]) => id === stop))) return;
84
+ const pressed = list.find(([, el]) => el.getAttribute('aria-pressed') === 'true');
85
+ setStop((pressed ?? list[0])[0]);
86
+ });
87
+
88
+ const onKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
89
+ if (event.altKey || event.ctrlKey || event.metaKey) return;
90
+ const list = ordered();
91
+ const at = list.findIndex(([, el]) => el === event.target);
92
+ if (at < 0) return; // a key inside an open menu belongs to the menu
93
+ if (event.key === 'Escape') {
94
+ const target = typeof returnFocusTo === 'function' ? returnFocusTo() : returnFocusTo?.current;
95
+ if (!target) return;
96
+ event.preventDefault();
97
+ event.stopPropagation();
98
+ target.focus();
99
+ return;
100
+ }
101
+ // A horizontal toolbar mirrors in right-to-left text, as Menu does: the arrow that points ahead visually moves ahead.
102
+ const rtl = orientation !== 'vertical' && getComputedStyle(event.currentTarget).direction === 'rtl';
103
+ const forward = orientation === 'vertical' ? 'ArrowDown' : rtl ? 'ArrowLeft' : 'ArrowRight';
104
+ const back = orientation === 'vertical' ? 'ArrowUp' : rtl ? 'ArrowRight' : 'ArrowLeft';
105
+ let next: number;
106
+ switch (event.key) {
107
+ case forward: next = (at + 1) % list.length; break;
108
+ case back: next = (at - 1 + list.length) % list.length; break;
109
+ case 'Home': next = 0; break;
110
+ case 'End': next = list.length - 1; break;
111
+ default: return;
112
+ }
113
+ event.preventDefault();
114
+ event.stopPropagation(); // arrows, Home and End are also transport shortcuts on a replay page
115
+ setStop(list[next][0]);
116
+ list[next][1].focus();
117
+ };
118
+
119
+ const refresh = useCallback(() => setVersion((v) => v + 1), []);
120
+ const context = useMemo<ToolbarContext>(() => ({ orientation, stop, setStop, register, unregister, refresh }), [orientation, stop, register, unregister, refresh]);
121
+ return (
122
+ <Context.Provider value={context}>
123
+ <div data-mt="" data-part="toolbar" {...rest} className={className} role="toolbar" aria-label={label} aria-orientation={orientation} onKeyDown={onKeyDown}>
124
+ {children}
125
+ </div>
126
+ </Context.Provider>
127
+ );
128
+ }
129
+
130
+ export interface ToolbarButtonProps extends NameProps, DataAttributes, Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'type'> {
131
+ /** Makes the button a toggle: `aria-pressed` is this value. Leave it out for a plain button. */
132
+ pressed?: boolean;
133
+ }
134
+
135
+ /**
136
+ * A button in the toolbar. Visible words are `children`, else `label`. With only an icon, set `hideLabel` and `label`:
137
+ * the label becomes the accessible name. A button with no words at all gets no name, and warns in the console.
138
+ */
139
+ function Button({ pressed, icon, label, hideLabel, children, className, onClick, ...rest }: ToolbarButtonProps) {
140
+ const ref = useRef<HTMLButtonElement | null>(null);
141
+ const item = useToolbarItem(ref, rest.disabled);
142
+ const face = buttonFace(undefined, { icon, label, hideLabel, children, ariaLabel: rest['aria-label'] });
143
+ useNameCheck(ref, 'Toolbar.Button');
144
+ return (
145
+ <button
146
+ data-mt=""
147
+ data-part="toolbar-button"
148
+ data-touch-target=""
149
+ {...rest}
150
+ ref={ref}
151
+ type="button"
152
+ className={className}
153
+ aria-label={face['aria-label']}
154
+ aria-pressed={pressed}
155
+ tabIndex={item.tabIndex}
156
+ onFocus={(event) => { item.onFocus?.(); rest.onFocus?.(event); }}
157
+ onClick={onClick}
158
+ >
159
+ {face.content}
160
+ </button>
161
+ );
162
+ }
163
+
164
+ /** A thin divider between groups of items. */
165
+ function Separator({ className }: { className?: string }) {
166
+ return <div className={className} data-mt="" data-part="toolbar-separator" role="separator" />;
167
+ }
168
+
169
+ /** Warns once, in the console, when a control has no accessible name (no text and no aria-label). */
170
+ export function useNameCheck(ref: RefObject<HTMLElement | null>, part: string): void {
171
+ useEffect(() => {
172
+ const el = ref.current;
173
+ if (!el) return;
174
+ const named = (el.textContent ?? '').trim() !== '' || (el.getAttribute('aria-label') ?? '').trim() !== '' || el.hasAttribute('aria-labelledby');
175
+ if (!named) console.warn(`${part} has no accessible name: give it children, or a label with hideLabel when it shows only an icon.`);
176
+ }, [ref, part]);
177
+ }
178
+
179
+ export { Root, Button, Separator };
@@ -0,0 +1,6 @@
1
+ import { Root, Group, Play, Previous, Next, Start, End, Speed, Readout, Boundary } from './TransportParts.js';
2
+
3
+ // The parts live in TransportParts.js (a client module). The namespace object is built here, outside it, so a server
4
+ // component can write `Transport.Root`: a property of an object exported from a client module is not reachable there.
5
+ export * from './TransportParts.js';
6
+ export const Transport = { Root, Group, Play, Previous, Next, Start, End, Speed, Readout, Boundary };
@@ -0,0 +1,128 @@
1
+ import { createContext, useContext, type ButtonHTMLAttributes, type ReactElement, type ReactNode } from 'react';
2
+ import { Notice } from './Notice.js';
3
+
4
+ /**
5
+ * Transport as parts: Root, Group, Play, Previous, Next, Start, End, Speed, Readout, Boundary.
6
+ * Headless: parts render plain buttons and a readout and add `data-state`,
7
+ * `data-disabled` and `data-boundary`. The host owns the handlers and the copy. Keyboard shortcuts stay with the
8
+ * host; a suggested map: Space, ArrowLeft/Right, J/K, Home and End.
9
+ */
10
+ interface TransportContext {
11
+ playing: boolean;
12
+ loading: boolean;
13
+ }
14
+ const Context = createContext<TransportContext>({ playing: false, loading: false });
15
+
16
+ export interface TransportRootProps {
17
+ playing: boolean;
18
+ /** Another operation is running: Previous, Next, Start and End are disabled; Play is aria-disabled and ignores activation. */
19
+ loading?: boolean;
20
+ /** Set when the cursor sits at the start or the end of the session. */
21
+ boundary?: 'start' | 'end' | null;
22
+ className?: string;
23
+ 'aria-label'?: string;
24
+ children?: ReactNode;
25
+ }
26
+
27
+ function Root({ playing, loading = false, boundary = null, className, 'aria-label': label = 'Replay transport', children }: TransportRootProps) {
28
+ return (
29
+ <Context.Provider value={{ playing, loading }}>
30
+ <nav className={className} aria-label={label} data-state={playing ? 'playing' : 'paused'} data-boundary={boundary ?? undefined}>
31
+ {children}
32
+ </nav>
33
+ </Context.Provider>
34
+ );
35
+ }
36
+
37
+ function Group({ className, label, children }: { className?: string; label?: string; children?: ReactNode }) {
38
+ return label ? (
39
+ <div className={className} role="group" aria-label={label}>{children}</div>
40
+ ) : (
41
+ <div className={className}>{children}</div>
42
+ );
43
+ }
44
+
45
+ export type PartButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'type' | 'disabled'>;
46
+
47
+ /** The play toggle is never `disabled`: disabling the focused button would drop focus to <body> mid-gesture. */
48
+ function Play({ onClick, ...rest }: PartButtonProps) {
49
+ const { playing, loading } = useContext(Context);
50
+ return (
51
+ <button
52
+ type="button"
53
+ {...rest}
54
+ onClick={(event) => {
55
+ if (loading) return;
56
+ onClick?.(event);
57
+ }}
58
+ aria-disabled={loading ? 'true' : undefined}
59
+ data-state={playing ? 'playing' : 'paused'}
60
+ data-disabled={loading ? '' : undefined}
61
+ />
62
+ );
63
+ }
64
+
65
+ // The explicit type keeps the emitted .d.ts naming PartButtonProps; inferred, it expands every attribute from the React types
66
+ // the package was built against, and those names (SubmitEventHandler, ToggleEventHandler) do not exist in React 18.
67
+ export type StepButton = { (props: PartButtonProps): ReactElement; displayName?: string };
68
+ function stepButton(name: string): StepButton {
69
+ const Part: StepButton = (props: PartButtonProps) => {
70
+ const { loading } = useContext(Context);
71
+ return <button type="button" {...props} disabled={loading} data-disabled={loading ? '' : undefined} />;
72
+ };
73
+ Part.displayName = `Transport.${name}`;
74
+ return Part;
75
+ }
76
+ const Previous = stepButton('Previous');
77
+ const Next = stepButton('Next');
78
+ const Start = stepButton('Start');
79
+ const End = stepButton('End');
80
+
81
+ export interface TransportSpeedProps {
82
+ speed: number;
83
+ options: readonly number[];
84
+ onSpeedChange: (speed: number) => void;
85
+ /** Text for one option, for example `formatSpeed`. */
86
+ format: (speed: number) => string;
87
+ className?: string;
88
+ labelClassName?: string;
89
+ label?: string;
90
+ 'aria-label'?: string;
91
+ }
92
+
93
+ function Speed({ speed, options = [], onSpeedChange, format = (value: number) => `${value}x`, className, labelClassName, label = 'Speed', 'aria-label': ariaLabel = 'Playback speed, multiple of real time' }: TransportSpeedProps) {
94
+ return (
95
+ <label className={className}>
96
+ <span className={labelClassName}>{label}</span>
97
+ <select value={String(speed)} onChange={(event) => onSpeedChange(Number(event.target.value))} aria-label={ariaLabel}>
98
+ {(options ?? []).map((option) => (
99
+ <option key={option} value={String(option)}>
100
+ {format(option)}
101
+ </option>
102
+ ))}
103
+ </select>
104
+ </label>
105
+ );
106
+ }
107
+
108
+ /** Not announced while playing: the readout changes every frame. */
109
+ function Readout({ className, children }: { className?: string; children?: ReactNode }) {
110
+ const { playing } = useContext(Context);
111
+ return (
112
+ <output className={className} aria-live={playing ? 'off' : 'polite'}>
113
+ {children}
114
+ </output>
115
+ );
116
+ }
117
+
118
+ /** A normal boundary condition, not a failure: polite status text, nothing when no boundary is reached. */
119
+ function Boundary({ label, className }: { label: string | null; className?: string }) {
120
+ if (!label) return null;
121
+ return (
122
+ <Notice as="span" kind="info" className={className}>
123
+ {label}
124
+ </Notice>
125
+ );
126
+ }
127
+
128
+ export { Root, Group, Play, Previous, Next, Start, End, Speed, Readout, Boundary };
@@ -0,0 +1,6 @@
1
+ import { Histogram, Table } from './VolumeProfileParts.js';
2
+
3
+ // The parts live in VolumeProfileParts.js (a client module). The namespace object is built here, outside it, so a server
4
+ // component can write `VolumeProfile.Root`: a property of an object exported from a client module is not reachable there.
5
+ export * from './VolumeProfileParts.js';
6
+ export const VolumeProfile = { Histogram, Table };
@@ -0,0 +1,106 @@
1
+ import { useMemo, type ReactNode } from 'react';
2
+ import { useChart } from './ChartParts.js';
3
+ import { indexRangeOfTime, volumeProfile, type VolumeProfileResult } from './core/volume-profile.js';
4
+
5
+ /**
6
+ * VolumeProfile as parts: Histogram, Table. The Histogram goes inside `Chart.Plot` and draws horizontal bars on the
7
+ * right of the price pane, one per price row, lined up with the chart's price scale; lines mark the point of control
8
+ * (the busiest row) and the value area's top and bottom. The Table says the same rows in text.
9
+ *
10
+ * Both read the bars in view, or a fixed `{ from, to }` range of timestamps (inclusive). Give both the same options.
11
+ */
12
+ export interface VolumeProfileOptionsProps {
13
+ /** Number of price rows. Default 24. */
14
+ rows?: number;
15
+ /** Share of the volume the value area holds. Default 0.7. */
16
+ valueArea?: number;
17
+ /** A fixed range of Unix-millisecond timestamps instead of the bars in view. */
18
+ range?: { from: number; to: number };
19
+ }
20
+
21
+ function useProfile({ rows, valueArea, range }: VolumeProfileOptionsProps): VolumeProfileResult {
22
+ const { bars, frame } = useChart('VolumeProfile');
23
+ // The length is a dependency too: a host that pushes onto the same array must still get the new bars counted.
24
+ const fixed = useMemo(() => (range ? indexRangeOfTime(bars, range.from, range.to) : null), [bars, bars.length, range?.from, range?.to]);
25
+ const first = fixed ? fixed.first : frame.first;
26
+ const last = fixed ? fixed.last : frame.last;
27
+ return useMemo(() => volumeProfile(bars, { first, last, rows, valueArea }), [bars, bars.length, first, last, rows, valueArea]);
28
+ }
29
+
30
+ export interface VolumeProfileHistogramProps extends VolumeProfileOptionsProps {
31
+ /** Width of the longest bar in chart units. Default a quarter of the frame. */
32
+ width?: number;
33
+ /** Draw the POC, VAH and VAL lines across the plot. Default true. */
34
+ lines?: boolean;
35
+ className?: string;
36
+ }
37
+
38
+ /**
39
+ * Horizontal bars growing left from the right edge. Each row is a `<g data-part="profile-row">` with an up and a down
40
+ * rect (`data-change`); `data-value-area` marks rows inside the value area and `data-poc` the busiest row.
41
+ */
42
+ function Histogram({ width, lines = true, className, ...options }: VolumeProfileHistogramProps) {
43
+ const { frame, readoutId } = useChart('VolumeProfile.Histogram');
44
+ const result = useProfile(options);
45
+ if (result.rows.length === 0 || result.poc < 0) return null;
46
+ const longest = Math.max(1, Math.min(frame.plotWidth, Number.isFinite(width) ? Math.max(0, width as number) : frame.plotWidth * 0.25));
47
+ const peak = result.rows[result.poc].volume;
48
+ const [vaLow, vaHigh] = result.valueAreaRows;
49
+ const right = frame.plotWidth;
50
+ const nodes: ReactNode[] = [];
51
+ result.rows.forEach((row, i) => {
52
+ const top = frame.y(row.high);
53
+ const height = Math.max(1, frame.y(row.low) - top);
54
+ const upWidth = (row.upVolume / peak) * longest;
55
+ const downWidth = (row.downVolume / peak) * longest;
56
+ nodes.push(
57
+ <g key={i} data-part="profile-row" data-value-area={i >= vaLow && i <= vaHigh ? '' : undefined} data-poc={i === result.poc ? '' : undefined}>
58
+ <rect data-change="up" x={right - upWidth} y={top} width={upWidth} height={height} />
59
+ <rect data-change="down" x={right - upWidth - downWidth} y={top} width={downWidth} height={height} />
60
+ </g>,
61
+ );
62
+ });
63
+ const line = (part: 'poc' | 'vah' | 'val', price: number | null) => {
64
+ if (price === null || !Number.isFinite(price)) return null;
65
+ const y = frame.y(price);
66
+ return <line data-part={part} x1={0} x2={frame.plotWidth} y1={y} y2={y} />;
67
+ };
68
+ const poc = result.rows[result.poc];
69
+ return (
70
+ <g className={className} data-part="volume-profile" clipPath={`url(#${readoutId}-price)`} aria-hidden="true">
71
+ {nodes}
72
+ {lines && line('poc', (poc.low + poc.high) / 2)}
73
+ {lines && line('vah', result.vah)}
74
+ {lines && line('val', result.val)}
75
+ </g>
76
+ );
77
+ }
78
+
79
+ const volumeText = (v: number) => String(Math.round(v * 100) / 100);
80
+
81
+ /** The rows as a table, highest price first: price range, volume and share of the total. */
82
+ function Table({ className, caption = 'Volume by price', ...options }: VolumeProfileOptionsProps & { className?: string; caption?: string }) {
83
+ const { decimals } = useChart('VolumeProfile.Table');
84
+ const result = useProfile(options);
85
+ const [vaLow, vaHigh] = result.valueAreaRows;
86
+ const body: ReactNode[] = [];
87
+ for (let i = result.rows.length - 1; i >= 0; i--) {
88
+ const row = result.rows[i];
89
+ body.push(
90
+ <tr key={i} data-value-area={i >= vaLow && i <= vaHigh ? '' : undefined} data-poc={i === result.poc ? '' : undefined}>
91
+ <td data-mt-numeric>{row.low.toFixed(decimals)} – {row.high.toFixed(decimals)}</td>
92
+ <td data-mt-numeric>{volumeText(row.volume)}</td>
93
+ <td data-mt-numeric>{result.total > 0 ? `${((row.volume / result.total) * 100).toFixed(1)}%` : '–'}</td>
94
+ </tr>,
95
+ );
96
+ }
97
+ return (
98
+ <table className={className}>
99
+ <caption>{caption}</caption>
100
+ <thead><tr>{['Price', 'Volume', 'Share'].map((h) => <th key={h} scope="col">{h}</th>)}</tr></thead>
101
+ <tbody>{body}</tbody>
102
+ </table>
103
+ );
104
+ }
105
+
106
+ export { Histogram, Table };
@@ -0,0 +1,34 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ /**
4
+ * The accessible-name rule. A control that is an icon and nothing else still names its action in words: the
5
+ * words are the `aria-label`, and the icon is a decorative `data-part='icon'` span that assistive technology skips. No
6
+ * icon ships in this package; `icon` is a slot the host fills.
7
+ */
8
+ export interface NameProps {
9
+ /** A graphic for the button, drawn before any words. It is hidden from assistive technology, so give the button words too. */
10
+ icon?: ReactNode;
11
+ /** The action in words. It is the visible text unless `hideLabel` is set, and always the accessible name. */
12
+ label?: string;
13
+ /** Show only the icon. The `label` then becomes the `aria-label`. Needs `icon`. */
14
+ hideLabel?: boolean;
15
+ }
16
+
17
+ /**
18
+ * What a button shows and what it is called. `fallback` is the part's own word ("Lock", "Reset view"). Visible text is
19
+ * `children`, else `label`, else `fallback`; with `hideLabel` there is none and the name moves to `aria-label`.
20
+ */
21
+ export function buttonFace(fallback: string | undefined, { icon, label, hideLabel, children, ariaLabel }: NameProps & { children?: ReactNode; ariaLabel?: string }): { 'aria-label': string | undefined; content: ReactNode } {
22
+ const hasIcon = icon !== undefined && icon !== null && icon !== false;
23
+ const visible = hideLabel && hasIcon ? null : (children ?? label ?? fallback ?? null);
24
+ const name = ariaLabel ?? label ?? (typeof children === 'string' ? children : fallback);
25
+ return {
26
+ 'aria-label': visible === null || visible === false || visible === '' ? name : ariaLabel,
27
+ content: (
28
+ <>
29
+ {hasIcon && <span aria-hidden="true" data-part="icon">{icon}</span>}
30
+ {visible}
31
+ </>
32
+ ),
33
+ };
34
+ }
@@ -0,0 +1,12 @@
1
+ import type { CSSProperties } from 'react';
2
+ import { numericWidthValue } from './core/attributes.js';
3
+
4
+ export * from './core/attributes.js';
5
+
6
+ /**
7
+ * A column width that holds the widest value without growing: set the result as the element's style and give
8
+ * the cells `data-mt-numeric`. Counts characters of the longest sample (digits, separator, sign), in `ch`.
9
+ */
10
+ export function numericWidth(...samples: readonly string[]): CSSProperties {
11
+ return { ['--mt-numeric-width' as string]: numericWidthValue(...samples) } as CSSProperties;
12
+ }
@@ -0,0 +1,91 @@
1
+ // An adapter turns something a person has (a file, later a feed) into what the parts draw: bars, any series beside
2
+ // them, and the problems it found, named by line. It is a plain function over its input: it reads, never repairs,
3
+ // never throws, and never writes a zero where the source said nothing. `csvAdapter` is the first; a host can write
4
+ // its own to the same shape and check the result with `seriesSetSchema`.
5
+
6
+ import { parseBarsCsv, type CsvBarsError, type CsvBarsNote, type CsvBarsOptions } from './csv-bars.js';
7
+ import type { CandleBar } from './chart-scale.js';
8
+ import type { DataSeries, SeriesPoint, SeriesSet } from './series.js';
9
+
10
+ /** A problem found while reading. `line` is 1-based in the source; 0 means the source as a whole. */
11
+ export type AdapterIssue = CsvBarsError;
12
+
13
+ export interface AdapterResult {
14
+ /** Who read it, shown beside the data so a person can tell where it came from. */
15
+ adapter: { name: string; version: string };
16
+ bars: CandleBar[];
17
+ /** Series beside the bars, on the bars' timestamps. */
18
+ series: DataSeries[];
19
+ errors: AdapterIssue[];
20
+ /** Things that were assumed or changed on the way in, in words a status line can show. */
21
+ notes: string[];
22
+ }
23
+
24
+ /** The shape every adapter has: a name, a version and a `read` that never throws. */
25
+ export interface DataAdapter<Input, Options = undefined> {
26
+ name: string;
27
+ version: string;
28
+ read(input: Input, options?: Options): AdapterResult;
29
+ }
30
+
31
+ export type CsvAdapterOptions = Pick<CsvBarsOptions, 'maxRows' | 'utcOffsetMinutes'>;
32
+
33
+ const NOTES: Record<CsvBarsNote, string> = {
34
+ sorted: 'The rows were out of time order, so they were sorted.',
35
+ 'no-volume': 'There is no volume column, so volume is 0.',
36
+ 'no-header': 'There was no header row, so the columns were read as time, open, high, low, close, volume.',
37
+ };
38
+
39
+ /** A header turned into an id: lower case, words joined by a dash, and a number added when two would collide. */
40
+ function idFromHeader(header: string, taken: Set<string>): string {
41
+ const base = header.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'column';
42
+ let id = base;
43
+ for (let n = 2; taken.has(id); n++) id = `${base}-${n}`;
44
+ taken.add(id);
45
+ return id;
46
+ }
47
+
48
+ /**
49
+ * Reads a CSV of bars and any all-number columns beside them. Times without a zone are UTC unless `utcOffsetMinutes`
50
+ * says otherwise, and the note says which was used. An extra column's empty cell (or `-`, `NA`, `NaN`, `null`) is an
51
+ * `unknown` point, because the file does not say why it is empty; a `0` is the value 0. A column with any other text in
52
+ * it is not a series and is named in the notes.
53
+ */
54
+ export const csvAdapter: DataAdapter<string, CsvAdapterOptions> = {
55
+ name: 'csv',
56
+ version: '1',
57
+ read(text, options = {}) {
58
+ const given = options.utcOffsetMinutes;
59
+ if (given !== undefined && !(Number.isInteger(given) && given >= -720 && given <= 840)) {
60
+ return { adapter: { name: 'csv', version: '1' }, bars: [], series: [], errors: [{ line: 0, message: `utcOffsetMinutes must be a whole number of minutes from -720 to 840, not ${String(given)}, so nothing was read.` }], notes: [] };
61
+ }
62
+ const parsed = parseBarsCsv(text, { ...options, extraColumns: true });
63
+ const offset = Number.isFinite(options.utcOffsetMinutes) ? (options.utcOffsetMinutes as number) : 0;
64
+ const notes: string[] = parsed.notes.map((n) => NOTES[n]);
65
+ if (parsed.bars.length > 0 && (parsed.zoneless ?? 0) > 0) notes.push(offset === 0 ? 'Times with no zone were read as UTC.' : `Times with no zone were read as UTC${offset > 0 ? '+' : '-'}${Math.floor(Math.abs(offset) / 60)}:${String(Math.abs(offset) % 60).padStart(2, '0')}.`);
66
+ for (const h of parsed.skippedColumns ?? []) notes.push(`Column "${h}" has a cell that is not a number or a no-value word (blank, -, NA, NaN, null), so it was left out and is not a series.`);
67
+ const taken = new Set<string>(['bars']);
68
+ const series: DataSeries[] = (parsed.extras ?? []).map((col) => {
69
+ const points: SeriesPoint[] = parsed.bars.map((bar, i) => {
70
+ const t = new Date(bar.timestamp).toISOString();
71
+ const v = col.values[i];
72
+ return typeof v === 'number' ? { t, kind: 'value', v } : { t, kind: 'unknown' };
73
+ });
74
+ return { id: idFromHeader(col.header, taken), label: col.header, points };
75
+ });
76
+ return { adapter: { name: 'csv', version: '1' }, bars: parsed.bars, series, errors: parsed.errors, notes };
77
+ },
78
+ };
79
+
80
+ /**
81
+ * The result as one set, with the bars' close as the primary series (`bars`), so `report` can say where another
82
+ * series has no point or a different kind of gap. The bars must be in time order (every adapter's are).
83
+ */
84
+ export function toSeriesSet(result: AdapterResult): SeriesSet {
85
+ const bars: DataSeries = {
86
+ id: 'bars',
87
+ label: 'Close',
88
+ points: result.bars.map((b) => ({ t: new Date(b.timestamp).toISOString(), kind: 'value', v: b.close }) as SeriesPoint),
89
+ };
90
+ return { schemaVersion: '0', alignment: { mode: 'exact', primary: 'bars' }, series: [bars, ...result.series] };
91
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The data-attribute vocabulary every primitive speaks. State lives in `data-*`
3
+ * attributes, never in class names, so a host styles with `[data-side="bid"]` and the tokens in
4
+ * `tokens.css`. Values are the whole vocabulary: a part never invents another.
5
+ */
6
+ export type Side = 'bid' | 'ask';
7
+ /** Movement since the previous step. Not the diff status below: `data-change` is movement, `data-diff` is difference. */
8
+ export type Change = 'up' | 'down' | 'flat';
9
+ /** How a level differs between two books; the report's `change` field, as an attribute. */
10
+ export type DiffStatus = 'added' | 'removed' | 'modified';
11
+ export type Quality = 'ok' | 'gap' | 'stale' | 'truncated';
12
+ export type CursorMark = 'a' | 'b';
13
+
14
+ export interface PrimitiveState {
15
+ side?: Side;
16
+ change?: Change;
17
+ diff?: DiffStatus;
18
+ quality?: Quality;
19
+ cursor?: CursorMark;
20
+ /** True when the value comes from a synthetic session. */
21
+ synthetic?: boolean;
22
+ }
23
+
24
+ export type PrimitiveAttributes = {
25
+ /** Scopes tokens.css to primitive markup. */
26
+ 'data-mt': '';
27
+ 'data-side'?: Side;
28
+ 'data-change'?: Change;
29
+ 'data-diff'?: DiffStatus;
30
+ 'data-quality'?: Quality;
31
+ 'data-cursor'?: CursorMark;
32
+ 'data-synthetic'?: '';
33
+ };
34
+
35
+ /** Spreads onto a DOM element. Unset states emit nothing, so an attribute selector never matches by accident. */
36
+ export function primitiveAttributes(state: PrimitiveState): PrimitiveAttributes {
37
+ const out: PrimitiveAttributes = { 'data-mt': '' };
38
+ if (state.side) out['data-side'] = state.side;
39
+ if (state.change) out['data-change'] = state.change;
40
+ if (state.diff) out['data-diff'] = state.diff;
41
+ if (state.quality) out['data-quality'] = state.quality;
42
+ if (state.cursor) out['data-cursor'] = state.cursor;
43
+ if (state.synthetic) out['data-synthetic'] = '';
44
+ return out;
45
+ }
46
+
47
+ /** The width that holds the widest sample without growing, in `ch`: the value for `--mt-numeric-width`. */
48
+ export function numericWidthValue(...samples: readonly string[]): string {
49
+ const longest = samples.reduce((n, s) => Math.max(n, s.length), 0);
50
+ return `${longest}ch`;
51
+ }
@@ -0,0 +1,22 @@
1
+ import type { CandleBar } from './chart-scale.js';
2
+
3
+ /** Heikin Ashi bars from ordinary ones: each bar averages its own prices and the previous Heikin Ashi bar. */
4
+ export function heikinAshi(bars: readonly CandleBar[]): CandleBar[] {
5
+ const out: CandleBar[] = [];
6
+ for (let i = 0; i < bars.length; i++) {
7
+ const b = bars[i];
8
+ const close = (b.open + b.high + b.low + b.close) / 4;
9
+ const open = i === 0 ? (b.open + b.close) / 2 : (out[i - 1].open + out[i - 1].close) / 2;
10
+ out.push({ timestamp: b.timestamp, open, close, high: Math.max(b.high, open, close), low: Math.min(b.low, open, close), volume: b.volume });
11
+ }
12
+ return out;
13
+ }
14
+
15
+ const pad = (n: number) => String(n).padStart(2, '0');
16
+ /** The default time-axis label: HH:MM in UTC, with the month and day when the day changes from the previous label. */
17
+ export const formatBarTime = (t: number, previous: number | null) => {
18
+ const d = new Date(t);
19
+ const time = `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
20
+ const sameDay = previous !== null && new Date(previous).toISOString().slice(0, 10) === d.toISOString().slice(0, 10);
21
+ return sameDay ? time : `${d.toISOString().slice(5, 10)} ${time}`;
22
+ };