import { useEffect, useRef, useState } from "preact/hooks";
import type { JSX } from "preact";
import LineChart from "./line_chart.tsx";
import { customRangeEnd, customRangeStart, rangeMode, rangeValue } from "@islands/charts/state/summary.ts";
type ChartProps = {
name: string;
description?: string;
type?: "line";
endpoint: string;
};
const ChartPlugins: Record<string, (props: Chart.Plugin.Props) => JSX.Element> = {
line: LineChart,
};
export default function CardChart({ name, description, type = "line", endpoint }: ChartProps) {
const [data, setData] = useState<Chart.DataPoint[] | null>(null);
const [loading, setLoading] = useState(true);
const [width, setWidth] = useState(500);
const height = 160;
const [hoverX, setHoverX] = useState<number | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Update rendered width
useEffect(() => {
const updateWidth = () => {
if (containerRef.current) {
const rect = containerRef.current.getBoundingClientRect();
setWidth(rect.width);
}
};
updateWidth();
globalThis.addEventListener("resize", updateWidth);
return () => globalThis.removeEventListener("resize", updateWidth);
}, []);
// Load chart data
useEffect(() => {
const load = async () => {
try {
let qs = '';
switch(rangeMode.value) {
case "custom": {
qs = new URLSearchParams({
start: customRangeStart.value.toISOString(),
end: customRangeEnd.value.toISOString(),
}).toString();
break;
}
case "minutes":
case "hours":
case "days":
case "months":
case "weeks":
qs = new URLSearchParams({
[rangeMode.value]: rangeValue.value.toString()
}).toString();
break;
}
const res = await fetch(`${endpoint}?${qs}`);
const json = await res.json();
const points = json.data.map((d: any) => ({
timestamp: new Date(d.timestamp),
value: d.value,
label: d.label,
})) as Chart.DataPoint[];
setData(points);
} catch (err) {
console.error("Chart load failed:", err);
setData([]);
} finally {
setLoading(false);
}
};
load();
}, [endpoint, rangeMode.value, rangeValue.value, customRangeStart.value, customRangeEnd.value]);
// Nearest point from hoverX
let nearest: Chart.DataPoint | null = null;
let hoverY: number | null = null;
if (hoverX !== null && data && data.length > 0) {
const step = (width - 36) / (data.length - 1);
const index = Math.round((hoverX - 36) / step);
nearest = data[Math.max(0, Math.min(index, data.length - 1))];
if (nearest) {
const values = data.map(d => d.value);
const min = Math.min(...values);
const max = Math.max(...values);
const range = max - min || 1;
hoverY = height - ((nearest.value - min) / range) * height;
}
}
// Pointer handlers
const handlePointer = (clientX: number) => {
if (!containerRef.current) return;
const bounds = containerRef.current.getBoundingClientRect();
const x = clientX - bounds.left;
setHoverX(x);
};
const eventHandlers = {
onMouseMove: (e: MouseEvent | TouchEvent) => {
if(e instanceof MouseEvent) {
handlePointer(e.clientX)
} else if(globalThis.TouchEvent && e instanceof TouchEvent) {
handlePointer(e.touches[e.touches.length-1].clientX);
}
},
onMouseLeave: () => {
setHoverX(null);
},
onTouchStart: (e: TouchEvent) => {
handlePointer(e.touches[0].clientX);
},
onTouchMove: (e: TouchEvent) => {
handlePointer(e.touches[0].clientX);
},
onTouchEnd: () => {
}
};
const Plugin = ChartPlugins[type];
return (
<div class="bg-base-100 rounded-xl shadow p-4 space-y-4 w-128 max-w-full">
<div class="flex flex-col">
<span class="font-semibold text-base-content text-sm">{name}</span>
{description && (
<span class="text-xs text-base-content/60">{description}</span>
)}
</div>
<div ref={containerRef} class="h-40 w-full relative touch-none">
{loading ? (
<div class="skeleton h-full w-full rounded" />
) : data && data.length > 0 && Plugin ? (
<Plugin
data={data}
width={width}
height={height}
hoverX={hoverX}
hoverY={hoverY}
nearest={nearest}
eventHandlers={{
onMouseLeave: eventHandlers.onMouseLeave,
onMouseMove: eventHandlers.onMouseMove,
onTouchStart: eventHandlers.onTouchStart,
onTouchEnd: eventHandlers.onTouchEnd,
onTouchMove: eventHandlers.onTouchMove,
}}
/>
) : (
<div class="text-xs italic text-base-content/40 h-full flex items-center justify-center">
No data available
</div>
)}
</div>
</div>
);
}