@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
|
@@ -26,17 +26,24 @@ var SSTradingControls = (() => {
|
|
|
26
26
|
ActiveOrdersWidget: () => ActiveOrdersWidget,
|
|
27
27
|
ControlTypes: () => ControlTypes,
|
|
28
28
|
MarketDataLevels: () => MarketDataLevels,
|
|
29
|
+
OrderBookWidget: () => OrderBookWidget,
|
|
30
|
+
OrderEntrySides: () => OrderEntrySides,
|
|
31
|
+
OrderEntryTypes: () => OrderEntryTypes,
|
|
32
|
+
OrderEntryWidget: () => OrderEntryWidget,
|
|
29
33
|
OrderStates: () => OrderStates,
|
|
30
34
|
PRESENTATION_CLASSES: () => PRESENTATION_CLASSES,
|
|
31
35
|
PositionsWidget: () => PositionsWidget,
|
|
36
|
+
TradeFeedWidget: () => TradeFeedWidget,
|
|
32
37
|
TradeHistoryWidget: () => TradeHistoryWidget,
|
|
33
38
|
WatchlistWidget: () => WatchlistWidget,
|
|
39
|
+
aggregateBubbles: () => aggregateBubbles,
|
|
34
40
|
assertHost: () => assertHost,
|
|
35
41
|
cleanRejectReason: () => cleanRejectReason,
|
|
36
42
|
formatPnl: () => formatPnl,
|
|
37
43
|
formatPrice: () => formatPrice,
|
|
38
44
|
formatQty: () => formatQty,
|
|
39
45
|
formatTime: () => formatTime,
|
|
46
|
+
layoutBubbles: () => layoutBubbles,
|
|
40
47
|
makeElement: () => makeElement,
|
|
41
48
|
makeIcon: () => makeIcon,
|
|
42
49
|
makeIconButton: () => makeIconButton,
|
|
@@ -63,10 +70,12 @@ var SSTradingControls = (() => {
|
|
|
63
70
|
_required(host.t, "host.t", "function", controlName);
|
|
64
71
|
_required(host.presentation, "host.presentation", "object", controlName);
|
|
65
72
|
_required(host.presentation.sideText, "host.presentation.sideText", "function", controlName);
|
|
73
|
+
_required(host.presentation.isBuy, "host.presentation.isBuy", "function", controlName);
|
|
66
74
|
_required(host.presentation.typeText, "host.presentation.typeText", "function", controlName);
|
|
67
75
|
_required(host.presentation.statusText, "host.presentation.statusText", "function", controlName);
|
|
68
76
|
_required(host.presentation.sideClass, "host.presentation.sideClass", "function", controlName);
|
|
69
77
|
_required(host.presentation.pnlClass, "host.presentation.pnlClass", "function", controlName);
|
|
78
|
+
_required(host.presentation.canvasPalette, "host.presentation.canvasPalette", "function", controlName);
|
|
70
79
|
_required(host.preferences, "host.preferences", "object", controlName);
|
|
71
80
|
_required(host.preferences.get, "host.preferences.get", "function", controlName);
|
|
72
81
|
_required(host.preferences.set, "host.preferences.set", "function", controlName);
|
|
@@ -107,7 +116,10 @@ var SSTradingControls = (() => {
|
|
|
107
116
|
Watchlist: "watchlist",
|
|
108
117
|
ActiveOrders: "activeOrders",
|
|
109
118
|
TradeHistory: "tradeHistory",
|
|
110
|
-
Positions: "positions"
|
|
119
|
+
Positions: "positions",
|
|
120
|
+
OrderBook: "orderbook",
|
|
121
|
+
TradeFeed: "tradefeed",
|
|
122
|
+
OrderEntry: "orderEntry"
|
|
111
123
|
};
|
|
112
124
|
|
|
113
125
|
// src/formatters.ts
|
|
@@ -181,6 +193,59 @@ var SSTradingControls = (() => {
|
|
|
181
193
|
return `panel-${type}-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e3).toString(36)}`;
|
|
182
194
|
}
|
|
183
195
|
|
|
196
|
+
// src/grid-menu.ts
|
|
197
|
+
function makeGridMenu(host) {
|
|
198
|
+
return {
|
|
199
|
+
labels: {
|
|
200
|
+
sortAsc: host.t("SortAscending"),
|
|
201
|
+
sortDesc: host.t("SortDescending"),
|
|
202
|
+
sortClear: host.t("ClearSort"),
|
|
203
|
+
hideColumn: host.t("HideColumn"),
|
|
204
|
+
showAllColumns: host.t("ShowAllColumns"),
|
|
205
|
+
groupBy: host.t("GroupByColumn"),
|
|
206
|
+
ungroup: host.t("Ungroup"),
|
|
207
|
+
filterByValue: (text) => host.t("Filter by this value: {0}", text),
|
|
208
|
+
filterRule: host.t("FilterRule"),
|
|
209
|
+
showFilters: host.t("ShowFilterRow"),
|
|
210
|
+
hideFilters: host.t("HideFilterRow"),
|
|
211
|
+
showHeader: host.t("ShowHeaderRow"),
|
|
212
|
+
hideHeader: host.t("HideHeaderRow"),
|
|
213
|
+
clearFilters: host.t("ClearFilters"),
|
|
214
|
+
copyCell: host.t("CopyCell"),
|
|
215
|
+
copyRow: host.t("CopyRow"),
|
|
216
|
+
copyRows: (count) => host.t("Copy selected rows ({0})", count),
|
|
217
|
+
exportXlsx: host.t("ExportToXlsx")
|
|
218
|
+
},
|
|
219
|
+
filterDialog: {
|
|
220
|
+
labels: {
|
|
221
|
+
title: (header) => host.t("Filter: {0}", header),
|
|
222
|
+
apply: host.t("Apply"),
|
|
223
|
+
clear: host.t("Clear"),
|
|
224
|
+
cancel: host.t("Cancel"),
|
|
225
|
+
value: host.t("Value"),
|
|
226
|
+
and: host.t("FilterAnd"),
|
|
227
|
+
ops: {
|
|
228
|
+
contains: host.t("FilterOpContains"),
|
|
229
|
+
notContains: host.t("FilterOpNotContains"),
|
|
230
|
+
startsWith: host.t("FilterOpStartsWith"),
|
|
231
|
+
endsWith: host.t("FilterOpEndsWith"),
|
|
232
|
+
eq: host.t("FilterOpEq"),
|
|
233
|
+
ne: host.t("FilterOpNe"),
|
|
234
|
+
gt: host.t("FilterOpGt"),
|
|
235
|
+
ge: host.t("FilterOpGe"),
|
|
236
|
+
lt: host.t("FilterOpLt"),
|
|
237
|
+
le: host.t("FilterOpLe"),
|
|
238
|
+
between: host.t("FilterOpBetween"),
|
|
239
|
+
anyOf: host.t("FilterOpAnyOf"),
|
|
240
|
+
noneOf: host.t("FilterOpNoneOf"),
|
|
241
|
+
empty: host.t("FilterOpEmpty"),
|
|
242
|
+
notEmpty: host.t("FilterOpNotEmpty")
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
184
249
|
// node_modules/@stocksharp/grids/src/table-sort.ts
|
|
185
250
|
var SortDirections = {
|
|
186
251
|
Asc: "asc",
|
|
@@ -189,19 +254,21 @@ var SSTradingControls = (() => {
|
|
|
189
254
|
function readByKey(row, key) {
|
|
190
255
|
return row === null || row === void 0 ? void 0 : row[key];
|
|
191
256
|
}
|
|
192
|
-
var TableSort = class
|
|
257
|
+
var TableSort = class {
|
|
193
258
|
// `defaultSort` is the order applied when no header is explicitly selected — the table's
|
|
194
259
|
// resting sort. Pass null only for a table that genuinely has no meaningful default (raw row
|
|
195
260
|
// order); every real blotter should name one so its first paint is deterministic.
|
|
196
|
-
constructor(headerEl, accessors, onChange, defaultSort) {
|
|
261
|
+
constructor(headerEl, accessors, onChange, defaultSort, collator) {
|
|
197
262
|
__publicField(this, "col");
|
|
198
263
|
__publicField(this, "dir");
|
|
199
264
|
__publicField(this, "_headerEl");
|
|
200
265
|
__publicField(this, "_accessors");
|
|
201
266
|
__publicField(this, "_onChange");
|
|
202
267
|
__publicField(this, "_default");
|
|
268
|
+
__publicField(this, "_collator");
|
|
203
269
|
this.col = null;
|
|
204
270
|
this.dir = SortDirections.Asc;
|
|
271
|
+
this._collator = collator;
|
|
205
272
|
this._headerEl = headerEl;
|
|
206
273
|
this._accessors = accessors || {};
|
|
207
274
|
this._onChange = onChange;
|
|
@@ -248,14 +315,40 @@ var SSTradingControls = (() => {
|
|
|
248
315
|
if (aEmpty && bEmpty) return 0;
|
|
249
316
|
if (aEmpty) return 1;
|
|
250
317
|
if (bEmpty) return -1;
|
|
251
|
-
return
|
|
318
|
+
return this._compare(av, bv) * factor;
|
|
252
319
|
});
|
|
253
320
|
}
|
|
254
|
-
|
|
321
|
+
_compare(a, b) {
|
|
255
322
|
const an = typeof a === "number" ? a : Number(a);
|
|
256
323
|
const bn = typeof b === "number" ? b : Number(b);
|
|
257
324
|
if (isFinite(an) && isFinite(bn)) return an - bn;
|
|
258
|
-
return String(a)
|
|
325
|
+
return this._collator.compare(String(a), String(b));
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* The column the user picked, or null while the table's own default is in effect.
|
|
329
|
+
*
|
|
330
|
+
* Deliberately not the effective sort: this is what gets stored and handed back
|
|
331
|
+
* to `set`, and storing the default as though it were a choice would pin it --
|
|
332
|
+
* a later change to the table's default would then never reach anyone who had
|
|
333
|
+
* ever looked at the table.
|
|
334
|
+
*/
|
|
335
|
+
current() {
|
|
336
|
+
return this.col ? { col: this.col, dir: this.dir } : null;
|
|
337
|
+
}
|
|
338
|
+
/** Pick a column, or pass null to fall back to the table's default. */
|
|
339
|
+
set(col, dir) {
|
|
340
|
+
this.col = col;
|
|
341
|
+
this.dir = dir || SortDirections.Asc;
|
|
342
|
+
this._updateHeader();
|
|
343
|
+
this._onChange();
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Re-mark the header cells. A grid that rebuilds its `<thead>` -- moving a column,
|
|
347
|
+
* hiding one -- throws away the marks with the old cells, and the rows would then
|
|
348
|
+
* sit sorted under a header that says nothing about why.
|
|
349
|
+
*/
|
|
350
|
+
refreshHeader() {
|
|
351
|
+
this._updateHeader();
|
|
259
352
|
}
|
|
260
353
|
_updateHeader() {
|
|
261
354
|
if (!this._headerEl) return;
|
|
@@ -269,6 +362,310 @@ var SSTradingControls = (() => {
|
|
|
269
362
|
}
|
|
270
363
|
};
|
|
271
364
|
|
|
365
|
+
// node_modules/@stocksharp/grids/src/context-menu.ts
|
|
366
|
+
var DEFAULTS = {
|
|
367
|
+
menu: "grid-menu",
|
|
368
|
+
item: "grid-menu-item",
|
|
369
|
+
separator: "grid-menu-separator",
|
|
370
|
+
disabled: "is-disabled",
|
|
371
|
+
checked: "is-checked"
|
|
372
|
+
};
|
|
373
|
+
var _GridContextMenu = class _GridContextMenu {
|
|
374
|
+
constructor(classes) {
|
|
375
|
+
__publicField(this, "_classes");
|
|
376
|
+
__publicField(this, "_el", null);
|
|
377
|
+
__publicField(this, "_dismiss", null);
|
|
378
|
+
this._classes = { ...DEFAULTS, ...classes || {} };
|
|
379
|
+
}
|
|
380
|
+
/** Whether this menu is the one currently on screen. */
|
|
381
|
+
get isOpen() {
|
|
382
|
+
return this._el !== null;
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Draw the items at (x, y) in viewport coordinates. Opening a menu closes
|
|
386
|
+
* whichever one was open, including this one -- a right-click inside an open
|
|
387
|
+
* menu means "somewhere else", not "two menus".
|
|
388
|
+
*/
|
|
389
|
+
open(items, x, y) {
|
|
390
|
+
_GridContextMenu._open?.close();
|
|
391
|
+
if (items.length === 0) return;
|
|
392
|
+
const el = document.createElement("div");
|
|
393
|
+
el.className = this._classes.menu;
|
|
394
|
+
el.setAttribute("role", "menu");
|
|
395
|
+
el.style.position = "fixed";
|
|
396
|
+
el.style.left = `${x}px`;
|
|
397
|
+
el.style.top = `${y}px`;
|
|
398
|
+
for (const item of items) el.appendChild(this._itemEl(item));
|
|
399
|
+
document.body.appendChild(el);
|
|
400
|
+
this._el = el;
|
|
401
|
+
_GridContextMenu._open = this;
|
|
402
|
+
const dismiss = (event) => {
|
|
403
|
+
if (event.type === "keydown" && event.key !== "Escape") return;
|
|
404
|
+
const target = event.target;
|
|
405
|
+
if (event.type === "mousedown" && target && el.contains(target)) return;
|
|
406
|
+
this.close();
|
|
407
|
+
};
|
|
408
|
+
document.addEventListener("mousedown", dismiss, true);
|
|
409
|
+
document.addEventListener("keydown", dismiss, true);
|
|
410
|
+
window.addEventListener("resize", dismiss);
|
|
411
|
+
window.addEventListener("scroll", dismiss, true);
|
|
412
|
+
this._dismiss = () => {
|
|
413
|
+
document.removeEventListener("mousedown", dismiss, true);
|
|
414
|
+
document.removeEventListener("keydown", dismiss, true);
|
|
415
|
+
window.removeEventListener("resize", dismiss);
|
|
416
|
+
window.removeEventListener("scroll", dismiss, true);
|
|
417
|
+
};
|
|
418
|
+
this._keepInside(el, x, y);
|
|
419
|
+
}
|
|
420
|
+
close() {
|
|
421
|
+
if (!this._el) return;
|
|
422
|
+
this._dismiss?.();
|
|
423
|
+
this._dismiss = null;
|
|
424
|
+
this._el.parentNode?.removeChild(this._el);
|
|
425
|
+
this._el = null;
|
|
426
|
+
if (_GridContextMenu._open === this) _GridContextMenu._open = null;
|
|
427
|
+
}
|
|
428
|
+
_itemEl(item) {
|
|
429
|
+
if (!item.label) {
|
|
430
|
+
const sep = document.createElement("div");
|
|
431
|
+
sep.className = this._classes.separator;
|
|
432
|
+
sep.setAttribute("role", "separator");
|
|
433
|
+
return sep;
|
|
434
|
+
}
|
|
435
|
+
const el = document.createElement("div");
|
|
436
|
+
el.className = this._classes.item;
|
|
437
|
+
if (item.disabled) el.className += ` ${this._classes.disabled}`;
|
|
438
|
+
if (item.checked) el.className += ` ${this._classes.checked}`;
|
|
439
|
+
el.setAttribute("role", "menuitem");
|
|
440
|
+
el.textContent = item.label;
|
|
441
|
+
if (!item.disabled && item.run) {
|
|
442
|
+
el.addEventListener("click", () => {
|
|
443
|
+
this.close();
|
|
444
|
+
item.run();
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
return el;
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Nudge the menu back inside the window when it was opened near an edge.
|
|
451
|
+
* Measured after it is in the document, because until then it has no size.
|
|
452
|
+
*/
|
|
453
|
+
_keepInside(el, x, y) {
|
|
454
|
+
const rect = typeof el.getBoundingClientRect === "function" ? el.getBoundingClientRect() : null;
|
|
455
|
+
if (!rect) return;
|
|
456
|
+
const width = typeof window !== "undefined" ? window.innerWidth : 0;
|
|
457
|
+
const height = typeof window !== "undefined" ? window.innerHeight : 0;
|
|
458
|
+
if (width && x + rect.width > width) el.style.left = `${Math.max(0, width - rect.width)}px`;
|
|
459
|
+
if (height && y + rect.height > height) el.style.top = `${Math.max(0, height - rect.height)}px`;
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
__publicField(_GridContextMenu, "_open", null);
|
|
463
|
+
var GridContextMenu = _GridContextMenu;
|
|
464
|
+
|
|
465
|
+
// node_modules/@stocksharp/grids/src/filter-dialog.ts
|
|
466
|
+
var DEFAULT_CLASSES = {
|
|
467
|
+
dialog: "grid-filter-dialog",
|
|
468
|
+
title: "grid-filter-dialog-title",
|
|
469
|
+
row: "grid-filter-dialog-row",
|
|
470
|
+
select: "grid-filter-dialog-op",
|
|
471
|
+
input: "grid-filter-dialog-value",
|
|
472
|
+
values: "grid-filter-dialog-values",
|
|
473
|
+
valuesItem: "grid-filter-dialog-values-item",
|
|
474
|
+
actions: "grid-filter-dialog-actions",
|
|
475
|
+
apply: "grid-filter-dialog-apply",
|
|
476
|
+
clear: "grid-filter-dialog-clear",
|
|
477
|
+
cancel: "grid-filter-dialog-cancel"
|
|
478
|
+
};
|
|
479
|
+
var DEFAULT_LABELS = {
|
|
480
|
+
title: (header) => `Filter: ${header}`,
|
|
481
|
+
apply: "Apply",
|
|
482
|
+
clear: "Clear",
|
|
483
|
+
cancel: "Cancel",
|
|
484
|
+
value: "Value",
|
|
485
|
+
and: "and",
|
|
486
|
+
ops: {
|
|
487
|
+
contains: "contains",
|
|
488
|
+
notContains: "does not contain",
|
|
489
|
+
startsWith: "starts with",
|
|
490
|
+
endsWith: "ends with",
|
|
491
|
+
eq: "=",
|
|
492
|
+
ne: "\u2260",
|
|
493
|
+
gt: ">",
|
|
494
|
+
ge: "\u2265",
|
|
495
|
+
lt: "<",
|
|
496
|
+
le: "\u2264",
|
|
497
|
+
between: "between",
|
|
498
|
+
anyOf: "is any of",
|
|
499
|
+
noneOf: "is none of",
|
|
500
|
+
empty: "is empty",
|
|
501
|
+
notEmpty: "is not empty"
|
|
502
|
+
}
|
|
503
|
+
};
|
|
504
|
+
var OPS = {
|
|
505
|
+
text: ["contains", "notContains", "startsWith", "endsWith", "eq", "ne", "empty", "notEmpty"],
|
|
506
|
+
number: ["eq", "ne", "gt", "ge", "lt", "le", "between", "empty", "notEmpty"],
|
|
507
|
+
// A set column holds one of a handful of values, and the values are on screen to
|
|
508
|
+
// be ticked. Asking such a column to be typed at is how a filter on a side column
|
|
509
|
+
// ends up comparing "Buy" against the 0 the column actually holds.
|
|
510
|
+
set: ["anyOf", "noneOf", "empty", "notEmpty"]
|
|
511
|
+
};
|
|
512
|
+
var NEEDS_NO_OPERAND = /* @__PURE__ */ new Set(["empty", "notEmpty"]);
|
|
513
|
+
var PICKS_VALUES = /* @__PURE__ */ new Set(["anyOf", "noneOf"]);
|
|
514
|
+
var _GridFilterDialog = class _GridFilterDialog {
|
|
515
|
+
constructor(classes, labels) {
|
|
516
|
+
__publicField(this, "_classes");
|
|
517
|
+
__publicField(this, "_labels");
|
|
518
|
+
__publicField(this, "_el", null);
|
|
519
|
+
__publicField(this, "_unbind", null);
|
|
520
|
+
this._classes = { ...DEFAULT_CLASSES, ...classes || {} };
|
|
521
|
+
this._labels = { ...DEFAULT_LABELS, ...labels || {}, ops: { ...DEFAULT_LABELS.ops, ...labels?.ops || {} } };
|
|
522
|
+
}
|
|
523
|
+
get isOpen() {
|
|
524
|
+
return this._el !== null;
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Ask for a filter on one column. `commit` is called with the new filter, or
|
|
528
|
+
* with null when the user clears it; cancelling calls nothing at all, so a
|
|
529
|
+
* dialog dismissed by mistake leaves the table exactly as it was.
|
|
530
|
+
*/
|
|
531
|
+
open(options, commit) {
|
|
532
|
+
_GridFilterDialog._open?.close();
|
|
533
|
+
const ops = OPS[options.kind] || OPS.text;
|
|
534
|
+
const current = options.current || {};
|
|
535
|
+
const startOp = current.op || (current.min !== void 0 || current.max !== void 0 ? "between" : ops[0]);
|
|
536
|
+
const picked = new Set(current.values || []);
|
|
537
|
+
const el = document.createElement("div");
|
|
538
|
+
el.className = this._classes.dialog;
|
|
539
|
+
el.setAttribute("role", "dialog");
|
|
540
|
+
el.style.position = "fixed";
|
|
541
|
+
el.style.left = `${options.x}px`;
|
|
542
|
+
el.style.top = `${options.y}px`;
|
|
543
|
+
const title = document.createElement("div");
|
|
544
|
+
title.className = this._classes.title;
|
|
545
|
+
title.textContent = this._labels.title(options.header);
|
|
546
|
+
el.appendChild(title);
|
|
547
|
+
const row = document.createElement("div");
|
|
548
|
+
row.className = this._classes.row;
|
|
549
|
+
const select = document.createElement("select");
|
|
550
|
+
select.className = this._classes.select;
|
|
551
|
+
for (const op of ops) {
|
|
552
|
+
const option = document.createElement("option");
|
|
553
|
+
option.value = op;
|
|
554
|
+
option.textContent = this._labels.ops[op];
|
|
555
|
+
select.appendChild(option);
|
|
556
|
+
}
|
|
557
|
+
select.value = startOp;
|
|
558
|
+
const value = document.createElement("input");
|
|
559
|
+
value.className = this._classes.input;
|
|
560
|
+
value.type = options.kind === "number" ? "number" : "text";
|
|
561
|
+
value.placeholder = this._labels.value;
|
|
562
|
+
value.value = current.text !== void 0 ? current.text : current.min !== void 0 ? String(current.min) : "";
|
|
563
|
+
const and = document.createElement("span");
|
|
564
|
+
and.textContent = ` ${this._labels.and} `;
|
|
565
|
+
const upper = document.createElement("input");
|
|
566
|
+
upper.className = this._classes.input;
|
|
567
|
+
upper.type = "number";
|
|
568
|
+
upper.value = current.max !== void 0 ? String(current.max) : "";
|
|
569
|
+
row.appendChild(select);
|
|
570
|
+
row.appendChild(value);
|
|
571
|
+
row.appendChild(and);
|
|
572
|
+
row.appendChild(upper);
|
|
573
|
+
el.appendChild(row);
|
|
574
|
+
const boxes = [];
|
|
575
|
+
const list = document.createElement("div");
|
|
576
|
+
list.className = this._classes.values;
|
|
577
|
+
for (const option of options.values) {
|
|
578
|
+
const item = document.createElement("label");
|
|
579
|
+
item.className = this._classes.valuesItem;
|
|
580
|
+
const box = document.createElement("input");
|
|
581
|
+
box.type = "checkbox";
|
|
582
|
+
box.value = option.value;
|
|
583
|
+
box.checked = picked.has(option.value);
|
|
584
|
+
boxes.push(box);
|
|
585
|
+
item.appendChild(box);
|
|
586
|
+
item.appendChild(document.createTextNode(option.label));
|
|
587
|
+
list.appendChild(item);
|
|
588
|
+
}
|
|
589
|
+
if (options.values.length) el.appendChild(list);
|
|
590
|
+
const sync = () => {
|
|
591
|
+
const op = select.value;
|
|
592
|
+
const picks = PICKS_VALUES.has(op);
|
|
593
|
+
const none = NEEDS_NO_OPERAND.has(op) || picks;
|
|
594
|
+
value.style.display = none ? "none" : "";
|
|
595
|
+
and.style.display = op === "between" ? "" : "none";
|
|
596
|
+
upper.style.display = op === "between" ? "" : "none";
|
|
597
|
+
list.style.display = picks ? "" : "none";
|
|
598
|
+
};
|
|
599
|
+
select.addEventListener("change", sync);
|
|
600
|
+
sync();
|
|
601
|
+
const actions = document.createElement("div");
|
|
602
|
+
actions.className = this._classes.actions;
|
|
603
|
+
const applyButton = this._button(this._labels.apply, this._classes.apply, () => {
|
|
604
|
+
const op = select.value;
|
|
605
|
+
if (NEEDS_NO_OPERAND.has(op)) return commit({ op });
|
|
606
|
+
if (PICKS_VALUES.has(op)) {
|
|
607
|
+
const values = boxes.filter((box) => box.checked).map((box) => box.value);
|
|
608
|
+
return commit(values.length ? { op, values } : null);
|
|
609
|
+
}
|
|
610
|
+
if (op === "between") {
|
|
611
|
+
const min = value.value.trim() === "" ? void 0 : Number(value.value);
|
|
612
|
+
const max = upper.value.trim() === "" ? void 0 : Number(upper.value);
|
|
613
|
+
return commit(min === void 0 && max === void 0 ? null : { op, min, max });
|
|
614
|
+
}
|
|
615
|
+
return commit(value.value.trim() === "" ? null : { op, text: value.value });
|
|
616
|
+
});
|
|
617
|
+
actions.appendChild(applyButton);
|
|
618
|
+
actions.appendChild(this._button(this._labels.clear, this._classes.clear, () => commit(null)));
|
|
619
|
+
actions.appendChild(this._button(this._labels.cancel, this._classes.cancel, () => {
|
|
620
|
+
}));
|
|
621
|
+
el.appendChild(actions);
|
|
622
|
+
document.body.appendChild(el);
|
|
623
|
+
this._el = el;
|
|
624
|
+
_GridFilterDialog._open = this;
|
|
625
|
+
const dismiss = (event) => {
|
|
626
|
+
const target = event.target;
|
|
627
|
+
if (event.type === "keydown") {
|
|
628
|
+
const key = event.key;
|
|
629
|
+
if (key === "Enter") {
|
|
630
|
+
if (target !== select) applyButton.click();
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
if (key !== "Escape") return;
|
|
634
|
+
}
|
|
635
|
+
if (event.type === "mousedown" && target && el.contains(target)) return;
|
|
636
|
+
this.close();
|
|
637
|
+
};
|
|
638
|
+
document.addEventListener("mousedown", dismiss, true);
|
|
639
|
+
document.addEventListener("keydown", dismiss, true);
|
|
640
|
+
this._unbind = () => {
|
|
641
|
+
document.removeEventListener("mousedown", dismiss, true);
|
|
642
|
+
document.removeEventListener("keydown", dismiss, true);
|
|
643
|
+
};
|
|
644
|
+
if (typeof value.focus === "function") value.focus();
|
|
645
|
+
}
|
|
646
|
+
close() {
|
|
647
|
+
if (!this._el) return;
|
|
648
|
+
this._unbind?.();
|
|
649
|
+
this._unbind = null;
|
|
650
|
+
this._el.parentNode?.removeChild(this._el);
|
|
651
|
+
this._el = null;
|
|
652
|
+
if (_GridFilterDialog._open === this) _GridFilterDialog._open = null;
|
|
653
|
+
}
|
|
654
|
+
_button(label, className, run) {
|
|
655
|
+
const btn = document.createElement("button");
|
|
656
|
+
btn.type = "button";
|
|
657
|
+
btn.className = className;
|
|
658
|
+
btn.textContent = label;
|
|
659
|
+
btn.addEventListener("click", () => {
|
|
660
|
+
this.close();
|
|
661
|
+
run();
|
|
662
|
+
});
|
|
663
|
+
return btn;
|
|
664
|
+
}
|
|
665
|
+
};
|
|
666
|
+
__publicField(_GridFilterDialog, "_open", null);
|
|
667
|
+
var GridFilterDialog = _GridFilterDialog;
|
|
668
|
+
|
|
272
669
|
// node_modules/@stocksharp/grids/src/table-export.ts
|
|
273
670
|
var XML_DECL = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\r\n';
|
|
274
671
|
var CONTENT_TYPES = XML_DECL + '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/></Types>';
|
|
@@ -391,17 +788,75 @@ var SSTradingControls = (() => {
|
|
|
391
788
|
};
|
|
392
789
|
|
|
393
790
|
// node_modules/@stocksharp/grids/src/data-grid.ts
|
|
791
|
+
var NEWLINE = String.fromCharCode(10);
|
|
792
|
+
var TAB = String.fromCharCode(9);
|
|
394
793
|
var GridPinnedPlacements = {
|
|
395
794
|
Top: "top",
|
|
396
795
|
Bottom: "bottom"
|
|
397
796
|
};
|
|
398
|
-
var
|
|
797
|
+
var MENU_LABELS = {
|
|
798
|
+
sortAsc: "Sort ascending",
|
|
799
|
+
sortDesc: "Sort descending",
|
|
800
|
+
sortClear: "Clear sort",
|
|
801
|
+
hideColumn: "Hide column",
|
|
802
|
+
showAllColumns: "Show all columns",
|
|
803
|
+
groupBy: "Group by this column",
|
|
804
|
+
ungroup: "Ungroup",
|
|
805
|
+
filterByValue: (text) => `Filter by this value: ${text}`,
|
|
806
|
+
filterRule: "Filter\u2026",
|
|
807
|
+
showFilters: "Show filter row",
|
|
808
|
+
hideFilters: "Hide filter row",
|
|
809
|
+
showHeader: "Show header row",
|
|
810
|
+
hideHeader: "Hide header row",
|
|
811
|
+
clearFilters: "Clear filters",
|
|
812
|
+
copyCell: "Copy cell",
|
|
813
|
+
copyRow: "Copy row",
|
|
814
|
+
copyRows: (count) => `Copy selected rows (${count})`,
|
|
815
|
+
exportXlsx: "Export to .xlsx"
|
|
816
|
+
};
|
|
817
|
+
var _DataGrid = class _DataGrid {
|
|
399
818
|
constructor(options) {
|
|
400
819
|
__publicField(this, "options");
|
|
401
820
|
__publicField(this, "sort");
|
|
402
821
|
__publicField(this, "_rows");
|
|
403
822
|
__publicField(this, "_rowEls");
|
|
404
823
|
__publicField(this, "_cellEls");
|
|
824
|
+
/// Display order of every declared column, hidden ones included -- hiding must
|
|
825
|
+
/// not lose the slot a column goes back to.
|
|
826
|
+
__publicField(this, "_order");
|
|
827
|
+
__publicField(this, "_hidden");
|
|
828
|
+
/// The column a drag started on. Held here rather than in dataTransfer because
|
|
829
|
+
/// the payload is ours, not the page's: dropping a header onto another window
|
|
830
|
+
/// should do nothing, and reading it back out of the event would invite it.
|
|
831
|
+
__publicField(this, "_dragKey");
|
|
832
|
+
__publicField(this, "_filters");
|
|
833
|
+
__publicField(this, "_group");
|
|
834
|
+
__publicField(this, "_filtersVisible");
|
|
835
|
+
__publicField(this, "_headerVisible");
|
|
836
|
+
/// By row key, never by index or element: a repaint rebuilds every `<tr>`, and a
|
|
837
|
+
/// filter takes rows off screen and brings them back.
|
|
838
|
+
__publicField(this, "_selected");
|
|
839
|
+
/// Where a shift-click measures its range from.
|
|
840
|
+
__publicField(this, "_anchorKey");
|
|
841
|
+
// The keys on screen at the previous paint, for `flashNewClass`. Null until
|
|
842
|
+
// the first paint has happened — nothing was on screen yet, so nothing is
|
|
843
|
+
// news — and left null entirely while the option is off.
|
|
844
|
+
__publicField(this, "_flashKnown");
|
|
845
|
+
__publicField(this, "_menu");
|
|
846
|
+
__publicField(this, "_dragging");
|
|
847
|
+
/// One collator for the whole grid: the sort, the group order, the option list a
|
|
848
|
+
/// set filter offers, and the ordering filter rules. They compare the same strings
|
|
849
|
+
/// and had better agree about what comes first.
|
|
850
|
+
__publicField(this, "_collator");
|
|
851
|
+
/// What to undo when the grid is destroyed: the listeners it put on the document.
|
|
852
|
+
__publicField(this, "_release", []);
|
|
853
|
+
__publicField(this, "_filterDialog");
|
|
854
|
+
__publicField(this, "_collapsed");
|
|
855
|
+
/// The rendered filter controls, by column key, so a stored filter can be put
|
|
856
|
+
/// back into them without rebuilding the row under the user's cursor.
|
|
857
|
+
__publicField(this, "_filterEls");
|
|
858
|
+
/// Suppresses onStateChange while setState applies a stored arrangement.
|
|
859
|
+
__publicField(this, "_restoring");
|
|
405
860
|
for (const col of options.columns) {
|
|
406
861
|
if (col.exportable && !col.value && !col.exportValue)
|
|
407
862
|
throw new Error(`DataGrid: column "${col.key}" is exportable but declares no value to export`);
|
|
@@ -410,13 +865,565 @@ var SSTradingControls = (() => {
|
|
|
410
865
|
this._rows = [];
|
|
411
866
|
this._rowEls = /* @__PURE__ */ new Map();
|
|
412
867
|
this._cellEls = /* @__PURE__ */ new Map();
|
|
868
|
+
this._order = options.columns.map((col) => col.key);
|
|
869
|
+
this._hidden = /* @__PURE__ */ new Set();
|
|
870
|
+
this._dragKey = null;
|
|
871
|
+
this._filters = {};
|
|
872
|
+
this._filtersVisible = options.filtersVisible === true;
|
|
873
|
+
this._headerVisible = options.headerVisible !== false;
|
|
874
|
+
this._group = null;
|
|
875
|
+
this._collapsed = /* @__PURE__ */ new Set();
|
|
876
|
+
this._selected = /* @__PURE__ */ new Set();
|
|
877
|
+
this._anchorKey = null;
|
|
878
|
+
this._flashKnown = null;
|
|
879
|
+
this._menu = null;
|
|
880
|
+
this._filterDialog = null;
|
|
881
|
+
this._dragging = false;
|
|
882
|
+
this._collator = options.collator || new Intl.Collator(
|
|
883
|
+
options.locale || (typeof document !== "undefined" ? document.documentElement?.lang : "") || void 0,
|
|
884
|
+
// numeric so a text column of ids reads 1, 2, 10 rather than 1, 10, 2,
|
|
885
|
+
// and accent-sensitive so it folds case the way the text filters do.
|
|
886
|
+
{ numeric: true, sensitivity: "accent" }
|
|
887
|
+
);
|
|
888
|
+
this._filterEls = /* @__PURE__ */ new Map();
|
|
889
|
+
this._restoring = false;
|
|
413
890
|
const accessors = {};
|
|
414
891
|
for (const col of options.columns) {
|
|
415
892
|
if (col.value) accessors[col.key] = col.value;
|
|
416
893
|
}
|
|
417
894
|
this._renderHead();
|
|
418
|
-
this.sort = new TableSort(options.head, accessors, () => this.render(), options.defaultSort);
|
|
895
|
+
this.sort = new TableSort(options.head, accessors, () => this.render(), options.defaultSort, this._collator);
|
|
419
896
|
this._paint();
|
|
897
|
+
if (options.contextMenu) this._bindContextMenu();
|
|
898
|
+
if ((options.selection || "none") !== "none") this._bindCopyShortcut();
|
|
899
|
+
}
|
|
900
|
+
/** Every declared column in display order, hidden ones included. */
|
|
901
|
+
columnKeys() {
|
|
902
|
+
return [...this._order];
|
|
903
|
+
}
|
|
904
|
+
/** The columns actually rendered, in the order they are rendered. */
|
|
905
|
+
visibleColumns() {
|
|
906
|
+
const byKey = new Map(this.options.columns.map((col) => [col.key, col]));
|
|
907
|
+
return this._order.filter((key) => !this._hidden.has(key)).map((key) => byKey.get(key));
|
|
908
|
+
}
|
|
909
|
+
isColumnHidden(key) {
|
|
910
|
+
return this._hidden.has(key);
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* Put the columns in this order. Keys the caller leaves out keep their relative
|
|
914
|
+
* order behind the ones named -- a layout stored before a column existed must
|
|
915
|
+
* not make that column disappear.
|
|
916
|
+
*/
|
|
917
|
+
setColumnOrder(keys) {
|
|
918
|
+
const known = new Set(this.options.columns.map((col) => col.key));
|
|
919
|
+
for (const key of keys) {
|
|
920
|
+
if (!known.has(key)) throw new Error(`DataGrid: no column "${key}" to order`);
|
|
921
|
+
}
|
|
922
|
+
this._applyOrder(keys);
|
|
923
|
+
this._rerender();
|
|
924
|
+
}
|
|
925
|
+
hideColumn(key) {
|
|
926
|
+
if (this._hidden.has(key)) return;
|
|
927
|
+
this._hidden.add(key);
|
|
928
|
+
this._rerender();
|
|
929
|
+
}
|
|
930
|
+
showColumn(key) {
|
|
931
|
+
if (!this._hidden.delete(key)) return;
|
|
932
|
+
this._rerender();
|
|
933
|
+
}
|
|
934
|
+
filtersVisible() {
|
|
935
|
+
return this._filtersVisible;
|
|
936
|
+
}
|
|
937
|
+
/** Show or hide the filter row. The filters themselves are kept either way. */
|
|
938
|
+
showFilters(visible) {
|
|
939
|
+
if (this._filtersVisible === visible) return;
|
|
940
|
+
this._filtersVisible = visible;
|
|
941
|
+
this._rerender();
|
|
942
|
+
}
|
|
943
|
+
headerVisible() {
|
|
944
|
+
return this._headerVisible;
|
|
945
|
+
}
|
|
946
|
+
/** Show or hide the caption row. Sort, columns and filters are kept either way. */
|
|
947
|
+
showHeader(visible) {
|
|
948
|
+
if (this._headerVisible === visible) return;
|
|
949
|
+
this._headerVisible = visible;
|
|
950
|
+
this._rerender();
|
|
951
|
+
}
|
|
952
|
+
/** The filters in effect, by column key. Columns with none are absent. */
|
|
953
|
+
filters() {
|
|
954
|
+
return { ...this._filters };
|
|
955
|
+
}
|
|
956
|
+
/**
|
|
957
|
+
* Filter a column, or pass null to stop filtering it. A filter with nothing set
|
|
958
|
+
* -- a text box the user emptied -- is the same as null, so clearing an input
|
|
959
|
+
* brings the rows back instead of matching nothing.
|
|
960
|
+
*/
|
|
961
|
+
setFilter(key, filter) {
|
|
962
|
+
const normalized = filter && _DataGrid._normalizeFilter(filter);
|
|
963
|
+
if (normalized) this._filters[key] = normalized;
|
|
964
|
+
else delete this._filters[key];
|
|
965
|
+
this._afterFilterChange();
|
|
966
|
+
}
|
|
967
|
+
clearFilters() {
|
|
968
|
+
if (Object.keys(this._filters).length === 0) return;
|
|
969
|
+
this._filters = {};
|
|
970
|
+
this._afterFilterChange();
|
|
971
|
+
}
|
|
972
|
+
/** The held rows that pass every filter, in the order they were handed over. */
|
|
973
|
+
filteredRows() {
|
|
974
|
+
const entries = Object.entries(this._filters);
|
|
975
|
+
if (entries.length === 0) return this._rows;
|
|
976
|
+
const byKey = new Map(this.options.columns.map((col) => [col.key, col]));
|
|
977
|
+
return this._rows.filter((row) => entries.every(([key, filter]) => {
|
|
978
|
+
const col = byKey.get(key);
|
|
979
|
+
if (!col || !col.value) return true;
|
|
980
|
+
return this._passes(col.value(row), filter);
|
|
981
|
+
}));
|
|
982
|
+
}
|
|
983
|
+
_afterFilterChange() {
|
|
984
|
+
this.render();
|
|
985
|
+
this._refreshFilterRow();
|
|
986
|
+
if (!this._restoring && this.options.onStateChange) this.options.onStateChange(this.getState());
|
|
987
|
+
}
|
|
988
|
+
static _normalizeFilter(filter) {
|
|
989
|
+
const out = {};
|
|
990
|
+
if (filter.op === "empty" || filter.op === "notEmpty") return { op: filter.op };
|
|
991
|
+
if (filter.op) out.op = filter.op;
|
|
992
|
+
const text = typeof filter.text === "string" ? filter.text.trim() : "";
|
|
993
|
+
if (text) out.text = text;
|
|
994
|
+
if (typeof filter.min === "number" && Number.isFinite(filter.min)) out.min = filter.min;
|
|
995
|
+
if (typeof filter.max === "number" && Number.isFinite(filter.max)) out.max = filter.max;
|
|
996
|
+
if (filter.values && filter.values.length) out.values = [...filter.values];
|
|
997
|
+
if (out.op && out.text === void 0 && out.min === void 0 && out.max === void 0 && !out.values) return null;
|
|
998
|
+
return Object.keys(out).length ? out : null;
|
|
999
|
+
}
|
|
1000
|
+
_passes(value, filter) {
|
|
1001
|
+
const op = _DataGrid._effectiveOp(filter);
|
|
1002
|
+
if (!op) return true;
|
|
1003
|
+
const text = _DataGrid._text(value);
|
|
1004
|
+
if (op === "anyOf" || op === "noneOf") {
|
|
1005
|
+
const listed = (filter.values || []).includes(text);
|
|
1006
|
+
return op === "anyOf" ? listed : !listed;
|
|
1007
|
+
}
|
|
1008
|
+
if (op === "empty") return text === "";
|
|
1009
|
+
if (op === "notEmpty") return text !== "";
|
|
1010
|
+
if (op === "between") {
|
|
1011
|
+
const n = Number(text);
|
|
1012
|
+
if (!Number.isFinite(n)) return false;
|
|
1013
|
+
if (filter.min !== void 0 && n < filter.min) return false;
|
|
1014
|
+
if (filter.max !== void 0 && n > filter.max) return false;
|
|
1015
|
+
return true;
|
|
1016
|
+
}
|
|
1017
|
+
const operandText = filter.text !== void 0 ? filter.text : filter.min !== void 0 ? String(filter.min) : "";
|
|
1018
|
+
const left = Number(text);
|
|
1019
|
+
const right = Number(operandText);
|
|
1020
|
+
const numeric = text !== "" && operandText !== "" && Number.isFinite(left) && Number.isFinite(right);
|
|
1021
|
+
const order = numeric ? Math.sign(left - right) : this._collator.compare(text, operandText);
|
|
1022
|
+
switch (op) {
|
|
1023
|
+
case "contains":
|
|
1024
|
+
return text.toLowerCase().includes(operandText.toLowerCase());
|
|
1025
|
+
case "notContains":
|
|
1026
|
+
return !text.toLowerCase().includes(operandText.toLowerCase());
|
|
1027
|
+
case "startsWith":
|
|
1028
|
+
return text.toLowerCase().startsWith(operandText.toLowerCase());
|
|
1029
|
+
case "endsWith":
|
|
1030
|
+
return text.toLowerCase().endsWith(operandText.toLowerCase());
|
|
1031
|
+
case "eq":
|
|
1032
|
+
return numeric ? left === right : text.toLowerCase() === operandText.toLowerCase();
|
|
1033
|
+
case "ne":
|
|
1034
|
+
return numeric ? left !== right : text.toLowerCase() !== operandText.toLowerCase();
|
|
1035
|
+
case "gt":
|
|
1036
|
+
return order > 0;
|
|
1037
|
+
case "ge":
|
|
1038
|
+
return order >= 0;
|
|
1039
|
+
case "lt":
|
|
1040
|
+
return order < 0;
|
|
1041
|
+
case "le":
|
|
1042
|
+
return order <= 0;
|
|
1043
|
+
default:
|
|
1044
|
+
return true;
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
/// The rule actually in force. Absent `op` keeps the old meaning of the shape, so
|
|
1048
|
+
/// a filter written by the inline row -- or stored before operators existed --
|
|
1049
|
+
/// goes on meaning what it meant.
|
|
1050
|
+
static _effectiveOp(filter) {
|
|
1051
|
+
if (filter.op) return filter.op;
|
|
1052
|
+
if (filter.values !== void 0) return "anyOf";
|
|
1053
|
+
if (filter.min !== void 0 || filter.max !== void 0) return "between";
|
|
1054
|
+
if (filter.text !== void 0) return "contains";
|
|
1055
|
+
return null;
|
|
1056
|
+
}
|
|
1057
|
+
/**
|
|
1058
|
+
* The items the grid offers for a right-click. Public so a host that replaces
|
|
1059
|
+
* the menu can still reach for the parts of it that it wants to keep.
|
|
1060
|
+
*/
|
|
1061
|
+
menuItems(context) {
|
|
1062
|
+
const labels = { ...MENU_LABELS, ...this._menuOptions().labels || {} };
|
|
1063
|
+
const items = [];
|
|
1064
|
+
const col = context.column;
|
|
1065
|
+
if (col && col.value) {
|
|
1066
|
+
const sort = this.sort.current();
|
|
1067
|
+
items.push(
|
|
1068
|
+
{ label: labels.sortAsc, checked: sort?.col === col.key && sort.dir === "asc", run: () => this.sort.set(col.key, "asc") },
|
|
1069
|
+
{ label: labels.sortDesc, checked: sort?.col === col.key && sort.dir === "desc", run: () => this.sort.set(col.key, "desc") },
|
|
1070
|
+
{ label: labels.sortClear, disabled: !sort, run: () => this.sort.set(null, null) },
|
|
1071
|
+
{},
|
|
1072
|
+
this._group === col.key ? { label: labels.ungroup, run: () => this.groupBy(null) } : { label: labels.groupBy, run: () => this.groupBy(col.key) }
|
|
1073
|
+
);
|
|
1074
|
+
if (col.filter) {
|
|
1075
|
+
items.push({
|
|
1076
|
+
label: labels.filterRule,
|
|
1077
|
+
checked: this._filters[col.key] !== void 0,
|
|
1078
|
+
run: () => this.openFilterDialog(col.key, context.x, context.y)
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
if (context.row !== null) {
|
|
1082
|
+
const value = _DataGrid._text(col.value ? col.value(context.row) : null);
|
|
1083
|
+
items.push({
|
|
1084
|
+
label: labels.filterByValue(this._displayText(col, context.row)),
|
|
1085
|
+
run: () => this.setFilter(col.key, { op: "anyOf", values: [value] })
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
if (this.options.columns.some((c) => c.filter)) {
|
|
1090
|
+
items.push(
|
|
1091
|
+
{
|
|
1092
|
+
label: this._filtersVisible ? labels.hideFilters : labels.showFilters,
|
|
1093
|
+
checked: this._filtersVisible,
|
|
1094
|
+
run: () => this.showFilters(!this._filtersVisible)
|
|
1095
|
+
},
|
|
1096
|
+
{ label: labels.clearFilters, disabled: Object.keys(this._filters).length === 0, run: () => this.clearFilters() },
|
|
1097
|
+
{}
|
|
1098
|
+
);
|
|
1099
|
+
}
|
|
1100
|
+
if (col) {
|
|
1101
|
+
items.push({
|
|
1102
|
+
label: labels.hideColumn,
|
|
1103
|
+
// The last visible column cannot go: a table with no columns is a
|
|
1104
|
+
// blank rectangle with no way back to itself.
|
|
1105
|
+
disabled: this.visibleColumns().length <= 1,
|
|
1106
|
+
run: () => this.hideColumn(col.key)
|
|
1107
|
+
});
|
|
1108
|
+
}
|
|
1109
|
+
items.push({
|
|
1110
|
+
label: labels.showAllColumns,
|
|
1111
|
+
disabled: this._hidden.size === 0,
|
|
1112
|
+
run: () => {
|
|
1113
|
+
for (const key of [...this._hidden]) this.showColumn(key);
|
|
1114
|
+
}
|
|
1115
|
+
});
|
|
1116
|
+
items.push({
|
|
1117
|
+
label: this._headerVisible ? labels.hideHeader : labels.showHeader,
|
|
1118
|
+
checked: this._headerVisible,
|
|
1119
|
+
run: () => this.showHeader(!this._headerVisible)
|
|
1120
|
+
});
|
|
1121
|
+
if (context.row !== null) {
|
|
1122
|
+
items.push(
|
|
1123
|
+
{},
|
|
1124
|
+
{ label: labels.copyCell, run: () => _DataGrid._copy(context.text) },
|
|
1125
|
+
{
|
|
1126
|
+
// Names how many rows go, because "Copy row" over a selection of
|
|
1127
|
+
// twelve would be a lie about what the click is about to do.
|
|
1128
|
+
label: this._selected.size > 1 ? labels.copyRows(this._selected.size) : labels.copyRow,
|
|
1129
|
+
run: () => this.copyRows(context.row)
|
|
1130
|
+
}
|
|
1131
|
+
);
|
|
1132
|
+
}
|
|
1133
|
+
items.push({}, { label: labels.exportXlsx, run: () => this.download("export", "Export") });
|
|
1134
|
+
return items;
|
|
1135
|
+
}
|
|
1136
|
+
_menuOptions() {
|
|
1137
|
+
const option = this.options.contextMenu;
|
|
1138
|
+
return typeof option === "object" && option !== null ? option : {};
|
|
1139
|
+
}
|
|
1140
|
+
_bindContextMenu() {
|
|
1141
|
+
this._menu = new GridContextMenu(this._menuOptions().classes);
|
|
1142
|
+
const onContext = (event) => {
|
|
1143
|
+
_DataGrid._active = this;
|
|
1144
|
+
const target = event.target;
|
|
1145
|
+
const cell = target && typeof target.closest === "function" ? target.closest("[data-col]") : null;
|
|
1146
|
+
const rowEl = target && typeof target.closest === "function" ? target.closest("[data-row-key]") : null;
|
|
1147
|
+
const column = cell ? this.options.columns.find((c) => c.key === cell.dataset.col) || null : null;
|
|
1148
|
+
const rowKey = rowEl?.dataset.rowKey;
|
|
1149
|
+
const row = rowKey === void 0 ? null : this.displayRows().find((r) => this.options.rowKey(r) === rowKey) || null;
|
|
1150
|
+
const text = column && row && column.value ? _DataGrid._text(column.value(row)) : "";
|
|
1151
|
+
const at = event;
|
|
1152
|
+
const context = { column, row, text, x: at.clientX || 0, y: at.clientY || 0 };
|
|
1153
|
+
const build = this._menuOptions().items;
|
|
1154
|
+
const items = build ? build(context, this.menuItems(context)) : this.menuItems(context);
|
|
1155
|
+
if (items.length === 0) return;
|
|
1156
|
+
event.preventDefault();
|
|
1157
|
+
this._menu.open(items, context.x, context.y);
|
|
1158
|
+
};
|
|
1159
|
+
this.options.head.addEventListener("contextmenu", onContext);
|
|
1160
|
+
this.options.body.addEventListener("contextmenu", onContext);
|
|
1161
|
+
}
|
|
1162
|
+
/// The same columns and the same accessors the sheet uses. A row on the clipboard
|
|
1163
|
+
/// and a row in the .xlsx are the same data going to the same place, so a column
|
|
1164
|
+
/// that renders a button has nothing to contribute to either, and one that exports
|
|
1165
|
+
/// "Buy" must not copy the 0 behind it.
|
|
1166
|
+
_rowText(row) {
|
|
1167
|
+
return this.visibleColumns().filter((col) => col.exportable).map((col) => _DataGrid._text((col.exportValue || col.value)(row))).join(TAB);
|
|
1168
|
+
}
|
|
1169
|
+
/// Put text on the clipboard, returning whether anything was written.
|
|
1170
|
+
/// navigator.clipboard exists only in a secure context, so on a page served over
|
|
1171
|
+
/// plain http to anything but localhost it is simply undefined -- which is how
|
|
1172
|
+
/// Copy row came to do nothing at all on a LAN address while working locally. The
|
|
1173
|
+
/// older execCommand path still works there, so it is the fallback rather than a
|
|
1174
|
+
/// silent no-op.
|
|
1175
|
+
static _copy(text) {
|
|
1176
|
+
const clipboard = typeof navigator !== "undefined" ? navigator.clipboard : void 0;
|
|
1177
|
+
if (clipboard && clipboard.writeText) {
|
|
1178
|
+
clipboard.writeText(text).catch(() => _DataGrid._copyFallback(text));
|
|
1179
|
+
return true;
|
|
1180
|
+
}
|
|
1181
|
+
return _DataGrid._copyFallback(text);
|
|
1182
|
+
}
|
|
1183
|
+
static _copyFallback(text) {
|
|
1184
|
+
if (typeof document === "undefined" || !document.body) return false;
|
|
1185
|
+
const area = document.createElement("textarea");
|
|
1186
|
+
area.value = text;
|
|
1187
|
+
area.style.position = "fixed";
|
|
1188
|
+
area.style.left = "-9999px";
|
|
1189
|
+
document.body.appendChild(area);
|
|
1190
|
+
try {
|
|
1191
|
+
area.select();
|
|
1192
|
+
return typeof document.execCommand === "function" && document.execCommand("copy");
|
|
1193
|
+
} catch {
|
|
1194
|
+
return false;
|
|
1195
|
+
} finally {
|
|
1196
|
+
area.parentNode?.removeChild(area);
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
/**
|
|
1200
|
+
* What Copy row and Ctrl+C put on the clipboard: the selected rows when there is
|
|
1201
|
+
* a selection, otherwise the one row asked for. Tab-separated, one row per line,
|
|
1202
|
+
* which is what a spreadsheet reads back as cells.
|
|
1203
|
+
*/
|
|
1204
|
+
copyText(row) {
|
|
1205
|
+
const selected = this.selectedRows();
|
|
1206
|
+
const rows = selected.length > 0 ? selected : row === null ? [] : [row];
|
|
1207
|
+
return rows.map((r) => this._rowText(r)).join(NEWLINE);
|
|
1208
|
+
}
|
|
1209
|
+
/** Copy the current selection, or the row given. True when anything was written. */
|
|
1210
|
+
copyRows(row) {
|
|
1211
|
+
const text = this.copyText(row);
|
|
1212
|
+
return text === "" ? false : _DataGrid._copy(text);
|
|
1213
|
+
}
|
|
1214
|
+
/** Selected row keys, in the order they appear on screen. */
|
|
1215
|
+
selectedKeys() {
|
|
1216
|
+
const onScreen = this.displayRows().map((row) => this.options.rowKey(row));
|
|
1217
|
+
const known = new Set(onScreen);
|
|
1218
|
+
return [
|
|
1219
|
+
...onScreen.filter((key) => this._selected.has(key)),
|
|
1220
|
+
...[...this._selected].filter((key) => !known.has(key))
|
|
1221
|
+
];
|
|
1222
|
+
}
|
|
1223
|
+
/** The selected rows that are actually on screen. */
|
|
1224
|
+
selectedRows() {
|
|
1225
|
+
return this.displayRows().filter((row) => this._selected.has(this.options.rowKey(row)));
|
|
1226
|
+
}
|
|
1227
|
+
/**
|
|
1228
|
+
* Forget the flash baseline: the next paint reads as a fresh seed and
|
|
1229
|
+
* flashes nothing. For a host that replaces its rows wholesale — a tape
|
|
1230
|
+
* moving to another instrument is a new history, not fifty arrivals at
|
|
1231
|
+
* once.
|
|
1232
|
+
*/
|
|
1233
|
+
flashReset() {
|
|
1234
|
+
this._flashKnown = null;
|
|
1235
|
+
}
|
|
1236
|
+
setSelection(keys) {
|
|
1237
|
+
this._replaceSelection(new Set(keys));
|
|
1238
|
+
}
|
|
1239
|
+
clearSelection() {
|
|
1240
|
+
this._replaceSelection(/* @__PURE__ */ new Set());
|
|
1241
|
+
}
|
|
1242
|
+
_replaceSelection(next) {
|
|
1243
|
+
const before = this.selectedKeys();
|
|
1244
|
+
this._selected = next;
|
|
1245
|
+
const after = this.selectedKeys();
|
|
1246
|
+
if (before.length === after.length && before.every((key, i) => key === after[i])) return;
|
|
1247
|
+
this.render();
|
|
1248
|
+
if (this.options.onSelectionChange) this.options.onSelectionChange(after);
|
|
1249
|
+
}
|
|
1250
|
+
_beginDragSelect() {
|
|
1251
|
+
this._dragging = true;
|
|
1252
|
+
const end = () => {
|
|
1253
|
+
this._dragging = false;
|
|
1254
|
+
document.removeEventListener("mouseup", end, true);
|
|
1255
|
+
};
|
|
1256
|
+
document.addEventListener("mouseup", end, true);
|
|
1257
|
+
}
|
|
1258
|
+
_extendTo(key) {
|
|
1259
|
+
if (this._anchorKey === null) return;
|
|
1260
|
+
const order = this.displayRows().map((row) => this.options.rowKey(row));
|
|
1261
|
+
const from = order.indexOf(this._anchorKey);
|
|
1262
|
+
const to = order.indexOf(key);
|
|
1263
|
+
if (from === -1 || to === -1) return;
|
|
1264
|
+
this._replaceSelection(new Set(order.slice(Math.min(from, to), Math.max(from, to) + 1)));
|
|
1265
|
+
}
|
|
1266
|
+
/// Ctrl+C over the table copies the selected rows. Bound on the document because
|
|
1267
|
+
/// a table has no focus of its own, and answered only by the grid the pointer last
|
|
1268
|
+
/// touched -- otherwise every grid on the page would copy at once.
|
|
1269
|
+
_bindCopyShortcut() {
|
|
1270
|
+
const onKeyDown = (event) => {
|
|
1271
|
+
const e = event;
|
|
1272
|
+
if (!(e.ctrlKey || e.metaKey)) return;
|
|
1273
|
+
const isC = e.code ? e.code === "KeyC" : (e.key || "").toLowerCase() === "c";
|
|
1274
|
+
if (!isC) return;
|
|
1275
|
+
if (_DataGrid._active !== this) return;
|
|
1276
|
+
if (this._selected.size === 0) return;
|
|
1277
|
+
const tag = e.target && e.target.tagName;
|
|
1278
|
+
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
|
|
1279
|
+
if (this.copyRows(null)) event.preventDefault();
|
|
1280
|
+
};
|
|
1281
|
+
document.addEventListener("keydown", onKeyDown);
|
|
1282
|
+
this._release.push(() => document.removeEventListener("keydown", onKeyDown));
|
|
1283
|
+
}
|
|
1284
|
+
/**
|
|
1285
|
+
* Let the grid go. Everything it put outside its own two elements comes off: the
|
|
1286
|
+
* Ctrl+C binding on the document, and any menu or filter popup still open.
|
|
1287
|
+
*
|
|
1288
|
+
* A host needs this whenever it replaces a grid rather than updating one -- the
|
|
1289
|
+
* columns are declared once, so re-declaring them (translated headers, a different
|
|
1290
|
+
* set of columns) means a new grid over the same table, and the old one would go
|
|
1291
|
+
* on answering Ctrl+C from beyond the grave.
|
|
1292
|
+
*/
|
|
1293
|
+
destroy() {
|
|
1294
|
+
for (const release of this._release.splice(0)) release();
|
|
1295
|
+
this._menu?.close();
|
|
1296
|
+
this._filterDialog?.close();
|
|
1297
|
+
if (_DataGrid._active === this) _DataGrid._active = null;
|
|
1298
|
+
}
|
|
1299
|
+
_clickRow(key, event) {
|
|
1300
|
+
const mode = this.options.selection || "none";
|
|
1301
|
+
if (mode === "none") return;
|
|
1302
|
+
const modifiers = event;
|
|
1303
|
+
const next = /* @__PURE__ */ new Set();
|
|
1304
|
+
if (mode === "single" || !modifiers.ctrlKey && !modifiers.metaKey && !modifiers.shiftKey) {
|
|
1305
|
+
next.add(key);
|
|
1306
|
+
this._anchorKey = key;
|
|
1307
|
+
} else if (modifiers.shiftKey && this._anchorKey !== null) {
|
|
1308
|
+
const order = this.displayRows().map((row) => this.options.rowKey(row));
|
|
1309
|
+
const from = order.indexOf(this._anchorKey);
|
|
1310
|
+
const to = order.indexOf(key);
|
|
1311
|
+
if (from === -1 || to === -1) next.add(key);
|
|
1312
|
+
else for (const k of order.slice(Math.min(from, to), Math.max(from, to) + 1)) next.add(k);
|
|
1313
|
+
} else {
|
|
1314
|
+
for (const k of this._selected) next.add(k);
|
|
1315
|
+
if (!next.delete(key)) next.add(key);
|
|
1316
|
+
this._anchorKey = key;
|
|
1317
|
+
}
|
|
1318
|
+
this._replaceSelection(next);
|
|
1319
|
+
}
|
|
1320
|
+
/**
|
|
1321
|
+
* Open the rule dialog for a column -- an operator and its operand, which is what
|
|
1322
|
+
* the inline row cannot express without growing a second control per column.
|
|
1323
|
+
*/
|
|
1324
|
+
openFilterDialog(key, x, y) {
|
|
1325
|
+
const col = this.options.columns.find((c) => c.key === key);
|
|
1326
|
+
if (!col || !col.filter) return;
|
|
1327
|
+
const options = this._menuOptions().filterDialog || {};
|
|
1328
|
+
if (!this._filterDialog) this._filterDialog = new GridFilterDialog(options.classes, options.labels);
|
|
1329
|
+
this._filterDialog.open(
|
|
1330
|
+
{
|
|
1331
|
+
header: col.header,
|
|
1332
|
+
kind: col.filter,
|
|
1333
|
+
current: this._filters[key] || null,
|
|
1334
|
+
// A set column is picked from, not typed into: the dialog needs the
|
|
1335
|
+
// values the rows actually hold, each under the label it reads as.
|
|
1336
|
+
values: col.filter === "set" ? this._distinctValues(col) : [],
|
|
1337
|
+
x,
|
|
1338
|
+
y
|
|
1339
|
+
},
|
|
1340
|
+
(filter) => this.setFilter(key, filter)
|
|
1341
|
+
);
|
|
1342
|
+
}
|
|
1343
|
+
/** The column the rows are grouped under, or null. */
|
|
1344
|
+
groupedBy() {
|
|
1345
|
+
return this._group;
|
|
1346
|
+
}
|
|
1347
|
+
/** Group by a column, or pass null to go flat. */
|
|
1348
|
+
groupBy(key) {
|
|
1349
|
+
if (key !== null) {
|
|
1350
|
+
const col = this.options.columns.find((c) => c.key === key);
|
|
1351
|
+
if (!col || !col.value) throw new Error(`DataGrid: no column "${key}" with a value to group by`);
|
|
1352
|
+
}
|
|
1353
|
+
this._group = key;
|
|
1354
|
+
this._collapsed.clear();
|
|
1355
|
+
this._afterViewChange();
|
|
1356
|
+
}
|
|
1357
|
+
collapsedGroups() {
|
|
1358
|
+
return [...this._collapsed];
|
|
1359
|
+
}
|
|
1360
|
+
toggleGroup(value) {
|
|
1361
|
+
if (!this._collapsed.delete(value)) this._collapsed.add(value);
|
|
1362
|
+
this._afterViewChange();
|
|
1363
|
+
}
|
|
1364
|
+
_afterViewChange() {
|
|
1365
|
+
this.render();
|
|
1366
|
+
if (!this._restoring && this.options.onStateChange) this.options.onStateChange(this.getState());
|
|
1367
|
+
}
|
|
1368
|
+
/** The arrangement, as a host would store it. */
|
|
1369
|
+
getState() {
|
|
1370
|
+
return {
|
|
1371
|
+
order: [...this._order],
|
|
1372
|
+
hidden: this._order.filter((key) => this._hidden.has(key)),
|
|
1373
|
+
sort: this.sort.current(),
|
|
1374
|
+
filters: this.filters(),
|
|
1375
|
+
filtersVisible: this._filtersVisible,
|
|
1376
|
+
headerVisible: this._headerVisible,
|
|
1377
|
+
group: this._group,
|
|
1378
|
+
collapsed: this.collapsedGroups()
|
|
1379
|
+
};
|
|
1380
|
+
}
|
|
1381
|
+
/**
|
|
1382
|
+
* Restore an arrangement. Unknown column keys are dropped rather than rejected:
|
|
1383
|
+
* a view stored before a deploy has to survive one.
|
|
1384
|
+
*/
|
|
1385
|
+
setState(state) {
|
|
1386
|
+
const known = new Set(this.options.columns.map((col) => col.key));
|
|
1387
|
+
this._restoring = true;
|
|
1388
|
+
try {
|
|
1389
|
+
if (state.order) this._applyOrder(state.order.filter((key) => known.has(key)));
|
|
1390
|
+
if (state.hidden) this._hidden = new Set(state.hidden.filter((key) => known.has(key)));
|
|
1391
|
+
if (state.sort !== void 0) {
|
|
1392
|
+
if (state.sort) this.sort.set(state.sort.col, state.sort.dir);
|
|
1393
|
+
else this.sort.set(null, null);
|
|
1394
|
+
}
|
|
1395
|
+
if (state.filtersVisible !== void 0) this._filtersVisible = state.filtersVisible;
|
|
1396
|
+
if (state.headerVisible !== void 0) this._headerVisible = state.headerVisible;
|
|
1397
|
+
if (state.group !== void 0) {
|
|
1398
|
+
const col = state.group && this.options.columns.find((c) => c.key === state.group);
|
|
1399
|
+
this._group = col && col.value ? state.group : null;
|
|
1400
|
+
this._collapsed.clear();
|
|
1401
|
+
}
|
|
1402
|
+
if (state.collapsed) this._collapsed = new Set(state.collapsed);
|
|
1403
|
+
if (state.filters) {
|
|
1404
|
+
this._filters = {};
|
|
1405
|
+
for (const [key, filter] of Object.entries(state.filters)) {
|
|
1406
|
+
if (!known.has(key)) continue;
|
|
1407
|
+
const normalized = _DataGrid._normalizeFilter(filter);
|
|
1408
|
+
if (normalized) this._filters[key] = normalized;
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
this._rerender();
|
|
1412
|
+
} finally {
|
|
1413
|
+
this._restoring = false;
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
_applyOrder(keys) {
|
|
1417
|
+
const named = keys.filter((key, i) => keys.indexOf(key) === i);
|
|
1418
|
+
this._order = [...named, ...this._order.filter((key) => !named.includes(key))];
|
|
1419
|
+
}
|
|
1420
|
+
/// One place that repaints and tells the host, so no caller can do one without
|
|
1421
|
+
/// the other.
|
|
1422
|
+
_rerender() {
|
|
1423
|
+
this._renderHead();
|
|
1424
|
+
this.sort.refreshHeader();
|
|
1425
|
+
this.render();
|
|
1426
|
+
if (!this._restoring && this.options.onStateChange) this.options.onStateChange(this.getState());
|
|
420
1427
|
}
|
|
421
1428
|
/**
|
|
422
1429
|
* The rows the grid holds, in the order they were handed over. The grid keeps
|
|
@@ -431,11 +1438,23 @@ var SSTradingControls = (() => {
|
|
|
431
1438
|
* default). A sorted copy — the held array is never reordered.
|
|
432
1439
|
*/
|
|
433
1440
|
sortedRows() {
|
|
434
|
-
return this.sort.apply(this.
|
|
1441
|
+
return this.sort.apply(this.filteredRows());
|
|
1442
|
+
}
|
|
1443
|
+
/**
|
|
1444
|
+
* Every row that is on screen, in the order it appears there -- filtered, sorted,
|
|
1445
|
+
* and gathered into groups when grouping is on. Collapsed groups are included:
|
|
1446
|
+
* they are hidden, not excluded, and an export of a collapsed table that silently
|
|
1447
|
+
* dropped rows would be worse than one that shows them.
|
|
1448
|
+
*/
|
|
1449
|
+
displayRows() {
|
|
1450
|
+
const sorted = this.sortedRows();
|
|
1451
|
+
if (this._group === null) return sorted;
|
|
1452
|
+
return [...this._groups(sorted).values()].flatMap((group) => group.rows);
|
|
435
1453
|
}
|
|
436
1454
|
setRows(rows) {
|
|
437
1455
|
this._rows = rows || [];
|
|
438
1456
|
this.render();
|
|
1457
|
+
this._refreshFilterRow();
|
|
439
1458
|
}
|
|
440
1459
|
render() {
|
|
441
1460
|
this._paint();
|
|
@@ -446,17 +1465,39 @@ var SSTradingControls = (() => {
|
|
|
446
1465
|
this._cellEls.clear();
|
|
447
1466
|
const pinned = this.options.pinnedRows ? this.options.pinnedRows() : [];
|
|
448
1467
|
const sorted = this.sortedRows();
|
|
449
|
-
const
|
|
1468
|
+
const limit = this.options.renderLimit != null ? this.options.renderLimit : Infinity;
|
|
450
1469
|
const children = [];
|
|
451
1470
|
for (const row of pinned) {
|
|
452
1471
|
if (row.place === GridPinnedPlacements.Top) children.push(this._pinnedRow(row));
|
|
453
1472
|
}
|
|
454
|
-
|
|
455
|
-
|
|
1473
|
+
let painted = 0;
|
|
1474
|
+
if (this._group === null) {
|
|
1475
|
+
for (const row of sorted) {
|
|
1476
|
+
if (painted >= limit) break;
|
|
1477
|
+
children.push(this._bodyRow(row));
|
|
1478
|
+
painted++;
|
|
1479
|
+
}
|
|
1480
|
+
} else {
|
|
1481
|
+
for (const [key, group] of this._groups(sorted)) {
|
|
1482
|
+
if (painted >= limit) break;
|
|
1483
|
+
children.push(this._groupRow(key, group.label, group.rows.length));
|
|
1484
|
+
if (this._collapsed.has(key)) continue;
|
|
1485
|
+
for (const row of group.rows) {
|
|
1486
|
+
if (painted >= limit) break;
|
|
1487
|
+
children.push(this._bodyRow(row));
|
|
1488
|
+
painted++;
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
if (painted === 0 && pinned.length === 0 && this._collapsed.size === 0) children.push(this._emptyRow());
|
|
456
1493
|
for (const row of pinned) {
|
|
457
1494
|
if (row.place === GridPinnedPlacements.Bottom) children.push(this._pinnedRow(row));
|
|
458
1495
|
}
|
|
459
1496
|
this.options.body.replaceChildren(...children);
|
|
1497
|
+
if (this.options.flashNewClass) {
|
|
1498
|
+
const keys = this.displayRows().map((row) => this.options.rowKey(row));
|
|
1499
|
+
if (this._flashKnown !== null || keys.length > 0) this._flashKnown = new Set(keys);
|
|
1500
|
+
}
|
|
460
1501
|
}
|
|
461
1502
|
/**
|
|
462
1503
|
* The rendered row with this key, or null when it is not on screen (filtered
|
|
@@ -478,10 +1519,13 @@ var SSTradingControls = (() => {
|
|
|
478
1519
|
* content can be asserted without a browser.
|
|
479
1520
|
*/
|
|
480
1521
|
exportData() {
|
|
481
|
-
const columns = this.
|
|
1522
|
+
const columns = this.visibleColumns().filter((col) => col.exportable);
|
|
482
1523
|
return {
|
|
483
1524
|
headers: columns.map((col) => col.header),
|
|
484
|
-
|
|
1525
|
+
// Grouped, the sheet follows the order on screen but without the header
|
|
1526
|
+
// rows: a spreadsheet has its own grouping, and a label row in the middle
|
|
1527
|
+
// of the data would break every formula pointed at the range.
|
|
1528
|
+
rows: this.displayRows().map((row) => columns.map((col) => {
|
|
485
1529
|
const accessor = col.exportValue || col.value;
|
|
486
1530
|
return accessor ? accessor(row) : null;
|
|
487
1531
|
}))
|
|
@@ -493,10 +1537,17 @@ var SSTradingControls = (() => {
|
|
|
493
1537
|
TableExport.download(baseName, sheetName, data.headers, data.rows);
|
|
494
1538
|
}
|
|
495
1539
|
_renderHead() {
|
|
1540
|
+
if (!this._headerVisible) {
|
|
1541
|
+
const filterRow = this._filterRow();
|
|
1542
|
+
if (filterRow) this.options.head.replaceChildren(filterRow);
|
|
1543
|
+
else this.options.head.replaceChildren();
|
|
1544
|
+
return;
|
|
1545
|
+
}
|
|
496
1546
|
const tr = document.createElement("tr");
|
|
497
|
-
for (const col of this.
|
|
1547
|
+
for (const col of this.visibleColumns()) {
|
|
498
1548
|
const th = document.createElement("th");
|
|
499
1549
|
th.setAttribute("scope", "col");
|
|
1550
|
+
th.dataset.col = col.key;
|
|
500
1551
|
if (col.headerClass) th.className = col.headerClass;
|
|
501
1552
|
if (col.value) th.dataset.sort = col.key;
|
|
502
1553
|
if (col.headerHidden) {
|
|
@@ -507,9 +1558,142 @@ var SSTradingControls = (() => {
|
|
|
507
1558
|
} else {
|
|
508
1559
|
th.textContent = col.header;
|
|
509
1560
|
}
|
|
1561
|
+
if (this.options.reorderable) this._bindHeaderDrag(th, col.key);
|
|
510
1562
|
tr.appendChild(th);
|
|
511
1563
|
}
|
|
512
|
-
this.
|
|
1564
|
+
const filters = this._filterRow();
|
|
1565
|
+
if (filters) this.options.head.replaceChildren(tr, filters);
|
|
1566
|
+
else this.options.head.replaceChildren(tr);
|
|
1567
|
+
}
|
|
1568
|
+
/**
|
|
1569
|
+
* The row of filter controls, or null when no visible column declares one.
|
|
1570
|
+
*
|
|
1571
|
+
* Every visible column gets a cell even when it has no filter, or the row would
|
|
1572
|
+
* shear away from the captions above it. The cells carry no `data-sort`, so a
|
|
1573
|
+
* click inside one does not reach the sort handler bound on the `<thead>`.
|
|
1574
|
+
*/
|
|
1575
|
+
_filterRow() {
|
|
1576
|
+
const columns = this.visibleColumns();
|
|
1577
|
+
if (!this._filtersVisible || !columns.some((col) => col.filter)) return null;
|
|
1578
|
+
const tr = document.createElement("tr");
|
|
1579
|
+
tr.className = "grid-filters";
|
|
1580
|
+
this._filterEls = /* @__PURE__ */ new Map();
|
|
1581
|
+
for (const col of columns) {
|
|
1582
|
+
const cell = document.createElement("th");
|
|
1583
|
+
cell.dataset.filter = col.key;
|
|
1584
|
+
if (col.filter) {
|
|
1585
|
+
for (const el of this._filterControls(col)) cell.appendChild(el);
|
|
1586
|
+
}
|
|
1587
|
+
tr.appendChild(cell);
|
|
1588
|
+
}
|
|
1589
|
+
return tr;
|
|
1590
|
+
}
|
|
1591
|
+
_filterControls(col) {
|
|
1592
|
+
const current = this._filters[col.key] || {};
|
|
1593
|
+
if (col.filter === "text") {
|
|
1594
|
+
const input = document.createElement("input");
|
|
1595
|
+
input.type = "text";
|
|
1596
|
+
input.className = "grid-filter grid-filter-text";
|
|
1597
|
+
input.value = current.text || "";
|
|
1598
|
+
input.addEventListener("input", () => this.setFilter(col.key, { text: input.value }));
|
|
1599
|
+
this._filterEls.set(col.key, [input]);
|
|
1600
|
+
return [input];
|
|
1601
|
+
}
|
|
1602
|
+
if (col.filter === "number") {
|
|
1603
|
+
const read = (el) => {
|
|
1604
|
+
const n = Number(el.value);
|
|
1605
|
+
return el.value.trim() === "" || !Number.isFinite(n) ? void 0 : n;
|
|
1606
|
+
};
|
|
1607
|
+
const make = (className, value) => {
|
|
1608
|
+
const input = document.createElement("input");
|
|
1609
|
+
input.type = "number";
|
|
1610
|
+
input.className = `grid-filter ${className}`;
|
|
1611
|
+
input.value = value === void 0 ? "" : String(value);
|
|
1612
|
+
return input;
|
|
1613
|
+
};
|
|
1614
|
+
const min = make("grid-filter-min", current.min);
|
|
1615
|
+
const max = make("grid-filter-max", current.max);
|
|
1616
|
+
const push = () => this.setFilter(col.key, { min: read(min), max: read(max) });
|
|
1617
|
+
min.addEventListener("input", push);
|
|
1618
|
+
max.addEventListener("input", push);
|
|
1619
|
+
this._filterEls.set(col.key, [min, max]);
|
|
1620
|
+
return [min, max];
|
|
1621
|
+
}
|
|
1622
|
+
const select = document.createElement("select");
|
|
1623
|
+
select.className = "grid-filter grid-filter-set";
|
|
1624
|
+
const blank = document.createElement("option");
|
|
1625
|
+
blank.value = "";
|
|
1626
|
+
blank.textContent = "";
|
|
1627
|
+
select.appendChild(blank);
|
|
1628
|
+
for (const { value, label } of this._distinctValues(col)) {
|
|
1629
|
+
const option = document.createElement("option");
|
|
1630
|
+
option.value = value;
|
|
1631
|
+
option.textContent = label;
|
|
1632
|
+
select.appendChild(option);
|
|
1633
|
+
}
|
|
1634
|
+
select.value = current.values && current.values.length ? current.values[0] : "";
|
|
1635
|
+
select.addEventListener("change", () => this.setFilter(col.key, select.value ? { values: [select.value] } : null));
|
|
1636
|
+
this._filterEls.set(col.key, [select]);
|
|
1637
|
+
return [select];
|
|
1638
|
+
}
|
|
1639
|
+
/// Replaces the option list only when the values behind it changed, so a live
|
|
1640
|
+
/// table that repaints on every tick does not rebuild a select the user is using.
|
|
1641
|
+
_syncOptions(select, values) {
|
|
1642
|
+
const current = Array.from(select.children).slice(1).map((el) => el.value);
|
|
1643
|
+
if (current.length === values.length && current.every((v, i) => v === values[i].value)) return;
|
|
1644
|
+
const blank = document.createElement("option");
|
|
1645
|
+
blank.value = "";
|
|
1646
|
+
blank.textContent = "";
|
|
1647
|
+
const options = [blank];
|
|
1648
|
+
for (const { value, label } of values) {
|
|
1649
|
+
const option = document.createElement("option");
|
|
1650
|
+
option.value = value;
|
|
1651
|
+
option.textContent = label;
|
|
1652
|
+
options.push(option);
|
|
1653
|
+
}
|
|
1654
|
+
select.replaceChildren(...options);
|
|
1655
|
+
}
|
|
1656
|
+
/// The value as a person reads it. One place, because a group header, a filter's
|
|
1657
|
+
/// option list and the menu line that filters by a value all have to agree -- and
|
|
1658
|
+
/// all of them have to disagree with `value`, which stays what the grid compares.
|
|
1659
|
+
_displayText(col, row) {
|
|
1660
|
+
if (col.text) return col.text(row);
|
|
1661
|
+
return _DataGrid._text(col.value ? col.value(row) : null);
|
|
1662
|
+
}
|
|
1663
|
+
/// Every value the column holds, paired with the label it is picked by. Keyed by
|
|
1664
|
+
/// the raw value: two rows reading "Buy" are one option, and the option carries
|
|
1665
|
+
/// the 0 the filter will actually compare against.
|
|
1666
|
+
_distinctValues(col) {
|
|
1667
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1668
|
+
for (const row of this._rows) {
|
|
1669
|
+
if (!col.value) continue;
|
|
1670
|
+
const value = _DataGrid._text(col.value(row));
|
|
1671
|
+
if (!seen.has(value)) seen.set(value, this._displayText(col, row));
|
|
1672
|
+
}
|
|
1673
|
+
return [...seen.entries()].map(([value, label]) => ({ value, label })).sort((a, b) => this._collator.compare(a.label, b.label));
|
|
1674
|
+
}
|
|
1675
|
+
/**
|
|
1676
|
+
* Put the stored filters back into the controls without rebuilding the row.
|
|
1677
|
+
* Rebuilding it would take the focus and the caret out of the box the user is
|
|
1678
|
+
* still typing in.
|
|
1679
|
+
*/
|
|
1680
|
+
_refreshFilterRow() {
|
|
1681
|
+
for (const [key, els] of this._filterEls) {
|
|
1682
|
+
const filter = this._filters[key] || {};
|
|
1683
|
+
if (els.length === 2) {
|
|
1684
|
+
els[0].value = filter.min === void 0 ? "" : String(filter.min);
|
|
1685
|
+
els[1].value = filter.max === void 0 ? "" : String(filter.max);
|
|
1686
|
+
} else if (els[0].tagName === "SELECT") {
|
|
1687
|
+
const select = els[0];
|
|
1688
|
+
const col = this.options.columns.find((c) => c.key === key);
|
|
1689
|
+
if (col) this._syncOptions(select, this._distinctValues(col));
|
|
1690
|
+
select.value = filter.values && filter.values.length ? filter.values[0] : "";
|
|
1691
|
+
} else {
|
|
1692
|
+
const input = els[0];
|
|
1693
|
+
const want = filter.text || "";
|
|
1694
|
+
if (input.value !== want) input.value = want;
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
513
1697
|
}
|
|
514
1698
|
_bodyRow(row) {
|
|
515
1699
|
const tr = document.createElement("tr");
|
|
@@ -518,7 +1702,7 @@ var SSTradingControls = (() => {
|
|
|
518
1702
|
if (rowClass) tr.className = rowClass;
|
|
519
1703
|
tr.dataset.rowKey = key;
|
|
520
1704
|
const cells = /* @__PURE__ */ new Map();
|
|
521
|
-
for (const col of this.
|
|
1705
|
+
for (const col of this.visibleColumns()) {
|
|
522
1706
|
const td = document.createElement("td");
|
|
523
1707
|
td.dataset.col = col.key;
|
|
524
1708
|
const cellClass = col.cellClass ? col.cellClass(row) : "";
|
|
@@ -530,6 +1714,34 @@ var SSTradingControls = (() => {
|
|
|
530
1714
|
tr.appendChild(td);
|
|
531
1715
|
cells.set(col.key, td);
|
|
532
1716
|
}
|
|
1717
|
+
if (this._selected.has(key)) {
|
|
1718
|
+
const selectedClass = this.options.selectedClass || "is-selected";
|
|
1719
|
+
tr.className = tr.className ? `${tr.className} ${selectedClass}` : selectedClass;
|
|
1720
|
+
}
|
|
1721
|
+
if (this.options.flashNewClass && this._flashKnown !== null && !this._flashKnown.has(key)) {
|
|
1722
|
+
tr.className = tr.className ? `${tr.className} ${this.options.flashNewClass}` : this.options.flashNewClass;
|
|
1723
|
+
}
|
|
1724
|
+
const mode = this.options.selection || "none";
|
|
1725
|
+
if (mode !== "none") {
|
|
1726
|
+
tr.addEventListener("mousedown", (event) => {
|
|
1727
|
+
const button = event.button;
|
|
1728
|
+
if (button !== void 0 && button !== 0) return;
|
|
1729
|
+
event.preventDefault();
|
|
1730
|
+
_DataGrid._active = this;
|
|
1731
|
+
this._clickRow(key, event);
|
|
1732
|
+
const m = event;
|
|
1733
|
+
if (mode === "multi" && !m.shiftKey && !m.ctrlKey && !m.metaKey) this._beginDragSelect();
|
|
1734
|
+
});
|
|
1735
|
+
tr.addEventListener("mouseenter", (event) => {
|
|
1736
|
+
if (!this._dragging) return;
|
|
1737
|
+
const buttons = event.buttons;
|
|
1738
|
+
if (buttons !== void 0 && (buttons & 1) === 0) {
|
|
1739
|
+
this._dragging = false;
|
|
1740
|
+
return;
|
|
1741
|
+
}
|
|
1742
|
+
this._extendTo(key);
|
|
1743
|
+
});
|
|
1744
|
+
}
|
|
533
1745
|
if (this.options.bindRow) this.options.bindRow(tr, row);
|
|
534
1746
|
this._rowEls.set(key, tr);
|
|
535
1747
|
this._cellEls.set(key, cells);
|
|
@@ -542,12 +1754,16 @@ var SSTradingControls = (() => {
|
|
|
542
1754
|
const tr = document.createElement("tr");
|
|
543
1755
|
if (pinned.className) tr.className = pinned.className;
|
|
544
1756
|
tr.dataset.rowKey = pinned.key;
|
|
1757
|
+
const byColumn = /* @__PURE__ */ new Map();
|
|
1758
|
+
this.options.columns.forEach((col, i) => {
|
|
1759
|
+
const cell = pinned.cells[i];
|
|
1760
|
+
if (cell) byColumn.set(col.key, cell);
|
|
1761
|
+
});
|
|
545
1762
|
const cells = /* @__PURE__ */ new Map();
|
|
546
|
-
for (
|
|
547
|
-
const col = this.options.columns[i];
|
|
1763
|
+
for (const col of this.visibleColumns()) {
|
|
548
1764
|
const td = document.createElement("td");
|
|
549
1765
|
td.dataset.col = col.key;
|
|
550
|
-
const cell =
|
|
1766
|
+
const cell = byColumn.get(col.key);
|
|
551
1767
|
if (cell) {
|
|
552
1768
|
if (cell.className) td.className = cell.className;
|
|
553
1769
|
if (typeof cell.content === "string") td.textContent = cell.content;
|
|
@@ -560,19 +1776,79 @@ var SSTradingControls = (() => {
|
|
|
560
1776
|
this._cellEls.set(pinned.key, cells);
|
|
561
1777
|
return tr;
|
|
562
1778
|
}
|
|
1779
|
+
/// Rows by their group value, in value order, each group keeping the row order
|
|
1780
|
+
/// it was handed.
|
|
1781
|
+
_groups(rows) {
|
|
1782
|
+
const col = this.options.columns.find((c) => c.key === this._group);
|
|
1783
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1784
|
+
for (const row of rows) {
|
|
1785
|
+
const key = _DataGrid._text(col && col.value ? col.value(row) : null);
|
|
1786
|
+
const group = groups.get(key);
|
|
1787
|
+
if (group) group.rows.push(row);
|
|
1788
|
+
else groups.set(key, { label: col ? this._displayText(col, row) : key, rows: [row] });
|
|
1789
|
+
}
|
|
1790
|
+
return new Map([...groups.entries()].sort((a, b) => this._collator.compare(a[1].label, b[1].label)));
|
|
1791
|
+
}
|
|
1792
|
+
_groupRow(key, label, count) {
|
|
1793
|
+
const tr = document.createElement("tr");
|
|
1794
|
+
tr.className = this._collapsed.has(key) ? "grid-group is-collapsed" : "grid-group";
|
|
1795
|
+
tr.dataset.group = key;
|
|
1796
|
+
const collapsed = this._collapsed.has(key);
|
|
1797
|
+
const td = document.createElement("td");
|
|
1798
|
+
td.colSpan = this.visibleColumns().length;
|
|
1799
|
+
const content = this.options.groupHeader ? this.options.groupHeader(label, count, collapsed) : `${collapsed ? "\u25B8" : "\u25BE"} ${label} (${count})`;
|
|
1800
|
+
if (typeof content === "string") td.textContent = content;
|
|
1801
|
+
else td.appendChild(content);
|
|
1802
|
+
tr.appendChild(td);
|
|
1803
|
+
tr.addEventListener("click", () => this.toggleGroup(key));
|
|
1804
|
+
return tr;
|
|
1805
|
+
}
|
|
563
1806
|
_emptyRow() {
|
|
564
1807
|
const tr = document.createElement("tr");
|
|
565
1808
|
const td = document.createElement("td");
|
|
566
1809
|
td.className = "grid-empty";
|
|
567
|
-
td.colSpan = this.
|
|
1810
|
+
td.colSpan = this.visibleColumns().length;
|
|
568
1811
|
td.textContent = this.options.emptyText;
|
|
569
1812
|
tr.appendChild(td);
|
|
570
1813
|
return tr;
|
|
571
1814
|
}
|
|
1815
|
+
/**
|
|
1816
|
+
* Drag a header to move its column in front of the one it is dropped on.
|
|
1817
|
+
*
|
|
1818
|
+
* The dragged key lives on the instance rather than in dataTransfer: the payload
|
|
1819
|
+
* is this grid's, and reading it back out of the event would mean a header
|
|
1820
|
+
* dragged from anywhere -- another grid, another window -- could reorder this one.
|
|
1821
|
+
*/
|
|
1822
|
+
_bindHeaderDrag(th, key) {
|
|
1823
|
+
th.setAttribute("draggable", "true");
|
|
1824
|
+
th.addEventListener("dragstart", () => {
|
|
1825
|
+
this._dragKey = key;
|
|
1826
|
+
});
|
|
1827
|
+
th.addEventListener("dragend", () => {
|
|
1828
|
+
this._dragKey = null;
|
|
1829
|
+
});
|
|
1830
|
+
th.addEventListener("dragover", (event) => {
|
|
1831
|
+
if (this._dragKey !== null && this._dragKey !== key) event.preventDefault();
|
|
1832
|
+
});
|
|
1833
|
+
th.addEventListener("drop", (event) => {
|
|
1834
|
+
event.preventDefault();
|
|
1835
|
+
const moved = this._dragKey;
|
|
1836
|
+
this._dragKey = null;
|
|
1837
|
+
if (moved === null || moved === key) return;
|
|
1838
|
+
const order = this._order.filter((k) => k !== moved);
|
|
1839
|
+
order.splice(order.indexOf(key), 0, moved);
|
|
1840
|
+
this._applyOrder(order);
|
|
1841
|
+
this._rerender();
|
|
1842
|
+
});
|
|
1843
|
+
}
|
|
572
1844
|
static _text(value) {
|
|
573
1845
|
return value === null || value === void 0 ? "" : String(value);
|
|
574
1846
|
}
|
|
575
1847
|
};
|
|
1848
|
+
/// The grid a keyboard shortcut belongs to: the last one the pointer touched.
|
|
1849
|
+
/// Two grids on a page both holding a selection would otherwise both answer Ctrl+C.
|
|
1850
|
+
__publicField(_DataGrid, "_active", null);
|
|
1851
|
+
var DataGrid = _DataGrid;
|
|
576
1852
|
|
|
577
1853
|
// src/active-orders-widget.ts
|
|
578
1854
|
var OrderStates = {
|
|
@@ -674,11 +1950,17 @@ var SSTradingControls = (() => {
|
|
|
674
1950
|
rowKey: (o) => String(o.id),
|
|
675
1951
|
emptyText: this._host.t("NoActiveOrders"),
|
|
676
1952
|
// Terminal rows are greyed out so the live ones stay visually prominent.
|
|
677
|
-
rowClass: (o) => _ActiveOrdersWidget._rowClass(o)
|
|
1953
|
+
rowClass: (o) => _ActiveOrdersWidget._rowClass(o),
|
|
1954
|
+
contextMenu: makeGridMenu(this._host),
|
|
1955
|
+
// Press to mark, drag or Shift to range, Ctrl to toggle, Ctrl+C
|
|
1956
|
+
// to copy — all the grid's own; the press also claims the
|
|
1957
|
+
// gesture from the browser's text selection.
|
|
1958
|
+
selection: "multi"
|
|
678
1959
|
}) : null;
|
|
679
1960
|
this._host.register(this);
|
|
680
1961
|
}
|
|
681
1962
|
dispose() {
|
|
1963
|
+
this._grid?.destroy();
|
|
682
1964
|
this._host.unregister(this);
|
|
683
1965
|
try {
|
|
684
1966
|
this.rootEl.remove();
|
|
@@ -995,11 +2277,17 @@ var SSTradingControls = (() => {
|
|
|
995
2277
|
emptyText: this._host.t("No positions"),
|
|
996
2278
|
// Re-read on every render, so the cash figures track the live balance
|
|
997
2279
|
// without the widget having to repaint the position rows itself.
|
|
998
|
-
pinnedRows: () => this._pinnedRows()
|
|
2280
|
+
pinnedRows: () => this._pinnedRows(),
|
|
2281
|
+
contextMenu: makeGridMenu(this._host),
|
|
2282
|
+
// Press to mark, drag or Shift to range, Ctrl to toggle, Ctrl+C
|
|
2283
|
+
// to copy — the pinned cash row stays outside the selection the
|
|
2284
|
+
// same way it stays outside the sort.
|
|
2285
|
+
selection: "multi"
|
|
999
2286
|
}) : null;
|
|
1000
2287
|
this._host.register(this);
|
|
1001
2288
|
}
|
|
1002
2289
|
dispose() {
|
|
2290
|
+
this._grid?.destroy();
|
|
1003
2291
|
this._host.unregister(this);
|
|
1004
2292
|
try {
|
|
1005
2293
|
this.rootEl.remove();
|
|
@@ -1216,11 +2504,16 @@ var SSTradingControls = (() => {
|
|
|
1216
2504
|
// Most recent fill on top by default — the natural way to read a trade blotter.
|
|
1217
2505
|
defaultSort: { col: "time", dir: "desc" },
|
|
1218
2506
|
rowKey: (t) => String(t.id),
|
|
1219
|
-
emptyText: this._host.t("No trade history")
|
|
2507
|
+
emptyText: this._host.t("No trade history"),
|
|
2508
|
+
contextMenu: makeGridMenu(this._host),
|
|
2509
|
+
// Press to mark, drag or Shift to range, Ctrl to toggle, Ctrl+C
|
|
2510
|
+
// to copy — all the grid's own.
|
|
2511
|
+
selection: "multi"
|
|
1220
2512
|
}) : null;
|
|
1221
2513
|
this._host.register(this);
|
|
1222
2514
|
}
|
|
1223
2515
|
dispose() {
|
|
2516
|
+
this._grid?.destroy();
|
|
1224
2517
|
this._host.unregister(this);
|
|
1225
2518
|
try {
|
|
1226
2519
|
this.rootEl.remove();
|
|
@@ -1417,7 +2710,8 @@ var SSTradingControls = (() => {
|
|
|
1417
2710
|
afterRender: () => {
|
|
1418
2711
|
this._syncSubs();
|
|
1419
2712
|
this._publishVisible();
|
|
1420
|
-
}
|
|
2713
|
+
},
|
|
2714
|
+
contextMenu: makeGridMenu(this._host)
|
|
1421
2715
|
}) : null;
|
|
1422
2716
|
this._bind();
|
|
1423
2717
|
this._host.register(this);
|
|
@@ -1735,6 +3029,2033 @@ var SSTradingControls = (() => {
|
|
|
1735
3029
|
_WatchlistWidget.RENDER_CAP = 300;
|
|
1736
3030
|
_WatchlistWidget.TYPE = ControlTypes.Watchlist;
|
|
1737
3031
|
var WatchlistWidget = _WatchlistWidget;
|
|
3032
|
+
|
|
3033
|
+
// src/order-entry-widget.ts
|
|
3034
|
+
var OrderEntrySides = {
|
|
3035
|
+
Buy: "buy",
|
|
3036
|
+
Sell: "sell"
|
|
3037
|
+
};
|
|
3038
|
+
var OrderEntryTypes = {
|
|
3039
|
+
Market: "market",
|
|
3040
|
+
Limit: "limit",
|
|
3041
|
+
Stop: "stop",
|
|
3042
|
+
StopLimit: "stoplimit"
|
|
3043
|
+
};
|
|
3044
|
+
var WIRE_SIDES = { buy: 0, sell: 1 };
|
|
3045
|
+
var PERCENT_STEPS = [25, 50, 75, 100];
|
|
3046
|
+
var PRICE_INPUT = ".oe-field-price input";
|
|
3047
|
+
var STOP_INPUT = ".oe-field-stop input";
|
|
3048
|
+
var QTY_INPUT = ".oe-field-qty input";
|
|
3049
|
+
var TP_INPUT = ".oe-field-tp input";
|
|
3050
|
+
var SL_INPUT = ".oe-field-sl input";
|
|
3051
|
+
var TPSL_CHECK = ".oe-tpsl-toggle input";
|
|
3052
|
+
var AVAILABLE_VALUE = ".oe-avbl .oe-value";
|
|
3053
|
+
var TOTAL_VALUE = ".oe-total .oe-value";
|
|
3054
|
+
var MAX_VALUE = ".oe-foot .oe-value";
|
|
3055
|
+
var VALUE_INPUTS = [PRICE_INPUT, STOP_INPUT, QTY_INPUT, TP_INPUT, SL_INPUT];
|
|
3056
|
+
var _OrderEntryWidget = class _OrderEntryWidget {
|
|
3057
|
+
static create(hostEl, state, deps) {
|
|
3058
|
+
const host = assertHost(deps?.host, "OrderEntryWidget");
|
|
3059
|
+
const root = _OrderEntryWidget._buildRoot(host);
|
|
3060
|
+
root.id = makePanelId(_OrderEntryWidget.TYPE);
|
|
3061
|
+
hostEl.appendChild(root);
|
|
3062
|
+
return new _OrderEntryWidget(root, state || {}, deps);
|
|
3063
|
+
}
|
|
3064
|
+
// The pad's markup. The host stylesheet reads this structure, and a docking
|
|
3065
|
+
// host lifts `.panel-header`'s children into its tab strip.
|
|
3066
|
+
//
|
|
3067
|
+
// There is no `+` button in the header: a second pad on the same portfolio
|
|
3068
|
+
// is a host decision about the account, not a gesture the form offers.
|
|
3069
|
+
static _buildRoot(host) {
|
|
3070
|
+
const title = host.t("OrderEntry");
|
|
3071
|
+
return makePanelRoot("order-entry-panel", title, [
|
|
3072
|
+
makeElement("div", "panel-header", {}, [
|
|
3073
|
+
makeElement("span", "", {}, [title]),
|
|
3074
|
+
makeIconButton("bt-icon-btn bt-icon-cancel panel-close-btn", host.t("ClosePanel"), "bi-x", { type: "button" })
|
|
3075
|
+
]),
|
|
3076
|
+
makeElement("div", "order-entry-content", {}, [
|
|
3077
|
+
_OrderEntryWidget._buildTypeTabs(host),
|
|
3078
|
+
makeElement("div", "order-entry-split", {}, [
|
|
3079
|
+
_OrderEntryWidget._buildColumn(host, OrderEntrySides.Buy),
|
|
3080
|
+
_OrderEntryWidget._buildColumn(host, OrderEntrySides.Sell)
|
|
3081
|
+
])
|
|
3082
|
+
])
|
|
3083
|
+
]);
|
|
3084
|
+
}
|
|
3085
|
+
// Two pairs of type buttons, so each pair lines up with the column beneath
|
|
3086
|
+
// it. Which one is active is decided by `setOrderType`, from the constructor
|
|
3087
|
+
// and from a later click alike.
|
|
3088
|
+
static _buildTypeTabs(host) {
|
|
3089
|
+
const tab = (type, caption) => makeElement("button", "btn-ot", { type: "button", "data-type": type, "aria-pressed": "false" }, [caption]);
|
|
3090
|
+
return makeElement("div", "order-type-tabs", { role: "group", "aria-label": host.t("OrderType") }, [
|
|
3091
|
+
makeElement("div", "ot-pair", {}, [
|
|
3092
|
+
tab(OrderEntryTypes.Market, host.t("Market")),
|
|
3093
|
+
tab(OrderEntryTypes.Limit, host.t("Limit"))
|
|
3094
|
+
]),
|
|
3095
|
+
makeElement("div", "ot-pair", {}, [
|
|
3096
|
+
tab(OrderEntryTypes.Stop, host.t("Stop")),
|
|
3097
|
+
tab(OrderEntryTypes.StopLimit, host.t("StopLimit"))
|
|
3098
|
+
])
|
|
3099
|
+
]);
|
|
3100
|
+
}
|
|
3101
|
+
// One column. The modifier class and the submit button's colour class are
|
|
3102
|
+
// assigned as whole literals rather than composed, so the style contract can
|
|
3103
|
+
// read this control's class list out of the source.
|
|
3104
|
+
static _buildColumn(host, side) {
|
|
3105
|
+
const isBuy = side === OrderEntrySides.Buy;
|
|
3106
|
+
const sideText = host.presentation.sideText(WIRE_SIDES[side]);
|
|
3107
|
+
const maxLabel = isBuy ? host.t("Max Buy") : host.t("Max Sell");
|
|
3108
|
+
const hotkey = isBuy ? host.t("BuyHotkey") : host.t("SellHotkey");
|
|
3109
|
+
const submit = makeElement("button", "btn-oe-submit", { type: "button", "aria-label": sideText }, [
|
|
3110
|
+
sideText,
|
|
3111
|
+
makeElement("span", "hotkey-hint", {}, [`[${hotkey}]`])
|
|
3112
|
+
]);
|
|
3113
|
+
if (isBuy) submit.className = "btn-oe-submit btn-buy";
|
|
3114
|
+
else submit.className = "btn-oe-submit btn-sell";
|
|
3115
|
+
const column = makeElement("div", "oe-col", { "data-side": side }, [
|
|
3116
|
+
makeElement("div", "oe-avbl", {}, [
|
|
3117
|
+
makeElement("span", "oe-label", {}, [host.t("Avbl")]),
|
|
3118
|
+
makeElement("span", "oe-value", {}, ["--"])
|
|
3119
|
+
]),
|
|
3120
|
+
makeElement("div", "oe-field oe-field-price", {}, [
|
|
3121
|
+
makeElement("span", "oe-label", {}, [host.t("Price")]),
|
|
3122
|
+
makeElement("div", "oe-input-grp", {}, [
|
|
3123
|
+
makeElement("input", "oe-input", { type: "number", "aria-label": host.t("Price") }, []),
|
|
3124
|
+
makeElement("button", "btn-bbo", { type: "button", title: host.t("BestBidOrOffer") }, [host.t("BBO")])
|
|
3125
|
+
])
|
|
3126
|
+
]),
|
|
3127
|
+
makeElement("div", "oe-field oe-field-stop", {}, [
|
|
3128
|
+
makeElement("span", "oe-label", {}, [host.t("StopPrice")]),
|
|
3129
|
+
makeElement("input", "oe-input", { type: "number", "aria-label": host.t("StopPrice") }, [])
|
|
3130
|
+
]),
|
|
3131
|
+
makeElement("div", "oe-field oe-field-qty", {}, [
|
|
3132
|
+
makeElement("span", "oe-label", {}, [host.t("Amount")]),
|
|
3133
|
+
makeElement("input", "oe-input", { type: "number", "aria-label": host.t("OrderQuantity") }, [])
|
|
3134
|
+
]),
|
|
3135
|
+
makeElement(
|
|
3136
|
+
"div",
|
|
3137
|
+
"oe-pct-row",
|
|
3138
|
+
{ role: "group", "aria-label": host.t("PercentageOfBalance") },
|
|
3139
|
+
PERCENT_STEPS.map((pct) => makeElement("button", "btn-oe-pct", { type: "button", "data-pct": String(pct) }, [`${pct}%`]))
|
|
3140
|
+
),
|
|
3141
|
+
makeElement("div", "oe-total", {}, [
|
|
3142
|
+
makeElement("span", "oe-label", {}, [host.t("Total")]),
|
|
3143
|
+
makeElement("span", "oe-value", {}, ["--"])
|
|
3144
|
+
]),
|
|
3145
|
+
makeElement("label", "oe-tpsl-toggle", {}, [
|
|
3146
|
+
makeElement("input", "", { type: "checkbox" }, []),
|
|
3147
|
+
makeElement("span", "", {}, [host.t("TpSl")])
|
|
3148
|
+
]),
|
|
3149
|
+
makeElement("div", "oe-tpsl oe-field-hidden", {}, [
|
|
3150
|
+
makeElement("div", "oe-field oe-field-tp", {}, [
|
|
3151
|
+
makeElement("span", "oe-label", {}, [host.t("TakeProfit")]),
|
|
3152
|
+
makeElement("input", "oe-input", { type: "number", "aria-label": host.t("TakeProfit") }, [])
|
|
3153
|
+
]),
|
|
3154
|
+
makeElement("div", "oe-field oe-field-sl", {}, [
|
|
3155
|
+
makeElement("span", "oe-label", {}, [host.t("StopLoss")]),
|
|
3156
|
+
makeElement("input", "oe-input", { type: "number", "aria-label": host.t("StopLoss") }, [])
|
|
3157
|
+
])
|
|
3158
|
+
]),
|
|
3159
|
+
makeElement("div", "oe-foot", {}, [
|
|
3160
|
+
makeElement("span", "oe-label", {}, [maxLabel]),
|
|
3161
|
+
makeElement("span", "oe-value", {}, ["--"])
|
|
3162
|
+
]),
|
|
3163
|
+
submit,
|
|
3164
|
+
makeElement("div", "oe-estimate", { "aria-live": "polite" }, [])
|
|
3165
|
+
]);
|
|
3166
|
+
if (isBuy) column.className = "oe-col oe-col-buy";
|
|
3167
|
+
else column.className = "oe-col oe-col-sell";
|
|
3168
|
+
return column;
|
|
3169
|
+
}
|
|
3170
|
+
constructor(rootEl, _state, deps) {
|
|
3171
|
+
this._host = assertHost(deps?.host, "OrderEntryWidget");
|
|
3172
|
+
for (const name of ["submitOrder"]) {
|
|
3173
|
+
if (typeof deps?.[name] !== "function")
|
|
3174
|
+
throw new Error(`OrderEntryWidget: dep "${name}" is required`);
|
|
3175
|
+
}
|
|
3176
|
+
this.rootEl = rootEl;
|
|
3177
|
+
this._deps = deps;
|
|
3178
|
+
this.el = this.rootEl.querySelector(".order-entry-content");
|
|
3179
|
+
this._closeBtn = this.rootEl.querySelector(".panel-close-btn");
|
|
3180
|
+
this._cols = {
|
|
3181
|
+
buy: this.rootEl.querySelector(".oe-col-buy"),
|
|
3182
|
+
sell: this.rootEl.querySelector(".oe-col-sell")
|
|
3183
|
+
};
|
|
3184
|
+
this._orderType = OrderEntryTypes.Market;
|
|
3185
|
+
this._instrument = null;
|
|
3186
|
+
this._lastPrice = 0;
|
|
3187
|
+
this._bestBid = 0;
|
|
3188
|
+
this._bestAsk = 0;
|
|
3189
|
+
this._maxQuantity = { buy: null, sell: null };
|
|
3190
|
+
this._focused = null;
|
|
3191
|
+
this._enabled = true;
|
|
3192
|
+
this._closeBtn?.addEventListener("click", (e) => {
|
|
3193
|
+
e.preventDefault();
|
|
3194
|
+
this._host.close();
|
|
3195
|
+
});
|
|
3196
|
+
this._bindTypeTabs();
|
|
3197
|
+
for (const side of _OrderEntryWidget.SIDES) this._bindColumn(side);
|
|
3198
|
+
this.setOrderType(OrderEntryTypes.Market);
|
|
3199
|
+
this._host.register(this);
|
|
3200
|
+
}
|
|
3201
|
+
dispose() {
|
|
3202
|
+
this._host.unregister(this);
|
|
3203
|
+
try {
|
|
3204
|
+
this.rootEl.remove();
|
|
3205
|
+
} catch {
|
|
3206
|
+
}
|
|
3207
|
+
}
|
|
3208
|
+
/// The type the form is on. A host reads it before deciding whether a
|
|
3209
|
+
/// click-to-trade gesture has to switch the pad over first.
|
|
3210
|
+
get orderType() {
|
|
3211
|
+
return this._orderType;
|
|
3212
|
+
}
|
|
3213
|
+
/// The instrument the form is sized and priced against, or null before the
|
|
3214
|
+
/// host has named one. Read for its tick by a host that has to quantize a
|
|
3215
|
+
/// price of its own before handing it over.
|
|
3216
|
+
getInstrument() {
|
|
3217
|
+
return this._instrument;
|
|
3218
|
+
}
|
|
3219
|
+
/// Switch the form's order type and mark the matching tab. The tab lives
|
|
3220
|
+
/// under this control's own root, so a host switches type by naming the
|
|
3221
|
+
/// type rather than by handing over a button it looked up itself.
|
|
3222
|
+
setOrderType(type) {
|
|
3223
|
+
this._orderType = type;
|
|
3224
|
+
for (const button of this.rootEl.querySelectorAll(".btn-ot")) {
|
|
3225
|
+
const tab = button;
|
|
3226
|
+
const on = tab.dataset.type === type;
|
|
3227
|
+
tab.classList.toggle("active", on);
|
|
3228
|
+
tab.setAttribute("aria-pressed", String(on));
|
|
3229
|
+
}
|
|
3230
|
+
this._applyTypeVisibility();
|
|
3231
|
+
for (const side of _OrderEntryWidget.SIDES) this._recalculate(side);
|
|
3232
|
+
}
|
|
3233
|
+
/// Adopt the venue's size and price grid: the quantity input steps by the
|
|
3234
|
+
/// lot, the price inputs by the tick, and the quantity resets to the minimum
|
|
3235
|
+
/// lot — carrying the previous value across a switch yields nonsense like
|
|
3236
|
+
/// "10 BTC" after a stock.
|
|
3237
|
+
setInstrument(instrument) {
|
|
3238
|
+
this._instrument = instrument;
|
|
3239
|
+
if (!instrument) return;
|
|
3240
|
+
const lotSize = Number(instrument.lotSize) || 1;
|
|
3241
|
+
const tickSize = Number(instrument.tickSize) || 0.01;
|
|
3242
|
+
const minVolume = Number(instrument.minVolume) > 0 ? Number(instrument.minVolume) : lotSize;
|
|
3243
|
+
const maxVolume = instrument.maxVolume != null ? Number(instrument.maxVolume) : null;
|
|
3244
|
+
for (const side of _OrderEntryWidget.SIDES) {
|
|
3245
|
+
const qty = this._input(side, QTY_INPUT);
|
|
3246
|
+
if (qty) {
|
|
3247
|
+
qty.step = String(lotSize);
|
|
3248
|
+
qty.min = String(minVolume);
|
|
3249
|
+
qty.max = maxVolume != null ? String(maxVolume) : "";
|
|
3250
|
+
qty.value = _OrderEntryWidget._formatQty(minVolume);
|
|
3251
|
+
}
|
|
3252
|
+
for (const selector of [PRICE_INPUT, STOP_INPUT, TP_INPUT, SL_INPUT]) {
|
|
3253
|
+
const input = this._input(side, selector);
|
|
3254
|
+
if (input) input.step = String(tickSize);
|
|
3255
|
+
}
|
|
3256
|
+
}
|
|
3257
|
+
for (const side of _OrderEntryWidget.SIDES) this._recalculate(side);
|
|
3258
|
+
}
|
|
3259
|
+
/// The cash this side may still commit. Null reads as "not known yet",
|
|
3260
|
+
/// which is not the same as zero.
|
|
3261
|
+
setAvailable(side, available) {
|
|
3262
|
+
const value = this._cols[side]?.querySelector(AVAILABLE_VALUE);
|
|
3263
|
+
if (value) value.textContent = formatPrice(available);
|
|
3264
|
+
}
|
|
3265
|
+
/// The largest quantity this side can trade — what the percent buttons take
|
|
3266
|
+
/// a percentage OF. Without it they have no denominator, so they report the
|
|
3267
|
+
/// gap through `host.log` rather than inventing one (see `applyPercent`).
|
|
3268
|
+
setMaxQuantity(side, max) {
|
|
3269
|
+
this._maxQuantity[side] = max != null && isFinite(max) ? max : null;
|
|
3270
|
+
const value = this._cols[side]?.querySelector(MAX_VALUE);
|
|
3271
|
+
if (value) value.textContent = formatQty(this._maxQuantity[side]);
|
|
3272
|
+
}
|
|
3273
|
+
/// A reference price off the tape. Both columns follow the market while the
|
|
3274
|
+
/// form is untouched; the field the trader is typing in is left alone.
|
|
3275
|
+
setLimitPrice(price) {
|
|
3276
|
+
this._lastPrice = price;
|
|
3277
|
+
this._writePrices(this._bestBid > 0 ? this._bestBid : price, this._bestAsk > 0 ? this._bestAsk : price, false);
|
|
3278
|
+
}
|
|
3279
|
+
/// The same reference price, force-written: an explicit host gesture
|
|
3280
|
+
/// ("price this order here") outranks whatever was typed.
|
|
3281
|
+
seedLimitPrice(price) {
|
|
3282
|
+
this._lastPrice = price;
|
|
3283
|
+
this._writePrices(this._bestBid > 0 ? this._bestBid : price, this._bestAsk > 0 ? this._bestAsk : price, true);
|
|
3284
|
+
}
|
|
3285
|
+
/// Best bid and offer. The passive seed for a limit form is Buy = best bid
|
|
3286
|
+
/// and Sell = best ask — joining the book on each side, so the spread in the
|
|
3287
|
+
/// form matches the spread in the book. The BBO button next to the field is
|
|
3288
|
+
/// the opposite, explicit gesture: take what the other side is showing.
|
|
3289
|
+
setBbo(bid, ask) {
|
|
3290
|
+
if (bid > 0) this._bestBid = bid;
|
|
3291
|
+
if (ask > 0) this._bestAsk = ask;
|
|
3292
|
+
this._writePrices(
|
|
3293
|
+
this._bestBid > 0 ? this._bestBid : this._lastPrice,
|
|
3294
|
+
this._bestAsk > 0 ? this._bestAsk : this._lastPrice,
|
|
3295
|
+
false
|
|
3296
|
+
);
|
|
3297
|
+
}
|
|
3298
|
+
/// Fill one column's price with the best quote for that direction: Buy takes
|
|
3299
|
+
/// the ask, Sell takes the bid.
|
|
3300
|
+
applyBbo(side) {
|
|
3301
|
+
const bbo = side === OrderEntrySides.Buy ? this._bestAsk : this._bestBid;
|
|
3302
|
+
if (!(bbo > 0)) return;
|
|
3303
|
+
this.setPrice(side, bbo);
|
|
3304
|
+
}
|
|
3305
|
+
/// Force one column's price and let ticks resume updating it afterwards —
|
|
3306
|
+
/// what a click on a book level or a chart price means. Quantized to the
|
|
3307
|
+
/// instrument's tick, so a raw chart coordinate does not fail validation.
|
|
3308
|
+
setPrice(side, price) {
|
|
3309
|
+
const input = this._input(side, PRICE_INPUT);
|
|
3310
|
+
if (!input) return;
|
|
3311
|
+
const text = this._formatPriceForInput(price);
|
|
3312
|
+
if (!text) return;
|
|
3313
|
+
input.dataset.userEdited = "0";
|
|
3314
|
+
input.value = text;
|
|
3315
|
+
this._recalculate(side);
|
|
3316
|
+
}
|
|
3317
|
+
/// The same quantity in both columns — a quick-size gesture applies to
|
|
3318
|
+
/// whichever side the user goes on to press.
|
|
3319
|
+
setQuantity(quantity) {
|
|
3320
|
+
for (const side of _OrderEntryWidget.SIDES) {
|
|
3321
|
+
const input = this._input(side, QTY_INPUT);
|
|
3322
|
+
if (!input) continue;
|
|
3323
|
+
input.value = _OrderEntryWidget._formatQty(quantity);
|
|
3324
|
+
this._recalculate(side);
|
|
3325
|
+
}
|
|
3326
|
+
}
|
|
3327
|
+
/// Take a percentage of this side's maximum quantity, rounded down to a
|
|
3328
|
+
/// whole number of lots.
|
|
3329
|
+
applyPercent(side, pct) {
|
|
3330
|
+
const max = this._maxQuantity[side];
|
|
3331
|
+
if (max == null) {
|
|
3332
|
+
this._host.log(`OrderEntryWidget: no maximum quantity for the ${side} side, so ${pct}% of it is unknown`);
|
|
3333
|
+
return;
|
|
3334
|
+
}
|
|
3335
|
+
const qty = this._input(side, QTY_INPUT);
|
|
3336
|
+
if (!qty) return;
|
|
3337
|
+
const step = Number(qty.step) || 0;
|
|
3338
|
+
let target = max * (pct / 100);
|
|
3339
|
+
if (step > 0) target = Math.floor(target / step) * step;
|
|
3340
|
+
qty.value = _OrderEntryWidget._formatQty(target);
|
|
3341
|
+
this._recalculate(side);
|
|
3342
|
+
}
|
|
3343
|
+
/// Show or hide one column's take-profit / stop-loss block, checkbox
|
|
3344
|
+
/// included, so a host can set it without reaching for the input.
|
|
3345
|
+
toggleTpSl(side, on) {
|
|
3346
|
+
const check = this._input(side, TPSL_CHECK);
|
|
3347
|
+
if (check) check.checked = on;
|
|
3348
|
+
this._show(this._cols[side]?.querySelector(".oe-tpsl"), on);
|
|
3349
|
+
}
|
|
3350
|
+
/// Mark one column's submit button as the one a host-level gesture aimed at.
|
|
3351
|
+
/// Null clears both.
|
|
3352
|
+
preselect(side) {
|
|
3353
|
+
for (const each of _OrderEntryWidget.SIDES) {
|
|
3354
|
+
const button = this._cols[each]?.querySelector(".btn-oe-submit");
|
|
3355
|
+
button?.classList.toggle("btn-preselected", each === side);
|
|
3356
|
+
}
|
|
3357
|
+
}
|
|
3358
|
+
/// Turn submitting off while the host cannot send — a dropped socket, a
|
|
3359
|
+
/// portfolio still loading. The form stays readable and editable; only the
|
|
3360
|
+
/// two buttons stop.
|
|
3361
|
+
setEnabled(enabled) {
|
|
3362
|
+
this._enabled = enabled;
|
|
3363
|
+
this.el?.classList.toggle("oe-offline", !enabled);
|
|
3364
|
+
for (const side of _OrderEntryWidget.SIDES) this._validateSide(side);
|
|
3365
|
+
}
|
|
3366
|
+
/// The submit button, and whatever host gesture stands in for it. Validates
|
|
3367
|
+
/// first, so a form the venue would reject never reaches the account.
|
|
3368
|
+
submit(side) {
|
|
3369
|
+
const error = this.validate(side);
|
|
3370
|
+
this._validateSide(side);
|
|
3371
|
+
if (error || !this._enabled) return;
|
|
3372
|
+
this._deps.submitOrder(side, this.getValues(side));
|
|
3373
|
+
}
|
|
3374
|
+
/// What one column currently says.
|
|
3375
|
+
getValues(side) {
|
|
3376
|
+
const type = this._orderType;
|
|
3377
|
+
const carriesLimit = type === OrderEntryTypes.Limit || type === OrderEntryTypes.StopLimit;
|
|
3378
|
+
const carriesStop = type === OrderEntryTypes.Stop || type === OrderEntryTypes.StopLimit;
|
|
3379
|
+
const tpSlOn = this._input(side, TPSL_CHECK)?.checked === true;
|
|
3380
|
+
return {
|
|
3381
|
+
type,
|
|
3382
|
+
quantity: this._number(side, QTY_INPUT) ?? 0,
|
|
3383
|
+
limitPrice: carriesLimit ? this._number(side, PRICE_INPUT) : null,
|
|
3384
|
+
stopPrice: carriesStop ? this._number(side, STOP_INPUT) : null,
|
|
3385
|
+
takeProfit: tpSlOn ? this._number(side, TP_INPUT) : null,
|
|
3386
|
+
stopLoss: tpSlOn ? this._number(side, SL_INPUT) : null
|
|
3387
|
+
};
|
|
3388
|
+
}
|
|
3389
|
+
/// The reason this column cannot be sent, worded by the host, or null when
|
|
3390
|
+
/// it can. Nothing is checked before the instrument is known — the venue's
|
|
3391
|
+
/// grid is what every rule below measures against.
|
|
3392
|
+
validate(side) {
|
|
3393
|
+
const instrument = this._instrument;
|
|
3394
|
+
if (!instrument) return null;
|
|
3395
|
+
const { quantity, limitPrice, stopPrice, type } = this.getValues(side);
|
|
3396
|
+
if (quantity <= 0) return this._host.t("Quantity must be positive");
|
|
3397
|
+
const lotSize = Number(instrument.lotSize) || 0;
|
|
3398
|
+
if (lotSize > 0 && !_OrderEntryWidget._isMultipleOf(quantity, lotSize))
|
|
3399
|
+
return this._host.t("Quantity must be a multiple of {0}", lotSize);
|
|
3400
|
+
const minVolume = Number(instrument.minVolume) > 0 ? Number(instrument.minVolume) : lotSize;
|
|
3401
|
+
if (minVolume > 0 && quantity < minVolume) return this._host.t("Quantity must be >= {0}", minVolume);
|
|
3402
|
+
if (instrument.maxVolume != null && quantity > Number(instrument.maxVolume))
|
|
3403
|
+
return this._host.t("Quantity must be <= {0}", instrument.maxVolume);
|
|
3404
|
+
const tickSize = Number(instrument.tickSize) || 0;
|
|
3405
|
+
if (type === OrderEntryTypes.Limit || type === OrderEntryTypes.StopLimit) {
|
|
3406
|
+
if (!limitPrice || limitPrice <= 0) return this._host.t("Limit price must be positive");
|
|
3407
|
+
if (tickSize > 0 && !_OrderEntryWidget._isMultipleOf(limitPrice, tickSize))
|
|
3408
|
+
return this._host.t("Limit price must be a multiple of {0}", tickSize);
|
|
3409
|
+
}
|
|
3410
|
+
if (type === OrderEntryTypes.Stop || type === OrderEntryTypes.StopLimit) {
|
|
3411
|
+
if (!stopPrice || stopPrice <= 0) return this._host.t("Stop price must be positive");
|
|
3412
|
+
if (tickSize > 0 && !_OrderEntryWidget._isMultipleOf(stopPrice, tickSize))
|
|
3413
|
+
return this._host.t("Stop price must be a multiple of {0}", tickSize);
|
|
3414
|
+
}
|
|
3415
|
+
return null;
|
|
3416
|
+
}
|
|
3417
|
+
/// Map a form type onto StockSharp's `OrderTypes`: Limit=0, Market=1,
|
|
3418
|
+
/// Conditional=2. Static because a host needs it to word the order it is
|
|
3419
|
+
/// about to send, which it can do before any instance exists.
|
|
3420
|
+
static toApiType(uiType) {
|
|
3421
|
+
switch (uiType) {
|
|
3422
|
+
case OrderEntryTypes.Limit:
|
|
3423
|
+
return 0;
|
|
3424
|
+
case OrderEntryTypes.Market:
|
|
3425
|
+
return 1;
|
|
3426
|
+
case OrderEntryTypes.Stop:
|
|
3427
|
+
case OrderEntryTypes.StopLimit:
|
|
3428
|
+
return 2;
|
|
3429
|
+
default:
|
|
3430
|
+
return 1;
|
|
3431
|
+
}
|
|
3432
|
+
}
|
|
3433
|
+
/// Is `value` a whole number of `step`s? Scaled to integers first: a crypto
|
|
3434
|
+
/// lot of 1e-9 against a quantity of 0.001 is not a modulo binary floats can
|
|
3435
|
+
/// answer directly.
|
|
3436
|
+
static _isMultipleOf(value, step) {
|
|
3437
|
+
if (!(step > 0)) return true;
|
|
3438
|
+
if (value === 0) return true;
|
|
3439
|
+
const maxDec = Math.max(_OrderEntryWidget._decimals(step), _OrderEntryWidget._decimals(value));
|
|
3440
|
+
const scale = Math.pow(10, maxDec);
|
|
3441
|
+
const scaledValue = Math.round(value * scale);
|
|
3442
|
+
const scaledStep = Math.round(step * scale);
|
|
3443
|
+
if (scaledStep <= 0) return true;
|
|
3444
|
+
if (scaledValue === 0) return false;
|
|
3445
|
+
return scaledValue % scaledStep === 0;
|
|
3446
|
+
}
|
|
3447
|
+
/// A quantity as an input's `value`: every significant decimal, no thousands
|
|
3448
|
+
/// separator and no exponent, because the browser reads this string back as
|
|
3449
|
+
/// a number.
|
|
3450
|
+
static _formatQty(n) {
|
|
3451
|
+
if (!isFinite(n) || n === 0) return "0";
|
|
3452
|
+
const d = _OrderEntryWidget._decimals(n);
|
|
3453
|
+
return n.toFixed(Math.min(d, 20));
|
|
3454
|
+
}
|
|
3455
|
+
/// How many decimals a number is written with, exponent form included.
|
|
3456
|
+
static _decimals(n) {
|
|
3457
|
+
if (!isFinite(n) || n === 0) return 0;
|
|
3458
|
+
const s = Math.abs(n).toString();
|
|
3459
|
+
const eIdx = s.indexOf("e-");
|
|
3460
|
+
if (eIdx >= 0) {
|
|
3461
|
+
const mantissaDec = s.slice(0, eIdx).split(".")[1]?.length ?? 0;
|
|
3462
|
+
return parseInt(s.slice(eIdx + 2), 10) + mantissaDec;
|
|
3463
|
+
}
|
|
3464
|
+
const dot = s.indexOf(".");
|
|
3465
|
+
return dot < 0 ? 0 : s.length - dot - 1;
|
|
3466
|
+
}
|
|
3467
|
+
_bindTypeTabs() {
|
|
3468
|
+
for (const button of this.rootEl.querySelectorAll(".btn-ot")) {
|
|
3469
|
+
const tab = button;
|
|
3470
|
+
tab.addEventListener("click", (e) => {
|
|
3471
|
+
e.preventDefault();
|
|
3472
|
+
const type = tab.dataset.type;
|
|
3473
|
+
if (type) this.setOrderType(type);
|
|
3474
|
+
});
|
|
3475
|
+
}
|
|
3476
|
+
}
|
|
3477
|
+
// Everything one column listens to. Scoped to the column element, which is
|
|
3478
|
+
// what lets two pads coexist: an input is found by the field it belongs to
|
|
3479
|
+
// under its own root, never by a page-wide id.
|
|
3480
|
+
_bindColumn(side) {
|
|
3481
|
+
const column = this._cols[side];
|
|
3482
|
+
if (!column) return;
|
|
3483
|
+
const qty = this._input(side, QTY_INPUT);
|
|
3484
|
+
if (qty) {
|
|
3485
|
+
qty.value = "1";
|
|
3486
|
+
qty.min = "1";
|
|
3487
|
+
qty.step = "1";
|
|
3488
|
+
}
|
|
3489
|
+
for (const selector of VALUE_INPUTS) {
|
|
3490
|
+
const input = this._input(side, selector);
|
|
3491
|
+
if (!input) continue;
|
|
3492
|
+
input.addEventListener("input", () => this._recalculate(side));
|
|
3493
|
+
input.addEventListener("focus", () => {
|
|
3494
|
+
this._focused = input;
|
|
3495
|
+
});
|
|
3496
|
+
input.addEventListener("blur", () => {
|
|
3497
|
+
if (this._focused === input) this._focused = null;
|
|
3498
|
+
});
|
|
3499
|
+
if (selector === PRICE_INPUT || selector === STOP_INPUT)
|
|
3500
|
+
input.addEventListener("input", () => {
|
|
3501
|
+
input.dataset.userEdited = "1";
|
|
3502
|
+
});
|
|
3503
|
+
}
|
|
3504
|
+
column.querySelector(".btn-bbo")?.addEventListener("click", (e) => {
|
|
3505
|
+
e.preventDefault();
|
|
3506
|
+
this.applyBbo(side);
|
|
3507
|
+
});
|
|
3508
|
+
for (const button of column.querySelectorAll(".btn-oe-pct")) {
|
|
3509
|
+
const pct = button;
|
|
3510
|
+
pct.addEventListener("click", (e) => {
|
|
3511
|
+
e.preventDefault();
|
|
3512
|
+
this.applyPercent(side, Number(pct.dataset.pct) || 0);
|
|
3513
|
+
});
|
|
3514
|
+
}
|
|
3515
|
+
const check = this._input(side, TPSL_CHECK);
|
|
3516
|
+
check?.addEventListener("change", () => this.toggleTpSl(side, check.checked === true));
|
|
3517
|
+
column.querySelector(".btn-oe-submit")?.addEventListener("click", (e) => {
|
|
3518
|
+
e.preventDefault();
|
|
3519
|
+
this.submit(side);
|
|
3520
|
+
});
|
|
3521
|
+
}
|
|
3522
|
+
// Which price fields the current type makes meaningful. A class toggle, not
|
|
3523
|
+
// a display style: the pad emits names and the stylesheet decides.
|
|
3524
|
+
_applyTypeVisibility() {
|
|
3525
|
+
const showLimit = this._orderType === OrderEntryTypes.Limit || this._orderType === OrderEntryTypes.StopLimit;
|
|
3526
|
+
const showStop = this._orderType === OrderEntryTypes.Stop || this._orderType === OrderEntryTypes.StopLimit;
|
|
3527
|
+
for (const side of _OrderEntryWidget.SIDES) {
|
|
3528
|
+
this._show(this._cols[side]?.querySelector(".oe-field-price"), showLimit);
|
|
3529
|
+
this._show(this._cols[side]?.querySelector(".oe-field-stop"), showStop);
|
|
3530
|
+
}
|
|
3531
|
+
}
|
|
3532
|
+
_show(element, visible) {
|
|
3533
|
+
element?.classList.toggle("oe-field-hidden", !visible);
|
|
3534
|
+
}
|
|
3535
|
+
// Write both price inputs. `reset` is the explicit gesture: it clears the
|
|
3536
|
+
// "typed in" mark so the field follows the market again. Without it, a field
|
|
3537
|
+
// the user is in or has already edited is left alone.
|
|
3538
|
+
_writePrices(buyPrice, sellPrice, reset) {
|
|
3539
|
+
this._writePrice(OrderEntrySides.Buy, buyPrice, reset);
|
|
3540
|
+
this._writePrice(OrderEntrySides.Sell, sellPrice, reset);
|
|
3541
|
+
for (const side of _OrderEntryWidget.SIDES) this._recalculate(side);
|
|
3542
|
+
}
|
|
3543
|
+
_writePrice(side, price, reset) {
|
|
3544
|
+
const input = this._input(side, PRICE_INPUT);
|
|
3545
|
+
if (!input) return;
|
|
3546
|
+
const text = this._formatPriceForInput(price);
|
|
3547
|
+
if (!text) return;
|
|
3548
|
+
if (reset) {
|
|
3549
|
+
input.dataset.userEdited = "0";
|
|
3550
|
+
input.value = text;
|
|
3551
|
+
return;
|
|
3552
|
+
}
|
|
3553
|
+
if (this._focused === input) return;
|
|
3554
|
+
if (input.dataset.userEdited === "1") return;
|
|
3555
|
+
input.value = text;
|
|
3556
|
+
}
|
|
3557
|
+
// Round a price to the instrument's tick and cut the float tail: a mid price
|
|
3558
|
+
// of (bid+ask)/2 otherwise reaches the field as 0.44625000000000004.
|
|
3559
|
+
_formatPriceForInput(price) {
|
|
3560
|
+
const n = Number(price);
|
|
3561
|
+
if (!isFinite(n)) return "";
|
|
3562
|
+
const tickSize = Number(this._instrument?.tickSize) || 0;
|
|
3563
|
+
if (tickSize > 0)
|
|
3564
|
+
return (Math.round(n / tickSize) * tickSize).toFixed(_OrderEntryWidget._decimals(tickSize));
|
|
3565
|
+
return String(parseFloat(n.toPrecision(12)));
|
|
3566
|
+
}
|
|
3567
|
+
_recalculate(side) {
|
|
3568
|
+
this._updateEstimate(side);
|
|
3569
|
+
this._validateSide(side);
|
|
3570
|
+
}
|
|
3571
|
+
// What the order is worth at the price it would most likely fill at: the
|
|
3572
|
+
// limit or stop the form names, else the side-appropriate BBO, else the last
|
|
3573
|
+
// print.
|
|
3574
|
+
_updateEstimate(side) {
|
|
3575
|
+
const total = this._cols[side]?.querySelector(TOTAL_VALUE);
|
|
3576
|
+
if (!total) return;
|
|
3577
|
+
const quantity = this._number(side, QTY_INPUT) ?? 0;
|
|
3578
|
+
let price;
|
|
3579
|
+
if (this._orderType === OrderEntryTypes.Limit || this._orderType === OrderEntryTypes.StopLimit) {
|
|
3580
|
+
price = this._number(side, PRICE_INPUT) ?? 0;
|
|
3581
|
+
} else if (this._orderType === OrderEntryTypes.Stop) {
|
|
3582
|
+
price = this._number(side, STOP_INPUT) ?? this._lastPrice;
|
|
3583
|
+
} else {
|
|
3584
|
+
const bbo = side === OrderEntrySides.Buy ? this._bestAsk : this._bestBid;
|
|
3585
|
+
price = bbo > 0 ? bbo : this._lastPrice;
|
|
3586
|
+
}
|
|
3587
|
+
total.textContent = quantity > 0 && price > 0 ? (quantity * price).toLocaleString(void 0, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : "--";
|
|
3588
|
+
}
|
|
3589
|
+
// The estimate line doubles as the validation message and the submit button
|
|
3590
|
+
// follows it. A disabled pad reads as invalid for the same reason: there is
|
|
3591
|
+
// nothing the button could do.
|
|
3592
|
+
_validateSide(side) {
|
|
3593
|
+
const error = this.validate(side);
|
|
3594
|
+
const estimate = this._cols[side]?.querySelector(".oe-estimate");
|
|
3595
|
+
const submit = this._cols[side]?.querySelector(".btn-oe-submit");
|
|
3596
|
+
if (estimate) {
|
|
3597
|
+
estimate.textContent = error || "";
|
|
3598
|
+
estimate.classList.toggle("oe-estimate-error", !!error);
|
|
3599
|
+
}
|
|
3600
|
+
if (submit) submit.disabled = !!error || !this._enabled;
|
|
3601
|
+
}
|
|
3602
|
+
_input(side, selector) {
|
|
3603
|
+
return this._cols[side]?.querySelector(selector) ?? null;
|
|
3604
|
+
}
|
|
3605
|
+
// An input's value as a number, or null when it is blank or unparseable —
|
|
3606
|
+
// which is what "the user has not said" looks like on a number field.
|
|
3607
|
+
_number(side, selector) {
|
|
3608
|
+
const parsed = parseFloat(this._input(side, selector)?.value ?? "");
|
|
3609
|
+
return isFinite(parsed) ? parsed : null;
|
|
3610
|
+
}
|
|
3611
|
+
};
|
|
3612
|
+
_OrderEntryWidget.TYPE = ControlTypes.OrderEntry;
|
|
3613
|
+
_OrderEntryWidget.SIDES = [OrderEntrySides.Buy, OrderEntrySides.Sell];
|
|
3614
|
+
var OrderEntryWidget = _OrderEntryWidget;
|
|
3615
|
+
|
|
3616
|
+
// src/orderbook-depth.ts
|
|
3617
|
+
function depthSide(levels, direction, geometry) {
|
|
3618
|
+
if (levels.length === 0) return null;
|
|
3619
|
+
const best = levels[0].price;
|
|
3620
|
+
const worst = levels[levels.length - 1].price;
|
|
3621
|
+
const span = Math.abs(best - worst);
|
|
3622
|
+
if (!(span > 0)) return null;
|
|
3623
|
+
const reach = geometry.halfWidth - geometry.pad;
|
|
3624
|
+
const points = [{ x: geometry.midX + direction * geometry.pad, cumulative: 0 }];
|
|
3625
|
+
let cumulative = 0;
|
|
3626
|
+
for (const level of levels) {
|
|
3627
|
+
cumulative += level.quantity;
|
|
3628
|
+
const distance = Math.abs(level.price - best);
|
|
3629
|
+
points.push({
|
|
3630
|
+
x: geometry.midX + direction * (geometry.pad + distance / span * reach),
|
|
3631
|
+
cumulative
|
|
3632
|
+
});
|
|
3633
|
+
}
|
|
3634
|
+
return { points, total: cumulative };
|
|
3635
|
+
}
|
|
3636
|
+
function depthPolyline(side, maxTotal, geometry) {
|
|
3637
|
+
const scale = maxTotal > 0 ? maxTotal : 1;
|
|
3638
|
+
const yOf = (cumulative) => geometry.baseY - cumulative / scale * geometry.innerHeight;
|
|
3639
|
+
const line = [[side.points[0].x, geometry.baseY]];
|
|
3640
|
+
for (let i = 1; i < side.points.length; i++) {
|
|
3641
|
+
const previous = side.points[i - 1];
|
|
3642
|
+
const current = side.points[i];
|
|
3643
|
+
line.push([current.x, yOf(previous.cumulative)]);
|
|
3644
|
+
line.push([current.x, yOf(current.cumulative)]);
|
|
3645
|
+
}
|
|
3646
|
+
return line;
|
|
3647
|
+
}
|
|
3648
|
+
|
|
3649
|
+
// src/orderbook-widget.ts
|
|
3650
|
+
var RESTING_STATE = 3;
|
|
3651
|
+
var HEAT_FLOOR_PCT = 6;
|
|
3652
|
+
var HEAT_RANGE_PCT = 18;
|
|
3653
|
+
var _OrderBookWidget = class _OrderBookWidget {
|
|
3654
|
+
static create(hostEl, state, deps) {
|
|
3655
|
+
const host = assertHost(deps?.host, "OrderBookWidget");
|
|
3656
|
+
const root = _OrderBookWidget._buildRoot(host);
|
|
3657
|
+
root.id = makePanelId(_OrderBookWidget.TYPE);
|
|
3658
|
+
hostEl.appendChild(root);
|
|
3659
|
+
return new _OrderBookWidget(root, state || {}, deps);
|
|
3660
|
+
}
|
|
3661
|
+
// The panel's markup. The host stylesheet reads this structure, and a
|
|
3662
|
+
// docking host lifts `.panel-header`'s children into its tab strip — which
|
|
3663
|
+
// is why the header holds every control the ladder has.
|
|
3664
|
+
//
|
|
3665
|
+
// The `+` is built here rather than left out: the port documents `spawn` as
|
|
3666
|
+
// this control's gesture, and a button nothing renders is a capability
|
|
3667
|
+
// nobody can reach.
|
|
3668
|
+
static _buildRoot(host) {
|
|
3669
|
+
const title = host.t("OrderBook");
|
|
3670
|
+
return makePanelRoot("orderbook-panel", title, [
|
|
3671
|
+
makeElement("div", "panel-header", {}, [
|
|
3672
|
+
makeElement(
|
|
3673
|
+
"span",
|
|
3674
|
+
"ob-symbol-label",
|
|
3675
|
+
{ title: host.t("ClickToChangeSymbol"), role: "button", tabindex: "0" },
|
|
3676
|
+
["--"]
|
|
3677
|
+
),
|
|
3678
|
+
makeElement(
|
|
3679
|
+
"div",
|
|
3680
|
+
"ob-depth-selector",
|
|
3681
|
+
{ role: "group", "aria-label": host.t("OrderBookDepth") },
|
|
3682
|
+
_OrderBookWidget.DEPTHS.map((depth) => makeElement("button", "btn-ob-depth", {
|
|
3683
|
+
type: "button",
|
|
3684
|
+
"data-depth": String(depth),
|
|
3685
|
+
"aria-label": host.t("LevelsCount", depth),
|
|
3686
|
+
"aria-pressed": "false"
|
|
3687
|
+
}, [String(depth)]))
|
|
3688
|
+
),
|
|
3689
|
+
makeElement("div", "ob-view-selector", { role: "group", "aria-label": host.t("OrderBookView") }, [
|
|
3690
|
+
makeIconButton(
|
|
3691
|
+
"btn-ob-view",
|
|
3692
|
+
host.t("OrderBookViewDiagonal"),
|
|
3693
|
+
"bi-distribute-horizontal",
|
|
3694
|
+
{ type: "button", "data-view": "diagonal", "aria-pressed": "false" }
|
|
3695
|
+
),
|
|
3696
|
+
makeIconButton(
|
|
3697
|
+
"btn-ob-view",
|
|
3698
|
+
host.t("OrderBookViewStacked"),
|
|
3699
|
+
"bi-table",
|
|
3700
|
+
{ type: "button", "data-view": "stacked", "aria-pressed": "false" }
|
|
3701
|
+
)
|
|
3702
|
+
]),
|
|
3703
|
+
makeIconButton(
|
|
3704
|
+
"btn-ob-invert",
|
|
3705
|
+
host.t("OrderBookInvertSides"),
|
|
3706
|
+
"bi-arrow-down-up",
|
|
3707
|
+
{ type: "button", "aria-pressed": "false" }
|
|
3708
|
+
),
|
|
3709
|
+
makeIconButton(
|
|
3710
|
+
"btn-ob-depthtoggle",
|
|
3711
|
+
host.t("ToggleDepthChart"),
|
|
3712
|
+
"bi-graph-up",
|
|
3713
|
+
{ type: "button", "aria-pressed": "false" }
|
|
3714
|
+
),
|
|
3715
|
+
makeElement("span", "spread-label", { "aria-live": "polite" }, ["--"]),
|
|
3716
|
+
makeIconButton("bt-icon-btn ob-add-btn", host.t("AddOrderbook"), "bi-plus-lg", { type: "button" }),
|
|
3717
|
+
makeIconButton("bt-icon-btn bt-icon-cancel ob-close-btn", host.t("RemoveOrderbook"), "bi-x", { type: "button" })
|
|
3718
|
+
]),
|
|
3719
|
+
makeElement("canvas", "ob-depth-chart", { "aria-hidden": "true" }, []),
|
|
3720
|
+
makeElement("div", "ob-col-headers", { "aria-hidden": "true" }, [
|
|
3721
|
+
makeElement("span", "ob-col-price", {}, [host.t("Price")]),
|
|
3722
|
+
makeElement("span", "ob-col-qty", {}, [host.t("Qty")]),
|
|
3723
|
+
makeElement("span", "ob-col-total", {}, [host.t("Total")])
|
|
3724
|
+
]),
|
|
3725
|
+
makeElement("div", "orderbook-content", { "aria-live": "polite", "aria-atomic": "false" }, [
|
|
3726
|
+
makeElement("div", "orderbook-asks", { "aria-label": host.t("AskOrders") }, []),
|
|
3727
|
+
makeElement("div", "orderbook-mid", { "aria-label": host.t("MidPrice") }, [
|
|
3728
|
+
makeElement("span", "ob-mid-price", {}, ["--"]),
|
|
3729
|
+
makeElement("span", "ob-mid-spread", { "aria-live": "polite" }, [])
|
|
3730
|
+
]),
|
|
3731
|
+
makeElement("div", "orderbook-bids", { "aria-label": host.t("BidOrders") }, [])
|
|
3732
|
+
]),
|
|
3733
|
+
makeElement("div", "ob-sentiment", { "aria-label": host.t("OrderBookSentiment") }, [
|
|
3734
|
+
makeElement("div", "ob-sent-bid", {}, [
|
|
3735
|
+
makeElement("span", "ob-sent-label", {}, [host.t("BidShort")]),
|
|
3736
|
+
makeElement("span", "ob-sent-bid-pct", {}, ["--"])
|
|
3737
|
+
]),
|
|
3738
|
+
makeElement("div", "ob-sent-ask", {}, [
|
|
3739
|
+
makeElement("span", "ob-sent-ask-pct", {}, ["--"]),
|
|
3740
|
+
makeElement("span", "ob-sent-label", {}, [host.t("AskShort")])
|
|
3741
|
+
])
|
|
3742
|
+
])
|
|
3743
|
+
]);
|
|
3744
|
+
}
|
|
3745
|
+
constructor(rootEl, state, deps) {
|
|
3746
|
+
this._host = assertHost(deps?.host, "OrderBookWidget");
|
|
3747
|
+
for (const name of ["onPriceSelected", "onPriceExecuted", "maxDepth", "pixelRatio"]) {
|
|
3748
|
+
if (typeof deps?.[name] !== "function")
|
|
3749
|
+
throw new Error(`OrderBookWidget: dep "${name}" is required`);
|
|
3750
|
+
}
|
|
3751
|
+
this.rootEl = rootEl;
|
|
3752
|
+
this._deps = deps;
|
|
3753
|
+
this.asksEl = this.rootEl.querySelector(".orderbook-asks");
|
|
3754
|
+
this.bidsEl = this.rootEl.querySelector(".orderbook-bids");
|
|
3755
|
+
this.midEl = this.rootEl.querySelector(".ob-mid-price");
|
|
3756
|
+
this.spreadEl = this.rootEl.querySelector(".spread-label");
|
|
3757
|
+
this.contentEl = this.rootEl.querySelector(".orderbook-content");
|
|
3758
|
+
this.depthChartEl = this.rootEl.querySelector(".ob-depth-chart");
|
|
3759
|
+
this._midSpreadEl = this.rootEl.querySelector(".ob-mid-spread");
|
|
3760
|
+
this._sentimentEl = this.rootEl.querySelector(".ob-sentiment");
|
|
3761
|
+
this._sentBidPctEl = this.rootEl.querySelector(".ob-sent-bid-pct");
|
|
3762
|
+
this._sentAskPctEl = this.rootEl.querySelector(".ob-sent-ask-pct");
|
|
3763
|
+
this._symbolLabel = this.rootEl.querySelector(".ob-symbol-label");
|
|
3764
|
+
this._depthBtns = Array.from(this.rootEl.querySelectorAll(".btn-ob-depth"));
|
|
3765
|
+
this._viewBtns = Array.from(this.rootEl.querySelectorAll(".btn-ob-view"));
|
|
3766
|
+
this._invertBtn = this.rootEl.querySelector(".btn-ob-invert");
|
|
3767
|
+
this._depthToggleBtn = this.rootEl.querySelector(".btn-ob-depthtoggle");
|
|
3768
|
+
this._addBtn = this.rootEl.querySelector(".ob-add-btn");
|
|
3769
|
+
this._closeBtn = this.rootEl.querySelector(".ob-close-btn");
|
|
3770
|
+
const saved = state;
|
|
3771
|
+
this._prevQuantities = /* @__PURE__ */ new Map();
|
|
3772
|
+
this._bidsByPrice = /* @__PURE__ */ new Map();
|
|
3773
|
+
this._asksByPrice = /* @__PURE__ */ new Map();
|
|
3774
|
+
this._lastSequence = 0;
|
|
3775
|
+
this._currentSymbol = null;
|
|
3776
|
+
this._followsActive = saved.followsActive === true;
|
|
3777
|
+
this._view = saved.view === "diagonal" ? "diagonal" : "stacked";
|
|
3778
|
+
this._invertSides = saved.invertSides === true;
|
|
3779
|
+
this._showDepthChart = saved.showDepthChart !== false;
|
|
3780
|
+
let depth = _OrderBookWidget._isDepth(saved.depth) ? saved.depth : _OrderBookWidget._deepest();
|
|
3781
|
+
if (this._followsActive) {
|
|
3782
|
+
const preferences = this._host.preferences;
|
|
3783
|
+
const savedDepth = Number(preferences.get(_OrderBookWidget.DEPTH_KEY, null));
|
|
3784
|
+
if (_OrderBookWidget._isDepth(savedDepth)) depth = savedDepth;
|
|
3785
|
+
const savedView = preferences.get(_OrderBookWidget.VIEW_KEY, null);
|
|
3786
|
+
if (savedView === "diagonal" || savedView === "stacked") this._view = savedView;
|
|
3787
|
+
const savedInvert = preferences.get(_OrderBookWidget.INVERT_KEY, null);
|
|
3788
|
+
if (savedInvert !== null) this._invertSides = savedInvert === "true";
|
|
3789
|
+
const savedDepthChart = preferences.get(_OrderBookWidget.DEPTHCHART_KEY, null);
|
|
3790
|
+
if (savedDepthChart !== null) this._showDepthChart = savedDepthChart === "true";
|
|
3791
|
+
}
|
|
3792
|
+
this._depth = this._fitDepth(depth);
|
|
3793
|
+
this._wireHeader();
|
|
3794
|
+
this._wireBookClicks();
|
|
3795
|
+
this._refreshDepthButtons();
|
|
3796
|
+
this._applyViewState();
|
|
3797
|
+
this._refreshViewButtons();
|
|
3798
|
+
this._refreshInvertButton();
|
|
3799
|
+
this._refreshDepthToggleButton();
|
|
3800
|
+
if (saved.symbol && !this._followsActive) this.setSymbol(saved.symbol);
|
|
3801
|
+
this._host.register(this);
|
|
3802
|
+
}
|
|
3803
|
+
/// Release the market-data subscription, leave the fan-out and remove the
|
|
3804
|
+
/// panel. Called by the host when the panel is destroyed.
|
|
3805
|
+
dispose() {
|
|
3806
|
+
if (this._currentSymbol)
|
|
3807
|
+
void this._host.trading.marketData.removeSymbol(this._currentSymbol).catch(() => {
|
|
3808
|
+
});
|
|
3809
|
+
this._host.unregister(this);
|
|
3810
|
+
try {
|
|
3811
|
+
this.rootEl.remove();
|
|
3812
|
+
} catch {
|
|
3813
|
+
}
|
|
3814
|
+
}
|
|
3815
|
+
/// Switch to a new instrument: drop the old subscription, take a new one and
|
|
3816
|
+
/// blank the ledger, because a diff for the old symbol must never be applied
|
|
3817
|
+
/// to the new one.
|
|
3818
|
+
setSymbol(symbol) {
|
|
3819
|
+
if (!symbol) return;
|
|
3820
|
+
const next = String(symbol).toUpperCase();
|
|
3821
|
+
if (next === this._currentSymbol) return;
|
|
3822
|
+
const previous = this._currentSymbol;
|
|
3823
|
+
this._currentSymbol = next;
|
|
3824
|
+
this._resetLedger();
|
|
3825
|
+
if (this._symbolLabel) this._symbolLabel.textContent = next;
|
|
3826
|
+
this._render();
|
|
3827
|
+
const marketData = this._host.trading.marketData;
|
|
3828
|
+
if (previous) void marketData.removeSymbol(previous).catch(() => {
|
|
3829
|
+
});
|
|
3830
|
+
void marketData.addSymbol(next, MarketDataLevels.Full).catch(() => {
|
|
3831
|
+
});
|
|
3832
|
+
if (!this._followsActive) this._persistState({ symbol: next });
|
|
3833
|
+
}
|
|
3834
|
+
getSymbol() {
|
|
3835
|
+
return this._currentSymbol;
|
|
3836
|
+
}
|
|
3837
|
+
getDepth() {
|
|
3838
|
+
return this._depth;
|
|
3839
|
+
}
|
|
3840
|
+
/// True for the instance that follows the host's active symbol. A page has
|
|
3841
|
+
/// at most one, and a host that feeds a best-bid/ask from a ladder asks for
|
|
3842
|
+
/// it by this.
|
|
3843
|
+
isFollowsActive() {
|
|
3844
|
+
return this._followsActive;
|
|
3845
|
+
}
|
|
3846
|
+
/// The ledger as levels, best price first. Public because a host reads the
|
|
3847
|
+
/// touch off the ladder rather than keeping a second copy of the book.
|
|
3848
|
+
getBids() {
|
|
3849
|
+
return _OrderBookWidget._sorted(this._bidsByPrice, -1);
|
|
3850
|
+
}
|
|
3851
|
+
getAsks() {
|
|
3852
|
+
return _OrderBookWidget._sorted(this._asksByPrice, 1);
|
|
3853
|
+
}
|
|
3854
|
+
// The three setters below repaint BEFORE they persist: `_persistState` ends
|
|
3855
|
+
// in a layout flush and the host serializes the live panel to produce it, so
|
|
3856
|
+
// persisting first would write out the panel as it looked before the change
|
|
3857
|
+
// the user just made.
|
|
3858
|
+
setDepth(depth) {
|
|
3859
|
+
if (!_OrderBookWidget._isDepth(depth)) return;
|
|
3860
|
+
this._depth = this._fitDepth(depth);
|
|
3861
|
+
this._refreshDepthButtons();
|
|
3862
|
+
this._render();
|
|
3863
|
+
if (this._followsActive) this._host.preferences.set(_OrderBookWidget.DEPTH_KEY, String(this._depth));
|
|
3864
|
+
else this._persistState({ depth: this._depth });
|
|
3865
|
+
}
|
|
3866
|
+
setView(view) {
|
|
3867
|
+
if (view !== "diagonal" && view !== "stacked") return;
|
|
3868
|
+
this._view = view;
|
|
3869
|
+
this._applyViewState();
|
|
3870
|
+
this._refreshViewButtons();
|
|
3871
|
+
if (this._followsActive) this._host.preferences.set(_OrderBookWidget.VIEW_KEY, view);
|
|
3872
|
+
else this._persistState({ view });
|
|
3873
|
+
}
|
|
3874
|
+
/// Put the bid block above the mid line and the ask block below it, the
|
|
3875
|
+
/// reverse of the convention every Western exchange UI follows.
|
|
3876
|
+
setInvertSides(invert) {
|
|
3877
|
+
this._invertSides = invert === true;
|
|
3878
|
+
this._applyViewState();
|
|
3879
|
+
this._refreshInvertButton();
|
|
3880
|
+
if (this._followsActive)
|
|
3881
|
+
this._host.preferences.set(_OrderBookWidget.INVERT_KEY, this._invertSides ? "true" : "false");
|
|
3882
|
+
else this._persistState({ invertSides: this._invertSides });
|
|
3883
|
+
}
|
|
3884
|
+
/// Show or hide the depth chart above the ladder. The canvas keeps its last
|
|
3885
|
+
/// bitmap while hidden, so showing it again brings the chart straight back
|
|
3886
|
+
/// and the next frame repaints it.
|
|
3887
|
+
setShowDepthChart(show) {
|
|
3888
|
+
this._showDepthChart = show === true;
|
|
3889
|
+
this._applyViewState();
|
|
3890
|
+
this._refreshDepthToggleButton();
|
|
3891
|
+
if (this._followsActive)
|
|
3892
|
+
this._host.preferences.set(_OrderBookWidget.DEPTHCHART_KEY, this._showDepthChart ? "true" : "false");
|
|
3893
|
+
this._persistState({ showDepthChart: this._showDepthChart });
|
|
3894
|
+
}
|
|
3895
|
+
/// Apply one frame. A snapshot replaces the ledger; a diff merges into it,
|
|
3896
|
+
/// with `quantity: 0` deleting a level. A break in the sequence means a
|
|
3897
|
+
/// frame was missed, so the ladder asks for a fresh snapshot instead of
|
|
3898
|
+
/// applying a diff to state it can no longer trust.
|
|
3899
|
+
///
|
|
3900
|
+
/// Every instance receives every frame, so the symbol filter here is what
|
|
3901
|
+
/// keeps a frame for one instrument out of a ladder watching another.
|
|
3902
|
+
applyFrame(frame) {
|
|
3903
|
+
if (!frame || !frame.symbol) return;
|
|
3904
|
+
if (this._currentSymbol && frame.symbol !== this._currentSymbol) return;
|
|
3905
|
+
if (this._currentSymbol !== frame.symbol) {
|
|
3906
|
+
this._currentSymbol = frame.symbol;
|
|
3907
|
+
if (this._symbolLabel) this._symbolLabel.textContent = frame.symbol;
|
|
3908
|
+
}
|
|
3909
|
+
const sequence = frame.sequence ?? 0;
|
|
3910
|
+
if (frame.isSnapshot) {
|
|
3911
|
+
this._resetLedger();
|
|
3912
|
+
this._applySnapshotSide(this._bidsByPrice, frame.bids, "bid", frame.symbol, sequence);
|
|
3913
|
+
this._applySnapshotSide(this._asksByPrice, frame.asks, "ask", frame.symbol, sequence);
|
|
3914
|
+
this._lastSequence = sequence;
|
|
3915
|
+
} else {
|
|
3916
|
+
if (this._lastSequence !== 0 && sequence !== this._lastSequence + 1) {
|
|
3917
|
+
this._anomaly(`sequence gap: expected ${this._lastSequence + 1}, got ${sequence} sym=${frame.symbol} \u2014 requesting snapshot`);
|
|
3918
|
+
this._lastSequence = 0;
|
|
3919
|
+
void this._requestResubscribe(frame.symbol);
|
|
3920
|
+
return;
|
|
3921
|
+
}
|
|
3922
|
+
this._applyDiffSide(this._bidsByPrice, frame.bids, "bid", frame.symbol, sequence);
|
|
3923
|
+
this._applyDiffSide(this._asksByPrice, frame.asks, "ask", frame.symbol, sequence);
|
|
3924
|
+
this._lastSequence = sequence || this._lastSequence;
|
|
3925
|
+
}
|
|
3926
|
+
this._validateBookCross(frame.symbol, sequence, frame.isSnapshot === true);
|
|
3927
|
+
this._render();
|
|
3928
|
+
}
|
|
3929
|
+
_wireHeader() {
|
|
3930
|
+
for (const button of this._depthBtns) {
|
|
3931
|
+
button.addEventListener("click", (e) => {
|
|
3932
|
+
e.preventDefault();
|
|
3933
|
+
this.setDepth(Number(button.dataset.depth));
|
|
3934
|
+
});
|
|
3935
|
+
}
|
|
3936
|
+
for (const button of this._viewBtns) {
|
|
3937
|
+
button.addEventListener("click", (e) => {
|
|
3938
|
+
e.preventDefault();
|
|
3939
|
+
const view = button.dataset.view;
|
|
3940
|
+
if (view === "diagonal" || view === "stacked") this.setView(view);
|
|
3941
|
+
});
|
|
3942
|
+
}
|
|
3943
|
+
this._invertBtn?.addEventListener("click", (e) => {
|
|
3944
|
+
e.preventDefault();
|
|
3945
|
+
this.setInvertSides(!this._invertSides);
|
|
3946
|
+
});
|
|
3947
|
+
this._depthToggleBtn?.addEventListener("click", (e) => {
|
|
3948
|
+
e.preventDefault();
|
|
3949
|
+
this.setShowDepthChart(!this._showDepthChart);
|
|
3950
|
+
});
|
|
3951
|
+
this._addBtn?.addEventListener("click", (e) => {
|
|
3952
|
+
e.preventDefault();
|
|
3953
|
+
e.stopPropagation();
|
|
3954
|
+
this._host.spawn({
|
|
3955
|
+
symbol: this._currentSymbol,
|
|
3956
|
+
depth: this._depth,
|
|
3957
|
+
view: this._view,
|
|
3958
|
+
invertSides: this._invertSides,
|
|
3959
|
+
showDepthChart: this._showDepthChart,
|
|
3960
|
+
followsActive: false
|
|
3961
|
+
});
|
|
3962
|
+
});
|
|
3963
|
+
this._closeBtn?.addEventListener("click", (e) => {
|
|
3964
|
+
e.preventDefault();
|
|
3965
|
+
e.stopPropagation();
|
|
3966
|
+
this._host.close();
|
|
3967
|
+
});
|
|
3968
|
+
this._symbolLabel?.addEventListener("click", (e) => {
|
|
3969
|
+
e.preventDefault();
|
|
3970
|
+
e.stopPropagation();
|
|
3971
|
+
this._host.trading.pickInstrument((symbol) => {
|
|
3972
|
+
if (this._followsActive) {
|
|
3973
|
+
this._followsActive = false;
|
|
3974
|
+
this._persistState({ followsActive: false });
|
|
3975
|
+
}
|
|
3976
|
+
this.setSymbol(symbol);
|
|
3977
|
+
});
|
|
3978
|
+
});
|
|
3979
|
+
}
|
|
3980
|
+
// A click anywhere on a level, caught once on the container: the rows are
|
|
3981
|
+
// rebuilt on every frame, so a listener per row would be re-bound dozens of
|
|
3982
|
+
// times a second.
|
|
3983
|
+
//
|
|
3984
|
+
// Plain click prefills an order at that price and leaves the sending to the
|
|
3985
|
+
// user. Ctrl/Cmd click sends at once — a single click doing that is how a
|
|
3986
|
+
// sneeze on the bid becomes a fill.
|
|
3987
|
+
_wireBookClicks() {
|
|
3988
|
+
this.contentEl?.addEventListener("click", (e) => {
|
|
3989
|
+
const target = e.target;
|
|
3990
|
+
const row = target?.closest(".ob-row");
|
|
3991
|
+
if (!row) return;
|
|
3992
|
+
const price = Number(row.dataset.price);
|
|
3993
|
+
if (!Number.isFinite(price)) return;
|
|
3994
|
+
const side = row.dataset.side === "buy" ? 0 : 1;
|
|
3995
|
+
if (e.ctrlKey || e.metaKey) this._deps.onPriceExecuted(price, side);
|
|
3996
|
+
else this._deps.onPriceSelected(price, side);
|
|
3997
|
+
});
|
|
3998
|
+
}
|
|
3999
|
+
_refreshDepthButtons() {
|
|
4000
|
+
for (const button of this._depthBtns) {
|
|
4001
|
+
const on = Number(button.dataset.depth) === this._depth;
|
|
4002
|
+
button.classList.toggle("active", on);
|
|
4003
|
+
button.setAttribute("aria-pressed", on ? "true" : "false");
|
|
4004
|
+
}
|
|
4005
|
+
}
|
|
4006
|
+
_refreshViewButtons() {
|
|
4007
|
+
for (const button of this._viewBtns) {
|
|
4008
|
+
const on = button.dataset.view === this._view;
|
|
4009
|
+
button.classList.toggle("active", on);
|
|
4010
|
+
button.setAttribute("aria-pressed", on ? "true" : "false");
|
|
4011
|
+
}
|
|
4012
|
+
}
|
|
4013
|
+
_refreshInvertButton() {
|
|
4014
|
+
this._invertBtn?.classList.toggle("active", this._invertSides);
|
|
4015
|
+
this._invertBtn?.setAttribute("aria-pressed", this._invertSides ? "true" : "false");
|
|
4016
|
+
}
|
|
4017
|
+
_refreshDepthToggleButton() {
|
|
4018
|
+
this._depthToggleBtn?.classList.toggle("active", this._showDepthChart);
|
|
4019
|
+
this._depthToggleBtn?.setAttribute("aria-pressed", this._showDepthChart ? "true" : "false");
|
|
4020
|
+
}
|
|
4021
|
+
// The variant classes on the panel root the stylesheet keys the two layouts,
|
|
4022
|
+
// the side order and the depth chart off.
|
|
4023
|
+
_applyViewState() {
|
|
4024
|
+
this.rootEl.classList.toggle("ob-view-stacked", this._view === "stacked");
|
|
4025
|
+
this.rootEl.classList.toggle("ob-view-diagonal", this._view === "diagonal");
|
|
4026
|
+
this.rootEl.classList.toggle("ob-invert", this._invertSides);
|
|
4027
|
+
this.rootEl.classList.toggle("ob-hide-depth", !this._showDepthChart);
|
|
4028
|
+
}
|
|
4029
|
+
// Record what this panel remembers, then ask the host to flush the layout.
|
|
4030
|
+
// Two calls because they are two decisions, and every caller here is a
|
|
4031
|
+
// setting the user just changed by hand — so it is worth flushing.
|
|
4032
|
+
_persistState(patch) {
|
|
4033
|
+
this._host.persistState(patch);
|
|
4034
|
+
this._host.saveLayout();
|
|
4035
|
+
}
|
|
4036
|
+
_resetLedger() {
|
|
4037
|
+
this._bidsByPrice.clear();
|
|
4038
|
+
this._asksByPrice.clear();
|
|
4039
|
+
this._prevQuantities.clear();
|
|
4040
|
+
this._lastSequence = 0;
|
|
4041
|
+
}
|
|
4042
|
+
// The host answers how many levels it has room for, so a phone-sized page
|
|
4043
|
+
// narrows the ladder without this control knowing what a viewport is.
|
|
4044
|
+
_fitDepth(depth) {
|
|
4045
|
+
const room = Number(this._deps.maxDepth());
|
|
4046
|
+
if (!Number.isFinite(room) || room < 1) return depth;
|
|
4047
|
+
return Math.min(depth, room);
|
|
4048
|
+
}
|
|
4049
|
+
static _isDepth(depth) {
|
|
4050
|
+
return typeof depth === "number" && _OrderBookWidget.DEPTHS.includes(depth);
|
|
4051
|
+
}
|
|
4052
|
+
static _deepest() {
|
|
4053
|
+
return _OrderBookWidget.DEPTHS[_OrderBookWidget.DEPTHS.length - 1];
|
|
4054
|
+
}
|
|
4055
|
+
static _sorted(ledger, direction) {
|
|
4056
|
+
return [...ledger.entries()].filter(([price, quantity]) => Number.isFinite(price) && quantity > 0).sort((left, right) => direction * (left[0] - right[0])).map(([price, quantity]) => ({ price, quantity }));
|
|
4057
|
+
}
|
|
4058
|
+
_applySnapshotSide(ledger, levels, side, symbol, sequence) {
|
|
4059
|
+
for (const level of levels || []) {
|
|
4060
|
+
const quantity = level.quantity ?? 0;
|
|
4061
|
+
if (level.price == null || !Number.isFinite(level.price) || !(quantity > 0)) {
|
|
4062
|
+
this._anomaly(`snapshot ${side} level dropped: price=${level.price} qty=${level.quantity} sym=${symbol} seq=${sequence}`);
|
|
4063
|
+
continue;
|
|
4064
|
+
}
|
|
4065
|
+
ledger.set(level.price, quantity);
|
|
4066
|
+
}
|
|
4067
|
+
}
|
|
4068
|
+
_applyDiffSide(ledger, levels, side, symbol, sequence) {
|
|
4069
|
+
for (const level of levels || []) {
|
|
4070
|
+
if (level.price == null || !Number.isFinite(level.price)) {
|
|
4071
|
+
this._anomaly(`invalid ${side} price: price=${level.price} qty=${level.quantity} sym=${symbol} seq=${sequence} \u2014 ignoring`);
|
|
4072
|
+
continue;
|
|
4073
|
+
}
|
|
4074
|
+
const quantity = level.quantity ?? 0;
|
|
4075
|
+
if (quantity < 0) {
|
|
4076
|
+
this._anomaly(`negative ${side} qty: price=${level.price} qty=${quantity} sym=${symbol} seq=${sequence} \u2014 ignoring`);
|
|
4077
|
+
continue;
|
|
4078
|
+
}
|
|
4079
|
+
if (quantity === 0) {
|
|
4080
|
+
if (!ledger.has(level.price)) {
|
|
4081
|
+
this._anomaly(`delete missing ${side}: price=${level.price} sym=${symbol} seq=${sequence}`);
|
|
4082
|
+
continue;
|
|
4083
|
+
}
|
|
4084
|
+
ledger.delete(level.price);
|
|
4085
|
+
} else {
|
|
4086
|
+
ledger.set(level.price, quantity);
|
|
4087
|
+
}
|
|
4088
|
+
}
|
|
4089
|
+
}
|
|
4090
|
+
// Best bid below best ask, or something upstream is broken — usually a diff
|
|
4091
|
+
// applied to stale state after a gap the sequence check did not catch.
|
|
4092
|
+
_validateBookCross(symbol, sequence, wasSnapshot) {
|
|
4093
|
+
if (this._bidsByPrice.size === 0 || this._asksByPrice.size === 0) return;
|
|
4094
|
+
let bestBid = -Infinity;
|
|
4095
|
+
let bestAsk = Infinity;
|
|
4096
|
+
for (const price of this._bidsByPrice.keys()) if (price > bestBid) bestBid = price;
|
|
4097
|
+
for (const price of this._asksByPrice.keys()) if (price < bestAsk) bestAsk = price;
|
|
4098
|
+
if (bestBid >= bestAsk) {
|
|
4099
|
+
this._anomaly(`crossed book: bid=${bestBid} >= ask=${bestAsk} sym=${symbol} seq=${sequence} ${wasSnapshot ? "snapshot" : "diff"}`);
|
|
4100
|
+
}
|
|
4101
|
+
}
|
|
4102
|
+
// Ask for the symbol from scratch. Idempotent as far as the user is
|
|
4103
|
+
// concerned: the ladder blanks for a frame and refills.
|
|
4104
|
+
async _requestResubscribe(symbol) {
|
|
4105
|
+
try {
|
|
4106
|
+
await this._host.trading.marketData.resubscribe(symbol, MarketDataLevels.Full);
|
|
4107
|
+
} catch (err) {
|
|
4108
|
+
this._anomaly(`resubscribe after a gap failed for ${symbol}: ${err}`);
|
|
4109
|
+
}
|
|
4110
|
+
}
|
|
4111
|
+
// Wire trouble the user cannot see and support has to, through the port.
|
|
4112
|
+
_anomaly(message) {
|
|
4113
|
+
this._host.log(`OrderBookWidget: ${message}`);
|
|
4114
|
+
}
|
|
4115
|
+
_render() {
|
|
4116
|
+
this._paint(this.getBids(), this.getAsks());
|
|
4117
|
+
}
|
|
4118
|
+
_paint(bids, asks) {
|
|
4119
|
+
if (!this.asksEl || !this.bidsEl) return;
|
|
4120
|
+
const visibleAsks = asks.slice(0, this._depth);
|
|
4121
|
+
const visibleBids = bids.slice(0, this._depth);
|
|
4122
|
+
const maxBid = Math.max(...visibleBids.map((level) => level.quantity), 1);
|
|
4123
|
+
const maxAsk = Math.max(...visibleAsks.map((level) => level.quantity), 1);
|
|
4124
|
+
const maxBoth = Math.max(maxBid, maxAsk, 1);
|
|
4125
|
+
const own = this._ownQuantities();
|
|
4126
|
+
const painted = /* @__PURE__ */ new Map();
|
|
4127
|
+
const askTotals = [];
|
|
4128
|
+
let running = 0;
|
|
4129
|
+
for (const level of visibleAsks) {
|
|
4130
|
+
running += level.quantity;
|
|
4131
|
+
askTotals.push(running);
|
|
4132
|
+
}
|
|
4133
|
+
const askRows = [];
|
|
4134
|
+
for (let i = visibleAsks.length - 1; i >= 0; i--) {
|
|
4135
|
+
askRows.push(this._levelRow(visibleAsks[i], {
|
|
4136
|
+
rowClass: "ob-row ask",
|
|
4137
|
+
orderSide: "buy",
|
|
4138
|
+
key: `a_${visibleAsks[i].price}`,
|
|
4139
|
+
cumulative: askTotals[i],
|
|
4140
|
+
barPct: visibleAsks[i].quantity / maxAsk * 100,
|
|
4141
|
+
heatPct: HEAT_FLOOR_PCT + HEAT_RANGE_PCT * (visibleAsks[i].quantity / maxBoth),
|
|
4142
|
+
// More size on the offer is pressure downward, so a growing ask
|
|
4143
|
+
// level flashes in the falling colour.
|
|
4144
|
+
growthClass: "flash-red",
|
|
4145
|
+
shrinkClass: "flash-green",
|
|
4146
|
+
ownQuantity: own.asks.get(_OrderBookWidget._priceKey(visibleAsks[i].price)),
|
|
4147
|
+
painted
|
|
4148
|
+
}));
|
|
4149
|
+
}
|
|
4150
|
+
this.asksEl.replaceChildren(...askRows);
|
|
4151
|
+
running = 0;
|
|
4152
|
+
const bidRows = [];
|
|
4153
|
+
for (const level of visibleBids) {
|
|
4154
|
+
running += level.quantity;
|
|
4155
|
+
bidRows.push(this._levelRow(level, {
|
|
4156
|
+
rowClass: "ob-row bid",
|
|
4157
|
+
orderSide: "sell",
|
|
4158
|
+
key: `b_${level.price}`,
|
|
4159
|
+
cumulative: running,
|
|
4160
|
+
barPct: level.quantity / maxBid * 100,
|
|
4161
|
+
heatPct: HEAT_FLOOR_PCT + HEAT_RANGE_PCT * (level.quantity / maxBoth),
|
|
4162
|
+
growthClass: "flash-green",
|
|
4163
|
+
shrinkClass: "flash-red",
|
|
4164
|
+
ownQuantity: own.bids.get(_OrderBookWidget._priceKey(level.price)),
|
|
4165
|
+
painted
|
|
4166
|
+
}));
|
|
4167
|
+
}
|
|
4168
|
+
this.bidsEl.replaceChildren(...bidRows);
|
|
4169
|
+
this._prevQuantities = painted;
|
|
4170
|
+
if (asks.length > 0 && bids.length > 0) {
|
|
4171
|
+
const bestAsk = asks[0].price;
|
|
4172
|
+
const bestBid = bids[0].price;
|
|
4173
|
+
const spread = bestAsk - bestBid;
|
|
4174
|
+
if (this.midEl) this.midEl.textContent = formatPrice((bestAsk + bestBid) / 2);
|
|
4175
|
+
if (this._midSpreadEl) this._midSpreadEl.textContent = formatPrice(spread);
|
|
4176
|
+
if (this.spreadEl) this.spreadEl.textContent = this._host.t("Spread: {0}", formatPrice(spread));
|
|
4177
|
+
}
|
|
4178
|
+
this._paintSentiment(visibleBids, visibleAsks);
|
|
4179
|
+
this._paintDepthChart(visibleBids, visibleAsks);
|
|
4180
|
+
}
|
|
4181
|
+
// One level. Every measurement it carries — the bar's share of its side, the
|
|
4182
|
+
// heat tint — reaches the stylesheet as a custom property, so the widths and
|
|
4183
|
+
// the colours are still declared in CSS.
|
|
4184
|
+
_levelRow(level, options) {
|
|
4185
|
+
const quantity = makeElement("span", "qty", {}, [formatQty(level.quantity)]);
|
|
4186
|
+
if (options.ownQuantity) {
|
|
4187
|
+
quantity.appendChild(makeElement(
|
|
4188
|
+
"span",
|
|
4189
|
+
"own-badge",
|
|
4190
|
+
{ title: this._host.t("YourOrder") },
|
|
4191
|
+
[formatQty(options.ownQuantity)]
|
|
4192
|
+
));
|
|
4193
|
+
}
|
|
4194
|
+
const row = makeElement("div", options.rowClass, {
|
|
4195
|
+
"data-side": options.orderSide,
|
|
4196
|
+
"data-price": String(level.price)
|
|
4197
|
+
}, [
|
|
4198
|
+
makeElement("span", "price", {}, [formatPrice(level.price)]),
|
|
4199
|
+
quantity,
|
|
4200
|
+
makeElement("span", "total", {}, [formatQty(options.cumulative)]),
|
|
4201
|
+
makeElement("span", "bar", {}, [])
|
|
4202
|
+
]);
|
|
4203
|
+
row.style.setProperty("--t-ob-bar", `${options.barPct.toFixed(0)}%`);
|
|
4204
|
+
row.style.setProperty("--t-ob-heat", `${options.heatPct.toFixed(1)}%`);
|
|
4205
|
+
if (options.ownQuantity) row.classList.add("own");
|
|
4206
|
+
const previous = this._prevQuantities.get(options.key);
|
|
4207
|
+
if (previous !== void 0 && previous !== level.quantity)
|
|
4208
|
+
row.classList.add(level.quantity > previous ? options.growthClass : options.shrinkClass);
|
|
4209
|
+
options.painted.set(options.key, level.quantity);
|
|
4210
|
+
return row;
|
|
4211
|
+
}
|
|
4212
|
+
// This session's resting orders on this symbol, summed per price — the "your
|
|
4213
|
+
// size is here" badge is an overlay on the ledger, so it reads the client's
|
|
4214
|
+
// own order cache rather than subscribing to anything.
|
|
4215
|
+
//
|
|
4216
|
+
// Prices are keyed as fixed-point text: two floats that print the same are
|
|
4217
|
+
// the same level to a user, and `===` on them is not.
|
|
4218
|
+
_ownQuantities() {
|
|
4219
|
+
const bids = /* @__PURE__ */ new Map();
|
|
4220
|
+
const asks = /* @__PURE__ */ new Map();
|
|
4221
|
+
if (!this._currentSymbol) return { bids, asks };
|
|
4222
|
+
for (const order of this._host.trading.marketData.getOrders() || []) {
|
|
4223
|
+
if (!order || order.instrument !== this._currentSymbol) continue;
|
|
4224
|
+
if (order.status !== RESTING_STATE) continue;
|
|
4225
|
+
if (order.limitPrice == null || order.side == null) continue;
|
|
4226
|
+
const side = this._host.presentation.isBuy(order.side) ? bids : asks;
|
|
4227
|
+
const key = _OrderBookWidget._priceKey(order.limitPrice);
|
|
4228
|
+
const left = Number(order.balance ?? order.quantity) || 0;
|
|
4229
|
+
side.set(key, (side.get(key) || 0) + left);
|
|
4230
|
+
}
|
|
4231
|
+
return { bids, asks };
|
|
4232
|
+
}
|
|
4233
|
+
static _priceKey(price) {
|
|
4234
|
+
return Number(price).toFixed(8);
|
|
4235
|
+
}
|
|
4236
|
+
// The bid/ask split under the ladder, as one measurement the stylesheet
|
|
4237
|
+
// turns into two widths.
|
|
4238
|
+
//
|
|
4239
|
+
// It sums the VISIBLE levels only, so the figure moves when the user changes
|
|
4240
|
+
// depth. A whole-book share needs the server to aggregate one, which the
|
|
4241
|
+
// frame does not carry today.
|
|
4242
|
+
_paintSentiment(bids, asks) {
|
|
4243
|
+
if (!this._sentimentEl) return;
|
|
4244
|
+
const bidSum = bids.reduce((sum, level) => sum + level.quantity, 0);
|
|
4245
|
+
const askSum = asks.reduce((sum, level) => sum + level.quantity, 0);
|
|
4246
|
+
const total = bidSum + askSum;
|
|
4247
|
+
if (total <= 0) {
|
|
4248
|
+
this._sentimentEl.style.setProperty("--t-ob-sent", "50%");
|
|
4249
|
+
if (this._sentBidPctEl) this._sentBidPctEl.textContent = "--";
|
|
4250
|
+
if (this._sentAskPctEl) this._sentAskPctEl.textContent = "--";
|
|
4251
|
+
return;
|
|
4252
|
+
}
|
|
4253
|
+
const askPct = Math.round(askSum / total * 100);
|
|
4254
|
+
const bidPct = 100 - askPct;
|
|
4255
|
+
this._sentimentEl.style.setProperty("--t-ob-sent", `${bidPct}%`);
|
|
4256
|
+
if (this._sentBidPctEl) this._sentBidPctEl.textContent = `${bidPct}%`;
|
|
4257
|
+
if (this._sentAskPctEl) this._sentAskPctEl.textContent = `${askPct}%`;
|
|
4258
|
+
}
|
|
4259
|
+
// The depth curve above the ladder. Everything shaped is in
|
|
4260
|
+
// `orderbook-depth.ts`; what is left here is the canvas plumbing and the
|
|
4261
|
+
// host's palette, because a canvas takes colours as values and a class name
|
|
4262
|
+
// cannot reach one.
|
|
4263
|
+
_paintDepthChart(bids, asks) {
|
|
4264
|
+
const canvas = this.depthChartEl;
|
|
4265
|
+
if (!canvas || !this._showDepthChart) return;
|
|
4266
|
+
const ratio = Number(this._deps.pixelRatio()) || 1;
|
|
4267
|
+
const cssWidth = canvas.clientWidth || 0;
|
|
4268
|
+
const cssHeight = canvas.clientHeight || 0;
|
|
4269
|
+
if (cssWidth < 4 || cssHeight < 4) return;
|
|
4270
|
+
const width = Math.floor(cssWidth * ratio);
|
|
4271
|
+
const height = Math.floor(cssHeight * ratio);
|
|
4272
|
+
if (canvas.width !== width) canvas.width = width;
|
|
4273
|
+
if (canvas.height !== height) canvas.height = height;
|
|
4274
|
+
const ctx = canvas.getContext("2d");
|
|
4275
|
+
if (!ctx) return;
|
|
4276
|
+
ctx.clearRect(0, 0, width, height);
|
|
4277
|
+
if (bids.length === 0 && asks.length === 0) return;
|
|
4278
|
+
const pad = 2 * ratio;
|
|
4279
|
+
const midX = Math.floor(width / 2);
|
|
4280
|
+
const geometry = {
|
|
4281
|
+
midX,
|
|
4282
|
+
pad,
|
|
4283
|
+
halfWidth: midX - pad,
|
|
4284
|
+
baseY: height - pad,
|
|
4285
|
+
innerHeight: height - pad * 2
|
|
4286
|
+
};
|
|
4287
|
+
const bidSide = depthSide(bids, -1, geometry);
|
|
4288
|
+
const askSide = depthSide(asks, 1, geometry);
|
|
4289
|
+
const maxTotal = Math.max(bidSide ? bidSide.total : 0, askSide ? askSide.total : 0, 1);
|
|
4290
|
+
const palette = this._host.presentation.canvasPalette();
|
|
4291
|
+
if (bidSide) this._strokeDepthSide(ctx, depthPolyline(bidSide, maxTotal, geometry), palette.up, geometry, ratio);
|
|
4292
|
+
if (askSide) this._strokeDepthSide(ctx, depthPolyline(askSide, maxTotal, geometry), palette.down, geometry, ratio);
|
|
4293
|
+
ctx.globalAlpha = 0.4;
|
|
4294
|
+
ctx.fillStyle = palette.grid;
|
|
4295
|
+
ctx.fillRect(midX - Math.max(1, ratio / 2), 0, Math.max(1, ratio), height);
|
|
4296
|
+
ctx.globalAlpha = 1;
|
|
4297
|
+
}
|
|
4298
|
+
// Area under the step curve, then the curve on top. Opacity is varied here
|
|
4299
|
+
// rather than in the colour, so the host states one solid colour per
|
|
4300
|
+
// direction and this decides how much of it a fill is worth.
|
|
4301
|
+
_strokeDepthSide(ctx, line, color, geometry, ratio) {
|
|
4302
|
+
if (line.length === 0) return;
|
|
4303
|
+
ctx.beginPath();
|
|
4304
|
+
ctx.moveTo(line[0][0], geometry.baseY);
|
|
4305
|
+
for (const [x, y] of line) ctx.lineTo(x, y);
|
|
4306
|
+
ctx.lineTo(line[line.length - 1][0], geometry.baseY);
|
|
4307
|
+
ctx.closePath();
|
|
4308
|
+
ctx.globalAlpha = 0.18;
|
|
4309
|
+
ctx.fillStyle = color;
|
|
4310
|
+
ctx.fill();
|
|
4311
|
+
ctx.beginPath();
|
|
4312
|
+
ctx.moveTo(line[0][0], line[0][1]);
|
|
4313
|
+
for (let i = 1; i < line.length; i++) ctx.lineTo(line[i][0], line[i][1]);
|
|
4314
|
+
ctx.lineWidth = Math.max(1.5, 1.5 * ratio);
|
|
4315
|
+
ctx.lineJoin = "miter";
|
|
4316
|
+
ctx.globalAlpha = 0.95;
|
|
4317
|
+
ctx.strokeStyle = color;
|
|
4318
|
+
ctx.stroke();
|
|
4319
|
+
ctx.globalAlpha = 1;
|
|
4320
|
+
}
|
|
4321
|
+
};
|
|
4322
|
+
_OrderBookWidget.TYPE = ControlTypes.OrderBook;
|
|
4323
|
+
/// The depths the ladder offers, in the order the header shows them.
|
|
4324
|
+
_OrderBookWidget.DEPTHS = [5, 10];
|
|
4325
|
+
/// Persisted depth for the instance that follows the host's active symbol.
|
|
4326
|
+
/// Pinned instances carry their depth in their own per-panel state instead,
|
|
4327
|
+
/// which is what makes two ladders on one page able to differ.
|
|
4328
|
+
_OrderBookWidget.DEPTH_KEY = "terminal.obDepth";
|
|
4329
|
+
/// Persisted row layout for the follows-active instance.
|
|
4330
|
+
_OrderBookWidget.VIEW_KEY = "terminal.obView";
|
|
4331
|
+
/// Persisted "bids above the mid line" flag for the follows-active instance.
|
|
4332
|
+
_OrderBookWidget.INVERT_KEY = "terminal.obInvert";
|
|
4333
|
+
/// Persisted depth-chart visibility for the follows-active instance.
|
|
4334
|
+
_OrderBookWidget.DEPTHCHART_KEY = "terminal.obDepthChart";
|
|
4335
|
+
var OrderBookWidget = _OrderBookWidget;
|
|
4336
|
+
|
|
4337
|
+
// src/tradefeed-aggregator.ts
|
|
4338
|
+
function aggregateBubbles(ticks, maxBucketsPerSide) {
|
|
4339
|
+
if (!ticks || ticks.length === 0) return [];
|
|
4340
|
+
const budget = Math.max(1, Math.floor(maxBucketsPerSide));
|
|
4341
|
+
const buys = [];
|
|
4342
|
+
const sells = [];
|
|
4343
|
+
for (const tick of ticks) (tick.buy ? buys : sells).push(tick);
|
|
4344
|
+
const bubbles = [];
|
|
4345
|
+
for (const side of [buys, sells]) {
|
|
4346
|
+
if (side.length === 0) continue;
|
|
4347
|
+
if (side.length <= budget) {
|
|
4348
|
+
for (const tick of side) bubbles.push({ ...tick, count: 1 });
|
|
4349
|
+
continue;
|
|
4350
|
+
}
|
|
4351
|
+
const bucketSize = Math.ceil(side.length / budget);
|
|
4352
|
+
for (let start = 0; start < side.length; start += bucketSize)
|
|
4353
|
+
bubbles.push(merge(side.slice(start, start + bucketSize)));
|
|
4354
|
+
}
|
|
4355
|
+
return bubbles;
|
|
4356
|
+
}
|
|
4357
|
+
function merge(slice) {
|
|
4358
|
+
let quantity = 0;
|
|
4359
|
+
let weighted = 0;
|
|
4360
|
+
for (const tick of slice) {
|
|
4361
|
+
const q = tick.quantity || 0;
|
|
4362
|
+
quantity += q;
|
|
4363
|
+
weighted += q * tick.price;
|
|
4364
|
+
}
|
|
4365
|
+
const price = quantity > 0 ? weighted / quantity : slice[0].price;
|
|
4366
|
+
const middle = slice[Math.floor(slice.length / 2)];
|
|
4367
|
+
return {
|
|
4368
|
+
symbol: slice[0].symbol,
|
|
4369
|
+
side: slice[0].side,
|
|
4370
|
+
buy: slice[0].buy,
|
|
4371
|
+
price,
|
|
4372
|
+
quantity,
|
|
4373
|
+
time: middle.time,
|
|
4374
|
+
index: middle.index,
|
|
4375
|
+
count: slice.length
|
|
4376
|
+
};
|
|
4377
|
+
}
|
|
4378
|
+
|
|
4379
|
+
// src/tradefeed-bubbles.ts
|
|
4380
|
+
var PAD_X = 10;
|
|
4381
|
+
var PAD_Y = 10;
|
|
4382
|
+
var AXIS_WIDTH = 56;
|
|
4383
|
+
var LABEL_GAP = 4;
|
|
4384
|
+
var LANE_GAP = 2;
|
|
4385
|
+
var MIN_LANE_HEIGHT = 20;
|
|
4386
|
+
var AXIS_TICKS = 3;
|
|
4387
|
+
var MIN_RADIUS = 2;
|
|
4388
|
+
var MAX_RADIUS = 22;
|
|
4389
|
+
var RADIUS_LANE_SHARE = 0.4;
|
|
4390
|
+
var BUCKET_WIDTH = 8;
|
|
4391
|
+
var MIN_BUCKETS = 6;
|
|
4392
|
+
var MAX_BUCKETS = 30;
|
|
4393
|
+
function layoutBubbles(input) {
|
|
4394
|
+
const { width, height, total } = input;
|
|
4395
|
+
const axisX = Math.max(PAD_X + 1, width - AXIS_WIDTH);
|
|
4396
|
+
const drawWidth = Math.max(1, axisX - PAD_X);
|
|
4397
|
+
const layout = { axisX, labelX: axisX + LABEL_GAP, lanes: [], bubbles: [] };
|
|
4398
|
+
const lanes = input.lanes.filter((lane) => lane.ticks.length > 0);
|
|
4399
|
+
if (lanes.length === 0) return layout;
|
|
4400
|
+
const named = input.lanes.length > 1;
|
|
4401
|
+
const laneHeight = Math.max(
|
|
4402
|
+
MIN_LANE_HEIGHT,
|
|
4403
|
+
(height - 2 * PAD_Y - LANE_GAP * (input.lanes.length - 1)) / input.lanes.length
|
|
4404
|
+
);
|
|
4405
|
+
input.lanes.forEach((lane, position) => {
|
|
4406
|
+
const laneTop = named ? PAD_Y + position * (laneHeight + LANE_GAP) : PAD_Y;
|
|
4407
|
+
const laneSpan = named ? laneHeight : Math.max(MIN_LANE_HEIGHT, height - 2 * PAD_Y);
|
|
4408
|
+
const scale = priceScale(lane.ticks, laneTop, laneSpan);
|
|
4409
|
+
layout.lanes.push({
|
|
4410
|
+
symbol: lane.symbol,
|
|
4411
|
+
label: named ? { x: LABEL_GAP, y: laneTop + 2 } : null,
|
|
4412
|
+
separatorY: named && position > 0 ? laneTop - LANE_GAP / 2 : null,
|
|
4413
|
+
ticks: lane.ticks.length === 0 ? [] : axisTicks(scale)
|
|
4414
|
+
});
|
|
4415
|
+
if (lane.ticks.length === 0) return;
|
|
4416
|
+
const buckets = Math.max(MIN_BUCKETS, Math.min(MAX_BUCKETS, Math.floor(drawWidth / BUCKET_WIDTH)));
|
|
4417
|
+
const bubbles = aggregateBubbles(lane.ticks, buckets);
|
|
4418
|
+
let maxQuantity = 0;
|
|
4419
|
+
for (const bubble of bubbles) if (bubble.quantity > maxQuantity) maxQuantity = bubble.quantity;
|
|
4420
|
+
const quantityScale = Math.max(maxQuantity, Number.EPSILON);
|
|
4421
|
+
const radiusCap = Math.min(MAX_RADIUS, laneSpan * RADIUS_LANE_SHARE);
|
|
4422
|
+
for (const bubble of bubbles) {
|
|
4423
|
+
layout.bubbles.push({
|
|
4424
|
+
// Newest print (index 0) at the right edge, oldest at the left.
|
|
4425
|
+
x: PAD_X + drawWidth * (total <= 1 ? 1 : 1 - bubble.index / (total - 1)),
|
|
4426
|
+
y: scale.top + scale.height * (1 - (bubble.price - scale.min) / scale.range),
|
|
4427
|
+
radius: MIN_RADIUS + Math.sqrt(Math.max(0, bubble.quantity) / quantityScale) * radiusCap,
|
|
4428
|
+
bubble
|
|
4429
|
+
});
|
|
4430
|
+
}
|
|
4431
|
+
});
|
|
4432
|
+
layout.bubbles.sort((left, right) => right.bubble.index - left.bubble.index);
|
|
4433
|
+
return layout;
|
|
4434
|
+
}
|
|
4435
|
+
function priceScale(ticks, top, height) {
|
|
4436
|
+
let min = Infinity;
|
|
4437
|
+
let max = -Infinity;
|
|
4438
|
+
for (const tick of ticks) {
|
|
4439
|
+
if (tick.price < min) min = tick.price;
|
|
4440
|
+
if (tick.price > max) max = tick.price;
|
|
4441
|
+
}
|
|
4442
|
+
if (!isFinite(min) || !isFinite(max)) {
|
|
4443
|
+
min = 0;
|
|
4444
|
+
max = 0;
|
|
4445
|
+
}
|
|
4446
|
+
return { top, height, min, range: Math.max(max - min, Number.EPSILON) };
|
|
4447
|
+
}
|
|
4448
|
+
function axisTicks(scale) {
|
|
4449
|
+
const format = priceFormatter(scale.min, scale.min + scale.range);
|
|
4450
|
+
const ticks = [];
|
|
4451
|
+
for (let step = 0; step < AXIS_TICKS; step++) {
|
|
4452
|
+
const fraction = step / (AXIS_TICKS - 1);
|
|
4453
|
+
ticks.push({
|
|
4454
|
+
text: format(scale.min + scale.range - scale.range * fraction),
|
|
4455
|
+
y: scale.top + scale.height * fraction
|
|
4456
|
+
});
|
|
4457
|
+
}
|
|
4458
|
+
return ticks;
|
|
4459
|
+
}
|
|
4460
|
+
function priceFormatter(min, max) {
|
|
4461
|
+
const magnitude = Math.max(Math.abs(min), Math.abs(max));
|
|
4462
|
+
let digits = 6;
|
|
4463
|
+
if (magnitude >= 1e3) digits = 0;
|
|
4464
|
+
else if (magnitude >= 10) digits = 2;
|
|
4465
|
+
else if (magnitude >= 0.1) digits = 4;
|
|
4466
|
+
return (price) => price.toLocaleString(void 0, {
|
|
4467
|
+
minimumFractionDigits: digits,
|
|
4468
|
+
maximumFractionDigits: digits
|
|
4469
|
+
});
|
|
4470
|
+
}
|
|
4471
|
+
|
|
4472
|
+
// src/tradefeed-widget.ts
|
|
4473
|
+
var FILL_ALPHA = 0.55;
|
|
4474
|
+
var STROKE_ALPHA = 0.9;
|
|
4475
|
+
var LABEL_ALPHA = 0.55;
|
|
4476
|
+
var SEPARATOR_ALPHA = 0.12;
|
|
4477
|
+
var RULE_ALPHA = 0.08;
|
|
4478
|
+
var HOVER_SLACK = 6;
|
|
4479
|
+
var TOOLTIP_OFFSET = 12;
|
|
4480
|
+
var TOOLTIP_MARGIN = 4;
|
|
4481
|
+
var _TradeFeedWidget = class _TradeFeedWidget {
|
|
4482
|
+
static create(hostEl, state, deps) {
|
|
4483
|
+
const host = assertHost(deps?.host, "TradeFeedWidget");
|
|
4484
|
+
const root = _TradeFeedWidget._buildRoot(host);
|
|
4485
|
+
root.id = makePanelId(_TradeFeedWidget.TYPE);
|
|
4486
|
+
hostEl.appendChild(root);
|
|
4487
|
+
return new _TradeFeedWidget(root, state || {}, deps);
|
|
4488
|
+
}
|
|
4489
|
+
// The panel's markup. The host stylesheet reads this structure, and a
|
|
4490
|
+
// docking host lifts `.panel-header`'s children into its tab strip.
|
|
4491
|
+
//
|
|
4492
|
+
// Two `+` buttons, and they are not the same gesture: the one in the header
|
|
4493
|
+
// pins another instrument to THIS feed, the one beside it asks the host for
|
|
4494
|
+
// another feed panel. The tooltips are what tell them apart, so neither is
|
|
4495
|
+
// an icon on its own.
|
|
4496
|
+
static _buildRoot(host) {
|
|
4497
|
+
const addInstrument = host.t("AddInstrument");
|
|
4498
|
+
const marketTrades = host.t("MarketTrades");
|
|
4499
|
+
return makePanelRoot("tradefeed-panel tf-tab-market tf-view-list", host.t("TradeFeed"), [
|
|
4500
|
+
makeElement("div", "panel-header", {}, [
|
|
4501
|
+
makeElement("span", "", {}, [marketTrades]),
|
|
4502
|
+
makeIconButton("bt-icon-btn tf-add-symbol-btn", addInstrument, "bi-plus-lg", { type: "button" }),
|
|
4503
|
+
makeIconButton("bt-icon-btn panel-add-btn", host.t("Add trade feed"), "bi-window-plus", { type: "button" }),
|
|
4504
|
+
makeElement("div", "tradefeed-view-toggle tf-view-toggle", { role: "group", "aria-label": host.t("TradeFeedView") }, [
|
|
4505
|
+
makeIconButton("tf-view-btn", host.t("ListView"), "bi-list-ul", { type: "button", "data-view": "list" }),
|
|
4506
|
+
makeIconButton("tf-view-btn", host.t("BubbleChart"), "bi-circle-fill", { type: "button", "data-view": "bubbles" })
|
|
4507
|
+
]),
|
|
4508
|
+
makeIconButton("bt-icon-btn bt-icon-cancel panel-close-btn", host.t("ClosePanel"), "bi-x", { type: "button" })
|
|
4509
|
+
]),
|
|
4510
|
+
makeElement("div", "tf-tabs", { role: "tablist", "aria-label": host.t("Trade feed tabs") }, [
|
|
4511
|
+
makeElement("button", "tf-tab active", { type: "button", role: "tab", "aria-selected": "true", "data-tab": "market" }, [marketTrades]),
|
|
4512
|
+
makeElement("button", "tf-tab", { type: "button", role: "tab", "aria-selected": "false", "data-tab": "my" }, [host.t("My trades")])
|
|
4513
|
+
]),
|
|
4514
|
+
makeElement("div", "tf-extras", { role: "list", "aria-label": host.t("ExtraInstruments"), hidden: "" }, []),
|
|
4515
|
+
// Each tape is a DataGrid over the shared table skin: the <thead>
|
|
4516
|
+
// is left empty for the grid to fill, which is also what makes the
|
|
4517
|
+
// tape sortable and gives it the grid's menu and selection.
|
|
4518
|
+
makeElement("div", "tradefeed-content tf-market", { "aria-live": "polite" }, [
|
|
4519
|
+
makeElement("table", "terminal-table tradefeed-table", { role: "table", "aria-label": marketTrades }, [
|
|
4520
|
+
makeElement("thead", "", {}, []),
|
|
4521
|
+
makeElement("tbody", "", {}, [])
|
|
4522
|
+
])
|
|
4523
|
+
]),
|
|
4524
|
+
makeElement("div", "tradefeed-content tf-my", {}, [
|
|
4525
|
+
makeElement("table", "terminal-table tradefeed-table", { role: "table", "aria-label": host.t("My trades") }, [
|
|
4526
|
+
makeElement("thead", "", {}, []),
|
|
4527
|
+
makeElement("tbody", "", {}, [])
|
|
4528
|
+
])
|
|
4529
|
+
]),
|
|
4530
|
+
makeElement("canvas", "tradefeed-bubbles tf-bubbles", { "aria-hidden": "true" }, []),
|
|
4531
|
+
makeElement("div", "tf-bubble-tooltip", { role: "tooltip", hidden: "" }, [])
|
|
4532
|
+
]);
|
|
4533
|
+
}
|
|
4534
|
+
constructor(rootEl, state, deps) {
|
|
4535
|
+
this._host = assertHost(deps?.host, "TradeFeedWidget");
|
|
4536
|
+
this.rootEl = rootEl;
|
|
4537
|
+
this.extrasEl = this.rootEl.querySelector(".tf-extras");
|
|
4538
|
+
this.bubbleCanvas = this.rootEl.querySelector(".tf-bubbles");
|
|
4539
|
+
this._tabsEl = this.rootEl.querySelector(".tf-tabs");
|
|
4540
|
+
this._viewToggleEl = this.rootEl.querySelector(".tf-view-toggle");
|
|
4541
|
+
this._tooltipEl = this.rootEl.querySelector(".tf-bubble-tooltip");
|
|
4542
|
+
this._addBtn = this.rootEl.querySelector(".panel-add-btn");
|
|
4543
|
+
this._closeBtn = this.rootEl.querySelector(".panel-close-btn");
|
|
4544
|
+
this._addSymbolBtn = this.rootEl.querySelector(".tf-add-symbol-btn");
|
|
4545
|
+
this._bubbleCtx = this.bubbleCanvas?.getContext("2d") ?? null;
|
|
4546
|
+
this.trades = [];
|
|
4547
|
+
this.bubbleTrades = [];
|
|
4548
|
+
this.myTrades = [];
|
|
4549
|
+
this.avgQty = 100;
|
|
4550
|
+
this.tab = "market";
|
|
4551
|
+
this._seq = 0;
|
|
4552
|
+
this._bubbleHits = [];
|
|
4553
|
+
this._canvasSize = null;
|
|
4554
|
+
this._resizeObserver = null;
|
|
4555
|
+
this._marketGrid = this._makeGrid(".tf-market");
|
|
4556
|
+
this._myGrid = this._makeGrid(".tf-my");
|
|
4557
|
+
const saved = this._host.preferences.get(_TradeFeedWidget.VIEW_KEY, null);
|
|
4558
|
+
this.view = saved === "bubbles" ? "bubbles" : "list";
|
|
4559
|
+
this._activeSymbol = null;
|
|
4560
|
+
this._extraSymbols = new Set(Array.isArray(state.extras) ? state.extras : []);
|
|
4561
|
+
this._addBtn?.addEventListener("click", (e) => {
|
|
4562
|
+
e.preventDefault();
|
|
4563
|
+
this._host.spawn({ extras: [...this._extraSymbols] });
|
|
4564
|
+
});
|
|
4565
|
+
this._closeBtn?.addEventListener("click", (e) => {
|
|
4566
|
+
e.preventDefault();
|
|
4567
|
+
this.dispose();
|
|
4568
|
+
this._host.close();
|
|
4569
|
+
});
|
|
4570
|
+
this._addSymbolBtn?.addEventListener("click", (e) => {
|
|
4571
|
+
e.preventDefault();
|
|
4572
|
+
e.stopPropagation();
|
|
4573
|
+
this._host.trading.pickInstrument((symbol) => void this.addExtraSymbol(symbol));
|
|
4574
|
+
});
|
|
4575
|
+
this._bindTabs();
|
|
4576
|
+
this._bindViewToggle();
|
|
4577
|
+
this._bindBubbleHover();
|
|
4578
|
+
this._applyView();
|
|
4579
|
+
this._renderExtras();
|
|
4580
|
+
for (const symbol of this._extraSymbols)
|
|
4581
|
+
void this._host.trading.marketData.addSymbol(symbol, MarketDataLevels.Tape).catch(() => {
|
|
4582
|
+
});
|
|
4583
|
+
if (this.bubbleCanvas && typeof ResizeObserver === "function") {
|
|
4584
|
+
this._resizeObserver = new ResizeObserver(() => {
|
|
4585
|
+
this._sizeCanvas();
|
|
4586
|
+
this._renderBubbles();
|
|
4587
|
+
});
|
|
4588
|
+
this._resizeObserver.observe(this.bubbleCanvas);
|
|
4589
|
+
}
|
|
4590
|
+
this._host.register(this);
|
|
4591
|
+
}
|
|
4592
|
+
dispose() {
|
|
4593
|
+
try {
|
|
4594
|
+
this._resizeObserver?.disconnect();
|
|
4595
|
+
} catch {
|
|
4596
|
+
}
|
|
4597
|
+
this._marketGrid?.destroy();
|
|
4598
|
+
this._myGrid?.destroy();
|
|
4599
|
+
for (const symbol of this._extraSymbols)
|
|
4600
|
+
void this._host.trading.marketData.removeSymbol(symbol).catch(() => {
|
|
4601
|
+
});
|
|
4602
|
+
this._host.unregister(this);
|
|
4603
|
+
try {
|
|
4604
|
+
this.rootEl.remove();
|
|
4605
|
+
} catch {
|
|
4606
|
+
}
|
|
4607
|
+
}
|
|
4608
|
+
/// Which symbol the rest of the page is showing. The feed accepts prints
|
|
4609
|
+
/// for it without it being pinned, and re-syncs so the symbol column
|
|
4610
|
+
/// appears or disappears with the pinned set.
|
|
4611
|
+
setActiveSymbol(symbol) {
|
|
4612
|
+
this._activeSymbol = symbol || null;
|
|
4613
|
+
this._renderExtras();
|
|
4614
|
+
this._renderBubbles();
|
|
4615
|
+
}
|
|
4616
|
+
/// Replace the tape wholesale — what a host does when the page moves to a
|
|
4617
|
+
/// different instrument. The flash baseline resets with it: a new
|
|
4618
|
+
/// instrument's history is history, not fifty arrivals at once.
|
|
4619
|
+
setTrades(trades) {
|
|
4620
|
+
const rows = (trades || []).map((t) => this._stamp(t));
|
|
4621
|
+
this.trades = rows.slice(0, _TradeFeedWidget.MAX_ROWS);
|
|
4622
|
+
this.bubbleTrades = rows.slice(0, _TradeFeedWidget.MAX_BUBBLES);
|
|
4623
|
+
if (this.trades.length > 0) {
|
|
4624
|
+
const total = this.trades.reduce((sum, t) => sum + (t.quantity ?? 0), 0);
|
|
4625
|
+
this.avgQty = total / this.trades.length || this.avgQty;
|
|
4626
|
+
}
|
|
4627
|
+
this._marketGrid?.flashReset();
|
|
4628
|
+
this._marketGrid?.setRows([...this.trades]);
|
|
4629
|
+
this._renderBubbles();
|
|
4630
|
+
}
|
|
4631
|
+
/// One print off the wire. Ignored unless this feed watches its symbol:
|
|
4632
|
+
/// a host fans every tick to every feed and each decides for itself, which
|
|
4633
|
+
/// is the only arrangement that supports per-instance extras.
|
|
4634
|
+
addTrade(trade) {
|
|
4635
|
+
if (!trade || !this._watches(trade.symbol)) return;
|
|
4636
|
+
this._stamp(trade);
|
|
4637
|
+
this.trades.unshift(trade);
|
|
4638
|
+
if (this.trades.length > _TradeFeedWidget.MAX_ROWS) this.trades.length = _TradeFeedWidget.MAX_ROWS;
|
|
4639
|
+
this.bubbleTrades.unshift(trade);
|
|
4640
|
+
if (this.bubbleTrades.length > _TradeFeedWidget.MAX_BUBBLES) this.bubbleTrades.length = _TradeFeedWidget.MAX_BUBBLES;
|
|
4641
|
+
this.avgQty = this.avgQty * 0.95 + (trade.quantity ?? 0) * 0.05;
|
|
4642
|
+
this._marketGrid?.setRows([...this.trades]);
|
|
4643
|
+
this._renderBubbles();
|
|
4644
|
+
}
|
|
4645
|
+
/// The account's own fills for the My-trades tab. The one thing this panel
|
|
4646
|
+
/// pulls rather than being pushed.
|
|
4647
|
+
async loadMyTrades(portfolioId, symbol) {
|
|
4648
|
+
if (!portfolioId) {
|
|
4649
|
+
this.myTrades = [];
|
|
4650
|
+
this._renderMy();
|
|
4651
|
+
return;
|
|
4652
|
+
}
|
|
4653
|
+
try {
|
|
4654
|
+
this.myTrades = await this._host.trading.api.getExecutions(portfolioId, symbol, _TradeFeedWidget.MAX_ROWS) || [];
|
|
4655
|
+
} catch (err) {
|
|
4656
|
+
this._host.log(`TradeFeedWidget: failed to load own trades: ${err}`);
|
|
4657
|
+
this.myTrades = [];
|
|
4658
|
+
}
|
|
4659
|
+
this._renderMy();
|
|
4660
|
+
}
|
|
4661
|
+
// ---------------------------------------------------------------- the grid
|
|
4662
|
+
// One construction for both tapes: same columns, same chrome, different
|
|
4663
|
+
// tbody. The container holds the scroll; the grid holds the rows.
|
|
4664
|
+
_makeGrid(containerSelector) {
|
|
4665
|
+
const head = this.rootEl.querySelector(`${containerSelector} thead`);
|
|
4666
|
+
const body = this.rootEl.querySelector(`${containerSelector} tbody`);
|
|
4667
|
+
if (!head || !body) return null;
|
|
4668
|
+
return new DataGrid({
|
|
4669
|
+
head,
|
|
4670
|
+
body,
|
|
4671
|
+
columns: this._columns(),
|
|
4672
|
+
// Newest print on top — the only order a tape is read in at rest;
|
|
4673
|
+
// a header click can still reorder, and clears back to this.
|
|
4674
|
+
defaultSort: { col: "time", dir: "desc" },
|
|
4675
|
+
rowKey: (t) => _TradeFeedWidget._key(t),
|
|
4676
|
+
emptyText: this._host.t("No trades yet"),
|
|
4677
|
+
rowClass: (t) => this._rowClass(t),
|
|
4678
|
+
contextMenu: makeGridMenu(this._host),
|
|
4679
|
+
selection: "multi",
|
|
4680
|
+
// The grid marks what is new since the previous paint; the
|
|
4681
|
+
// stylesheet decides what a flash looks like.
|
|
4682
|
+
flashNewClass: "flash-new"
|
|
4683
|
+
});
|
|
4684
|
+
}
|
|
4685
|
+
// Prints arrive anonymous; executions arrive with ids. Stamp the former
|
|
4686
|
+
// once, on arrival, and key both without ever colliding.
|
|
4687
|
+
_stamp(trade) {
|
|
4688
|
+
const keyed = trade;
|
|
4689
|
+
if (keyed._k == null && keyed.id == null) keyed._k = ++this._seq;
|
|
4690
|
+
return trade;
|
|
4691
|
+
}
|
|
4692
|
+
static _key(trade) {
|
|
4693
|
+
const keyed = trade;
|
|
4694
|
+
return keyed.id != null ? String(keyed.id) : `p${keyed._k}`;
|
|
4695
|
+
}
|
|
4696
|
+
// The direction class sits on the row, so price and side inherit its
|
|
4697
|
+
// colour while time and qty state their own — plus the "unusually large"
|
|
4698
|
+
// mark, measured against the rolling average as of this paint.
|
|
4699
|
+
_rowClass(trade) {
|
|
4700
|
+
const classes = this._host.presentation.sideClass(trade.side).split(" ").filter(Boolean);
|
|
4701
|
+
if ((trade.quantity ?? 0) > this.avgQty * 2) classes.push("large-trade");
|
|
4702
|
+
return classes.join(" ");
|
|
4703
|
+
}
|
|
4704
|
+
// The tape's single column declaration, shared by both tabs. `time` sorts
|
|
4705
|
+
// by the wire timestamp and shows the clock; the symbol column exists
|
|
4706
|
+
// always and is hidden until a second instrument is in the feed.
|
|
4707
|
+
_columns() {
|
|
4708
|
+
const label = (key) => this._host.t(key);
|
|
4709
|
+
const presentation = this._host.presentation;
|
|
4710
|
+
return [
|
|
4711
|
+
{
|
|
4712
|
+
key: "time",
|
|
4713
|
+
header: label("Time"),
|
|
4714
|
+
exportable: true,
|
|
4715
|
+
value: (t) => String(t.time || t.executedAt || ""),
|
|
4716
|
+
render: (t) => formatTime(t.time || t.executedAt)
|
|
4717
|
+
},
|
|
4718
|
+
{
|
|
4719
|
+
key: "symbol",
|
|
4720
|
+
header: label("Sym"),
|
|
4721
|
+
exportable: true,
|
|
4722
|
+
value: (t) => t.symbol
|
|
4723
|
+
},
|
|
4724
|
+
{
|
|
4725
|
+
key: "price",
|
|
4726
|
+
header: label("Price"),
|
|
4727
|
+
exportable: true,
|
|
4728
|
+
value: (t) => t.price,
|
|
4729
|
+
render: (t) => formatPrice(t.price)
|
|
4730
|
+
},
|
|
4731
|
+
{
|
|
4732
|
+
key: "quantity",
|
|
4733
|
+
header: label("Qty"),
|
|
4734
|
+
exportable: true,
|
|
4735
|
+
value: (t) => t.quantity,
|
|
4736
|
+
render: (t) => formatQty(t.quantity)
|
|
4737
|
+
},
|
|
4738
|
+
{
|
|
4739
|
+
key: "side",
|
|
4740
|
+
header: label("Side"),
|
|
4741
|
+
exportable: true,
|
|
4742
|
+
value: (t) => t.side,
|
|
4743
|
+
render: (t) => presentation.sideText(t.side),
|
|
4744
|
+
exportValue: (t) => presentation.sideText(t.side)
|
|
4745
|
+
}
|
|
4746
|
+
];
|
|
4747
|
+
}
|
|
4748
|
+
// Both tapes show the symbol column exactly while a second instrument is
|
|
4749
|
+
// in the feed — through the grid's own column visibility, so the user's
|
|
4750
|
+
// hide/show from the menu still works the rest of the time.
|
|
4751
|
+
_syncSymbolColumn() {
|
|
4752
|
+
const multi = this._extraSymbols.size > 0;
|
|
4753
|
+
for (const grid of [this._marketGrid, this._myGrid]) {
|
|
4754
|
+
if (!grid) continue;
|
|
4755
|
+
if (multi) grid.showColumn("symbol");
|
|
4756
|
+
else grid.hideColumn("symbol");
|
|
4757
|
+
}
|
|
4758
|
+
}
|
|
4759
|
+
/// Pin an extra instrument to this feed: subscribe it, show it alongside
|
|
4760
|
+
/// the primary, and remember it in the instance's state.
|
|
4761
|
+
async addExtraSymbol(symbol) {
|
|
4762
|
+
if (!symbol) return;
|
|
4763
|
+
const sym = String(symbol).toUpperCase();
|
|
4764
|
+
if (sym === this._activeSymbol || this._extraSymbols.has(sym)) return;
|
|
4765
|
+
this._extraSymbols.add(sym);
|
|
4766
|
+
try {
|
|
4767
|
+
await this._host.trading.marketData.addSymbol(sym, MarketDataLevels.Tape);
|
|
4768
|
+
} catch {
|
|
4769
|
+
}
|
|
4770
|
+
this._renderExtras();
|
|
4771
|
+
this._renderBubbles();
|
|
4772
|
+
this._persistExtras();
|
|
4773
|
+
}
|
|
4774
|
+
/// Unpin one, dropping its subscription and the prints already on screen.
|
|
4775
|
+
async removeExtraSymbol(symbol) {
|
|
4776
|
+
if (!symbol) return;
|
|
4777
|
+
const sym = String(symbol).toUpperCase();
|
|
4778
|
+
if (!this._extraSymbols.has(sym)) return;
|
|
4779
|
+
this._extraSymbols.delete(sym);
|
|
4780
|
+
try {
|
|
4781
|
+
await this._host.trading.marketData.removeSymbol(sym);
|
|
4782
|
+
} catch {
|
|
4783
|
+
}
|
|
4784
|
+
this.trades = this.trades.filter((t) => t.symbol !== sym);
|
|
4785
|
+
this.bubbleTrades = this.bubbleTrades.filter((t) => t.symbol !== sym);
|
|
4786
|
+
this._marketGrid?.setRows([...this.trades]);
|
|
4787
|
+
this._renderExtras();
|
|
4788
|
+
this._renderBubbles();
|
|
4789
|
+
this._persistExtras();
|
|
4790
|
+
}
|
|
4791
|
+
getExtraSymbols() {
|
|
4792
|
+
return [...this._extraSymbols];
|
|
4793
|
+
}
|
|
4794
|
+
// Record what this instance pins, then ask for the layout to be flushed.
|
|
4795
|
+
// Two calls because they are two decisions: what the instance remembers,
|
|
4796
|
+
// and whether that is worth writing out now. Pinning is — losing the pin on
|
|
4797
|
+
// the next reload is exactly the complaint.
|
|
4798
|
+
_persistExtras() {
|
|
4799
|
+
this._host.persistState({ extras: [...this._extraSymbols] });
|
|
4800
|
+
this._host.saveLayout();
|
|
4801
|
+
}
|
|
4802
|
+
// Does this feed show `symbol` — as the page's active one, or pinned?
|
|
4803
|
+
_watches(symbol) {
|
|
4804
|
+
if (!symbol) return false;
|
|
4805
|
+
return symbol === this._activeSymbol || this._extraSymbols.has(symbol);
|
|
4806
|
+
}
|
|
4807
|
+
// ---------------------------------------------------------------- the list
|
|
4808
|
+
_renderMy() {
|
|
4809
|
+
this._myGrid?.setRows(this.myTrades.map((t) => this._stamp(t)));
|
|
4810
|
+
}
|
|
4811
|
+
_renderExtras() {
|
|
4812
|
+
this._syncSymbolColumn();
|
|
4813
|
+
if (!this.extrasEl) return;
|
|
4814
|
+
if (this._extraSymbols.size === 0) {
|
|
4815
|
+
this.extrasEl.replaceChildren();
|
|
4816
|
+
this.extrasEl.setAttribute("hidden", "");
|
|
4817
|
+
return;
|
|
4818
|
+
}
|
|
4819
|
+
this.extrasEl.removeAttribute("hidden");
|
|
4820
|
+
const remove = this._host.t("Remove");
|
|
4821
|
+
const chips = [...this._extraSymbols].map((symbol) => {
|
|
4822
|
+
const button = makeIconButton("tf-extra-rm", remove, "bi-x", { type: "button" });
|
|
4823
|
+
button.addEventListener("click", (e) => {
|
|
4824
|
+
e.preventDefault();
|
|
4825
|
+
e.stopPropagation();
|
|
4826
|
+
void this.removeExtraSymbol(symbol);
|
|
4827
|
+
});
|
|
4828
|
+
return makeElement("span", "tf-extra-chip", { role: "listitem" }, [
|
|
4829
|
+
makeElement("span", "tf-extra-sym", {}, [symbol]),
|
|
4830
|
+
button
|
|
4831
|
+
]);
|
|
4832
|
+
});
|
|
4833
|
+
this.extrasEl.replaceChildren(...chips);
|
|
4834
|
+
}
|
|
4835
|
+
// --------------------------------------------------------------- the views
|
|
4836
|
+
_bindTabs() {
|
|
4837
|
+
this._tabsEl?.addEventListener("click", (e) => {
|
|
4838
|
+
const button = e.target?.closest(".tf-tab");
|
|
4839
|
+
if (!button) return;
|
|
4840
|
+
this._selectTab(button.dataset.tab || "market");
|
|
4841
|
+
});
|
|
4842
|
+
}
|
|
4843
|
+
_selectTab(tab) {
|
|
4844
|
+
this.tab = tab === "my" ? "my" : "market";
|
|
4845
|
+
this._tabsEl?.querySelectorAll(".tf-tab").forEach((b) => {
|
|
4846
|
+
const selected = b.dataset.tab === this.tab;
|
|
4847
|
+
b.classList.toggle("active", selected);
|
|
4848
|
+
b.setAttribute("aria-selected", selected ? "true" : "false");
|
|
4849
|
+
});
|
|
4850
|
+
this.rootEl.classList.toggle("tf-tab-market", this.tab === "market");
|
|
4851
|
+
this.rootEl.classList.toggle("tf-tab-my", this.tab === "my");
|
|
4852
|
+
if (this.tab === "my") void this.loadMyTrades(this._host.trading.portfolioId(), this._activeSymbol);
|
|
4853
|
+
else this._renderBubbles();
|
|
4854
|
+
}
|
|
4855
|
+
_bindViewToggle() {
|
|
4856
|
+
this._viewToggleEl?.addEventListener("click", (e) => {
|
|
4857
|
+
const button = e.target?.closest(".tf-view-btn");
|
|
4858
|
+
if (!button) return;
|
|
4859
|
+
const next = button.dataset.view;
|
|
4860
|
+
if (next !== "list" && next !== "bubbles" || next === this.view) return;
|
|
4861
|
+
this.view = next;
|
|
4862
|
+
this._host.preferences.set(_TradeFeedWidget.VIEW_KEY, next);
|
|
4863
|
+
this._applyView();
|
|
4864
|
+
});
|
|
4865
|
+
}
|
|
4866
|
+
// Which rendering is showing is two class names on the root, not a display
|
|
4867
|
+
// written onto elements: a narrow viewport forces the list back on, and
|
|
4868
|
+
// that is a stylesheet's decision to make, not this control's.
|
|
4869
|
+
_applyView() {
|
|
4870
|
+
const list = this.view === "list";
|
|
4871
|
+
this.rootEl.classList.toggle("tf-view-list", list);
|
|
4872
|
+
this.rootEl.classList.toggle("tf-view-bubbles", !list);
|
|
4873
|
+
this._viewToggleEl?.querySelectorAll(".tf-view-btn").forEach((b) => {
|
|
4874
|
+
const selected = b.dataset.view === this.view;
|
|
4875
|
+
b.classList.toggle("active", selected);
|
|
4876
|
+
b.setAttribute("aria-pressed", selected ? "true" : "false");
|
|
4877
|
+
});
|
|
4878
|
+
if (!list) {
|
|
4879
|
+
this._sizeCanvas();
|
|
4880
|
+
this._renderBubbles();
|
|
4881
|
+
}
|
|
4882
|
+
}
|
|
4883
|
+
// -------------------------------------------------------------- the chart
|
|
4884
|
+
// The canvas in CSS pixels, and its backing store in device pixels. Without
|
|
4885
|
+
// the second the chart is drawn at a third of the resolution the screen has.
|
|
4886
|
+
_sizeCanvas() {
|
|
4887
|
+
const canvas = this.bubbleCanvas;
|
|
4888
|
+
if (!canvas) return;
|
|
4889
|
+
const rect = canvas.getBoundingClientRect();
|
|
4890
|
+
const ratio = typeof devicePixelRatio === "number" && devicePixelRatio > 0 ? devicePixelRatio : 1;
|
|
4891
|
+
const width = Math.max(1, Math.floor(rect.width));
|
|
4892
|
+
const height = Math.max(1, Math.floor(rect.height));
|
|
4893
|
+
if (canvas.width !== width * ratio || canvas.height !== height * ratio) {
|
|
4894
|
+
canvas.width = width * ratio;
|
|
4895
|
+
canvas.height = height * ratio;
|
|
4896
|
+
this._bubbleCtx?.setTransform(ratio, 0, 0, ratio, 0, 0);
|
|
4897
|
+
}
|
|
4898
|
+
this._canvasSize = { width, height };
|
|
4899
|
+
}
|
|
4900
|
+
// The prints as the chart reads them: direction decided once by the host,
|
|
4901
|
+
// and a position on the shared time axis. Newest first, as the tape holds
|
|
4902
|
+
// them.
|
|
4903
|
+
_feedTicks() {
|
|
4904
|
+
const presentation = this._host.presentation;
|
|
4905
|
+
return this.bubbleTrades.map((trade, index) => ({
|
|
4906
|
+
symbol: trade.symbol || "",
|
|
4907
|
+
side: trade.side,
|
|
4908
|
+
buy: presentation.isBuy(trade.side),
|
|
4909
|
+
price: Number(trade.price ?? 0),
|
|
4910
|
+
quantity: Number(trade.quantity ?? 0),
|
|
4911
|
+
time: trade.time || trade.executedAt || "",
|
|
4912
|
+
index
|
|
4913
|
+
}));
|
|
4914
|
+
}
|
|
4915
|
+
// One lane per watched symbol, active first. A feed with nothing pinned is
|
|
4916
|
+
// one lane over everything it holds, which is also the only case where the
|
|
4917
|
+
// lane needs no name.
|
|
4918
|
+
_lanes(ticks) {
|
|
4919
|
+
if (this._extraSymbols.size === 0)
|
|
4920
|
+
return [{ symbol: this._activeSymbol || "", ticks }];
|
|
4921
|
+
const symbols = this._activeSymbol ? [this._activeSymbol] : [];
|
|
4922
|
+
for (const symbol of this._extraSymbols) if (!symbols.includes(symbol)) symbols.push(symbol);
|
|
4923
|
+
return symbols.map((symbol) => ({ symbol, ticks: ticks.filter((t) => t.symbol === symbol) }));
|
|
4924
|
+
}
|
|
4925
|
+
_renderBubbles() {
|
|
4926
|
+
const ctx = this._bubbleCtx;
|
|
4927
|
+
if (!ctx || this.view !== "bubbles") return;
|
|
4928
|
+
if (!this._canvasSize) this._sizeCanvas();
|
|
4929
|
+
const size = this._canvasSize;
|
|
4930
|
+
if (!size) return;
|
|
4931
|
+
ctx.clearRect(0, 0, size.width, size.height);
|
|
4932
|
+
this._bubbleHits = [];
|
|
4933
|
+
const ticks = this._feedTicks();
|
|
4934
|
+
if (ticks.length === 0) return;
|
|
4935
|
+
const layout = layoutBubbles({ width: size.width, height: size.height, total: ticks.length, lanes: this._lanes(ticks) });
|
|
4936
|
+
const palette = this._host.presentation.canvasPalette();
|
|
4937
|
+
ctx.font = palette.font;
|
|
4938
|
+
ctx.lineWidth = 1;
|
|
4939
|
+
for (const lane of layout.lanes) {
|
|
4940
|
+
if (lane.separatorY !== null) {
|
|
4941
|
+
ctx.globalAlpha = SEPARATOR_ALPHA;
|
|
4942
|
+
ctx.strokeStyle = palette.grid;
|
|
4943
|
+
_TradeFeedWidget._rule(ctx, 0, size.width, lane.separatorY);
|
|
4944
|
+
}
|
|
4945
|
+
if (lane.label) {
|
|
4946
|
+
ctx.globalAlpha = LABEL_ALPHA;
|
|
4947
|
+
ctx.fillStyle = palette.grid;
|
|
4948
|
+
ctx.textBaseline = "top";
|
|
4949
|
+
ctx.textAlign = "left";
|
|
4950
|
+
ctx.fillText(lane.symbol, lane.label.x, lane.label.y);
|
|
4951
|
+
}
|
|
4952
|
+
for (const tick of lane.ticks) {
|
|
4953
|
+
ctx.globalAlpha = RULE_ALPHA;
|
|
4954
|
+
ctx.strokeStyle = palette.grid;
|
|
4955
|
+
_TradeFeedWidget._rule(ctx, 0, layout.axisX - 2, tick.y);
|
|
4956
|
+
ctx.globalAlpha = LABEL_ALPHA;
|
|
4957
|
+
ctx.fillStyle = palette.grid;
|
|
4958
|
+
ctx.textBaseline = "middle";
|
|
4959
|
+
ctx.textAlign = "left";
|
|
4960
|
+
ctx.fillText(tick.text, layout.labelX, tick.y);
|
|
4961
|
+
}
|
|
4962
|
+
}
|
|
4963
|
+
for (const shape of layout.bubbles) {
|
|
4964
|
+
const colour = shape.bubble.buy ? palette.up : palette.down;
|
|
4965
|
+
ctx.fillStyle = colour;
|
|
4966
|
+
ctx.strokeStyle = colour;
|
|
4967
|
+
ctx.beginPath();
|
|
4968
|
+
ctx.arc(shape.x, shape.y, shape.radius, 0, Math.PI * 2);
|
|
4969
|
+
ctx.globalAlpha = FILL_ALPHA;
|
|
4970
|
+
ctx.fill();
|
|
4971
|
+
ctx.globalAlpha = STROKE_ALPHA;
|
|
4972
|
+
ctx.stroke();
|
|
4973
|
+
this._bubbleHits.push(shape);
|
|
4974
|
+
}
|
|
4975
|
+
ctx.globalAlpha = 1;
|
|
4976
|
+
}
|
|
4977
|
+
static _rule(ctx, from, to, y) {
|
|
4978
|
+
ctx.beginPath();
|
|
4979
|
+
ctx.moveTo(from, y);
|
|
4980
|
+
ctx.lineTo(to, y);
|
|
4981
|
+
ctx.stroke();
|
|
4982
|
+
}
|
|
4983
|
+
_bindBubbleHover() {
|
|
4984
|
+
const canvas = this.bubbleCanvas;
|
|
4985
|
+
if (!canvas) return;
|
|
4986
|
+
canvas.addEventListener("mousemove", (e) => {
|
|
4987
|
+
if (this.view !== "bubbles") {
|
|
4988
|
+
this._hideTooltip();
|
|
4989
|
+
return;
|
|
4990
|
+
}
|
|
4991
|
+
const rect = canvas.getBoundingClientRect();
|
|
4992
|
+
const x = e.clientX - rect.left;
|
|
4993
|
+
const y = e.clientY - rect.top;
|
|
4994
|
+
const hit = this._hitTest(x, y);
|
|
4995
|
+
if (hit) this._showTooltip(hit.bubble, x, y);
|
|
4996
|
+
else this._hideTooltip();
|
|
4997
|
+
});
|
|
4998
|
+
canvas.addEventListener("mouseleave", () => this._hideTooltip());
|
|
4999
|
+
}
|
|
5000
|
+
// Backwards, so the circle drawn last — the newest print, on top — is the
|
|
5001
|
+
// one a cursor over both picks.
|
|
5002
|
+
_hitTest(x, y) {
|
|
5003
|
+
for (let i = this._bubbleHits.length - 1; i >= 0; i--) {
|
|
5004
|
+
const shape = this._bubbleHits[i];
|
|
5005
|
+
const dx = x - shape.x;
|
|
5006
|
+
const dy = y - shape.y;
|
|
5007
|
+
const reach = Math.max(shape.radius, HOVER_SLACK);
|
|
5008
|
+
if (dx * dx + dy * dy <= reach * reach) return shape;
|
|
5009
|
+
}
|
|
5010
|
+
return null;
|
|
5011
|
+
}
|
|
5012
|
+
_showTooltip(bubble, x, y) {
|
|
5013
|
+
const tooltip = this._tooltipEl;
|
|
5014
|
+
const canvas = this.bubbleCanvas;
|
|
5015
|
+
if (!tooltip || !canvas) return;
|
|
5016
|
+
const presentation = this._host.presentation;
|
|
5017
|
+
const aggregated = bubble.count > 1;
|
|
5018
|
+
const side = bubble.side;
|
|
5019
|
+
const lines = [];
|
|
5020
|
+
if (this._extraSymbols.size > 0) lines.push(this._tooltipLine(this._host.t("Symbol"), bubble.symbol, ""));
|
|
5021
|
+
lines.push(this._tooltipLine(this._host.t("Time"), formatTime(bubble.time), ""));
|
|
5022
|
+
lines.push(this._tooltipLine(aggregated ? this._host.t("VWAP") : this._host.t("Price"), formatPrice(bubble.price), ""));
|
|
5023
|
+
lines.push(this._tooltipLine(aggregated ? this._host.t("Total qty") : this._host.t("Qty"), formatQty(bubble.quantity), ""));
|
|
5024
|
+
lines.push(this._tooltipLine(this._host.t("Side"), presentation.sideText(side), presentation.sideClass(side)));
|
|
5025
|
+
if (aggregated) lines.push(this._tooltipLine(this._host.t("Trades"), String(bubble.count), ""));
|
|
5026
|
+
tooltip.replaceChildren(...lines);
|
|
5027
|
+
tooltip.removeAttribute("hidden");
|
|
5028
|
+
const canvasRect = canvas.getBoundingClientRect();
|
|
5029
|
+
const parentRect = tooltip.offsetParent?.getBoundingClientRect() ?? canvasRect;
|
|
5030
|
+
const originX = canvasRect.left - parentRect.left;
|
|
5031
|
+
const originY = canvasRect.top - parentRect.top;
|
|
5032
|
+
const maxX = originX + canvasRect.width - tooltip.offsetWidth - TOOLTIP_MARGIN;
|
|
5033
|
+
const maxY = originY + canvasRect.height - tooltip.offsetHeight - TOOLTIP_MARGIN;
|
|
5034
|
+
tooltip.style.left = `${Math.max(0, Math.min(originX + x + TOOLTIP_OFFSET, maxX))}px`;
|
|
5035
|
+
tooltip.style.top = `${Math.max(0, Math.min(originY + y + TOOLTIP_OFFSET, maxY))}px`;
|
|
5036
|
+
}
|
|
5037
|
+
_tooltipLine(label, value, valueClass) {
|
|
5038
|
+
const row = makeElement("div", "tf-bbtt-row", {}, [
|
|
5039
|
+
makeElement("span", "tf-bbtt-label", {}, [label]),
|
|
5040
|
+
makeElement("span", "tf-bbtt-value", {}, [value])
|
|
5041
|
+
]);
|
|
5042
|
+
for (const name of valueClass.split(" ")) if (name) row.childNodes[1].classList.add(name);
|
|
5043
|
+
return row;
|
|
5044
|
+
}
|
|
5045
|
+
_hideTooltip() {
|
|
5046
|
+
this._tooltipEl?.setAttribute("hidden", "");
|
|
5047
|
+
}
|
|
5048
|
+
};
|
|
5049
|
+
_TradeFeedWidget.TYPE = ControlTypes.TradeFeed;
|
|
5050
|
+
/// Shared across every trade feed on the page: which of the two renderings
|
|
5051
|
+
/// the user last chose. The value is what a user's stored settings already
|
|
5052
|
+
/// hold, so it is spelled exactly as it always was.
|
|
5053
|
+
_TradeFeedWidget.VIEW_KEY = "terminal.tradeFeedView";
|
|
5054
|
+
/// Rows kept in the list, and prints kept for the chart. The chart holds
|
|
5055
|
+
/// far more because it compacts them: a bubble stands for a slice.
|
|
5056
|
+
_TradeFeedWidget.MAX_ROWS = 50;
|
|
5057
|
+
_TradeFeedWidget.MAX_BUBBLES = 500;
|
|
5058
|
+
var TradeFeedWidget = _TradeFeedWidget;
|
|
1738
5059
|
return __toCommonJS(index_exports);
|
|
1739
5060
|
})();
|
|
1740
5061
|
//# sourceMappingURL=sstradingcontrols.js.map
|