@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,529 @@
1
+ import { createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent, type PointerEvent, type ReactNode } from 'react';
2
+ import { buttonFace, type NameProps } from './accessible-name.js';
3
+ import { primitiveAttributes, type Change } from './attributes.js';
4
+ import { chartFrame, clampPriceRange, dataPriceSpan, growViewport, lastBars, type ChartInclude, panPriceRange, panViewport, priceTicks, resolveChartKey, tickDecimals, zoomPriceRange, zoomViewport, type CandleBar, type ChartFrame, type ChartViewport, type PriceSpan } from './core/chart-scale.js';
5
+ import { INITIAL_CHART_GESTURE, isChartGestureActive, reduceChartGesture, type ChartGestureResult, type ChartGestureState, type ChartGestureView } from './core/chart-gesture.js';
6
+ import { heikinAshi, formatBarTime } from './core/candles.js';
7
+ import { AXIS_BOTTOM, MAIN_PANEL, panelBands } from './core/panels.js';
8
+ import { PanelsContext } from './panels-context.js';
9
+ import { SeriesContext } from './selection-context.js';
10
+
11
+ /**
12
+ * Chart as parts: Root, Plot, Grid, Candles, Volume, PriceAxis, TimeAxis, Crosshair, Readout, Table. Headless: parts
13
+ * draw SVG and add `data-change` (up or down bar) and `data-part`; tokens.css and the host give them colour. Studies
14
+ * and drawings are more parts inside Plot that read the same frame through useChart().
15
+ *
16
+ * One tab stop. ArrowLeft/Right move the crosshair bar by bar, Shift+Arrow pans, + and - zoom, Home/End jump to the
17
+ * ends, Escape clears. A pointer drag pans and the wheel zooms around the pointer. Two fingers pinch to zoom the time
18
+ * (spread sideways) and the price (spread up and down). The price follows the bars until a person moves it: ArrowUp and
19
+ * ArrowDown, Shift+ArrowUp and Shift+ArrowDown, a drag on the price labels, or the wheel over them fix the price range,
20
+ * so a line can be placed at any price; Autoscale or Reset gives it back, and so does a double tap on the price labels. A Readout says the crosshair bar in
21
+ * words and a Table carries the visible bars, so the chart is never only a picture.
22
+ */
23
+ export type CandleStyle = 'candle' | 'hollow' | 'ohlc' | 'line' | 'area' | 'heikin-ashi';
24
+
25
+ export interface ChartContext {
26
+ bars: readonly CandleBar[];
27
+ frame: ChartFrame;
28
+ /** The crosshair bar index, or null. */
29
+ active: number | null;
30
+ setActive: (index: number | null) => void;
31
+ /** The crosshair price under the pointer, or null when the keyboard moved it (then it sits on the close). */
32
+ pointerPrice: number | null;
33
+ /** True while the pointer is over a panel below the first, where the first panel's price means nothing. */
34
+ pointerBelow: boolean;
35
+ viewport: ChartViewport;
36
+ setViewport: (viewport: ChartViewport) => void;
37
+ /** The price range a person fixed, or null while the price follows the bars. */
38
+ priceRange: PriceSpan | null;
39
+ /** Fix the price range (kept usable around the bars), or null to follow the bars again. */
40
+ setPriceRange: (range: PriceSpan | null) => void;
41
+ /** The lowest and highest price of all the bars, or null for none; what a fixed range is kept near. */
42
+ dataSpan: PriceSpan | null;
43
+ /** Client coordinates to chart (viewBox) coordinates; null before the Plot mounts. */
44
+ toChart: (clientX: number, clientY: number) => { x: number; y: number } | null;
45
+ readoutId: string;
46
+ /** Decimals for printed prices. */
47
+ decimals: number;
48
+ /** The height of the whole plot, all panels and the time labels. Equal to `frame.height` unless a Panels.Root is above. */
49
+ totalHeight: number;
50
+ /** The bottom of the last panel, from the top of the plot: where a tap stops being on the chart. */
51
+ plotBottom: number;
52
+ }
53
+ const Context = createContext<ChartContext | null>(null);
54
+
55
+ /** The frame and state of the chart above. Studies and drawings call this. */
56
+ export function useChart(part = 'part'): ChartContext {
57
+ const context = useContext(Context);
58
+ if (!context) throw new Error(`Chart ${part} needs a Chart.Root above it.`);
59
+ return context;
60
+ }
61
+
62
+ export interface ChartRootProps {
63
+ bars: readonly CandleBar[];
64
+ /** Controlled viewport; by default the chart opens on the last 120 bars. */
65
+ viewport?: ChartViewport;
66
+ onViewportChange?: (viewport: ChartViewport) => void;
67
+ /** Controlled fixed price range; null follows the bars. By default the chart keeps it itself and starts on the bars. */
68
+ priceRange?: PriceSpan | null;
69
+ onPriceRangeChange?: (range: PriceSpan | null) => void;
70
+ /** Controlled crosshair bar index. */
71
+ active?: number | null;
72
+ onActiveChange?: (index: number | null) => void;
73
+ /**
74
+ * viewBox width. Leave it out and the chart lays itself out to its own width in CSS pixels, so axis text stays the
75
+ * size your CSS gives it on a phone; it starts at 800 until measured. A number fixes the viewBox, and the SVG scales.
76
+ */
77
+ width?: number;
78
+ /** viewBox height; with the width left out, the plot's height in CSS pixels. Default 420. */
79
+ height?: number;
80
+ /** Share of the height for volume, 0 to hide the pane. Default 0.2. */
81
+ volumeShare?: number;
82
+ /** Prices a study or drawing needs kept in view: [low, high] pairs, or a function of the bars in view. Keep it stable (useMemo or useCallback). */
83
+ include?: ChartInclude;
84
+ /** Decimals for printed prices. Default 2. */
85
+ decimals?: number;
86
+ /** Marks every part as synthetic data. */
87
+ synthetic?: boolean;
88
+ className?: string;
89
+ 'aria-label'?: string;
90
+ children?: ReactNode;
91
+ }
92
+
93
+ const finiteBar = (bar: CandleBar | null | undefined): bar is CandleBar =>
94
+ bar != null && Number.isFinite(bar.timestamp) && Math.abs(bar.timestamp) <= 8.64e15
95
+ && Number.isFinite(bar.open) && Number.isFinite(bar.high) && Number.isFinite(bar.low) && Number.isFinite(bar.close);
96
+
97
+ /** The bars that can be drawn: a bar with a missing or non-finite time or price is left out (so indexes after it shift: filter before computing a study), and no list draws nothing. */
98
+ function usableBars(bars: readonly CandleBar[] | null | undefined): readonly CandleBar[] {
99
+ if (!Array.isArray(bars)) return [];
100
+ return bars.every(finiteBar) ? bars : bars.filter(finiteBar);
101
+ }
102
+
103
+ function Root({ bars: rawBars, viewport, onViewportChange, priceRange, onPriceRangeChange, active, onActiveChange, width: fixedWidth, height = 420, volumeShare, include, decimals: rawDecimals = 2, synthetic, className, 'aria-label': label = 'Price chart', children }: ChartRootProps) {
104
+ const bars = useMemo(() => usableBars(rawBars), [rawBars, rawBars?.length]); // length too: a host may append to the same array
105
+ const [innerViewport, setInnerViewport] = useState<ChartViewport>(() => lastBars(bars.length, 120));
106
+ const [seenTotal, setSeenTotal] = useState(bars.length);
107
+ const [innerActive, setInnerActive] = useState<number | null>(null);
108
+ const [innerRange, setInnerRange] = useState<PriceSpan | null>(null);
109
+ if (seenTotal !== bars.length) {
110
+ // A new or grown series: a view that showed it all reopens on the default window, a view at the end keeps following, and never point past the data.
111
+ setSeenTotal(bars.length);
112
+ if (viewport === undefined) {
113
+ const next = growViewport(innerViewport, seenTotal, bars.length);
114
+ if (next !== innerViewport) setInnerViewport(next);
115
+ }
116
+ if (active === undefined && innerActive !== null && innerActive >= bars.length) setInnerActive(bars.length > 0 ? bars.length - 1 : null);
117
+ }
118
+ // toFixed throws outside 0..100; 20 is more than any price needs.
119
+ const decimals = Number.isFinite(rawDecimals) ? Math.min(20, Math.max(0, Math.round(rawDecimals))) : 2;
120
+ const [scaleBars, setScaleBars] = useState<readonly CandleBar[] | null>(null);
121
+ const [pointerPrice, setPointerPrice] = useState<number | null>(null);
122
+ const [pointerBelow, setPointerBelow] = useState(false);
123
+ const svgRef = useRef<SVGSVGElement | null>(null);
124
+ const figureRef = useRef<HTMLElement | null>(null);
125
+ const [measured, setMeasured] = useState<number | null>(null);
126
+ // Follow the figure's width, so a narrow screen gets a narrower layout rather than smaller text. Measured before
127
+ // paint, so the first frame is already the right width.
128
+ useLayoutEffect(() => {
129
+ const node = figureRef.current;
130
+ if (fixedWidth !== undefined || !node) return;
131
+ const read = () => { const w = Math.round(node.clientWidth); if (w > 0) setMeasured(w); };
132
+ read();
133
+ if (typeof ResizeObserver === 'undefined') return;
134
+ const observer = new ResizeObserver(read);
135
+ observer.observe(node);
136
+ return () => observer.disconnect();
137
+ }, [fixedWidth]);
138
+ const width = fixedWidth ?? measured ?? 800;
139
+ const view = viewport ?? innerViewport;
140
+ const activeIndex = active !== undefined ? active : innerActive;
141
+ const range = priceRange !== undefined ? priceRange : innerRange;
142
+ // With a Panels.Root above, the first band is only part of the height and the series placed in it keep themselves in view.
143
+ const panels = useContext(PanelsContext);
144
+ const layoutPanels = panels?.layout.panels;
145
+ const mainHeight = useMemo(() => {
146
+ if (!layoutPanels) return null;
147
+ const band = panelBands(layoutPanels, height - AXIS_BOTTOM).find((b) => b.id === MAIN_PANEL);
148
+ return band ? band.height : height - AXIS_BOTTOM;
149
+ }, [layoutPanels, height]);
150
+ const extraInclude = panels?.mainInclude;
151
+ const mergedInclude = useMemo<ChartInclude | undefined>(() => {
152
+ if (!extraInclude) return include;
153
+ return (visible) => [...(typeof include === 'function' ? include(visible) : include ?? []), ...extraInclude(visible, bars)];
154
+ }, [include, extraInclude, bars]);
155
+ const frame = useMemo(() => chartFrame(scaleBars && scaleBars.length === bars.length ? scaleBars : bars, view, { width, height: mainHeight ?? height }, { volumeShare, include: mergedInclude, priceRange: range, ...(mainHeight !== null ? { axisBottom: 0 } : {}) }), [scaleBars, bars, bars.length, view, width, height, mainHeight, volumeShare, mergedInclude, range]);
156
+ const totalHeight = mainHeight !== null ? height : frame.height;
157
+ const plotBottom = mainHeight !== null ? height - AXIS_BOTTOM : frame.plotHeight;
158
+ const dataSpan = useMemo(() => dataPriceSpan(bars), [bars, bars.length]);
159
+ const readoutId = useId();
160
+ const setViewport = useCallback((next: ChartViewport) => {
161
+ if (viewport === undefined) setInnerViewport(next);
162
+ onViewportChange?.(next);
163
+ }, [viewport, onViewportChange]);
164
+ const setPriceRange = useCallback((next: PriceSpan | null) => {
165
+ const kept = next ? clampPriceRange(next, dataSpan) : null;
166
+ if (priceRange === undefined) setInnerRange(kept);
167
+ onPriceRangeChange?.(kept);
168
+ }, [priceRange, onPriceRangeChange, dataSpan]);
169
+ const setActive = useCallback((next: number | null) => {
170
+ if (active === undefined) setInnerActive(next);
171
+ onActiveChange?.(next);
172
+ }, [active, onActiveChange]);
173
+ const toChart = useCallback((clientX: number, clientY: number) => {
174
+ const svg = svgRef.current;
175
+ const matrix = svg?.getScreenCTM();
176
+ if (!svg || !matrix) return null;
177
+ const point = new DOMPoint(clientX, clientY).matrixTransform(matrix.inverse());
178
+ return { x: point.x, y: point.y };
179
+ }, []);
180
+ const context = useMemo<ChartContext>(() => ({
181
+ bars, frame, active: activeIndex, setActive, pointerPrice, pointerBelow, viewport: view, setViewport, priceRange: range, setPriceRange, dataSpan, toChart, readoutId, decimals, totalHeight, plotBottom,
182
+ }), [bars, frame, activeIndex, setActive, pointerPrice, pointerBelow, view, setViewport, range, setPriceRange, dataSpan, toChart, readoutId, decimals, totalHeight, plotBottom]);
183
+ const plotRef = useMemo(() => ({ svgRef, setPointerPrice, setPointerBelow, setScaleBars }), []);
184
+ return (
185
+ <Context.Provider value={context}>
186
+ <PlotRef.Provider value={plotRef}>
187
+ <figure ref={figureRef} className={className} aria-label={label} {...primitiveAttributes({ synthetic })}>{children}</figure>
188
+ </PlotRef.Provider>
189
+ </Context.Provider>
190
+ );
191
+ }
192
+
193
+ const PlotRef = createContext<{
194
+ svgRef: { current: SVGSVGElement | null };
195
+ setPointerPrice: (price: number | null) => void;
196
+ setPointerBelow: (below: boolean) => void;
197
+ /** Bars the price range is taken from when they differ from the data (Heikin Ashi). */
198
+ setScaleBars: (bars: readonly CandleBar[] | null) => void;
199
+ } | null>(null);
200
+
201
+ /** The drawing surface. Put Grid, Candles, Volume, axes, studies and drawings inside it. */
202
+ function Plot({ className, label = 'Candles, use the arrow keys to move between bars', children }: { className?: string; label?: string; children?: ReactNode }) {
203
+ const { bars, frame, active, setActive, viewport, setViewport, setPriceRange, dataSpan, toChart, readoutId, totalHeight, plotBottom } = useChart('Plot');
204
+ const plot = useContext(PlotRef);
205
+ // The gesture rules live in core/chart-gesture; this holds the state between events and does the DOM work.
206
+ const gesture = useRef<ChartGestureState>(INITIAL_CHART_GESTURE);
207
+ const heldRange = (): PriceSpan => [frame.priceMin, frame.priceMax];
208
+ const total = bars.length;
209
+ const view = (): ChartGestureView => ({ frame, viewport, total, dataSpan, hoverBottom: plotBottom });
210
+ const clampBar = (index: number) => Math.min(total - 1, Math.max(0, Math.round(index)));
211
+
212
+ const onKeyDown = (event: KeyboardEvent<SVGSVGElement>) => {
213
+ const action = resolveChartKey(event);
214
+ if (!action || total === 0) return;
215
+ event.preventDefault();
216
+ event.stopPropagation(); // arrows, Home and End are also transport shortcuts on a replay page
217
+ plot?.setPointerPrice(null);
218
+ plot?.setPointerBelow(false);
219
+ const span = viewport.end - viewport.start;
220
+ const current = active ?? frame.last;
221
+ switch (action) {
222
+ case 'previous-bar': case 'next-bar': {
223
+ const next = clampBar(current + (action === 'next-bar' ? 1 : -1));
224
+ setActive(next);
225
+ if (next < viewport.start || next >= viewport.end) setViewport(panViewport(viewport, next < viewport.start ? next - viewport.start : next - viewport.end + 1, total));
226
+ break;
227
+ }
228
+ case 'pan-left': setViewport(panViewport(viewport, -Math.max(1, span / 10), total)); break;
229
+ case 'pan-right': setViewport(panViewport(viewport, Math.max(1, span / 10), total)); break;
230
+ case 'price-up': case 'price-down': {
231
+ const r = heldRange();
232
+ setPriceRange(panPriceRange(r, ((action === 'price-up' ? 1 : -1) * (r[1] - r[0])) / 10, dataSpan));
233
+ break;
234
+ }
235
+ case 'price-zoom-in': case 'price-zoom-out': {
236
+ const r = heldRange();
237
+ setPriceRange(zoomPriceRange(r, action === 'price-zoom-in' ? 0.8 : 1.25, (r[0] + r[1]) / 2, dataSpan));
238
+ break;
239
+ }
240
+ case 'zoom-in': setViewport(zoomViewport(viewport, 0.8, active ?? viewport.end - 1, total)); break;
241
+ case 'zoom-out': setViewport(zoomViewport(viewport, 1.25, active ?? viewport.end - 1, total)); break;
242
+ case 'first': setActive(0); setViewport(panViewport(viewport, -viewport.start, total)); break;
243
+ case 'last': setActive(total - 1); setViewport(panViewport(viewport, total - viewport.end, total)); break;
244
+ case 'clear': setActive(null); break;
245
+ }
246
+ };
247
+ const apply = (result: ChartGestureResult, event?: PointerEvent<SVGSVGElement>) => {
248
+ gesture.current = result.state;
249
+ if (result.capture) event?.currentTarget.setPointerCapture?.(event.pointerId);
250
+ if (result.viewport) setViewport(result.viewport);
251
+ if (result.priceRange !== undefined) setPriceRange(result.priceRange);
252
+ if (result.hover) {
253
+ if (result.hover.bar !== undefined) setActive(result.hover.bar);
254
+ plot?.setPointerPrice(result.hover.price);
255
+ }
256
+ };
257
+ const onPointerDown = (event: PointerEvent<SVGSVGElement>) => {
258
+ if (event.button !== 0) return;
259
+ const point = toChart(event.clientX, event.clientY);
260
+ if (!point) return;
261
+ apply(reduceChartGesture({ type: 'down', pointerId: event.pointerId, touch: event.pointerType === 'touch', point, view: view() }, gesture.current), event);
262
+ };
263
+ const onPointerMove = (event: PointerEvent<SVGSVGElement>) => {
264
+ const point = toChart(event.clientX, event.clientY);
265
+ if (!point) return;
266
+ plot?.setPointerBelow(point.y >= frame.plotHeight && point.y < plotBottom);
267
+ apply(reduceChartGesture({ type: 'move', pointerId: event.pointerId, point, view: view() }, gesture.current));
268
+ };
269
+ const endDrag = (event: PointerEvent<SVGSVGElement>) => {
270
+ apply(reduceChartGesture(event.type === 'pointerup' ? { type: 'up', pointerId: event.pointerId, time: event.timeStamp } : { type: 'cancel', pointerId: event.pointerId }, gesture.current));
271
+ };
272
+ // The wheel zooms, so the page must not scroll under the pointer: React's onWheel is passive, so this listener is
273
+ // attached natively with passive false, reading the latest values through a ref.
274
+ const onWheel = useRef<(event: globalThis.WheelEvent) => void>(() => {});
275
+ onWheel.current = (event) => {
276
+ const point = toChart(event.clientX, event.clientY);
277
+ if (!point) return;
278
+ const result = reduceChartGesture({ type: 'wheel', point, deltaY: event.deltaY, view: view() }, gesture.current);
279
+ if (result.preventDefault) event.preventDefault();
280
+ apply(result);
281
+ };
282
+ const svg = useRef<SVGSVGElement | null>(null);
283
+ useEffect(() => {
284
+ const node = svg.current;
285
+ if (!node) return;
286
+ const listener = (event: globalThis.WheelEvent) => onWheel.current(event);
287
+ node.addEventListener('wheel', listener, { passive: false });
288
+ return () => node.removeEventListener('wheel', listener);
289
+ }, []);
290
+ return (
291
+ <svg
292
+ ref={(node) => { svg.current = node; if (plot) plot.svgRef.current = node; }}
293
+ className={className}
294
+ data-part="plot"
295
+ viewBox={`0 0 ${frame.width} ${totalHeight}`}
296
+ role="group"
297
+ aria-label={label}
298
+ aria-describedby={readoutId}
299
+ tabIndex={0}
300
+ style={{ touchAction: 'pan-y' }}
301
+ onKeyDown={onKeyDown}
302
+ onPointerDown={onPointerDown}
303
+ onPointerMove={onPointerMove}
304
+ onPointerUp={endDrag}
305
+ onPointerCancel={endDrag}
306
+ onPointerLeave={(event) => { if (!isChartGestureActive(gesture.current)) { plot?.setPointerPrice(null); plot?.setPointerBelow(false); if (event.pointerType === 'mouse') setActive(null); } }}
307
+ >
308
+ <defs>
309
+ <clipPath id={`${readoutId}-clip`}><rect x={0} y={0} width={frame.width} height={totalHeight} /></clipPath>
310
+ <clipPath id={`${readoutId}-plot`}><rect x={0} y={0} width={frame.plotWidth} height={totalHeight} /></clipPath>
311
+ {/* Price-scaled marks (studies, the volume profile) stop at the volume pane, even where a fixed range or a band
312
+ goes past the prices in view. */}
313
+ <clipPath id={`${readoutId}-price`}><rect x={0} y={0} width={frame.plotWidth} height={frame.volumeTop} /></clipPath>
314
+ {/* The plot area without the gutters, for marks a host places anywhere (drawings). */}
315
+ <clipPath id={`${readoutId}-plotarea`}><rect x={0} y={0} width={frame.plotWidth} height={frame.plotHeight} /></clipPath>
316
+ </defs>
317
+ <g clipPath={`url(#${readoutId}-clip)`}>
318
+ {/* The price labels take the finger for themselves, so a drag there moves the price instead of the page. */}
319
+ <rect data-part="price-gutter" x={frame.plotWidth} y={0} width={Math.max(0, frame.width - frame.plotWidth)} height={frame.plotHeight} fill="transparent" style={{ touchAction: 'none' }} />
320
+ {children}
321
+ </g>
322
+ </svg>
323
+ );
324
+ }
325
+
326
+ /** Horizontal lines at the price ticks. */
327
+ function Grid({ className, ticks = 6 }: { className?: string; ticks?: number }) {
328
+ const { frame, readoutId } = useChart('Grid');
329
+ return (
330
+ <g className={className} data-part="grid" clipPath={`url(#${readoutId}-price)`} aria-hidden="true">
331
+ {priceTicks(frame.priceMin, frame.priceMax, ticks).map((t) => <line key={t} x1={0} x2={frame.plotWidth} y1={frame.y(t)} y2={frame.y(t)} />)}
332
+ </g>
333
+ );
334
+ }
335
+
336
+ const changeOf = (bar: CandleBar): Change => (bar.close > bar.open ? 'up' : bar.close < bar.open ? 'down' : 'flat');
337
+
338
+ /**
339
+ * The bars in one of six styles. Each candle is a `<g data-change>` holding a wick line and a body rect (or the OHLC
340
+ * ticks); line and area draw one path through the closes. Only bars in the viewport are drawn.
341
+ */
342
+ function Candles({ className, style = 'candle' }: { className?: string; style?: CandleStyle }) {
343
+ const { bars, frame, readoutId } = useChart('Candles');
344
+ const setScaleBars = useContext(PlotRef)?.setScaleBars;
345
+ // Inside a Series.Root the candles say whether the series is selected; without one nothing is added.
346
+ const selectedState = useContext(SeriesContext)?.selected ? 'selected' : undefined;
347
+ // The length is a dependency too, so bars a host pushes onto the same array are transformed before they are drawn.
348
+ const source = useMemo(() => (style === 'heikin-ashi' ? heikinAshi(bars) : bars), [bars, bars.length, style]);
349
+ useEffect(() => {
350
+ if (!setScaleBars || style !== 'heikin-ashi') return;
351
+ setScaleBars(source);
352
+ return () => setScaleBars(null);
353
+ }, [setScaleBars, source, style]);
354
+ // Price-scaled, so it stops at the volume pane: a price range a person moved can put bars below the pane.
355
+ const clip = `url(#${readoutId}-price)`;
356
+ const { first, last, x, y, barWidth } = frame;
357
+ if (last < first) return null;
358
+ if (style === 'line' || style === 'area') {
359
+ let d = '';
360
+ for (let i = first; i <= last; i++) d += `${i === first ? 'M' : 'L'}${x(i).toFixed(2)},${y(source[i].close).toFixed(2)}`;
361
+ const area = style === 'area' ? `${d}L${x(last).toFixed(2)},${frame.priceBottom}L${x(first).toFixed(2)},${frame.priceBottom}Z` : null;
362
+ return (
363
+ <g className={className} data-part="candles" data-style={style} data-state={selectedState} clipPath={clip} aria-hidden="true">
364
+ {area && <path data-part="area" d={area} />}
365
+ <path data-part="line" d={d} fill="none" />
366
+ </g>
367
+ );
368
+ }
369
+ const body = Math.max(1, barWidth * 0.7);
370
+ const nodes: ReactNode[] = [];
371
+ for (let i = first; i <= last; i++) {
372
+ const bar = source[i];
373
+ const cx = x(i);
374
+ const change = changeOf(bar);
375
+ if (style === 'ohlc') {
376
+ nodes.push(
377
+ <g key={bar.timestamp} data-change={change}>
378
+ <line x1={cx} x2={cx} y1={y(bar.high)} y2={y(bar.low)} />
379
+ <line x1={cx - body / 2} x2={cx} y1={y(bar.open)} y2={y(bar.open)} />
380
+ <line x1={cx} x2={cx + body / 2} y1={y(bar.close)} y2={y(bar.close)} />
381
+ </g>,
382
+ );
383
+ continue;
384
+ }
385
+ const top = y(Math.max(bar.open, bar.close));
386
+ const bottom = y(Math.min(bar.open, bar.close));
387
+ nodes.push(
388
+ <g key={bar.timestamp} data-change={change} data-hollow={style === 'hollow' && change === 'up' ? '' : undefined}>
389
+ <line data-part="wick" x1={cx} x2={cx} y1={y(bar.high)} y2={y(bar.low)} />
390
+ <rect data-part="body" x={cx - body / 2} y={top} width={body} height={Math.max(1, bottom - top)} />
391
+ </g>,
392
+ );
393
+ }
394
+ return <g className={className} data-part="candles" data-style={style} data-state={selectedState} clipPath={clip} aria-hidden="true">{nodes}</g>;
395
+ }
396
+
397
+ /** Volume bars in the pane under the prices, coloured by the bar's direction. */
398
+ function Volume({ className }: { className?: string }) {
399
+ const { bars, frame, readoutId } = useChart('Volume');
400
+ const { first, last, x, barWidth, volumeY, volumeBottom, volumeTop } = frame;
401
+ if (last < first || volumeBottom === volumeTop) return null;
402
+ const width = Math.max(1, barWidth * 0.7);
403
+ const nodes: ReactNode[] = [];
404
+ for (let i = first; i <= last; i++) {
405
+ const bar = bars[i];
406
+ const top = volumeY(bar.volume);
407
+ nodes.push(<rect key={bar.timestamp} data-change={changeOf(bar)} x={x(i) - width / 2} y={top} width={width} height={Math.max(0, volumeBottom - top)} />);
408
+ }
409
+ return <g className={className} data-part="volume" clipPath={`url(#${readoutId}-plot)`} aria-hidden="true">{nodes}</g>;
410
+ }
411
+
412
+ /** Price labels at the ticks, in the gutter right of the bars. */
413
+ function PriceAxis({ className, ticks = 6 }: { className?: string; ticks?: number }) {
414
+ const { frame } = useChart('PriceAxis');
415
+ const values = priceTicks(frame.priceMin, frame.priceMax, ticks);
416
+ const places = tickDecimals(values);
417
+ return (
418
+ <g className={className} data-part="price-axis" aria-hidden="true" style={{ pointerEvents: 'none' }}>
419
+ {values.map((t) => <text key={t} x={frame.plotWidth + 6} y={frame.y(t) + 4} textAnchor="start">{t.toFixed(places)}</text>)}
420
+ </g>
421
+ );
422
+ }
423
+
424
+ export interface TimeAxisProps {
425
+ className?: string;
426
+ /** Prints a bar's time. Default: HH:MM in UTC, with the date when the day changes. */
427
+ format?: (timestamp: number, previous: number | null) => string;
428
+ /** About how many labels. Default 6, or one per 110 pixels of plot when that is fewer, so narrow charts do not overlap. */
429
+ labels?: number;
430
+ }
431
+
432
+ /** Time labels in the gutter under the panes, spaced in whole bars. */
433
+ function TimeAxis({ className, format = formatBarTime, labels: wanted }: TimeAxisProps) {
434
+ const { bars, frame, totalHeight } = useChart('TimeAxis');
435
+ if (frame.last < frame.first) return null;
436
+ const labels = wanted ?? Math.max(2, Math.min(6, Math.floor(frame.plotWidth / 110)));
437
+ const step = Math.max(1, Math.round((frame.last - frame.first + 1) / labels));
438
+ const nodes: ReactNode[] = [];
439
+ let previous: number | null = null;
440
+ for (let i = Math.ceil(frame.first / step) * step; i <= frame.last; i += step) {
441
+ const t = bars[i].timestamp;
442
+ nodes.push(<text key={t} x={frame.x(i)} y={totalHeight - 4} textAnchor="middle">{format(t, previous)}</text>);
443
+ previous = t;
444
+ }
445
+ return <g className={className} data-part="time-axis" aria-hidden="true">{nodes}</g>;
446
+ }
447
+
448
+ /** A vertical line on the crosshair bar and a horizontal one at the pointer price (or the close from the keyboard). */
449
+ function Crosshair({ className }: { className?: string }) {
450
+ const { bars, frame, active, pointerPrice, pointerBelow, decimals } = useChart('Crosshair');
451
+ if (active === null || !bars[active]) return null;
452
+ const price = pointerPrice ?? bars[active].close;
453
+ const cx = frame.x(active);
454
+ const cy = frame.y(price);
455
+ return (
456
+ <g className={className} data-part="crosshair" aria-hidden="true" style={{ pointerEvents: 'none' }}>
457
+ <line x1={cx} x2={cx} y1={0} y2={frame.plotHeight} />
458
+ {/* Below the first band (a pointer over another panel) the price on this scale means nothing, so only the bar line shows. */}
459
+ {!pointerBelow && cy >= 0 && cy <= frame.plotHeight && <line x1={0} x2={frame.plotWidth} y1={cy} y2={cy} />}
460
+ {!pointerBelow && cy >= 0 && cy <= frame.plotHeight && <text data-part="crosshair-price" x={frame.plotWidth + 6} y={cy + 4} textAnchor="start">{price.toFixed(decimals)}</text>}
461
+ </g>
462
+ );
463
+ }
464
+
465
+ /** The crosshair bar in words, announced politely. The host may pass its own wording. */
466
+ function Readout({ className, format }: { className?: string; format?: (bar: CandleBar, index: number) => string }) {
467
+ const { bars, active, readoutId, decimals } = useChart('Readout');
468
+ const bar = active === null ? undefined : bars[active];
469
+ const f = (n: number) => n.toFixed(decimals);
470
+ const text = !bar ? '' : format ? format(bar, active!) : `${new Date(bar.timestamp).toISOString().slice(0, 16).replace('T', ' ')} UTC, open ${f(bar.open)}, high ${f(bar.high)}, low ${f(bar.low)}, close ${f(bar.close)}, volume ${bar.volume}`;
471
+ return <output id={readoutId} className={className} aria-live="polite" data-change={bar ? changeOf(bar) : undefined}>{text}</output>;
472
+ }
473
+
474
+ /**
475
+ * A button that puts the view back where the chart opened: the last 120 bars, the newest bar at the right, the price
476
+ * following the bars again. It is the way
477
+ * back from a view lost by panning or zooming, and it works by touch, with no keyboard. It leaves drawings and studies
478
+ * alone. It is disabled while the view already is the opening one.
479
+ */
480
+ function Reset({ className, icon, label, hideLabel, children }: NameProps & { className?: string; children?: ReactNode }) {
481
+ const { bars, frame, viewport, setViewport, setPriceRange, setActive } = useChart('Reset');
482
+ const face = buttonFace('Reset view', { icon, label, hideLabel, children });
483
+ const home = lastBars(bars.length, 120);
484
+ const atHome = viewport.start === home.start && viewport.end === home.end && !frame.manualPrice;
485
+ return (
486
+ <button type="button" className={className} data-part="chart-reset" aria-label={face['aria-label']} disabled={atHome} onClick={() => { setViewport(home); setPriceRange(null); setActive(null); }}>
487
+ {face.content}
488
+ </button>
489
+ );
490
+ }
491
+
492
+ /**
493
+ * A button that gives the price back to the bars after it was moved by hand, and leaves the time alone. It is disabled
494
+ * while the price already follows the bars.
495
+ */
496
+ function Autoscale({ className, icon, label, hideLabel, children }: NameProps & { className?: string; children?: ReactNode }) {
497
+ const { frame, setPriceRange } = useChart('Autoscale');
498
+ const face = buttonFace('Auto price', { icon, label, hideLabel, children });
499
+ return (
500
+ <button type="button" className={className} data-part="chart-autoscale" aria-label={face['aria-label']} disabled={!frame.manualPrice} onClick={() => setPriceRange(null)}>
501
+ {face.content}
502
+ </button>
503
+ );
504
+ }
505
+
506
+ /** The visible bars as a table: the text equivalent of the plot. */
507
+ function Table({ className, caption = 'Bars in view' }: { className?: string; caption?: string }) {
508
+ const { bars, frame, active, decimals } = useChart('Table');
509
+ const rows: ReactNode[] = [];
510
+ for (let i = frame.first; i <= frame.last; i++) {
511
+ const bar = bars[i];
512
+ rows.push(
513
+ <tr key={bar.timestamp} data-change={changeOf(bar)} aria-current={i === active ? 'true' : undefined}>
514
+ <td data-mt-numeric>{new Date(bar.timestamp).toISOString().slice(0, 16).replace('T', ' ')}</td>
515
+ {[bar.open, bar.high, bar.low, bar.close].map((v, k) => <td key={k} data-mt-numeric>{v.toFixed(decimals)}</td>)}
516
+ <td data-mt-numeric>{bar.volume}</td>
517
+ </tr>,
518
+ );
519
+ }
520
+ return (
521
+ <table className={className}>
522
+ <caption>{caption}</caption>
523
+ <thead><tr>{['Time (UTC)', 'Open', 'High', 'Low', 'Close', 'Volume'].map((h) => <th key={h} scope="col">{h}</th>)}</tr></thead>
524
+ <tbody>{rows}</tbody>
525
+ </table>
526
+ );
527
+ }
528
+
529
+ export { Root, Plot, Grid, Candles, Volume, PriceAxis, TimeAxis, Crosshair, Readout, Reset, Autoscale, Table };
@@ -0,0 +1,6 @@
1
+ import { Root, Input, Status } from './CsvDropParts.js';
2
+
3
+ // The parts live in CsvDropParts.js (a client module). The namespace object is built here, outside it, so a server
4
+ // component can write `CsvDrop.Root`: a property of an object exported from a client module is not reachable there.
5
+ export * from './CsvDropParts.js';
6
+ export const CsvDrop = { Root, Input, Status };
@@ -0,0 +1,128 @@
1
+ import { createContext, useCallback, useContext, useRef, useState, type DragEvent, type ReactNode } from 'react';
2
+ import { describeBarsCsv, parseBarsCsv, type CsvBarsResult } from './core/csv-bars.js';
3
+
4
+ export interface CsvDropLoad extends CsvBarsResult {
5
+ fileName: string;
6
+ }
7
+
8
+ type CsvDropState =
9
+ | { kind: 'idle' }
10
+ | { kind: 'reading'; fileName: string }
11
+ | { kind: 'loaded'; load: CsvDropLoad }
12
+ | { kind: 'error'; message: string };
13
+
14
+ interface CsvDropContext {
15
+ state: CsvDropState;
16
+ read: (file: File) => void;
17
+ }
18
+
19
+ const Ctx = createContext<CsvDropContext | null>(null);
20
+
21
+ function useCsvDrop(): CsvDropContext {
22
+ const ctx = useContext(Ctx);
23
+ if (!ctx) throw new Error('CsvDrop parts must be inside CsvDrop.Root');
24
+ return ctx;
25
+ }
26
+
27
+ export interface CsvDropRootProps {
28
+ /** Called after every read with the bars and what was skipped; the host decides what to show. */
29
+ onLoad: (load: CsvDropLoad) => void;
30
+ /** Bigger files are refused before reading. Default 50 MB. */
31
+ maxBytes?: number;
32
+ /** More rows than this are refused. Default 500,000. */
33
+ maxRows?: number;
34
+ className?: string;
35
+ children?: ReactNode;
36
+ }
37
+
38
+ /**
39
+ * A drop area for the user's own CSV file. The file is read in the browser and never leaves the page. Drop a file
40
+ * on the area, or use CsvDrop.Input (the keyboard path: a native file picker). State shows as `data-state`
41
+ * ('idle' | 'reading' | 'loaded' | 'error') and `data-dragging` while a file is over the area.
42
+ */
43
+ function Root({ onLoad, maxBytes = 50 * 1024 * 1024, maxRows, className, children }: CsvDropRootProps) {
44
+ const [state, setState] = useState<CsvDropState>({ kind: 'idle' });
45
+ const [dragging, setDragging] = useState(false);
46
+ const depth = useRef(0);
47
+ // Each read gets a number; a slower earlier read that finishes late is dropped, so the newest file always wins.
48
+ const generation = useRef(0);
49
+ const read = useCallback((file: File) => {
50
+ const id = ++generation.current;
51
+ if (file.size > maxBytes) {
52
+ setState({ kind: 'error', message: `${file.name} is larger than ${Math.round(maxBytes / 1024 / 1024)} MB, so it wasn't read.` });
53
+ return;
54
+ }
55
+ setState({ kind: 'reading', fileName: file.name });
56
+ file.text().then(
57
+ (text) => {
58
+ if (id !== generation.current) return;
59
+ const load = { ...parseBarsCsv(text, { maxRows }), fileName: file.name };
60
+ setState({ kind: 'loaded', load });
61
+ onLoad(load);
62
+ },
63
+ () => { if (id === generation.current) setState({ kind: 'error', message: `${file.name} couldn't be read.` }); },
64
+ );
65
+ }, [maxBytes, maxRows, onLoad]);
66
+
67
+ const hasFile = (e: DragEvent) => Array.from(e.dataTransfer.types).includes('Files');
68
+ return (
69
+ <Ctx.Provider value={{ state, read }}>
70
+ <div
71
+ data-mt=""
72
+ data-part="csv-drop"
73
+ data-state={state.kind}
74
+ data-dragging={dragging ? '' : undefined}
75
+ className={className}
76
+ onDragEnter={(e) => { if (!hasFile(e)) return; e.preventDefault(); depth.current++; setDragging(true); }}
77
+ onDragOver={(e) => { if (hasFile(e)) e.preventDefault(); }}
78
+ onDragLeave={() => { depth.current = Math.max(0, depth.current - 1); if (depth.current === 0) setDragging(false); }}
79
+ onDrop={(e) => {
80
+ if (!hasFile(e)) return;
81
+ e.preventDefault();
82
+ depth.current = 0;
83
+ setDragging(false);
84
+ const file = e.dataTransfer.files[0];
85
+ if (file) read(file);
86
+ }}
87
+ >
88
+ {children}
89
+ </div>
90
+ </Ctx.Provider>
91
+ );
92
+ }
93
+
94
+ /** The native file picker, labelled by its children. Accepts .csv, .tsv and .txt. */
95
+ function Input({ className, children }: { className?: string; children?: ReactNode }) {
96
+ const { read } = useCsvDrop();
97
+ return (
98
+ <label data-part="csv-input" className={className}>
99
+ {children}
100
+ <input
101
+ type="file"
102
+ accept=".csv,.tsv,.txt,text/csv,text/tab-separated-values,text/plain"
103
+ onChange={(e) => {
104
+ const file = e.target.files?.[0];
105
+ if (file) read(file);
106
+ e.target.value = '';
107
+ }}
108
+ />
109
+ </label>
110
+ );
111
+ }
112
+
113
+ /** A polite status line: reading, how many bars loaded, and which rows were skipped and why. */
114
+ function Status({ className, idle }: { className?: string; idle?: ReactNode }) {
115
+ const { state } = useCsvDrop();
116
+ let text: ReactNode = idle ?? null;
117
+ if (state.kind === 'reading') text = `Reading ${state.fileName}…`;
118
+ if (state.kind === 'loaded') text = describeBarsCsv(state.load, state.load.fileName);
119
+ if (state.kind === 'error') text = state.message;
120
+ const failed = state.kind === 'error' || (state.kind === 'loaded' && state.load.bars.length === 0);
121
+ return (
122
+ <p role="status" aria-live="polite" data-part="csv-status" data-kind={failed ? 'error' : undefined} className={className}>
123
+ {text}
124
+ </p>
125
+ );
126
+ }
127
+
128
+ export { Root, Input, Status };