@stocksharp/trading-controls 0.1.0 → 1.1.1
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 +22 -3
- 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
|
@@ -0,0 +1,744 @@
|
|
|
1
|
+
// Trade feed — multi-instance.
|
|
2
|
+
//
|
|
3
|
+
// The public tape: every print for the symbol the page is showing, plus any
|
|
4
|
+
// extra instruments this instance has pinned, in one of two renderings. The
|
|
5
|
+
// list is a row per print; the bubble chart is the same prints scattered by
|
|
6
|
+
// time and price, sized by volume and coloured by direction. Which of the two
|
|
7
|
+
// is showing is shared across every feed on the page through the host's
|
|
8
|
+
// preference store; the pinned extras belong to the instance and ride in its
|
|
9
|
+
// persisted state.
|
|
10
|
+
//
|
|
11
|
+
// Builds its own DOM (see `_buildRoot`) rather than cloning a <template> out of
|
|
12
|
+
// the page, so it can be constructed by any host that supplies a `TradingHost`.
|
|
13
|
+
//
|
|
14
|
+
// Ticks are pushed in — `setTrades` for a fresh symbol, `addTrade` per print —
|
|
15
|
+
// because a host fans one socket to every live feed rather than each of them
|
|
16
|
+
// subscribing again. The one thing the panel pulls is the account's own fills,
|
|
17
|
+
// on the My-trades tab, through `trading.api.getExecutions`.
|
|
18
|
+
//
|
|
19
|
+
// The chart is a <canvas>, the single surface a class name cannot reach: its
|
|
20
|
+
// geometry comes out of `tradefeed-bubbles.ts` as numbers and its colours out
|
|
21
|
+
// of the host's `canvasPalette()`, so the package still paints nothing from a
|
|
22
|
+
// palette of its own.
|
|
23
|
+
import { formatPrice, formatQty, formatTime } from './formatters.js';
|
|
24
|
+
import { makeElement, makeIconButton, makePanelId, makePanelRoot } from './dom.js';
|
|
25
|
+
import { ControlTypes } from './control-types.js';
|
|
26
|
+
import { makeGridMenu } from './grid-menu.js';
|
|
27
|
+
import { MarketDataLevels, TradingHost, assertHost } from './trading-host.js';
|
|
28
|
+
import type { TradeRow } from './trading-data.js';
|
|
29
|
+
import type { FeedBubble, FeedTick } from './tradefeed-aggregator.js';
|
|
30
|
+
import { layoutBubbles, type BubbleLane, type BubbleShape } from './tradefeed-bubbles.js';
|
|
31
|
+
import { DataGrid, GridColumn } from '@stocksharp/grids/source/data-grid';
|
|
32
|
+
|
|
33
|
+
// A print carries no id of its own, so the widget stamps one on arrival — the
|
|
34
|
+
// grid addresses rows by key, and a tape with fifty anonymous rows would have
|
|
35
|
+
// no identities to diff, select or flash by.
|
|
36
|
+
type KeyedTrade = TradeRow & { _k?: number };
|
|
37
|
+
|
|
38
|
+
/// The panel needs nothing beyond the host port. It reports no application
|
|
39
|
+
/// action: a tape is read, and the two gestures it does make — pin an
|
|
40
|
+
/// instrument, open another feed — are the host's own `pickInstrument` and
|
|
41
|
+
/// `spawn`.
|
|
42
|
+
export interface TradeFeedDeps {
|
|
43
|
+
host: TradingHost;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Opacity, not colour. The hue of every stroke below is the host's
|
|
47
|
+
// `canvasPalette`; how solid it is drawn is the chart's own business, and
|
|
48
|
+
// keeping the two apart is what lets one palette serve a light theme and a dark
|
|
49
|
+
// one without the package knowing which it is in.
|
|
50
|
+
const FILL_ALPHA = 0.55;
|
|
51
|
+
const STROKE_ALPHA = 0.9;
|
|
52
|
+
const LABEL_ALPHA = 0.55;
|
|
53
|
+
const SEPARATOR_ALPHA = 0.12;
|
|
54
|
+
const RULE_ALPHA = 0.08;
|
|
55
|
+
|
|
56
|
+
// How far from a bubble's edge a cursor still counts as over it. Small bubbles
|
|
57
|
+
// are otherwise impossible to hit.
|
|
58
|
+
const HOVER_SLACK = 6;
|
|
59
|
+
// Gap between the cursor and the tooltip, and between the tooltip and the edge
|
|
60
|
+
// it is clamped against.
|
|
61
|
+
const TOOLTIP_OFFSET = 12;
|
|
62
|
+
const TOOLTIP_MARGIN = 4;
|
|
63
|
+
|
|
64
|
+
export class TradeFeedWidget {
|
|
65
|
+
static TYPE = ControlTypes.TradeFeed;
|
|
66
|
+
/// Shared across every trade feed on the page: which of the two renderings
|
|
67
|
+
/// the user last chose. The value is what a user's stored settings already
|
|
68
|
+
/// hold, so it is spelled exactly as it always was.
|
|
69
|
+
static VIEW_KEY = 'terminal.tradeFeedView';
|
|
70
|
+
/// Rows kept in the list, and prints kept for the chart. The chart holds
|
|
71
|
+
/// far more because it compacts them: a bubble stands for a slice.
|
|
72
|
+
static MAX_ROWS = 50;
|
|
73
|
+
static MAX_BUBBLES = 500;
|
|
74
|
+
|
|
75
|
+
rootEl: HTMLElement;
|
|
76
|
+
extrasEl: HTMLElement | null;
|
|
77
|
+
bubbleCanvas: HTMLCanvasElement | null;
|
|
78
|
+
trades: TradeRow[];
|
|
79
|
+
bubbleTrades: TradeRow[];
|
|
80
|
+
myTrades: TradeRow[];
|
|
81
|
+
avgQty: number;
|
|
82
|
+
tab: string;
|
|
83
|
+
view: string;
|
|
84
|
+
// `//` rather than `///` from here down — see the note in positions-widget.
|
|
85
|
+
_host: TradingHost;
|
|
86
|
+
_marketGrid: DataGrid<TradeRow> | null;
|
|
87
|
+
_myGrid: DataGrid<TradeRow> | null;
|
|
88
|
+
// The stamp the next anonymous print gets — see KeyedTrade.
|
|
89
|
+
_seq: number;
|
|
90
|
+
_tabsEl: HTMLElement | null;
|
|
91
|
+
_viewToggleEl: HTMLElement | null;
|
|
92
|
+
_tooltipEl: HTMLElement | null;
|
|
93
|
+
_addBtn: HTMLElement | null;
|
|
94
|
+
_closeBtn: HTMLElement | null;
|
|
95
|
+
_addSymbolBtn: HTMLElement | null;
|
|
96
|
+
_bubbleCtx: CanvasRenderingContext2D | null;
|
|
97
|
+
// Rebuilt on every paint: where each bubble landed and what it stands for.
|
|
98
|
+
// Hover walks it backwards so the circle drawn last — the newest print —
|
|
99
|
+
// wins wherever two overlap.
|
|
100
|
+
_bubbleHits: BubbleShape[];
|
|
101
|
+
_canvasSize: { width: number; height: number } | null;
|
|
102
|
+
_resizeObserver: ResizeObserver | null;
|
|
103
|
+
_activeSymbol: string | null;
|
|
104
|
+
_extraSymbols: Set<string>;
|
|
105
|
+
|
|
106
|
+
static create(hostEl: HTMLElement, state: Record<string, unknown>, deps: TradeFeedDeps): TradeFeedWidget {
|
|
107
|
+
// Assert before building: the markup below is localized through the
|
|
108
|
+
// host, so a missing host has to fail here rather than render a panel
|
|
109
|
+
// captioned with raw English keys.
|
|
110
|
+
const host = assertHost(deps?.host, 'TradeFeedWidget');
|
|
111
|
+
const root = TradeFeedWidget._buildRoot(host);
|
|
112
|
+
root.id = makePanelId(TradeFeedWidget.TYPE);
|
|
113
|
+
hostEl.appendChild(root);
|
|
114
|
+
return new TradeFeedWidget(root, state || {}, deps);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// The panel's markup. The host stylesheet reads this structure, and a
|
|
118
|
+
// docking host lifts `.panel-header`'s children into its tab strip.
|
|
119
|
+
//
|
|
120
|
+
// Two `+` buttons, and they are not the same gesture: the one in the header
|
|
121
|
+
// pins another instrument to THIS feed, the one beside it asks the host for
|
|
122
|
+
// another feed panel. The tooltips are what tell them apart, so neither is
|
|
123
|
+
// an icon on its own.
|
|
124
|
+
static _buildRoot(host: TradingHost): HTMLElement {
|
|
125
|
+
const addInstrument = host.t('AddInstrument');
|
|
126
|
+
const marketTrades = host.t('MarketTrades');
|
|
127
|
+
return makePanelRoot('tradefeed-panel tf-tab-market tf-view-list', host.t('TradeFeed'), [
|
|
128
|
+
makeElement('div', 'panel-header', {}, [
|
|
129
|
+
makeElement('span', '', {}, [marketTrades]),
|
|
130
|
+
makeIconButton('bt-icon-btn tf-add-symbol-btn', addInstrument, 'bi-plus-lg', { type: 'button' }),
|
|
131
|
+
makeIconButton('bt-icon-btn panel-add-btn', host.t('Add trade feed'), 'bi-window-plus', { type: 'button' }),
|
|
132
|
+
makeElement('div', 'tradefeed-view-toggle tf-view-toggle', { role: 'group', 'aria-label': host.t('TradeFeedView') }, [
|
|
133
|
+
makeIconButton('tf-view-btn', host.t('ListView'), 'bi-list-ul', { type: 'button', 'data-view': 'list' }),
|
|
134
|
+
makeIconButton('tf-view-btn', host.t('BubbleChart'), 'bi-circle-fill', { type: 'button', 'data-view': 'bubbles' }),
|
|
135
|
+
]),
|
|
136
|
+
makeIconButton('bt-icon-btn bt-icon-cancel panel-close-btn', host.t('ClosePanel'), 'bi-x', { type: 'button' }),
|
|
137
|
+
]),
|
|
138
|
+
makeElement('div', 'tf-tabs', { role: 'tablist', 'aria-label': host.t('Trade feed tabs') }, [
|
|
139
|
+
makeElement('button', 'tf-tab active', { type: 'button', role: 'tab', 'aria-selected': 'true', 'data-tab': 'market' }, [marketTrades]),
|
|
140
|
+
makeElement('button', 'tf-tab', { type: 'button', role: 'tab', 'aria-selected': 'false', 'data-tab': 'my' }, [host.t('My trades')]),
|
|
141
|
+
]),
|
|
142
|
+
makeElement('div', 'tf-extras', { role: 'list', 'aria-label': host.t('ExtraInstruments'), hidden: '' }, []),
|
|
143
|
+
// Each tape is a DataGrid over the shared table skin: the <thead>
|
|
144
|
+
// is left empty for the grid to fill, which is also what makes the
|
|
145
|
+
// tape sortable and gives it the grid's menu and selection.
|
|
146
|
+
makeElement('div', 'tradefeed-content tf-market', { 'aria-live': 'polite' }, [
|
|
147
|
+
makeElement('table', 'terminal-table tradefeed-table', { role: 'table', 'aria-label': marketTrades }, [
|
|
148
|
+
makeElement('thead', '', {}, []),
|
|
149
|
+
makeElement('tbody', '', {}, []),
|
|
150
|
+
]),
|
|
151
|
+
]),
|
|
152
|
+
makeElement('div', 'tradefeed-content tf-my', {}, [
|
|
153
|
+
makeElement('table', 'terminal-table tradefeed-table', { role: 'table', 'aria-label': host.t('My trades') }, [
|
|
154
|
+
makeElement('thead', '', {}, []),
|
|
155
|
+
makeElement('tbody', '', {}, []),
|
|
156
|
+
]),
|
|
157
|
+
]),
|
|
158
|
+
makeElement('canvas', 'tradefeed-bubbles tf-bubbles', { 'aria-hidden': 'true' }, []),
|
|
159
|
+
makeElement('div', 'tf-bubble-tooltip', { role: 'tooltip', hidden: '' }, []),
|
|
160
|
+
]);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
constructor(rootEl: HTMLElement, state: Record<string, unknown>, deps: TradeFeedDeps) {
|
|
164
|
+
this._host = assertHost(deps?.host, 'TradeFeedWidget');
|
|
165
|
+
|
|
166
|
+
this.rootEl = rootEl;
|
|
167
|
+
this.extrasEl = this.rootEl.querySelector('.tf-extras');
|
|
168
|
+
this.bubbleCanvas = this.rootEl.querySelector('.tf-bubbles');
|
|
169
|
+
this._tabsEl = this.rootEl.querySelector('.tf-tabs');
|
|
170
|
+
this._viewToggleEl = this.rootEl.querySelector('.tf-view-toggle');
|
|
171
|
+
this._tooltipEl = this.rootEl.querySelector('.tf-bubble-tooltip');
|
|
172
|
+
this._addBtn = this.rootEl.querySelector('.panel-add-btn');
|
|
173
|
+
this._closeBtn = this.rootEl.querySelector('.panel-close-btn');
|
|
174
|
+
this._addSymbolBtn = this.rootEl.querySelector('.tf-add-symbol-btn');
|
|
175
|
+
this._bubbleCtx = this.bubbleCanvas?.getContext('2d') ?? null;
|
|
176
|
+
|
|
177
|
+
this.trades = [];
|
|
178
|
+
this.bubbleTrades = [];
|
|
179
|
+
this.myTrades = [];
|
|
180
|
+
// Seed rather than zero: the "unusually large" test is a multiple of the
|
|
181
|
+
// running average, and an average starting at zero would flag the first
|
|
182
|
+
// print of the session.
|
|
183
|
+
this.avgQty = 100;
|
|
184
|
+
this.tab = 'market';
|
|
185
|
+
this._seq = 0;
|
|
186
|
+
this._bubbleHits = [];
|
|
187
|
+
this._canvasSize = null;
|
|
188
|
+
this._resizeObserver = null;
|
|
189
|
+
|
|
190
|
+
this._marketGrid = this._makeGrid('.tf-market');
|
|
191
|
+
this._myGrid = this._makeGrid('.tf-my');
|
|
192
|
+
|
|
193
|
+
const saved = this._host.preferences.get(TradeFeedWidget.VIEW_KEY, null);
|
|
194
|
+
this.view = saved === 'bubbles' ? 'bubbles' : 'list';
|
|
195
|
+
|
|
196
|
+
// The page's active symbol drives the primary feed; extras are the
|
|
197
|
+
// instruments this instance pinned on top of it, each refcount-
|
|
198
|
+
// subscribed and shown with a symbol column.
|
|
199
|
+
this._activeSymbol = null;
|
|
200
|
+
this._extraSymbols = new Set(Array.isArray(state.extras) ? state.extras as string[] : []);
|
|
201
|
+
|
|
202
|
+
this._addBtn?.addEventListener('click', (e) => {
|
|
203
|
+
e.preventDefault();
|
|
204
|
+
this._host.spawn({ extras: [...this._extraSymbols] });
|
|
205
|
+
});
|
|
206
|
+
this._closeBtn?.addEventListener('click', (e) => {
|
|
207
|
+
e.preventDefault();
|
|
208
|
+
this.dispose();
|
|
209
|
+
this._host.close();
|
|
210
|
+
});
|
|
211
|
+
this._addSymbolBtn?.addEventListener('click', (e) => {
|
|
212
|
+
e.preventDefault();
|
|
213
|
+
e.stopPropagation();
|
|
214
|
+
this._host.trading.pickInstrument((symbol) => void this.addExtraSymbol(symbol));
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
this._bindTabs();
|
|
218
|
+
this._bindViewToggle();
|
|
219
|
+
this._bindBubbleHover();
|
|
220
|
+
this._applyView();
|
|
221
|
+
this._renderExtras();
|
|
222
|
+
|
|
223
|
+
// Resubscribe the persisted extras up front: without this a reload
|
|
224
|
+
// renders the chip and streams nothing behind it.
|
|
225
|
+
for (const symbol of this._extraSymbols)
|
|
226
|
+
void this._host.trading.marketData.addSymbol(symbol, MarketDataLevels.Tape).catch(() => { });
|
|
227
|
+
|
|
228
|
+
if (this.bubbleCanvas && typeof ResizeObserver === 'function') {
|
|
229
|
+
this._resizeObserver = new ResizeObserver(() => { this._sizeCanvas(); this._renderBubbles(); });
|
|
230
|
+
this._resizeObserver.observe(this.bubbleCanvas);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
this._host.register(this);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
dispose(): void {
|
|
237
|
+
try { this._resizeObserver?.disconnect(); } catch { /* already torn down */ }
|
|
238
|
+
// The grids hold document-level listeners (the copy shortcut) that
|
|
239
|
+
// outlive a removed subtree — they have to be told, not just detached.
|
|
240
|
+
this._marketGrid?.destroy();
|
|
241
|
+
this._myGrid?.destroy();
|
|
242
|
+
// Drop this feed's share of every extra so a symbol nobody else watches
|
|
243
|
+
// stops streaming — the client refcounts, so a neighbour keeps its own.
|
|
244
|
+
for (const symbol of this._extraSymbols)
|
|
245
|
+
void this._host.trading.marketData.removeSymbol(symbol).catch(() => { });
|
|
246
|
+
this._host.unregister(this);
|
|
247
|
+
try { this.rootEl.remove(); } catch { /* already detached */ }
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/// Which symbol the rest of the page is showing. The feed accepts prints
|
|
251
|
+
/// for it without it being pinned, and re-syncs so the symbol column
|
|
252
|
+
/// appears or disappears with the pinned set.
|
|
253
|
+
setActiveSymbol(symbol: string | null): void {
|
|
254
|
+
this._activeSymbol = symbol || null;
|
|
255
|
+
this._renderExtras();
|
|
256
|
+
this._renderBubbles();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/// Replace the tape wholesale — what a host does when the page moves to a
|
|
260
|
+
/// different instrument. The flash baseline resets with it: a new
|
|
261
|
+
/// instrument's history is history, not fifty arrivals at once.
|
|
262
|
+
setTrades(trades: TradeRow[]): void {
|
|
263
|
+
const rows = (trades || []).map(t => this._stamp(t));
|
|
264
|
+
this.trades = rows.slice(0, TradeFeedWidget.MAX_ROWS);
|
|
265
|
+
this.bubbleTrades = rows.slice(0, TradeFeedWidget.MAX_BUBBLES);
|
|
266
|
+
if (this.trades.length > 0) {
|
|
267
|
+
const total = this.trades.reduce((sum, t) => sum + (t.quantity ?? 0), 0);
|
|
268
|
+
this.avgQty = total / this.trades.length || this.avgQty;
|
|
269
|
+
}
|
|
270
|
+
this._marketGrid?.flashReset();
|
|
271
|
+
this._marketGrid?.setRows([...this.trades]);
|
|
272
|
+
this._renderBubbles();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/// One print off the wire. Ignored unless this feed watches its symbol:
|
|
276
|
+
/// a host fans every tick to every feed and each decides for itself, which
|
|
277
|
+
/// is the only arrangement that supports per-instance extras.
|
|
278
|
+
addTrade(trade: TradeRow): void {
|
|
279
|
+
if (!trade || !this._watches(trade.symbol)) return;
|
|
280
|
+
this._stamp(trade);
|
|
281
|
+
this.trades.unshift(trade);
|
|
282
|
+
if (this.trades.length > TradeFeedWidget.MAX_ROWS) this.trades.length = TradeFeedWidget.MAX_ROWS;
|
|
283
|
+
this.bubbleTrades.unshift(trade);
|
|
284
|
+
if (this.bubbleTrades.length > TradeFeedWidget.MAX_BUBBLES) this.bubbleTrades.length = TradeFeedWidget.MAX_BUBBLES;
|
|
285
|
+
// A rolling average rather than a re-sum: "unusually large" should track
|
|
286
|
+
// the recent tape, not the whole session.
|
|
287
|
+
this.avgQty = this.avgQty * 0.95 + (trade.quantity ?? 0) * 0.05;
|
|
288
|
+
|
|
289
|
+
// The grid diffs by key, and its flashNewClass is what marks the
|
|
290
|
+
// newcomer — the hand-built prepend this replaces did both by hand.
|
|
291
|
+
this._marketGrid?.setRows([...this.trades]);
|
|
292
|
+
this._renderBubbles();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/// The account's own fills for the My-trades tab. The one thing this panel
|
|
296
|
+
/// pulls rather than being pushed.
|
|
297
|
+
async loadMyTrades(portfolioId: number | null, symbol: string | null): Promise<void> {
|
|
298
|
+
if (!portfolioId) {
|
|
299
|
+
this.myTrades = [];
|
|
300
|
+
this._renderMy();
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
try {
|
|
304
|
+
this.myTrades = await this._host.trading.api.getExecutions(portfolioId, symbol, TradeFeedWidget.MAX_ROWS) || [];
|
|
305
|
+
} catch (err) {
|
|
306
|
+
// Through the port, not the console: this is the diagnostic the
|
|
307
|
+
// user cannot see and support has to, and routing it here is also
|
|
308
|
+
// what makes it assertable.
|
|
309
|
+
this._host.log(`TradeFeedWidget: failed to load own trades: ${err}`);
|
|
310
|
+
this.myTrades = [];
|
|
311
|
+
}
|
|
312
|
+
this._renderMy();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ---------------------------------------------------------------- the grid
|
|
316
|
+
|
|
317
|
+
// One construction for both tapes: same columns, same chrome, different
|
|
318
|
+
// tbody. The container holds the scroll; the grid holds the rows.
|
|
319
|
+
_makeGrid(containerSelector: string): DataGrid<TradeRow> | null {
|
|
320
|
+
const head = this.rootEl.querySelector(`${containerSelector} thead`);
|
|
321
|
+
const body = this.rootEl.querySelector(`${containerSelector} tbody`);
|
|
322
|
+
if (!head || !body) return null;
|
|
323
|
+
return new DataGrid<TradeRow>({
|
|
324
|
+
head: head as HTMLElement,
|
|
325
|
+
body: body as HTMLElement,
|
|
326
|
+
columns: this._columns(),
|
|
327
|
+
// Newest print on top — the only order a tape is read in at rest;
|
|
328
|
+
// a header click can still reorder, and clears back to this.
|
|
329
|
+
defaultSort: { col: 'time', dir: 'desc' },
|
|
330
|
+
rowKey: (t) => TradeFeedWidget._key(t),
|
|
331
|
+
emptyText: this._host.t('No trades yet'),
|
|
332
|
+
rowClass: (t) => this._rowClass(t),
|
|
333
|
+
contextMenu: makeGridMenu(this._host),
|
|
334
|
+
selection: 'multi',
|
|
335
|
+
// The grid marks what is new since the previous paint; the
|
|
336
|
+
// stylesheet decides what a flash looks like.
|
|
337
|
+
flashNewClass: 'flash-new',
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Prints arrive anonymous; executions arrive with ids. Stamp the former
|
|
342
|
+
// once, on arrival, and key both without ever colliding.
|
|
343
|
+
_stamp(trade: TradeRow): TradeRow {
|
|
344
|
+
const keyed = trade as KeyedTrade;
|
|
345
|
+
if (keyed._k == null && keyed.id == null) keyed._k = ++this._seq;
|
|
346
|
+
return trade;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
static _key(trade: TradeRow): string {
|
|
350
|
+
const keyed = trade as KeyedTrade;
|
|
351
|
+
return keyed.id != null ? String(keyed.id) : `p${keyed._k}`;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// The direction class sits on the row, so price and side inherit its
|
|
355
|
+
// colour while time and qty state their own — plus the "unusually large"
|
|
356
|
+
// mark, measured against the rolling average as of this paint.
|
|
357
|
+
_rowClass(trade: TradeRow): string {
|
|
358
|
+
const classes = this._host.presentation.sideClass(trade.side!).split(' ').filter(Boolean);
|
|
359
|
+
if ((trade.quantity ?? 0) > this.avgQty * 2) classes.push('large-trade');
|
|
360
|
+
return classes.join(' ');
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// The tape's single column declaration, shared by both tabs. `time` sorts
|
|
364
|
+
// by the wire timestamp and shows the clock; the symbol column exists
|
|
365
|
+
// always and is hidden until a second instrument is in the feed.
|
|
366
|
+
_columns(): GridColumn<TradeRow>[] {
|
|
367
|
+
const label = (key: string) => this._host.t(key);
|
|
368
|
+
const presentation = this._host.presentation;
|
|
369
|
+
return [
|
|
370
|
+
{
|
|
371
|
+
key: 'time',
|
|
372
|
+
header: label('Time'),
|
|
373
|
+
exportable: true,
|
|
374
|
+
value: (t) => String(t.time || t.executedAt || ''),
|
|
375
|
+
render: (t) => formatTime(t.time || t.executedAt),
|
|
376
|
+
},
|
|
377
|
+
{
|
|
378
|
+
key: 'symbol',
|
|
379
|
+
header: label('Sym'),
|
|
380
|
+
exportable: true,
|
|
381
|
+
value: (t) => t.symbol,
|
|
382
|
+
},
|
|
383
|
+
{
|
|
384
|
+
key: 'price',
|
|
385
|
+
header: label('Price'),
|
|
386
|
+
exportable: true,
|
|
387
|
+
value: (t) => t.price,
|
|
388
|
+
render: (t) => formatPrice(t.price),
|
|
389
|
+
},
|
|
390
|
+
{
|
|
391
|
+
key: 'quantity',
|
|
392
|
+
header: label('Qty'),
|
|
393
|
+
exportable: true,
|
|
394
|
+
value: (t) => t.quantity,
|
|
395
|
+
render: (t) => formatQty(t.quantity),
|
|
396
|
+
},
|
|
397
|
+
{
|
|
398
|
+
key: 'side',
|
|
399
|
+
header: label('Side'),
|
|
400
|
+
exportable: true,
|
|
401
|
+
value: (t) => t.side,
|
|
402
|
+
render: (t) => presentation.sideText(t.side!),
|
|
403
|
+
exportValue: (t) => presentation.sideText(t.side!),
|
|
404
|
+
},
|
|
405
|
+
];
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Both tapes show the symbol column exactly while a second instrument is
|
|
409
|
+
// in the feed — through the grid's own column visibility, so the user's
|
|
410
|
+
// hide/show from the menu still works the rest of the time.
|
|
411
|
+
_syncSymbolColumn(): void {
|
|
412
|
+
const multi = this._extraSymbols.size > 0;
|
|
413
|
+
for (const grid of [this._marketGrid, this._myGrid]) {
|
|
414
|
+
if (!grid) continue;
|
|
415
|
+
if (multi) grid.showColumn('symbol');
|
|
416
|
+
else grid.hideColumn('symbol');
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/// Pin an extra instrument to this feed: subscribe it, show it alongside
|
|
421
|
+
/// the primary, and remember it in the instance's state.
|
|
422
|
+
async addExtraSymbol(symbol: string): Promise<void> {
|
|
423
|
+
if (!symbol) return;
|
|
424
|
+
const sym = String(symbol).toUpperCase();
|
|
425
|
+
if (sym === this._activeSymbol || this._extraSymbols.has(sym)) return;
|
|
426
|
+
this._extraSymbols.add(sym);
|
|
427
|
+
// A tape needs the prints, and only the prints — the depth `Full` would
|
|
428
|
+
// add is bandwidth this panel never renders.
|
|
429
|
+
try { await this._host.trading.marketData.addSymbol(sym, MarketDataLevels.Tape); } catch { /* socket race */ }
|
|
430
|
+
this._renderExtras();
|
|
431
|
+
this._renderBubbles();
|
|
432
|
+
this._persistExtras();
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/// Unpin one, dropping its subscription and the prints already on screen.
|
|
436
|
+
async removeExtraSymbol(symbol: string): Promise<void> {
|
|
437
|
+
if (!symbol) return;
|
|
438
|
+
const sym = String(symbol).toUpperCase();
|
|
439
|
+
if (!this._extraSymbols.has(sym)) return;
|
|
440
|
+
this._extraSymbols.delete(sym);
|
|
441
|
+
try { await this._host.trading.marketData.removeSymbol(sym); } catch { /* socket race */ }
|
|
442
|
+
this.trades = this.trades.filter(t => t.symbol !== sym);
|
|
443
|
+
this.bubbleTrades = this.bubbleTrades.filter(t => t.symbol !== sym);
|
|
444
|
+
this._marketGrid?.setRows([...this.trades]);
|
|
445
|
+
this._renderExtras();
|
|
446
|
+
this._renderBubbles();
|
|
447
|
+
this._persistExtras();
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
getExtraSymbols(): string[] {
|
|
451
|
+
return [...this._extraSymbols];
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Record what this instance pins, then ask for the layout to be flushed.
|
|
455
|
+
// Two calls because they are two decisions: what the instance remembers,
|
|
456
|
+
// and whether that is worth writing out now. Pinning is — losing the pin on
|
|
457
|
+
// the next reload is exactly the complaint.
|
|
458
|
+
_persistExtras(): void {
|
|
459
|
+
this._host.persistState({ extras: [...this._extraSymbols] });
|
|
460
|
+
this._host.saveLayout();
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Does this feed show `symbol` — as the page's active one, or pinned?
|
|
464
|
+
_watches(symbol: string | undefined): boolean {
|
|
465
|
+
if (!symbol) return false;
|
|
466
|
+
return symbol === this._activeSymbol || this._extraSymbols.has(symbol);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ---------------------------------------------------------------- the list
|
|
470
|
+
|
|
471
|
+
_renderMy(): void {
|
|
472
|
+
this._myGrid?.setRows(this.myTrades.map(t => this._stamp(t)));
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
_renderExtras(): void {
|
|
476
|
+
// Every path that changes the pinned set comes through here, so the
|
|
477
|
+
// symbol column tracks the set without a second call at each site.
|
|
478
|
+
this._syncSymbolColumn();
|
|
479
|
+
if (!this.extrasEl) return;
|
|
480
|
+
if (this._extraSymbols.size === 0) {
|
|
481
|
+
this.extrasEl.replaceChildren();
|
|
482
|
+
this.extrasEl.setAttribute('hidden', '');
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
this.extrasEl.removeAttribute('hidden');
|
|
486
|
+
const remove = this._host.t('Remove');
|
|
487
|
+
const chips = [...this._extraSymbols].map((symbol) => {
|
|
488
|
+
const button = makeIconButton('tf-extra-rm', remove, 'bi-x', { type: 'button' });
|
|
489
|
+
button.addEventListener('click', (e) => {
|
|
490
|
+
e.preventDefault();
|
|
491
|
+
e.stopPropagation();
|
|
492
|
+
void this.removeExtraSymbol(symbol);
|
|
493
|
+
});
|
|
494
|
+
return makeElement('span', 'tf-extra-chip', { role: 'listitem' }, [
|
|
495
|
+
makeElement('span', 'tf-extra-sym', {}, [symbol]),
|
|
496
|
+
button,
|
|
497
|
+
]);
|
|
498
|
+
});
|
|
499
|
+
this.extrasEl.replaceChildren(...chips);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// --------------------------------------------------------------- the views
|
|
503
|
+
|
|
504
|
+
_bindTabs(): void {
|
|
505
|
+
this._tabsEl?.addEventListener('click', (e) => {
|
|
506
|
+
const button = (e.target as Element | null)?.closest('.tf-tab') as HTMLElement | null;
|
|
507
|
+
if (!button) return;
|
|
508
|
+
this._selectTab(button.dataset.tab || 'market');
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
_selectTab(tab: string): void {
|
|
513
|
+
this.tab = tab === 'my' ? 'my' : 'market';
|
|
514
|
+
this._tabsEl?.querySelectorAll('.tf-tab').forEach((b) => {
|
|
515
|
+
const selected = (b as HTMLElement).dataset.tab === this.tab;
|
|
516
|
+
b.classList.toggle('active', selected);
|
|
517
|
+
b.setAttribute('aria-selected', selected ? 'true' : 'false');
|
|
518
|
+
});
|
|
519
|
+
this.rootEl.classList.toggle('tf-tab-market', this.tab === 'market');
|
|
520
|
+
this.rootEl.classList.toggle('tf-tab-my', this.tab === 'my');
|
|
521
|
+
// The tab is also the request: opening it is when the fills are worth
|
|
522
|
+
// fetching, and the portfolio is read per use because the user can have
|
|
523
|
+
// switched account under a panel that is already on screen.
|
|
524
|
+
if (this.tab === 'my') void this.loadMyTrades(this._host.trading.portfolioId(), this._activeSymbol);
|
|
525
|
+
else this._renderBubbles();
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
_bindViewToggle(): void {
|
|
529
|
+
this._viewToggleEl?.addEventListener('click', (e) => {
|
|
530
|
+
const button = (e.target as Element | null)?.closest('.tf-view-btn') as HTMLElement | null;
|
|
531
|
+
if (!button) return;
|
|
532
|
+
const next = button.dataset.view;
|
|
533
|
+
if ((next !== 'list' && next !== 'bubbles') || next === this.view) return;
|
|
534
|
+
this.view = next;
|
|
535
|
+
this._host.preferences.set(TradeFeedWidget.VIEW_KEY, next);
|
|
536
|
+
this._applyView();
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Which rendering is showing is two class names on the root, not a display
|
|
541
|
+
// written onto elements: a narrow viewport forces the list back on, and
|
|
542
|
+
// that is a stylesheet's decision to make, not this control's.
|
|
543
|
+
_applyView(): void {
|
|
544
|
+
const list = this.view === 'list';
|
|
545
|
+
this.rootEl.classList.toggle('tf-view-list', list);
|
|
546
|
+
this.rootEl.classList.toggle('tf-view-bubbles', !list);
|
|
547
|
+
this._viewToggleEl?.querySelectorAll('.tf-view-btn').forEach((b) => {
|
|
548
|
+
const selected = (b as HTMLElement).dataset.view === this.view;
|
|
549
|
+
b.classList.toggle('active', selected);
|
|
550
|
+
b.setAttribute('aria-pressed', selected ? 'true' : 'false');
|
|
551
|
+
});
|
|
552
|
+
if (!list) {
|
|
553
|
+
this._sizeCanvas();
|
|
554
|
+
this._renderBubbles();
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// -------------------------------------------------------------- the chart
|
|
559
|
+
|
|
560
|
+
// The canvas in CSS pixels, and its backing store in device pixels. Without
|
|
561
|
+
// the second the chart is drawn at a third of the resolution the screen has.
|
|
562
|
+
_sizeCanvas(): void {
|
|
563
|
+
const canvas = this.bubbleCanvas;
|
|
564
|
+
if (!canvas) return;
|
|
565
|
+
const rect = canvas.getBoundingClientRect();
|
|
566
|
+
const ratio = typeof devicePixelRatio === 'number' && devicePixelRatio > 0 ? devicePixelRatio : 1;
|
|
567
|
+
const width = Math.max(1, Math.floor(rect.width));
|
|
568
|
+
const height = Math.max(1, Math.floor(rect.height));
|
|
569
|
+
if (canvas.width !== width * ratio || canvas.height !== height * ratio) {
|
|
570
|
+
canvas.width = width * ratio;
|
|
571
|
+
canvas.height = height * ratio;
|
|
572
|
+
this._bubbleCtx?.setTransform(ratio, 0, 0, ratio, 0, 0);
|
|
573
|
+
}
|
|
574
|
+
this._canvasSize = { width, height };
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// The prints as the chart reads them: direction decided once by the host,
|
|
578
|
+
// and a position on the shared time axis. Newest first, as the tape holds
|
|
579
|
+
// them.
|
|
580
|
+
_feedTicks(): FeedTick[] {
|
|
581
|
+
const presentation = this._host.presentation;
|
|
582
|
+
return this.bubbleTrades.map((trade, index) => ({
|
|
583
|
+
symbol: trade.symbol || '',
|
|
584
|
+
side: trade.side!,
|
|
585
|
+
buy: presentation.isBuy(trade.side!),
|
|
586
|
+
price: Number(trade.price ?? 0),
|
|
587
|
+
quantity: Number(trade.quantity ?? 0),
|
|
588
|
+
time: trade.time || trade.executedAt || '',
|
|
589
|
+
index,
|
|
590
|
+
}));
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// One lane per watched symbol, active first. A feed with nothing pinned is
|
|
594
|
+
// one lane over everything it holds, which is also the only case where the
|
|
595
|
+
// lane needs no name.
|
|
596
|
+
_lanes(ticks: FeedTick[]): BubbleLane[] {
|
|
597
|
+
if (this._extraSymbols.size === 0)
|
|
598
|
+
return [{ symbol: this._activeSymbol || '', ticks }];
|
|
599
|
+
const symbols = this._activeSymbol ? [this._activeSymbol] : [];
|
|
600
|
+
for (const symbol of this._extraSymbols) if (!symbols.includes(symbol)) symbols.push(symbol);
|
|
601
|
+
return symbols.map(symbol => ({ symbol, ticks: ticks.filter(t => t.symbol === symbol) }));
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
_renderBubbles(): void {
|
|
605
|
+
const ctx = this._bubbleCtx;
|
|
606
|
+
if (!ctx || this.view !== 'bubbles') return;
|
|
607
|
+
if (!this._canvasSize) this._sizeCanvas();
|
|
608
|
+
const size = this._canvasSize;
|
|
609
|
+
if (!size) return;
|
|
610
|
+
|
|
611
|
+
ctx.clearRect(0, 0, size.width, size.height);
|
|
612
|
+
this._bubbleHits = [];
|
|
613
|
+
const ticks = this._feedTicks();
|
|
614
|
+
if (ticks.length === 0) return;
|
|
615
|
+
|
|
616
|
+
const layout = layoutBubbles({ width: size.width, height: size.height, total: ticks.length, lanes: this._lanes(ticks) });
|
|
617
|
+
const palette = this._host.presentation.canvasPalette();
|
|
618
|
+
ctx.font = palette.font;
|
|
619
|
+
ctx.lineWidth = 1;
|
|
620
|
+
|
|
621
|
+
for (const lane of layout.lanes) {
|
|
622
|
+
if (lane.separatorY !== null) {
|
|
623
|
+
ctx.globalAlpha = SEPARATOR_ALPHA;
|
|
624
|
+
ctx.strokeStyle = palette.grid;
|
|
625
|
+
TradeFeedWidget._rule(ctx, 0, size.width, lane.separatorY);
|
|
626
|
+
}
|
|
627
|
+
if (lane.label) {
|
|
628
|
+
ctx.globalAlpha = LABEL_ALPHA;
|
|
629
|
+
ctx.fillStyle = palette.grid;
|
|
630
|
+
ctx.textBaseline = 'top';
|
|
631
|
+
ctx.textAlign = 'left';
|
|
632
|
+
ctx.fillText(lane.symbol, lane.label.x, lane.label.y);
|
|
633
|
+
}
|
|
634
|
+
for (const tick of lane.ticks) {
|
|
635
|
+
ctx.globalAlpha = RULE_ALPHA;
|
|
636
|
+
ctx.strokeStyle = palette.grid;
|
|
637
|
+
TradeFeedWidget._rule(ctx, 0, layout.axisX - 2, tick.y);
|
|
638
|
+
ctx.globalAlpha = LABEL_ALPHA;
|
|
639
|
+
ctx.fillStyle = palette.grid;
|
|
640
|
+
ctx.textBaseline = 'middle';
|
|
641
|
+
ctx.textAlign = 'left';
|
|
642
|
+
ctx.fillText(tick.text, layout.labelX, tick.y);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
for (const shape of layout.bubbles) {
|
|
647
|
+
const colour = shape.bubble.buy ? palette.up : palette.down;
|
|
648
|
+
ctx.fillStyle = colour;
|
|
649
|
+
ctx.strokeStyle = colour;
|
|
650
|
+
ctx.beginPath();
|
|
651
|
+
ctx.arc(shape.x, shape.y, shape.radius, 0, Math.PI * 2);
|
|
652
|
+
// Filled softly and outlined firmly, so overlapping prints stay
|
|
653
|
+
// countable instead of merging into one blob.
|
|
654
|
+
ctx.globalAlpha = FILL_ALPHA;
|
|
655
|
+
ctx.fill();
|
|
656
|
+
ctx.globalAlpha = STROKE_ALPHA;
|
|
657
|
+
ctx.stroke();
|
|
658
|
+
this._bubbleHits.push(shape);
|
|
659
|
+
}
|
|
660
|
+
ctx.globalAlpha = 1;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
static _rule(ctx: CanvasRenderingContext2D, from: number, to: number, y: number): void {
|
|
664
|
+
ctx.beginPath();
|
|
665
|
+
ctx.moveTo(from, y);
|
|
666
|
+
ctx.lineTo(to, y);
|
|
667
|
+
ctx.stroke();
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
_bindBubbleHover(): void {
|
|
671
|
+
const canvas = this.bubbleCanvas;
|
|
672
|
+
if (!canvas) return;
|
|
673
|
+
canvas.addEventListener('mousemove', (e: MouseEvent) => {
|
|
674
|
+
if (this.view !== 'bubbles') { this._hideTooltip(); return; }
|
|
675
|
+
const rect = canvas.getBoundingClientRect();
|
|
676
|
+
const x = e.clientX - rect.left;
|
|
677
|
+
const y = e.clientY - rect.top;
|
|
678
|
+
const hit = this._hitTest(x, y);
|
|
679
|
+
if (hit) this._showTooltip(hit.bubble, x, y);
|
|
680
|
+
else this._hideTooltip();
|
|
681
|
+
});
|
|
682
|
+
canvas.addEventListener('mouseleave', () => this._hideTooltip());
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Backwards, so the circle drawn last — the newest print, on top — is the
|
|
686
|
+
// one a cursor over both picks.
|
|
687
|
+
_hitTest(x: number, y: number): BubbleShape | null {
|
|
688
|
+
for (let i = this._bubbleHits.length - 1; i >= 0; i--) {
|
|
689
|
+
const shape = this._bubbleHits[i];
|
|
690
|
+
const dx = x - shape.x;
|
|
691
|
+
const dy = y - shape.y;
|
|
692
|
+
const reach = Math.max(shape.radius, HOVER_SLACK);
|
|
693
|
+
if (dx * dx + dy * dy <= reach * reach) return shape;
|
|
694
|
+
}
|
|
695
|
+
return null;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
_showTooltip(bubble: FeedBubble, x: number, y: number): void {
|
|
699
|
+
const tooltip = this._tooltipEl;
|
|
700
|
+
const canvas = this.bubbleCanvas;
|
|
701
|
+
if (!tooltip || !canvas) return;
|
|
702
|
+
const presentation = this._host.presentation;
|
|
703
|
+
const aggregated = bubble.count > 1;
|
|
704
|
+
// The raw side the print carried, not a spelling this file picked:
|
|
705
|
+
// wording and colouring it are the host's, whichever of the three
|
|
706
|
+
// spellings its wire uses.
|
|
707
|
+
const side = bubble.side;
|
|
708
|
+
|
|
709
|
+
const lines: HTMLElement[] = [];
|
|
710
|
+
if (this._extraSymbols.size > 0) lines.push(this._tooltipLine(this._host.t('Symbol'), bubble.symbol, ''));
|
|
711
|
+
lines.push(this._tooltipLine(this._host.t('Time'), formatTime(bubble.time), ''));
|
|
712
|
+
lines.push(this._tooltipLine(aggregated ? this._host.t('VWAP') : this._host.t('Price'), formatPrice(bubble.price), ''));
|
|
713
|
+
lines.push(this._tooltipLine(aggregated ? this._host.t('Total qty') : this._host.t('Qty'), formatQty(bubble.quantity), ''));
|
|
714
|
+
lines.push(this._tooltipLine(this._host.t('Side'), presentation.sideText(side), presentation.sideClass(side)));
|
|
715
|
+
if (aggregated) lines.push(this._tooltipLine(this._host.t('Trades'), String(bubble.count), ''));
|
|
716
|
+
tooltip.replaceChildren(...lines);
|
|
717
|
+
|
|
718
|
+
// Measured, then placed: the size is only known once it is showing, and
|
|
719
|
+
// it is clamped inside the canvas so a print near the right or bottom
|
|
720
|
+
// edge does not push its own tooltip out of view.
|
|
721
|
+
tooltip.removeAttribute('hidden');
|
|
722
|
+
const canvasRect = canvas.getBoundingClientRect();
|
|
723
|
+
const parentRect = tooltip.offsetParent?.getBoundingClientRect() ?? canvasRect;
|
|
724
|
+
const originX = canvasRect.left - parentRect.left;
|
|
725
|
+
const originY = canvasRect.top - parentRect.top;
|
|
726
|
+
const maxX = originX + canvasRect.width - tooltip.offsetWidth - TOOLTIP_MARGIN;
|
|
727
|
+
const maxY = originY + canvasRect.height - tooltip.offsetHeight - TOOLTIP_MARGIN;
|
|
728
|
+
tooltip.style.left = `${Math.max(0, Math.min(originX + x + TOOLTIP_OFFSET, maxX))}px`;
|
|
729
|
+
tooltip.style.top = `${Math.max(0, Math.min(originY + y + TOOLTIP_OFFSET, maxY))}px`;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
_tooltipLine(label: string, value: string, valueClass: string): HTMLElement {
|
|
733
|
+
const row = makeElement('div', 'tf-bbtt-row', {}, [
|
|
734
|
+
makeElement('span', 'tf-bbtt-label', {}, [label]),
|
|
735
|
+
makeElement('span', 'tf-bbtt-value', {}, [value]),
|
|
736
|
+
]);
|
|
737
|
+
for (const name of valueClass.split(' ')) if (name) (row.childNodes[1] as HTMLElement).classList.add(name);
|
|
738
|
+
return row;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
_hideTooltip(): void {
|
|
742
|
+
this._tooltipEl?.setAttribute('hidden', '');
|
|
743
|
+
}
|
|
744
|
+
}
|