0.1.6Updated a month ago
import { truncate_number } from "@islands/utils/truncate_number.ts";

export default function LineChart({
  data,
  width,
  height,
  hoverX,
  nearest,
  eventHandlers,
}: Chart.Plugin.Props) {
  const paddingLeft = 42;
  const values = data.map(d => d.value);
  const min = Math.min(...values);
  const max = Math.max(...values);

  const ticks = horizontal_ticks(min, max);

  const points = data.map((d, i) => {
    const x = paddingLeft + (i / (data.length - 1)) * (width - paddingLeft);
    const y = height - ((d.value - ticks[0]) / (ticks.at(-1)! - ticks[0])) * height;
    return { x, y, ...d };
  });

  const linePath = points
    .map((p, i) => `${i === 0 ? "M" : "L"} ${p.x.toFixed(2)} ${p.y.toFixed(2)}`)
    .join(" ");
  const areaPath = `${linePath} L ${points.at(-1)!.x} ${height} L ${points[0].x} ${height} Z`;

  return (
    <div class="relative w-full h-full">
      <svg
        viewBox={`0 0 ${width} ${height}`}
        width="100%"
        height="100%"
        class="text-primary stroke-primary"
        {...eventHandlers}
      >
        <defs>
          <linearGradient id="chart-fill" x1="0" y1="0" x2="0" y2="1">
            <stop offset="0%" stop-color="currentColor" stop-opacity="0.8" />
            <stop offset="100%" stop-color="currentColor" stop-opacity="0.4" />
          </linearGradient>
        </defs>

        {/* Y-axis grid */}
        {ticks.map((val, i) => {
          const y =
            height -
            ((val - ticks[0]) / (ticks.at(-1)! - ticks[0])) * height;
          return (
            <g key={val}>
              <line x1={paddingLeft} x2={width} y1={y} y2={y} stroke="hsl(var(--b2))" />
              { i > 0 && i < ticks.length - 1 &&
                <text
                  x={paddingLeft - 6}
                  y={y}
                  text-anchor="end"
                  dominant-baseline="middle"
                  class="text-xs fill-base-content/50"
                >
                  { truncate_number(val) }
                </text>
              }
            </g>
          );
        })}

        <path d={areaPath} fill="url(#chart-fill)" />
        <path d={linePath} class="stroke-current stroke-[2] fill-none" />

        {/* Vertical line at hoverX */}
        {hoverX !== null && (
          <line
            x1={ Math.max(paddingLeft, hoverX) }
            x2={ Math.max(paddingLeft, hoverX) }
            y1={ 0 }
            y2={ height }
            stroke="white"
            stroke-width="1"
            class="pointer-events-none"
          />
        )}
      </svg>

      {/* Tooltip */}
      {nearest && hoverX !== null && (
        <div
          class="absolute text-xs px-2 py-1 rounded bg-base-200 shadow text-base-content transition-all duration-150 ease-out scale-95 opacity-0 animate-[peek_0.2s_ease-out_forwards]"
          style={{
            left: `${Math.max(paddingLeft, hoverX)}px`,
            top: "0.25rem",
            transform: "translateX(-50%)",
            pointerEvents: "none",
            whiteSpace: "nowrap",
          }}
        >
          <div class="font-bold">{truncate_number(nearest.value)}</div>
          <div class="opacity-60">
            {nearest.label}
          </div>
        </div>
      )}
    </div>
  );
}

function horizontal_ticks(min: number, max: number, count = 4): number[] {
  const range = max - min;
  const rawStep = range / count;
  const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep)));
  const step = Math.ceil(rawStep / magnitude) * magnitude;

  const tickMin = Math.floor(min / step) * step;
  const tickMax = Math.ceil(max / step) * step;

  const ticks = [];
  for (let y = tickMin; y <= tickMax; y += step) {
    ticks.push(y);
  }

  return ticks;
}