app/components/CashFlowChart.vue

295 lines
No EOL
8.3 KiB
Vue

<template>
<div class="w-full h-full">
<canvas
ref="chartCanvas"
class="w-full h-full"
:width="width"
:height="height"
></canvas>
</div>
</template>
<script setup lang="ts">
interface ChartData {
month: number
monthName: string
revenue: number
expenses: number
netCashFlow: number
runningBalance: number
oneOffEvents?: Array<{ type: string, amount: number, name: string }>
}
interface Props {
data: ChartData[]
viewMode: 'combined' | 'runway' | 'cashflow'
width?: number
height?: number
}
const props = withDefaults(defineProps<Props>(), {
width: 800,
height: 320
})
const chartCanvas = ref<HTMLCanvasElement | null>(null)
const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
function drawChart() {
if (!chartCanvas.value || !props.data.length) return
const canvas = chartCanvas.value
const ctx = canvas.getContext('2d')
if (!ctx) return
// Clear canvas
ctx.clearRect(0, 0, canvas.width, canvas.height)
const padding = 60
const chartWidth = canvas.width - padding * 2
const chartHeight = canvas.height - padding * 2
const dataLength = props.data.length
const scaleX = chartWidth / (dataLength - 1)
// Calculate data ranges based on view mode
let maxValue = 0
let minValue = 0
if (props.viewMode === 'runway' || props.viewMode === 'combined') {
const balances = props.data.map(d => d.runningBalance)
maxValue = Math.max(maxValue, ...balances)
minValue = Math.min(minValue, ...balances)
}
if (props.viewMode === 'cashflow' || props.viewMode === 'combined') {
const revenues = props.data.map(d => d.revenue)
const expenses = props.data.map(d => d.expenses)
maxValue = Math.max(maxValue, ...revenues, ...expenses)
}
// Add some padding to the range
const range = maxValue - minValue
maxValue += range * 0.1
minValue -= range * 0.1
// Ensure we show zero line
if (minValue > 0) minValue = Math.min(0, minValue)
if (maxValue < 0) maxValue = Math.max(0, maxValue)
const valueRange = maxValue - minValue
const scaleY = valueRange > 0 ? chartHeight / valueRange : 1
// Helper functions
const getX = (index: number) => padding + (index * scaleX)
const getY = (value: number) => padding + chartHeight - ((value - minValue) * scaleY)
// Draw background for negative values
if (minValue < 0) {
const zeroY = getY(0)
ctx.fillStyle = 'rgba(239, 68, 68, 0.1)'
ctx.fillRect(padding, zeroY, chartWidth, canvas.height - zeroY - padding)
}
// Draw grid
drawGrid(ctx, padding, chartWidth, chartHeight, minValue, maxValue, valueRange, scaleY)
// Draw data based on view mode
if (props.viewMode === 'cashflow' || props.viewMode === 'combined') {
drawCashFlowData(ctx, getX, getY)
}
if (props.viewMode === 'runway' || props.viewMode === 'combined') {
drawRunwayData(ctx, getX, getY)
}
// Draw one-off events
drawOneOffEvents(ctx, getX, getY)
// Draw axis labels
drawAxisLabels(ctx, padding, chartWidth, chartHeight, minValue, maxValue, valueRange)
}
function drawGrid(ctx: CanvasRenderingContext2D, padding: number, chartWidth: number, chartHeight: number, minValue: number, maxValue: number, valueRange: number, scaleY: number) {
ctx.strokeStyle = '#e5e7eb'
ctx.lineWidth = 1
// Horizontal grid lines
const gridLines = 5
for (let i = 0; i <= gridLines; i++) {
const y = padding + (chartHeight / gridLines) * i
ctx.beginPath()
ctx.moveTo(padding, y)
ctx.lineTo(padding + chartWidth, y)
ctx.stroke()
}
// Vertical grid lines (every 3 months)
for (let i = 0; i < props.data.length; i += 3) {
const x = padding + (i * chartWidth) / (props.data.length - 1)
ctx.beginPath()
ctx.moveTo(x, padding)
ctx.lineTo(x, padding + chartHeight)
ctx.stroke()
}
// Zero line if applicable
if (minValue < 0 && maxValue > 0) {
const zeroY = padding + chartHeight - ((0 - minValue) * scaleY)
ctx.strokeStyle = '#6b7280'
ctx.lineWidth = 2
ctx.beginPath()
ctx.moveTo(padding, zeroY)
ctx.lineTo(padding + chartWidth, zeroY)
ctx.stroke()
}
}
function drawCashFlowData(ctx: CanvasRenderingContext2D, getX: (i: number) => number, getY: (v: number) => number) {
// Draw revenue line
ctx.strokeStyle = '#3b82f6'
ctx.lineWidth = 2
ctx.beginPath()
props.data.forEach((dataPoint, index) => {
const x = getX(index)
const y = getY(dataPoint.revenue)
if (index === 0) {
ctx.moveTo(x, y)
} else {
ctx.lineTo(x, y)
}
})
ctx.stroke()
// Draw expenses line
ctx.strokeStyle = '#ef4444'
ctx.lineWidth = 2
ctx.beginPath()
props.data.forEach((dataPoint, index) => {
const x = getX(index)
const y = getY(dataPoint.expenses)
if (index === 0) {
ctx.moveTo(x, y)
} else {
ctx.lineTo(x, y)
}
})
ctx.stroke()
}
function drawRunwayData(ctx: CanvasRenderingContext2D, getX: (i: number) => number, getY: (v: number) => number) {
// Draw balance line
ctx.strokeStyle = '#10b981'
ctx.lineWidth = 3
ctx.beginPath()
props.data.forEach((dataPoint, index) => {
const x = getX(index)
const y = getY(dataPoint.runningBalance)
if (index === 0) {
ctx.moveTo(x, y)
} else {
ctx.lineTo(x, y)
}
})
ctx.stroke()
// Mark zero crossing points
ctx.fillStyle = '#ef4444'
for (let i = 1; i < props.data.length; i++) {
const prev = props.data[i - 1].runningBalance
const current = props.data[i].runningBalance
if (prev >= 0 && current < 0) {
// Crossed from positive to negative
const x = getX(i)
const y = getY(0)
ctx.beginPath()
ctx.arc(x, y, 4, 0, 2 * Math.PI)
ctx.fill()
// Add warning label
ctx.fillStyle = '#ef4444'
ctx.font = '12px sans-serif'
ctx.textAlign = 'center'
ctx.fillText('Out of Money', x, y - 10)
ctx.fillStyle = '#ef4444' // Reset for next point
}
}
}
function drawOneOffEvents(ctx: CanvasRenderingContext2D, getX: (i: number) => number, getY: (v: number) => number) {
props.data.forEach((dataPoint, index) => {
if (dataPoint.oneOffEvents && dataPoint.oneOffEvents.length > 0) {
const x = getX(index)
let yOffset = 0
dataPoint.oneOffEvents.forEach(event => {
const isIncome = event.type === 'income'
const baseY = getY(isIncome ? dataPoint.revenue : dataPoint.expenses)
const y = baseY + yOffset
// Draw event marker
ctx.fillStyle = isIncome ? '#8b5cf6' : '#f59e0b'
ctx.beginPath()
ctx.arc(x, y, 3, 0, 2 * Math.PI)
ctx.fill()
// Add tooltip-style label
if (event.amount > 0) {
ctx.fillStyle = isIncome ? '#8b5cf6' : '#f59e0b'
ctx.font = '10px sans-serif'
ctx.textAlign = 'center'
const shortName = event.name.length > 15 ? event.name.substring(0, 12) + '...' : event.name
ctx.fillText(shortName, x, y - 8)
}
yOffset += 15
})
}
})
}
function drawAxisLabels(ctx: CanvasRenderingContext2D, padding: number, chartWidth: number, chartHeight: number, minValue: number, maxValue: number, valueRange: number) {
ctx.fillStyle = '#6b7280'
ctx.font = '12px sans-serif'
// X-axis labels (months)
ctx.textAlign = 'center'
props.data.forEach((dataPoint, index) => {
if (index % 2 === 0) { // Show every other month to avoid crowding
const x = padding + (index * chartWidth) / (props.data.length - 1)
ctx.fillText(monthNames[dataPoint.month], x, padding + chartHeight + 20)
}
})
// Y-axis labels (values)
ctx.textAlign = 'right'
const gridLines = 5
for (let i = 0; i <= gridLines; i++) {
const value = minValue + (valueRange / gridLines) * (gridLines - i)
const y = padding + (chartHeight / gridLines) * i + 4
ctx.fillText(formatShort(value), padding - 10, y)
}
}
function formatShort(value: number): string {
if (Math.abs(value) >= 1000000) {
return `${(value / 1000000).toFixed(1)}M`
}
if (Math.abs(value) >= 1000) {
return `${(value / 1000).toFixed(0)}k`
}
return Math.round(value).toString()
}
// Watch for changes and redraw
watch(() => [props.data, props.viewMode], () => {
nextTick(() => drawChart())
}, { deep: true })
onMounted(() => {
nextTick(() => drawChart())
})
</script>