Add price-range zoom controls to the P/L chart

−/+ buttons (with a ±% readout) and a Fit button in the chart header widen
or narrow the underlying-price window the curve is sampled over, so you can
inspect a tight range around spot or zoom out to see the full payoff shape.
Bumped sampling density to 221 points.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ojy
2026-05-13 04:49:23 +00:00
parent 703c305cf1
commit 6ecc69df99

View File

@@ -96,15 +96,25 @@
<!-- P/L chart -->
<div class="chart-card mb-3" x-show="legs.length > 0" x-cloak>
<div class="card-header d-flex align-items-center justify-content-between flex-wrap gap-2">
<div class="card-header d-flex align-items-center justify-content-between flex-wrap gap-3">
<h3 class="card-title text-white mb-0">Profit / Loss vs. Underlying Price</h3>
<div class="d-flex align-items-center gap-2" style="min-width:320px;">
<div class="d-flex align-items-center flex-wrap gap-3">
<!-- price-range zoom -->
<div class="btn-group btn-group-sm" role="group" aria-label="Zoom price range">
<button class="btn btn-outline-secondary" @click="zoomOut()" title="Zoom out — wider price range"></button>
<button class="btn btn-outline-secondary" disabled style="min-width:5rem" x-text="'±' + lastHalfPct + '%'"></button>
<button class="btn btn-outline-secondary" @click="zoomIn()" title="Zoom in — narrower price range">+</button>
<button class="btn btn-outline-secondary" @click="zoomFit()" title="Reset zoom">Fit</button>
</div>
<!-- time slider -->
<div class="d-flex align-items-center gap-2" style="min-width:300px;">
<span class="text-secondary small text-nowrap">Now</span>
<input type="range" class="form-range" min="0" :max="maxDTE" step="1" x-model.number="dteOffset" @input="scheduleRender()" style="min-width:160px;">
<input type="range" class="form-range" min="0" :max="maxDTE" step="1" x-model.number="dteOffset" @input="scheduleRender()" style="min-width:150px;">
<span class="text-secondary small text-nowrap">Exp</span>
<span class="badge bg-blue-lt text-nowrap" x-text="dteLabel"></span>
</div>
</div>
</div>
<div class="card-body p-0">
<div id="plChart" style="min-height:380px;" role="img" aria-label="Profit and loss diagram"></div>
</div>
@@ -300,6 +310,7 @@
// state
symbol: '', symbols: [], spot: 0, legs: [],
dteOffset: 0,
xZoom: 1, lastHalfPct: 0,
refreshing: false, showManual: false, toast: '',
manual: { side:'long', type:'call', qty:1, strike:null, expiry:'', entryPrice:null, ivPct:null },
chart: null, _renderTimer: null,
@@ -517,6 +528,9 @@
clearTimeout(this._renderTimer);
this._renderTimer = setTimeout(() => this.renderChart(), 40);
},
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(); },
zoomFit() { this.xZoom = 1; this.renderChart(); },
renderChart() {
const legs = this.activeLegs, net = this.netCost;
@@ -528,10 +542,12 @@
const strikes = legs.map(l=>l.strike);
const minK = Math.min(...strikes), maxK = Math.max(...strikes);
const span = maxK - minK;
const half = 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 lo = Math.max(0.01, spot - half), hi = spot + half;
this.lastHalfPct = spot > 0 ? Math.round(half / spot * 100) : 0;
const expAt = this.minDTE;
const N = 161;
const N = 221;
// x-grid = evenly spaced points + the exact strike kinks (so payoff
// vertices — butterfly peak, condor body, etc. — render sharply)
const xGrid = [];