@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.
Files changed (77) hide show
  1. package/README.md +92 -6
  2. package/dist/esm/active-orders-widget.js +9 -0
  3. package/dist/esm/active-orders-widget.js.map +1 -1
  4. package/dist/esm/control-types.js +3 -0
  5. package/dist/esm/control-types.js.map +1 -1
  6. package/dist/esm/grid-menu.js +54 -0
  7. package/dist/esm/grid-menu.js.map +1 -0
  8. package/dist/esm/index.js +8 -0
  9. package/dist/esm/index.js.map +1 -1
  10. package/dist/esm/order-entry-widget.js +654 -0
  11. package/dist/esm/order-entry-widget.js.map +1 -0
  12. package/dist/esm/orderbook-depth.js +51 -0
  13. package/dist/esm/orderbook-depth.js.map +1 -0
  14. package/dist/esm/orderbook-widget.js +792 -0
  15. package/dist/esm/orderbook-widget.js.map +1 -0
  16. package/dist/esm/positions-widget.js +9 -0
  17. package/dist/esm/positions-widget.js.map +1 -1
  18. package/dist/esm/trade-history-widget.js +8 -0
  19. package/dist/esm/trade-history-widget.js.map +1 -1
  20. package/dist/esm/tradefeed-aggregator.js +58 -0
  21. package/dist/esm/tradefeed-aggregator.js.map +1 -0
  22. package/dist/esm/tradefeed-bubbles.js +131 -0
  23. package/dist/esm/tradefeed-bubbles.js.map +1 -0
  24. package/dist/esm/tradefeed-widget.js +692 -0
  25. package/dist/esm/tradefeed-widget.js.map +1 -0
  26. package/dist/esm/trading-host.js +2 -0
  27. package/dist/esm/trading-host.js.map +1 -1
  28. package/dist/esm/watchlist-widget.js +2 -0
  29. package/dist/esm/watchlist-widget.js.map +1 -1
  30. package/dist/sstradingcontrols.js +3346 -25
  31. package/dist/sstradingcontrols.js.map +3 -3
  32. package/dist/types/active-orders-widget.d.ts.map +1 -1
  33. package/dist/types/control-types.d.ts +3 -0
  34. package/dist/types/control-types.d.ts.map +1 -1
  35. package/dist/types/grid-menu.d.ts +4 -0
  36. package/dist/types/grid-menu.d.ts.map +1 -0
  37. package/dist/types/index.d.ts +13 -0
  38. package/dist/types/index.d.ts.map +1 -1
  39. package/dist/types/order-entry-widget.d.ts +86 -0
  40. package/dist/types/order-entry-widget.d.ts.map +1 -0
  41. package/dist/types/orderbook-depth.d.ts +19 -0
  42. package/dist/types/orderbook-depth.d.ts.map +1 -0
  43. package/dist/types/orderbook-widget.d.ts +105 -0
  44. package/dist/types/orderbook-widget.d.ts.map +1 -0
  45. package/dist/types/positions-widget.d.ts.map +1 -1
  46. package/dist/types/trade-history-widget.d.ts.map +1 -1
  47. package/dist/types/tradefeed-aggregator.d.ts +15 -0
  48. package/dist/types/tradefeed-aggregator.d.ts.map +1 -0
  49. package/dist/types/tradefeed-bubbles.d.ts +39 -0
  50. package/dist/types/tradefeed-bubbles.d.ts.map +1 -0
  51. package/dist/types/tradefeed-widget.d.ts +78 -0
  52. package/dist/types/tradefeed-widget.d.ts.map +1 -0
  53. package/dist/types/trading-data.d.ts +22 -0
  54. package/dist/types/trading-data.d.ts.map +1 -1
  55. package/dist/types/trading-host.d.ts +8 -0
  56. package/dist/types/trading-host.d.ts.map +1 -1
  57. package/dist/types/watchlist-widget.d.ts.map +1 -1
  58. package/package.json +22 -3
  59. package/screenshots/panels.jpg +0 -0
  60. package/src/active-orders-widget.ts +9 -0
  61. package/src/control-types.ts +3 -0
  62. package/src/grid-menu.ts +67 -0
  63. package/src/index.ts +31 -0
  64. package/src/order-entry-widget.ts +733 -0
  65. package/src/orderbook-depth.ts +99 -0
  66. package/src/orderbook-widget.ts +896 -0
  67. package/src/positions-widget.ts +9 -0
  68. package/src/trade-history-widget.ts +8 -0
  69. package/src/tradefeed-aggregator.ts +95 -0
  70. package/src/tradefeed-bubbles.ts +194 -0
  71. package/src/tradefeed-widget.ts +744 -0
  72. package/src/trading-data.ts +168 -121
  73. package/src/trading-host.ts +326 -300
  74. package/src/watchlist-widget.ts +2 -0
  75. package/styles/theme.css +23 -0
  76. package/styles/trading-controls.css +911 -0
  77. 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 _TableSort {
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 _TableSort._compare(av, bv) * factor;
318
+ return this._compare(av, bv) * factor;
252
319
  });
253
320
  }
254
- static _compare(a, b) {
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).localeCompare(String(b));
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 DataGrid = class _DataGrid {
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._rows);
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 painted = this.options.renderLimit != null ? sorted.slice(0, this.options.renderLimit) : sorted;
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
- if (painted.length === 0 && pinned.length === 0) children.push(this._emptyRow());
455
- for (const row of painted) children.push(this._bodyRow(row));
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.options.columns.filter((col) => col.exportable);
1522
+ const columns = this.visibleColumns().filter((col) => col.exportable);
482
1523
  return {
483
1524
  headers: columns.map((col) => col.header),
484
- rows: this.sortedRows().map((row) => columns.map((col) => {
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.options.columns) {
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.options.head.replaceChildren(tr);
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.options.columns) {
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 (let i = 0; i < this.options.columns.length; i++) {
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 = pinned.cells[i];
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.options.columns.length;
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