parityDiagram = {
const { amountUSD, spotRate, domesticRate, foreignRate } = paritySettings
const { usdStart, usdEnd, eurosNow, eurosEnd, forwardRate, usdEndHedged } = parityData
const seed = animationSeed
const container = html`<div style="position: relative; width: 100%; max-width: 760px; margin: 0.75rem auto 0;"></div>`
const width = 760
const height = 360
const svg = DOM.svg(width, height)
svg.setAttribute("viewBox", `0 0 ${width} ${height}`)
svg.style.width = "100%"
svg.style.height = "auto"
svg.style.background = "#ffffff"
svg.style.borderRadius = "16px"
svg.style.boxShadow = "0 12px 28px rgba(15, 23, 42, 0.08)"
svg.style.border = "1px solid #e9ecef"
container.append(svg)
const NS = "http://www.w3.org/2000/svg"
const defs = document.createElementNS(NS, "defs")
const makeMarker = ({ id, color }) => {
const marker = document.createElementNS(NS, "marker")
marker.setAttribute("id", id)
marker.setAttribute("markerWidth", "10")
marker.setAttribute("markerHeight", "7")
marker.setAttribute("refX", "10")
marker.setAttribute("refY", "3.5")
marker.setAttribute("orient", "auto")
marker.setAttribute("markerUnits", "strokeWidth")
const arrow = document.createElementNS(NS, "path")
arrow.setAttribute("d", "M 0 0 L 10 3.5 L 0 7 z")
arrow.setAttribute("fill", color)
marker.append(arrow)
return marker
}
defs.append(makeMarker({ id: "arrow-blue", color: "#1f4f9a" }))
defs.append(makeMarker({ id: "arrow-grey", color: "#868e96" }))
defs.append(makeMarker({ id: "arrow-green", color: "#0ca678" }))
svg.append(defs)
const start = { x: 120, y: 190 }
const topEnd = { x: 620, y: 90 }
const topControl = { x: 320, y: 110 }
const bottomEnd = { x: 620, y: 290 }
const bottomControl = { x: 320, y: 280 }
const hedgeControl = { x: 680, y: 200 }
const makePath = ({ d, color, width = 5, marker, dash = false }) => {
const path = document.createElementNS(NS, "path")
path.setAttribute("d", d)
path.setAttribute("fill", "none")
path.setAttribute("stroke", color)
path.setAttribute("stroke-width", width)
path.setAttribute("stroke-linecap", "round")
path.setAttribute("stroke-linejoin", "round")
if (marker) {
path.setAttribute("marker-end", `url(#${marker})`)
}
if (dash) {
path.setAttribute("stroke-dasharray", "10 6")
}
svg.append(path)
return path
}
const topPath = makePath({
d: `M ${start.x} ${start.y} Q ${topControl.x} ${topControl.y} ${topEnd.x} ${topEnd.y}`,
color: "#1f4f9a",
marker: "arrow-blue"
})
const bottomPath = makePath({
d: `M ${start.x} ${start.y} Q ${bottomControl.x} ${bottomControl.y} ${bottomEnd.x} ${bottomEnd.y}`,
color: "#868e96",
marker: "arrow-grey"
})
const hedgePath = makePath({
d: `M ${bottomEnd.x} ${bottomEnd.y} Q ${hedgeControl.x} ${hedgeControl.y} ${topEnd.x} ${topEnd.y}`,
color: "#0ca678",
marker: "arrow-green"
})
const startCircle = document.createElementNS(NS, "circle")
startCircle.setAttribute("cx", start.x)
startCircle.setAttribute("cy", start.y)
startCircle.setAttribute("r", 18)
startCircle.setAttribute("fill", "#264653")
startCircle.setAttribute("fill-opacity", "0.92")
svg.append(startCircle)
const topCircle = document.createElementNS(NS, "circle")
topCircle.setAttribute("r", 10)
topCircle.setAttribute("fill", "#1f4f9a")
svg.append(topCircle)
const bottomCircle = document.createElementNS(NS, "circle")
bottomCircle.setAttribute("r", 10)
bottomCircle.setAttribute("fill", "#868e96")
svg.append(bottomCircle)
const startPoint = topPath.getPointAtLength(0)
topCircle.setAttribute("cx", startPoint.x)
topCircle.setAttribute("cy", startPoint.y)
bottomCircle.setAttribute("cx", startPoint.x)
bottomCircle.setAttribute("cy", startPoint.y)
const createLabel = ({ x, y, lines, anchor = "start", color = "#212529", weight = 500, size = 14 }) => {
const text = document.createElementNS(NS, "text")
text.setAttribute("x", x)
text.setAttribute("y", y)
text.setAttribute("text-anchor", anchor)
text.setAttribute("fill", color)
text.setAttribute("font-weight", weight)
text.setAttribute("font-size", size)
text.setAttribute("dominant-baseline", "hanging")
text.style.opacity = 0
text.style.transition = "opacity 320ms ease"
const supPattern = /e\^\(([^)]+)\)/g
lines.forEach((line, index) => {
supPattern.lastIndex = 0
const span = document.createElementNS(NS, "tspan")
if (index > 0) {
span.setAttribute("x", x)
}
span.setAttribute("dy", index === 0 ? 0 : "1.2em")
let lastIndex = 0
let match
while ((match = supPattern.exec(line)) !== null) {
if (match.index > lastIndex) {
span.append(document.createTextNode(line.slice(lastIndex, match.index)))
}
span.append(document.createTextNode("e"))
const superscript = document.createElementNS(NS, "tspan")
superscript.setAttribute("baseline-shift", "super")
superscript.setAttribute("font-size", "0.75em")
superscript.textContent = `(${match[1]})`
span.append(superscript)
lastIndex = match.index + match[0].length
}
if (lastIndex < line.length) {
span.append(document.createTextNode(line.slice(lastIndex)))
}
text.append(span)
})
svg.append(text)
return text
}
const setLabelX = (label, x) => {
label.setAttribute("x", x)
label.querySelectorAll("tspan").forEach(span => {
if (span.hasAttribute("x")) {
span.setAttribute("x", x)
}
})
}
const keepLabelWithinBounds = (label, margin = 24) => {
if (typeof label?.getBBox !== "function") return
const bounds = label.getBBox()
const originalX = Number(label.getAttribute("x"))
if (Number.isNaN(originalX)) return
const maxRight = width - margin
const minLeft = margin
let targetX = originalX
if (bounds.x + bounds.width > maxRight) {
targetX -= bounds.x + bounds.width - maxRight
}
if (bounds.x < minLeft) {
targetX += minLeft - bounds.x
}
if (targetX !== originalX) {
setLabelX(label, targetX)
}
}
const startLabel = createLabel({
x: start.x - 40,
y: start.y - 60,
anchor: "start",
lines: [
"Start in USD",
`${formatUSD(usdStart)}`,
"split into two paths"
],
color: "#212529",
weight: 600
})
startLabel.style.opacity = 1
const topLabel = createLabel({
x: topEnd.x - 90,
y: topEnd.y - 60,
anchor: "end",
lines: [
"Invest in 🇺🇸",
`${formatUSD(usdStart)} × e^(${(domesticRate * 100).toFixed(2)}%)`,
`= ${formatUSD(usdEnd)}`
],
color: "#1f4f9a"
})
const bottomLabel = createLabel({
x: bottomEnd.x - 40,
y: bottomEnd.y + 16,
anchor: "end",
lines: [
"Convert to 🇪🇺 at S₀",
`${formatSpot(spotRate)} → ${formatEUR(eurosNow)}`,
`Grow at e^(${(foreignRate * 100).toFixed(2)}%) → ${formatEUR(eurosEnd)}`
],
color: "#495057"
})
const hedgeLabel = createLabel({
x: topEnd.x - 30,
y: topEnd.y + 55,
anchor: "end",
lines: [
`Forward rate F₀ = $${forwardRate.toFixed(3)}/EUR`,
`Hedged payoff = ${formatUSD(usdEndHedged)}`,
"Matches domestic payoff ✓"
],
color: "#0ca678"
})
keepLabelWithinBounds(hedgeLabel)
const parityLabel = createLabel({
x: topEnd.x,
y: topEnd.y - 60,
anchor: "middle",
lines: [
"No-arbitrage balance",
`${formatUSD(usdEnd)} = ${formatUSD(usdEndHedged)}`
],
color: "#212529",
weight: 600,
size: 15
})
const frameIds = new Set()
const scheduleFrame = callback => {
const id = requestAnimationFrame(time => {
frameIds.delete(id)
callback(time)
})
frameIds.add(id)
return id
}
const timeouts = []
const scheduleTimeout = (fn, delay) => {
const id = setTimeout(fn, delay)
timeouts.push(id)
}
const animateStroke = (path, duration, delay = 0) => {
const length = path.getTotalLength()
path.style.transition = "none"
path.style.strokeDasharray = length
path.style.strokeDashoffset = length
path.getBoundingClientRect()
scheduleTimeout(() => {
path.style.transition = `stroke-dashoffset ${duration}ms ease-in-out`
path.style.strokeDashoffset = 0
}, delay)
}
const moveCircle = (circle, path, duration, delay = 0) => {
const length = path.getTotalLength()
const origin = path.getPointAtLength(0)
circle.setAttribute("cx", origin.x)
circle.setAttribute("cy", origin.y)
let startTime = null
const step = time => {
if (startTime === null) startTime = time
const elapsed = time - startTime
if (elapsed < delay) {
scheduleFrame(step)
return
}
const t = Math.min((elapsed - delay) / duration, 1)
const eased = easeInOutCubic(Math.max(0, t))
const point = path.getPointAtLength(eased * length)
circle.setAttribute("cx", point.x)
circle.setAttribute("cy", point.y)
if (t < 1) {
scheduleFrame(step)
}
}
scheduleFrame(step)
}
const topDuration = 1400
const topDelay = 120
animateStroke(topPath, topDuration, topDelay)
moveCircle(topCircle, topPath, topDuration, topDelay)
scheduleTimeout(() => {
topLabel.style.opacity = 1
}, topDelay + topDuration - 200)
const bottomDelay = topDelay + 700
const bottomDuration = 1400
animateStroke(bottomPath, bottomDuration, bottomDelay)
moveCircle(bottomCircle, bottomPath, bottomDuration, bottomDelay)
scheduleTimeout(() => {
bottomLabel.style.opacity = 1
}, bottomDelay + bottomDuration - 200)
const hedgeDelay = bottomDelay + bottomDuration + 200
const hedgeDuration = 1000
animateStroke(hedgePath, hedgeDuration, hedgeDelay)
moveCircle(bottomCircle, hedgePath, hedgeDuration, hedgeDelay)
scheduleTimeout(() => {
hedgeLabel.style.opacity = 1
parityLabel.style.opacity = 1
}, hedgeDelay + hedgeDuration - 150)
invalidation.then(() => {
frameIds.forEach(id => cancelAnimationFrame(id))
timeouts.forEach(id => clearTimeout(id))
})
return container
}