295 lines
No EOL
8.3 KiB
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> |