import { useEffect, useRef } from 'react' function parseDate(s) { if (!s) return null const [y, m, d] = s.split('-').map(Number) return new Date(y, (m || 1) - 1, (d || 1)) } function addMonths(date, months) { const d = new Date(date) d.setMonth(d.getMonth() + months) return d } function fmtDate(d) { return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0') } function roundRect(ctx, x, y, w, h, r, fill, stroke) { ctx.beginPath() ctx.moveTo(x + r, y) ctx.lineTo(x + w - r, y) ctx.quadraticCurveTo(x + w, y, x + w, y + r) ctx.lineTo(x + w, y + h - r) ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h) ctx.lineTo(x + r, y + h) ctx.quadraticCurveTo(x, y + h, x, y + h - r) ctx.lineTo(x, y + r) ctx.quadraticCurveTo(x, y, x + r, y) ctx.closePath() if (fill) ctx.fill() if (stroke) ctx.stroke() } export default function Timeline({ dateFrom, dateTo, offsetYr, offsetMo }) { const canvasRef = useRef(null) useEffect(() => { const canvas = canvasRef.current if (!canvas) return const dpr = window.devicePixelRatio || 1 const W = canvas.offsetWidth || 500 canvas.width = W * dpr canvas.height = 90 * dpr const ctx = canvas.getContext('2d') ctx.scale(dpr, dpr) const H = 90 const PAD = { l: 8, r: 8 } const trackH = 22 const srcY = 20 const projY = srcY + trackH + 10 const drawW = W - PAD.l - PAD.r const srcStart = parseDate(dateFrom) const srcEnd = parseDate(dateTo) if (!srcStart || !srcEnd || isNaN(srcStart) || isNaN(srcEnd)) return const offsetMoTotal = (offsetYr || 0) * 12 + (offsetMo || 0) const projStart = addMonths(srcStart, offsetMoTotal) const projEnd = addMonths(srcEnd, offsetMoTotal) const winStart = addMonths(srcStart, -1) const winEnd = addMonths(projEnd, 1) const winMs = winEnd - winStart function xOf(date) { return PAD.l + ((date - winStart) / winMs) * drawW } ctx.clearRect(0, 0, W, H) // axis ctx.strokeStyle = '#e5e7eb' ctx.lineWidth = 1 ctx.beginPath() ctx.moveTo(PAD.l, srcY - 8) ctx.lineTo(PAD.l + drawW, srcY - 8) ctx.stroke() // month ticks + year labels const tickStart = new Date(winStart.getFullYear(), winStart.getMonth(), 1) for (let d = new Date(tickStart); d <= winEnd; d = addMonths(d, 1)) { const x = xOf(d) if (x < PAD.l || x > PAD.l + drawW) continue ctx.strokeStyle = '#f3f4f6' ctx.lineWidth = 1 ctx.beginPath() ctx.moveTo(x, srcY - 8) ctx.lineTo(x, projY + trackH) ctx.stroke() if (d.getMonth() === 0) { ctx.fillStyle = '#6b7280' ctx.font = 'bold 9px system-ui' ctx.textAlign = 'center' ctx.fillText(d.getFullYear(), x, srcY - 10) } } // source band const sx1 = xOf(srcStart), sx2 = xOf(srcEnd) ctx.fillStyle = '#dbeafe' ctx.strokeStyle = '#93c5fd' ctx.lineWidth = 1 roundRect(ctx, sx1, srcY, Math.max(sx2 - sx1, 4), trackH, 4, true, true) ctx.fillStyle = '#1d4ed8' ctx.font = '10px system-ui' ctx.textAlign = 'left' ctx.fillText('Source ' + dateFrom + ' → ' + dateTo, sx1 + 6, srcY + 14) if (offsetMoTotal > 0) { const px1 = xOf(projStart), px2 = xOf(projEnd) ctx.fillStyle = '#dcfce7' ctx.strokeStyle = '#86efac' roundRect(ctx, px1, projY, Math.max(px2 - px1, 4), trackH, 4, true, true) ctx.fillStyle = '#15803d' ctx.font = '10px system-ui' ctx.textAlign = 'left' ctx.fillText('Projected ' + fmtDate(projStart) + ' → ' + fmtDate(projEnd), px1 + 6, projY + 14) // arrow const arrowY = srcY + trackH / 2 ctx.strokeStyle = '#94a3b8' ctx.lineWidth = 1 ctx.setLineDash([3, 3]) ctx.beginPath() ctx.moveTo(sx1, arrowY) ctx.lineTo(px1 - 2, arrowY) ctx.stroke() ctx.setLineDash([]) ctx.fillStyle = '#94a3b8' ctx.beginPath() ctx.moveTo(px1 + 4, arrowY) ctx.lineTo(px1 - 4, arrowY - 4) ctx.lineTo(px1 - 4, arrowY + 4) ctx.closePath() ctx.fill() const label = '+' + (offsetYr ? offsetYr + 'yr ' : '') + (offsetMo ? offsetMo + 'mo' : '') ctx.fillStyle = '#64748b' ctx.font = '9px system-ui' ctx.textAlign = 'center' ctx.fillText(label.trim(), (sx1 + px1) / 2, arrowY - 5) } }, [dateFrom, dateTo, offsetYr, offsetMo]) return }