@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
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react';
|
|
4
|
+
import { clampFraction, fractionFromPointer, isActiveScrubPointer, isPrimaryScrubPointer, reduceScrubGesture, resolveScrubKey } from './core/scrubber-gesture.js';
|
|
5
|
+
const Context = createContext(null);
|
|
6
|
+
/** The live scrub state, for a readout that must follow the preview. Throws a plain message outside a `Scrubber.Root`. */
|
|
7
|
+
export function useScrubber() {
|
|
8
|
+
const context = useContext(Context);
|
|
9
|
+
if (!context)
|
|
10
|
+
throw new Error('useScrubber needs a Scrubber.Root above it: there is no scrub state to read.');
|
|
11
|
+
const { fraction, committedFraction, dragging, loading } = context;
|
|
12
|
+
return { fraction, committedFraction, dragging, loading };
|
|
13
|
+
}
|
|
14
|
+
function Root({ fraction: committedInput, loading = false, onBeginScrub, onEndScrub, onCommit, onStep, children }) {
|
|
15
|
+
const committedFraction = committedInput;
|
|
16
|
+
const [dragFraction, setDragFraction] = useState(null);
|
|
17
|
+
// Which pointer owns the drag: a ref, because the very next pointer event of the same gesture must read it
|
|
18
|
+
// before any state update has been applied.
|
|
19
|
+
const activePointerId = useRef(null);
|
|
20
|
+
const live = useRef({ dragFraction, loading, onBeginScrub, onEndScrub, onCommit, onStep, committedFraction });
|
|
21
|
+
live.current = { dragFraction, loading, onBeginScrub, onEndScrub, onCommit, onStep, committedFraction };
|
|
22
|
+
const apply = useCallback((event) => {
|
|
23
|
+
const now = live.current;
|
|
24
|
+
const result = reduceScrubGesture(event, now.dragFraction);
|
|
25
|
+
// The ref is updated here as well as at render, so two events in one frame still see the latest preview.
|
|
26
|
+
live.current = { ...now, dragFraction: result.dragFraction };
|
|
27
|
+
setDragFraction(result.dragFraction);
|
|
28
|
+
if (result.begin)
|
|
29
|
+
now.onBeginScrub?.();
|
|
30
|
+
if (result.end)
|
|
31
|
+
now.onEndScrub?.();
|
|
32
|
+
if (result.commit !== null)
|
|
33
|
+
now.onCommit(result.commit);
|
|
34
|
+
}, []);
|
|
35
|
+
const fractionAt = (event) => {
|
|
36
|
+
const rect = event.currentTarget.getBoundingClientRect();
|
|
37
|
+
return fractionFromPointer(event.clientX, rect.left, rect.width);
|
|
38
|
+
};
|
|
39
|
+
const trackHandlers = useMemo(() => ({
|
|
40
|
+
onPointerDown(event) {
|
|
41
|
+
if (live.current.loading)
|
|
42
|
+
return;
|
|
43
|
+
// Gate before capturing: a right-click must reach the browser's own menu, and a second finger must not start a scrub.
|
|
44
|
+
if (!isPrimaryScrubPointer(event))
|
|
45
|
+
return;
|
|
46
|
+
// Without this, a press after a text selection starts a native drag and the browser cancels the pointer.
|
|
47
|
+
event.preventDefault();
|
|
48
|
+
// preventDefault also stops the browser focusing what was pressed, so a press on the thumb focuses it here.
|
|
49
|
+
const pressed = event.target instanceof Element ? event.target.closest('[tabindex]') : null;
|
|
50
|
+
if (pressed && event.currentTarget.contains(pressed))
|
|
51
|
+
pressed.focus({ preventScroll: true });
|
|
52
|
+
activePointerId.current = event.pointerId;
|
|
53
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
54
|
+
apply({ type: 'down', fraction: fractionAt(event) });
|
|
55
|
+
},
|
|
56
|
+
onPointerMove(event) {
|
|
57
|
+
if (!isActiveScrubPointer(event.pointerId, activePointerId.current))
|
|
58
|
+
return;
|
|
59
|
+
apply({ type: 'move', fraction: fractionAt(event) });
|
|
60
|
+
},
|
|
61
|
+
onPointerUp(event) {
|
|
62
|
+
if (!isActiveScrubPointer(event.pointerId, activePointerId.current))
|
|
63
|
+
return;
|
|
64
|
+
activePointerId.current = null;
|
|
65
|
+
// The release's own coordinate, read the same way as a move; the last move may lag it.
|
|
66
|
+
apply({ type: 'up', fraction: fractionAt(event), loading: live.current.loading });
|
|
67
|
+
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
68
|
+
},
|
|
69
|
+
onPointerCancel(event) {
|
|
70
|
+
if (!isActiveScrubPointer(event.pointerId, activePointerId.current))
|
|
71
|
+
return;
|
|
72
|
+
activePointerId.current = null;
|
|
73
|
+
apply({ type: 'cancel' });
|
|
74
|
+
},
|
|
75
|
+
}), [apply]);
|
|
76
|
+
const onThumbKeyDown = useCallback((event) => {
|
|
77
|
+
const now = live.current;
|
|
78
|
+
if (now.loading)
|
|
79
|
+
return;
|
|
80
|
+
const action = resolveScrubKey(event.key, now.committedFraction);
|
|
81
|
+
if (!action)
|
|
82
|
+
return;
|
|
83
|
+
event.preventDefault();
|
|
84
|
+
if (action.type === 'step') {
|
|
85
|
+
// A host may also listen for arrows on the window; letting this bubble would step twice.
|
|
86
|
+
event.stopPropagation();
|
|
87
|
+
now.onStep(action.direction);
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
now.onCommit(action.fraction);
|
|
91
|
+
}
|
|
92
|
+
}, []);
|
|
93
|
+
const dragging = dragFraction !== null;
|
|
94
|
+
const context = useMemo(() => ({
|
|
95
|
+
fraction: dragFraction ?? committedFraction,
|
|
96
|
+
committedFraction,
|
|
97
|
+
dragging,
|
|
98
|
+
loading,
|
|
99
|
+
trackHandlers,
|
|
100
|
+
onThumbKeyDown,
|
|
101
|
+
}), [dragFraction, committedFraction, dragging, loading, trackHandlers, onThumbKeyDown]);
|
|
102
|
+
return _jsx(Context.Provider, { value: context, children: children });
|
|
103
|
+
}
|
|
104
|
+
function useContextOrThrow(part) {
|
|
105
|
+
const context = useContext(Context);
|
|
106
|
+
if (!context)
|
|
107
|
+
throw new Error(`Scrubber.${part} needs a Scrubber.Root above it.`);
|
|
108
|
+
return context;
|
|
109
|
+
}
|
|
110
|
+
/** The pressable area. Pointer handlers live here; marks and the thumb go inside it. */
|
|
111
|
+
function Track({ className, children }) {
|
|
112
|
+
const { trackHandlers, dragging, loading } = useContextOrThrow('Track');
|
|
113
|
+
return (_jsx("div", { className: className, ...trackHandlers, "data-part": "scrubber-track", "data-state": dragging ? 'dragging' : 'idle', "data-disabled": loading ? '' : undefined, children: children }));
|
|
114
|
+
}
|
|
115
|
+
const percent = (fraction) => `${(clampFraction(fraction) * 100).toFixed(3)}%`;
|
|
116
|
+
/** The one tab stop: `role="slider"`, positioned at the live fraction. Arrows step, Home/End/PageUp/PageDown jump. */
|
|
117
|
+
function Thumb({ className, label = 'Session position', valueText, describedBy }) {
|
|
118
|
+
const { fraction, loading, onThumbKeyDown } = useContextOrThrow('Thumb');
|
|
119
|
+
return (_jsx("div", { className: className, "data-part": "scrubber-thumb", role: "slider", tabIndex: 0, "aria-label": label, "aria-describedby": describedBy, "aria-disabled": loading || undefined, "aria-valuemin": 0, "aria-valuemax": 100, "aria-valuenow": Math.round(clampFraction(fraction) * 100), "aria-valuetext": valueText, style: { left: percent(fraction) }, onKeyDown: onThumbKeyDown }));
|
|
120
|
+
}
|
|
121
|
+
/** A position on the track that informs but cannot be activated (a declared gap, a verified checkpoint). */
|
|
122
|
+
function Mark({ at, span, label, kind, title, tabIndex, id, className }) {
|
|
123
|
+
const style = span === undefined ? { left: percent(at) } : { left: percent(at), width: percent(Math.max(0, span)) };
|
|
124
|
+
return _jsx("span", { id: id, className: className, "data-part": "scrubber-mark", "data-kind": kind, style: style, role: "img", tabIndex: tabIndex, title: title, "aria-label": label });
|
|
125
|
+
}
|
|
126
|
+
export { Root, Track, Thumb, Mark };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Series as StudySeries } from './core/studies.js';
|
|
2
|
+
import { Root, Layer, SeriesRoot, Select, Options } from './SelectionParts.js';
|
|
3
|
+
export * from './SelectionParts.js';
|
|
4
|
+
export declare const Selection: {
|
|
5
|
+
Root: typeof Root;
|
|
6
|
+
Layer: typeof Layer;
|
|
7
|
+
};
|
|
8
|
+
export type Series = StudySeries;
|
|
9
|
+
export declare const Series: {
|
|
10
|
+
Root: typeof SeriesRoot;
|
|
11
|
+
Select: typeof Select;
|
|
12
|
+
Options: typeof Options;
|
|
13
|
+
};
|
|
14
|
+
//# sourceMappingURL=Selection.d.ts.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { Root, Layer, SeriesRoot, Select, Options } from './SelectionParts.js';
|
|
2
|
+
// The parts live in SelectionParts.js (a client module). The namespace objects are built here, outside it, so a server
|
|
3
|
+
// component can write `Selection.Root` and `Series.Root`: a property of an object exported from a client module is not
|
|
4
|
+
// reachable there.
|
|
5
|
+
export * from './SelectionParts.js';
|
|
6
|
+
export const Selection = { Root, Layer };
|
|
7
|
+
export const Series = { Root: SeriesRoot, Select, Options };
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import { type NameProps } from './accessible-name.js';
|
|
3
|
+
import { type SelectionContextValue } from './selection-context.js';
|
|
4
|
+
import { type ChartSelection } from './core/selection.js';
|
|
5
|
+
/**
|
|
6
|
+
* Selection as parts: Selection.Root, Selection.Layer, and Series.Root, Series.Select, Series.Options. One thing is
|
|
7
|
+
* selected on a chart at a time: a drawing, the bar series or a study. Headless: the parts set `data-part` and
|
|
8
|
+
* `data-state='selected'`; the host gives them a look and fills the options panel with its own controls.
|
|
9
|
+
*
|
|
10
|
+
* Root holds the selection (controlled or not) and says each change in a polite live region. Everything is opt-in:
|
|
11
|
+
* without a Root above them, Drawings and Studies behave as they always did.
|
|
12
|
+
*/
|
|
13
|
+
export interface SelectionRootProps {
|
|
14
|
+
/** Controlled selection; without it Root keeps its own. */
|
|
15
|
+
selection?: ChartSelection;
|
|
16
|
+
onSelectionChange?: (selection: ChartSelection) => void;
|
|
17
|
+
children?: ReactNode;
|
|
18
|
+
}
|
|
19
|
+
/** Holds the selection and announces changes. Wrap the Chart, the Drawings.Root and the series and study controls in it. */
|
|
20
|
+
declare function Root({ selection, onSelectionChange, children }: SelectionRootProps): import("react").JSX.Element;
|
|
21
|
+
declare function useSelection(part: string): SelectionContextValue;
|
|
22
|
+
/**
|
|
23
|
+
* The tap and Escape rules for the chart. Put it inside `Chart.Plot`, next to `Drawings.Layer` if there is one. It draws
|
|
24
|
+
* nothing. A tap (not a drag) on clear chart puts a selection away; with nothing selected and a `Series.Root` mounted
|
|
25
|
+
* it selects the series; a tap on a study's line or band (a `Study.Line` or `Study.Band` with an `id`) selects that study.
|
|
26
|
+
* Escape, with the chart focused, clears the selection (a half-placed drawing or an armed tool take the first Escape).
|
|
27
|
+
* A press on a drawing is Drawings.Layer's, and a drag is the chart panning, so neither counts.
|
|
28
|
+
*/
|
|
29
|
+
declare function Layer(): import("react").JSX.Element;
|
|
30
|
+
export interface SeriesRootProps {
|
|
31
|
+
/** The selection id of the series. Unique on the chart. */
|
|
32
|
+
id: string;
|
|
33
|
+
/** What the series is called: the button's name, the panel's label and the live-region text. */
|
|
34
|
+
label: string;
|
|
35
|
+
children?: ReactNode;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Says that a bar series exists, so a tap on clear chart can select it (with a Selection.Layer in the plot). It draws
|
|
39
|
+
* nothing. Put it above the chart and the Series.Select and Series.Options. The selected series shows
|
|
40
|
+
* `data-state='selected'` on its `Chart.Candles`.
|
|
41
|
+
*/
|
|
42
|
+
declare function SeriesRoot({ id, label, children }: SeriesRootProps): import("react").JSX.Element;
|
|
43
|
+
/**
|
|
44
|
+
* A button that selects the series, and puts the selection away when pressed again: the keyboard and touch path to what
|
|
45
|
+
* a tap on clear chart does. `aria-pressed` carries the state. Its name is the series label unless you give a `label`
|
|
46
|
+
* or children.
|
|
47
|
+
*/
|
|
48
|
+
declare function Select({ className, icon, label, hideLabel, children }: NameProps & {
|
|
49
|
+
className?: string;
|
|
50
|
+
children?: ReactNode;
|
|
51
|
+
}): import("react").JSX.Element;
|
|
52
|
+
/**
|
|
53
|
+
* A headless panel for the series' options. It shows its children only while the series is selected. The primitives
|
|
54
|
+
* ship no option fields: put your own controls inside (an interval list, an instrument picker) and decide what a change
|
|
55
|
+
* means. `label` names the group; the default is the series label and "options".
|
|
56
|
+
*/
|
|
57
|
+
declare function Options({ className, label, children }: {
|
|
58
|
+
className?: string;
|
|
59
|
+
label?: string;
|
|
60
|
+
children?: ReactNode;
|
|
61
|
+
}): import("react").JSX.Element | null;
|
|
62
|
+
export { Root, Layer, SeriesRoot, Select, Options, useSelection };
|
|
63
|
+
//# sourceMappingURL=SelectionParts.d.ts.map
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
|
4
|
+
import { buttonFace } from './accessible-name.js';
|
|
5
|
+
import { useChart } from './ChartParts.js';
|
|
6
|
+
import { SelectionContext, SeriesContext } from './selection-context.js';
|
|
7
|
+
import { describeSelection, sameSelection, selectionAfterEscape, selectionAfterTap } from './core/selection.js';
|
|
8
|
+
// Visually hidden, still announced. Inline so the live region works without a stylesheet.
|
|
9
|
+
const HIDDEN = { position: 'absolute', width: 1, height: 1, margin: -1, padding: 0, overflow: 'hidden', clip: 'rect(0 0 0 0)', whiteSpace: 'nowrap', border: 0 };
|
|
10
|
+
/** Holds the selection and announces changes. Wrap the Chart, the Drawings.Root and the series and study controls in it. */
|
|
11
|
+
function Root({ selection, onSelectionChange, children }) {
|
|
12
|
+
const [inner, setInner] = useState(null);
|
|
13
|
+
const current = selection !== undefined ? selection : inner;
|
|
14
|
+
// `latest` is the selection as the host supplied it, plus any change requested in the event being handled, so two parts
|
|
15
|
+
// agreeing on one tap report once. It goes back to the rendered value as soon as the event is over: a controlled host
|
|
16
|
+
// that declines a request is never left with a selection it did not accept.
|
|
17
|
+
const latest = useRef(current);
|
|
18
|
+
const rendered = useRef(current);
|
|
19
|
+
latest.current = current;
|
|
20
|
+
rendered.current = current;
|
|
21
|
+
const select = useCallback((next) => {
|
|
22
|
+
if (sameSelection(latest.current, next))
|
|
23
|
+
return;
|
|
24
|
+
latest.current = next;
|
|
25
|
+
queueMicrotask(() => { latest.current = rendered.current; });
|
|
26
|
+
if (selection === undefined)
|
|
27
|
+
setInner(next);
|
|
28
|
+
onSelectionChange?.(next);
|
|
29
|
+
}, [selection, onSelectionChange]);
|
|
30
|
+
const currentRef = useCallback(() => latest.current, []);
|
|
31
|
+
const [series, setSeries] = useState(null);
|
|
32
|
+
const selectRef = useRef(select);
|
|
33
|
+
selectRef.current = select;
|
|
34
|
+
const mounted = useRef(null);
|
|
35
|
+
const registerSeries = useCallback((next) => {
|
|
36
|
+
mounted.current = next;
|
|
37
|
+
setSeries(next);
|
|
38
|
+
return () => {
|
|
39
|
+
if (mounted.current === next)
|
|
40
|
+
mounted.current = null;
|
|
41
|
+
setSeries((was) => (was && was.id === next.id ? null : was));
|
|
42
|
+
// A series that goes away cannot stay selected. A new label, or React replaying the effect, registers again in the
|
|
43
|
+
// same commit, so only a series still gone after that is removed.
|
|
44
|
+
queueMicrotask(() => {
|
|
45
|
+
const now = latest.current;
|
|
46
|
+
if (mounted.current?.id !== next.id && now && now.kind === 'series' && now.id === next.id)
|
|
47
|
+
selectRef.current(null);
|
|
48
|
+
});
|
|
49
|
+
};
|
|
50
|
+
}, []);
|
|
51
|
+
const [names, setNames] = useState({});
|
|
52
|
+
const namesRef = useRef(names);
|
|
53
|
+
namesRef.current = names;
|
|
54
|
+
const setName = useCallback((kind, id, name) => {
|
|
55
|
+
setNames((was) => (was[kind]?.[id] === name ? was : { ...was, [kind]: { ...was[kind], [id]: name } }));
|
|
56
|
+
return () => { };
|
|
57
|
+
}, []);
|
|
58
|
+
const guard = useRef(null);
|
|
59
|
+
const value = useMemo(() => ({ selection: current, select, current: currentRef, series, registerSeries, setName, guard }), [current, select, currentRef, series, registerSeries, setName]);
|
|
60
|
+
const [status, setStatus] = useState('');
|
|
61
|
+
const seen = useRef(current === null ? '' : `${current.kind}:${current.id}`);
|
|
62
|
+
const usedId = useRef(false); // the last announcement had no name yet and said the id
|
|
63
|
+
const key = current === null ? '' : `${current.kind}:${current.id}`;
|
|
64
|
+
const nameNow = current === null ? undefined : names[current.kind]?.[current.id];
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (seen.current === key && !(usedId.current && nameNow !== undefined))
|
|
67
|
+
return;
|
|
68
|
+
seen.current = key;
|
|
69
|
+
usedId.current = current !== null && nameNow === undefined;
|
|
70
|
+
setStatus(describeSelection(current, names));
|
|
71
|
+
}, [key, current, names, nameNow]);
|
|
72
|
+
return (_jsxs(SelectionContext.Provider, { value: value, children: [children, _jsx("span", { "data-part": "selection-status", role: "status", "aria-live": "polite", style: HIDDEN, children: status })] }));
|
|
73
|
+
}
|
|
74
|
+
function useSelection(part) {
|
|
75
|
+
const context = useContext(SelectionContext);
|
|
76
|
+
if (!context)
|
|
77
|
+
throw new Error(`${part} needs a Selection.Root above it.`);
|
|
78
|
+
return context;
|
|
79
|
+
}
|
|
80
|
+
const TAP_SLOP = 6;
|
|
81
|
+
const TAP_MS = 600;
|
|
82
|
+
/**
|
|
83
|
+
* The tap and Escape rules for the chart. Put it inside `Chart.Plot`, next to `Drawings.Layer` if there is one. It draws
|
|
84
|
+
* nothing. A tap (not a drag) on clear chart puts a selection away; with nothing selected and a `Series.Root` mounted
|
|
85
|
+
* it selects the series; a tap on a study's line or band (a `Study.Line` or `Study.Band` with an `id`) selects that study.
|
|
86
|
+
* Escape, with the chart focused, clears the selection (a half-placed drawing or an armed tool take the first Escape).
|
|
87
|
+
* A press on a drawing is Drawings.Layer's, and a drag is the chart panning, so neither counts.
|
|
88
|
+
*/
|
|
89
|
+
function Layer() {
|
|
90
|
+
const ctx = useSelection('Selection.Layer');
|
|
91
|
+
const chart = useChart('Selection.Layer');
|
|
92
|
+
const ref = useRef(null);
|
|
93
|
+
const live = useRef({ ctx, chart });
|
|
94
|
+
live.current = { ctx, chart };
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
const svg = ref.current?.ownerSVGElement;
|
|
97
|
+
if (!svg)
|
|
98
|
+
return;
|
|
99
|
+
// `before` is the selection at the press: another listener on the same tap (Drawings.Layer putting a drawing away) may change it before the release.
|
|
100
|
+
let tap = null;
|
|
101
|
+
const down = new Set();
|
|
102
|
+
const onDown = (event) => {
|
|
103
|
+
if (event.isPrimary)
|
|
104
|
+
down.clear();
|
|
105
|
+
down.add(event.pointerId);
|
|
106
|
+
// A second finger is a pinch, not a tap.
|
|
107
|
+
if (down.size > 1) {
|
|
108
|
+
tap = null;
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (event.button !== 0)
|
|
112
|
+
return;
|
|
113
|
+
const { ctx: c, chart: ch } = live.current;
|
|
114
|
+
const point = ch.toChart(event.clientX, event.clientY);
|
|
115
|
+
if (!point || point.x < 0 || point.x >= ch.frame.plotWidth || point.y < 0 || point.y >= ch.plotBottom)
|
|
116
|
+
return;
|
|
117
|
+
if (c.guard.current?.owns(event.clientX, event.clientY, event.pointerType))
|
|
118
|
+
return;
|
|
119
|
+
// A line of a study or of a series in a panel (data-select-id) is a hit; clear chart below the first panel is not the bar series'.
|
|
120
|
+
const hitEl = event.target?.closest?.("[data-part='study-hit'],[data-part='series-hit']");
|
|
121
|
+
const hitId = hitEl?.getAttribute('data-select-id');
|
|
122
|
+
const hit = hitId ? { kind: hitEl?.getAttribute('data-part') === 'series-hit' ? 'series' : 'study', id: hitId } : null;
|
|
123
|
+
tap = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, at: event.timeStamp, hit, before: c.current(), inMain: point.y < ch.frame.plotHeight };
|
|
124
|
+
};
|
|
125
|
+
const onMove = (event) => {
|
|
126
|
+
if (tap && tap.pointerId === event.pointerId && Math.hypot(event.clientX - tap.x, event.clientY - tap.y) > TAP_SLOP)
|
|
127
|
+
tap = null;
|
|
128
|
+
};
|
|
129
|
+
const onUp = (event) => {
|
|
130
|
+
down.delete(event.pointerId);
|
|
131
|
+
if (!tap || tap.pointerId !== event.pointerId)
|
|
132
|
+
return;
|
|
133
|
+
const t = tap;
|
|
134
|
+
tap = null;
|
|
135
|
+
if (event.type !== 'pointerup' || Math.hypot(event.clientX - t.x, event.clientY - t.y) > TAP_SLOP || event.timeStamp - t.at >= TAP_MS)
|
|
136
|
+
return;
|
|
137
|
+
const { ctx: c } = live.current;
|
|
138
|
+
c.select(selectionAfterTap(t.before, t.hit, t.inMain ? c.series?.id ?? null : null));
|
|
139
|
+
};
|
|
140
|
+
const onKey = (event) => {
|
|
141
|
+
if (event.key !== 'Escape' || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey)
|
|
142
|
+
return;
|
|
143
|
+
const { ctx: c } = live.current;
|
|
144
|
+
if (c.guard.current?.busy())
|
|
145
|
+
return;
|
|
146
|
+
c.select(selectionAfterEscape(c.current()));
|
|
147
|
+
};
|
|
148
|
+
svg.addEventListener('pointerdown', onDown);
|
|
149
|
+
svg.addEventListener('pointermove', onMove);
|
|
150
|
+
svg.addEventListener('pointerup', onUp);
|
|
151
|
+
svg.addEventListener('pointercancel', onUp);
|
|
152
|
+
svg.addEventListener('keydown', onKey);
|
|
153
|
+
return () => {
|
|
154
|
+
svg.removeEventListener('pointerdown', onDown);
|
|
155
|
+
svg.removeEventListener('pointermove', onMove);
|
|
156
|
+
svg.removeEventListener('pointerup', onUp);
|
|
157
|
+
svg.removeEventListener('pointercancel', onUp);
|
|
158
|
+
svg.removeEventListener('keydown', onKey);
|
|
159
|
+
};
|
|
160
|
+
}, []);
|
|
161
|
+
return _jsx("g", { ref: ref, "data-part": "selection-layer", "aria-hidden": "true" });
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Says that a bar series exists, so a tap on clear chart can select it (with a Selection.Layer in the plot). It draws
|
|
165
|
+
* nothing. Put it above the chart and the Series.Select and Series.Options. The selected series shows
|
|
166
|
+
* `data-state='selected'` on its `Chart.Candles`.
|
|
167
|
+
*/
|
|
168
|
+
function SeriesRoot({ id, label, children }) {
|
|
169
|
+
const ctx = useSelection('Series.Root');
|
|
170
|
+
const { registerSeries, setName } = ctx;
|
|
171
|
+
useEffect(() => {
|
|
172
|
+
const undo = registerSeries({ id, label });
|
|
173
|
+
setName('series', id, label);
|
|
174
|
+
return undo;
|
|
175
|
+
}, [registerSeries, setName, id, label]);
|
|
176
|
+
const selected = ctx.selection !== null && ctx.selection.kind === 'series' && ctx.selection.id === id;
|
|
177
|
+
const value = useMemo(() => ({ id, label, selected }), [id, label, selected]);
|
|
178
|
+
return _jsx(SeriesContext.Provider, { value: value, children: children });
|
|
179
|
+
}
|
|
180
|
+
function useSeries(part) {
|
|
181
|
+
const context = useContext(SeriesContext);
|
|
182
|
+
if (!context)
|
|
183
|
+
throw new Error(`Series.${part} needs a Series.Root above it.`);
|
|
184
|
+
return context;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* A button that selects the series, and puts the selection away when pressed again: the keyboard and touch path to what
|
|
188
|
+
* a tap on clear chart does. `aria-pressed` carries the state. Its name is the series label unless you give a `label`
|
|
189
|
+
* or children.
|
|
190
|
+
*/
|
|
191
|
+
function Select({ className, icon, label, hideLabel, children }) {
|
|
192
|
+
const ctx = useSelection('Series.Select');
|
|
193
|
+
const series = useSeries('Select');
|
|
194
|
+
const face = buttonFace(series.label, { icon, label, hideLabel, children });
|
|
195
|
+
return (_jsx("button", { type: "button", className: className, "data-mt": "", "data-touch-target": "", "data-part": "series-select", "data-state": series.selected ? 'selected' : undefined, "aria-label": face['aria-label'], "aria-pressed": series.selected, onClick: () => ctx.select(series.selected ? null : { kind: 'series', id: series.id }), children: face.content }));
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* A headless panel for the series' options. It shows its children only while the series is selected. The primitives
|
|
199
|
+
* ship no option fields: put your own controls inside (an interval list, an instrument picker) and decide what a change
|
|
200
|
+
* means. `label` names the group; the default is the series label and "options".
|
|
201
|
+
*/
|
|
202
|
+
function Options({ className, label, children }) {
|
|
203
|
+
const series = useSeries('Options');
|
|
204
|
+
if (!series.selected)
|
|
205
|
+
return null;
|
|
206
|
+
return (_jsx("div", { className: className, role: "group", "aria-label": label ?? `${series.label} options`, "data-mt": "", "data-part": "series-options", "data-series": series.id, children: children }));
|
|
207
|
+
}
|
|
208
|
+
export { Root, Layer, SeriesRoot, Select, Options, useSelection };
|
package/dist/Studies.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { Root, Line, Band, Legend } from './StudiesParts.js';
|
|
2
|
+
// The parts live in StudiesParts.js (a client module). The namespace object is built here, outside it, so a server
|
|
3
|
+
// component can write `Study.Root`: a property of an object exported from a client module is not reachable there.
|
|
4
|
+
export * from './StudiesParts.js';
|
|
5
|
+
export const Study = { Root, Line, Band, Legend };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import { type SeriesPoint } from './core/series.js';
|
|
3
|
+
/** Keeps the studies' names and values so the Legend can list them. Draws nothing. */
|
|
4
|
+
declare function Root({ children }: {
|
|
5
|
+
children?: ReactNode;
|
|
6
|
+
}): import("react").JSX.Element;
|
|
7
|
+
export interface StudyLineProps {
|
|
8
|
+
/** The line on its own clock, placed on the bars by exact time. A bar with no point on it has no value there; a bar's gap kind (closed, missing, ...) is not drawn. */
|
|
9
|
+
points?: ReadonlyArray<SeriesPoint>;
|
|
10
|
+
/**
|
|
11
|
+
* One value per bar by position, null where the study has none. A study computed from these bars (`sma(bars, 20)`) is
|
|
12
|
+
* already by position. Give `points` for a series that comes from elsewhere. Neither given draws nothing.
|
|
13
|
+
* @deprecated Give `points` (`pointsFromValues(bars, sma(bars, 20))` makes them). `values` is removed in 0.0.7.
|
|
14
|
+
*/
|
|
15
|
+
values?: ReadonlyArray<number | null>;
|
|
16
|
+
/** Shown in the Legend and carried as `data-study`. */
|
|
17
|
+
name: string;
|
|
18
|
+
/** Makes the study selectable (with a Selection.Root above): by a tap on the line and by its Legend button. Unique on the chart. */
|
|
19
|
+
id?: string;
|
|
20
|
+
className?: string;
|
|
21
|
+
}
|
|
22
|
+
declare function Line({ values: given, points, name, id, className }: StudyLineProps): import("react").JSX.Element | null;
|
|
23
|
+
export interface StudyBandProps {
|
|
24
|
+
upper: ReadonlyArray<number | null>;
|
|
25
|
+
lower: ReadonlyArray<number | null>;
|
|
26
|
+
/** Shown in the Legend as "lower - upper" and carried as `data-study`. Without a name the band is not listed. */
|
|
27
|
+
name?: string;
|
|
28
|
+
/** Makes the study selectable (with a Selection.Root above): by a tap on the band and, when it has a name, by its Legend button. Unique on the chart. */
|
|
29
|
+
id?: string;
|
|
30
|
+
className?: string;
|
|
31
|
+
}
|
|
32
|
+
/** The space between two series, filled. It stops where either series has no value. */
|
|
33
|
+
declare function Band({ upper: givenUpper, lower: givenLower, name, id, className }: StudyBandProps): import("react").JSX.Element | null;
|
|
34
|
+
export interface StudyLegendProps {
|
|
35
|
+
className?: string;
|
|
36
|
+
/** Prints one value. Default: the chart's decimals. */
|
|
37
|
+
format?: (value: number) => string;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The studies and their values at the crosshair bar (the last bar in view while there is no crosshair). Put it outside
|
|
41
|
+
* the SVG. `data-active` is present while the values belong to a crosshair bar. A study with no value there shows a dash.
|
|
42
|
+
*/
|
|
43
|
+
declare function Legend({ className, format }: StudyLegendProps): import("react").JSX.Element;
|
|
44
|
+
export { Root, Line, Band, Legend };
|
|
45
|
+
//# sourceMappingURL=StudiesParts.d.ts.map
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { createContext, useCallback, useContext, useEffect, useId, useMemo, useState } from 'react';
|
|
4
|
+
import { useChart } from './ChartParts.js';
|
|
5
|
+
import { resolveSeries } from './core/series.js';
|
|
6
|
+
import { SelectionContext } from './selection-context.js';
|
|
7
|
+
const RegistryContext = createContext(null);
|
|
8
|
+
const EntriesContext = createContext(new Map());
|
|
9
|
+
/** Keeps the studies' names and values so the Legend can list them. Draws nothing. */
|
|
10
|
+
function Root({ children }) {
|
|
11
|
+
const [entries, setEntries] = useState(() => new Map());
|
|
12
|
+
const register = useCallback((id, entry) => {
|
|
13
|
+
setEntries((current) => {
|
|
14
|
+
const next = new Map(current);
|
|
15
|
+
next.set(id, entry);
|
|
16
|
+
return next;
|
|
17
|
+
});
|
|
18
|
+
}, []);
|
|
19
|
+
const unregister = useCallback((id) => {
|
|
20
|
+
setEntries((current) => {
|
|
21
|
+
if (!current.has(id))
|
|
22
|
+
return current;
|
|
23
|
+
const next = new Map(current);
|
|
24
|
+
next.delete(id);
|
|
25
|
+
return next;
|
|
26
|
+
});
|
|
27
|
+
}, []);
|
|
28
|
+
const registry = useMemo(() => ({ register, unregister }), [register, unregister]);
|
|
29
|
+
return (_jsx(RegistryContext.Provider, { value: registry, children: _jsx(EntriesContext.Provider, { value: entries, children: children }) }));
|
|
30
|
+
}
|
|
31
|
+
function useRegistration(name, series, selectId, problems) {
|
|
32
|
+
const registry = useContext(RegistryContext);
|
|
33
|
+
const id = useId();
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
registry?.register(id, { name, series, selectId, problems });
|
|
36
|
+
return () => registry?.unregister(id);
|
|
37
|
+
}, [registry, id, name, series, selectId, problems]);
|
|
38
|
+
}
|
|
39
|
+
/** Wide enough for a finger: the transparent hit stroke is --mt-touch-target (44px) across. */
|
|
40
|
+
const HIT_WIDTH = 44;
|
|
41
|
+
/**
|
|
42
|
+
* Selection for a study with an `id`: whether it is selected, and the extra id the Legend needs. Without a Selection.Root
|
|
43
|
+
* above, or without an id, nothing is selectable and the study draws exactly as it did.
|
|
44
|
+
*/
|
|
45
|
+
function useStudySelection(id, name) {
|
|
46
|
+
const selection = useContext(SelectionContext);
|
|
47
|
+
const setName = selection?.setName;
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (setName && id !== undefined && name)
|
|
50
|
+
setName('study', id, name);
|
|
51
|
+
}, [setName, id, name]);
|
|
52
|
+
const selectId = selection && id !== undefined ? id : undefined;
|
|
53
|
+
const sel = selection?.selection;
|
|
54
|
+
const selected = selectId !== undefined && !!sel && sel.kind === 'study' && sel.id === selectId;
|
|
55
|
+
return { selectId, selected };
|
|
56
|
+
}
|
|
57
|
+
const num = (n) => n.toFixed(2);
|
|
58
|
+
/** One run of consecutive bars first..last, as SVG path segments. `pick` returns a point for a bar or null to break the run. */
|
|
59
|
+
function runs(first, last, pick) {
|
|
60
|
+
const out = [];
|
|
61
|
+
let run = [];
|
|
62
|
+
for (let i = first; i <= last; i++) {
|
|
63
|
+
if (pick(i) === null) {
|
|
64
|
+
if (run.length)
|
|
65
|
+
out.push(run);
|
|
66
|
+
run = [];
|
|
67
|
+
}
|
|
68
|
+
else
|
|
69
|
+
run.push(i);
|
|
70
|
+
}
|
|
71
|
+
if (run.length)
|
|
72
|
+
out.push(run);
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
const valid = (v) => v !== null && v !== undefined && Number.isFinite(v);
|
|
76
|
+
/** A line through the study's values in view. It lifts the pen at every null, so a warm-up gap stays a gap. */
|
|
77
|
+
const NO_VALUES = [];
|
|
78
|
+
function Line({ values: given, points, name, id, className }) {
|
|
79
|
+
const { frame, readoutId, bars } = useChart('Study.Line');
|
|
80
|
+
// Points are placed on the bars by exact time; a study's own values are already one per bar.
|
|
81
|
+
const resolved = useMemo(() => {
|
|
82
|
+
if (points === undefined) {
|
|
83
|
+
if (given !== undefined)
|
|
84
|
+
return { values: given, problems: [] };
|
|
85
|
+
return { values: NO_VALUES, problems: [`"${name}" has neither points nor values, so there is nothing to draw.`] };
|
|
86
|
+
}
|
|
87
|
+
const r = resolveSeries(bars.map((b) => b.timestamp), { id: id ?? name, label: name, points });
|
|
88
|
+
return { values: r.values, problems: r.problems };
|
|
89
|
+
}, [points, given, bars, id, name]);
|
|
90
|
+
const values = resolved.values;
|
|
91
|
+
const series = useMemo(() => [values], [values]);
|
|
92
|
+
const { selectId, selected } = useStudySelection(id, name);
|
|
93
|
+
useRegistration(name, series, selectId, resolved.problems);
|
|
94
|
+
let d = '';
|
|
95
|
+
for (const run of runs(frame.first, frame.last, (i) => (valid(values[i]) ? 0 : null))) {
|
|
96
|
+
if (run.length < 2)
|
|
97
|
+
continue;
|
|
98
|
+
d += run.map((i, k) => `${k === 0 ? 'M' : 'L'}${num(frame.x(i))},${num(frame.y(values[i]))}`).join('');
|
|
99
|
+
}
|
|
100
|
+
if (!d)
|
|
101
|
+
return null;
|
|
102
|
+
const line = _jsx("path", { className: className, "data-part": "study-line", "data-state": selected ? 'selected' : undefined, clipPath: `url(#${readoutId}-price)`, "data-study": name, d: d, fill: "none", "aria-hidden": "true" });
|
|
103
|
+
if (selectId === undefined)
|
|
104
|
+
return line;
|
|
105
|
+
// A wide transparent stroke on top, so a finger can hit a thin line. The Selection.Layer reads the tap from `data-select-id`.
|
|
106
|
+
return (_jsxs(_Fragment, { children: [line, _jsx("path", { "data-part": "study-hit", "data-select-id": selectId, clipPath: `url(#${readoutId}-price)`, d: d, fill: "none", stroke: "transparent", strokeWidth: HIT_WIDTH, strokeLinecap: "round", strokeLinejoin: "round", pointerEvents: "stroke", "aria-hidden": "true" })] }));
|
|
107
|
+
}
|
|
108
|
+
/** The space between two series, filled. It stops where either series has no value. */
|
|
109
|
+
function Band({ upper: givenUpper, lower: givenLower, name = '', id, className }) {
|
|
110
|
+
const upper = givenUpper ?? NO_VALUES; // a side that is not computed yet leaves the band empty
|
|
111
|
+
const lower = givenLower ?? NO_VALUES;
|
|
112
|
+
const { frame, readoutId } = useChart('Study.Band');
|
|
113
|
+
const series = useMemo(() => [lower, upper], [lower, upper]);
|
|
114
|
+
const { selectId, selected } = useStudySelection(id, name);
|
|
115
|
+
useRegistration(name, series, selectId);
|
|
116
|
+
let d = '';
|
|
117
|
+
const both = (i) => (valid(upper[i]) && valid(lower[i]) ? 0 : null);
|
|
118
|
+
for (const run of runs(frame.first, frame.last, both)) {
|
|
119
|
+
if (run.length < 2)
|
|
120
|
+
continue;
|
|
121
|
+
const top = run.map((i, k) => `${k === 0 ? 'M' : 'L'}${num(frame.x(i))},${num(frame.y(upper[i]))}`).join('');
|
|
122
|
+
const bottom = [...run].reverse().map((i) => `L${num(frame.x(i))},${num(frame.y(lower[i]))}`).join('');
|
|
123
|
+
d += `${top}${bottom}Z`;
|
|
124
|
+
}
|
|
125
|
+
if (!d)
|
|
126
|
+
return null;
|
|
127
|
+
const band = _jsx("path", { className: className, "data-part": "study-band", "data-state": selected ? 'selected' : undefined, clipPath: `url(#${readoutId}-price)`, "data-study": name || undefined, d: d, "aria-hidden": "true" });
|
|
128
|
+
if (selectId === undefined)
|
|
129
|
+
return band;
|
|
130
|
+
return (_jsxs(_Fragment, { children: [band, _jsx("path", { "data-part": "study-hit", "data-select-id": selectId, clipPath: `url(#${readoutId}-price)`, d: d, fill: "transparent", stroke: "transparent", strokeWidth: HIT_WIDTH, strokeLinejoin: "round", pointerEvents: "all", "aria-hidden": "true" })] }));
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* The studies and their values at the crosshair bar (the last bar in view while there is no crosshair). Put it outside
|
|
134
|
+
* the SVG. `data-active` is present while the values belong to a crosshair bar. A study with no value there shows a dash.
|
|
135
|
+
*/
|
|
136
|
+
function Legend({ className, format }) {
|
|
137
|
+
const { active, frame, decimals } = useChart('Study.Legend');
|
|
138
|
+
const entries = useContext(EntriesContext);
|
|
139
|
+
const selection = useContext(SelectionContext);
|
|
140
|
+
const index = active ?? frame.last;
|
|
141
|
+
const text = (v) => (valid(v) ? (format ? format(v) : v.toFixed(decimals)) : '–');
|
|
142
|
+
const items = [];
|
|
143
|
+
entries.forEach((entry, id) => {
|
|
144
|
+
if (!entry.name)
|
|
145
|
+
return;
|
|
146
|
+
const selectId = selection ? entry.selectId : undefined;
|
|
147
|
+
const selected = selectId !== undefined && selection.selection?.kind === 'study' && selection.selection.id === selectId;
|
|
148
|
+
const name = _jsx("span", { "data-part": "study-name", children: entry.name });
|
|
149
|
+
items.push(_jsxs("li", { "data-part": "study-legend-item", "data-study": entry.name, "data-state": selected ? 'selected' : undefined, children: [selectId === undefined ? name : (_jsx("button", { type: "button", "data-mt": "", "data-touch-target": "", "data-part": "study-select", "aria-pressed": selected, onClick: () => selection.select(selected ? null : { kind: 'study', id: selectId }), children: name })), ' ', _jsx("span", { "data-part": "study-value", "data-mt-numeric": true, children: entry.series.map((s) => text(s[index])).join(' – ') }), (entry.problems ?? []).map((p) => _jsxs("span", { "data-part": "study-problem", role: "note", children: [" ", p] }, p))] }, id));
|
|
150
|
+
});
|
|
151
|
+
return _jsx("ul", { className: className, "data-part": "study-legend", "data-active": active !== null ? '' : undefined, "aria-label": "Study values", children: items });
|
|
152
|
+
}
|
|
153
|
+
export { Root, Line, Band, Legend };
|
package/dist/Submenu.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { List, SubmenuRoot, SubmenuTrigger } from './MenuParts.js';
|
|
2
|
+
// A submenu is a menu inside a menu. Its list and items are the Menu ones (Menu.List, Menu.Item); only the root and the
|
|
3
|
+
// trigger differ. Built here, outside the client module, for the same reason as Menu.
|
|
4
|
+
export const Submenu = { Root: SubmenuRoot, Trigger: SubmenuTrigger, List };
|