viewof volumeOpenInterestLab = {
const { html } = htl
const container = html`<div class="voi-lab"></div>`
const style = html`<style>
.voi-lab { display: grid; gap: 1.5rem; }
.voi-counter-row { display: flex; flex-wrap: wrap; gap: 1rem; }
.voi-counter-card { flex: 1 1 180px; background: #f7f8fd; border: 1px solid #d8dff0; border-radius: 12px; padding: 1rem 1.25rem; box-shadow: 0 1px 2px rgba(31, 42, 72, 0.12); transition: transform 0.2s ease, border-color 0.2s ease; }
.voi-counter-card--flash { animation: voiFlash 0.65s ease; }
@keyframes voiFlash { 0% { transform: translateY(0); border-color: #d8dff0; } 40% { transform: translateY(-2px) scale(1.01); border-color: #4c74ff; } 100% { transform: translateY(0); border-color: #d8dff0; } }
.voi-counter-label { font-size: 0.9rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: #4c5b70; }
.voi-counter-value { font-size: 2.1rem; font-weight: 700; color: #16213d; line-height: 1.2; }
.voi-counter-delta { margin-top: 0.35rem; font-size: 0.95rem; font-weight: 600; }
.voi-counter-delta[data-state="positive"] { color: #1d6f42; }
.voi-counter-delta[data-state="negative"] { color: #b71c1c; }
.voi-counter-delta[data-state="neutral"] { color: #5c6f82; }
.voi-controls { display: grid; gap: 0.6rem; }
.voi-controls-title { font-size: 0.95rem; font-weight: 600; color: #354157; text-transform: uppercase; letter-spacing: 0.05em; }
.voi-scenario-buttons { display: grid; gap: 0.75rem; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); }
.voi-scenario-button { border: 1px solid #c8d2ef; border-radius: 10px; padding: 0.9rem 1rem; background: #ffffff; font-size: 0.98rem; text-align: left; cursor: pointer; transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease; }
.voi-scenario-button:hover { transform: translateY(-1px); box-shadow: 0 6px 12px rgba(31, 42, 72, 0.12); }
.voi-scenario-button.is-active { border-color: #4c74ff; box-shadow: 0 0 0 2px rgba(76, 116, 255, 0.2); }
.voi-stage { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1rem; align-items: center; padding: 1.25rem 1.5rem; background: #ffffff; border: 1px solid #d8dff0; border-radius: 14px; box-shadow: 0 10px 18px rgba(39, 49, 86, 0.12); }
.voi-trader-card { background: #f3f5fb; border: 1px solid #d4def7; border-radius: 12px; padding: 1rem; min-height: 140px; display: grid; gap: 0.4rem; transition: transform 0.2s ease; }
.voi-trader-card--pulse { animation: voiTraderPulse 0.9s ease; }
@keyframes voiTraderPulse { 0% { transform: scale(1); } 40% { transform: scale(1.03); } 100% { transform: scale(1); } }
.voi-trader-header { font-size: 0.85rem; font-weight: 700; color: #3c4b66; text-transform: uppercase; letter-spacing: 0.06em; }
.voi-trader-heading { font-size: 1.1rem; font-weight: 700; color: #16213d; }
.voi-trader-detail { font-size: 0.95rem; color: #445265; line-height: 1.4; }
.voi-contract-lane { display: grid; justify-items: center; gap: 0.85rem; }
.voi-contract-chip { padding: 0.55rem 1.25rem; border-radius: 999px; background: linear-gradient(135deg, #304ffe, #7a8cff); color: #ffffff; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; box-shadow: 0 10px 16px rgba(48, 79, 254, 0.32); }
.voi-delta-row { display: flex; flex-direction: column; gap: 0.4rem; align-items: center; }
.voi-delta-pill { font-size: 0.88rem; font-weight: 600; padding: 0.25rem 0.75rem; border-radius: 999px; border: 1px solid #cad4ef; color: #405070; background: #f7f8fd; }
.voi-delta-pill[data-state="positive"] { border-color: #3aa35c; color: #1d6f42; background: #e5f4ea; }
.voi-delta-pill[data-state="negative"] { border-color: #dc6a6a; color: #b71c1c; background: #fde8e8; }
.voi-delta-pill[data-state="neutral"] { border-color: #cad4ef; color: #5c6f82; background: #f7f8fd; }
.voi-caption { font-size: 1rem; color: #3b465c; line-height: 1.6; }
.voi-reset-row { display: flex; justify-content: flex-end; }
.voi-reset-button { border: 1px solid #c8d2ef; background: #ffffff; border-radius: 999px; padding: 0.5rem 1.2rem; font-weight: 600; cursor: pointer; transition: background 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease; }
.voi-reset-button:hover { background: #f0f3ff; box-shadow: 0 6px 16px rgba(39, 49, 86, 0.12); transform: translateY(-1px); }
.voi-reset-button:disabled, .voi-scenario-button:disabled { cursor: not-allowed; opacity: 0.65; box-shadow: none; transform: none; }
@media (max-width: 780px) {
.voi-stage { grid-template-columns: 1fr; }
.voi-contract-lane { order: -1; }
.voi-counter-row { flex-direction: column; }
.voi-reset-row { justify-content: center; }
}
</style>`
container.append(style)
const state = { volume: 0, openInterest: 0 }
let isAnimating = false
const counterRow = html`<div class="voi-counter-row"></div>`
const makeCounterCard = title => {
const card = html`<div class="voi-counter-card">
<div class="voi-counter-label">${title}</div>
<div class="voi-counter-value">0</div>
<div class="voi-counter-delta" data-state="neutral">Change 0</div>
</div>`
return {
card,
valueEl: card.querySelector(".voi-counter-value"),
deltaEl: card.querySelector(".voi-counter-delta")
}
}
const volumeCard = makeCounterCard("Trading Volume")
const openInterestCard = makeCounterCard("Open Interest")
counterRow.append(volumeCard.card, openInterestCard.card)
const controls = html`<div class="voi-controls"></div>`
const controlsTitle = html`<div class="voi-controls-title">Pick a trade flow:</div>`
const buttonGroup = html`<div class="voi-scenario-buttons"></div>`
controls.append(controlsTitle, buttonGroup)
const stage = html`<div class="voi-stage">
<div class="voi-trader-card voi-trader-card--buyer">
<div class="voi-trader-header">Buyer</div>
<div class="voi-trader-heading">Waiting...</div>
<div class="voi-trader-detail">Choose a scenario to begin.</div>
</div>
<div class="voi-contract-lane">
<div class="voi-contract-chip">Contract</div>
<div class="voi-delta-row">
<span class="voi-delta-pill" data-metric="volume" data-state="neutral">Volume change 0</span>
<span class="voi-delta-pill" data-metric="openInterest" data-state="neutral">Open Interest change 0</span>
</div>
</div>
<div class="voi-trader-card voi-trader-card--seller">
<div class="voi-trader-header">Seller</div>
<div class="voi-trader-heading">Waiting...</div>
<div class="voi-trader-detail">Choose a scenario to begin.</div>
</div>
</div>`
const buyerHeading = stage.querySelector(".voi-trader-card--buyer .voi-trader-heading")
const buyerDetail = stage.querySelector(".voi-trader-card--buyer .voi-trader-detail")
const sellerHeading = stage.querySelector(".voi-trader-card--seller .voi-trader-heading")
const sellerDetail = stage.querySelector(".voi-trader-card--seller .voi-trader-detail")
const contractChip = stage.querySelector(".voi-contract-chip")
const deltaPills = {
volume: stage.querySelector('.voi-delta-pill[data-metric="volume"]'),
openInterest: stage.querySelector('.voi-delta-pill[data-metric="openInterest"]')
}
const caption = html`<div class="voi-caption">Click a scenario to see how the tallies respond.</div>`
const resetRow = html`<div class="voi-reset-row"></div>`
const resetButton = html`<button class="voi-reset-button" type="button">Reset counters</button>`
resetRow.append(resetButton)
container.append(counterRow, controls, stage, caption, resetRow)
const buttonsByScenario = new Map()
const formatDeltaText = (label, value) => `${label} change ${value > 0 ? "+" : value < 0 ? "" : ""}${value}`
const updateCounterCard = (card, value, delta) => {
card.valueEl.textContent = value.toLocaleString()
const deltaText = `Change ${delta > 0 ? "+" : delta < 0 ? "" : ""}${delta}`
card.deltaEl.textContent = deltaText
const stateName = delta > 0 ? "positive" : delta < 0 ? "negative" : "neutral"
card.deltaEl.dataset.state = stateName
card.card.classList.remove("voi-counter-card--flash")
void card.card.offsetWidth
card.card.classList.add("voi-counter-card--flash")
}
const updateDeltaPill = (pill, label, value) => {
pill.textContent = formatDeltaText(label, value)
const stateName = value > 0 ? "positive" : value < 0 ? "negative" : "neutral"
pill.dataset.state = stateName
}
const disableControls = flag => {
buttonsByScenario.forEach(btn => { btn.disabled = flag })
resetButton.disabled = flag
}
const resetStage = () => {
buttonsByScenario.forEach(btn => btn.classList.remove("is-active"))
buyerHeading.textContent = "Waiting..."
buyerDetail.textContent = "Choose a scenario to begin."
sellerHeading.textContent = "Waiting..."
sellerDetail.textContent = "Choose a scenario to begin."
caption.textContent = "Click a scenario to see how the tallies respond."
updateDeltaPill(deltaPills.volume, "Volume", 0)
updateDeltaPill(deltaPills.openInterest, "Open Interest", 0)
}
volumeOpenInterestScenarios.forEach(scenario => {
const button = html`<button type="button" class="voi-scenario-button">${scenario.label}</button>`
button.addEventListener("click", () => runScenario(scenario))
buttonGroup.append(button)
buttonsByScenario.set(scenario.key, button)
})
const runScenario = async scenario => {
if (isAnimating) return
isAnimating = true
disableControls(true)
buttonsByScenario.forEach((btn, key) => {
btn.classList.toggle("is-active", key === scenario.key)
})
buyerHeading.textContent = scenario.buyer.heading
buyerDetail.textContent = scenario.buyer.detail
sellerHeading.textContent = scenario.seller.heading
sellerDetail.textContent = scenario.seller.detail
caption.textContent = scenario.narration
stage.querySelectorAll(".voi-trader-card").forEach(card => {
card.classList.remove("voi-trader-card--pulse")
void card.offsetWidth
card.classList.add("voi-trader-card--pulse")
})
contractChip.style.opacity = 1
const transfer = contractChip.animate(
[
{ transform: "translateX(96px) scale(0.92)", opacity: 0.85 },
{ transform: "translateX(0px) scale(1)", opacity: 1 },
{ transform: "translateX(-96px) scale(0.92)", opacity: scenario.openInterestDelta < 0 ? 0.4 : 0.85 }
],
{ duration: 1100, easing: "ease-in-out" }
)
await transfer.finished
if (scenario.openInterestDelta < 0) {
await contractChip.animate(
[
{ transform: "translateX(-96px) scale(0.92)", opacity: 0.4 },
{ transform: "translateX(0px) scale(0.9)", opacity: 0 }
],
{ duration: 350, easing: "ease-in", fill: "forwards" }
).finished
await new Promise(resolve => setTimeout(resolve, 220))
contractChip.style.opacity = 1
contractChip.animate(
[
{ transform: "translateX(0px) scale(0.9)", opacity: 0 },
{ transform: "translateX(0px) scale(1)", opacity: 1 }
],
{ duration: 320, easing: "ease-out" }
)
}
const appliedVolumeDelta = scenario.volumeDelta
state.volume += appliedVolumeDelta
const previousOpenInterest = state.openInterest
const tentativeOpenInterest = previousOpenInterest + scenario.openInterestDelta
state.openInterest = Math.max(0, tentativeOpenInterest)
const appliedOpenInterestDelta = state.openInterest - previousOpenInterest
updateCounterCard(volumeCard, state.volume, appliedVolumeDelta)
updateCounterCard(openInterestCard, state.openInterest, appliedOpenInterestDelta)
updateDeltaPill(deltaPills.volume, "Volume", appliedVolumeDelta)
updateDeltaPill(deltaPills.openInterest, "Open Interest", appliedOpenInterestDelta)
disableControls(false)
isAnimating = false
}
resetButton.addEventListener("click", () => {
if (isAnimating) return
state.volume = 0
state.openInterest = 0
updateCounterCard(volumeCard, state.volume, 0)
updateCounterCard(openInterestCard, state.openInterest, 0)
resetStage()
})
return container
}