All files / src/components/charts TrendChart.tsx

100% Statements 18/18
87.5% Branches 7/8
100% Functions 3/3
100% Lines 17/17

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91                                                    21x 20x 20x 20x 20x 20x 20x 21x 260x 260x 260x   21x 21x 21x 21x                                                     12x 12x                                          
"use client";
 
/**
 * TrendChart — a tiny zero-dependency SVG line+area sparkline for personal-health
 * trends (normalised to the series min/max so small variations are visible).
 * Optional `markers` overlay vertical event annotations (rendered as HTML so
 * they aren't distorted by the stretched SVG). Pure presentational; synthetic.
 */
export interface TrendMarker {
  /** position along the x-axis, 0 (oldest) … 1 (newest) */
  x: number;
  color: string;
  label: string;
}
 
export function TrendChart({
  points,
  color,
  height = 64,
  markers = [],
}: {
  points: number[];
  color: string;
  height?: number;
  markers?: TrendMarker[];
}) {
  if (!points || points.length < 2) return null;
  const W = 260;
  const H = 64;
  const pad = 6;
  const min = Math.min(...points);
  const max = Math.max(...points);
  const range = max - min || 1;
  const xy = points.map((v, i) => {
    const x = pad + (i / (points.length - 1)) * (W - 2 * pad);
    const y = pad + (1 - (v - min) / range) * (H - 2 * pad);
    return `${x.toFixed(1)},${y.toFixed(1)}`;
  });
  const line = xy.join(" ");
  const area = `${pad},${H - pad} ${line} ${W - pad},${H - pad}`;
  const gid = `tg-${color.replace("#", "")}`;
  return (
    <div className="relative w-full" style={{ height }}>
      <svg
        viewBox={`0 0 ${W} ${H}`}
        preserveAspectRatio="none"
        role="img"
        aria-hidden="true"
        className="block w-full h-full"
      >
        <defs>
          <linearGradient id={gid} x1="0" y1="0" x2="0" y2="1">
            <stop offset="0%" stopColor={color} stopOpacity="0.28" />
            <stop offset="100%" stopColor={color} stopOpacity="0" />
          </linearGradient>
        </defs>
        <polygon points={area} fill={`url(#${gid})`} />
        <polyline
          points={line}
          fill="none"
          stroke={color}
          strokeWidth="2"
          strokeLinejoin="round"
          strokeLinecap="round"
          vectorEffect="non-scaling-stroke"
        />
      </svg>
      {markers.map((m, i) => {
        const left = `${Math.max(0, Math.min(1, m.x)) * 100}%`;
        return (
          <div
            key={i}
            className="absolute top-0 bottom-0 pointer-events-none"
            style={{ left }}
            title={m.label}
          >
            <div
              className="absolute top-1.5 bottom-1.5 -translate-x-1/2 border-l border-dashed"
              style={{ borderColor: m.color, opacity: 0.75 }}
            />
            <div
              className="absolute top-0 -translate-x-1/2 w-2 h-2 rounded-full ring-1 ring-white"
              style={{ background: m.color }}
            />
          </div>
        );
      })}
    </div>
  );
}