diff --git a/frontend/src/components/ReadingHistoryGraph.tsx b/frontend/src/components/ReadingHistoryGraph.tsx new file mode 100644 index 0000000..6e884b3 --- /dev/null +++ b/frontend/src/components/ReadingHistoryGraph.tsx @@ -0,0 +1,177 @@ +import type { GraphDataPoint } from '../generated/model'; + +interface ReadingHistoryGraphProps { + data: GraphDataPoint[]; + width?: number; + height?: number; +} + +interface SVGPoint { + x: number; + y: number; +} + +/** + * Generates bezier control points for smooth curves + */ +function getBezierControlPoint( + currentPoint: SVGPoint, + prevPoint: SVGPoint | null, + nextPoint: SVGPoint | null, + isReverse: boolean +): SVGPoint { + // First / Last Point + const pPrev = prevPoint || currentPoint; + const pNext = nextPoint || currentPoint; + + const smoothingRatio = 0.2; + const directionModifier = isReverse ? Math.PI : 0; + + const lengthX = pNext.x - pPrev.x; + const lengthY = pNext.y - pPrev.y; + + const length = Math.sqrt(lengthX * lengthX + lengthY * lengthY); + const angle = Math.atan2(lengthY, lengthX) + directionModifier; + const controlPointLength = length * smoothingRatio; + + return { + x: currentPoint.x + Math.cos(angle) * controlPointLength, + y: currentPoint.y + Math.sin(angle) * controlPointLength, + }; +} + +/** + * Generates the bezier path for the graph + */ +function generateBezierPath(points: SVGPoint[]): string { + if (points.length === 0) return ''; + + const first = points[0]; + let path = `M ${first.x},${first.y}`; + + for (let i = 1; i < points.length; i++) { + const current = points[i]; + const prev = points[i - 1]; + const prevPrev = i - 2 >= 0 ? points[i - 2] : current; + const next = i + 1 < points.length ? points[i + 1] : current; + + const startControl = getBezierControlPoint(prev, prevPrev, current, false); + const endControl = getBezierControlPoint(current, prev, next, true); + + path += ` C${startControl.x},${startControl.y} ${endControl.x},${endControl.y} ${current.x},${current.y}`; + } + + return path; +} + +/** + * Calculate points for SVG rendering + */ +function calculatePoints( + data: GraphDataPoint[], + width: number, + height: number +): SVGPoint[] { + if (data.length === 0) return []; + + const maxMinutes = Math.max(...data.map((d) => d.minutes_read), 1); + const paddingX = width * 0.03; // 3% padding on sides + const paddingY = height * 0.1; // 10% padding on top/bottom + const usableWidth = width - paddingX * 2; + const usableHeight = height - paddingY * 2; + + return data.map((point, index) => { + const x = paddingX + (index / (data.length - 1)) * usableWidth; + // Y is inverted (0 is top in SVG) + const y = + paddingY + usableHeight - (point.minutes_read / maxMinutes) * usableHeight; + return { x, y }; + }); +} + +/** + * Formats a date string + */ +function formatDate(dateString: string): string { + const date = new Date(dateString); + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); +} + +/** + * ReadingHistoryGraph component + * + * Displays a bezier curve graph of daily reading totals with hover tooltips. + */ +export default function ReadingHistoryGraph({ + data, + width = 800, + height = 70, +}: ReadingHistoryGraphProps) { + if (!data || data.length < 2) { + return ( +
No data available
+No data available
-Daily Read Totals
-