@stocksharp/trading-controls 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +113 -5
  2. package/dist/esm/chart-engine.js +15 -0
  3. package/dist/esm/chart-engine.js.map +1 -0
  4. package/dist/esm/control-types.js +4 -0
  5. package/dist/esm/control-types.js.map +1 -1
  6. package/dist/esm/equity-widget.js +261 -0
  7. package/dist/esm/equity-widget.js.map +1 -0
  8. package/dist/esm/heatmap-grid.js +271 -0
  9. package/dist/esm/heatmap-grid.js.map +1 -0
  10. package/dist/esm/index.js +19 -0
  11. package/dist/esm/index.js.map +1 -1
  12. package/dist/esm/optimization-heatmap-widget.js +303 -0
  13. package/dist/esm/optimization-heatmap-widget.js.map +1 -0
  14. package/dist/esm/option-smile-widget.js +252 -0
  15. package/dist/esm/option-smile-widget.js.map +1 -0
  16. package/dist/esm/pnl-curve.js +5 -1
  17. package/dist/esm/pnl-curve.js.map +1 -1
  18. package/dist/esm/surface-grid.js +282 -0
  19. package/dist/esm/surface-grid.js.map +1 -0
  20. package/dist/esm/surface-widget.js +379 -0
  21. package/dist/esm/surface-widget.js.map +1 -0
  22. package/dist/sstradingcontrols.js +1595 -168
  23. package/dist/sstradingcontrols.js.map +4 -4
  24. package/dist/types/chart-engine.d.ts +3 -0
  25. package/dist/types/chart-engine.d.ts.map +1 -0
  26. package/dist/types/control-types.d.ts +4 -0
  27. package/dist/types/control-types.d.ts.map +1 -1
  28. package/dist/types/equity-widget.d.ts +34 -0
  29. package/dist/types/equity-widget.d.ts.map +1 -0
  30. package/dist/types/heatmap-grid.d.ts +88 -0
  31. package/dist/types/heatmap-grid.d.ts.map +1 -0
  32. package/dist/types/index.d.ts +15 -0
  33. package/dist/types/index.d.ts.map +1 -1
  34. package/dist/types/optimization-heatmap-widget.d.ts +45 -0
  35. package/dist/types/optimization-heatmap-widget.d.ts.map +1 -0
  36. package/dist/types/option-smile-widget.d.ts +39 -0
  37. package/dist/types/option-smile-widget.d.ts.map +1 -0
  38. package/dist/types/pnl-curve.d.ts +3 -0
  39. package/dist/types/pnl-curve.d.ts.map +1 -1
  40. package/dist/types/surface-grid.d.ts +64 -0
  41. package/dist/types/surface-grid.d.ts.map +1 -0
  42. package/dist/types/surface-widget.d.ts +64 -0
  43. package/dist/types/surface-widget.d.ts.map +1 -0
  44. package/package.json +28 -2
  45. package/screenshots/equity.png +0 -0
  46. package/screenshots/optimization-surface.png +0 -0
  47. package/screenshots/optimization.png +0 -0
  48. package/screenshots/option-smile.png +0 -0
  49. package/screenshots/panels.jpg +0 -0
  50. package/src/chart-engine.global.ts +64 -0
  51. package/src/chart-engine.ts +29 -0
  52. package/src/control-types.ts +4 -0
  53. package/src/equity-widget.ts +289 -0
  54. package/src/heatmap-grid.ts +405 -0
  55. package/src/index.ts +46 -0
  56. package/src/optimization-heatmap-widget.ts +347 -0
  57. package/src/option-smile-widget.ts +281 -0
  58. package/src/pnl-curve.ts +13 -1
  59. package/src/surface-grid.ts +410 -0
  60. package/src/surface-widget.ts +457 -0
  61. package/styles/trading-controls.css +281 -0
  62. package/translation-keys.json +14 -1
@@ -24,11 +24,21 @@ var SSTradingControls = (() => {
24
24
  var index_exports = {};
25
25
  __export(index_exports, {
26
26
  ActiveOrdersWidget: () => ActiveOrdersWidget,
27
+ AreaSeries: () => AreaSeries,
27
28
  ControlTypes: () => ControlTypes,
29
+ CrosshairMode: () => CrosshairMode,
30
+ DEFAULT_VIEW: () => DEFAULT_VIEW,
31
+ EquityWidget: () => EquityWidget,
32
+ HeatDirections: () => HeatDirections,
33
+ LineSeries: () => LineSeries,
28
34
  LogLevels: () => LogLevels,
29
35
  LogMonitorWidget: () => LogMonitorWidget,
36
+ MAX_PITCH: () => MAX_PITCH,
37
+ MIN_PITCH: () => MIN_PITCH,
30
38
  MarketDataLevels: () => MarketDataLevels,
39
+ OptimizationHeatmapWidget: () => OptimizationHeatmapWidget,
31
40
  OptionDeskWidget: () => OptionDeskWidget,
41
+ OptionSmileWidget: () => OptionSmileWidget,
32
42
  OptionTypes: () => OptionTypes,
33
43
  OrderBookWidget: () => OrderBookWidget,
34
44
  OrderEntrySides: () => OrderEntrySides,
@@ -40,16 +50,20 @@ var SSTradingControls = (() => {
40
50
  StatisticsWidget: () => StatisticsWidget,
41
51
  StrategiesWidget: () => StrategiesWidget,
42
52
  StrategyStates: () => StrategyStates,
53
+ SurfaceWidget: () => SurfaceWidget,
43
54
  TradeFeedWidget: () => TradeFeedWidget,
44
55
  TradeHistoryWidget: () => TradeHistoryWidget,
45
56
  WatchlistWidget: () => WatchlistWidget,
46
57
  aggregateBubbles: () => aggregateBubbles,
47
58
  assertHost: () => assertHost,
48
59
  buildLogTree: () => buildLogTree,
60
+ clampView: () => clampView,
49
61
  cleanRejectReason: () => cleanRejectReason,
50
62
  compressPnl: () => compressPnl,
63
+ createChart: () => createChart,
51
64
  d1: () => d1,
52
65
  d2: () => d2,
66
+ dragView: () => dragView,
53
67
  drawPnlCurve: () => drawPnlCurve,
54
68
  formatPnl: () => formatPnl,
55
69
  formatPrice: () => formatPrice,
@@ -59,9 +73,12 @@ var SSTradingControls = (() => {
59
73
  greekPlaces: () => greekPlaces,
60
74
  greekScales: () => greekScales,
61
75
  greeks: () => greeks,
76
+ heatScale: () => heatScale,
77
+ hitHeatmap: () => hitHeatmap,
62
78
  impliedVolatility: () => impliedVolatility,
63
79
  keepLog: () => keepLog,
64
80
  layoutBubbles: () => layoutBubbles,
81
+ layoutHeatmap: () => layoutHeatmap,
65
82
  makeElement: () => makeElement,
66
83
  makeGridMenu: () => makeGridMenu,
67
84
  makeIcon: () => makeIcon,
@@ -72,9 +89,17 @@ var SSTradingControls = (() => {
72
89
  normalPdf: () => normalPdf,
73
90
  pnlCurve: () => pnlCurve,
74
91
  premium: () => premium,
92
+ project: () => project,
75
93
  scaleChain: () => scaleChain,
76
94
  sideGreeks: () => sideGreeks,
77
- subtreeOf: () => subtreeOf
95
+ sideVolatility: () => sideVolatility,
96
+ sortedChain: () => sortedChain,
97
+ subtreeOf: () => subtreeOf,
98
+ surfaceLayout: () => surfaceLayout,
99
+ tintOf: () => tintOf,
100
+ toSmileSeries: () => toSmileSeries,
101
+ valueAt: () => valueAt,
102
+ zoomView: () => zoomView
78
103
  });
79
104
 
80
105
  // src/trading-host.ts
@@ -150,7 +175,11 @@ var SSTradingControls = (() => {
150
175
  Statistics: "statistics",
151
176
  LogMonitor: "logMonitor",
152
177
  Strategies: "strategies",
153
- OptionDesk: "optionDesk"
178
+ OptionDesk: "optionDesk",
179
+ OptionSmile: "optionSmile",
180
+ Equity: "equity",
181
+ OptimizationHeatmap: "optimizationHeatmap",
182
+ OptimizationSurface: "optimizationSurface"
154
183
  };
155
184
 
156
185
  // src/formatters.ts
@@ -4438,6 +4467,7 @@ var SSTradingControls = (() => {
4438
4467
  const y = (value) => valueSpan === 0 ? top : bottom - (value - min) / valueSpan * (bottom - top);
4439
4468
  const curve = shown.map((p) => [x(p.time), y(p.value)]);
4440
4469
  const zeroY = y(0);
4470
+ const last = shown[shown.length - 1].value;
4441
4471
  return {
4442
4472
  points: curve,
4443
4473
  // Closed along the baseline rather than along the foot of the box: a run entirely above
@@ -4446,7 +4476,10 @@ var SSTradingControls = (() => {
4446
4476
  zeroY,
4447
4477
  min,
4448
4478
  max,
4449
- positive: shown[shown.length - 1].value >= 0
4479
+ from: firstTime,
4480
+ to: lastTime,
4481
+ last,
4482
+ positive: last >= 0
4450
4483
  };
4451
4484
  }
4452
4485
  function drawPnlCurve(ctx, curve, box, style) {
@@ -4476,6 +4509,1341 @@ var SSTradingControls = (() => {
4476
4509
  ctx.stroke();
4477
4510
  }
4478
4511
 
4512
+ // src/chart-engine.global.ts
4513
+ function engine() {
4514
+ const found = globalThis.SSChart;
4515
+ if (found === void 0 || found === null) {
4516
+ throw new Error(
4517
+ "The chart panels need @stocksharp/chart. Load its bundle (sschart.js) before sstradingcontrols.js, or import this package from npm rather than as a script."
4518
+ );
4519
+ }
4520
+ return found;
4521
+ }
4522
+ var createChart = (container, options) => engine().createChart(container, options);
4523
+ var AreaSeries = new Proxy({}, {
4524
+ get: (_target, key) => engine().AreaSeries[key]
4525
+ });
4526
+ var LineSeries = new Proxy({}, {
4527
+ get: (_target, key) => engine().LineSeries[key]
4528
+ });
4529
+ var CrosshairMode = new Proxy({}, {
4530
+ get: (_target, key) => engine().CrosshairMode[key]
4531
+ });
4532
+
4533
+ // src/equity-widget.ts
4534
+ var FILL_TOP_ALPHA = 0.28;
4535
+ var FILL_BOTTOM_ALPHA = 0.02;
4536
+ var LINE_WIDTH = 2;
4537
+ function toSeries(points) {
4538
+ const seen = /* @__PURE__ */ new Set();
4539
+ const out = [];
4540
+ for (const point of [...points].sort((a, b) => a.time - b.time)) {
4541
+ if (!Number.isFinite(point.time) || !Number.isFinite(point.value)) continue;
4542
+ const second = Math.floor(point.time / 1e3);
4543
+ if (seen.has(second)) out[out.length - 1] = { time: second, value: point.value };
4544
+ else out.push({ time: second, value: point.value });
4545
+ seen.add(second);
4546
+ }
4547
+ return out;
4548
+ }
4549
+ var _EquityWidget = class _EquityWidget {
4550
+ static create(hostEl, state, deps) {
4551
+ const host = assertHost(deps?.host, "EquityWidget");
4552
+ const root = _EquityWidget._buildRoot(host);
4553
+ root.id = makePanelId(_EquityWidget.TYPE);
4554
+ hostEl.appendChild(root);
4555
+ return new _EquityWidget(root, state || {}, deps);
4556
+ }
4557
+ static _buildRoot(host) {
4558
+ const title = host.t("Equity");
4559
+ return makePanelRoot("equity-panel", title, [
4560
+ makeElement("div", "panel-header", {}, [
4561
+ makeElement("span", "", {}, [title]),
4562
+ makeElement("span", "equity-last", {}, []),
4563
+ makeIconButton("bt-icon-btn equity-reset-btn", host.t("ResetView"), "bi-arrow-clockwise", { type: "button" }),
4564
+ makeIconButton("bt-icon-btn bt-icon-cancel panel-close-btn", host.t("ClosePanel"), "bi-x", { type: "button" })
4565
+ ]),
4566
+ makeElement("div", "panel-body", {}, [
4567
+ makeElement("div", "equity-chart", { role: "img", "aria-label": host.t("PnLChart") }, [
4568
+ // Over the curve rather than in the panel header, for the same reason the
4569
+ // smile's is: a reading about a moment belongs beside that moment, and a host
4570
+ // that lifts panel headers into a tab strip would put it in a tab title.
4571
+ makeElement("div", "equity-hover", {}, []),
4572
+ makeElement("div", "equity-empty", {}, [host.t("NoEquity")])
4573
+ ])
4574
+ ])
4575
+ ]);
4576
+ }
4577
+ constructor(rootEl, _state, deps) {
4578
+ this._host = assertHost(deps?.host, "EquityWidget");
4579
+ this.rootEl = rootEl;
4580
+ this._closeBtn = this.rootEl.querySelector(".panel-close-btn");
4581
+ this._resetBtn = this.rootEl.querySelector(".equity-reset-btn");
4582
+ this._lastEl = this.rootEl.querySelector(".equity-last");
4583
+ this._hoverEl = this.rootEl.querySelector(".equity-hover");
4584
+ this._emptyEl = this.rootEl.querySelector(".equity-empty");
4585
+ this._chartEl = this.rootEl.querySelector(".equity-chart");
4586
+ this._chart = null;
4587
+ this._series = null;
4588
+ this._points = [];
4589
+ this._resizeObserver = null;
4590
+ this._closeBtn?.addEventListener("click", (e) => {
4591
+ e.preventDefault();
4592
+ this._host.close();
4593
+ });
4594
+ this._resetBtn?.addEventListener("click", (e) => {
4595
+ e.preventDefault();
4596
+ this.resetZoom();
4597
+ });
4598
+ if (this._chartEl !== null && typeof ResizeObserver !== "undefined") {
4599
+ this._resizeObserver = new ResizeObserver(() => this._fit());
4600
+ this._resizeObserver.observe(this._chartEl);
4601
+ }
4602
+ this._render();
4603
+ this._host.register(this);
4604
+ }
4605
+ dispose() {
4606
+ try {
4607
+ this._resizeObserver?.disconnect();
4608
+ } catch {
4609
+ }
4610
+ this._resizeObserver = null;
4611
+ try {
4612
+ this._chart?.remove();
4613
+ } catch {
4614
+ }
4615
+ this._chart = null;
4616
+ this._series = null;
4617
+ this._host.unregister(this);
4618
+ try {
4619
+ this.rootEl.remove();
4620
+ } catch {
4621
+ }
4622
+ }
4623
+ /// Show this run. The whole curve at once: it is a cumulative figure, so a set of points that
4624
+ /// no longer contains an earlier sample is a different run rather than a longer one.
4625
+ ///
4626
+ /// `time` is unix milliseconds.
4627
+ update(points) {
4628
+ this._points = points || [];
4629
+ this._render();
4630
+ }
4631
+ /// Show the whole run again, after a zoom.
4632
+ resetZoom() {
4633
+ try {
4634
+ this._chart?.timeScale().fitContent();
4635
+ } catch {
4636
+ }
4637
+ }
4638
+ /// The engine, for a host that wants to add to this chart - a benchmark line, a marker at a
4639
+ /// drawdown. Null until there is something to draw.
4640
+ chart() {
4641
+ return this._chart;
4642
+ }
4643
+ // Built on first use rather than in the constructor: the engine measures its container, and
4644
+ // a panel that has not been laid out yet has none.
4645
+ _ensureChart() {
4646
+ if (this._chart !== null) return true;
4647
+ if (this._chartEl === null) return false;
4648
+ const palette = this._host.presentation.canvasPalette();
4649
+ this._chart = createChart(this._chartEl, {
4650
+ layout: {
4651
+ background: { type: "solid", color: "transparent" },
4652
+ textColor: palette.grid,
4653
+ fontFamily: palette.font,
4654
+ fontSize: 11,
4655
+ attributionLogo: false
4656
+ },
4657
+ grid: { vertLines: { color: palette.grid }, horzLines: { color: palette.grid } },
4658
+ crosshair: { mode: CrosshairMode.Normal },
4659
+ rightPriceScale: { borderColor: palette.grid },
4660
+ timeScale: {
4661
+ borderColor: palette.grid,
4662
+ timeVisible: true,
4663
+ secondsVisible: true,
4664
+ // The engine dates an axis in UTC unless told otherwise, and everything else on
4665
+ // the page - the readout beside this one included - reads in the browser's zone.
4666
+ // Two labels for one instant, three hours apart, is worse than either.
4667
+ timeZone: browserTimeZone()
4668
+ }
4669
+ });
4670
+ this._series = this._chart.addSeries(AreaSeries, {
4671
+ lineWidth: LINE_WIDTH,
4672
+ lineColor: palette.up,
4673
+ topColor: withAlpha(palette.up, FILL_TOP_ALPHA),
4674
+ bottomColor: withAlpha(palette.up, FILL_BOTTOM_ALPHA)
4675
+ });
4676
+ this._chart.subscribeCrosshairMove((param) => this._renderHover(param));
4677
+ return true;
4678
+ }
4679
+ _render() {
4680
+ const series = toSeries(this._points);
4681
+ const empty = series.length < 2;
4682
+ if (this._emptyEl !== null) this._emptyEl.hidden = !empty;
4683
+ this._renderLast(empty ? null : series[series.length - 1].value);
4684
+ if (empty) return;
4685
+ if (!this._ensureChart() || this._series === null) return;
4686
+ const palette = this._host.presentation.canvasPalette();
4687
+ const last = series[series.length - 1].value;
4688
+ const colour = last >= 0 ? palette.up : palette.down;
4689
+ this._series.applyOptions({
4690
+ lineColor: colour,
4691
+ topColor: withAlpha(colour, FILL_TOP_ALPHA),
4692
+ bottomColor: withAlpha(colour, FILL_BOTTOM_ALPHA)
4693
+ });
4694
+ this._series.setData(series);
4695
+ this._fit();
4696
+ }
4697
+ _fit() {
4698
+ if (this._chart === null || this._chartEl === null) return;
4699
+ const rect = this._chartEl.getBoundingClientRect();
4700
+ if (rect.width <= 0 || rect.height <= 0) return;
4701
+ try {
4702
+ this._chart.resize(rect.width, rect.height);
4703
+ } catch {
4704
+ }
4705
+ }
4706
+ // Where the run stands, in the header.
4707
+ _renderLast(value) {
4708
+ const el = this._lastEl;
4709
+ if (el === null) return;
4710
+ el.textContent = value === null ? "" : formatPnl(value);
4711
+ el.className = value === null ? "equity-last" : `equity-last ${this._host.presentation.pnlClass(value)}`;
4712
+ }
4713
+ // What the pointer is over. The engine hands over the moment and the value at it; the moment
4714
+ // is worded by the host, because what a time reads as is the page's business and not this
4715
+ // package's.
4716
+ _renderHover(param) {
4717
+ const el = this._hoverEl;
4718
+ if (el === null) return;
4719
+ const point = this._series === null ? void 0 : param.seriesData.get(this._series);
4720
+ const value = point?.value;
4721
+ if (param.time === null || value === void 0 || !Number.isFinite(value)) {
4722
+ el.textContent = "";
4723
+ el.className = "equity-hover";
4724
+ return;
4725
+ }
4726
+ const moment = new Date(Number(param.time) * 1e3);
4727
+ el.textContent = `${this._host.presentation.timeText(moment)} ${formatPnl(value)}`;
4728
+ el.className = `equity-hover ${this._host.presentation.pnlClass(value)}`;
4729
+ }
4730
+ };
4731
+ _EquityWidget.TYPE = ControlTypes.Equity;
4732
+ var EquityWidget = _EquityWidget;
4733
+ function browserTimeZone() {
4734
+ try {
4735
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
4736
+ } catch {
4737
+ return "UTC";
4738
+ }
4739
+ }
4740
+ function withAlpha(colour, alpha) {
4741
+ return `color-mix(in srgb, ${colour} ${Math.round(alpha * 100)}%, transparent)`;
4742
+ }
4743
+
4744
+ // src/statistics-widget.ts
4745
+ function rank(rows) {
4746
+ const first = /* @__PURE__ */ new Map();
4747
+ for (const row of rows) {
4748
+ const seen = first.get(row.category);
4749
+ if (seen === void 0 || row.order < seen) first.set(row.category, row.order);
4750
+ }
4751
+ return rows.map((row) => ({ ...row, categoryRank: first.get(row.category) ?? row.order }));
4752
+ }
4753
+ var _StatisticsWidget = class _StatisticsWidget {
4754
+ static create(hostEl, state, deps) {
4755
+ const host = assertHost(deps?.host, "StatisticsWidget");
4756
+ const root = _StatisticsWidget._buildRoot(host);
4757
+ root.id = makePanelId(_StatisticsWidget.TYPE);
4758
+ hostEl.appendChild(root);
4759
+ return new _StatisticsWidget(root, state || {}, deps);
4760
+ }
4761
+ // The panel's markup. No rail action but the export: a statistic is produced by the
4762
+ // strategy and there is nothing here to cancel, reload or edit.
4763
+ static _buildRoot(host) {
4764
+ const title = host.t("Statistics");
4765
+ return makePanelRoot("statistics-panel", title, [
4766
+ makeElement("div", "panel-header", {}, [
4767
+ makeElement("span", "", {}, [title]),
4768
+ makeIconButton("bt-icon-btn bt-icon-cancel panel-close-btn", host.t("ClosePanel"), "bi-x", { type: "button" })
4769
+ ]),
4770
+ makeElement("div", "panel-body panel-body-with-rail", {}, [
4771
+ makeElement("div", "panel-body-content", {}, [
4772
+ makeElement("table", "terminal-table statistics-table", { role: "table", "aria-label": host.t("StatisticsList") }, [
4773
+ makeElement("thead", "", {}, []),
4774
+ makeElement("tbody", "statistics-body", {}, [])
4775
+ ])
4776
+ ]),
4777
+ makeElement("div", "panel-rail", { role: "toolbar", "aria-label": host.t("StatisticsActions") }, [
4778
+ makeIconButton("bt-icon-btn panel-export-btn", host.t("ExportToExcel"), "bi-file-earmark-spreadsheet", {})
4779
+ ])
4780
+ ])
4781
+ ]);
4782
+ }
4783
+ constructor(rootEl, _state, deps) {
4784
+ this._host = assertHost(deps?.host, "StatisticsWidget");
4785
+ this.rootEl = rootEl;
4786
+ this.el = this.rootEl.querySelector(".statistics-body");
4787
+ this._closeBtn = this.rootEl.querySelector(".panel-close-btn");
4788
+ this._exportBtn = this.rootEl.querySelector(".panel-export-btn");
4789
+ this._rows = [];
4790
+ this._closeBtn?.addEventListener("click", (e) => {
4791
+ e.preventDefault();
4792
+ this._host.close();
4793
+ });
4794
+ this._exportBtn?.addEventListener("click", (e) => {
4795
+ e.preventDefault();
4796
+ this._export();
4797
+ });
4798
+ const head = this.rootEl.querySelector(".statistics-table thead");
4799
+ this._grid = head && this.el ? new DataGrid({
4800
+ head,
4801
+ body: this.el,
4802
+ columns: this._columns(),
4803
+ // Not a sort the reader chose: the registry hands every parameter an order, and
4804
+ // the bands it assigns are what puts profit above drawdown above trade counts.
4805
+ defaultSort: { col: "order", dir: "asc" },
4806
+ rowKey: (r) => String(r.key),
4807
+ emptyText: this._host.t("NoStatistics"),
4808
+ contextMenu: makeGridMenu(this._host),
4809
+ // The groups sit where the registry order puts them, not where the
4810
+ // alphabet would: profit, then trades, then positions, then orders.
4811
+ groupOrder: "rows",
4812
+ selection: "multi"
4813
+ }) : null;
4814
+ this._grid?.setState({ group: "category", hidden: ["category", "order"] });
4815
+ this._host.register(this);
4816
+ }
4817
+ dispose() {
4818
+ this._grid?.destroy();
4819
+ this._host.unregister(this);
4820
+ try {
4821
+ this.rootEl.remove();
4822
+ } catch {
4823
+ }
4824
+ }
4825
+ /// Show these statistics. The whole set at once: a strategy publishes its parameters as one
4826
+ /// table and a row that vanished from it has stopped existing, not stopped changing.
4827
+ update(rows) {
4828
+ this._rows = rank(rows || []);
4829
+ this._grid?.setRows(this._rows);
4830
+ }
4831
+ _export() {
4832
+ this._grid?.download("statistics", this._host.t("Statistics"));
4833
+ }
4834
+ // Two visible columns and one that only groups. The order column is hidden as well: it
4835
+ // decides the sort and means nothing to a reader.
4836
+ _columns() {
4837
+ const label = (key) => this._host.t(key);
4838
+ return [
4839
+ {
4840
+ key: "category",
4841
+ header: label("Category"),
4842
+ exportable: false,
4843
+ // The group's place, not its name: groups are laid out in the order the
4844
+ // registry gives their parameters, so profit comes before trade counts.
4845
+ // Grouping on the name would order them alphabetically, and on the
4846
+ // translated name would reorder them with the reader's language.
4847
+ value: (r) => r.categoryRank,
4848
+ text: (r) => r.categoryText || r.category
4849
+ },
4850
+ {
4851
+ key: "order",
4852
+ header: label("Order"),
4853
+ exportable: false,
4854
+ value: (r) => r.order
4855
+ },
4856
+ {
4857
+ key: "name",
4858
+ header: label("Name"),
4859
+ exportable: true,
4860
+ value: (r) => r.name,
4861
+ bindCell: (td, r) => {
4862
+ if (r.description) td.setAttribute("title", r.description);
4863
+ }
4864
+ },
4865
+ {
4866
+ key: "value",
4867
+ header: label("Value"),
4868
+ exportable: true,
4869
+ cellClass: () => "statistic-value",
4870
+ // Sorts on the raw value so a number sorts as a number, reads as the text below.
4871
+ value: (r) => r.value,
4872
+ render: (r) => formatStatistic(r.value),
4873
+ exportValue: (r) => r.value ?? ""
4874
+ }
4875
+ ];
4876
+ }
4877
+ };
4878
+ _StatisticsWidget.TYPE = ControlTypes.Statistics;
4879
+ var StatisticsWidget = _StatisticsWidget;
4880
+ function formatStatistic(value) {
4881
+ if (value === null || value === void 0 || value === "") return "";
4882
+ if (typeof value === "number") {
4883
+ if (!isFinite(value)) return "";
4884
+ return String(Math.round(value * 100) / 100);
4885
+ }
4886
+ if (value instanceof Date) return isoDate(value);
4887
+ if (typeof value === "string") {
4888
+ const at = /^\d{4}-\d{2}-\d{2}([T ]|$)/.test(value) ? new Date(value) : null;
4889
+ if (at !== null && !isNaN(at.getTime())) return isoDate(at);
4890
+ return value;
4891
+ }
4892
+ return String(value);
4893
+ }
4894
+ function isoDate(at) {
4895
+ return at.toISOString().slice(0, 10);
4896
+ }
4897
+
4898
+ // src/heatmap-grid.ts
4899
+ var HeatDirections = {
4900
+ Higher: "higher",
4901
+ Lower: "lower"
4902
+ };
4903
+ var LEFT_GUTTER = 54;
4904
+ var RIGHT_PAD = 6;
4905
+ var TOP_BAND = 26;
4906
+ var BOTTOM_BAND = 30;
4907
+ var TICK_GAP = 4;
4908
+ var LABEL_HEIGHT = 13;
4909
+ var CELL_INSET = 0.5;
4910
+ var LEGEND_WIDTH = 140;
4911
+ var LEGEND_HEIGHT = 8;
4912
+ var LEGEND_Y = 14;
4913
+ var LEGEND_STEPS = 28;
4914
+ var MIN_TICK_WIDTH = 34;
4915
+ var MIN_TICK_HEIGHT = 13;
4916
+ var NUMERIC = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?\s*%?$/;
4917
+ function foldCells(cells) {
4918
+ const folded = /* @__PURE__ */ new Map();
4919
+ const order = [];
4920
+ for (const cell of cells) {
4921
+ if (cell === null || cell === void 0 || !Number.isFinite(cell.value)) continue;
4922
+ const id = pairKey(cell.x, cell.y);
4923
+ const found = folded.get(id);
4924
+ if (found === void 0) {
4925
+ folded.set(id, { x: cell.x, y: cell.y, sum: cell.value, count: 1 });
4926
+ order.push(id);
4927
+ } else {
4928
+ found.sum += cell.value;
4929
+ found.count += 1;
4930
+ }
4931
+ }
4932
+ return order.map((id) => {
4933
+ const fold = folded.get(id);
4934
+ return { x: fold.x, y: fold.y, value: fold.sum / fold.count, count: fold.count };
4935
+ });
4936
+ }
4937
+ function axisValues(values) {
4938
+ const unique = [];
4939
+ const seen = /* @__PURE__ */ new Set();
4940
+ for (const value of values) {
4941
+ if (seen.has(value)) continue;
4942
+ seen.add(value);
4943
+ unique.push(value);
4944
+ }
4945
+ if (unique.every((value) => NUMERIC.test(value)))
4946
+ return unique.sort((left, right) => numberOf(left) - numberOf(right));
4947
+ return unique.sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
4948
+ }
4949
+ function heatScale(buckets) {
4950
+ let min = Infinity;
4951
+ let max = -Infinity;
4952
+ for (const bucket of buckets) {
4953
+ if (bucket.value < min) min = bucket.value;
4954
+ if (bucket.value > max) max = bucket.value;
4955
+ }
4956
+ if (!isFinite(min) || !isFinite(max)) {
4957
+ min = 0;
4958
+ max = 0;
4959
+ }
4960
+ const anchor = min <= 0 && max >= 0 ? 0 : (min + max) / 2;
4961
+ const reach = Math.max(Math.abs(max - anchor), Math.abs(anchor - min));
4962
+ return { anchor, reach: reach > 0 ? reach : 1, min, max };
4963
+ }
4964
+ function tintOf(value, scale, betterWhen) {
4965
+ const distance = (value - scale.anchor) / scale.reach;
4966
+ const signed = betterWhen === HeatDirections.Lower ? -distance : distance;
4967
+ return Math.max(-1, Math.min(1, signed));
4968
+ }
4969
+ function valueAt(tint, scale, betterWhen) {
4970
+ const distance = betterWhen === HeatDirections.Lower ? -tint : tint;
4971
+ return scale.anchor + distance * scale.reach;
4972
+ }
4973
+ function layoutHeatmap(input) {
4974
+ const buckets = foldCells(input.cells);
4975
+ if (buckets.length === 0) return null;
4976
+ const columns = axisValues(buckets.map((bucket) => bucket.x));
4977
+ const rows = axisValues(buckets.map((bucket) => bucket.y));
4978
+ const scale = heatScale(buckets);
4979
+ const plot = {
4980
+ x: LEFT_GUTTER,
4981
+ y: TOP_BAND,
4982
+ width: Math.max(1, input.width - LEFT_GUTTER - RIGHT_PAD),
4983
+ height: Math.max(1, input.height - TOP_BAND - BOTTOM_BAND)
4984
+ };
4985
+ const cellWidth = plot.width / columns.length;
4986
+ const cellHeight = plot.height / rows.length;
4987
+ const rectOf = (column, row) => ({
4988
+ x: plot.x + column * cellWidth + CELL_INSET,
4989
+ // Row 0 sits at the FOOT of the plot: this is a chart, and a chart's Y grows upward. A
4990
+ // table would put it at the top, and then the map and its axis would disagree about
4991
+ // which corner is which.
4992
+ y: plot.y + plot.height - (row + 1) * cellHeight + CELL_INSET,
4993
+ width: Math.max(1, cellWidth - CELL_INSET * 2),
4994
+ height: Math.max(1, cellHeight - CELL_INSET * 2)
4995
+ });
4996
+ const measured = new Map(buckets.map((bucket) => [pairKey(bucket.x, bucket.y), bucket]));
4997
+ const cells = [];
4998
+ const gaps = [];
4999
+ for (let row = 0; row < rows.length; row++) {
5000
+ for (let column = 0; column < columns.length; column++) {
5001
+ const bucket = measured.get(pairKey(columns[column], rows[row]));
5002
+ if (bucket === void 0) {
5003
+ gaps.push({ x: columns[column], y: rows[row], rect: rectOf(column, row) });
5004
+ continue;
5005
+ }
5006
+ cells.push({
5007
+ bucket,
5008
+ rect: rectOf(column, row),
5009
+ tint: tintOf(bucket.value, scale, input.betterWhen),
5010
+ best: false
5011
+ });
5012
+ }
5013
+ }
5014
+ let best = cells[0];
5015
+ for (const shape of cells) if (shape.tint > best.tint) best = shape;
5016
+ best.best = true;
5017
+ const xTicks = tickIndices(columns.length, tickStep(columns.length, plot.width, MIN_TICK_WIDTH)).map((index) => ({
5018
+ text: columns[index],
5019
+ x: plot.x + (index + 0.5) * cellWidth,
5020
+ y: plot.y + plot.height + TICK_GAP
5021
+ }));
5022
+ const yTicks = tickIndices(rows.length, tickStep(rows.length, plot.height, MIN_TICK_HEIGHT)).map((index) => ({
5023
+ text: rows[index],
5024
+ x: plot.x - TICK_GAP,
5025
+ y: plot.y + plot.height - (index + 0.5) * cellHeight
5026
+ }));
5027
+ return {
5028
+ plot,
5029
+ columns,
5030
+ rows,
5031
+ cells,
5032
+ gaps,
5033
+ xTicks,
5034
+ yTicks,
5035
+ xTitle: { text: input.xLabel, x: plot.x + plot.width / 2, y: plot.y + plot.height + TICK_GAP + LABEL_HEIGHT },
5036
+ // Above the plot rather than beside it: a title down the left edge would have to be
5037
+ // rotated, and a rotation is a transform the control would then have to unwind around
5038
+ // everything else it draws.
5039
+ yTitle: { text: input.yLabel, x: 0, y: 0 },
5040
+ legend: legendOf(plot, scale, input.betterWhen),
5041
+ scale
5042
+ };
5043
+ }
5044
+ function hitHeatmap(layout, x, y) {
5045
+ for (const shape of layout.cells) {
5046
+ const rect = shape.rect;
5047
+ if (x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height)
5048
+ return shape;
5049
+ }
5050
+ return null;
5051
+ }
5052
+ function legendOf(plot, scale, betterWhen) {
5053
+ const right = plot.x + plot.width;
5054
+ const left = Math.max(plot.x, right - LEGEND_WIDTH);
5055
+ const stepWidth = (right - left) / LEGEND_STEPS;
5056
+ const steps = [];
5057
+ for (let step = 0; step < LEGEND_STEPS; step++) {
5058
+ steps.push({
5059
+ // Half a pixel of overlap: abutting fills leave a seam wherever the boundary is
5060
+ // fractional, and a key with a comb of background through it reads as a scale with
5061
+ // holes in it.
5062
+ rect: { x: left + step * stepWidth, y: LEGEND_Y, width: stepWidth + 0.5, height: LEGEND_HEIGHT },
5063
+ tint: -1 + 2 * step / (LEGEND_STEPS - 1)
5064
+ });
5065
+ }
5066
+ return {
5067
+ steps,
5068
+ low: { value: valueAt(-1, scale, betterWhen), x: left, y: 0 },
5069
+ anchor: { value: scale.anchor, x: (left + right) / 2, y: 0 },
5070
+ high: { value: valueAt(1, scale, betterWhen), x: right, y: 0 }
5071
+ };
5072
+ }
5073
+ function tickStep(count, span, room) {
5074
+ const slots = Math.max(1, Math.floor(span / room));
5075
+ return Math.max(1, Math.ceil(count / slots));
5076
+ }
5077
+ function tickIndices(count, step) {
5078
+ const indices = [];
5079
+ for (let index = 0; index < count; index += step) indices.push(index);
5080
+ const last = count - 1;
5081
+ if (indices[indices.length - 1] !== last) {
5082
+ if (last - indices[indices.length - 1] < step) indices.pop();
5083
+ indices.push(last);
5084
+ }
5085
+ return indices;
5086
+ }
5087
+ function pairKey(x, y) {
5088
+ return `${x}\0${y}`;
5089
+ }
5090
+ function numberOf(value) {
5091
+ return Number(value.trim().replace(/%$/, ""));
5092
+ }
5093
+
5094
+ // src/optimization-heatmap-widget.ts
5095
+ var GROUND_ALPHA = 0.1;
5096
+ var GAP_ALPHA = 0.25;
5097
+ var LABEL_ALPHA = 0.65;
5098
+ var BEST_WIDTH = 2;
5099
+ var TOOLTIP_OFFSET = 12;
5100
+ var TOOLTIP_MARGIN = 4;
5101
+ var _OptimizationHeatmapWidget = class _OptimizationHeatmapWidget {
5102
+ static create(hostEl, state, deps) {
5103
+ const host = assertHost(deps?.host, "OptimizationHeatmapWidget");
5104
+ const root = _OptimizationHeatmapWidget._buildRoot(host);
5105
+ root.id = makePanelId(_OptimizationHeatmapWidget.TYPE);
5106
+ hostEl.appendChild(root);
5107
+ return new _OptimizationHeatmapWidget(root, state || {}, deps);
5108
+ }
5109
+ // The panel's markup. The metric's name sits in the header rather than on the canvas: it is
5110
+ // the caption the drawn legend is read against, and putting it in the chrome costs the map
5111
+ // no pixels and leaves the text selectable and sized by the stylesheet.
5112
+ static _buildRoot(host) {
5113
+ const title = host.t("OptimizationHeatmap");
5114
+ return makePanelRoot("optimization-heatmap-panel", title, [
5115
+ makeElement("div", "panel-header", {}, [
5116
+ makeElement("span", "", {}, [title]),
5117
+ makeElement("span", "heatmap-metric", {}, []),
5118
+ makeIconButton("bt-icon-btn bt-icon-cancel panel-close-btn", host.t("ClosePanel"), "bi-x", { type: "button" })
5119
+ ]),
5120
+ makeElement("div", "panel-body", {}, [
5121
+ makeElement("canvas", "heatmap-canvas", { role: "img", "aria-label": host.t("OptimizationHeatmapChart") }, []),
5122
+ makeElement("div", "heatmap-empty", { hidden: "" }, [host.t("NoOptimizationResults")]),
5123
+ makeElement("div", "heatmap-tooltip", { role: "tooltip", hidden: "" }, [])
5124
+ ])
5125
+ ]);
5126
+ }
5127
+ constructor(rootEl, _state, deps) {
5128
+ this._host = assertHost(deps?.host, "OptimizationHeatmapWidget");
5129
+ this.rootEl = rootEl;
5130
+ this.canvasEl = this.rootEl.querySelector(".heatmap-canvas");
5131
+ this._metricEl = this.rootEl.querySelector(".heatmap-metric");
5132
+ this._emptyEl = this.rootEl.querySelector(".heatmap-empty");
5133
+ this._tooltipEl = this.rootEl.querySelector(".heatmap-tooltip");
5134
+ this._closeBtn = this.rootEl.querySelector(".panel-close-btn");
5135
+ this._ctx = this.canvasEl?.getContext("2d") ?? null;
5136
+ this._data = null;
5137
+ this._layout = null;
5138
+ this._size = null;
5139
+ this._resizeObserver = null;
5140
+ this._closeBtn?.addEventListener("click", (e) => {
5141
+ e.preventDefault();
5142
+ this._host.close();
5143
+ });
5144
+ this._bindHover();
5145
+ this._setEmpty(true);
5146
+ if (this.canvasEl && typeof ResizeObserver === "function") {
5147
+ this._resizeObserver = new ResizeObserver(() => this._render());
5148
+ this._resizeObserver.observe(this.canvasEl);
5149
+ }
5150
+ this._host.register(this);
5151
+ }
5152
+ dispose() {
5153
+ try {
5154
+ this._resizeObserver?.disconnect();
5155
+ } catch {
5156
+ }
5157
+ this._host.unregister(this);
5158
+ try {
5159
+ this.rootEl.remove();
5160
+ } catch {
5161
+ }
5162
+ }
5163
+ /// Show this map. The whole of it at once: a pair that has dropped out of the set has stopped
5164
+ /// existing, and leaving its cell behind would report a run that is no longer in the report.
5165
+ update(data) {
5166
+ this._data = data;
5167
+ if (this._metricEl !== null) this._metricEl.textContent = data.metricLabel;
5168
+ this._hideTooltip();
5169
+ this._render();
5170
+ }
5171
+ // The canvas in CSS pixels, and its backing store in device pixels. Without the second the
5172
+ // map is drawn at a third of the resolution the screen has, and a lattice of hairlines is
5173
+ // exactly the drawing that shows it.
5174
+ _sizeCanvas() {
5175
+ const canvas = this.canvasEl;
5176
+ if (!canvas) return;
5177
+ const rect = canvas.getBoundingClientRect();
5178
+ const ratio = typeof devicePixelRatio === "number" && devicePixelRatio > 0 ? devicePixelRatio : 1;
5179
+ const width = Math.max(1, Math.floor(rect.width));
5180
+ const height = Math.max(1, Math.floor(rect.height));
5181
+ if (canvas.width !== width * ratio || canvas.height !== height * ratio) {
5182
+ canvas.width = width * ratio;
5183
+ canvas.height = height * ratio;
5184
+ this._ctx?.setTransform(ratio, 0, 0, ratio, 0, 0);
5185
+ }
5186
+ this._size = { width, height };
5187
+ }
5188
+ _render() {
5189
+ const ctx = this._ctx;
5190
+ if (ctx === null) return;
5191
+ this._sizeCanvas();
5192
+ const size = this._size;
5193
+ if (size === null) return;
5194
+ ctx.clearRect(0, 0, size.width, size.height);
5195
+ this._layout = null;
5196
+ const data = this._data;
5197
+ if (data === null) {
5198
+ this._setEmpty(true);
5199
+ return;
5200
+ }
5201
+ const layout = layoutHeatmap({
5202
+ width: size.width,
5203
+ height: size.height,
5204
+ cells: data.cells,
5205
+ betterWhen: data.betterWhen,
5206
+ xLabel: data.xLabel,
5207
+ yLabel: data.yLabel
5208
+ });
5209
+ this._setEmpty(layout === null);
5210
+ if (layout === null) return;
5211
+ this._layout = layout;
5212
+ const palette = this._host.presentation.canvasPalette();
5213
+ ctx.font = palette.font;
5214
+ ctx.lineWidth = 1;
5215
+ for (const shape of layout.cells) this._paintTint(ctx, shape.rect, shape.tint, palette.grid, palette.up, palette.down);
5216
+ ctx.globalAlpha = GAP_ALPHA;
5217
+ ctx.strokeStyle = palette.grid;
5218
+ for (const gap of layout.gaps) {
5219
+ ctx.beginPath();
5220
+ ctx.moveTo(gap.rect.x, gap.rect.y + gap.rect.height);
5221
+ ctx.lineTo(gap.rect.x + gap.rect.width, gap.rect.y);
5222
+ ctx.stroke();
5223
+ }
5224
+ const best = layout.cells.find((shape) => shape.best);
5225
+ if (best !== void 0) {
5226
+ ctx.globalAlpha = 1;
5227
+ ctx.strokeStyle = palette.grid;
5228
+ ctx.lineWidth = BEST_WIDTH;
5229
+ _OptimizationHeatmapWidget._outline(ctx, best.rect, BEST_WIDTH / 2);
5230
+ ctx.lineWidth = 1;
5231
+ }
5232
+ for (const step of layout.legend.steps)
5233
+ this._paintTint(ctx, step.rect, step.tint, palette.grid, palette.up, palette.down);
5234
+ ctx.globalAlpha = LABEL_ALPHA;
5235
+ ctx.fillStyle = palette.grid;
5236
+ ctx.textBaseline = "top";
5237
+ ctx.textAlign = "center";
5238
+ for (const tick of layout.xTicks) ctx.fillText(tick.text, tick.x, tick.y);
5239
+ ctx.fillText(layout.xTitle.text, layout.xTitle.x, layout.xTitle.y);
5240
+ ctx.fillText(formatStatistic(layout.legend.anchor.value), layout.legend.anchor.x, layout.legend.anchor.y);
5241
+ ctx.textAlign = "left";
5242
+ ctx.fillText(layout.yTitle.text, layout.yTitle.x, layout.yTitle.y);
5243
+ ctx.fillText(formatStatistic(layout.legend.low.value), layout.legend.low.x, layout.legend.low.y);
5244
+ ctx.textAlign = "right";
5245
+ ctx.fillText(formatStatistic(layout.legend.high.value), layout.legend.high.x, layout.legend.high.y);
5246
+ ctx.textBaseline = "middle";
5247
+ for (const tick of layout.yTicks) ctx.fillText(tick.text, tick.x, tick.y);
5248
+ ctx.globalAlpha = 1;
5249
+ }
5250
+ // The ground, then as much of the direction colour as the cell earned. Two fills rather than
5251
+ // one blended colour: blending would need a background this control is not allowed to know.
5252
+ _paintTint(ctx, rect, tint, ground, up, down) {
5253
+ ctx.globalAlpha = GROUND_ALPHA;
5254
+ ctx.fillStyle = ground;
5255
+ ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
5256
+ if (tint === 0) return;
5257
+ ctx.globalAlpha = Math.abs(tint);
5258
+ ctx.fillStyle = tint > 0 ? up : down;
5259
+ ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
5260
+ }
5261
+ static _outline(ctx, rect, inset) {
5262
+ const left = rect.x + inset;
5263
+ const top = rect.y + inset;
5264
+ const right = rect.x + rect.width - inset;
5265
+ const bottom = rect.y + rect.height - inset;
5266
+ ctx.beginPath();
5267
+ ctx.moveTo(left, top);
5268
+ ctx.lineTo(right, top);
5269
+ ctx.lineTo(right, bottom);
5270
+ ctx.lineTo(left, bottom);
5271
+ ctx.closePath();
5272
+ ctx.stroke();
5273
+ }
5274
+ _setEmpty(empty) {
5275
+ if (this._emptyEl === null) return;
5276
+ if (empty) this._emptyEl.removeAttribute("hidden");
5277
+ else this._emptyEl.setAttribute("hidden", "");
5278
+ }
5279
+ _bindHover() {
5280
+ const canvas = this.canvasEl;
5281
+ if (!canvas) return;
5282
+ canvas.addEventListener("mousemove", (e) => {
5283
+ const layout = this._layout;
5284
+ if (layout === null) {
5285
+ this._hideTooltip();
5286
+ return;
5287
+ }
5288
+ const rect = canvas.getBoundingClientRect();
5289
+ const x = e.clientX - rect.left;
5290
+ const y = e.clientY - rect.top;
5291
+ const hit = hitHeatmap(layout, x, y);
5292
+ if (hit !== null) this._showTooltip(hit, x, y);
5293
+ else this._hideTooltip();
5294
+ });
5295
+ canvas.addEventListener("mouseleave", () => this._hideTooltip());
5296
+ }
5297
+ _showTooltip(shape, x, y) {
5298
+ const tooltip = this._tooltipEl;
5299
+ const canvas = this.canvasEl;
5300
+ const data = this._data;
5301
+ if (!tooltip || !canvas || data === null) return;
5302
+ const lines = [
5303
+ this._tooltipLine(data.xLabel, shape.bucket.x),
5304
+ this._tooltipLine(data.yLabel, shape.bucket.y),
5305
+ this._tooltipLine(data.metricLabel, formatStatistic(shape.bucket.value))
5306
+ ];
5307
+ if (shape.bucket.count > 1)
5308
+ lines.push(this._tooltipLine(this._host.t("Runs"), String(shape.bucket.count)));
5309
+ if (shape.best)
5310
+ lines.push(makeElement("div", "heatmap-tt-row heatmap-tt-best", {}, [this._host.t("Best")]));
5311
+ tooltip.replaceChildren(...lines);
5312
+ tooltip.removeAttribute("hidden");
5313
+ const canvasRect = canvas.getBoundingClientRect();
5314
+ const parentRect = tooltip.offsetParent?.getBoundingClientRect() ?? canvasRect;
5315
+ const originX = canvasRect.left - parentRect.left;
5316
+ const originY = canvasRect.top - parentRect.top;
5317
+ const maxX = originX + canvasRect.width - tooltip.offsetWidth - TOOLTIP_MARGIN;
5318
+ const maxY = originY + canvasRect.height - tooltip.offsetHeight - TOOLTIP_MARGIN;
5319
+ tooltip.style.left = `${Math.max(0, Math.min(originX + x + TOOLTIP_OFFSET, maxX))}px`;
5320
+ tooltip.style.top = `${Math.max(0, Math.min(originY + y + TOOLTIP_OFFSET, maxY))}px`;
5321
+ }
5322
+ _tooltipLine(label, value) {
5323
+ return makeElement("div", "heatmap-tt-row", {}, [
5324
+ makeElement("span", "heatmap-tt-label", {}, [label]),
5325
+ makeElement("span", "heatmap-tt-value", {}, [value])
5326
+ ]);
5327
+ }
5328
+ _hideTooltip() {
5329
+ this._tooltipEl?.setAttribute("hidden", "");
5330
+ }
5331
+ };
5332
+ _OptimizationHeatmapWidget.TYPE = ControlTypes.OptimizationHeatmap;
5333
+ var OptimizationHeatmapWidget = _OptimizationHeatmapWidget;
5334
+
5335
+ // src/surface-grid.ts
5336
+ var MIN_PITCH = 0.12;
5337
+ var MAX_PITCH = 1.45;
5338
+ var DEFAULT_VIEW = { yaw: -0.7, pitch: 0.62, zoom: 1 };
5339
+ var DRAG_TO_RADIANS = 8e-3;
5340
+ var MIN_ZOOM = 0.4;
5341
+ var MAX_ZOOM = 4;
5342
+ function clampView(view) {
5343
+ const twoPi = Math.PI * 2;
5344
+ return {
5345
+ // Wrapped rather than clamped: turning the surface right round is a gesture a reader
5346
+ // makes on purpose, to see the far side of a ridge.
5347
+ yaw: (view.yaw % twoPi + twoPi) % twoPi,
5348
+ pitch: Math.min(MAX_PITCH, Math.max(MIN_PITCH, view.pitch)),
5349
+ zoom: Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, view.zoom))
5350
+ };
5351
+ }
5352
+ function dragView(view, dx, dy) {
5353
+ return clampView({
5354
+ yaw: view.yaw + dx * DRAG_TO_RADIANS,
5355
+ // Dragging down tips the surface towards a top view, which is the direction the gesture
5356
+ // suggests: the far edge comes up to meet the pointer.
5357
+ pitch: view.pitch + dy * DRAG_TO_RADIANS,
5358
+ zoom: view.zoom
5359
+ });
5360
+ }
5361
+ function zoomView(view, factor) {
5362
+ return clampView({ yaw: view.yaw, pitch: view.pitch, zoom: view.zoom * factor });
5363
+ }
5364
+ function project(nx, ny, nz, view, box) {
5365
+ const cy = Math.cos(view.yaw);
5366
+ const sy = Math.sin(view.yaw);
5367
+ const rx = nx * cy - ny * sy;
5368
+ const ry = nx * sy + ny * cy;
5369
+ const cp = Math.cos(view.pitch);
5370
+ const sp = Math.sin(view.pitch);
5371
+ const span = Math.min(box.width, box.height) / 2;
5372
+ const unit = span * view.zoom / Math.SQRT2;
5373
+ return {
5374
+ x: box.width / 2 + rx * unit,
5375
+ // Screen y grows downward, so height subtracts.
5376
+ y: box.height / 2 + (ry * sp - nz * cp) * unit,
5377
+ // Larger is nearer the viewer.
5378
+ depth: ry * cp + nz * sp
5379
+ };
5380
+ }
5381
+ function surfaceLayout(input) {
5382
+ const buckets = foldCells(input.cells);
5383
+ if (buckets.length === 0) return null;
5384
+ const xValues = axisOrder(buckets.map((b) => b.x));
5385
+ const yValues = axisOrder(buckets.map((b) => b.y));
5386
+ if (xValues.length < 2 || yValues.length < 2) return null;
5387
+ const scale = heatScale(buckets);
5388
+ const at = /* @__PURE__ */ new Map();
5389
+ for (const bucket of buckets) at.set(`${bucket.x} ${bucket.y}`, bucket);
5390
+ const box = { width: input.width, height: input.height };
5391
+ const view = clampView(input.view);
5392
+ const corners = [];
5393
+ const vertices = [];
5394
+ for (let ix = 0; ix < xValues.length; ix++) {
5395
+ const column = [];
5396
+ for (let iy = 0; iy < yValues.length; iy++) {
5397
+ const bucket = at.get(`${xValues[ix]} ${yValues[iy]}`);
5398
+ const tint = bucket === void 0 ? 0 : tintOf(bucket.value, scale, input.betterWhen);
5399
+ const nx = axisPosition(ix, xValues.length);
5400
+ const ny = axisPosition(iy, yValues.length);
5401
+ const point = project(nx, ny, (tint + 1) / 2, view, box);
5402
+ column.push({ ...point, tint });
5403
+ if (bucket !== void 0) {
5404
+ vertices.push({
5405
+ at: [point.x, point.y],
5406
+ x: xValues[ix],
5407
+ y: yValues[iy],
5408
+ value: bucket.value,
5409
+ depth: point.depth
5410
+ });
5411
+ }
5412
+ }
5413
+ corners.push(column);
5414
+ }
5415
+ const quads = [];
5416
+ for (let ix = 0; ix + 1 < xValues.length; ix++) {
5417
+ for (let iy = 0; iy + 1 < yValues.length; iy++) {
5418
+ const near = at.get(`${xValues[ix]} ${yValues[iy]}`);
5419
+ const holes = [
5420
+ near,
5421
+ at.get(`${xValues[ix + 1]} ${yValues[iy]}`),
5422
+ at.get(`${xValues[ix + 1]} ${yValues[iy + 1]}`),
5423
+ at.get(`${xValues[ix]} ${yValues[iy + 1]}`)
5424
+ ];
5425
+ if (near === void 0 || holes.some((b2) => b2 === void 0)) continue;
5426
+ const a = corners[ix][iy];
5427
+ const b = corners[ix + 1][iy];
5428
+ const c = corners[ix + 1][iy + 1];
5429
+ const d = corners[ix][iy + 1];
5430
+ quads.push({
5431
+ points: [[a.x, a.y], [b.x, b.y], [c.x, c.y], [d.x, d.y]],
5432
+ // The mean of the corners, so a face slopes in colour the way it slopes in height
5433
+ // rather than taking the tint of whichever corner happened to be first.
5434
+ tint: (a.tint + b.tint + c.tint + d.tint) / 4,
5435
+ depth: (a.depth + b.depth + c.depth + d.depth) / 4,
5436
+ bucket: near
5437
+ });
5438
+ }
5439
+ }
5440
+ quads.sort((p, q) => p.depth - q.depth);
5441
+ const floor = (nx, ny) => {
5442
+ const p = project(nx, ny, 0, view, box);
5443
+ return [p.x, p.y];
5444
+ };
5445
+ const outward = (from, to) => {
5446
+ const centre = floor(0, 0);
5447
+ const midX = (from[0] + to[0]) / 2;
5448
+ const midY = (from[1] + to[1]) / 2;
5449
+ const dx = midX - centre[0];
5450
+ const dy = midY - centre[1];
5451
+ const length = Math.hypot(dx, dy) || 1;
5452
+ return [dx / length, dy / length];
5453
+ };
5454
+ const depthAt = (nx, ny) => project(nx, ny, 0, view, box).depth;
5455
+ const xSide = depthAt(0, -1) >= depthAt(0, 1) ? -1 : 1;
5456
+ const ySide = depthAt(-1, 0) >= depthAt(1, 0) ? -1 : 1;
5457
+ const xEdge = [floor(-1, xSide), floor(1, xSide)];
5458
+ const yEdge = [floor(ySide, -1), floor(ySide, 1)];
5459
+ const xAway = outward(xEdge[0], xEdge[1]);
5460
+ const yAway = outward(yEdge[0], yEdge[1]);
5461
+ const zBottom = project(-ySide, -xSide, 0, view, box);
5462
+ const zTop = project(-ySide, -xSide, 1, view, box);
5463
+ const zAway = outward([zBottom.x, zBottom.y], [zTop.x, zTop.y]);
5464
+ return {
5465
+ quads,
5466
+ vertices,
5467
+ axes: [
5468
+ {
5469
+ from: xEdge[0],
5470
+ to: xEdge[1],
5471
+ axis: "x",
5472
+ ticks: axisTicks(xValues, (i) => floor(axisPosition(i, xValues.length), xSide), xAway)
5473
+ },
5474
+ {
5475
+ from: yEdge[0],
5476
+ to: yEdge[1],
5477
+ axis: "y",
5478
+ ticks: axisTicks(yValues, (i) => floor(ySide, axisPosition(i, yValues.length)), yAway)
5479
+ },
5480
+ {
5481
+ from: [zBottom.x, zBottom.y],
5482
+ to: [zTop.x, zTop.y],
5483
+ axis: "z",
5484
+ // Five marks up the height, which is what the colour scale runs over: the floor is
5485
+ // the worst run and the top the best. Worded by the caller, which knows the metric.
5486
+ ticks: heightTicks(view, box, zAway, -ySide, -xSide)
5487
+ }
5488
+ ],
5489
+ xValues,
5490
+ yValues,
5491
+ scale
5492
+ };
5493
+ }
5494
+ var SURFACE_HEIGHT_TICKS = [0, 0.25, 0.5, 0.75, 1];
5495
+ var MAX_AXIS_TICKS = 8;
5496
+ function axisTicks(values, at, away) {
5497
+ if (values.length === 0) return [];
5498
+ const step = Math.max(1, Math.ceil(values.length / MAX_AXIS_TICKS));
5499
+ const ticks = [];
5500
+ for (let i = 0; i < values.length; i += step) ticks.push({ at: at(i), label: values[i], away });
5501
+ const last = values.length - 1;
5502
+ if (last > 0 && last % step !== 0)
5503
+ ticks.push({ at: at(last), label: values[last], away });
5504
+ return ticks;
5505
+ }
5506
+ function heightTicks(view, box, away, nx, ny) {
5507
+ return SURFACE_HEIGHT_TICKS.map((height) => {
5508
+ const point = project(nx, ny, height, view, box);
5509
+ return { at: [point.x, point.y], label: String(height), away };
5510
+ });
5511
+ }
5512
+ function nearestVertex(vertices, x, y, reach) {
5513
+ let best = null;
5514
+ let bestDistance = reach;
5515
+ for (const vertex of vertices) {
5516
+ const distance = Math.hypot(vertex.at[0] - x, vertex.at[1] - y);
5517
+ if (distance > bestDistance) continue;
5518
+ if (distance === bestDistance && best !== null && vertex.depth <= best.depth) continue;
5519
+ best = vertex;
5520
+ bestDistance = distance;
5521
+ }
5522
+ return best;
5523
+ }
5524
+ function axisOrder(values) {
5525
+ const distinct = [...new Set(values)];
5526
+ const numeric = distinct.every((v) => v.trim() !== "" && isFinite(Number(v)));
5527
+ return numeric ? distinct.sort((a, b) => Number(a) - Number(b)) : distinct.sort((a, b) => a.localeCompare(b));
5528
+ }
5529
+ function axisPosition(index, count) {
5530
+ return count < 2 ? 0 : index / (count - 1) * 2 - 1;
5531
+ }
5532
+
5533
+ // src/surface-widget.ts
5534
+ var FACE_ALPHA = 0.85;
5535
+ var EDGE_ALPHA = 0.35;
5536
+ var FLOOR_ALPHA = 0.5;
5537
+ var EDGE_WIDTH = 1;
5538
+ var LABEL_ALPHA2 = 0.8;
5539
+ var WHEEL_STEP = 1.12;
5540
+ var WHEEL_UNITS_PER_NOTCH = 100;
5541
+ var WHEEL_MAX_NOTCHES = 2;
5542
+ function wheelNotches(deltaY, deltaMode) {
5543
+ if (!Number.isFinite(deltaY) || deltaY === 0) return 0;
5544
+ const perNotch = deltaMode === 1 ? 3 : deltaMode === 2 ? 1 : WHEEL_UNITS_PER_NOTCH;
5545
+ const notches = deltaY / perNotch;
5546
+ return Math.max(-WHEEL_MAX_NOTCHES, Math.min(WHEEL_MAX_NOTCHES, notches));
5547
+ }
5548
+ var TICK_LENGTH = 5;
5549
+ var TICK_GAP2 = 4;
5550
+ var CAPTION_OFFSET = 34;
5551
+ var HOVER_REACH = 22;
5552
+ var HOVER_RING = 5;
5553
+ var _SurfaceWidget = class _SurfaceWidget {
5554
+ static create(hostEl, state, deps) {
5555
+ const host = assertHost(deps?.host, "SurfaceWidget");
5556
+ const root = _SurfaceWidget._buildRoot(host);
5557
+ root.id = makePanelId(_SurfaceWidget.TYPE);
5558
+ hostEl.appendChild(root);
5559
+ return new _SurfaceWidget(root, state || {}, deps);
5560
+ }
5561
+ static _buildRoot(host) {
5562
+ const title = host.t("OptimizationSurface");
5563
+ return makePanelRoot("surface-panel", title, [
5564
+ makeElement("div", "panel-header", {}, [
5565
+ makeElement("span", "", {}, [title]),
5566
+ makeElement("span", "surface-metric", {}, []),
5567
+ makeIconButton("bt-icon-btn surface-reset-btn", host.t("ResetView"), "bi-arrow-clockwise", { type: "button" }),
5568
+ makeIconButton("bt-icon-btn bt-icon-cancel panel-close-btn", host.t("ClosePanel"), "bi-x", { type: "button" })
5569
+ ]),
5570
+ makeElement("div", "panel-body", {}, [
5571
+ makeElement("div", "surface-chart", {}, [
5572
+ makeElement("canvas", "surface-canvas", { role: "img", "aria-label": host.t("OptimizationSurfaceChart") }, []),
5573
+ // Over the canvas rather than in the panel header: a reading about the point
5574
+ // under the pointer belongs beside that point. A host that lifts its panel
5575
+ // headers into a tab strip would otherwise put the reading in a tab title.
5576
+ makeElement("div", "surface-readout", {}, []),
5577
+ makeElement("div", "surface-empty", { hidden: "" }, [host.t("NoOptimizationResults")])
5578
+ ])
5579
+ ])
5580
+ ]);
5581
+ }
5582
+ constructor(rootEl, _state, deps) {
5583
+ this._host = assertHost(deps?.host, "SurfaceWidget");
5584
+ this.rootEl = rootEl;
5585
+ this.canvasEl = this.rootEl.querySelector(".surface-canvas");
5586
+ this._emptyEl = this.rootEl.querySelector(".surface-empty");
5587
+ this._closeBtn = this.rootEl.querySelector(".panel-close-btn");
5588
+ this._resetBtn = this.rootEl.querySelector(".surface-reset-btn");
5589
+ this._ctx = this.canvasEl?.getContext("2d") ?? null;
5590
+ this._data = null;
5591
+ this._layout = null;
5592
+ this._hover = null;
5593
+ this._hoverEl = this.rootEl.querySelector(".surface-readout");
5594
+ this._view = DEFAULT_VIEW;
5595
+ this._resizeObserver = null;
5596
+ this._pointers = /* @__PURE__ */ new Map();
5597
+ this._pinch = 0;
5598
+ this._closeBtn?.addEventListener("click", (e) => {
5599
+ e.preventDefault();
5600
+ this._host.close();
5601
+ });
5602
+ this._resetBtn?.addEventListener("click", (e) => {
5603
+ e.preventDefault();
5604
+ this._view = DEFAULT_VIEW;
5605
+ this._render();
5606
+ });
5607
+ this._bindGestures();
5608
+ if (this.canvasEl !== null && typeof ResizeObserver !== "undefined") {
5609
+ this._resizeObserver = new ResizeObserver(() => this._render());
5610
+ this._resizeObserver.observe(this.canvasEl);
5611
+ }
5612
+ this._host.register(this);
5613
+ }
5614
+ dispose() {
5615
+ this._resizeObserver?.disconnect();
5616
+ this._resizeObserver = null;
5617
+ this._host.unregister(this);
5618
+ try {
5619
+ this.rootEl.remove();
5620
+ } catch {
5621
+ }
5622
+ }
5623
+ /// Show these results. The whole set at once: a surface is one sweep, and half of one drawn
5624
+ /// over the other half would be a landscape of two different runs.
5625
+ update(data) {
5626
+ this._data = data;
5627
+ this._render();
5628
+ }
5629
+ /// The view the surface is currently seen from, so a host can persist it.
5630
+ view() {
5631
+ return { ...this._view };
5632
+ }
5633
+ /// Put the surface back where it started.
5634
+ resetView() {
5635
+ this._view = DEFAULT_VIEW;
5636
+ this._render();
5637
+ }
5638
+ // One pointer turns, two zoom, a wheel zooms. Pointer events cover mouse, pen and touch, so
5639
+ // there is one implementation rather than one per input device.
5640
+ _bindGestures() {
5641
+ const canvas = this.canvasEl;
5642
+ if (canvas === null) return;
5643
+ canvas.addEventListener("pointerdown", (event) => {
5644
+ event.preventDefault();
5645
+ try {
5646
+ canvas.setPointerCapture(event.pointerId);
5647
+ } catch {
5648
+ }
5649
+ this._pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
5650
+ this._pinch = this._pointerSpread();
5651
+ });
5652
+ canvas.addEventListener("pointermove", (event) => {
5653
+ const previous = this._pointers.get(event.pointerId);
5654
+ if (previous === void 0) return;
5655
+ event.preventDefault();
5656
+ this._pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
5657
+ if (this._pointers.size >= 2) {
5658
+ const spread = this._pointerSpread();
5659
+ if (this._pinch > 0 && spread > 0) this._view = zoomView(this._view, spread / this._pinch);
5660
+ this._pinch = spread;
5661
+ } else {
5662
+ this._view = dragView(this._view, event.clientX - previous.x, event.clientY - previous.y);
5663
+ }
5664
+ this._render();
5665
+ });
5666
+ const release = (event) => {
5667
+ this._pointers.delete(event.pointerId);
5668
+ this._pinch = this._pointerSpread();
5669
+ };
5670
+ canvas.addEventListener("pointerup", release);
5671
+ canvas.addEventListener("pointercancel", release);
5672
+ canvas.addEventListener("pointerleave", release);
5673
+ canvas.addEventListener("pointermove", (event) => {
5674
+ if (this._pointers.size > 0) return;
5675
+ const box = canvas.getBoundingClientRect();
5676
+ this._setHover(event.clientX - box.left, event.clientY - box.top);
5677
+ });
5678
+ canvas.addEventListener("pointerleave", () => this._setHover(null, null));
5679
+ canvas.addEventListener("wheel", (event) => {
5680
+ event.preventDefault();
5681
+ const notches = wheelNotches(event.deltaY, event.deltaMode);
5682
+ if (notches === 0) return;
5683
+ this._view = zoomView(this._view, Math.pow(WHEEL_STEP, -notches));
5684
+ this._render();
5685
+ }, { passive: false });
5686
+ }
5687
+ // The vertex under the pointer, and a repaint when it changes. Compared by identity of the
5688
+ // grid pair rather than of the object: a re-layout builds new vertices for the same runs.
5689
+ _setHover(x, y) {
5690
+ const found = x === null || y === null || this._layout === null ? null : nearestVertex(this._layout.vertices, x, y, HOVER_REACH);
5691
+ const before = this._hover;
5692
+ const same = before !== null && found !== null && before.x === found.x && before.y === found.y;
5693
+ if (same || before === null && found === null) return;
5694
+ this._hover = found;
5695
+ this._renderHover();
5696
+ this._render();
5697
+ }
5698
+ // Which pair it is and what it measured, in the panel's own strip over the canvas.
5699
+ _renderHover() {
5700
+ const el = this._hoverEl;
5701
+ if (el === null || this._data === null) return;
5702
+ const hover = this._hover;
5703
+ if (hover === null) {
5704
+ el.textContent = "";
5705
+ return;
5706
+ }
5707
+ el.textContent = `${this._data.xLabel} ${hover.x} \xB7 ${this._data.yLabel} ${hover.y} ${formatPnl(hover.value)}`;
5708
+ }
5709
+ // How far apart the two pointers are, or zero when there are not two.
5710
+ _pointerSpread() {
5711
+ const points = [...this._pointers.values()];
5712
+ if (points.length < 2) return 0;
5713
+ return Math.hypot(points[0].x - points[1].x, points[0].y - points[1].y);
5714
+ }
5715
+ _render() {
5716
+ const canvas = this.canvasEl;
5717
+ const ctx = this._ctx;
5718
+ if (canvas === null || ctx === null) return;
5719
+ const box = canvas.getBoundingClientRect();
5720
+ const width = Math.round(box.width);
5721
+ const height = Math.round(box.height);
5722
+ if (width <= 0 || height <= 0) return;
5723
+ const ratio = typeof window === "undefined" ? 1 : window.devicePixelRatio || 1;
5724
+ canvas.width = Math.round(width * ratio);
5725
+ canvas.height = Math.round(height * ratio);
5726
+ ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
5727
+ ctx.clearRect(0, 0, width, height);
5728
+ const metric = this.rootEl.querySelector(".surface-metric");
5729
+ if (metric !== null) metric.textContent = this._data?.metricLabel ?? "";
5730
+ const layout = this._data === null ? null : surfaceLayout({
5731
+ width,
5732
+ height,
5733
+ cells: this._data.cells,
5734
+ betterWhen: this._data.betterWhen,
5735
+ view: this._view
5736
+ });
5737
+ this._layout = layout;
5738
+ if (this._emptyEl !== null) this._emptyEl.hidden = layout !== null;
5739
+ if (layout === null || this._data === null) {
5740
+ this._hover = null;
5741
+ this._renderHover();
5742
+ return;
5743
+ }
5744
+ const palette = this._host.presentation.canvasPalette();
5745
+ this._drawFloor(ctx, layout, palette.grid);
5746
+ this._drawFaces(ctx, layout, palette);
5747
+ this._drawAxes(ctx, layout, palette);
5748
+ this._drawHover(ctx, palette);
5749
+ }
5750
+ // The three axes: the two the grid is swept over and the one it is measured against, each with
5751
+ // its own marks. A landscape with no numbers on it is a picture of a shape, not a reading.
5752
+ _drawAxes(ctx, layout, palette) {
5753
+ if (this._data === null) return;
5754
+ ctx.font = palette.font;
5755
+ ctx.textBaseline = "middle";
5756
+ ctx.strokeStyle = palette.grid;
5757
+ ctx.fillStyle = palette.grid;
5758
+ ctx.lineWidth = EDGE_WIDTH;
5759
+ for (const axis of layout.axes) {
5760
+ ctx.globalAlpha = LABEL_ALPHA2;
5761
+ for (const tick of axis.ticks) {
5762
+ const [ax, ay] = tick.at;
5763
+ const [dx, dy] = tick.away;
5764
+ ctx.beginPath();
5765
+ ctx.moveTo(ax, ay);
5766
+ ctx.lineTo(ax + dx * TICK_LENGTH, ay + dy * TICK_LENGTH);
5767
+ ctx.stroke();
5768
+ const tx = ax + dx * (TICK_LENGTH + TICK_GAP2);
5769
+ const ty = ay + dy * (TICK_LENGTH + TICK_GAP2);
5770
+ ctx.textAlign = Math.abs(dx) < 0.4 ? "center" : dx > 0 ? "left" : "right";
5771
+ ctx.fillText(this._tickLabel(axis.axis, tick.label, layout), tx, ty);
5772
+ }
5773
+ const midX = (axis.from[0] + axis.to[0]) / 2;
5774
+ const midY = (axis.from[1] + axis.to[1]) / 2;
5775
+ const away = axis.ticks.length > 0 ? axis.ticks[0].away : [0, 1];
5776
+ ctx.globalAlpha = 1;
5777
+ ctx.textAlign = "center";
5778
+ ctx.fillText(
5779
+ this._axisCaption(axis.axis),
5780
+ midX + away[0] * CAPTION_OFFSET,
5781
+ midY + away[1] * CAPTION_OFFSET
5782
+ );
5783
+ }
5784
+ ctx.globalAlpha = 1;
5785
+ }
5786
+ // The vertical axis is measured in the metric, so its 0..1 heights are worded as figures; the
5787
+ // other two carry the parameter values as they were swept.
5788
+ _tickLabel(axis, label, layout) {
5789
+ if (axis !== "z" || this._data === null) return label;
5790
+ const height = Number(label);
5791
+ return formatPnl(valueAt(height * 2 - 1, layout.scale, this._data.betterWhen));
5792
+ }
5793
+ _axisCaption(axis) {
5794
+ if (this._data === null) return "";
5795
+ if (axis === "x") return this._data.xLabel;
5796
+ if (axis === "y") return this._data.yLabel;
5797
+ return this._data.metricLabel;
5798
+ }
5799
+ // A ring on the vertex under the pointer, so the figure in the strip has a place on the
5800
+ // landscape rather than being a number beside a picture.
5801
+ _drawHover(ctx, palette) {
5802
+ const hover = this._hover;
5803
+ if (hover === null) return;
5804
+ ctx.beginPath();
5805
+ ctx.arc(hover.at[0], hover.at[1], HOVER_RING, 0, Math.PI * 2);
5806
+ ctx.strokeStyle = palette.up;
5807
+ ctx.lineWidth = 2;
5808
+ ctx.globalAlpha = 1;
5809
+ ctx.stroke();
5810
+ }
5811
+ // The frame the surface stands on, so a reader can tell which way the grid runs even when
5812
+ // the landscape hides its own edges.
5813
+ _drawFloor(ctx, layout, colour) {
5814
+ ctx.globalAlpha = FLOOR_ALPHA;
5815
+ ctx.strokeStyle = colour;
5816
+ ctx.lineWidth = EDGE_WIDTH;
5817
+ for (const axis of layout.axes) {
5818
+ ctx.beginPath();
5819
+ ctx.moveTo(axis.from[0], axis.from[1]);
5820
+ ctx.lineTo(axis.to[0], axis.to[1]);
5821
+ ctx.stroke();
5822
+ }
5823
+ ctx.globalAlpha = 1;
5824
+ }
5825
+ // Far to near, each face filled by how good it is and outlined so the grid stays legible
5826
+ // where two faces are nearly the same colour.
5827
+ _drawFaces(ctx, layout, palette) {
5828
+ for (const quad of layout.quads) {
5829
+ ctx.beginPath();
5830
+ ctx.moveTo(quad.points[0][0], quad.points[0][1]);
5831
+ for (const [x, y] of quad.points.slice(1)) ctx.lineTo(x, y);
5832
+ ctx.closePath();
5833
+ ctx.globalAlpha = FACE_ALPHA * Math.max(0.18, Math.abs(quad.tint));
5834
+ ctx.fillStyle = quad.tint >= 0 ? palette.up : palette.down;
5835
+ ctx.fill();
5836
+ ctx.globalAlpha = EDGE_ALPHA;
5837
+ ctx.strokeStyle = palette.grid;
5838
+ ctx.lineWidth = EDGE_WIDTH;
5839
+ ctx.stroke();
5840
+ }
5841
+ ctx.globalAlpha = 1;
5842
+ }
5843
+ };
5844
+ _SurfaceWidget.TYPE = ControlTypes.OptimizationSurface;
5845
+ var SurfaceWidget = _SurfaceWidget;
5846
+
4479
5847
  // src/black-scholes.ts
4480
5848
  var OptionTypes = {
4481
5849
  Call: "call",
@@ -4854,6 +6222,219 @@ var SSTradingControls = (() => {
4854
6222
  return `${(value * 100).toFixed(2)}%`;
4855
6223
  }
4856
6224
 
6225
+ // src/option-smile-widget.ts
6226
+ var LINE_WIDTH2 = 2;
6227
+ function sideVolatility(side) {
6228
+ if (side === void 0 || side === null) return null;
6229
+ const last = side.ivLast;
6230
+ if (typeof last === "number" && isFinite(last) && last > 0) return last;
6231
+ const bid = side.ivBid;
6232
+ const ask = side.ivAsk;
6233
+ const quoted = [bid, ask].filter((v) => typeof v === "number" && isFinite(v) && v > 0);
6234
+ if (quoted.length === 0) return null;
6235
+ return quoted.reduce((sum, v) => sum + v, 0) / quoted.length;
6236
+ }
6237
+ function sortedChain(strikes) {
6238
+ return [...strikes].filter((row) => row !== null && row !== void 0 && isFinite(row.strike)).sort((a, b) => a.strike - b.strike);
6239
+ }
6240
+ function toSmileSeries(chain, put) {
6241
+ const out = [];
6242
+ for (const row of chain) {
6243
+ const volatility = sideVolatility(put ? row.put : row.call);
6244
+ if (volatility === null) out.push({ time: row.strike });
6245
+ else out.push({ time: row.strike, value: volatility * 100 });
6246
+ }
6247
+ return out;
6248
+ }
6249
+ var _OptionSmileWidget = class _OptionSmileWidget {
6250
+ static create(hostEl, state, deps) {
6251
+ const host = assertHost(deps?.host, "OptionSmileWidget");
6252
+ const root = _OptionSmileWidget._buildRoot(host);
6253
+ root.id = makePanelId(_OptionSmileWidget.TYPE);
6254
+ hostEl.appendChild(root);
6255
+ return new _OptionSmileWidget(root, state || {}, deps);
6256
+ }
6257
+ static _buildRoot(host) {
6258
+ const title = host.t("OptionSmile");
6259
+ return makePanelRoot("option-smile-panel", title, [
6260
+ makeElement("div", "panel-header", {}, [
6261
+ makeElement("span", "", {}, [title]),
6262
+ makeIconButton("bt-icon-btn option-smile-reset-btn", host.t("ResetView"), "bi-arrow-clockwise", { type: "button" }),
6263
+ makeIconButton("bt-icon-btn bt-icon-cancel panel-close-btn", host.t("ClosePanel"), "bi-x", { type: "button" })
6264
+ ]),
6265
+ makeElement("div", "panel-body", {}, [
6266
+ makeElement("div", "option-smile-legend", {}, [
6267
+ makeElement("span", "option-smile-axis", {}, [host.t("ImpliedVolatility")]),
6268
+ makeElement("span", "option-smile-key", {}, [
6269
+ makeElement("span", "option-smile-dot option-smile-dot-call", {}, []),
6270
+ host.t("Call")
6271
+ ]),
6272
+ makeElement("span", "option-smile-key", {}, [
6273
+ makeElement("span", "option-smile-dot option-smile-dot-put", {}, []),
6274
+ host.t("Put")
6275
+ ]),
6276
+ makeElement("span", "option-smile-spot", {}, [])
6277
+ ]),
6278
+ makeElement("div", "option-smile-chart", { role: "img", "aria-label": host.t("OptionChain") }, [
6279
+ // Over the chart, not in the panel header: a reading about the point under the
6280
+ // pointer belongs beside that point, and a host that lifts panel headers into a
6281
+ // tab strip would otherwise write it into a tab title.
6282
+ makeElement("div", "option-smile-hover", {}, []),
6283
+ makeElement("div", "option-smile-empty", {}, [host.t("NoOptions")])
6284
+ ])
6285
+ ])
6286
+ ]);
6287
+ }
6288
+ constructor(rootEl, _state, deps) {
6289
+ this._host = assertHost(deps?.host, "OptionSmileWidget");
6290
+ this.rootEl = rootEl;
6291
+ this._closeBtn = this.rootEl.querySelector(".panel-close-btn");
6292
+ this._resetBtn = this.rootEl.querySelector(".option-smile-reset-btn");
6293
+ this._emptyEl = this.rootEl.querySelector(".option-smile-empty");
6294
+ this._chartEl = this.rootEl.querySelector(".option-smile-chart");
6295
+ this._spotEl = this.rootEl.querySelector(".option-smile-spot");
6296
+ this._hoverEl = this.rootEl.querySelector(".option-smile-hover");
6297
+ this._chart = null;
6298
+ this._call = null;
6299
+ this._put = null;
6300
+ this._strikes = [];
6301
+ this._context = {};
6302
+ this._resizeObserver = null;
6303
+ this._closeBtn?.addEventListener("click", (e) => {
6304
+ e.preventDefault();
6305
+ this._host.close();
6306
+ });
6307
+ this._resetBtn?.addEventListener("click", (e) => {
6308
+ e.preventDefault();
6309
+ this.resetZoom();
6310
+ });
6311
+ if (this._chartEl !== null && typeof ResizeObserver !== "undefined") {
6312
+ this._resizeObserver = new ResizeObserver(() => this._fit());
6313
+ this._resizeObserver.observe(this._chartEl);
6314
+ }
6315
+ this._render();
6316
+ this._host.register(this);
6317
+ }
6318
+ dispose() {
6319
+ try {
6320
+ this._resizeObserver?.disconnect();
6321
+ } catch {
6322
+ }
6323
+ this._resizeObserver = null;
6324
+ try {
6325
+ this._chart?.remove();
6326
+ } catch {
6327
+ }
6328
+ this._chart = null;
6329
+ this._call = null;
6330
+ this._put = null;
6331
+ this._host.unregister(this);
6332
+ try {
6333
+ this.rootEl.remove();
6334
+ } catch {
6335
+ }
6336
+ }
6337
+ /// Show this chain, priced against this context.
6338
+ ///
6339
+ /// The desk's two arguments unchanged, and for the same reason it takes them together: they
6340
+ /// are one observation. A smile marked with a spot the curves never saw is two moments.
6341
+ update(strikes, context = {}) {
6342
+ this._strikes = strikes || [];
6343
+ this._context = context || {};
6344
+ this._render();
6345
+ }
6346
+ /// Show the whole chain again, after a zoom.
6347
+ resetZoom() {
6348
+ try {
6349
+ this._chart?.timeScale().fitContent();
6350
+ } catch {
6351
+ }
6352
+ }
6353
+ /// The engine, for a host that wants to add to this chart - a second expiry, a marker.
6354
+ chart() {
6355
+ return this._chart;
6356
+ }
6357
+ _ensureChart() {
6358
+ if (this._chart !== null) return true;
6359
+ if (this._chartEl === null) return false;
6360
+ const palette = this._host.presentation.canvasPalette();
6361
+ this._chart = createChart(this._chartEl, {
6362
+ layout: {
6363
+ background: { type: "solid", color: "transparent" },
6364
+ textColor: palette.grid,
6365
+ fontFamily: palette.font,
6366
+ fontSize: 11,
6367
+ attributionLogo: false
6368
+ },
6369
+ grid: { vertLines: { color: palette.grid }, horzLines: { color: palette.grid } },
6370
+ crosshair: { mode: CrosshairMode.Normal },
6371
+ rightPriceScale: { borderColor: palette.grid },
6372
+ timeScale: {
6373
+ borderColor: palette.grid,
6374
+ // Evenly spaced by listing, and worded as strikes: the axis carries the strike
6375
+ // itself, so the label and the value can never drift apart.
6376
+ mode: "ordinal",
6377
+ formatter: (value) => formatPrice(Number(value))
6378
+ }
6379
+ });
6380
+ this._call = this._chart.addSeries(LineSeries, { lineWidth: LINE_WIDTH2, color: palette.up });
6381
+ this._put = this._chart.addSeries(LineSeries, { lineWidth: LINE_WIDTH2, color: palette.down });
6382
+ this._chart.subscribeCrosshairMove((param) => this._renderHover(param));
6383
+ return true;
6384
+ }
6385
+ _render() {
6386
+ const chain = sortedChain(this._strikes);
6387
+ const quoted = chain.some((row) => sideVolatility(row.call) !== null || sideVolatility(row.put) !== null);
6388
+ if (this._emptyEl !== null) this._emptyEl.hidden = quoted;
6389
+ this._renderSpot();
6390
+ if (!quoted) return;
6391
+ if (!this._ensureChart() || this._call === null || this._put === null) return;
6392
+ this._call.setData(toSmileSeries(chain, false));
6393
+ this._put.setData(toSmileSeries(chain, true));
6394
+ this._fit();
6395
+ }
6396
+ _fit() {
6397
+ if (this._chart === null || this._chartEl === null) return;
6398
+ const rect = this._chartEl.getBoundingClientRect();
6399
+ if (rect.width <= 0 || rect.height <= 0) return;
6400
+ try {
6401
+ this._chart.resize(rect.width, rect.height);
6402
+ } catch {
6403
+ }
6404
+ }
6405
+ // Where the money is. Not a line on the chart: an ordinal axis has a rung per listed strike
6406
+ // and the spot is between two of them, so it is stated rather than drawn at a place the
6407
+ // ladder does not have.
6408
+ _renderSpot() {
6409
+ const el = this._spotEl;
6410
+ if (el === null) return;
6411
+ const spot = this._context.assetPrice;
6412
+ if (typeof spot !== "number" || !isFinite(spot)) {
6413
+ el.textContent = "";
6414
+ return;
6415
+ }
6416
+ el.textContent = `${this._host.t("Underlying")} ${formatPrice(spot)}`;
6417
+ }
6418
+ // What the pointer is over: the strike and both sides at it. Both, because a smile is read by
6419
+ // the distance between the two curves - a readout naming one of them answers half of it.
6420
+ _renderHover(param) {
6421
+ const el = this._hoverEl;
6422
+ if (el === null) return;
6423
+ if (param.time === null) {
6424
+ el.textContent = "";
6425
+ return;
6426
+ }
6427
+ const at = (series) => {
6428
+ const point = series === null ? void 0 : param.seriesData.get(series);
6429
+ const value = point?.value;
6430
+ return typeof value === "number" && isFinite(value) ? `${value.toFixed(2)}%` : "--";
6431
+ };
6432
+ el.textContent = `${formatPrice(Number(param.time))} ${at(this._call)} / ${at(this._put)}`;
6433
+ }
6434
+ };
6435
+ _OptionSmileWidget.TYPE = ControlTypes.OptionSmile;
6436
+ var OptionSmileWidget = _OptionSmileWidget;
6437
+
4857
6438
  // src/strategies-widget.ts
4858
6439
  var StrategyStates = {
4859
6440
  Stopped: "stopped",
@@ -5460,160 +7041,6 @@ var SSTradingControls = (() => {
5460
7041
  }
5461
7042
  }
5462
7043
 
5463
- // src/statistics-widget.ts
5464
- function rank(rows) {
5465
- const first = /* @__PURE__ */ new Map();
5466
- for (const row of rows) {
5467
- const seen = first.get(row.category);
5468
- if (seen === void 0 || row.order < seen) first.set(row.category, row.order);
5469
- }
5470
- return rows.map((row) => ({ ...row, categoryRank: first.get(row.category) ?? row.order }));
5471
- }
5472
- var _StatisticsWidget = class _StatisticsWidget {
5473
- static create(hostEl, state, deps) {
5474
- const host = assertHost(deps?.host, "StatisticsWidget");
5475
- const root = _StatisticsWidget._buildRoot(host);
5476
- root.id = makePanelId(_StatisticsWidget.TYPE);
5477
- hostEl.appendChild(root);
5478
- return new _StatisticsWidget(root, state || {}, deps);
5479
- }
5480
- // The panel's markup. No rail action but the export: a statistic is produced by the
5481
- // strategy and there is nothing here to cancel, reload or edit.
5482
- static _buildRoot(host) {
5483
- const title = host.t("Statistics");
5484
- return makePanelRoot("statistics-panel", title, [
5485
- makeElement("div", "panel-header", {}, [
5486
- makeElement("span", "", {}, [title]),
5487
- makeIconButton("bt-icon-btn bt-icon-cancel panel-close-btn", host.t("ClosePanel"), "bi-x", { type: "button" })
5488
- ]),
5489
- makeElement("div", "panel-body panel-body-with-rail", {}, [
5490
- makeElement("div", "panel-body-content", {}, [
5491
- makeElement("table", "terminal-table statistics-table", { role: "table", "aria-label": host.t("StatisticsList") }, [
5492
- makeElement("thead", "", {}, []),
5493
- makeElement("tbody", "statistics-body", {}, [])
5494
- ])
5495
- ]),
5496
- makeElement("div", "panel-rail", { role: "toolbar", "aria-label": host.t("StatisticsActions") }, [
5497
- makeIconButton("bt-icon-btn panel-export-btn", host.t("ExportToExcel"), "bi-file-earmark-spreadsheet", {})
5498
- ])
5499
- ])
5500
- ]);
5501
- }
5502
- constructor(rootEl, _state, deps) {
5503
- this._host = assertHost(deps?.host, "StatisticsWidget");
5504
- this.rootEl = rootEl;
5505
- this.el = this.rootEl.querySelector(".statistics-body");
5506
- this._closeBtn = this.rootEl.querySelector(".panel-close-btn");
5507
- this._exportBtn = this.rootEl.querySelector(".panel-export-btn");
5508
- this._rows = [];
5509
- this._closeBtn?.addEventListener("click", (e) => {
5510
- e.preventDefault();
5511
- this._host.close();
5512
- });
5513
- this._exportBtn?.addEventListener("click", (e) => {
5514
- e.preventDefault();
5515
- this._export();
5516
- });
5517
- const head = this.rootEl.querySelector(".statistics-table thead");
5518
- this._grid = head && this.el ? new DataGrid({
5519
- head,
5520
- body: this.el,
5521
- columns: this._columns(),
5522
- // Not a sort the reader chose: the registry hands every parameter an order, and
5523
- // the bands it assigns are what puts profit above drawdown above trade counts.
5524
- defaultSort: { col: "order", dir: "asc" },
5525
- rowKey: (r) => String(r.key),
5526
- emptyText: this._host.t("NoStatistics"),
5527
- contextMenu: makeGridMenu(this._host),
5528
- // The groups sit where the registry order puts them, not where the
5529
- // alphabet would: profit, then trades, then positions, then orders.
5530
- groupOrder: "rows",
5531
- selection: "multi"
5532
- }) : null;
5533
- this._grid?.setState({ group: "category", hidden: ["category", "order"] });
5534
- this._host.register(this);
5535
- }
5536
- dispose() {
5537
- this._grid?.destroy();
5538
- this._host.unregister(this);
5539
- try {
5540
- this.rootEl.remove();
5541
- } catch {
5542
- }
5543
- }
5544
- /// Show these statistics. The whole set at once: a strategy publishes its parameters as one
5545
- /// table and a row that vanished from it has stopped existing, not stopped changing.
5546
- update(rows) {
5547
- this._rows = rank(rows || []);
5548
- this._grid?.setRows(this._rows);
5549
- }
5550
- _export() {
5551
- this._grid?.download("statistics", this._host.t("Statistics"));
5552
- }
5553
- // Two visible columns and one that only groups. The order column is hidden as well: it
5554
- // decides the sort and means nothing to a reader.
5555
- _columns() {
5556
- const label = (key) => this._host.t(key);
5557
- return [
5558
- {
5559
- key: "category",
5560
- header: label("Category"),
5561
- exportable: false,
5562
- // The group's place, not its name: groups are laid out in the order the
5563
- // registry gives their parameters, so profit comes before trade counts.
5564
- // Grouping on the name would order them alphabetically, and on the
5565
- // translated name would reorder them with the reader's language.
5566
- value: (r) => r.categoryRank,
5567
- text: (r) => r.categoryText || r.category
5568
- },
5569
- {
5570
- key: "order",
5571
- header: label("Order"),
5572
- exportable: false,
5573
- value: (r) => r.order
5574
- },
5575
- {
5576
- key: "name",
5577
- header: label("Name"),
5578
- exportable: true,
5579
- value: (r) => r.name,
5580
- bindCell: (td, r) => {
5581
- if (r.description) td.setAttribute("title", r.description);
5582
- }
5583
- },
5584
- {
5585
- key: "value",
5586
- header: label("Value"),
5587
- exportable: true,
5588
- cellClass: () => "statistic-value",
5589
- // Sorts on the raw value so a number sorts as a number, reads as the text below.
5590
- value: (r) => r.value,
5591
- render: (r) => formatStatistic(r.value),
5592
- exportValue: (r) => r.value ?? ""
5593
- }
5594
- ];
5595
- }
5596
- };
5597
- _StatisticsWidget.TYPE = ControlTypes.Statistics;
5598
- var StatisticsWidget = _StatisticsWidget;
5599
- function formatStatistic(value) {
5600
- if (value === null || value === void 0 || value === "") return "";
5601
- if (typeof value === "number") {
5602
- if (!isFinite(value)) return "";
5603
- return String(Math.round(value * 100) / 100);
5604
- }
5605
- if (value instanceof Date) return isoDate(value);
5606
- if (typeof value === "string") {
5607
- const at = /^\d{4}-\d{2}-\d{2}([T ]|$)/.test(value) ? new Date(value) : null;
5608
- if (at !== null && !isNaN(at.getTime())) return isoDate(at);
5609
- return value;
5610
- }
5611
- return String(value);
5612
- }
5613
- function isoDate(at) {
5614
- return at.toISOString().slice(0, 10);
5615
- }
5616
-
5617
7044
  // src/tradefeed-aggregator.ts
5618
7045
  function aggregateBubbles(ticks, maxBucketsPerSide) {
5619
7046
  if (!ticks || ticks.length === 0) return [];
@@ -5690,7 +7117,7 @@ var SSTradingControls = (() => {
5690
7117
  symbol: lane.symbol,
5691
7118
  label: named ? { x: LABEL_GAP, y: laneTop + 2 } : null,
5692
7119
  separatorY: named && position > 0 ? laneTop - LANE_GAP / 2 : null,
5693
- ticks: lane.ticks.length === 0 ? [] : axisTicks(scale)
7120
+ ticks: lane.ticks.length === 0 ? [] : axisTicks2(scale)
5694
7121
  });
5695
7122
  if (lane.ticks.length === 0) return;
5696
7123
  const buckets = Math.max(MIN_BUCKETS, Math.min(MAX_BUCKETS, Math.floor(drawWidth / BUCKET_WIDTH)));
@@ -5725,7 +7152,7 @@ var SSTradingControls = (() => {
5725
7152
  }
5726
7153
  return { top, height, min, range: Math.max(max - min, Number.EPSILON) };
5727
7154
  }
5728
- function axisTicks(scale) {
7155
+ function axisTicks2(scale) {
5729
7156
  const format = priceFormatter(scale.min, scale.min + scale.range);
5730
7157
  const ticks = [];
5731
7158
  for (let step = 0; step < AXIS_TICKS; step++) {
@@ -5752,12 +7179,12 @@ var SSTradingControls = (() => {
5752
7179
  // src/tradefeed-widget.ts
5753
7180
  var FILL_ALPHA = 0.55;
5754
7181
  var STROKE_ALPHA = 0.9;
5755
- var LABEL_ALPHA = 0.55;
7182
+ var LABEL_ALPHA3 = 0.55;
5756
7183
  var SEPARATOR_ALPHA = 0.12;
5757
7184
  var RULE_ALPHA = 0.08;
5758
7185
  var HOVER_SLACK = 6;
5759
- var TOOLTIP_OFFSET = 12;
5760
- var TOOLTIP_MARGIN = 4;
7186
+ var TOOLTIP_OFFSET2 = 12;
7187
+ var TOOLTIP_MARGIN2 = 4;
5761
7188
  var _TradeFeedWidget = class _TradeFeedWidget {
5762
7189
  static create(hostEl, state, deps) {
5763
7190
  const host = assertHost(deps?.host, "TradeFeedWidget");
@@ -6223,7 +7650,7 @@ var SSTradingControls = (() => {
6223
7650
  _TradeFeedWidget._rule(ctx, 0, size.width, lane.separatorY);
6224
7651
  }
6225
7652
  if (lane.label) {
6226
- ctx.globalAlpha = LABEL_ALPHA;
7653
+ ctx.globalAlpha = LABEL_ALPHA3;
6227
7654
  ctx.fillStyle = palette.grid;
6228
7655
  ctx.textBaseline = "top";
6229
7656
  ctx.textAlign = "left";
@@ -6233,7 +7660,7 @@ var SSTradingControls = (() => {
6233
7660
  ctx.globalAlpha = RULE_ALPHA;
6234
7661
  ctx.strokeStyle = palette.grid;
6235
7662
  _TradeFeedWidget._rule(ctx, 0, layout.axisX - 2, tick.y);
6236
- ctx.globalAlpha = LABEL_ALPHA;
7663
+ ctx.globalAlpha = LABEL_ALPHA3;
6237
7664
  ctx.fillStyle = palette.grid;
6238
7665
  ctx.textBaseline = "middle";
6239
7666
  ctx.textAlign = "left";
@@ -6309,10 +7736,10 @@ var SSTradingControls = (() => {
6309
7736
  const parentRect = tooltip.offsetParent?.getBoundingClientRect() ?? canvasRect;
6310
7737
  const originX = canvasRect.left - parentRect.left;
6311
7738
  const originY = canvasRect.top - parentRect.top;
6312
- const maxX = originX + canvasRect.width - tooltip.offsetWidth - TOOLTIP_MARGIN;
6313
- const maxY = originY + canvasRect.height - tooltip.offsetHeight - TOOLTIP_MARGIN;
6314
- tooltip.style.left = `${Math.max(0, Math.min(originX + x + TOOLTIP_OFFSET, maxX))}px`;
6315
- tooltip.style.top = `${Math.max(0, Math.min(originY + y + TOOLTIP_OFFSET, maxY))}px`;
7739
+ const maxX = originX + canvasRect.width - tooltip.offsetWidth - TOOLTIP_MARGIN2;
7740
+ const maxY = originY + canvasRect.height - tooltip.offsetHeight - TOOLTIP_MARGIN2;
7741
+ tooltip.style.left = `${Math.max(0, Math.min(originX + x + TOOLTIP_OFFSET2, maxX))}px`;
7742
+ tooltip.style.top = `${Math.max(0, Math.min(originY + y + TOOLTIP_OFFSET2, maxY))}px`;
6316
7743
  }
6317
7744
  _tooltipLine(label, value, valueClass) {
6318
7745
  const row = makeElement("div", "tf-bbtt-row", {}, [