- ui/: React + Vite + Tailwind app (Setup, Baseline, Forecast views, collapsible sidebar, status bar, canvas timeline) - server.js: serve built UI from public/app/ - package.json: add build script (cd ui && npm run build) - routes/sources.js: default new col_meta role to 'dimension' instead of 'ignore' - .gitignore: exclude public/app/ build output - pf_spec.md: update tech stack, nav, frontend section, and project status to reflect current implementation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
148 lines
4.4 KiB
JavaScript
148 lines
4.4 KiB
JavaScript
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 <canvas ref={canvasRef} height={90} style={{ width: '100%', display: 'block' }} />
|
|
}
|