Drag-to-pan the P/L chart price range

Hold the left mouse button on the chart and drag left/right to slide the
underlying-price window; the curve is re-sampled as you drag so it always
extends to the edges. Fit / switching symbols / clear-all re-centre and
reset zoom.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ojy
2026-05-13 05:03:49 +00:00
parent 6ffde10391
commit 03afae3d04

View File

@@ -116,7 +116,9 @@
</div> </div>
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
<div id="plChart" style="min-height:380px;" role="img" aria-label="Profit and loss diagram"></div> <div id="plChart" :style="'min-height:380px;user-select:none;cursor:' + (_drag ? 'grabbing' : 'grab')"
@mousedown="startPan($event)" @mousemove.window="onPan($event)" @mouseup.window="endPan()" @mouseleave.window="endPan()"
role="img" aria-label="Profit and loss diagram (drag left/right to pan the price range)"></div>
</div> </div>
</div> </div>
@@ -315,7 +317,8 @@
// state // state
symbol: '', symbols: [], spot: 0, legs: [], symbol: '', symbols: [], spot: 0, legs: [],
dteOffset: 0, dteOffset: 0,
xZoom: 1, lastHalfPct: 0, xZoom: 1, xPan: 0, lastHalfPct: 0,
_drag: null,
refreshing: false, showManual: false, toast: '', refreshing: false, showManual: false, toast: '',
manual: { side:'long', type:'call', qty:1, strike:null, expiry:'', entryPrice:null, ivPct:null }, manual: { side:'long', type:'call', qty:1, strike:null, expiry:'', entryPrice:null, ivPct:null },
chart: null, _renderTimer: null, chart: null, _renderTimer: null,
@@ -385,7 +388,7 @@
switchSymbol(sym) { switchSymbol(sym) {
if (!sym || sym === this.symbol) return; if (!sym || sym === this.symbol) return;
StrategyStore.setActive(sym); StrategyStore.setActive(sym);
this.dteOffset = 0; this.dteOffset = 0; this.xPan = 0; this.xZoom = 1;
this.reload(); this.reload();
if (this.legs.length > 0) this.reloadMarket(false); if (this.legs.length > 0) this.reloadMarket(false);
}, },
@@ -499,7 +502,7 @@
clearAll() { clearAll() {
if (!confirm('Clear all ' + (this.symbol || '') + ' legs?')) return; if (!confirm('Clear all ' + (this.symbol || '') + ' legs?')) return;
StrategyStore.clear(); StrategyStore.clear();
this.dteOffset = 0; this.dteOffset = 0; this.xPan = 0; this.xZoom = 1;
if (this.chart) { this.chart.destroy(); this.chart = null; } if (this.chart) { this.chart.destroy(); this.chart = null; }
this.reload(); // re-renders the chart if another symbol's basket is now active this.reload(); // re-renders the chart if another symbol's basket is now active
}, },
@@ -583,7 +586,23 @@
}, },
zoomIn() { this.xZoom = Math.max(0.2, +(this.xZoom / 1.4).toFixed(3)); this.renderChart(); }, zoomIn() { this.xZoom = Math.max(0.2, +(this.xZoom / 1.4).toFixed(3)); this.renderChart(); },
zoomOut() { this.xZoom = Math.min(8, +(this.xZoom * 1.4).toFixed(3)); this.renderChart(); }, zoomOut() { this.xZoom = Math.min(8, +(this.xZoom * 1.4).toFixed(3)); this.renderChart(); },
zoomFit() { this.xZoom = 1; this.renderChart(); }, zoomFit() { this.xZoom = 1; this.xPan = 0; this.renderChart(); },
// click-and-drag to pan the price window
startPan(e) {
if (e.button !== 0) return;
const pw = (this._lastPlotW && this._lastPlotW > 50) ? this._lastPlotW
: ((document.getElementById('plChart')?.clientWidth || 600) - 70);
this._drag = { x: e.clientX, pan: this.xPan || 0, half: this._lastHalf || ((this.spot || 100) * 0.22), pw };
e.preventDefault();
},
onPan(e) {
if (!this._drag) return;
const dx = e.clientX - this._drag.x; // drag right -> show lower prices
this.xPan = this._drag.pan - (dx / this._drag.pw) * (2 * this._drag.half);
this.scheduleRender();
},
endPan() { if (this._drag) this._drag = null; },
renderChart() { renderChart() {
const legs = this.activeLegs, net = this.netCost; const legs = this.activeLegs, net = this.netCost;
@@ -597,8 +616,10 @@
const span = maxK - minK; const span = maxK - minK;
const baseHalf = Math.max(spot*0.22, span*1.5, (maxK-spot)*1.4, (spot-minK)*1.4, 5); const baseHalf = Math.max(spot*0.22, span*1.5, (maxK-spot)*1.4, (spot-minK)*1.4, 5);
const half = baseHalf * (this.xZoom || 1); const half = baseHalf * (this.xZoom || 1);
const lo = Math.max(0.01, spot - half), hi = spot + half; const center = spot + (this.xPan || 0);
const lo = Math.max(0.01, center - half), hi = center + half;
this.lastHalfPct = spot > 0 ? Math.round(half / spot * 100) : 0; this.lastHalfPct = spot > 0 ? Math.round(half / spot * 100) : 0;
this._lastHalf = half; this._lastSpot = spot;
const expAt = this.minDTE; const expAt = this.minDTE;
const N = 221; const N = 221;
// x-grid = evenly spaced points + the exact strike kinks (so payoff // x-grid = evenly spaced points + the exact strike kinks (so payoff
@@ -690,6 +711,11 @@
this.chart = new ApexCharts(document.getElementById('plChart'), opts); this.chart = new ApexCharts(document.getElementById('plChart'), opts);
this.chart.render(); this.chart.render();
} }
// remember the plot-area width for drag-to-pan math
try {
const gw = this.chart && this.chart.w && this.chart.w.globals && this.chart.w.globals.gridWidth;
this._lastPlotW = (gw && gw > 50) ? gw : ((document.getElementById('plChart')?.clientWidth || 600) - 70);
} catch {}
}, },
fmtMoney, fmtMoney,