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;
}