@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.
- package/AGENTS.md +44 -0
- package/CHANGELOG.md +19 -0
- package/LICENSE +201 -0
- package/README.md +636 -0
- package/dist/Chart.d.ts +18 -0
- package/dist/Chart.js +6 -0
- package/dist/ChartParts.d.ts +151 -0
- package/dist/ChartParts.js +389 -0
- package/dist/CsvDrop.d.ts +8 -0
- package/dist/CsvDrop.js +5 -0
- package/dist/CsvDropParts.d.ts +33 -0
- package/dist/CsvDropParts.js +77 -0
- package/dist/Cursor.d.ts +6 -0
- package/dist/Cursor.js +5 -0
- package/dist/CursorParts.d.ts +51 -0
- package/dist/CursorParts.js +40 -0
- package/dist/DepthProfile.d.ts +13 -0
- package/dist/DepthProfile.js +5 -0
- package/dist/DepthProfileParts.d.ts +45 -0
- package/dist/DepthProfileParts.js +96 -0
- package/dist/DiffTable.d.ts +13 -0
- package/dist/DiffTable.js +5 -0
- package/dist/DiffTableParts.d.ts +57 -0
- package/dist/DiffTableParts.js +103 -0
- package/dist/Drawings.d.ts +13 -0
- package/dist/Drawings.js +5 -0
- package/dist/DrawingsParts.d.ts +194 -0
- package/dist/DrawingsParts.js +775 -0
- package/dist/Fullscreen.d.ts +7 -0
- package/dist/Fullscreen.js +5 -0
- package/dist/FullscreenParts.d.ts +54 -0
- package/dist/FullscreenParts.js +250 -0
- package/dist/Ladder.d.ts +13 -0
- package/dist/Ladder.js +5 -0
- package/dist/LadderParts.d.ts +49 -0
- package/dist/LadderParts.js +56 -0
- package/dist/Menu.d.ts +11 -0
- package/dist/Menu.js +5 -0
- package/dist/MenuParts.d.ts +52 -0
- package/dist/MenuParts.js +202 -0
- package/dist/Notice.d.ts +18 -0
- package/dist/Notice.js +9 -0
- package/dist/Panels.d.ts +9 -0
- package/dist/Panels.js +5 -0
- package/dist/PanelsParts.d.ts +62 -0
- package/dist/PanelsParts.js +387 -0
- package/dist/Receipt.d.ts +11 -0
- package/dist/Receipt.js +5 -0
- package/dist/ReceiptParts.d.ts +45 -0
- package/dist/ReceiptParts.js +71 -0
- package/dist/Scrubber.d.ts +9 -0
- package/dist/Scrubber.js +5 -0
- package/dist/ScrubberParts.d.ts +74 -0
- package/dist/ScrubberParts.js +126 -0
- package/dist/Selection.d.ts +14 -0
- package/dist/Selection.js +7 -0
- package/dist/SelectionParts.d.ts +63 -0
- package/dist/SelectionParts.js +208 -0
- package/dist/Studies.d.ts +9 -0
- package/dist/Studies.js +5 -0
- package/dist/StudiesParts.d.ts +45 -0
- package/dist/StudiesParts.js +153 -0
- package/dist/Submenu.d.ts +7 -0
- package/dist/Submenu.js +4 -0
- package/dist/SymbolSearch.d.ts +10 -0
- package/dist/SymbolSearch.js +5 -0
- package/dist/SymbolSearchParts.d.ts +39 -0
- package/dist/SymbolSearchParts.js +86 -0
- package/dist/Tape.d.ts +15 -0
- package/dist/Tape.js +5 -0
- package/dist/TapeParts.d.ts +69 -0
- package/dist/TapeParts.js +139 -0
- package/dist/Toolbar.d.ts +8 -0
- package/dist/Toolbar.js +5 -0
- package/dist/ToolbarParts.d.ts +61 -0
- package/dist/ToolbarParts.js +122 -0
- package/dist/Transport.d.ts +15 -0
- package/dist/Transport.js +5 -0
- package/dist/TransportParts.d.ts +52 -0
- package/dist/TransportParts.js +47 -0
- package/dist/VolumeProfile.d.ts +7 -0
- package/dist/VolumeProfile.js +5 -0
- package/dist/VolumeProfileParts.d.ts +37 -0
- package/dist/VolumeProfileParts.js +57 -0
- package/dist/accessible-name.d.ts +26 -0
- package/dist/accessible-name.js +14 -0
- package/dist/attributes.d.ts +8 -0
- package/dist/attributes.js +9 -0
- package/dist/core/adapter.d.ts +38 -0
- package/dist/core/adapter.js +64 -0
- package/dist/core/attributes.d.ts +36 -0
- package/dist/core/attributes.js +22 -0
- package/dist/core/candles.d.ts +6 -0
- package/dist/core/candles.js +19 -0
- package/dist/core/chart-gesture.d.ts +109 -0
- package/dist/core/chart-gesture.js +161 -0
- package/dist/core/chart-scale.d.ts +141 -0
- package/dist/core/chart-scale.js +302 -0
- package/dist/core/csv-bars.d.ts +54 -0
- package/dist/core/csv-bars.js +325 -0
- package/dist/core/depth-profile.d.ts +43 -0
- package/dist/core/depth-profile.js +66 -0
- package/dist/core/diff-table.d.ts +57 -0
- package/dist/core/diff-table.js +77 -0
- package/dist/core/drawing-fields.d.ts +11 -0
- package/dist/core/drawing-fields.js +15 -0
- package/dist/core/drawings.d.ts +194 -0
- package/dist/core/drawings.js +403 -0
- package/dist/core/fullscreen.d.ts +32 -0
- package/dist/core/fullscreen.js +16 -0
- package/dist/core/index.d.ts +41 -0
- package/dist/core/index.js +23 -0
- package/dist/core/ladder-keys.d.ts +35 -0
- package/dist/core/ladder-keys.js +43 -0
- package/dist/core/ladder.d.ts +65 -0
- package/dist/core/ladder.js +93 -0
- package/dist/core/panels.d.ts +72 -0
- package/dist/core/panels.js +151 -0
- package/dist/core/receipt-rules.d.ts +42 -0
- package/dist/core/receipt-rules.js +20 -0
- package/dist/core/scrubber-gesture.d.ts +66 -0
- package/dist/core/scrubber-gesture.js +69 -0
- package/dist/core/selection.d.ts +24 -0
- package/dist/core/selection.js +33 -0
- package/dist/core/series.d.ts +161 -0
- package/dist/core/series.js +331 -0
- package/dist/core/studies.d.ts +80 -0
- package/dist/core/studies.js +237 -0
- package/dist/core/symbol-search.d.ts +89 -0
- package/dist/core/symbol-search.js +223 -0
- package/dist/core/tape.d.ts +77 -0
- package/dist/core/tape.js +136 -0
- package/dist/core/volume-profile.d.ts +47 -0
- package/dist/core/volume-profile.js +86 -0
- package/dist/index.d.ts +82 -0
- package/dist/index.js +42 -0
- package/dist/ladder-keys.d.ts +16 -0
- package/dist/ladder-keys.js +86 -0
- package/dist/panels-context.d.ts +47 -0
- package/dist/panels-context.js +3 -0
- package/dist/selection-context.d.ts +41 -0
- package/dist/selection-context.js +4 -0
- package/examples/csv-to-chart-frame.mjs +17 -0
- package/examples/studies.mjs +12 -0
- package/llms.txt +16 -0
- package/package.json +67 -0
- package/src/Chart.tsx +7 -0
- package/src/ChartParts.tsx +529 -0
- package/src/CsvDrop.tsx +6 -0
- package/src/CsvDropParts.tsx +128 -0
- package/src/Cursor.tsx +6 -0
- package/src/CursorParts.tsx +77 -0
- package/src/DepthProfile.tsx +6 -0
- package/src/DepthProfileParts.tsx +154 -0
- package/src/DiffTable.tsx +6 -0
- package/src/DiffTableParts.tsx +160 -0
- package/src/Drawings.tsx +6 -0
- package/src/DrawingsParts.tsx +1080 -0
- package/src/Fullscreen.tsx +6 -0
- package/src/FullscreenParts.tsx +318 -0
- package/src/Ladder.tsx +6 -0
- package/src/LadderParts.tsx +114 -0
- package/src/Menu.tsx +6 -0
- package/src/MenuParts.tsx +364 -0
- package/src/Notice.tsx +27 -0
- package/src/Panels.tsx +6 -0
- package/src/PanelsParts.tsx +454 -0
- package/src/Receipt.tsx +6 -0
- package/src/ReceiptParts.tsx +118 -0
- package/src/Scrubber.tsx +6 -0
- package/src/ScrubberParts.tsx +207 -0
- package/src/Selection.tsx +12 -0
- package/src/SelectionParts.tsx +251 -0
- package/src/Studies.tsx +6 -0
- package/src/StudiesParts.tsx +226 -0
- package/src/Submenu.tsx +5 -0
- package/src/SymbolSearch.tsx +6 -0
- package/src/SymbolSearchParts.tsx +166 -0
- package/src/Tape.tsx +6 -0
- package/src/TapeParts.tsx +208 -0
- package/src/Toolbar.tsx +6 -0
- package/src/ToolbarParts.tsx +179 -0
- package/src/Transport.tsx +6 -0
- package/src/TransportParts.tsx +128 -0
- package/src/VolumeProfile.tsx +6 -0
- package/src/VolumeProfileParts.tsx +106 -0
- package/src/accessible-name.tsx +34 -0
- package/src/attributes.ts +12 -0
- package/src/core/adapter.ts +91 -0
- package/src/core/attributes.ts +51 -0
- package/src/core/candles.ts +22 -0
- package/src/core/chart-gesture.ts +228 -0
- package/src/core/chart-scale.ts +368 -0
- package/src/core/csv-bars.ts +328 -0
- package/src/core/depth-profile.ts +84 -0
- package/src/core/diff-table.ts +105 -0
- package/src/core/drawing-fields.ts +17 -0
- package/src/core/drawings.ts +478 -0
- package/src/core/fullscreen.ts +32 -0
- package/src/core/index.ts +42 -0
- package/src/core/ladder-keys.ts +61 -0
- package/src/core/ladder.ts +156 -0
- package/src/core/panels.ts +177 -0
- package/src/core/receipt-rules.ts +45 -0
- package/src/core/scrubber-gesture.ts +102 -0
- package/src/core/selection.ts +41 -0
- package/src/core/series.ts +374 -0
- package/src/core/studies.ts +260 -0
- package/src/core/symbol-search.ts +256 -0
- package/src/core/tape.ts +159 -0
- package/src/core/volume-profile.ts +116 -0
- package/src/index.ts +83 -0
- package/src/ladder-keys.ts +83 -0
- package/src/panels-context.ts +46 -0
- package/src/selection-context.ts +38 -0
- package/src/tokens.css +235 -0
package/src/Cursor.tsx
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { Root } from './CursorParts.js';
|
|
2
|
+
|
|
3
|
+
// The parts live in CursorParts.js (a client module). The namespace object is built here, outside it, so a server
|
|
4
|
+
// component can write `Cursor.Root`: a property of an object exported from a client module is not reachable there.
|
|
5
|
+
export * from './CursorParts.js';
|
|
6
|
+
export const Cursor = { Root };
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { createContext, useCallback, useContext, useMemo, useRef, useState, type ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cursor: the one owner of "where in the session are we". Every view (Transport, Scrubber,
|
|
5
|
+
* Ladder, DiffTable) reads this one value instead of keeping its own copy, so linked panels cannot disagree
|
|
6
|
+
* about time. Renders no DOM.
|
|
7
|
+
*
|
|
8
|
+
* The value is the engine's own cursor shape, sequence and timestamp as bigint: an exact integer, never a
|
|
9
|
+
* float. (A wire form with decimal strings belongs at the contract boundary, not in this React state.)
|
|
10
|
+
*
|
|
11
|
+
* Controlled (`value` + `onValueChange`) or uncontrolled (`defaultValue`), the same three-prop pattern the
|
|
12
|
+
* headless libraries use. A controlled Root never changes the value itself: `setValue` only calls
|
|
13
|
+
* `onValueChange`, and the host decides.
|
|
14
|
+
*/
|
|
15
|
+
export type CursorValue =
|
|
16
|
+
| { readonly position: 'initial' }
|
|
17
|
+
| { readonly position: 'event'; readonly sequence: bigint; readonly timestamp: bigint };
|
|
18
|
+
|
|
19
|
+
export type CursorState = 'idle' | 'playing' | 'seeking';
|
|
20
|
+
export type CursorBoundary = 'start' | 'end' | null;
|
|
21
|
+
|
|
22
|
+
export const CURSOR_START: CursorValue = { position: 'initial' };
|
|
23
|
+
|
|
24
|
+
/** True when both are the same place in the session. */
|
|
25
|
+
export function sameCursor(a: CursorValue, b: CursorValue): boolean {
|
|
26
|
+
if (a.position === 'initial' || b.position === 'initial') return a.position === b.position;
|
|
27
|
+
return a.sequence === b.sequence && a.timestamp === b.timestamp;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface CursorRootProps {
|
|
31
|
+
value?: CursorValue;
|
|
32
|
+
defaultValue?: CursorValue;
|
|
33
|
+
onValueChange?: (value: CursorValue) => void;
|
|
34
|
+
/** What the host is doing with the cursor. Exposed as `data-state` to every view that asks for attributes. */
|
|
35
|
+
state?: CursorState;
|
|
36
|
+
/** Set when the cursor is at the start or end of the session. */
|
|
37
|
+
boundary?: CursorBoundary;
|
|
38
|
+
children?: ReactNode;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface CursorContext {
|
|
42
|
+
value: CursorValue;
|
|
43
|
+
setValue: (value: CursorValue) => void;
|
|
44
|
+
state: CursorState;
|
|
45
|
+
boundary: CursorBoundary;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const Context = createContext<CursorContext | null>(null);
|
|
49
|
+
|
|
50
|
+
function Root({ value, defaultValue = CURSOR_START, onValueChange, state = 'idle', boundary = null, children }: CursorRootProps) {
|
|
51
|
+
const controlled = value !== undefined;
|
|
52
|
+
const [inner, setInner] = useState<CursorValue>(defaultValue);
|
|
53
|
+
const current = controlled ? value : inner;
|
|
54
|
+
// The latest callback and mode, read at call time, so `setValue` keeps one identity for the life of the Root.
|
|
55
|
+
const live = useRef({ controlled, onValueChange, current });
|
|
56
|
+
live.current = { controlled, onValueChange, current };
|
|
57
|
+
const setValue = useCallback((next: CursorValue) => {
|
|
58
|
+
const { controlled: isControlled, onValueChange: notify, current: now } = live.current;
|
|
59
|
+
if (sameCursor(now, next)) return;
|
|
60
|
+
if (!isControlled) setInner(next);
|
|
61
|
+
notify?.(next);
|
|
62
|
+
}, []);
|
|
63
|
+
const context = useMemo(() => ({ value: current, setValue, state, boundary }), [current, setValue, state, boundary]);
|
|
64
|
+
return <Context.Provider value={context}>{children}</Context.Provider>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Reads the shared cursor. Throws a plain message when there is no `Cursor.Root` above, rather than guessing a position. */
|
|
68
|
+
export function useCursor(): CursorContext & { attributes: { 'data-state': CursorState; 'data-boundary'?: 'start' | 'end' } } {
|
|
69
|
+
const context = useContext(Context);
|
|
70
|
+
if (!context) throw new Error('useCursor needs a Cursor.Root above it: there is no shared cursor to read.');
|
|
71
|
+
const attributes = context.boundary
|
|
72
|
+
? { 'data-state': context.state, 'data-boundary': context.boundary }
|
|
73
|
+
: { 'data-state': context.state };
|
|
74
|
+
return { ...context, attributes };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export { Root };
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { Root, Plot, Bids, Asks, Axis, Crosshair, Readout, Table } from './DepthProfileParts.js';
|
|
2
|
+
|
|
3
|
+
// The parts live in DepthProfileParts.js (a client module). The namespace object is built here, outside it, so a server
|
|
4
|
+
// component can write `DepthProfile.Root`: a property of an object exported from a client module is not reachable there.
|
|
5
|
+
export * from './DepthProfileParts.js';
|
|
6
|
+
export const DepthProfile = { Root, Plot, Bids, Asks, Axis, Crosshair, Readout, Table };
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { createContext, useCallback, useContext, useId, useMemo, useState, type KeyboardEvent, type PointerEvent, type ReactNode } from 'react';
|
|
2
|
+
import { DEPTH_HEIGHT, DEPTH_WIDTH, depthGeometry, depthLevelId, nextDepthIndex, resolveDepthKey, type DepthGeometry, type DepthLevel } from './core/depth-profile.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* DepthProfile as parts: Root, Plot, Bids, Asks, Axis, Crosshair, Readout, Table. Headless: the
|
|
6
|
+
* parts draw cumulative depth for each side and add `data-side`. A chart alone is a picture, so the same levels are
|
|
7
|
+
* also a Table; a keyboard user moves a crosshair level by level and a Readout says where it is in words.
|
|
8
|
+
*
|
|
9
|
+
* Prices and sizes are exact decimal strings and are printed as given. A tap or click on the Plot puts the crosshair on the
|
|
10
|
+
* nearest level (a touch screen has no arrow keys); the keyboard and the Table are the other ways in.
|
|
11
|
+
*/
|
|
12
|
+
interface DepthContext extends DepthGeometry {
|
|
13
|
+
active: number | null;
|
|
14
|
+
setActive: (index: number | null) => void;
|
|
15
|
+
readoutId: string;
|
|
16
|
+
}
|
|
17
|
+
const Context = createContext<DepthContext | null>(null);
|
|
18
|
+
|
|
19
|
+
function useDepth(part: string): DepthContext {
|
|
20
|
+
const context = useContext(Context);
|
|
21
|
+
if (!context) throw new Error(`DepthProfile.${part} needs a DepthProfile.Root above it.`);
|
|
22
|
+
return context;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface DepthProfileRootProps {
|
|
26
|
+
/** Best price first on each side. */
|
|
27
|
+
levels: readonly DepthLevel[];
|
|
28
|
+
/** Controlled crosshair: the `side:price` id of the level, or null for none. */
|
|
29
|
+
active?: string | null;
|
|
30
|
+
onActiveChange?: (id: string | null) => void;
|
|
31
|
+
className?: string;
|
|
32
|
+
'aria-label'?: string;
|
|
33
|
+
children?: ReactNode;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function Root({ levels: given, active, onActiveChange, className, 'aria-label': label = 'Cumulative order-book depth', children }: DepthProfileRootProps) {
|
|
37
|
+
const levels = useMemo(() => (Array.isArray(given) ? given.filter((level) => level != null) : []), [given]); // no list, or a hole in it, is left out
|
|
38
|
+
const geometry = useMemo(() => depthGeometry(levels), [levels]);
|
|
39
|
+
const [inner, setInner] = useState<string | null>(null);
|
|
40
|
+
const controlled = active !== undefined;
|
|
41
|
+
const activeId = controlled ? active : inner;
|
|
42
|
+
const index = activeId === null ? -1 : geometry.ordered.findIndex((l) => depthLevelId(l) === activeId);
|
|
43
|
+
const readoutId = useId();
|
|
44
|
+
const setActive = useCallback((next: number | null) => {
|
|
45
|
+
const id = next === null ? null : depthLevelId(geometry.ordered[next]);
|
|
46
|
+
if (!controlled) setInner(id);
|
|
47
|
+
onActiveChange?.(id);
|
|
48
|
+
}, [geometry, controlled, onActiveChange]);
|
|
49
|
+
const context = useMemo<DepthContext>(() => ({ ...geometry, active: index < 0 ? null : index, setActive, readoutId }), [geometry, index, setActive, readoutId]);
|
|
50
|
+
return (
|
|
51
|
+
<Context.Provider value={context}>
|
|
52
|
+
<figure className={className} aria-label={label}>{children}</figure>
|
|
53
|
+
</Context.Provider>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The drawing. One tab stop; a tap or click picks the nearest level; ArrowLeft/Right move the crosshair, Home/End jump to the ends, Escape clears it. */
|
|
58
|
+
function Plot({ className, label = 'Bid and ask cumulative depth', children }: { className?: string; label?: string; children?: ReactNode }) {
|
|
59
|
+
const { ordered, active, setActive, readoutId, x } = useDepth('Plot');
|
|
60
|
+
const onKeyDown = (event: KeyboardEvent<SVGSVGElement>) => {
|
|
61
|
+
const action = resolveDepthKey(event);
|
|
62
|
+
if (!action) return;
|
|
63
|
+
event.preventDefault();
|
|
64
|
+
event.stopPropagation(); // arrows, Home and End are also transport shortcuts on the page
|
|
65
|
+
setActive(nextDepthIndex(ordered, active, action));
|
|
66
|
+
};
|
|
67
|
+
const onPointerDown = (event: PointerEvent<SVGSVGElement>) => {
|
|
68
|
+
const svg = event.currentTarget;
|
|
69
|
+
const matrix = svg.getScreenCTM();
|
|
70
|
+
if (!ordered.length || !matrix) return;
|
|
71
|
+
// Through the inverse screen transform, so the letterboxing of preserveAspectRatio is accounted for.
|
|
72
|
+
const point = svg.createSVGPoint();
|
|
73
|
+
point.x = event.clientX;
|
|
74
|
+
point.y = event.clientY;
|
|
75
|
+
const at = point.matrixTransform(matrix.inverse()).x;
|
|
76
|
+
let nearest = 0;
|
|
77
|
+
for (let i = 1; i < ordered.length; i++) {
|
|
78
|
+
if (Math.abs(x(ordered[i].price) - at) < Math.abs(x(ordered[nearest].price) - at)) nearest = i;
|
|
79
|
+
}
|
|
80
|
+
setActive(nearest);
|
|
81
|
+
};
|
|
82
|
+
return (
|
|
83
|
+
<svg className={className} viewBox={`0 0 ${DEPTH_WIDTH} ${DEPTH_HEIGHT}`} role="group" aria-label={label} aria-describedby={readoutId} tabIndex={0} onKeyDown={onKeyDown} onPointerDown={onPointerDown}>
|
|
84
|
+
{children}
|
|
85
|
+
</svg>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function Bids({ className }: { className?: string }) {
|
|
90
|
+
const { bidPoints } = useDepth('Bids');
|
|
91
|
+
return <polygon className={className} data-part="depth-bids" data-side="bid" points={bidPoints} />;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function Asks({ className }: { className?: string }) {
|
|
95
|
+
const { askPoints } = useDepth('Asks');
|
|
96
|
+
return <polygon className={className} data-part="depth-asks" data-side="ask" points={askPoints} />;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The lowest and highest price on the plot, as recorded, at its two ends. */
|
|
100
|
+
function Axis({ className }: { className?: string }) {
|
|
101
|
+
const { ordered } = useDepth('Axis');
|
|
102
|
+
if (ordered.length === 0) return null;
|
|
103
|
+
const low = ordered[0].price;
|
|
104
|
+
const high = ordered[ordered.length - 1].price;
|
|
105
|
+
return (
|
|
106
|
+
<g className={className} data-part="depth-axis" aria-hidden="true">
|
|
107
|
+
<text x={0} y={DEPTH_HEIGHT - 2} textAnchor="start">{low}</text>
|
|
108
|
+
<text x={DEPTH_WIDTH} y={DEPTH_HEIGHT - 2} textAnchor="end">{high}</text>
|
|
109
|
+
</g>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** A line and a point at the active level; nothing is drawn while there is none. */
|
|
114
|
+
function Crosshair({ className }: { className?: string }) {
|
|
115
|
+
const { ordered, active, x, y } = useDepth('Crosshair');
|
|
116
|
+
if (active === null) return null;
|
|
117
|
+
const level = ordered[active];
|
|
118
|
+
return (
|
|
119
|
+
<g className={className} data-part="depth-crosshair" data-side={level.side} aria-hidden="true">
|
|
120
|
+
<line x1={x(level.price)} x2={x(level.price)} y1={0} y2={DEPTH_HEIGHT} />
|
|
121
|
+
<circle cx={x(level.price)} cy={y(level.cumulative)} r={4} />
|
|
122
|
+
</g>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The active level in words, announced politely. The host may pass its own wording. */
|
|
127
|
+
function Readout({ className, format }: { className?: string; format?: (level: DepthLevel) => string }) {
|
|
128
|
+
const { ordered, active, readoutId } = useDepth('Readout');
|
|
129
|
+
const level = active === null ? null : ordered[active];
|
|
130
|
+
const text = level === null ? '' : format ? format(level) : `${level.side === 'bid' ? 'Bid' : 'Ask'} ${level.price}, cumulative size ${level.cumulative}`;
|
|
131
|
+
return <output id={readoutId} className={className} aria-live="polite" data-side={level?.side}>{text}</output>;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The same levels as a table: the text equivalent of the plot. Prices run low to high. */
|
|
135
|
+
function Table({ className, caption = 'Cumulative depth by price level' }: { className?: string; caption?: string }) {
|
|
136
|
+
const { ordered, active } = useDepth('Table');
|
|
137
|
+
return (
|
|
138
|
+
<table className={className}>
|
|
139
|
+
<caption>{caption}</caption>
|
|
140
|
+
<thead><tr><th scope="col">Side</th><th scope="col">Price</th><th scope="col">Cumulative size</th></tr></thead>
|
|
141
|
+
<tbody>
|
|
142
|
+
{ordered.map((level, i) => (
|
|
143
|
+
<tr key={depthLevelId(level)} data-side={level.side} aria-current={i === active ? 'true' : undefined}>
|
|
144
|
+
<td>{level.side === 'bid' ? 'Bid' : 'Ask'}</td>
|
|
145
|
+
<td data-mt-numeric>{level.price}</td>
|
|
146
|
+
<td data-mt-numeric>{level.cumulative}</td>
|
|
147
|
+
</tr>
|
|
148
|
+
))}
|
|
149
|
+
</tbody>
|
|
150
|
+
</table>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export { Root, Plot, Bids, Asks, Axis, Crosshair, Readout, Table };
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { Root, Header, Row, Price, QtyA, QtyB, Delta, ChangeMark } from './DiffTableParts.js';
|
|
2
|
+
|
|
3
|
+
// The parts live in DiffTableParts.js (a client module). The namespace object is built here, outside it, so a server
|
|
4
|
+
// component can write `DiffTable.Root`: a property of an object exported from a client module is not reachable there.
|
|
5
|
+
export * from './DiffTableParts.js';
|
|
6
|
+
export const DiffTable = { Root, Header, Row, Price, QtyA, QtyB, Delta, ChangeMark };
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { createContext, useCallback, useContext, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
|
|
2
|
+
import { primitiveAttributes } from './attributes.js';
|
|
3
|
+
import { DIFF_CHANGE_LABEL, diffRowId, holdDiffOrder, nextDiffIndex, nextDiffSort, resolveDiffKey, signedDelta, sortDiffRows, type DiffChange, type DiffSort, type DiffTableRow } from './core/diff-table.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* DiffTable as parts: Root, Header, Row, Price, QtyA, QtyB, Delta, ChangeMark. Headless: the parts
|
|
7
|
+
* are real table elements and add `data-side`, `data-diff` and `data-sorted`; the host chooses the columns.
|
|
8
|
+
*
|
|
9
|
+
* Rows hold their order while the pointer is over the table. Keys: arrows, Home and End move between rows, Enter or
|
|
10
|
+
* Space picks the row, `a` and `b` ask the host to focus the A or B cursor control (the pair itself stays with the
|
|
11
|
+
* host's Cursor), `s` cycles the sort. Values are exact decimal strings and are printed as given.
|
|
12
|
+
*/
|
|
13
|
+
interface DiffContext {
|
|
14
|
+
activeId: string | null;
|
|
15
|
+
firstId: string | null;
|
|
16
|
+
setActive: (id: string) => void;
|
|
17
|
+
}
|
|
18
|
+
const Context = createContext<DiffContext | null>(null);
|
|
19
|
+
|
|
20
|
+
function useDiff(part: string): DiffContext {
|
|
21
|
+
const context = useContext(Context);
|
|
22
|
+
if (!context) throw new Error(`DiffTable.${part} needs a DiffTable.Root above it.`);
|
|
23
|
+
return context;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface DiffTableRootProps {
|
|
27
|
+
rows: readonly DiffTableRow[];
|
|
28
|
+
/** Controlled sort; leave out for an uncontrolled table that starts as `book`. */
|
|
29
|
+
sort?: DiffSort;
|
|
30
|
+
onSortChange?: (sort: DiffSort) => void;
|
|
31
|
+
/** Enter or Space on a focused row. */
|
|
32
|
+
onSelect?: (row: DiffTableRow) => void;
|
|
33
|
+
/** The `a` or `b` key: focus that cursor control. */
|
|
34
|
+
onFocusCursor?: (which: 'a' | 'b') => void;
|
|
35
|
+
className?: string;
|
|
36
|
+
caption?: string;
|
|
37
|
+
captionClassName?: string;
|
|
38
|
+
/** The parts, given the rows in the order to show them (sorted, and held while the pointer is over the table). */
|
|
39
|
+
children: (rows: DiffTableRow[]) => ReactNode;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function Root({ rows, sort, onSortChange, onSelect, onFocusCursor, className, caption, captionClassName, children }: DiffTableRootProps) {
|
|
43
|
+
const [innerSort, setInnerSort] = useState<DiffSort>('book');
|
|
44
|
+
const controlled = sort !== undefined;
|
|
45
|
+
const current = controlled ? sort : innerSort;
|
|
46
|
+
const [held, setHeld] = useState<string[] | null>(null);
|
|
47
|
+
const [activeId, setActiveId] = useState<string | null>(null);
|
|
48
|
+
const table = useRef<HTMLTableElement>(null);
|
|
49
|
+
|
|
50
|
+
const sorted = useMemo(() => sortDiffRows(rows, current), [rows, current]);
|
|
51
|
+
const shown = useMemo(() => (held ? holdDiffOrder(held, sorted) : sorted), [held, sorted]);
|
|
52
|
+
const firstId = shown.length ? diffRowId(shown[0]) : null;
|
|
53
|
+
|
|
54
|
+
const changeSort = useCallback(() => {
|
|
55
|
+
const next = nextDiffSort(current);
|
|
56
|
+
if (!controlled) setInnerSort(next);
|
|
57
|
+
setHeld(null); // asking for a new order is the one time the rows may move under the pointer
|
|
58
|
+
onSortChange?.(next);
|
|
59
|
+
}, [current, controlled, onSortChange]);
|
|
60
|
+
|
|
61
|
+
const focusRow = (id: string) => {
|
|
62
|
+
setActiveId(id);
|
|
63
|
+
for (const tr of table.current?.querySelectorAll<HTMLElement>('tbody tr[data-diff-row]') ?? []) {
|
|
64
|
+
if (tr.dataset.diffRow === id) { tr.focus(); return; }
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const onKeyDown = (event: KeyboardEvent<HTMLTableElement>) => {
|
|
69
|
+
const action = resolveDiffKey(event.nativeEvent);
|
|
70
|
+
if (!action) return;
|
|
71
|
+
const onRow = (event.target as HTMLElement).closest('tr[data-diff-row]') as HTMLElement | null;
|
|
72
|
+
if (action.type === 'select') {
|
|
73
|
+
if (event.target !== onRow || !onRow) return; // Enter and Space on the pick button are the button's own
|
|
74
|
+
const row = shown.find((r) => diffRowId(r) === onRow.dataset.diffRow);
|
|
75
|
+
if (!row || !onSelect) return;
|
|
76
|
+
event.preventDefault();
|
|
77
|
+
onSelect(row);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (action.type === 'move' && !onRow) return;
|
|
81
|
+
event.preventDefault();
|
|
82
|
+
event.stopPropagation(); // these letters and arrows are also page shortcuts
|
|
83
|
+
if (action.type === 'move') {
|
|
84
|
+
const index = shown.findIndex((r) => diffRowId(r) === onRow?.dataset.diffRow);
|
|
85
|
+
const to = nextDiffIndex(shown.length, index, action.to);
|
|
86
|
+
if (to >= 0) focusRow(diffRowId(shown[to]));
|
|
87
|
+
} else if (action.type === 'cursor') onFocusCursor?.(action.which);
|
|
88
|
+
else changeSort();
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const context = useMemo<DiffContext>(() => ({ activeId, firstId, setActive: setActiveId }), [activeId, firstId]);
|
|
92
|
+
return (
|
|
93
|
+
<Context.Provider value={context}>
|
|
94
|
+
<table
|
|
95
|
+
ref={table}
|
|
96
|
+
className={className}
|
|
97
|
+
data-mt=""
|
|
98
|
+
data-sorted={current}
|
|
99
|
+
onPointerEnter={() => setHeld(sorted.map(diffRowId))}
|
|
100
|
+
onPointerLeave={() => setHeld(null)}
|
|
101
|
+
onKeyDown={onKeyDown}
|
|
102
|
+
>
|
|
103
|
+
{caption && <caption className={captionClassName}>{caption}</caption>}
|
|
104
|
+
{children(shown)}
|
|
105
|
+
</table>
|
|
106
|
+
</Context.Provider>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The header row. Put the `<th scope="col">` cells inside. */
|
|
111
|
+
function Header({ className, children }: { className?: string; children: ReactNode }) {
|
|
112
|
+
return <thead><tr className={className}>{children}</tr></thead>;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** One level. A single tab stop for the table: the active row, or the first. The pointer does not move it. */
|
|
116
|
+
function Row({ row, selected, className, children }: { row: DiffTableRow; selected?: boolean; className?: string; children: ReactNode }) {
|
|
117
|
+
const { activeId, firstId, setActive } = useDiff('Row');
|
|
118
|
+
const id = diffRowId(row);
|
|
119
|
+
const stop = activeId === null ? id === firstId : id === activeId;
|
|
120
|
+
const { 'data-mt': _scope, ...state } = primitiveAttributes({ side: row.side });
|
|
121
|
+
return (
|
|
122
|
+
<tr
|
|
123
|
+
className={className}
|
|
124
|
+
{...state}
|
|
125
|
+
data-diff={row.change === 'unchanged' ? undefined : row.change}
|
|
126
|
+
data-diff-row={id}
|
|
127
|
+
aria-selected={selected === undefined ? undefined : selected}
|
|
128
|
+
tabIndex={stop ? 0 : -1}
|
|
129
|
+
onFocus={() => setActive(id)}
|
|
130
|
+
>
|
|
131
|
+
{children}
|
|
132
|
+
</tr>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The row header cell: the price as recorded. Pass children to put a control inside. */
|
|
137
|
+
function Price({ row, className, children }: { row: DiffTableRow; className?: string; children?: ReactNode }) {
|
|
138
|
+
return <th scope="row" className={className} data-mt-numeric="">{children ?? row.price}</th>;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function QtyA({ row, className }: { row: DiffTableRow; className?: string }) {
|
|
142
|
+
return <td className={className} data-mt-numeric="">{row.qtyA}</td>;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function QtyB({ row, className }: { row: DiffTableRow; className?: string }) {
|
|
146
|
+
return <td className={className} data-mt-numeric="">{row.qtyB}</td>;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** qtyB minus qtyA with its sign shown. */
|
|
150
|
+
function Delta({ row, className }: { row: DiffTableRow; className?: string }) {
|
|
151
|
+
return <td className={className} data-mt-numeric="">{signedDelta(row.delta)}</td>;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The change in words; tokens.css adds the mark (+, −, ±) so it never rests on colour alone. */
|
|
155
|
+
function ChangeMark({ change, className, label }: { change: DiffChange; className?: string; label?: string }) {
|
|
156
|
+
const attributes = primitiveAttributes(change === 'unchanged' ? {} : { diff: change });
|
|
157
|
+
return <span className={className} {...attributes}>{label ?? DIFF_CHANGE_LABEL[change]}</span>;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export { Root, Header, Row, Price, QtyA, QtyB, Delta, ChangeMark };
|
package/src/Drawings.tsx
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { Root, Layer, Toolbar, List, Remove, Undo, Lock, Edit } from './DrawingsParts.js';
|
|
2
|
+
|
|
3
|
+
// The parts live in DrawingsParts.js (a client module). The namespace object is built here, outside it, so a server
|
|
4
|
+
// component can write `Drawings.Root`: a property of an object exported from a client module is not reachable there.
|
|
5
|
+
export * from './DrawingsParts.js';
|
|
6
|
+
export const Drawings = { Root, Layer, Toolbar, List, Remove, Undo, Lock, Edit };
|