@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,156 @@
1
+ /**
2
+ * The rules of the price ladder as plain functions. Prices and sizes arrive as exact decimal strings and are summed and
3
+ * grouped as scaled integers, never as floats; the only number made here is a bar's width, 0 to 1. A level that breaks a
4
+ * rule is reported and left out. A crossed book is reported and still drawn as given: nothing here repairs a book.
5
+ */
6
+
7
+ export interface LadderLevelIn {
8
+ /** Exact decimal string, above zero. */
9
+ price: string;
10
+ /** Exact decimal string, above zero. */
11
+ size: string;
12
+ }
13
+
14
+ export interface LadderInput {
15
+ bids: readonly LadderLevelIn[];
16
+ asks: readonly LadderLevelIn[];
17
+ /** The grouping step, an exact decimal string above zero: `0.25` groups prices into quarters. Bids round down, asks round up, so grouping never crosses the spread. */
18
+ step: string;
19
+ /** Rows shown on each side, nearest the spread. Default 10. */
20
+ levels?: number;
21
+ /** The last trade price, for the divider row. */
22
+ last?: string;
23
+ /** Size traded at each price, for the traded column. A trade at or above the best ask is counted in the ask row's bucket, a lower one in the bid row's. */
24
+ traded?: readonly LadderLevelIn[];
25
+ }
26
+
27
+ export type LadderSide = 'bid' | 'ask';
28
+
29
+ export interface LadderRow {
30
+ /** `side:price`, the row's identity (the price, not the position). */
31
+ id: string;
32
+ side: LadderSide;
33
+ /** The bucket's price, with as many decimal places as the step. */
34
+ price: string;
35
+ /** Total size in the bucket, with as many places as the most precise size given. */
36
+ size: string;
37
+ /** Size traded at this price, when any was. */
38
+ traded?: string;
39
+ /** Bar width: this row's size over the largest row shown, 0 to 1. */
40
+ fraction: number;
41
+ /** The best bid or the best ask. */
42
+ best: boolean;
43
+ }
44
+
45
+ export interface LadderIssue {
46
+ where: 'bids' | 'asks' | 'traded' | 'step' | 'last' | 'book';
47
+ /** Place in that list, when the problem is one entry. */
48
+ index?: number;
49
+ message: string;
50
+ }
51
+
52
+ export interface LadderBuilt {
53
+ /** Top to bottom: highest ask first, lowest ask last. */
54
+ asks: LadderRow[];
55
+ /** Top to bottom: highest bid first. */
56
+ bids: LadderRow[];
57
+ /** Asks then bids, the order a ladder is drawn. */
58
+ rows: LadderRow[];
59
+ /** The divider between asks and bids: the last price and the spread, each only when it can be said. */
60
+ last: { price?: string; spread?: string };
61
+ issues: LadderIssue[];
62
+ }
63
+
64
+ const POSITIVE = /^(\d+)(?:\.(\d+))?$/;
65
+
66
+ interface Dec { units: bigint; places: number }
67
+
68
+ function parse(value: unknown): Dec | null {
69
+ if (typeof value !== 'string') return null;
70
+ const m = POSITIVE.exec(value);
71
+ if (!m || !/[1-9]/.test(value)) return null;
72
+ const places = m[2]?.length ?? 0;
73
+ return { units: BigInt(m[1] + (m[2] ?? '')), places };
74
+ }
75
+
76
+ const rescale = (d: Dec, places: number): bigint => d.units * 10n ** BigInt(places - d.places);
77
+
78
+ function show(units: bigint, places: number): string {
79
+ const digits = units.toString().padStart(places + 1, '0');
80
+ return places === 0 ? digits : `${digits.slice(0, -places)}.${digits.slice(-places)}`;
81
+ }
82
+
83
+ interface Good { index: number; price: Dec; size: Dec }
84
+
85
+ function readLevels(list: readonly LadderLevelIn[] | undefined, where: 'bids' | 'asks' | 'traded', issues: LadderIssue[]): Good[] {
86
+ const out: Good[] = [];
87
+ (list ?? []).forEach((item, index) => {
88
+ const price = parse(item?.price);
89
+ const size = parse(item?.size);
90
+ if (!price || !size) { issues.push({ where, index, message: `${!price ? 'price' : 'size'} must be an exact decimal string above zero` }); return; }
91
+ out.push({ index, price, size });
92
+ });
93
+ return out;
94
+ }
95
+
96
+ /**
97
+ * Group the book into rows. Bids and asks are grouped by `step`, summed exactly, trimmed to `levels` rows a side, and
98
+ * given a bar width. Nothing is drawn for a level that breaks a rule; `issues` says which.
99
+ */
100
+ export function buildLadder(input: LadderInput): LadderBuilt {
101
+ const issues: LadderIssue[] = [];
102
+ const empty: LadderBuilt = { asks: [], bids: [], rows: [], last: {}, issues };
103
+ const step = parse(input.step);
104
+ if (!step) { issues.push({ where: 'step', message: 'step must be an exact decimal string above zero' }); return empty; }
105
+ const bids = readLevels(input.bids, 'bids', issues);
106
+ const asks = readLevels(input.asks, 'asks', issues);
107
+ const traded = readLevels(input.traded, 'traded', issues);
108
+
109
+ const places = Math.max(step.places, ...bids.concat(asks, traded).map((g) => g.price.places));
110
+ const unit = rescale(step, places);
111
+ const sizePlaces = Math.max(0, ...bids.concat(asks, traded).map((g) => g.size.places));
112
+ const floor = (p: bigint) => (p / unit) * unit;
113
+ const ceil = (p: bigint) => ((p + unit - 1n) / unit) * unit;
114
+
115
+ const bucket = (list: Good[], round: (p: bigint) => bigint): Map<bigint, bigint> => {
116
+ const sums = new Map<bigint, bigint>();
117
+ for (const g of list) {
118
+ const key = round(rescale(g.price, places));
119
+ sums.set(key, (sums.get(key) ?? 0n) + rescale(g.size, sizePlaces));
120
+ }
121
+ return sums;
122
+ };
123
+ const descending = (a: bigint, b: bigint) => (a < b ? 1 : a > b ? -1 : 0);
124
+ const take = Math.max(1, Math.floor(input.levels ?? 10));
125
+ const bidBuckets = [...bucket(bids, floor)].sort((a, b) => descending(a[0], b[0])).slice(0, take);
126
+ const askBuckets = [...bucket(asks, ceil)].sort((a, b) => descending(b[0], a[0])).slice(0, take); // lowest first, nearest the spread
127
+
128
+ const bestBid = bids.length ? bids.reduce((m, g) => (rescale(g.price, places) > m ? rescale(g.price, places) : m), 0n) : null;
129
+ const bestAsk = asks.length ? asks.reduce((m, g) => { const p = rescale(g.price, places); return m === null || p < m ? p : m; }, null as bigint | null) : null;
130
+ if (bestBid !== null && bestAsk !== null && bestBid >= bestAsk) {
131
+ issues.push({ where: 'book', message: `crossed book: best bid ${show(bestBid, places)} is not below best ask ${show(bestAsk, places)}` });
132
+ }
133
+
134
+ // Traded size: at or above the best ask it belongs to an ask row, below it to a bid row.
135
+ const tradedUp = bucket(traded.filter((g) => bestAsk !== null && rescale(g.price, places) >= bestAsk), ceil);
136
+ const tradedDown = bucket(traded.filter((g) => bestAsk === null || rescale(g.price, places) < bestAsk), floor);
137
+
138
+ const largest = [...bidBuckets, ...askBuckets].reduce((m, [, s]) => (s > m ? s : m), 0n);
139
+ const bar = (s: bigint) => (largest === 0n ? 0 : Number((s * 10000n) / largest) / 10000);
140
+ const make = (side: LadderSide, [key, size]: [bigint, bigint], best: boolean, tradedMap: Map<bigint, bigint>): LadderRow => {
141
+ const price = show(key / 10n ** BigInt(places - step.places), step.places); // a bucket key is a whole number of steps, so this divides exactly
142
+ const t = tradedMap.get(key);
143
+ return { id: `${side}:${price}`, side, price, size: show(size, sizePlaces), traded: t === undefined ? undefined : show(t, sizePlaces), fraction: bar(size), best };
144
+ };
145
+ const askRows = askBuckets.map((b, i) => make('ask', b, i === 0, tradedUp)).reverse(); // drawn highest first
146
+ const bidRows = bidBuckets.map((b, i) => make('bid', b, i === 0, tradedDown));
147
+
148
+ const last: LadderBuilt['last'] = {};
149
+ if (input.last !== undefined) {
150
+ if (parse(input.last)) last.price = input.last;
151
+ else issues.push({ where: 'last', message: 'last must be an exact decimal string above zero' });
152
+ }
153
+ if (bestBid !== null && bestAsk !== null && bestAsk > bestBid) last.spread = show(bestAsk - bestBid, places);
154
+
155
+ return { asks: askRows, bids: bidRows, rows: [...askRows, ...bidRows], last, issues };
156
+ }
@@ -0,0 +1,177 @@
1
+ // Panels: a chart is one or more vertical bands, each with its own price scale, sharing one time axis. The bars (and the
2
+ // volume under them) stay in the first band, "main". Other series are placed in a band by id, and move between bands by
3
+ // relative destinations ("up", "down", "new panel above or below"), which is the single-pointer path WCAG 2.2 (2.5.7)
4
+ // asks of any drag. The rules are plain functions so any framework layer, and a test, can use them.
5
+
6
+ /** The id of the first band. It always exists, is always first, and holds the chart's own bars and volume. */
7
+ export const MAIN_PANEL = 'main';
8
+
9
+ export interface PanelSpec {
10
+ id: string;
11
+ /** What the band is called in lists and live-region text. */
12
+ label: string;
13
+ /** Share of the height. Default 1 for a panel and 3 for main. */
14
+ weight?: number;
15
+ }
16
+
17
+ export interface PanelLayout {
18
+ /** The bands from top to bottom. The first is always `main`. */
19
+ panels: readonly PanelSpec[];
20
+ /** Series id to panel id. A series with no entry, or a panel that is gone, is in `main`. */
21
+ placement: Readonly<Record<string, string>>;
22
+ }
23
+
24
+ export interface PanelBand {
25
+ id: string;
26
+ /** Top edge, from the top of the plot. */
27
+ top: number;
28
+ height: number;
29
+ }
30
+
31
+ /** Height kept under the bands for the time labels: the same as the chart frame's default `axisBottom`. */
32
+ export const AXIS_BOTTOM = 18;
33
+
34
+ const MIN_BAND = 40;
35
+ const MAIN_WEIGHT = 3;
36
+
37
+ const weightOf = (panel: PanelSpec) => (Number.isFinite(panel.weight) && (panel.weight as number) > 0 ? (panel.weight as number) : panel.id === MAIN_PANEL ? MAIN_WEIGHT : 1);
38
+
39
+ /** A layout with only `main` (and the given bands below it), `main` forced to the front. */
40
+ export function normalizeLayout(layout: PanelLayout): PanelLayout {
41
+ const seen = new Set<string>();
42
+ const rest: PanelSpec[] = [];
43
+ let main: PanelSpec = { id: MAIN_PANEL, label: 'Main' };
44
+ for (const panel of layout.panels ?? []) {
45
+ if (!panel || typeof panel.id !== 'string' || seen.has(panel.id)) continue;
46
+ seen.add(panel.id);
47
+ if (panel.id === MAIN_PANEL) main = { ...main, ...panel };
48
+ else rest.push(panel);
49
+ }
50
+ return { panels: [main, ...rest], placement: layout.placement ?? {} };
51
+ }
52
+
53
+ /** The panel a series is in: its placement when that panel exists, otherwise `main`. */
54
+ export function panelOf(layout: PanelLayout, seriesId: string): string {
55
+ const wanted = layout.placement[seriesId];
56
+ return wanted !== undefined && layout.panels.some((p) => p.id === wanted) ? wanted : MAIN_PANEL;
57
+ }
58
+
59
+ /**
60
+ * Split `height` into bands by weight, top to bottom, with no gaps. A band is never shorter than 40 while the height
61
+ * allows it; with too little height the bands share what there is.
62
+ */
63
+ export function panelBands(panels: readonly PanelSpec[], height: number): PanelBand[] {
64
+ const total = Number.isFinite(height) && height > 0 ? height : 1;
65
+ const weights = panels.map(weightOf);
66
+ const sum = weights.reduce((a, b) => a + b, 0) || 1;
67
+ const floor = Math.min(MIN_BAND, total / Math.max(1, panels.length));
68
+ let heights = weights.map((w) => Math.max(floor, (total * w) / sum));
69
+ // Taking the floor from some bands can overshoot; scale the bands that were above it back down.
70
+ const over = heights.reduce((a, b) => a + b, 0) - total;
71
+ if (over > 0.001) {
72
+ const spare = heights.reduce((a, h) => a + Math.max(0, h - floor), 0);
73
+ if (spare > 0) heights = heights.map((h) => h - (Math.max(0, h - floor) / spare) * over);
74
+ }
75
+ let top = 0;
76
+ return panels.map((p, i) => {
77
+ const h = i === panels.length - 1 ? Math.max(0, total - top) : Math.round(heights[i] * 100) / 100;
78
+ const band = { id: p.id, top: Math.round(top * 100) / 100, height: h };
79
+ top += h;
80
+ return band;
81
+ });
82
+ }
83
+
84
+ /** Where a series can go: an existing panel, or a new one beside the panel it is in. */
85
+ export type MoveDestination =
86
+ | { kind: 'panel'; panelId: string }
87
+ | { kind: 'new'; side: 'above' | 'below'; beside: string };
88
+
89
+ export interface MoveTarget {
90
+ destination: MoveDestination;
91
+ /** The words for a button, for example "Move up to Main" or "Move to a new panel below". */
92
+ label: string;
93
+ }
94
+
95
+ /**
96
+ * The destinations for one series, relative to where it is: the panel above, the panel below, a new panel above, a new
97
+ * panel below. A destination that would change nothing is left out (a lone series in a panel gains nothing from a new
98
+ * panel beside its own), and so is "new panel above" for `main`, which stays first.
99
+ */
100
+ export function moveTargets(layout: PanelLayout, seriesId: string): MoveTarget[] {
101
+ const here = panelOf(layout, seriesId);
102
+ const at = layout.panels.findIndex((p) => p.id === here);
103
+ const out: MoveTarget[] = [];
104
+ const above = layout.panels[at - 1];
105
+ const below = layout.panels[at + 1];
106
+ if (above) out.push({ destination: { kind: 'panel', panelId: above.id }, label: `Move up to ${above.label}` });
107
+ if (below) out.push({ destination: { kind: 'panel', panelId: below.id }, label: `Move down to ${below.label}` });
108
+ const alone = here !== MAIN_PANEL && layout.panels.length > 0 && Object.keys(layout.placement).filter((id) => panelOf(layout, id) === here).length <= 1;
109
+ if (here !== MAIN_PANEL && !alone) out.push({ destination: { kind: 'new', side: 'above', beside: here }, label: 'Move to a new panel above' });
110
+ if (!alone) out.push({ destination: { kind: 'new', side: 'below', beside: here }, label: 'Move to a new panel below' });
111
+ return out;
112
+ }
113
+
114
+ const nextPanelId = (panels: readonly PanelSpec[]): { id: string; label: string } => {
115
+ let n = panels.length;
116
+ const ids = new Set(panels.map((p) => p.id));
117
+ while (ids.has(`panel-${n}`)) n++;
118
+ return { id: `panel-${n}`, label: `Panel ${n}` };
119
+ };
120
+
121
+ /**
122
+ * Move a series. Returns the same layout object when nothing changes. A panel left with no series is removed (never
123
+ * `main`). The result is a new layout: the arguments are not changed.
124
+ */
125
+ export function moveSeries(layout: PanelLayout, seriesId: string, destination: MoveDestination): PanelLayout {
126
+ const here = panelOf(layout, seriesId);
127
+ let panels = [...layout.panels];
128
+ let target: string;
129
+ if (destination.kind === 'panel') {
130
+ if (!panels.some((p) => p.id === destination.panelId) || destination.panelId === here) return layout;
131
+ target = destination.panelId;
132
+ } else {
133
+ const beside = panels.findIndex((p) => p.id === destination.beside);
134
+ if (beside < 0) return layout;
135
+ if (destination.side === 'above' && panels[beside].id === MAIN_PANEL) return layout; // main stays first
136
+ const made = nextPanelId(panels);
137
+ panels.splice(destination.side === 'above' ? beside : beside + 1, 0, { id: made.id, label: made.label });
138
+ target = made.id;
139
+ }
140
+ const placement = { ...layout.placement, [seriesId]: target };
141
+ const used = new Set(Object.keys(placement).map((id) => panelOf({ panels, placement }, id)));
142
+ panels = panels.filter((p) => p.id === MAIN_PANEL || used.has(p.id) || p.id === target);
143
+ return { panels, placement };
144
+ }
145
+
146
+ /** The words for a live region after a move, for example "Moved RSI to Panel 2." */
147
+ export function describeMove(label: string, panelLabel: string): string {
148
+ return `Moved ${label} to ${panelLabel}.`;
149
+ }
150
+
151
+ /** A series as `reconcileLayout` reads it: its id and the panel it asks to start in. */
152
+ export interface PanelSeriesRequest { id: string; panel?: string }
153
+
154
+ /**
155
+ * Bring an uncontrolled layout in step with a changed list of series, keeping every placement a person already made.
156
+ * A series that is new (not in `seen`) starts in the panel it names, which is created below `main` when it does not
157
+ * exist (labelled from `named`, else its id). A series that is gone loses its placement, and a panel left with no
158
+ * series is removed. Returns the same layout when nothing changes.
159
+ */
160
+ export function reconcileLayout(layout: PanelLayout, series: readonly PanelSeriesRequest[], seen: readonly string[], named: readonly PanelSpec[] = []): PanelLayout {
161
+ const now = new Set(series.map((s) => s.id));
162
+ const before = new Set(seen);
163
+ let panels = [...layout.panels];
164
+ const placement: Record<string, string> = { ...layout.placement };
165
+ let changed = false;
166
+ for (const id of seen) if (!now.has(id) && id in placement) { delete placement[id]; changed = true; }
167
+ for (const s of series) {
168
+ if (before.has(s.id) || !s.panel || s.panel === MAIN_PANEL) continue;
169
+ if (!panels.some((p) => p.id === s.panel)) panels.push(named.find((n) => n.id === s.panel) ?? { id: s.panel, label: s.panel });
170
+ placement[s.id] = s.panel;
171
+ changed = true;
172
+ }
173
+ const used = new Set(Object.keys(placement).map((id) => placement[id]));
174
+ const kept = panels.filter((p) => p.id === MAIN_PANEL || used.has(p.id));
175
+ if (kept.length !== panels.length) { panels = kept; changed = true; }
176
+ return changed ? { panels, placement } : layout;
177
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The rules of the evidence receipt as plain functions. Digests and sizes are strings and are
3
+ * printed as given.
4
+ */
5
+ export interface ReceiptData {
6
+ dataset: string;
7
+ symbol: string;
8
+ synthetic: boolean;
9
+ a: string;
10
+ b: string;
11
+ eventsBetween: number;
12
+ sourceDigest: string;
13
+ stateDigestA: string;
14
+ stateDigestB: string;
15
+ resultHash: string;
16
+ engineVersion: string;
17
+ levelsDiffer: number;
18
+ /** Whether replaying from the start gave the same two states and the same result hash. */
19
+ recomputeMatch: boolean;
20
+ /** The line that must travel with a copy when the data is not synthetic. */
21
+ shareNote?: string;
22
+ }
23
+
24
+ export type ReceiptVerdict = 'match' | 'mismatch';
25
+ export const receiptVerdict = (receipt: Pick<ReceiptData, 'recomputeMatch'>): ReceiptVerdict => (receipt.recomputeMatch ? 'match' : 'mismatch');
26
+
27
+ export type ReceiptHashOf = 'source' | 'a' | 'b' | 'result';
28
+ export function receiptHash(receipt: ReceiptData, of: ReceiptHashOf): string {
29
+ return of === 'source' ? receipt.sourceDigest : of === 'a' ? receipt.stateDigestA : of === 'b' ? receipt.stateDigestB : receipt.resultHash;
30
+ }
31
+
32
+ interface KeyLike { key: string; ctrlKey: boolean; metaKey: boolean; altKey: boolean }
33
+
34
+ /**
35
+ * Enter or `y` copies the result hash. Enter on a button or link is that control's own and is left alone; `y` is
36
+ * ignored while typing in a field.
37
+ */
38
+ export function resolveReceiptKey(event: KeyLike, target: { tagName: string; isContentEditable?: boolean }): 'copy-hash' | null {
39
+ if (event.ctrlKey || event.metaKey || event.altKey) return null;
40
+ const tag = target.tagName.toLowerCase();
41
+ if (tag === 'input' || tag === 'textarea' || tag === 'select' || target.isContentEditable) return null;
42
+ if (event.key === 'y') return 'copy-hash';
43
+ if (event.key === 'Enter' && tag !== 'button' && tag !== 'a') return 'copy-hash';
44
+ return null;
45
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * The scrub gesture as plain data in, plain data out. Everything here works on a fraction of the
3
+ * track, 0 to 1, and knows nothing about time, sequences or events: the host turns a committed fraction into a
4
+ * place in its session. That keeps the rules testable without a DOM and keeps the Scrubber usable on any ruler.
5
+ */
6
+
7
+ /** Clamp a track fraction to [0, 1]; a value that is not a finite number becomes 0. */
8
+ export function clampFraction(value: number): number {
9
+ if (!Number.isFinite(value)) return 0;
10
+ return value < 0 ? 0 : value > 1 ? 1 : value;
11
+ }
12
+
13
+ export type ScrubGestureEvent =
14
+ | { type: 'down'; fraction: number }
15
+ | { type: 'move'; fraction: number }
16
+ /**
17
+ * `fraction` is where the release itself landed: pointer input coalesces, so the release can be measurably ahead
18
+ * of the last move. When it is left out the last previewed fraction is used. `loading` is whether the host is
19
+ * already busy with another navigation at the moment of release, passed in as data so the decision lives here.
20
+ */
21
+ | { type: 'up'; fraction?: number; loading?: boolean }
22
+ | { type: 'cancel' };
23
+
24
+ export interface ScrubGestureResult {
25
+ /** The next preview fraction; null when no drag is in progress. */
26
+ dragFraction: number | null;
27
+ /** Non-null exactly once per gesture: the fraction to commit when the pointer is released. */
28
+ commit: number | null;
29
+ begin: boolean;
30
+ end: boolean;
31
+ }
32
+
33
+ const IDLE: Omit<ScrubGestureResult, 'dragFraction'> = { commit: null, begin: false, end: false };
34
+
35
+ /**
36
+ * Moving only ever changes the preview; only a release, with a drag in progress, commits, and it commits once.
37
+ * A release while the host is loading ends the gesture without committing, so two navigations are never in
38
+ * flight at once. A cancelled gesture (the pointer left the window) never commits.
39
+ */
40
+ export function reduceScrubGesture(event: ScrubGestureEvent, dragFraction: number | null): ScrubGestureResult {
41
+ switch (event.type) {
42
+ case 'down':
43
+ return { dragFraction: clampFraction(event.fraction), commit: null, begin: true, end: false };
44
+ case 'move':
45
+ return dragFraction === null ? { dragFraction, ...IDLE } : { dragFraction: clampFraction(event.fraction), ...IDLE };
46
+ case 'up':
47
+ if (dragFraction === null) return { dragFraction: null, ...IDLE };
48
+ if (event.loading) return { dragFraction: null, commit: null, begin: false, end: true };
49
+ return { dragFraction: null, commit: clampFraction(event.fraction ?? dragFraction), begin: false, end: true };
50
+ case 'cancel':
51
+ return dragFraction === null ? { dragFraction: null, ...IDLE } : { dragFraction: null, commit: null, begin: false, end: true };
52
+ }
53
+ }
54
+
55
+ /** The two pointer fields a scrub gate reads, named so the gates are testable without a DOM event. */
56
+ export interface ScrubPointer {
57
+ /** 0 is the primary button (left, or a pen or touch contact). */
58
+ button: number;
59
+ /** False for any additional simultaneous contact in a multi-touch gesture. */
60
+ isPrimary: boolean;
61
+ }
62
+
63
+ /** Whether a pointer-down may start a scrub. A right-click, middle-click or second finger is left to the browser. */
64
+ export function isPrimaryScrubPointer(event: ScrubPointer): boolean {
65
+ return event.button === 0 && event.isPrimary;
66
+ }
67
+
68
+ /** Whether a move, up or cancel belongs to the pointer that began the drag now in progress (a second contact cannot hijack it). */
69
+ export function isActiveScrubPointer(pointerId: number, activePointerId: number | null): boolean {
70
+ return activePointerId !== null && pointerId === activePointerId;
71
+ }
72
+
73
+ /** Where a pointer sits along a track, as a fraction. A track with no width reads as 0. */
74
+ export function fractionFromPointer(clientX: number, left: number, width: number): number {
75
+ if (!(width > 0)) return 0;
76
+ return clampFraction((clientX - left) / width);
77
+ }
78
+
79
+ export type ScrubKeyAction = { type: 'step'; direction: -1 | 1 } | { type: 'jump'; fraction: number };
80
+
81
+ /**
82
+ * Arrow keys step one event, which only the host can define, so they resolve to a direction. Home and End jump to
83
+ * the ends; PageUp and PageDown move 10% of the track. Any other key is left alone (null).
84
+ */
85
+ export function resolveScrubKey(key: string, currentFraction: number): ScrubKeyAction | null {
86
+ switch (key) {
87
+ case 'ArrowLeft':
88
+ return { type: 'step', direction: -1 };
89
+ case 'ArrowRight':
90
+ return { type: 'step', direction: 1 };
91
+ case 'Home':
92
+ return { type: 'jump', fraction: 0 };
93
+ case 'End':
94
+ return { type: 'jump', fraction: 1 };
95
+ case 'PageUp':
96
+ return { type: 'jump', fraction: clampFraction(currentFraction + 0.1) };
97
+ case 'PageDown':
98
+ return { type: 'jump', fraction: clampFraction(currentFraction - 0.1) };
99
+ default:
100
+ return null;
101
+ }
102
+ }
@@ -0,0 +1,41 @@
1
+ // One selection for a chart: a drawing, the bar series or a study, never more than one. The rules are plain functions
2
+ // so any framework layer (and a test) can use them.
3
+
4
+ /** What is selected on a chart, or null for nothing. */
5
+ export type ChartSelection = { kind: 'drawing' | 'series' | 'study'; id: string } | null;
6
+
7
+ /** True when two selections point at the same thing. */
8
+ export function sameSelection(a: ChartSelection, b: ChartSelection): boolean {
9
+ return a === b || (a !== null && b !== null && a.kind === b.kind && a.id === b.id);
10
+ }
11
+
12
+ /**
13
+ * A tap on clear chart (no drawing, no study under it). With something selected, it only puts the selection away. With
14
+ * nothing selected it selects the series when one is mounted (`seriesId`), and otherwise changes nothing. Returns the
15
+ * same value when nothing changes.
16
+ */
17
+ export function selectionAfterClearTap(selection: ChartSelection, seriesId: string | null): ChartSelection {
18
+ if (selection !== null) return null;
19
+ return seriesId !== null ? { kind: 'series', id: seriesId } : selection;
20
+ }
21
+
22
+ /** A tap on something selectable (a study line) selects it, whatever was selected before. */
23
+ export function selectionAfterTap(selection: ChartSelection, hit: ChartSelection, seriesId: string | null): ChartSelection {
24
+ if (hit === null) return selectionAfterClearTap(selection, seriesId);
25
+ return sameSelection(selection, hit) ? selection : hit;
26
+ }
27
+
28
+ /** Escape clears whatever is selected. */
29
+ export function selectionAfterEscape(selection: ChartSelection): ChartSelection {
30
+ return selection === null ? selection : null;
31
+ }
32
+
33
+ /** The names a selection can be described with, per kind and id. A missing name falls back to the id. */
34
+ export type SelectionNames = { [K in 'drawing' | 'series' | 'study']?: Readonly<Record<string, string>> };
35
+
36
+ /** The words for a live region: what is selected now, or that nothing is. */
37
+ export function describeSelection(selection: ChartSelection, names: SelectionNames = {}): string {
38
+ if (selection === null) return 'Selection cleared.';
39
+ const name = names[selection.kind]?.[selection.id] ?? selection.id;
40
+ return `Selected ${selection.kind}: ${name}.`;
41
+ }