|
| 1 | +import React, { RefObject, useEffect, useRef } from "react"; |
| 2 | +import ZoomPlugin from "chartjs-plugin-zoom"; |
| 3 | +import { |
| 4 | + Chart, |
| 5 | + ChartItem, |
| 6 | + ChartConfiguration, |
| 7 | + LineController, |
| 8 | + LineElement, |
| 9 | + PointElement, |
| 10 | + LinearScale, |
| 11 | + Title, |
| 12 | + CategoryScale, |
| 13 | + ChartType, |
| 14 | + Legend, |
| 15 | + Decimation, |
| 16 | + Tooltip, |
| 17 | +} from "chart.js"; |
| 18 | + |
| 19 | +Chart.register( |
| 20 | + LineController, |
| 21 | + LineElement, |
| 22 | + PointElement, |
| 23 | + LinearScale, |
| 24 | + CategoryScale, |
| 25 | + Title, |
| 26 | + Legend, |
| 27 | + Decimation, |
| 28 | + Tooltip, |
| 29 | + ZoomPlugin, |
| 30 | +); |
| 31 | + |
| 32 | +// ArrayLike<any> is used for data because chartjs supports TypedArrays and we |
| 33 | +// might use those too down the line |
| 34 | +interface LineChartProps { |
| 35 | + title: string; |
| 36 | + numRows: number; // total number of rows of data. Used to rerender the chart on updates |
| 37 | + dataX: ArrayLike<any>; // eg. timestamps |
| 38 | + dataY: ArrayLike<any>[]; // 1 or more y-value sets |
| 39 | + datasetNames?: string[]; // optional list of names to give each y-value set |
| 40 | + dataXUnits: string; |
| 41 | + dataYUnits: string; |
| 42 | +} |
| 43 | + |
| 44 | +const CHART_TYPE: ChartType = "line"; |
| 45 | + |
| 46 | +const unique_colors: (keyof DefaultColors)[] = [ |
| 47 | + "red", |
| 48 | + "amber", |
| 49 | + "lime", |
| 50 | + "cyan", |
| 51 | + "blue", |
| 52 | + "violet", |
| 53 | + "fuchsia", |
| 54 | + "pink", |
| 55 | +]; |
| 56 | +import colors from "tailwindcss/colors"; |
| 57 | +import { DefaultColors } from "tailwindcss/types/generated/colors"; |
| 58 | + |
| 59 | +function initChart( |
| 60 | + chartRef: RefObject<ChartItem>, |
| 61 | + { dataY, datasetNames, dataXUnits, dataYUnits }: LineChartProps, |
| 62 | +) { |
| 63 | + const config: ChartConfiguration<ChartType, (typeof dataY)[0], number> = { |
| 64 | + type: CHART_TYPE, |
| 65 | + data: { |
| 66 | + datasets: dataY.map((_, i) => ({ |
| 67 | + data: [], |
| 68 | + label: datasetNames ? datasetNames[i] : `Dataset ${i}`, |
| 69 | + backgroundColor: `${colors[unique_colors[i]]["900"]}`, |
| 70 | + borderColor: `${colors[unique_colors[i]]["700"]}`, |
| 71 | + radius: 0, |
| 72 | + })), |
| 73 | + // labels: [], |
| 74 | + }, |
| 75 | + options: { |
| 76 | + datasets: { |
| 77 | + line: { |
| 78 | + indexAxis: "x", |
| 79 | + parsing: false, |
| 80 | + normalized: true, |
| 81 | + pointRadius: 0, // too noisy |
| 82 | + borderWidth: 2, |
| 83 | + borderDash: [], |
| 84 | + spanGaps: true, |
| 85 | + tension: 0, |
| 86 | + stepped: false, |
| 87 | + }, |
| 88 | + }, |
| 89 | + maintainAspectRatio: false, |
| 90 | + responsive: true, |
| 91 | + animation: false, |
| 92 | + parsing: false, |
| 93 | + interaction: { |
| 94 | + mode: "index", |
| 95 | + // mode: "nearest", |
| 96 | + // axis: "x", |
| 97 | + intersect: true, |
| 98 | + }, |
| 99 | + plugins: { |
| 100 | + decimation: { |
| 101 | + enabled: true, |
| 102 | + // algorithm: "min-max", |
| 103 | + algorithm: "lttb", |
| 104 | + // samples: 50, |
| 105 | + // threshold: 50, |
| 106 | + }, |
| 107 | + legend: { |
| 108 | + display: true, |
| 109 | + position: "top", |
| 110 | + align: "center", |
| 111 | + labels: { |
| 112 | + font: { size: 12 }, |
| 113 | + boxWidth: 8, |
| 114 | + boxHeight: 8, |
| 115 | + color: colors.neutral[400], |
| 116 | + }, |
| 117 | + }, |
| 118 | + zoom: { |
| 119 | + pan: { |
| 120 | + enabled: true, |
| 121 | + mode: "x", |
| 122 | + }, |
| 123 | + limits: { |
| 124 | + y: { min: "original", max: "original" }, |
| 125 | + }, |
| 126 | + zoom: { |
| 127 | + mode: "x", |
| 128 | + wheel: { |
| 129 | + enabled: true, |
| 130 | + }, |
| 131 | + drag: { |
| 132 | + enabled: true, |
| 133 | + maintainAspectRatio: false, |
| 134 | + modifierKey: "shift", |
| 135 | + }, |
| 136 | + pinch: { |
| 137 | + enabled: true, |
| 138 | + }, |
| 139 | + }, |
| 140 | + }, |
| 141 | + title: { |
| 142 | + // We'll use ItemContainer's title instead |
| 143 | + display: false, |
| 144 | + }, |
| 145 | + tooltip: { |
| 146 | + enabled: true, |
| 147 | + animation: { |
| 148 | + duration: 100, |
| 149 | + }, |
| 150 | + }, |
| 151 | + }, |
| 152 | + aspectRatio: 1.3, |
| 153 | + scales: { |
| 154 | + x: { |
| 155 | + title: { |
| 156 | + display: dataXUnits != null, |
| 157 | + text: dataXUnits, |
| 158 | + font: { size: 14 }, |
| 159 | + color: colors["neutral"][500], |
| 160 | + }, |
| 161 | + type: "linear", |
| 162 | + ticks: { |
| 163 | + color: "rgba(255,255,255,.7)", |
| 164 | + maxRotation: 0, // Disabled rotation for performance |
| 165 | + autoSkip: true, |
| 166 | + }, |
| 167 | + grid: { display: false }, |
| 168 | + }, |
| 169 | + y: { |
| 170 | + title: { |
| 171 | + display: dataYUnits != null, |
| 172 | + text: dataYUnits, |
| 173 | + font: { size: 14 }, |
| 174 | + color: colors["neutral"][500], |
| 175 | + }, |
| 176 | + type: "linear", |
| 177 | + ticks: { |
| 178 | + color: "rgba(255,255,255,.7)", |
| 179 | + maxRotation: 0, // Disabled rotation for performance |
| 180 | + autoSkip: true, |
| 181 | + }, |
| 182 | + grid: { color: "rgba(255, 255, 255, 0.15)" }, |
| 183 | + }, |
| 184 | + }, |
| 185 | + }, |
| 186 | + }; |
| 187 | + |
| 188 | + return new Chart(chartRef.current!, config); |
| 189 | +} |
| 190 | + |
| 191 | +export default function LineChart(props: LineChartProps) { |
| 192 | + const { dataX, dataY, numRows, datasetNames: names } = props; |
| 193 | + |
| 194 | + for (const ySet of dataY) { |
| 195 | + if (ySet.length != dataX.length) { |
| 196 | + throw Error("dataY lists must have the same length as dataX!"); |
| 197 | + } |
| 198 | + } |
| 199 | + |
| 200 | + if (names && dataY.length != names.length) { |
| 201 | + throw Error("names list must have the same length as dataY"); |
| 202 | + } |
| 203 | + |
| 204 | + const chartRef = useRef<ChartItem>(null); |
| 205 | + const chartInstanceRef = useRef<Chart<ChartType, (typeof dataY)[0], number> | null>(null); |
| 206 | + |
| 207 | + // // The chart's own list of stored points, of length MAX_LENGTH. |
| 208 | + // // Keeping our chart points in a useState requires reassigning a |
| 209 | + // // new copy of the list to it on each render. Works for now |
| 210 | + // const [dataPoints, setDataPoints] = useState<[number, number][]>([]); |
| 211 | + |
| 212 | + useEffect(() => { |
| 213 | + chartInstanceRef.current = initChart(chartRef, props); |
| 214 | + |
| 215 | + return () => { |
| 216 | + chartInstanceRef.current?.destroy(); |
| 217 | + chartInstanceRef.current = null; |
| 218 | + }; |
| 219 | + }, []); |
| 220 | + |
| 221 | + useEffect(() => { |
| 222 | + if (chartInstanceRef.current && dataX && dataX.length > 0) { |
| 223 | + // assemble data |
| 224 | + for (const [idx, ySet] of dataY.filter((d) => d.length > 0).entries()) { |
| 225 | + chartInstanceRef.current.data.datasets[idx].data = [ |
| 226 | + ...Array.from(dataX).map((v, i) => ({ |
| 227 | + x: v, |
| 228 | + y: ySet[i], |
| 229 | + })), |
| 230 | + ]; |
| 231 | + } |
| 232 | + //todo: make 10s the min width somehow? |
| 233 | + // let data = chartInstanceRef.current.data.datasets[0].data; |
| 234 | + // if (data[0][0] - data[0][data.length - 1] < 10) { |
| 235 | + // } |
| 236 | + |
| 237 | + // console.log(chartInstanceRef.current.config.options?.indexAxis) |
| 238 | + // console.log(chartInstanceRef.current.data.datasets[0].parsing) |
| 239 | + |
| 240 | + // TODO: do we want to set scales manually? It should do this for us |
| 241 | + // just fine |
| 242 | + // // @ts-ignore-next // We know this exists already from the config above. smh |
| 243 | + // chartInstanceRef.current.config.options.scales.y = { |
| 244 | + // TODO: setting the min/max on each update causes a sort of |
| 245 | + // visual flickering and is probably very laggy. |
| 246 | + // min: dataY[0][0], |
| 247 | + // max: dataY[0][dataX.length - 1], |
| 248 | + // min: Math.floor(dataPoints[0][0]), |
| 249 | + // max: Math.ceil(dataPoints[dataPoints.length - 1][0]), |
| 250 | + // }; |
| 251 | + |
| 252 | + chartInstanceRef.current.update('none'); |
| 253 | + } |
| 254 | + }, [numRows]); |
| 255 | + |
| 256 | + return ( |
| 257 | + <div className="break-words"> |
| 258 | + <div className="relative"> |
| 259 | + {/* for some reason tsserver gets confused here... not idea why */} |
| 260 | + <canvas ref={chartRef as any}></canvas> |
| 261 | + </div> |
| 262 | + </div> |
| 263 | + ); |
| 264 | +} |
0 commit comments