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

320 lines
8.8 KiB
TypeScript

/**
* Canvas rendering for flow-based visualization
*/
import type { FlowNetwork, FlowNode, FlowAllocation, FlowParticle } from './types'
import { getFlowNodeCenter, getFlowStatusColor } from './utils'
/**
* Render a flow node
* Shows inflow, absorption, and outflow rates
*/
export function renderFlowNode(
ctx: CanvasRenderingContext2D,
node: FlowNode,
isSelected: boolean = false
): void {
const { x, y, width, height } = node
// Background
ctx.fillStyle = isSelected ? '#1e293b' : '#0f172a'
ctx.fillRect(x, y, width, height)
// Border (thicker if selected)
ctx.strokeStyle = isSelected ? '#06b6d4' : '#334155'
ctx.lineWidth = isSelected ? 3 : 1
ctx.strokeRect(x, y, width, height)
// Flow visualization bars
const barWidth = width - 20
const barHeight = 8
const barX = x + 10
let barY = y + 25
// Inflow bar (blue)
if (node.inflow > 0) {
const inflowPercent = Math.min(1, node.inflow / node.maxAbsorption)
ctx.fillStyle = 'rgba(59, 130, 246, 0.7)'
ctx.fillRect(barX, barY, barWidth * inflowPercent, barHeight)
ctx.strokeStyle = 'rgba(59, 130, 246, 0.5)'
ctx.strokeRect(barX, barY, barWidth, barHeight)
}
barY += barHeight + 4
// Absorption bar (status color)
if (node.absorbed > 0) {
const absorbedPercent = Math.min(1, node.absorbed / node.maxAbsorption)
ctx.fillStyle = getFlowStatusColor(node.status, 0.7)
ctx.fillRect(barX, barY, barWidth * absorbedPercent, barHeight)
ctx.strokeStyle = getFlowStatusColor(node.status, 0.5)
ctx.strokeRect(barX, barY, barWidth, barHeight)
}
barY += barHeight + 4
// Outflow bar (green)
if (node.outflow > 0) {
const outflowPercent = Math.min(1, node.outflow / node.maxAbsorption)
ctx.fillStyle = 'rgba(16, 185, 129, 0.7)'
ctx.fillRect(barX, barY, barWidth * outflowPercent, barHeight)
ctx.strokeStyle = 'rgba(16, 185, 129, 0.5)'
ctx.strokeRect(barX, barY, barWidth, barHeight)
}
// Node name
ctx.fillStyle = '#f1f5f9'
ctx.font = 'bold 14px sans-serif'
ctx.textAlign = 'center'
ctx.fillText(node.name, x + width / 2, y + 16)
// Flow rates
ctx.font = '10px monospace'
ctx.fillStyle = '#94a3b8'
const textX = x + width / 2
let textY = y + height - 30
if (node.inflow > 0) {
ctx.fillText(`${node.inflow.toFixed(1)}`, textX, textY)
textY += 12
}
if (node.absorbed > 0) {
ctx.fillText(`${node.absorbed.toFixed(1)}`, textX, textY)
textY += 12
}
if (node.outflow > 0) {
ctx.fillText(`${node.outflow.toFixed(1)}`, textX, textY)
}
// External flow indicator
if (node.externalFlow > 0 && !node.isOverflowSink) {
ctx.fillStyle = '#10b981'
ctx.beginPath()
ctx.arc(x + width - 10, y + 10, 5, 0, 2 * Math.PI)
ctx.fill()
}
// Overflow sink indicator
if (node.isOverflowSink) {
ctx.fillStyle = '#64748b'
ctx.font = 'bold 12px sans-serif'
ctx.textAlign = 'center'
ctx.fillText('SINK', x + width / 2, y + height / 2)
}
// Center dot for connections
const centerX = x + width / 2
const centerY = y + height / 2
ctx.fillStyle = isSelected ? '#06b6d4' : '#475569'
ctx.beginPath()
ctx.arc(centerX, centerY, 4, 0, 2 * Math.PI)
ctx.fill()
}
/**
* Render a flow allocation arrow
* Thickness represents flow amount
*/
export function renderFlowAllocation(
ctx: CanvasRenderingContext2D,
allocation: FlowAllocation,
sourceNode: FlowNode,
targetNode: FlowNode,
isSelected: boolean = false
): void {
const source = getFlowNodeCenter(sourceNode)
const target = getFlowNodeCenter(targetNode)
// Calculate arrow properties
const dx = target.x - source.x
const dy = target.y - source.y
const angle = Math.atan2(dy, dx)
const length = Math.sqrt(dx * dx + dy * dy)
// Shorten arrow to not overlap nodes
const shortenStart = 60
const shortenEnd = 60
const startX = source.x + (shortenStart / length) * dx
const startY = source.y + (shortenStart / length) * dy
const endX = target.x - (shortenEnd / length) * dx
const endY = target.y - (shortenEnd / length) * dy
// Arrow thickness based on flow amount
const flowAmount = sourceNode.outflow * allocation.percentage
const thickness = Math.max(2, Math.min(12, 2 + flowAmount / 10))
// Color based on selection and flow amount
const hasFlow = flowAmount > 0.1
const baseColor = isSelected ? '#06b6d4' : hasFlow ? '#10b981' : '#475569'
const alpha = hasFlow ? 0.8 : 0.3
// Draw arrow line
ctx.strokeStyle = baseColor
ctx.globalAlpha = alpha
ctx.lineWidth = thickness
ctx.lineCap = 'round'
ctx.beginPath()
ctx.moveTo(startX, startY)
ctx.lineTo(endX, endY)
ctx.stroke()
// Draw arrowhead
const headSize = 10 + thickness
ctx.fillStyle = baseColor
ctx.beginPath()
ctx.moveTo(endX, endY)
ctx.lineTo(
endX - headSize * Math.cos(angle - Math.PI / 6),
endY - headSize * Math.sin(angle - Math.PI / 6)
)
ctx.lineTo(
endX - headSize * Math.cos(angle + Math.PI / 6),
endY - headSize * Math.sin(angle + Math.PI / 6)
)
ctx.closePath()
ctx.fill()
ctx.globalAlpha = 1.0
// Label with flow amount
if (hasFlow || isSelected) {
const midX = (startX + endX) / 2
const midY = (startY + endY) / 2
// Background for text
ctx.fillStyle = '#0f172a'
ctx.fillRect(midX - 20, midY - 8, 40, 16)
// Text
ctx.fillStyle = baseColor
ctx.font = '11px monospace'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(flowAmount.toFixed(1), midX, midY)
}
}
/**
* Render flow particles moving along allocations
*/
export function renderFlowParticles(
ctx: CanvasRenderingContext2D,
particles: FlowParticle[],
network: FlowNetwork
): void {
particles.forEach(particle => {
// Find the allocation
const allocation = network.allocations.find(a => a.id === particle.allocationId)
if (!allocation) {
// Handle virtual overflow allocations
if (particle.allocationId.startsWith('virtual_')) {
const sourceNodeId = particle.allocationId.split('_')[1]
const sourceNode = network.nodes.find(n => n.id === sourceNodeId)
const overflowNode = network.nodes.find(n => n.isOverflowSink)
if (sourceNode && overflowNode) {
renderParticle(ctx, particle, sourceNode, overflowNode)
}
}
return
}
const sourceNode = network.nodes.find(n => n.id === allocation.sourceNodeId)
const targetNode = network.nodes.find(n => n.id === allocation.targetNodeId)
if (!sourceNode || !targetNode) return
renderParticle(ctx, particle, sourceNode, targetNode)
})
}
/**
* Render a single particle
*/
function renderParticle(
ctx: CanvasRenderingContext2D,
particle: FlowParticle,
sourceNode: FlowNode,
targetNode: FlowNode
): void {
const source = getFlowNodeCenter(sourceNode)
const target = getFlowNodeCenter(targetNode)
// Interpolate position
const x = source.x + (target.x - source.x) * particle.progress
const y = source.y + (target.y - source.y) * particle.progress
// Particle size based on amount
const size = Math.max(3, Math.min(8, particle.amount / 10))
// Draw particle
ctx.fillStyle = '#10b981'
ctx.globalAlpha = 0.8
ctx.beginPath()
ctx.arc(x, y, size, 0, 2 * Math.PI)
ctx.fill()
ctx.globalAlpha = 1.0
// Glow effect
const gradient = ctx.createRadialGradient(x, y, 0, x, y, size * 2)
gradient.addColorStop(0, 'rgba(16, 185, 129, 0.3)')
gradient.addColorStop(1, 'rgba(16, 185, 129, 0)')
ctx.fillStyle = gradient
ctx.beginPath()
ctx.arc(x, y, size * 2, 0, 2 * Math.PI)
ctx.fill()
}
/**
* Render entire flow network
*/
export function renderFlowNetwork(
ctx: CanvasRenderingContext2D,
network: FlowNetwork,
canvasWidth: number,
canvasHeight: number,
particles: FlowParticle[],
selectedNodeId: string | null = null,
selectedAllocationId: string | null = null
): void {
// Clear canvas
ctx.fillStyle = '#0f172a'
ctx.fillRect(0, 0, canvasWidth, canvasHeight)
// Draw allocations (arrows) first
network.allocations.forEach(allocation => {
const sourceNode = network.nodes.find(n => n.id === allocation.sourceNodeId)
const targetNode = network.nodes.find(n => n.id === allocation.targetNodeId)
if (sourceNode && targetNode) {
renderFlowAllocation(
ctx,
allocation,
sourceNode,
targetNode,
allocation.id === selectedAllocationId
)
}
})
// Draw particles
renderFlowParticles(ctx, particles, network)
// Draw nodes on top
network.nodes.forEach(node => {
renderFlowNode(ctx, node, node.id === selectedNodeId)
})
// Draw network stats in corner
ctx.fillStyle = '#f1f5f9'
ctx.font = '12px monospace'
ctx.textAlign = 'left'
const statsX = 10
let statsY = 20
ctx.fillText(`Inflow: ${network.totalInflow.toFixed(1)}`, statsX, statsY)
statsY += 16
ctx.fillText(`Absorbed: ${network.totalAbsorbed.toFixed(1)}`, statsX, statsY)
statsY += 16
ctx.fillText(`Outflow: ${network.totalOutflow.toFixed(1)}`, statsX, statsY)
}