293 lines
7 KiB
TypeScript
293 lines
7 KiB
TypeScript
import type { Component } from "solid-js";
|
|
import { createEffect, createSignal, mergeProps, on, onCleanup, onMount } from "solid-js";
|
|
import { unwrap } from "solid-js/store";
|
|
|
|
import type { Ref } from "@solid-primitives/refs";
|
|
import { mergeRefs } from "@solid-primitives/refs";
|
|
import type {
|
|
ChartComponent,
|
|
ChartData,
|
|
ChartItem,
|
|
ChartOptions,
|
|
ChartType,
|
|
ChartTypeRegistry,
|
|
Plugin as ChartPlugin,
|
|
TooltipModel,
|
|
} from "chart.js";
|
|
import {
|
|
ArcElement,
|
|
BarController,
|
|
BarElement,
|
|
BubbleController,
|
|
CategoryScale,
|
|
Chart,
|
|
Colors,
|
|
DoughnutController,
|
|
Filler,
|
|
Legend,
|
|
LinearScale,
|
|
LineController,
|
|
LineElement,
|
|
PieController,
|
|
PointElement,
|
|
PolarAreaController,
|
|
RadarController,
|
|
RadialLinearScale,
|
|
ScatterController,
|
|
Tooltip,
|
|
} from "chart.js";
|
|
import { WordCloudController, WordElement } from "chartjs-chart-wordcloud";
|
|
import ChartDeferred from "chartjs-plugin-deferred";
|
|
import ChartZoom from "chartjs-plugin-zoom";
|
|
|
|
Chart.register(ChartDeferred, ChartZoom);
|
|
|
|
interface TypedChartProps {
|
|
data: ChartData;
|
|
options?: ChartOptions;
|
|
plugins?: ChartPlugin[];
|
|
ref?: Ref<HTMLCanvasElement | null>;
|
|
width?: number | undefined;
|
|
height?: number | undefined;
|
|
}
|
|
|
|
type ChartProps = TypedChartProps & {
|
|
type: ChartType;
|
|
};
|
|
|
|
interface ChartContext {
|
|
chart: Chart;
|
|
tooltip: TooltipModel<keyof ChartTypeRegistry>;
|
|
}
|
|
|
|
const BaseChart: Component<ChartProps> = (rawProps) => {
|
|
const [canvasRef, setCanvasRef] = createSignal<HTMLCanvasElement | null>();
|
|
const [chart, setChart] = createSignal<Chart>();
|
|
|
|
const props = mergeProps(
|
|
{
|
|
width: 512,
|
|
height: 512,
|
|
options: { responsive: true } as ChartOptions,
|
|
plugins: [] as ChartPlugin[],
|
|
},
|
|
rawProps,
|
|
);
|
|
|
|
const init = () => {
|
|
const ctx = canvasRef()?.getContext("2d") as ChartItem;
|
|
const config = unwrap(props);
|
|
const chart = new Chart(ctx, {
|
|
type: config.type,
|
|
data: config.data,
|
|
options: config.options,
|
|
plugins: config.plugins,
|
|
});
|
|
setChart(chart);
|
|
};
|
|
|
|
onMount(() => {
|
|
init();
|
|
});
|
|
|
|
createEffect(
|
|
on(
|
|
() => props.data,
|
|
() => {
|
|
chart()!.data = props.data;
|
|
chart()!.update();
|
|
},
|
|
{ defer: true },
|
|
),
|
|
);
|
|
|
|
createEffect(
|
|
on(
|
|
() => props.options,
|
|
() => {
|
|
chart()!.options = props.options;
|
|
chart()!.update();
|
|
},
|
|
{ defer: true },
|
|
),
|
|
);
|
|
|
|
createEffect(
|
|
on(
|
|
[() => props.width, () => props.height],
|
|
() => {
|
|
chart()!.resize(props.width, props.height);
|
|
},
|
|
{ defer: true },
|
|
),
|
|
);
|
|
|
|
createEffect(
|
|
on(
|
|
() => props.type,
|
|
() => {
|
|
const dimensions = [chart()!.width, chart()!.height];
|
|
chart()!.destroy();
|
|
init();
|
|
chart()!.resize(...dimensions);
|
|
},
|
|
{ defer: true },
|
|
),
|
|
);
|
|
|
|
onCleanup(() => {
|
|
chart()?.destroy();
|
|
mergeRefs(props.ref, null);
|
|
});
|
|
|
|
Chart.register(Colors, Filler, Legend, Tooltip);
|
|
return (
|
|
<canvas
|
|
ref={mergeRefs(props.ref, (el) => setCanvasRef(el))}
|
|
height={props.height}
|
|
width={props.width}
|
|
/>
|
|
);
|
|
};
|
|
|
|
function showTooltip(context: ChartContext) {
|
|
let el = document.getElementById("chartjs-tooltip");
|
|
if (!el) {
|
|
el = document.createElement("div");
|
|
el.id = "chartjs-tooltip";
|
|
document.body.appendChild(el);
|
|
}
|
|
|
|
const model = context.tooltip;
|
|
if (model.opacity === 0 || !model.body) {
|
|
el.style.opacity = "0";
|
|
return;
|
|
}
|
|
|
|
el.className = `p-2 bg-card text-card-foreground rounded-lg border shadow-sm text-sm ${
|
|
model.yAlign ?? `no-transform`
|
|
}`;
|
|
|
|
let content = "";
|
|
|
|
model.title.forEach((title) => {
|
|
content += `<h3 class="font-semibold leading-none tracking-tight">${title}</h3>`;
|
|
});
|
|
|
|
content += `<div class="mt-1 text-muted-foreground">`;
|
|
const body = model.body.flatMap((body) => body.lines);
|
|
body.forEach((line, i) => {
|
|
const colors = model.labelColors[i];
|
|
content += `
|
|
<div class="flex items-center">
|
|
<span class="inline-block h-2 w-2 mr-1 rounded-full border" style="background: ${colors.backgroundColor}; border-color: ${colors.borderColor}"></span>
|
|
${line}
|
|
</div>`;
|
|
});
|
|
content += `</div>`;
|
|
|
|
el.innerHTML = content;
|
|
|
|
const pos = context.chart.canvas.getBoundingClientRect();
|
|
el.style.opacity = "1";
|
|
el.style.position = "absolute";
|
|
el.style.left = `${(pos.left + window.scrollX + model.caretX).toString()}px`;
|
|
el.style.top = `${(pos.top + window.scrollY + model.caretY).toString()}px`;
|
|
el.style.pointerEvents = "none";
|
|
}
|
|
|
|
function createTypedChart(type: ChartType, components: ChartComponent[]): Component<TypedChartProps> {
|
|
const chartsWithScales: ChartType[] = ["bar", "line", "scatter"];
|
|
const chartsWithLegends: ChartType[] = ["bar", "line"];
|
|
|
|
const options: ChartOptions = {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
scales: chartsWithScales.includes(type)
|
|
? {
|
|
x: {
|
|
border: { display: false },
|
|
grid: { display: false },
|
|
},
|
|
y: {
|
|
border: {
|
|
dash: [3],
|
|
dashOffset: 3,
|
|
display: false,
|
|
},
|
|
grid: {
|
|
color: "hsla(240, 3.8%, 46.1%, 0.4)",
|
|
},
|
|
},
|
|
}
|
|
: {},
|
|
plugins: {
|
|
legend: chartsWithLegends.includes(type)
|
|
? {
|
|
display: true,
|
|
align: "end",
|
|
labels: {
|
|
usePointStyle: true,
|
|
boxWidth: 6,
|
|
boxHeight: 6,
|
|
color: "hsl(240, 3.8%, 46.1%)",
|
|
font: { size: 14 },
|
|
},
|
|
}
|
|
: { display: false },
|
|
tooltip: {
|
|
enabled: false,
|
|
external: (context) => {
|
|
showTooltip(context);
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
Chart.register(...components);
|
|
return (props) => (
|
|
<BaseChart
|
|
type={type}
|
|
options={options}
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const BarChart = /* #__PURE__ */ createTypedChart("bar", [BarController, BarElement, CategoryScale, LinearScale]);
|
|
const BubbleChart = /* #__PURE__ */ createTypedChart("bubble", [BubbleController, PointElement, LinearScale]);
|
|
const DonutChart = /* #__PURE__ */ createTypedChart("doughnut", [DoughnutController, ArcElement]);
|
|
const LineChart = /* #__PURE__ */ createTypedChart("line", [
|
|
LineController,
|
|
LineElement,
|
|
PointElement,
|
|
CategoryScale,
|
|
LinearScale,
|
|
]);
|
|
const PieChart = /* #__PURE__ */ createTypedChart("pie", [PieController, ArcElement]);
|
|
const PolarAreaChart = /* #__PURE__ */ createTypedChart("polarArea", [
|
|
PolarAreaController,
|
|
ArcElement,
|
|
RadialLinearScale,
|
|
]);
|
|
const RadarChart = /* #__PURE__ */ createTypedChart("radar", [
|
|
RadarController,
|
|
LineElement,
|
|
PointElement,
|
|
RadialLinearScale,
|
|
]);
|
|
const ScatterChart = /* #__PURE__ */ createTypedChart("scatter", [ScatterController, PointElement, LinearScale]);
|
|
const WordCloudChart = /* #__PURE__ */ createTypedChart("wordCloud", [WordCloudController, WordElement]);
|
|
|
|
export {
|
|
BarChart,
|
|
BubbleChart,
|
|
BaseChart as Chart,
|
|
DonutChart,
|
|
LineChart,
|
|
PieChart,
|
|
PolarAreaChart,
|
|
RadarChart,
|
|
ScatterChart,
|
|
WordCloudChart,
|
|
};
|