post-app-website-new/lib/tbff/rendering.ts

299 lines
8.2 KiB
TypeScript

/**
* Canvas rendering functions for Flow Funding visualization
*/
import type { FlowFundingAccount, FlowFundingNetwork, Allocation } from './types'
import { getStatusColor, getAccountCenter, formatCurrency, formatPercentage } from './utils'
/**
* Draw threshold line inside account rectangle
*/
function drawThresholdLine(
ctx: CanvasRenderingContext2D,
account: FlowFundingAccount,
threshold: number,
color: string,
label: string
) {
if (threshold <= 0) return
const thresholdRatio = threshold / account.maxThreshold
const lineY = account.y + account.height - thresholdRatio * account.height
// Draw dashed line
ctx.strokeStyle = color
ctx.lineWidth = 2
ctx.setLineDash([5, 5])
ctx.beginPath()
ctx.moveTo(account.x, lineY)
ctx.lineTo(account.x + account.width, lineY)
ctx.stroke()
ctx.setLineDash([])
// Draw label
ctx.fillStyle = color
ctx.font = 'bold 10px sans-serif'
ctx.fillText(label, account.x + 5, lineY - 3)
}
/**
* Render a Flow Funding account as a colored rectangle
*/
export function renderAccount(
ctx: CanvasRenderingContext2D,
account: FlowFundingAccount,
isSelected: boolean = false
) {
// Draw border (thicker if selected)
ctx.strokeStyle = isSelected ? '#22d3ee' : getStatusColor(account.status)
ctx.lineWidth = isSelected ? 4 : 3
ctx.strokeRect(account.x, account.y, account.width, account.height)
// Calculate fill height based on balance
const fillRatio = Math.min(account.balance / account.maxThreshold, 1)
const fillHeight = fillRatio * account.height
const fillY = account.y + account.height - fillHeight
// Draw fill with gradient
const gradient = ctx.createLinearGradient(
account.x,
account.y,
account.x,
account.y + account.height
)
gradient.addColorStop(0, getStatusColor(account.status, 0.2))
gradient.addColorStop(1, getStatusColor(account.status, 0.6))
ctx.fillStyle = gradient
ctx.fillRect(account.x, fillY, account.width, fillHeight)
// Draw threshold lines
if (account.minThreshold > 0) {
drawThresholdLine(ctx, account, account.minThreshold, '#ef4444', 'Min')
}
drawThresholdLine(ctx, account, account.maxThreshold, '#10b981', 'Max')
// Draw text labels
ctx.fillStyle = '#ffffff'
ctx.font = 'bold 16px sans-serif'
ctx.fillText(account.name, account.x + 10, account.y + 25)
ctx.font = '13px monospace'
ctx.fillStyle = '#e2e8f0'
ctx.fillText(`Balance: ${formatCurrency(account.balance)}`, account.x + 10, account.y + 50)
ctx.font = '11px sans-serif'
ctx.fillStyle = '#cbd5e1'
ctx.fillText(`Min: ${formatCurrency(account.minThreshold)}`, account.x + 10, account.y + 70)
ctx.fillText(`Max: ${formatCurrency(account.maxThreshold)}`, account.x + 10, account.y + 85)
// Show status badge
const statusColors = {
deficit: '#ef4444',
minimum: '#eab308',
healthy: '#6366f1',
overflow: '#10b981',
}
const statusLabels = {
deficit: 'DEFICIT',
minimum: 'AT MIN',
healthy: 'HEALTHY',
overflow: 'OVERFLOW',
}
ctx.fillStyle = statusColors[account.status]
ctx.font = 'bold 10px sans-serif'
const statusText = statusLabels[account.status]
const statusWidth = ctx.measureText(statusText).width
ctx.fillRect(account.x + account.width - statusWidth - 15, account.y + 8, statusWidth + 10, 18)
ctx.fillStyle = '#ffffff'
ctx.fillText(statusText, account.x + account.width - statusWidth - 10, account.y + 20)
// Show overflow/shortfall amount if significant
if (account.overflow > 0) {
ctx.fillStyle = '#10b981'
ctx.font = 'bold 12px sans-serif'
ctx.fillText(
`+${formatCurrency(account.overflow)} overflow`,
account.x + 10,
account.y + account.height - 10
)
} else if (account.shortfall > 0) {
ctx.fillStyle = '#ef4444'
ctx.font = 'bold 12px sans-serif'
ctx.fillText(
`-${formatCurrency(account.shortfall)} needed`,
account.x + 10,
account.y + account.height - 10
)
}
// Draw center dot (connection point)
const center = getAccountCenter(account)
ctx.fillStyle = '#22d3ee'
ctx.beginPath()
ctx.arc(center.x, center.y, 4, 0, 2 * Math.PI)
ctx.fill()
}
/**
* Draw arrowhead at end of line
*/
function drawArrowhead(
ctx: CanvasRenderingContext2D,
x1: number,
y1: number,
x2: number,
y2: number,
color: string,
size: number = 15
) {
const angle = Math.atan2(y2 - y1, x2 - x1)
ctx.fillStyle = color
ctx.beginPath()
ctx.moveTo(x2, y2)
ctx.lineTo(
x2 - size * Math.cos(angle - Math.PI / 6),
y2 - size * Math.sin(angle - Math.PI / 6)
)
ctx.lineTo(
x2 - size * Math.cos(angle + Math.PI / 6),
y2 - size * Math.sin(angle + Math.PI / 6)
)
ctx.closePath()
ctx.fill()
}
/**
* Render an allocation arrow between accounts
*/
export function renderAllocation(
ctx: CanvasRenderingContext2D,
allocation: Allocation,
sourceAccount: FlowFundingAccount,
targetAccount: FlowFundingAccount,
isSelected: boolean = false
) {
const start = getAccountCenter(sourceAccount)
const end = getAccountCenter(targetAccount)
// Line thickness based on percentage
const baseWidth = 2
const maxWidth = 10
const width = baseWidth + allocation.percentage * (maxWidth - baseWidth)
// Color based on whether source has overflow
const hasOverflow = sourceAccount.balance > sourceAccount.maxThreshold
const color = hasOverflow ? '#10b981' : isSelected ? '#22d3ee' : '#64748b'
const alpha = hasOverflow ? 1.0 : isSelected ? 1.0 : 0.5
// Draw arrow line
ctx.strokeStyle = color
ctx.globalAlpha = alpha
ctx.lineWidth = width
ctx.beginPath()
ctx.moveTo(start.x, start.y)
ctx.lineTo(end.x, end.y)
ctx.stroke()
// Draw arrowhead
drawArrowhead(ctx, start.x, start.y, end.x, end.y, color, width * 1.8)
// Draw percentage label at midpoint
const midX = (start.x + end.x) / 2
const midY = (start.y + end.y) / 2
// Background for label
ctx.globalAlpha = 0.8
ctx.fillStyle = '#1e293b'
const labelText = formatPercentage(allocation.percentage)
const textMetrics = ctx.measureText(labelText)
ctx.fillRect(midX - 2, midY - 18, textMetrics.width + 8, 20)
// Label text
ctx.globalAlpha = 1.0
ctx.fillStyle = color
ctx.font = 'bold 12px sans-serif'
ctx.fillText(labelText, midX + 2, midY - 3)
ctx.globalAlpha = 1.0
}
/**
* Clear and render entire network
*/
export function renderNetwork(
ctx: CanvasRenderingContext2D,
network: FlowFundingNetwork,
canvasWidth: number,
canvasHeight: number,
selectedAccountId: string | null = null,
selectedAllocationId: string | null = null
) {
// Clear canvas
ctx.fillStyle = '#0f172a'
ctx.fillRect(0, 0, canvasWidth, canvasHeight)
// Draw allocations first (so they appear behind accounts)
network.allocations.forEach((allocation) => {
const sourceAccount = network.accounts.find((a) => a.id === allocation.sourceAccountId)
const targetAccount = network.accounts.find((a) => a.id === allocation.targetAccountId)
if (sourceAccount && targetAccount) {
renderAllocation(
ctx,
allocation,
sourceAccount,
targetAccount,
allocation.id === selectedAllocationId
)
}
})
// Draw accounts
network.accounts.forEach((account) => {
renderAccount(ctx, account, account.id === selectedAccountId)
})
// Draw network stats in corner
drawNetworkStats(ctx, network, canvasWidth)
}
/**
* Draw network statistics in top-right corner
*/
function drawNetworkStats(
ctx: CanvasRenderingContext2D,
network: FlowFundingNetwork,
canvasWidth: number
) {
const padding = 15
const lineHeight = 20
const x = canvasWidth - 200
ctx.fillStyle = 'rgba(30, 41, 59, 0.9)'
ctx.fillRect(x - 10, padding - 5, 210, lineHeight * 5 + 10)
ctx.fillStyle = '#22d3ee'
ctx.font = 'bold 14px sans-serif'
ctx.fillText('Network Stats', x, padding + lineHeight * 0)
ctx.font = '12px monospace'
ctx.fillStyle = '#94a3b8'
ctx.fillText(`Total Funds: ${formatCurrency(network.totalFunds)}`, x, padding + lineHeight * 1)
ctx.fillStyle = '#ef4444'
ctx.fillText(
`Shortfall: ${formatCurrency(network.totalShortfall)}`,
x,
padding + lineHeight * 2
)
ctx.fillStyle = '#eab308'
ctx.fillText(`Capacity: ${formatCurrency(network.totalCapacity)}`, x, padding + lineHeight * 3)
ctx.fillStyle = '#10b981'
ctx.fillText(`Overflow: ${formatCurrency(network.totalOverflow)}`, x, padding + lineHeight * 4)
}