@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,454 @@
|
|
|
1
|
+
import { useCallback, useContext, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react';
|
|
2
|
+
import { buttonFace } from './accessible-name.js';
|
|
3
|
+
import { useChart } from './ChartParts.js';
|
|
4
|
+
import { chartFrame, priceTicks, tickDecimals, type CandleBar, type ChartFrame, type ChartViewport, type PriceSpan } from './core/chart-scale.js';
|
|
5
|
+
import { AXIS_BOTTOM, describeMove, MAIN_PANEL, moveSeries, moveTargets, normalizeLayout, panelBands, panelOf, reconcileLayout, type MoveDestination, type PanelLayout, type PanelSpec } from './core/panels.js';
|
|
6
|
+
import { duplicateIds, resolveSeries, type AlignOptions, type ResolvedSeries } from './core/series.js';
|
|
7
|
+
import { PanelsContext, type PanelSeriesData, type PanelsContextValue } from './panels-context.js';
|
|
8
|
+
import { SelectionContext } from './selection-context.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Panels as parts: Panels.Root, Panels.Layer, Panels.List and Panels.Move. A chart is one or more vertical bands, each
|
|
12
|
+
* with its own price scale, on one shared time axis. The chart's own bars and volume stay in the first band, `main`.
|
|
13
|
+
* Other series (a value per bar, like a study) are placed in a band by id and moved between bands by a menu of relative
|
|
14
|
+
* destinations ("Move up to Main", "Move to a new panel below"), which works by touch and keyboard. Headless: the parts
|
|
15
|
+
* set `data-part` and `data-state='selected'`; tokens.css gives the lines a stroke and the host gives them colour.
|
|
16
|
+
*
|
|
17
|
+
* Everything is opt-in: without a Panels.Root above it, Chart draws exactly as it did.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface PanelsRootProps {
|
|
21
|
+
/** How a series with `points` is placed on the bars. Default `{ mode: 'exact' }`; `asof` with `toleranceMs` takes the latest point at or before each bar. */
|
|
22
|
+
align?: AlignOptions;
|
|
23
|
+
/** The series to draw besides the chart's bars. Keep it stable (useMemo); a new array each render only costs a layout pass. */
|
|
24
|
+
series: readonly (PanelSeriesData & { /** The panel it starts in (default `main`). Panels named here are created below `main`, in the order the series appear. */ panel?: string })[];
|
|
25
|
+
/** Names and weights for the bands the series start in, below `main`. A panel a series names but this leaves out is created with its id as the label. */
|
|
26
|
+
panels?: readonly PanelSpec[];
|
|
27
|
+
/** Controlled layout; without it Root keeps its own. */
|
|
28
|
+
layout?: PanelLayout;
|
|
29
|
+
onLayoutChange?: (layout: PanelLayout) => void;
|
|
30
|
+
children?: ReactNode;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const HIDDEN: CSSProperties = { position: 'absolute', width: 1, height: 1, margin: -1, padding: 0, overflow: 'hidden', clip: 'rect(0 0 0 0)', whiteSpace: 'nowrap', border: 0 };
|
|
34
|
+
|
|
35
|
+
/** The layout the `panel` fields of the series ask for: `main` first, then each named panel in the order first seen. */
|
|
36
|
+
function initialLayout(series: PanelsRootProps['series'], named: readonly PanelSpec[] = []): PanelLayout {
|
|
37
|
+
const panels: PanelSpec[] = [{ id: MAIN_PANEL, label: 'Main' }];
|
|
38
|
+
// A band named in `panels` that no series asks for is not made: a band with no series is removed.
|
|
39
|
+
for (const n of named) if (n.id !== MAIN_PANEL && series.some((s) => s.panel === n.id)) panels.push(n);
|
|
40
|
+
const placement: Record<string, string> = {};
|
|
41
|
+
for (const s of series) {
|
|
42
|
+
if (!s.panel || s.panel === MAIN_PANEL) continue;
|
|
43
|
+
if (!panels.some((p) => p.id === s.panel)) panels.push({ id: s.panel, label: s.panel });
|
|
44
|
+
placement[s.id] = s.panel;
|
|
45
|
+
}
|
|
46
|
+
return { panels, placement };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const finite = (v: number | null | undefined): v is number => v !== null && v !== undefined && Number.isFinite(v);
|
|
50
|
+
|
|
51
|
+
/** Holds the layout, says each move in a polite live region and tells Chart how tall the first band is. Wrap the Chart in it. */
|
|
52
|
+
function Root({ series: given, panels: named, layout, onLayoutChange, align, children }: PanelsRootProps) {
|
|
53
|
+
// A list that is not a list, or an entry that is not a series, is left out and named in the problems, not thrown at render.
|
|
54
|
+
const series = useMemo(() => (Array.isArray(given) ? given.filter((s) => s !== null && typeof s === 'object' && typeof s.id === 'string') : []), [given]);
|
|
55
|
+
const unusable = given === undefined ? 0 : Array.isArray(given) ? given.length - series.length : -1;
|
|
56
|
+
const [inner, setInner] = useState<PanelLayout>(() => initialLayout(series, named));
|
|
57
|
+
// Series that come or go after the first render: a new one starts where it asks, a gone one leaves its panel (an empty panel goes). Done while rendering, so there is no frame with the series in the wrong band.
|
|
58
|
+
const [seen, setSeen] = useState<string[]>(() => series.map((s) => s.id));
|
|
59
|
+
if (layout === undefined && (seen.length !== series.length || series.some((s, i) => s.id !== seen[i]))) {
|
|
60
|
+
setSeen(series.map((s) => s.id));
|
|
61
|
+
setInner((was) => reconcileLayout(was, series, seen, named));
|
|
62
|
+
}
|
|
63
|
+
const current = useMemo(() => normalizeLayout(layout !== undefined ? layout : inner), [layout, inner]);
|
|
64
|
+
const latest = useRef(current);
|
|
65
|
+
latest.current = current;
|
|
66
|
+
const labels = useRef(new Map<string, string>());
|
|
67
|
+
labels.current = new Map(series.map((s) => [s.id, s.label]));
|
|
68
|
+
const [status, setStatus] = useState('');
|
|
69
|
+
const pending = useRef<{ id: string; panel: string } | null>(null);
|
|
70
|
+
const announce = (now: PanelLayout) => {
|
|
71
|
+
const p = pending.current;
|
|
72
|
+
if (!p || panelOf(now, p.id) !== p.panel) return;
|
|
73
|
+
pending.current = null;
|
|
74
|
+
const where = now.panels.find((x) => x.id === p.panel);
|
|
75
|
+
setStatus(describeMove(labels.current.get(p.id) ?? p.id, where?.label ?? 'a panel'));
|
|
76
|
+
};
|
|
77
|
+
const move = useCallback((seriesId: string, destination: MoveDestination) => {
|
|
78
|
+
const was = latest.current;
|
|
79
|
+
const next = moveSeries(was, seriesId, destination);
|
|
80
|
+
if (next === was) return;
|
|
81
|
+
latest.current = next;
|
|
82
|
+
if (layout === undefined) setInner(next);
|
|
83
|
+
onLayoutChange?.(next);
|
|
84
|
+
// A controlled host may decline the move; it is said only once the layout it gives back has the series there.
|
|
85
|
+
pending.current = { id: seriesId, panel: panelOf(next, seriesId) };
|
|
86
|
+
if (layout === undefined) announce(next);
|
|
87
|
+
}, [layout, onLayoutChange]);
|
|
88
|
+
useEffect(() => { if (layout !== undefined) announce(current); }, [layout, current]);
|
|
89
|
+
|
|
90
|
+
// Series placed on the bars once per bars array and series object; a crosshair move re-renders without redoing it.
|
|
91
|
+
const mode = align?.mode;
|
|
92
|
+
const tolerance = align?.toleranceMs;
|
|
93
|
+
// One entry per bars array, rebuilt when its length changes (a host may append bars to the same array).
|
|
94
|
+
const cache = useMemo(() => ({ byBars: new WeakMap<readonly CandleBar[], { len: number; times: number[]; made: WeakMap<PanelSeriesData, ResolvedSeries> }>() }), [mode, tolerance]);
|
|
95
|
+
const resolve = useCallback((item: PanelSeriesData, bars: readonly CandleBar[]): ResolvedSeries => {
|
|
96
|
+
let entry = cache.byBars.get(bars);
|
|
97
|
+
if (!entry || entry.len !== bars.length) {
|
|
98
|
+
entry = { len: bars.length, times: bars.map((b) => b.timestamp), made: new WeakMap() };
|
|
99
|
+
cache.byBars.set(bars, entry);
|
|
100
|
+
}
|
|
101
|
+
let hit = entry.made.get(item);
|
|
102
|
+
if (!hit) {
|
|
103
|
+
hit = resolveSeries(entry.times, item, { mode, toleranceMs: tolerance });
|
|
104
|
+
entry.made.set(item, hit);
|
|
105
|
+
}
|
|
106
|
+
return hit;
|
|
107
|
+
}, [cache, mode, tolerance]);
|
|
108
|
+
const problems = useMemo(() => {
|
|
109
|
+
const out = duplicateIds(series).map((id) => `The series id "${id}" is used more than once. Ids must be unique on a chart.`);
|
|
110
|
+
if (unusable < 0) out.push('The series given is not a list, so no series is drawn.');
|
|
111
|
+
else if (unusable > 0) out.push(`${unusable} ${unusable === 1 ? 'entry' : 'entries'} in the series list ${unusable === 1 ? 'is' : 'are'} not a series (an object with a string id) and ${unusable === 1 ? 'was' : 'were'} left out.`);
|
|
112
|
+
return out;
|
|
113
|
+
}, [series, unusable]);
|
|
114
|
+
|
|
115
|
+
const mainInclude = useCallback((visible: { first: number; last: number }, bars: readonly CandleBar[]): readonly PriceSpan[] => {
|
|
116
|
+
const out: PriceSpan[] = [];
|
|
117
|
+
for (const s of series) {
|
|
118
|
+
if (panelOf(current, s.id) !== MAIN_PANEL) continue;
|
|
119
|
+
const values = resolve(s, bars).values;
|
|
120
|
+
let low = Infinity;
|
|
121
|
+
let high = -Infinity;
|
|
122
|
+
for (let i = visible.first; i <= visible.last; i++) {
|
|
123
|
+
const v = values[i];
|
|
124
|
+
if (finite(v)) { if (v < low) low = v; if (v > high) high = v; }
|
|
125
|
+
}
|
|
126
|
+
if (low <= high) out.push(s.style === 'histogram' ? [Math.min(low, 0), Math.max(high, 0)] : [low, high]); // a histogram's bars start at zero
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}, [series, current, resolve]);
|
|
130
|
+
|
|
131
|
+
// A series of this Root that is no longer given cannot stay selected. Only ids this Root has had count, so a selection
|
|
132
|
+
// of the chart's own bar series (a Series.Root, registered after the first render) is never touched here.
|
|
133
|
+
const selection = useContext(SelectionContext);
|
|
134
|
+
const selectedId = selection?.selection?.kind === 'series' ? selection.selection.id : null;
|
|
135
|
+
const had = useRef(new Set<string>());
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
const ids = new Set(series.map((s) => s.id));
|
|
138
|
+
if (selection && selectedId !== null && had.current.has(selectedId) && !ids.has(selectedId)) {
|
|
139
|
+
queueMicrotask(() => {
|
|
140
|
+
const now = selection.current();
|
|
141
|
+
if (now && now.kind === 'series' && now.id === selectedId) selection.select(null);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
had.current = new Set([...had.current, ...ids]);
|
|
145
|
+
}, [selection, selectedId, series]);
|
|
146
|
+
|
|
147
|
+
const value = useMemo<PanelsContextValue>(() => ({ series, layout: current, move, mainInclude, resolve, problems }), [series, current, move, mainInclude, resolve, problems]);
|
|
148
|
+
return (
|
|
149
|
+
<PanelsContext.Provider value={value}>
|
|
150
|
+
{children}
|
|
151
|
+
<span data-part="panels-status" role="status" aria-live="polite" style={HIDDEN}>{status}</span>
|
|
152
|
+
</PanelsContext.Provider>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function usePanels(part: string): PanelsContextValue {
|
|
157
|
+
const context = useContext(PanelsContext);
|
|
158
|
+
if (!context) throw new Error(`Panels.${part} needs a Panels.Root above it.`);
|
|
159
|
+
return context;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const NO_ITEMS: readonly PanelSeriesData[] = [];
|
|
163
|
+
|
|
164
|
+
/** Words for a bar with no number, from the reason the series gives. */
|
|
165
|
+
const NO_VALUE_WORDS: Record<string, string> = {
|
|
166
|
+
missing: 'missing (the feed has nothing here)',
|
|
167
|
+
'market-closed': 'market closed',
|
|
168
|
+
'not-applicable': 'not applicable',
|
|
169
|
+
halted: 'halted',
|
|
170
|
+
unknown: 'unknown (the source does not say why)',
|
|
171
|
+
'no-point': 'no point at this bar',
|
|
172
|
+
};
|
|
173
|
+
const noValueWords = (state: string | undefined) => (state && NO_VALUE_WORDS[state]) || 'no value';
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* One sentence on where a series has no value: how many bars, by reason, and when it stops for good. Empty when every
|
|
177
|
+
* bar has a value, or none does (the problem note says that).
|
|
178
|
+
*/
|
|
179
|
+
function gapSummary(values: ReadonlyArray<number | null>, states: ReadonlyArray<string> | undefined, bars: readonly CandleBar[]): string {
|
|
180
|
+
if (!states || bars.length === 0) return '';
|
|
181
|
+
let gaps = 0;
|
|
182
|
+
let lastValue = -1;
|
|
183
|
+
const by: Record<string, number> = {};
|
|
184
|
+
for (let i = 0; i < bars.length; i++) {
|
|
185
|
+
if (finite(values[i])) { lastValue = i; continue; }
|
|
186
|
+
gaps++;
|
|
187
|
+
const k = noValueWords(states[i]);
|
|
188
|
+
by[k] = (by[k] ?? 0) + 1;
|
|
189
|
+
}
|
|
190
|
+
if (gaps === 0 || lastValue < 0) return '';
|
|
191
|
+
const parts = Object.entries(by).map(([k, n]) => `${n} ${k}`).join(', ');
|
|
192
|
+
const stops = lastValue < bars.length - 1 ? ` No value after ${new Date(bars[lastValue]!.timestamp).toISOString()}.` : '';
|
|
193
|
+
return `${gaps} of ${bars.length} bars have no value (${parts}).${stops}`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const gapCache = new WeakMap<ResolvedSeries, { len: number; text: string }>();
|
|
197
|
+
/** The gap sentence, kept per resolved series so a crosshair move does not rescan every bar. */
|
|
198
|
+
function gapsOf(resolved: ResolvedSeries, bars: readonly CandleBar[]): string {
|
|
199
|
+
const hit = gapCache.get(resolved);
|
|
200
|
+
if (hit && hit.len === bars.length) return hit.text;
|
|
201
|
+
const text = gapSummary(resolved.values, resolved.states, bars);
|
|
202
|
+
gapCache.set(resolved, { len: bars.length, text });
|
|
203
|
+
return text;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Wide enough for a finger: the transparent hit stroke is --mt-touch-target (44px) across. */
|
|
207
|
+
const HIT_WIDTH = 44;
|
|
208
|
+
const num = (n: number) => n.toFixed(2);
|
|
209
|
+
|
|
210
|
+
/** One path per run of consecutive values. */
|
|
211
|
+
function pathOf(frame: ChartFrame, values: ReadonlyArray<number | null>): string {
|
|
212
|
+
let d = '';
|
|
213
|
+
let run: number[] = [];
|
|
214
|
+
const flush = () => {
|
|
215
|
+
if (run.length >= 2) d += run.map((i, k) => `${k === 0 ? 'M' : 'L'}${num(frame.x(i))},${num(frame.y(values[i] as number))}`).join('');
|
|
216
|
+
run = [];
|
|
217
|
+
};
|
|
218
|
+
for (let i = frame.first; i <= frame.last; i++) {
|
|
219
|
+
if (finite(values[i])) run.push(i);
|
|
220
|
+
else flush();
|
|
221
|
+
}
|
|
222
|
+
flush();
|
|
223
|
+
return d;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
type Placed = { item: PanelSeriesData; values: ReadonlyArray<number | null> };
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The bars a band's price scale is taken from: one per bar in view, spanning the values of the series in it (NaN where
|
|
230
|
+
* none), plus zero for a histogram and any reference levels. Only the window is filled, so a long history costs
|
|
231
|
+
* nothing; chartFrame reads nothing outside it.
|
|
232
|
+
*/
|
|
233
|
+
function scaleBars(bars: readonly CandleBar[], viewport: ChartViewport, inBand: readonly Placed[]): CandleBar[] {
|
|
234
|
+
const out = new Array<CandleBar>(bars.length);
|
|
235
|
+
const from = Math.max(0, Math.floor(viewport.start));
|
|
236
|
+
const to = Math.min(bars.length - 1, Math.ceil(viewport.end) - 1);
|
|
237
|
+
const extras: number[] = [];
|
|
238
|
+
for (const { item } of inBand) {
|
|
239
|
+
if (item.style === 'histogram') extras.push(0);
|
|
240
|
+
for (const l of Array.isArray(item.levels) ? item.levels : []) if (Number.isFinite(l)) extras.push(l);
|
|
241
|
+
}
|
|
242
|
+
for (let i = from; i <= to; i++) {
|
|
243
|
+
let low = Infinity;
|
|
244
|
+
let high = -Infinity;
|
|
245
|
+
for (const { values } of inBand) {
|
|
246
|
+
const v = values[i];
|
|
247
|
+
if (finite(v)) { if (v < low) low = v; if (v > high) high = v; }
|
|
248
|
+
}
|
|
249
|
+
const has = low <= high;
|
|
250
|
+
if (has) for (const e of extras) { if (e < low) low = e; if (e > high) high = e; }
|
|
251
|
+
out[i] = { timestamp: bars[i].timestamp, open: has ? low : NaN, high: has ? high : NaN, low: has ? low : NaN, close: has ? high : NaN, volume: 0 };
|
|
252
|
+
}
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** The fixed range a band asks for: the widest of the `range`s of its series, or null to follow the data. */
|
|
257
|
+
function fixedRange(inBand: readonly Placed[]): PriceSpan | null {
|
|
258
|
+
let low = Infinity;
|
|
259
|
+
let high = -Infinity;
|
|
260
|
+
for (const { item } of inBand) {
|
|
261
|
+
const r = item.range;
|
|
262
|
+
if (r && Number.isFinite(r[0]) && Number.isFinite(r[1]) && r[0] < r[1]) { if (r[0] < low) low = r[0]; if (r[1] > high) high = r[1]; }
|
|
263
|
+
}
|
|
264
|
+
return low < high ? [low, high] : null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function useSeriesSelection(id: string, label: string) {
|
|
268
|
+
const selection = useContext(SelectionContext);
|
|
269
|
+
const setName = selection?.setName;
|
|
270
|
+
useEffect(() => { if (setName) setName('series', id, label); }, [setName, id, label]);
|
|
271
|
+
const sel = selection?.selection;
|
|
272
|
+
return { selectable: !!selection, selected: !!sel && sel.kind === 'series' && sel.id === id };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Runs of one bar with a value on both sides empty: a line cannot show them, so they are drawn as dots. */
|
|
276
|
+
function loneIndexes(frame: ChartFrame, values: ReadonlyArray<number | null>): number[] {
|
|
277
|
+
const out: number[] = [];
|
|
278
|
+
for (let i = frame.first; i <= frame.last && out.length < 300; i++) {
|
|
279
|
+
if (finite(values[i]) && !finite(values[i - 1]) && !finite(values[i + 1])) out.push(i);
|
|
280
|
+
}
|
|
281
|
+
return out;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** A bar from zero to the value, for each bar in view. */
|
|
285
|
+
function histogramPath(frame: ChartFrame, values: ReadonlyArray<number | null>): string {
|
|
286
|
+
const half = Math.max(0.5, frame.barWidth * 0.35);
|
|
287
|
+
const zero = frame.y(0);
|
|
288
|
+
let d = '';
|
|
289
|
+
for (let i = frame.first; i <= frame.last; i++) {
|
|
290
|
+
const v = values[i];
|
|
291
|
+
if (finite(v)) d += `M${num(frame.x(i) - half)},${num(zero)}V${num(frame.y(v))}h${num(half * 2)}V${num(zero)}Z`;
|
|
292
|
+
}
|
|
293
|
+
return d;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function SeriesPaths({ item, values, frame, clip }: { item: PanelSeriesData; values: ReadonlyArray<number | null>; frame: ChartFrame; clip: string }) {
|
|
297
|
+
const { selectable, selected } = useSeriesSelection(item.id, item.label);
|
|
298
|
+
const histogram = item.style === 'histogram';
|
|
299
|
+
const d = histogram ? histogramPath(frame, values) : pathOf(frame, values);
|
|
300
|
+
const lone = histogram ? [] : loneIndexes(frame, values);
|
|
301
|
+
if (!d && lone.length === 0) return null;
|
|
302
|
+
return (
|
|
303
|
+
<>
|
|
304
|
+
{d && <path data-part="panel-series" data-series={item.id} data-style={histogram ? 'histogram' : undefined} data-state={selected ? 'selected' : undefined} clipPath={clip} d={d} fill={histogram ? 'currentColor' : 'none'} aria-hidden="true" />}
|
|
305
|
+
{lone.map((i) => <circle key={i} data-part="panel-point" data-series={item.id} data-state={selected ? 'selected' : undefined} clipPath={clip} cx={num(frame.x(i))} cy={num(frame.y(values[i] as number))} r={3} fill="currentColor" aria-hidden="true" />)}
|
|
306
|
+
{selectable && <path data-part="series-hit" data-select-id={item.id} clipPath={clip} d={d + lone.map((i) => `M${num(frame.x(i))},${num(frame.y(values[i] as number))}h0`).join('')} fill="none" stroke="transparent" strokeWidth={HIT_WIDTH} strokeLinecap="round" strokeLinejoin="round" pointerEvents="stroke" aria-hidden="true" />}
|
|
307
|
+
</>
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function Band({ spec, top, height, items, bars }: { spec: PanelSpec; top: number; height: number; items: readonly PanelSeriesData[]; bars: readonly CandleBar[] }) {
|
|
312
|
+
const { frame: main, viewport, active, readoutId } = useChart('Panels.Layer');
|
|
313
|
+
const { resolve } = usePanels('Layer');
|
|
314
|
+
const placed = useMemo<Placed[]>(() => items.map((item) => ({ item, values: resolve(item, bars).values })), [items, bars, resolve]);
|
|
315
|
+
const frame = useMemo(() => chartFrame(scaleBars(bars, viewport, placed), viewport, { width: main.width, height }, { volumeShare: 0, axisBottom: 0, priceRange: fixedRange(placed) }), [bars, bars.length, placed, viewport, main.width, height]);
|
|
316
|
+
const levels = useMemo(() => [...new Set(items.flatMap((it) => (Array.isArray(it.levels) ? it.levels : []).filter(Number.isFinite)))], [items]);
|
|
317
|
+
const ticks = priceTicks(frame.priceMin, frame.priceMax, 3);
|
|
318
|
+
const places = tickDecimals(ticks);
|
|
319
|
+
const clip = `${readoutId}-panel-${spec.id}`;
|
|
320
|
+
return (
|
|
321
|
+
<g data-part="panel" data-panel={spec.id} transform={`translate(0,${top})`}>
|
|
322
|
+
<defs><clipPath id={clip}><rect x={0} y={0} width={frame.plotWidth} height={height} /></clipPath></defs>
|
|
323
|
+
<line data-part="panel-divider" x1={0} x2={main.width} y1={0} y2={0} aria-hidden="true" />
|
|
324
|
+
<g data-part="panel-grid" aria-hidden="true" clipPath={`url(#${clip})`}>
|
|
325
|
+
{ticks.map((t) => <line key={t} x1={0} x2={frame.plotWidth} y1={frame.y(t)} y2={frame.y(t)} />)}
|
|
326
|
+
</g>
|
|
327
|
+
<g data-part="panel-axis" aria-hidden="true" style={{ pointerEvents: 'none' }}>
|
|
328
|
+
{ticks.map((t) => <text key={t} x={frame.plotWidth + 6} y={frame.y(t) + 4} textAnchor="start">{t.toFixed(places)}</text>)}
|
|
329
|
+
</g>
|
|
330
|
+
{levels.map((l) => frame.y(l) >= 0 && frame.y(l) <= height && <line key={`level-${l}`} data-part="panel-level" x1={0} x2={frame.plotWidth} y1={frame.y(l)} y2={frame.y(l)} aria-hidden="true" />)}
|
|
331
|
+
<text data-part="panel-label" x={6} y={14} aria-hidden="true" style={{ pointerEvents: 'none' }}>{spec.label}</text>
|
|
332
|
+
{active !== null && <line data-part="panel-crosshair" x1={frame.x(active)} x2={frame.x(active)} y1={0} y2={height} aria-hidden="true" style={{ pointerEvents: 'none' }} />}
|
|
333
|
+
{placed.map(({ item, values }) => <SeriesPaths key={item.id} item={item} values={values} frame={frame} clip={`url(#${clip})`} />)}
|
|
334
|
+
</g>
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Draws the series in their bands. Put it inside `Chart.Plot`, after the chart's own parts. The series in `main` use the
|
|
340
|
+
* chart's price scale (and keep themselves in view); every other band has its own scale, grid, price labels and
|
|
341
|
+
* a divider. With a Selection.Root above, a tap on a series line selects it. A band is never shorter than 40, so give the
|
|
342
|
+
* chart enough height for the bands you use.
|
|
343
|
+
*/
|
|
344
|
+
function Layer() {
|
|
345
|
+
const { series, layout, resolve } = usePanels('Layer');
|
|
346
|
+
const { bars, frame, totalHeight, readoutId } = useChart('Panels.Layer');
|
|
347
|
+
const bands = useMemo(() => panelBands(layout.panels, totalHeight - AXIS_BOTTOM), [layout.panels, totalHeight]);
|
|
348
|
+
// One array per panel, kept between renders (a crosshair move re-renders but changes neither the series nor the layout).
|
|
349
|
+
const byPanel = useMemo(() => {
|
|
350
|
+
const map = new Map<string, PanelSeriesData[]>();
|
|
351
|
+
for (const p of layout.panels) map.set(p.id, []);
|
|
352
|
+
for (const s of series) map.get(panelOf(layout, s.id))?.push(s);
|
|
353
|
+
return map;
|
|
354
|
+
}, [series, layout]);
|
|
355
|
+
const inPanel = (id: string) => byPanel.get(id) ?? NO_ITEMS;
|
|
356
|
+
return (
|
|
357
|
+
<g data-part="panels" aria-hidden="true">
|
|
358
|
+
<g data-part="panel" data-panel={MAIN_PANEL} clipPath={`url(#${readoutId}-price)`}>
|
|
359
|
+
{inPanel(MAIN_PANEL).map((item) => <SeriesPaths key={item.id} item={item} values={resolve(item, bars).values} frame={frame} clip={`url(#${readoutId}-price)`} />)}
|
|
360
|
+
</g>
|
|
361
|
+
{layout.panels.slice(1).map((spec, i) => {
|
|
362
|
+
const band = bands[i + 1];
|
|
363
|
+
return band ? <Band key={spec.id} spec={spec} top={band.top} height={band.height} items={inPanel(spec.id)} bars={bars} /> : null;
|
|
364
|
+
})}
|
|
365
|
+
</g>
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export interface PanelsListProps {
|
|
370
|
+
className?: string;
|
|
371
|
+
/** Prints one value. Default: the chart's decimals. */
|
|
372
|
+
format?: (value: number) => string;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* The series as text: each one's name, its panel, and its value at the crosshair bar (the last bar in view when there is
|
|
377
|
+
* no crosshair), so the numbers are not only a picture. With a Selection.Root above, each row has a button that selects
|
|
378
|
+
* the series. Put it outside the SVG, inside `Chart.Root`.
|
|
379
|
+
*/
|
|
380
|
+
function List({ className, format }: PanelsListProps) {
|
|
381
|
+
const { series, layout, resolve, problems: setProblems } = usePanels('List');
|
|
382
|
+
const { active, frame, decimals, bars } = useChart('Panels.List');
|
|
383
|
+
const selection = useContext(SelectionContext);
|
|
384
|
+
const at = active ?? frame.last;
|
|
385
|
+
const f = format ?? ((n: number) => n.toFixed(decimals));
|
|
386
|
+
if (series.length === 0 && setProblems.length === 0) return null;
|
|
387
|
+
return (
|
|
388
|
+
<>
|
|
389
|
+
{setProblems.map((p) => <p key={p} data-part="panel-problem" role="note">{p}</p>)}
|
|
390
|
+
<ul className={className} data-part="panel-list" aria-label="Series">
|
|
391
|
+
{series.map((s) => {
|
|
392
|
+
const here = layout.panels.find((p) => p.id === panelOf(layout, s.id));
|
|
393
|
+
const resolved = resolve(s, bars);
|
|
394
|
+
const v = resolved.values[at];
|
|
395
|
+
const selected = !!selection?.selection && selection.selection.kind === 'series' && selection.selection.id === s.id;
|
|
396
|
+
const face = buttonFace(s.label, {});
|
|
397
|
+
return (
|
|
398
|
+
<li key={s.id} data-part="panel-list-item" data-series={s.id} data-state={selected ? 'selected' : undefined}>
|
|
399
|
+
{selection
|
|
400
|
+
? <button type="button" data-mt="" data-touch-target="" data-part="panel-select" data-state={selected ? 'selected' : undefined} aria-pressed={selected} aria-label={face['aria-label']} onClick={() => selection.select(selected ? null : { kind: 'series', id: s.id })}>{face.content}</button>
|
|
401
|
+
: <span data-part="panel-name">{s.label}</span>}
|
|
402
|
+
<span data-part="panel-where"> {here?.label ?? 'Main'}</span>
|
|
403
|
+
<span data-part="panel-value" data-mt-numeric=""> {finite(v) ? f(v) : noValueWords(resolved.states?.[at])}</span>
|
|
404
|
+
{gapsOf(resolved, bars) && <span data-part="panel-gaps"> {gapsOf(resolved, bars)}</span>}
|
|
405
|
+
{resolved.problems.map((p) => <span key={p} data-part="panel-problem" role="note"> {p}</span>)}
|
|
406
|
+
</li>
|
|
407
|
+
);
|
|
408
|
+
})}
|
|
409
|
+
</ul>
|
|
410
|
+
</>
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export interface PanelsMoveProps {
|
|
415
|
+
className?: string;
|
|
416
|
+
/** The series to move. Default: the selected one (it needs a Selection.Root above). */
|
|
417
|
+
seriesId?: string;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* The way to move a series to another panel without dragging: buttons for where it can go, relative to where it is
|
|
422
|
+
* (up, down, a new panel above, a new panel below). It shows while a series of the Root is selected (or for `seriesId`)
|
|
423
|
+
* and says each move in the live region. After a move the focus goes to the group, so a keyboard or screen reader user
|
|
424
|
+
* is not left on a button that is gone.
|
|
425
|
+
*/
|
|
426
|
+
function Move({ className, seriesId }: PanelsMoveProps) {
|
|
427
|
+
const { series, layout, move } = usePanels('Move');
|
|
428
|
+
const selection = useContext(SelectionContext);
|
|
429
|
+
const id = seriesId ?? (selection?.selection?.kind === 'series' ? selection.selection.id : undefined);
|
|
430
|
+
const item = id === undefined ? undefined : series.find((s) => s.id === id);
|
|
431
|
+
const group = useRef<HTMLDivElement | null>(null);
|
|
432
|
+
const [moved, setMoved] = useState(0);
|
|
433
|
+
useEffect(() => { if (moved > 0) group.current?.focus(); }, [moved]);
|
|
434
|
+
if (!item) return null;
|
|
435
|
+
const targets = moveTargets(layout, item.id);
|
|
436
|
+
return (
|
|
437
|
+
<div ref={group} className={className} role="group" tabIndex={-1} aria-label={`Move ${item.label}`} data-mt="" data-part="panel-move" data-series={item.id}>
|
|
438
|
+
{targets.map((t) => (
|
|
439
|
+
<button
|
|
440
|
+
key={t.destination.kind === 'panel' ? `to:${t.destination.panelId}` : `new:${t.destination.side}`}
|
|
441
|
+
type="button"
|
|
442
|
+
data-mt=""
|
|
443
|
+
data-touch-target=""
|
|
444
|
+
data-part="panel-move-to"
|
|
445
|
+
onClick={() => { move(item.id, t.destination); setMoved((n) => n + 1); }}
|
|
446
|
+
>
|
|
447
|
+
{t.label}
|
|
448
|
+
</button>
|
|
449
|
+
))}
|
|
450
|
+
</div>
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
export { Root, Layer, List, Move, usePanels };
|
package/src/Receipt.tsx
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { Root, Field, Hash, CopyButton, ShareNote, Verdict } from './ReceiptParts.js';
|
|
2
|
+
|
|
3
|
+
// The parts live in ReceiptParts.js (a client module). The namespace object is built here, outside it, so a server
|
|
4
|
+
// component can write `Receipt.Root`: a property of an object exported from a client module is not reachable there.
|
|
5
|
+
export * from './ReceiptParts.js';
|
|
6
|
+
export const Receipt = { Root, Field, Hash, CopyButton, ShareNote, Verdict };
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type KeyboardEvent, type ReactNode } from 'react';
|
|
2
|
+
import { Notice } from './Notice.js';
|
|
3
|
+
import { receiptHash, receiptVerdict, resolveReceiptKey, type ReceiptData, type ReceiptHashOf } from './core/receipt-rules.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Receipt as parts: Root, Field, Hash, CopyButton, ShareNote, Verdict. Headless: the parts add
|
|
7
|
+
* `data-verdict` (match or mismatch) and `data-synthetic` (on the card itself, without the `data-mt` scope, so tokens.css
|
|
8
|
+
* does not paint the whole card; the Verdict part carries the scope), and a copy always says whether it worked.
|
|
9
|
+
*
|
|
10
|
+
* Enter or `y` anywhere in the receipt (not on a button or link, which keep their own Enter) copies the result
|
|
11
|
+
* hash. A receipt for data that is not synthetic must carry its `shareNote`; ShareNote prints it, and the contract
|
|
12
|
+
* (`receiptPropsSchema`) refuses a receipt without one.
|
|
13
|
+
*/
|
|
14
|
+
/** `from` is the button that copied, or `key` for Enter or y, so only one button says how it went. */
|
|
15
|
+
interface CopyStatus { text: string; ok: boolean; from: 'receipt' | 'hash' | 'key' }
|
|
16
|
+
interface ReceiptContext {
|
|
17
|
+
receipt: ReceiptData;
|
|
18
|
+
receiptText: string;
|
|
19
|
+
copy: (text: string, what: string, from: CopyStatus['from']) => Promise<void>;
|
|
20
|
+
status: CopyStatus | null;
|
|
21
|
+
/** How many hash buttons there are: a key copy reports under one of them, or under the receipt button if none. */
|
|
22
|
+
hashButtons: number;
|
|
23
|
+
addHashButton: () => () => void;
|
|
24
|
+
}
|
|
25
|
+
const Context = createContext<ReceiptContext | null>(null);
|
|
26
|
+
|
|
27
|
+
function useReceipt(part: string): ReceiptContext {
|
|
28
|
+
const context = useContext(Context);
|
|
29
|
+
if (!context) throw new Error(`Receipt.${part} needs a Receipt.Root above it.`);
|
|
30
|
+
return context;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ReceiptRootProps {
|
|
34
|
+
receipt: ReceiptData;
|
|
35
|
+
/** The whole receipt as text, for the Copy receipt button. The host owns the wording. */
|
|
36
|
+
receiptText: string;
|
|
37
|
+
/** Writes text to the clipboard; the default is `navigator.clipboard.writeText`. Rejecting means it could not copy. */
|
|
38
|
+
onCopy?: (text: string) => Promise<void>;
|
|
39
|
+
className?: string;
|
|
40
|
+
'aria-label'?: string;
|
|
41
|
+
children?: ReactNode;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function Root({ receipt, receiptText, onCopy, className, 'aria-label': label = 'Evidence receipt', children }: ReceiptRootProps) {
|
|
45
|
+
const [status, setStatus] = useState<CopyStatus | null>(null);
|
|
46
|
+
const [hashButtons, setHashButtons] = useState(0);
|
|
47
|
+
const addHashButton = useCallback(() => {
|
|
48
|
+
setHashButtons((n) => n + 1);
|
|
49
|
+
return () => setHashButtons((n) => n - 1);
|
|
50
|
+
}, []);
|
|
51
|
+
const copy = useCallback(async (text: string, what: string, from: CopyStatus['from']) => {
|
|
52
|
+
try {
|
|
53
|
+
await (onCopy ? onCopy(text) : navigator.clipboard.writeText(text));
|
|
54
|
+
setStatus({ text: `${what} copied.`, ok: true, from });
|
|
55
|
+
} catch {
|
|
56
|
+
setStatus({ text: 'Could not copy here. Select the text and copy it.', ok: false, from });
|
|
57
|
+
}
|
|
58
|
+
}, [onCopy]);
|
|
59
|
+
const context = useMemo<ReceiptContext>(() => ({ receipt, receiptText, copy, status, hashButtons, addHashButton }), [receipt, receiptText, copy, status, hashButtons, addHashButton]);
|
|
60
|
+
const onKeyDown = (event: KeyboardEvent<HTMLElement>) => {
|
|
61
|
+
if (resolveReceiptKey(event.nativeEvent, event.target as HTMLElement) !== 'copy-hash') return;
|
|
62
|
+
event.preventDefault();
|
|
63
|
+
event.stopPropagation(); // Enter and y are not for the page's shortcuts while a receipt is focused
|
|
64
|
+
void copy(receipt.resultHash, 'Result hash', 'key');
|
|
65
|
+
};
|
|
66
|
+
return (
|
|
67
|
+
<Context.Provider value={context}>
|
|
68
|
+
<section className={className} aria-label={label} data-synthetic={receipt.synthetic ? '' : undefined} data-verdict={receiptVerdict(receipt)} onKeyDown={onKeyDown}>
|
|
69
|
+
{children}
|
|
70
|
+
</section>
|
|
71
|
+
</Context.Provider>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** One labelled fact: a `<dt>` and a `<dd>` for a `<dl>`. */
|
|
76
|
+
function Field({ label, children }: { label: string; children: ReactNode }) {
|
|
77
|
+
return (<><dt>{label}</dt><dd>{children}</dd></>);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A digest as recorded, in a focusable `<code>` so a keyboard user can land on it and press Enter or `y`. */
|
|
81
|
+
function Hash({ of, className }: { of: ReceiptHashOf; className?: string }) {
|
|
82
|
+
const { receipt } = useReceipt('Hash');
|
|
83
|
+
return <code className={className} data-mt-numeric="" tabIndex={0}>{receiptHash(receipt, of)}</code>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A button that copies the whole receipt (or just the result hash), then says whether it worked. Only the button that
|
|
88
|
+
* copied shows the status; a copy by Enter or y shows under the hash button, or the receipt button when there is none.
|
|
89
|
+
*/
|
|
90
|
+
function CopyButton({ what = 'receipt', className, statusClassName, children }: { what?: 'receipt' | 'hash'; className?: string; statusClassName?: string; children?: ReactNode }) {
|
|
91
|
+
const { receipt, receiptText, copy, status, hashButtons, addHashButton } = useReceipt('CopyButton');
|
|
92
|
+
useEffect(() => (what === 'hash' ? addHashButton() : undefined), [what, addHashButton]);
|
|
93
|
+
const shown = status !== null && (status.from === what || (status.from === 'key' && (what === 'hash' || hashButtons === 0))) ? status : null;
|
|
94
|
+
return (
|
|
95
|
+
<>
|
|
96
|
+
<button type="button" className={className} onClick={() => void (what === 'hash' ? copy(receipt.resultHash, 'Result hash', 'hash') : copy(receiptText, 'Receipt', 'receipt'))}>
|
|
97
|
+
{children ?? (what === 'hash' ? 'Copy result hash' : 'Copy receipt')}
|
|
98
|
+
</button>
|
|
99
|
+
<Notice as="span" kind={shown === null ? 'info' : shown.ok ? 'ok' : 'error'} className={statusClassName}>{shown?.text ?? ''}</Notice>
|
|
100
|
+
</>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The line that must travel with a copy of non-synthetic data; nothing when there is none. */
|
|
105
|
+
function ShareNote({ className }: { className?: string }) {
|
|
106
|
+
const { receipt } = useReceipt('ShareNote');
|
|
107
|
+
if (!receipt.shareNote) return null;
|
|
108
|
+
return <p className={className}>{receipt.shareNote}</p>;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Whether replaying from the start gave the same result, in words. Tokens.css colours a mismatch. */
|
|
112
|
+
function Verdict({ className, matchText = 'Same two states and the same result hash.', mismatchText = 'Did not reproduce the same result.' }: { className?: string; matchText?: string; mismatchText?: string }) {
|
|
113
|
+
const { receipt } = useReceipt('Verdict');
|
|
114
|
+
const verdict = receiptVerdict(receipt);
|
|
115
|
+
return <span className={className} data-mt="" data-verdict={verdict}>{verdict === 'match' ? matchText : mismatchText}</span>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export { Root, Field, Hash, CopyButton, ShareNote, Verdict };
|
package/src/Scrubber.tsx
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { Root, Track, Thumb, Mark } from './ScrubberParts.js';
|
|
2
|
+
|
|
3
|
+
// The parts live in ScrubberParts.js (a client module). The namespace object is built here, outside it, so a server
|
|
4
|
+
// component can write `Scrubber.Root`: a property of an object exported from a client module is not reachable there.
|
|
5
|
+
export * from './ScrubberParts.js';
|
|
6
|
+
export const Scrubber = { Root, Track, Thumb, Mark };
|