wip 15
This commit is contained in:
@@ -2,11 +2,9 @@ import type { GraphDataPoint } from '../generated/model';
|
||||
|
||||
interface ReadingHistoryGraphProps {
|
||||
data: GraphDataPoint[];
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
interface SVGPoint {
|
||||
export interface SVGPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
@@ -14,6 +12,20 @@ interface SVGPoint {
|
||||
/**
|
||||
* Generates bezier control points for smooth curves
|
||||
*/
|
||||
function getSVGBezierOpposedLine(
|
||||
pointA: SVGPoint,
|
||||
pointB: SVGPoint
|
||||
): { Length: number; Angle: number } {
|
||||
const lengthX = pointB.x - pointA.x;
|
||||
const lengthY = pointB.y - pointA.y;
|
||||
|
||||
// Go uses int() which truncates toward zero, JavaScript Math.trunc matches this
|
||||
return {
|
||||
Length: Math.floor(Math.sqrt(lengthX * lengthX + lengthY * lengthY)),
|
||||
Angle: Math.trunc(Math.atan2(lengthY, lengthX)),
|
||||
};
|
||||
}
|
||||
|
||||
function getBezierControlPoint(
|
||||
currentPoint: SVGPoint,
|
||||
prevPoint: SVGPoint | null,
|
||||
@@ -21,92 +33,169 @@ function getBezierControlPoint(
|
||||
isReverse: boolean
|
||||
): SVGPoint {
|
||||
// First / Last Point
|
||||
const pPrev = prevPoint || currentPoint;
|
||||
const pNext = nextPoint || currentPoint;
|
||||
let pPrev = prevPoint;
|
||||
let pNext = nextPoint;
|
||||
if (!pPrev) {
|
||||
pPrev = currentPoint;
|
||||
}
|
||||
if (!pNext) {
|
||||
pNext = currentPoint;
|
||||
}
|
||||
|
||||
const smoothingRatio = 0.2;
|
||||
const directionModifier = isReverse ? Math.PI : 0;
|
||||
// Modifiers
|
||||
const smoothingRatio: number = 0.2;
|
||||
const directionModifier: number = 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;
|
||||
const opposingLine = getSVGBezierOpposedLine(pPrev, pNext);
|
||||
const lineAngle: number = opposingLine.Angle + directionModifier;
|
||||
const lineLength: number = opposingLine.Length * smoothingRatio;
|
||||
|
||||
// Calculate Control Point - Go converts everything to int
|
||||
// Note: int(math.Cos(...) * lineLength) means truncate product, not truncate then multiply
|
||||
return {
|
||||
x: currentPoint.x + Math.cos(angle) * controlPointLength,
|
||||
y: currentPoint.y + Math.sin(angle) * controlPointLength,
|
||||
x: Math.floor(currentPoint.x + Math.trunc(Math.cos(lineAngle) * lineLength)),
|
||||
y: Math.floor(currentPoint.y + Math.trunc(Math.sin(lineAngle) * lineLength)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}`;
|
||||
function getSVGBezierPath(points: SVGPoint[]): string {
|
||||
if (points.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return path;
|
||||
let bezierSVGPath: string = '';
|
||||
|
||||
for (let index = 0; index < points.length; index++) {
|
||||
const point = points[index];
|
||||
if (index === 0) {
|
||||
bezierSVGPath += `M ${point.x},${point.y}`;
|
||||
} else {
|
||||
const pointPlusOne = points[index + 1];
|
||||
const pointMinusOne = points[index - 1];
|
||||
const pointMinusTwo: SVGPoint | null = index - 2 >= 0 ? points[index - 2] : null;
|
||||
|
||||
const startControlPoint: SVGPoint = getBezierControlPoint(
|
||||
pointMinusOne,
|
||||
pointMinusTwo,
|
||||
point,
|
||||
false
|
||||
);
|
||||
const endControlPoint: SVGPoint = getBezierControlPoint(
|
||||
point,
|
||||
pointMinusOne,
|
||||
pointPlusOne || point,
|
||||
true
|
||||
);
|
||||
|
||||
// Go converts all coordinates to int
|
||||
bezierSVGPath += ` C${startControlPoint.x},${startControlPoint.y} ${endControlPoint.x},${endControlPoint.y} ${point.x},${point.y}`;
|
||||
}
|
||||
}
|
||||
|
||||
return bezierSVGPath;
|
||||
}
|
||||
|
||||
export interface SVGGraphData {
|
||||
LinePoints: SVGPoint[];
|
||||
BezierPath: string;
|
||||
BezierFill: string;
|
||||
Offset: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate points for SVG rendering
|
||||
* Get SVG Graph Data
|
||||
*/
|
||||
function calculatePoints(
|
||||
data: GraphDataPoint[],
|
||||
width: number,
|
||||
height: number
|
||||
): SVGPoint[] {
|
||||
if (data.length === 0) return [];
|
||||
export function getSVGGraphData(
|
||||
inputData: GraphDataPoint[],
|
||||
svgWidth: number,
|
||||
svgHeight: number
|
||||
): SVGGraphData {
|
||||
// Derive Height
|
||||
let maxHeight: number = 0;
|
||||
for (const item of inputData) {
|
||||
if (item.minutes_read > maxHeight) {
|
||||
maxHeight = item.minutes_read;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
// Vertical Graph Real Estate
|
||||
const sizePercentage: number = 0.5;
|
||||
|
||||
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 };
|
||||
});
|
||||
// Scale Ratio -> Desired Height
|
||||
const sizeRatio: number = (svgHeight * sizePercentage) / maxHeight;
|
||||
|
||||
// Point Block Offset
|
||||
const blockOffset: number = Math.floor(svgWidth / inputData.length);
|
||||
|
||||
// Line & Bar Points
|
||||
const linePoints: SVGPoint[] = [];
|
||||
|
||||
// Bezier Fill Coordinates (Max X, Min X, Max Y)
|
||||
let maxBX: number = 0;
|
||||
let maxBY: number = 0;
|
||||
let minBX: number = 0;
|
||||
|
||||
for (let idx = 0; idx < inputData.length; idx++) {
|
||||
// Go uses int conversion
|
||||
const itemSize = Math.floor(inputData[idx].minutes_read * sizeRatio);
|
||||
const itemY = svgHeight - itemSize;
|
||||
const lineX = (idx + 1) * blockOffset;
|
||||
|
||||
linePoints.push({
|
||||
x: lineX,
|
||||
y: itemY,
|
||||
});
|
||||
|
||||
if (lineX > maxBX) {
|
||||
maxBX = lineX;
|
||||
}
|
||||
|
||||
if (lineX < minBX) {
|
||||
minBX = lineX;
|
||||
}
|
||||
|
||||
if (itemY > maxBY) {
|
||||
maxBY = itemY;
|
||||
}
|
||||
}
|
||||
|
||||
// Return Data
|
||||
return {
|
||||
LinePoints: linePoints,
|
||||
BezierPath: getSVGBezierPath(linePoints),
|
||||
BezierFill: `L ${Math.floor(maxBX)},${Math.floor(maxBY)} L ${Math.floor(minBX + blockOffset)},${Math.floor(maxBY)} Z`,
|
||||
Offset: blockOffset,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date string
|
||||
* Formats a date string to YYYY-MM-DD format (ISO-like)
|
||||
* Note: The date string from the API is already in YYYY-MM-DD format,
|
||||
* but since JavaScript Date parsing can add timezone offsets, we use UTC
|
||||
* methods to ensure we get the correct date.
|
||||
*/
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
// Use UTC methods to avoid timezone offset issues
|
||||
const year = date.getUTCFullYear();
|
||||
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getUTCDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* ReadingHistoryGraph component
|
||||
*
|
||||
*
|
||||
* Displays a bezier curve graph of daily reading totals with hover tooltips.
|
||||
* Exact copy of Go template implementation.
|
||||
*/
|
||||
export default function ReadingHistoryGraph({
|
||||
data,
|
||||
width = 800,
|
||||
height = 70,
|
||||
}: ReadingHistoryGraphProps) {
|
||||
export default function ReadingHistoryGraph({ data }: ReadingHistoryGraphProps) {
|
||||
const svgWidth = 800;
|
||||
const svgHeight = 70;
|
||||
|
||||
if (!data || data.length < 2) {
|
||||
return (
|
||||
<div className="relative flex h-24 items-center justify-center bg-gray-100 dark:bg-gray-600">
|
||||
@@ -115,62 +204,49 @@ export default function ReadingHistoryGraph({
|
||||
);
|
||||
}
|
||||
|
||||
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`;
|
||||
const {
|
||||
BezierPath,
|
||||
BezierFill,
|
||||
LinePoints: _linePoints,
|
||||
} = getSVGGraphData(data, svgWidth, svgHeight);
|
||||
|
||||
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}
|
||||
/>
|
||||
<div className="relative">
|
||||
<svg viewBox={`26 0 755 ${svgHeight}`} preserveAspectRatio="none" width="100%" height="6em">
|
||||
<path fill="#316BBE" fillOpacity="0.5" stroke="none" d={`${BezierPath} ${BezierFill}`} />
|
||||
<path fill="none" stroke="#316BBE" d={BezierPath} />
|
||||
</svg>
|
||||
|
||||
{/* Hover overlays */}
|
||||
<div className="absolute top-0 size-full">
|
||||
{data.map((point, i) => {
|
||||
return (
|
||||
<div
|
||||
className="absolute top-0 flex size-full"
|
||||
style={{
|
||||
width: 'calc(100% * 31 / 30)',
|
||||
transform: 'translateX(-50%)',
|
||||
left: '50%',
|
||||
}}
|
||||
>
|
||||
{data.map((point, i) => (
|
||||
<div
|
||||
key={i}
|
||||
onClick
|
||||
className="w-full opacity-0 hover:opacity-100"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(rgba(128, 128, 128, 0.5), rgba(128, 128, 128, 0.5)) no-repeat center/2px 100%',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
key={i}
|
||||
className="group relative flex-1 cursor-pointer"
|
||||
onClick={(e) => e.preventDefault()}
|
||||
className="pointer-events-none absolute top-3 flex flex-col items-center rounded p-2 text-xs dark:text-white"
|
||||
style={{
|
||||
transform: 'translateX(-50%)',
|
||||
left: '50%',
|
||||
backgroundColor: 'rgba(128, 128, 128, 0.2)',
|
||||
}}
|
||||
>
|
||||
{/* 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>
|
||||
<span>{formatDate(point.date)}</span>
|
||||
<span>{point.minutes_read} minutes</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user