@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.
- package/README.md +113 -5
- package/dist/esm/chart-engine.js +15 -0
- package/dist/esm/chart-engine.js.map +1 -0
- package/dist/esm/control-types.js +4 -0
- package/dist/esm/control-types.js.map +1 -1
- package/dist/esm/equity-widget.js +261 -0
- package/dist/esm/equity-widget.js.map +1 -0
- package/dist/esm/heatmap-grid.js +271 -0
- package/dist/esm/heatmap-grid.js.map +1 -0
- package/dist/esm/index.js +19 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/optimization-heatmap-widget.js +303 -0
- package/dist/esm/optimization-heatmap-widget.js.map +1 -0
- package/dist/esm/option-smile-widget.js +252 -0
- package/dist/esm/option-smile-widget.js.map +1 -0
- package/dist/esm/pnl-curve.js +5 -1
- package/dist/esm/pnl-curve.js.map +1 -1
- package/dist/esm/surface-grid.js +282 -0
- package/dist/esm/surface-grid.js.map +1 -0
- package/dist/esm/surface-widget.js +379 -0
- package/dist/esm/surface-widget.js.map +1 -0
- package/dist/sstradingcontrols.js +1595 -168
- package/dist/sstradingcontrols.js.map +4 -4
- package/dist/types/chart-engine.d.ts +3 -0
- package/dist/types/chart-engine.d.ts.map +1 -0
- package/dist/types/control-types.d.ts +4 -0
- package/dist/types/control-types.d.ts.map +1 -1
- package/dist/types/equity-widget.d.ts +34 -0
- package/dist/types/equity-widget.d.ts.map +1 -0
- package/dist/types/heatmap-grid.d.ts +88 -0
- package/dist/types/heatmap-grid.d.ts.map +1 -0
- package/dist/types/index.d.ts +15 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/optimization-heatmap-widget.d.ts +45 -0
- package/dist/types/optimization-heatmap-widget.d.ts.map +1 -0
- package/dist/types/option-smile-widget.d.ts +39 -0
- package/dist/types/option-smile-widget.d.ts.map +1 -0
- package/dist/types/pnl-curve.d.ts +3 -0
- package/dist/types/pnl-curve.d.ts.map +1 -1
- package/dist/types/surface-grid.d.ts +64 -0
- package/dist/types/surface-grid.d.ts.map +1 -0
- package/dist/types/surface-widget.d.ts +64 -0
- package/dist/types/surface-widget.d.ts.map +1 -0
- package/package.json +28 -2
- package/screenshots/equity.png +0 -0
- package/screenshots/optimization-surface.png +0 -0
- package/screenshots/optimization.png +0 -0
- package/screenshots/option-smile.png +0 -0
- package/screenshots/panels.jpg +0 -0
- package/src/chart-engine.global.ts +64 -0
- package/src/chart-engine.ts +29 -0
- package/src/control-types.ts +4 -0
- package/src/equity-widget.ts +289 -0
- package/src/heatmap-grid.ts +405 -0
- package/src/index.ts +46 -0
- package/src/optimization-heatmap-widget.ts +347 -0
- package/src/option-smile-widget.ts +281 -0
- package/src/pnl-curve.ts +13 -1
- package/src/surface-grid.ts +410 -0
- package/src/surface-widget.ts +457 -0
- package/styles/trading-controls.css +281 -0
- 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
|
-
|
|
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
|
-
|
|
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 ? [] :
|
|
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
|
|
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
|
|
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
|
|
5760
|
-
var
|
|
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 =
|
|
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 =
|
|
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 -
|
|
6313
|
-
const maxY = originY + canvasRect.height - tooltip.offsetHeight -
|
|
6314
|
-
tooltip.style.left = `${Math.max(0, Math.min(originX + x +
|
|
6315
|
-
tooltip.style.top = `${Math.max(0, Math.min(originY + y +
|
|
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", {}, [
|