wip 14
This commit is contained in:
177
frontend/src/components/ReadingHistoryGraph.tsx
Normal file
177
frontend/src/components/ReadingHistoryGraph.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="relative flex h-24 items-center justify-center bg-gray-100 dark:bg-gray-600">
|
||||||
|
<p className="text-gray-400 dark:text-gray-300">No data available</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const points = calculatePoints(data, width, height);
|
||||||
|
const bezierPath = generateBezierPath(points);
|
||||||
|
|
||||||
|
// Calculate fill path (closed loop for area fill)
|
||||||
|
const firstX = Math.min(...points.map((p) => p.x));
|
||||||
|
const lastX = Math.max(...points.map((p) => p.x));
|
||||||
|
|
||||||
|
const areaPath = `${bezierPath} L ${lastX},${height} L ${firstX},${height} Z`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full">
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${width} ${height}`}
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
className="h-24"
|
||||||
|
>
|
||||||
|
{/* Area fill */}
|
||||||
|
<path
|
||||||
|
fill="#316BBE"
|
||||||
|
fillOpacity="0.5"
|
||||||
|
stroke="none"
|
||||||
|
d={areaPath}
|
||||||
|
/>
|
||||||
|
{/* Bezier curve line */}
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
stroke="#316BBE"
|
||||||
|
strokeWidth="2"
|
||||||
|
d={bezierPath}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Hover overlays */}
|
||||||
|
<div className="absolute top-0 size-full">
|
||||||
|
{data.map((point, i) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="group relative flex-1 cursor-pointer"
|
||||||
|
onClick={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
{/* Vertical indicator line on hover */}
|
||||||
|
<div className="absolute inset-0 flex items-center opacity-0 group-hover:opacity-100">
|
||||||
|
<div className="h-full w-px bg-gray-400 opacity-30" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tooltip */}
|
||||||
|
<div className="pointer-events-none absolute bottom-full left-1/2 mb-2 hidden -translate-x-1/2 rounded-md bg-gray-800 px-3 py-2 text-xs text-white shadow-lg group-hover:block dark:bg-gray-200 dark:text-gray-900">
|
||||||
|
<div className="font-medium">{formatDate(point.date)}</div>
|
||||||
|
<div>{point.minutes_read} minutes</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// Reading History Graph
|
||||||
|
export { default as ReadingHistoryGraph } from './ReadingHistoryGraph';
|
||||||
|
|
||||||
// Toast components
|
// Toast components
|
||||||
export { Toast } from './Toast';
|
export { Toast } from './Toast';
|
||||||
export { ToastProvider, useToasts } from './ToastContext';
|
export { ToastProvider, useToasts } from './ToastContext';
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useGetHome, useGetDocuments } from '../generated/anthoLumeAPIV1';
|
import { useGetHome, useGetDocuments } from '../generated/anthoLumeAPIV1';
|
||||||
import type { GraphDataPoint, LeaderboardData } from '../generated/model';
|
import type { LeaderboardData } from '../generated/model';
|
||||||
|
import ReadingHistoryGraph from '../components/ReadingHistoryGraph';
|
||||||
|
|
||||||
interface InfoCardProps {
|
interface InfoCardProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -139,34 +140,7 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function GraphVisualization({ data }: { data: GraphDataPoint[] }) {
|
|
||||||
if (!data || data.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="relative flex h-24 items-center justify-center bg-gray-100 dark:bg-gray-600">
|
|
||||||
<p className="text-gray-400 dark:text-gray-300">No data available</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simple bar visualization (could be enhanced with SVG bezier curve like SSR)
|
|
||||||
const maxMinutes = Math.max(...data.map(d => d.minutes_read), 1);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative flex h-24 items-end justify-between bg-gray-100 p-2 dark:bg-gray-600">
|
|
||||||
{data.map((point, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="group relative mx-0.5 flex-1 bg-blue-500 transition-colors hover:bg-blue-600"
|
|
||||||
style={{ height: `${(point.minutes_read / maxMinutes) * 100}%` }}
|
|
||||||
>
|
|
||||||
<div className="pointer-events-none absolute bottom-full left-0 mb-1 w-full text-center text-xs text-gray-600 opacity-0 group-hover:opacity-100 dark:text-gray-300">
|
|
||||||
{point.minutes_read} min
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const { data: homeData, isLoading: homeLoading } = useGetHome();
|
const { data: homeData, isLoading: homeLoading } = useGetHome();
|
||||||
@@ -190,7 +164,7 @@ export default function HomePage() {
|
|||||||
<p className="absolute left-5 top-3 w-max border-b border-gray-200 text-sm font-semibold text-gray-700 dark:border-gray-500 dark:text-white">
|
<p className="absolute left-5 top-3 w-max border-b border-gray-200 text-sm font-semibold text-gray-700 dark:border-gray-500 dark:text-white">
|
||||||
Daily Read Totals
|
Daily Read Totals
|
||||||
</p>
|
</p>
|
||||||
<GraphVisualization data={graphData || []} />
|
<ReadingHistoryGraph data={graphData || []} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user