@stocksharp/trading-controls 0.1.0 → 1.1.0
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/README.md +92 -6
- package/dist/esm/active-orders-widget.js +9 -0
- package/dist/esm/active-orders-widget.js.map +1 -1
- package/dist/esm/control-types.js +3 -0
- package/dist/esm/control-types.js.map +1 -1
- package/dist/esm/grid-menu.js +54 -0
- package/dist/esm/grid-menu.js.map +1 -0
- package/dist/esm/index.js +8 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/order-entry-widget.js +654 -0
- package/dist/esm/order-entry-widget.js.map +1 -0
- package/dist/esm/orderbook-depth.js +51 -0
- package/dist/esm/orderbook-depth.js.map +1 -0
- package/dist/esm/orderbook-widget.js +792 -0
- package/dist/esm/orderbook-widget.js.map +1 -0
- package/dist/esm/positions-widget.js +9 -0
- package/dist/esm/positions-widget.js.map +1 -1
- package/dist/esm/trade-history-widget.js +8 -0
- package/dist/esm/trade-history-widget.js.map +1 -1
- package/dist/esm/tradefeed-aggregator.js +58 -0
- package/dist/esm/tradefeed-aggregator.js.map +1 -0
- package/dist/esm/tradefeed-bubbles.js +131 -0
- package/dist/esm/tradefeed-bubbles.js.map +1 -0
- package/dist/esm/tradefeed-widget.js +692 -0
- package/dist/esm/tradefeed-widget.js.map +1 -0
- package/dist/esm/trading-host.js +2 -0
- package/dist/esm/trading-host.js.map +1 -1
- package/dist/esm/watchlist-widget.js +2 -0
- package/dist/esm/watchlist-widget.js.map +1 -1
- package/dist/sstradingcontrols.js +3346 -25
- package/dist/sstradingcontrols.js.map +3 -3
- package/dist/types/active-orders-widget.d.ts.map +1 -1
- package/dist/types/control-types.d.ts +3 -0
- package/dist/types/control-types.d.ts.map +1 -1
- package/dist/types/grid-menu.d.ts +4 -0
- package/dist/types/grid-menu.d.ts.map +1 -0
- package/dist/types/index.d.ts +13 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/order-entry-widget.d.ts +86 -0
- package/dist/types/order-entry-widget.d.ts.map +1 -0
- package/dist/types/orderbook-depth.d.ts +19 -0
- package/dist/types/orderbook-depth.d.ts.map +1 -0
- package/dist/types/orderbook-widget.d.ts +105 -0
- package/dist/types/orderbook-widget.d.ts.map +1 -0
- package/dist/types/positions-widget.d.ts.map +1 -1
- package/dist/types/trade-history-widget.d.ts.map +1 -1
- package/dist/types/tradefeed-aggregator.d.ts +15 -0
- package/dist/types/tradefeed-aggregator.d.ts.map +1 -0
- package/dist/types/tradefeed-bubbles.d.ts +39 -0
- package/dist/types/tradefeed-bubbles.d.ts.map +1 -0
- package/dist/types/tradefeed-widget.d.ts +78 -0
- package/dist/types/tradefeed-widget.d.ts.map +1 -0
- package/dist/types/trading-data.d.ts +22 -0
- package/dist/types/trading-data.d.ts.map +1 -1
- package/dist/types/trading-host.d.ts +8 -0
- package/dist/types/trading-host.d.ts.map +1 -1
- package/dist/types/watchlist-widget.d.ts.map +1 -1
- package/package.json +21 -2
- package/screenshots/panels.jpg +0 -0
- package/src/active-orders-widget.ts +9 -0
- package/src/control-types.ts +3 -0
- package/src/grid-menu.ts +67 -0
- package/src/index.ts +31 -0
- package/src/order-entry-widget.ts +733 -0
- package/src/orderbook-depth.ts +99 -0
- package/src/orderbook-widget.ts +896 -0
- package/src/positions-widget.ts +9 -0
- package/src/trade-history-widget.ts +8 -0
- package/src/tradefeed-aggregator.ts +95 -0
- package/src/tradefeed-bubbles.ts +194 -0
- package/src/tradefeed-widget.ts +744 -0
- package/src/trading-data.ts +168 -121
- package/src/trading-host.ts +326 -300
- package/src/watchlist-widget.ts +2 -0
- package/styles/theme.css +23 -0
- package/styles/trading-controls.css +911 -0
- package/translation-keys.json +103 -2
package/src/positions-widget.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { formatPnl, formatPrice } from './formatters.js';
|
|
16
16
|
import { makeElement, makeIconButton, makePanelId, makePanelRoot } from './dom.js';
|
|
17
17
|
import { ControlTypes } from './control-types.js';
|
|
18
|
+
import { makeGridMenu } from './grid-menu.js';
|
|
18
19
|
import { TradingHost, assertHost } from './trading-host.js';
|
|
19
20
|
import type { BalanceRow, PositionRow } from './trading-data.js';
|
|
20
21
|
import { DataGrid, GridColumn, GridPinnedRow, GridPinnedPlacements } from '@stocksharp/grids/source/data-grid';
|
|
@@ -135,6 +136,11 @@ export class PositionsWidget {
|
|
|
135
136
|
// Re-read on every render, so the cash figures track the live balance
|
|
136
137
|
// without the widget having to repaint the position rows itself.
|
|
137
138
|
pinnedRows: () => this._pinnedRows(),
|
|
139
|
+
contextMenu: makeGridMenu(this._host),
|
|
140
|
+
// Press to mark, drag or Shift to range, Ctrl to toggle, Ctrl+C
|
|
141
|
+
// to copy — the pinned cash row stays outside the selection the
|
|
142
|
+
// same way it stays outside the sort.
|
|
143
|
+
selection: 'multi',
|
|
138
144
|
})
|
|
139
145
|
: null;
|
|
140
146
|
|
|
@@ -145,6 +151,9 @@ export class PositionsWidget {
|
|
|
145
151
|
}
|
|
146
152
|
|
|
147
153
|
dispose(): void {
|
|
154
|
+
// The grid holds document-level listeners (the copy shortcut) that
|
|
155
|
+
// outlive a removed subtree — it has to be told, not just detached.
|
|
156
|
+
this._grid?.destroy();
|
|
148
157
|
this._host.unregister(this);
|
|
149
158
|
try { this.rootEl.remove(); } catch { /* already detached */ }
|
|
150
159
|
}
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { formatPrice, formatQty, formatTime } from './formatters.js';
|
|
16
16
|
import { makeElement, makeIconButton, makePanelId, makePanelRoot } from './dom.js';
|
|
17
17
|
import { ControlTypes } from './control-types.js';
|
|
18
|
+
import { makeGridMenu } from './grid-menu.js';
|
|
18
19
|
import { TradingHost, assertHost } from './trading-host.js';
|
|
19
20
|
import type { TradeRow } from './trading-data.js';
|
|
20
21
|
import { DataGrid, GridColumn } from '@stocksharp/grids/source/data-grid';
|
|
@@ -109,6 +110,10 @@ export class TradeHistoryWidget {
|
|
|
109
110
|
defaultSort: { col: 'time', dir: 'desc' },
|
|
110
111
|
rowKey: (t) => String(t.id),
|
|
111
112
|
emptyText: this._host.t('No trade history'),
|
|
113
|
+
contextMenu: makeGridMenu(this._host),
|
|
114
|
+
// Press to mark, drag or Shift to range, Ctrl to toggle, Ctrl+C
|
|
115
|
+
// to copy — all the grid's own.
|
|
116
|
+
selection: 'multi',
|
|
112
117
|
})
|
|
113
118
|
: null;
|
|
114
119
|
|
|
@@ -116,6 +121,9 @@ export class TradeHistoryWidget {
|
|
|
116
121
|
}
|
|
117
122
|
|
|
118
123
|
dispose(): void {
|
|
124
|
+
// The grid holds document-level listeners (the copy shortcut) that
|
|
125
|
+
// outlive a removed subtree — it has to be told, not just detached.
|
|
126
|
+
this._grid?.destroy();
|
|
119
127
|
this._host.unregister(this);
|
|
120
128
|
try { this.rootEl.remove(); } catch { /* already detached */ }
|
|
121
129
|
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Bubble compaction for a tape.
|
|
2
|
+
//
|
|
3
|
+
// One lane of a bubble chart is one symbol's ticks, and a busy symbol delivers
|
|
4
|
+
// far more of them than a lane has room for: drawn one circle per print, the
|
|
5
|
+
// lane melts into a solid wall. Bucketing by time slice and side turns that
|
|
6
|
+
// back into something readable — at most two bubbles per slice, each placed at
|
|
7
|
+
// its slice's volume-weighted average price and sized by its summed volume.
|
|
8
|
+
//
|
|
9
|
+
// Pure: no DOM, no host, no clock. The trade feed renders what comes out of
|
|
10
|
+
// here, which is what lets the arithmetic a busy symbol depends on be covered
|
|
11
|
+
// directly rather than through a canvas.
|
|
12
|
+
import type { OrderSide } from './trading-data.js';
|
|
13
|
+
|
|
14
|
+
/// One print, with the two things the tape has already decided about it: which
|
|
15
|
+
/// direction it went, and where it sits on the shared time axis.
|
|
16
|
+
///
|
|
17
|
+
/// `buy` is resolved by the host's presentation before a tick reaches this
|
|
18
|
+
/// module — the wire spells a side three ways and normalising it is the host's
|
|
19
|
+
/// job, not this file's — while `side` keeps the raw value, because wording it
|
|
20
|
+
/// is the host's job too and only it can do that. `index` is the tick's
|
|
21
|
+
/// position in the WHOLE feed rather than in its lane, so two symbols with
|
|
22
|
+
/// wildly different prices still flow along one time axis.
|
|
23
|
+
export interface FeedTick {
|
|
24
|
+
symbol: string;
|
|
25
|
+
side: OrderSide;
|
|
26
|
+
buy: boolean;
|
|
27
|
+
price: number;
|
|
28
|
+
quantity: number;
|
|
29
|
+
time: string;
|
|
30
|
+
index: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/// One bubble: a tick, or the slice of ticks it stands for.
|
|
34
|
+
export interface FeedBubble extends FeedTick {
|
|
35
|
+
/// How many ticks this bubble represents — 1 when it is a tick itself, and
|
|
36
|
+
/// what the tooltip reads to say "VWAP" instead of "Price".
|
|
37
|
+
count: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/// Bucket one lane's ticks into at most `maxBucketsPerSide` bubbles per side.
|
|
41
|
+
///
|
|
42
|
+
/// Below that budget every tick passes through as its own one-tick bubble, so a
|
|
43
|
+
/// sparse symbol still shows every print. Above it, each (side, slice) merges
|
|
44
|
+
/// into one bubble. `ticks` is newest-first, which is how the tape holds them,
|
|
45
|
+
/// so the first slice is the most recent block.
|
|
46
|
+
export function aggregateBubbles(ticks: FeedTick[], maxBucketsPerSide: number): FeedBubble[] {
|
|
47
|
+
if (!ticks || ticks.length === 0) return [];
|
|
48
|
+
const budget = Math.max(1, Math.floor(maxBucketsPerSide));
|
|
49
|
+
|
|
50
|
+
// The two directions compete for the same horizontal room, so each gets its
|
|
51
|
+
// own budget rather than the busier one crowding the other out.
|
|
52
|
+
const buys: FeedTick[] = [];
|
|
53
|
+
const sells: FeedTick[] = [];
|
|
54
|
+
for (const tick of ticks) (tick.buy ? buys : sells).push(tick);
|
|
55
|
+
|
|
56
|
+
const bubbles: FeedBubble[] = [];
|
|
57
|
+
for (const side of [buys, sells]) {
|
|
58
|
+
if (side.length === 0) continue;
|
|
59
|
+
if (side.length <= budget) {
|
|
60
|
+
for (const tick of side) bubbles.push({ ...tick, count: 1 });
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const bucketSize = Math.ceil(side.length / budget);
|
|
64
|
+
for (let start = 0; start < side.length; start += bucketSize)
|
|
65
|
+
bubbles.push(merge(side.slice(start, start + bucketSize)));
|
|
66
|
+
}
|
|
67
|
+
return bubbles;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/// One slice as a single bubble: total volume, volume-weighted price, and the
|
|
71
|
+
/// slice's middle tick for its position on the time axis.
|
|
72
|
+
function merge(slice: FeedTick[]): FeedBubble {
|
|
73
|
+
let quantity = 0;
|
|
74
|
+
let weighted = 0;
|
|
75
|
+
for (const tick of slice) {
|
|
76
|
+
const q = tick.quantity || 0;
|
|
77
|
+
quantity += q;
|
|
78
|
+
weighted += q * tick.price;
|
|
79
|
+
}
|
|
80
|
+
// A slice can legitimately total zero volume (an all-zero-quantity tape is
|
|
81
|
+
// rare live and ordinary in a fixture), and dividing by it would place the
|
|
82
|
+
// bubble at NaN — nowhere, silently. Its first price is the honest answer.
|
|
83
|
+
const price = quantity > 0 ? weighted / quantity : slice[0].price;
|
|
84
|
+
const middle = slice[Math.floor(slice.length / 2)];
|
|
85
|
+
return {
|
|
86
|
+
symbol: slice[0].symbol,
|
|
87
|
+
side: slice[0].side,
|
|
88
|
+
buy: slice[0].buy,
|
|
89
|
+
price,
|
|
90
|
+
quantity,
|
|
91
|
+
time: middle.time,
|
|
92
|
+
index: middle.index,
|
|
93
|
+
count: slice.length,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// Where every bubble, rule and label of a bubble chart goes.
|
|
2
|
+
//
|
|
3
|
+
// The trade feed's second rendering is a scatter of prints: time along X,
|
|
4
|
+
// price up Y, volume as the radius, direction as the colour. It is drawn on a
|
|
5
|
+
// canvas, which is the one surface a class name cannot reach — so the geometry
|
|
6
|
+
// is computed here, as numbers, and the control does nothing but stroke and
|
|
7
|
+
// fill what comes back.
|
|
8
|
+
//
|
|
9
|
+
// Pure, and that is the point: a fake canvas that swallows drawing calls proves
|
|
10
|
+
// nothing, while the arithmetic below — lanes sharing one time axis, a price
|
|
11
|
+
// scale per symbol, a radius that stays inside its lane — is exactly what can
|
|
12
|
+
// be wrong. It is covered directly.
|
|
13
|
+
import { aggregateBubbles, type FeedBubble, type FeedTick } from './tradefeed-aggregator.js';
|
|
14
|
+
|
|
15
|
+
/// One symbol's prints. A feed following a single symbol has one unnamed lane;
|
|
16
|
+
/// pinning extras splits the height into one lane each so a $76k symbol and a
|
|
17
|
+
/// $270 one both stay visible instead of one flattening the other.
|
|
18
|
+
export interface BubbleLane {
|
|
19
|
+
symbol: string;
|
|
20
|
+
ticks: FeedTick[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface BubbleLayoutInput {
|
|
24
|
+
width: number;
|
|
25
|
+
height: number;
|
|
26
|
+
lanes: BubbleLane[];
|
|
27
|
+
/// How many ticks the whole feed holds. The X axis is a tick's index in
|
|
28
|
+
/// that list, so lanes flow along one shared time axis rather than each
|
|
29
|
+
/// stretching its own count across the full width.
|
|
30
|
+
total: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/// One bubble, placed.
|
|
34
|
+
export interface BubbleShape {
|
|
35
|
+
x: number;
|
|
36
|
+
y: number;
|
|
37
|
+
radius: number;
|
|
38
|
+
bubble: FeedBubble;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/// One price label on the right-hand axis, and the rule leading to it.
|
|
42
|
+
export interface BubbleAxisTick {
|
|
43
|
+
text: string;
|
|
44
|
+
y: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/// One lane, placed. `label` is null for a feed following a single symbol —
|
|
48
|
+
/// naming the only lane says nothing — and `separatorY` is null for the first
|
|
49
|
+
/// lane, which has nothing above it to be divided from.
|
|
50
|
+
export interface BubbleLaneShape {
|
|
51
|
+
symbol: string;
|
|
52
|
+
label: { x: number; y: number } | null;
|
|
53
|
+
separatorY: number | null;
|
|
54
|
+
ticks: BubbleAxisTick[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface BubbleLayout {
|
|
58
|
+
/// Left edge of the axis strip: rules stop just short of it, labels start
|
|
59
|
+
/// just past it.
|
|
60
|
+
axisX: number;
|
|
61
|
+
/// Where an axis label's text begins.
|
|
62
|
+
labelX: number;
|
|
63
|
+
lanes: BubbleLaneShape[];
|
|
64
|
+
/// Every bubble in draw order — oldest first, so the newest print lands on
|
|
65
|
+
/// top of what it overlaps. Hit-testing walks this backwards for the same
|
|
66
|
+
/// reason.
|
|
67
|
+
bubbles: BubbleShape[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Geometry. These are the chart's proportions, not the host's looks: a canvas
|
|
71
|
+
// has no cascade to read them from, and moving them into CSS would only mean
|
|
72
|
+
// reading them back out again.
|
|
73
|
+
const PAD_X = 10;
|
|
74
|
+
const PAD_Y = 10;
|
|
75
|
+
const AXIS_WIDTH = 56;
|
|
76
|
+
const LABEL_GAP = 4;
|
|
77
|
+
const LANE_GAP = 2;
|
|
78
|
+
const MIN_LANE_HEIGHT = 20;
|
|
79
|
+
const AXIS_TICKS = 3;
|
|
80
|
+
const MIN_RADIUS = 2;
|
|
81
|
+
const MAX_RADIUS = 22;
|
|
82
|
+
/// A bubble may grow to this share of its lane's height, so a lane with one
|
|
83
|
+
/// enormous print does not draw a circle taller than the lane it sits in.
|
|
84
|
+
const RADIUS_LANE_SHARE = 0.4;
|
|
85
|
+
/// Horizontal room one bucket wants. Narrower and the lane reads as a wall;
|
|
86
|
+
/// wider and a sparse symbol turns into a handful of lonely dots.
|
|
87
|
+
const BUCKET_WIDTH = 8;
|
|
88
|
+
const MIN_BUCKETS = 6;
|
|
89
|
+
const MAX_BUCKETS = 30;
|
|
90
|
+
|
|
91
|
+
/// Place every lane and every bubble for one paint.
|
|
92
|
+
export function layoutBubbles(input: BubbleLayoutInput): BubbleLayout {
|
|
93
|
+
const { width, height, total } = input;
|
|
94
|
+
const axisX = Math.max(PAD_X + 1, width - AXIS_WIDTH);
|
|
95
|
+
const drawWidth = Math.max(1, axisX - PAD_X);
|
|
96
|
+
const layout: BubbleLayout = { axisX, labelX: axisX + LABEL_GAP, lanes: [], bubbles: [] };
|
|
97
|
+
|
|
98
|
+
const lanes = input.lanes.filter(lane => lane.ticks.length > 0);
|
|
99
|
+
if (lanes.length === 0) return layout;
|
|
100
|
+
|
|
101
|
+
const named = input.lanes.length > 1;
|
|
102
|
+
const laneHeight = Math.max(
|
|
103
|
+
MIN_LANE_HEIGHT,
|
|
104
|
+
(height - 2 * PAD_Y - LANE_GAP * (input.lanes.length - 1)) / input.lanes.length);
|
|
105
|
+
|
|
106
|
+
// Placed against the DECLARED lanes, not the non-empty ones: a symbol that
|
|
107
|
+
// has not printed yet keeps its slot rather than letting the lanes below it
|
|
108
|
+
// slide up and back down on its first tick.
|
|
109
|
+
input.lanes.forEach((lane, position) => {
|
|
110
|
+
const laneTop = named ? PAD_Y + position * (laneHeight + LANE_GAP) : PAD_Y;
|
|
111
|
+
const laneSpan = named ? laneHeight : Math.max(MIN_LANE_HEIGHT, height - 2 * PAD_Y);
|
|
112
|
+
const scale = priceScale(lane.ticks, laneTop, laneSpan);
|
|
113
|
+
|
|
114
|
+
layout.lanes.push({
|
|
115
|
+
symbol: lane.symbol,
|
|
116
|
+
label: named ? { x: LABEL_GAP, y: laneTop + 2 } : null,
|
|
117
|
+
separatorY: named && position > 0 ? laneTop - LANE_GAP / 2 : null,
|
|
118
|
+
ticks: lane.ticks.length === 0 ? [] : axisTicks(scale),
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
if (lane.ticks.length === 0) return;
|
|
122
|
+
const buckets = Math.max(MIN_BUCKETS, Math.min(MAX_BUCKETS, Math.floor(drawWidth / BUCKET_WIDTH)));
|
|
123
|
+
const bubbles = aggregateBubbles(lane.ticks, buckets);
|
|
124
|
+
let maxQuantity = 0;
|
|
125
|
+
for (const bubble of bubbles) if (bubble.quantity > maxQuantity) maxQuantity = bubble.quantity;
|
|
126
|
+
const quantityScale = Math.max(maxQuantity, Number.EPSILON);
|
|
127
|
+
const radiusCap = Math.min(MAX_RADIUS, laneSpan * RADIUS_LANE_SHARE);
|
|
128
|
+
|
|
129
|
+
for (const bubble of bubbles) {
|
|
130
|
+
layout.bubbles.push({
|
|
131
|
+
// Newest print (index 0) at the right edge, oldest at the left.
|
|
132
|
+
x: PAD_X + drawWidth * (total <= 1 ? 1 : 1 - bubble.index / (total - 1)),
|
|
133
|
+
y: scale.top + scale.height * (1 - (bubble.price - scale.min) / scale.range),
|
|
134
|
+
radius: MIN_RADIUS + Math.sqrt(Math.max(0, bubble.quantity) / quantityScale) * radiusCap,
|
|
135
|
+
bubble,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// Oldest first: a later print draws over an earlier one, and the hit test
|
|
141
|
+
// reads the same list backwards so the circle on top is the one picked.
|
|
142
|
+
layout.bubbles.sort((left, right) => right.bubble.index - left.bubble.index);
|
|
143
|
+
return layout;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
interface PriceScale {
|
|
147
|
+
top: number;
|
|
148
|
+
height: number;
|
|
149
|
+
min: number;
|
|
150
|
+
range: number;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/// The lane's price range, as the Y axis. `range` is never zero — a lane whose
|
|
154
|
+
/// prints are all at one price would otherwise divide by it — so a flat lane
|
|
155
|
+
/// draws along its own top edge rather than nowhere.
|
|
156
|
+
function priceScale(ticks: FeedTick[], top: number, height: number): PriceScale {
|
|
157
|
+
let min = Infinity;
|
|
158
|
+
let max = -Infinity;
|
|
159
|
+
for (const tick of ticks) {
|
|
160
|
+
if (tick.price < min) min = tick.price;
|
|
161
|
+
if (tick.price > max) max = tick.price;
|
|
162
|
+
}
|
|
163
|
+
if (!isFinite(min) || !isFinite(max)) { min = 0; max = 0; }
|
|
164
|
+
return { top, height, min, range: Math.max(max - min, Number.EPSILON) };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/// Three labels down the lane: its high, its middle and its low.
|
|
168
|
+
function axisTicks(scale: PriceScale): BubbleAxisTick[] {
|
|
169
|
+
const format = priceFormatter(scale.min, scale.min + scale.range);
|
|
170
|
+
const ticks: BubbleAxisTick[] = [];
|
|
171
|
+
for (let step = 0; step < AXIS_TICKS; step++) {
|
|
172
|
+
const fraction = step / (AXIS_TICKS - 1);
|
|
173
|
+
ticks.push({
|
|
174
|
+
text: format((scale.min + scale.range) - scale.range * fraction),
|
|
175
|
+
y: scale.top + scale.height * fraction,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
return ticks;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/// Decimals chosen from the lane's magnitude. A fixed precision is wrong at
|
|
182
|
+
/// both ends: two decimals on a $76,000 print is noise, and none on a $0.04 one
|
|
183
|
+
/// collapses the whole lane onto a single label.
|
|
184
|
+
export function priceFormatter(min: number, max: number): (price: number) => string {
|
|
185
|
+
const magnitude = Math.max(Math.abs(min), Math.abs(max));
|
|
186
|
+
let digits = 6;
|
|
187
|
+
if (magnitude >= 1000) digits = 0;
|
|
188
|
+
else if (magnitude >= 10) digits = 2;
|
|
189
|
+
else if (magnitude >= 0.1) digits = 4;
|
|
190
|
+
return (price: number) => price.toLocaleString(undefined, {
|
|
191
|
+
minimumFractionDigits: digits,
|
|
192
|
+
maximumFractionDigits: digits,
|
|
193
|
+
});
|
|
194
|
+
}
|