Skip to content

Commit b1a25ce

Browse files
chore: wip: new graph component
Signed-off-by: Henry Gressmann <[email protected]>
1 parent 1a9dac1 commit b1a25ce

File tree

4 files changed

+315
-0
lines changed

4 files changed

+315
-0
lines changed

web/bun.lockb

0 Bytes
Binary file not shown.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
.graph {
2+
height: 100%;
3+
}
4+
5+
.tooltip {
6+
background-color: var(--pico-secondary-background);
7+
padding: 0.4rem 0.5rem;
8+
border-radius: 0.4rem;
9+
min-width: 7rem;
10+
11+
h2 {
12+
margin-bottom: 0.3rem;
13+
font-size: 1rem;
14+
color: var(--pico-contrast);
15+
}
16+
17+
h3 {
18+
font-size: 1rem;
19+
display: flex;
20+
justify-content: space-between;
21+
margin: 0;
22+
color: var(--pico-contrast);
23+
font-weight: 800;
24+
align-items: center;
25+
26+
span {
27+
color: var(--pico-h3-color);
28+
padding: 0.1rem 0.2rem;
29+
font-weight: 500;
30+
border-radius: 0.2rem;
31+
}
32+
}
33+
}
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2+
import styles from "./graph.module.css";
3+
4+
import { ResponsiveLine, type SliceTooltipProps } from "@nivo/line";
5+
import { useWindowSize } from "@uidotdev/usehooks";
6+
import { addMonths } from "date-fns";
7+
import type { DataPoint } from ".";
8+
import type { Metric } from "../../api";
9+
import type { DateRange } from "../../api/ranges";
10+
import { formatMetricVal } from "../../utils";
11+
import { Tooltip } from "react-tooltip";
12+
import { scaleLinear, scaleUtc } from "d3-scale";
13+
import { extent } from "d3-array";
14+
import { area, curveMonotoneX } from "d3-shape";
15+
import { select } from "d3-selection";
16+
import { easeCubic, easeLinear } from "d3-ease";
17+
18+
export type GraphRange = "year" | "month" | "day" | "hour";
19+
20+
const formatDate = (date: Date, range: GraphRange | "day+hour" | "day+year" = "day") => {
21+
switch (range) {
22+
case "day+year":
23+
return Intl.DateTimeFormat("en-US", { year: "numeric", month: "short", day: "numeric" }).format(date);
24+
case "year":
25+
return Intl.DateTimeFormat("en-US", { year: "numeric" }).format(date);
26+
case "month":
27+
return Intl.DateTimeFormat("en-US", { month: "short" }).format(addMonths(date, 1));
28+
case "day":
29+
return Intl.DateTimeFormat("en-US", { month: "short", day: "numeric" }).format(date);
30+
case "day+hour":
31+
return Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", hour: "numeric" }).format(date);
32+
case "hour":
33+
return Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "numeric" }).format(date);
34+
}
35+
};
36+
37+
// const Tooltip = (props: SliceTooltipProps & { title: string; range: DateRange; metric: Metric }) => {
38+
// const point = props.slice.points[0].data;
39+
// const value = point.y.valueOf() as number;
40+
41+
// return (
42+
// <div data-theme="dark" className={styles.tooltip}>
43+
// <h2>{props.title}</h2>
44+
// <h3>
45+
// <span>{formatDate(new Date(point.x), props.range.getTooltipRange())}</span>{" "}
46+
// {formatMetricVal(value, props.metric)}
47+
// </h3>
48+
// </div>
49+
// );
50+
// };
51+
52+
export const LineGraph2 = ({
53+
data,
54+
title,
55+
range,
56+
metric,
57+
}: {
58+
data: DataPoint[];
59+
title: string;
60+
range: DateRange;
61+
metric: Metric;
62+
}) => {
63+
const svgRef = useRef<SVGSVGElement | null>(null);
64+
const svgBackgroundRef = useRef<SVGPathElement | null>(null);
65+
const svgLineRef = useRef<SVGPathElement | null>(null);
66+
const containerRef = useRef<HTMLDivElement | null>(null);
67+
68+
// get the container size using a resize observer
69+
const [dimensions, setDimensions] = useState({ width: 800, height: 500 });
70+
useEffect(() => {
71+
if (containerRef.current) {
72+
const observer = new ResizeObserver((entries) => {
73+
for (const {
74+
contentRect: { width, height },
75+
} of entries) {
76+
setDimensions({ width, height });
77+
}
78+
});
79+
observer.observe(containerRef.current);
80+
return () => observer.disconnect();
81+
}
82+
}, []);
83+
84+
const axisRange = range.getAxisRange();
85+
86+
const updateGraph = useCallback(
87+
(transition: number, ease: (normalizedTime: number) => number = easeCubic) => {
88+
if (!svgBackgroundRef.current || !svgLineRef.current) return;
89+
90+
const [minX, maxX] = extent(data, (d) => d.x);
91+
const [minY, maxY] = extent(data, (d) => d.y);
92+
93+
const paddingBottom = 20;
94+
const xAxis = scaleUtc([minX || 0, maxX || 0], [0, dimensions.width]);
95+
const yAxis = scaleLinear([0, Math.max((maxY || 0) * 1.4, 20)], [dimensions.height - paddingBottom, 0]);
96+
97+
const svgArea = area<DataPoint>()
98+
.x((d) => xAxis(d.x))
99+
.y0(yAxis(0) + paddingBottom)
100+
.y1((d) => yAxis(d.y));
101+
svgArea.length;
102+
103+
const svgLine = area<DataPoint>()
104+
.x((d) => xAxis(d.x))
105+
.y((d) => yAxis(d.y));
106+
107+
select(svgBackgroundRef.current)
108+
.transition()
109+
.ease(ease)
110+
.duration(transition)
111+
.attr("d", svgArea(data) || "");
112+
113+
select(svgLineRef.current)
114+
.transition()
115+
.ease(ease)
116+
.duration(transition)
117+
.attr("d", svgLine(data) || "");
118+
},
119+
[dimensions, data],
120+
);
121+
122+
// biome-ignore lint/correctness/useExhaustiveDependencies: only need to run this effect when dimensions change
123+
useEffect(() => updateGraph(20, easeLinear), [dimensions]);
124+
// biome-ignore lint/correctness/useExhaustiveDependencies: only need to run this effect when data changes
125+
useEffect(() => updateGraph(500), [data]);
126+
127+
return (
128+
<div ref={containerRef} className={styles.graph} data-tooltip-float={true} data-tooltip-id="graph">
129+
<svg
130+
ref={svgRef}
131+
style={{ display: "block", width: "100%", height: "100%" }}
132+
// height={dimensions.height}
133+
// width={dimensions.width}
134+
// viewBox={`0 0 ${dimensions.width} ${dimensions.height}`}
135+
>
136+
<defs>
137+
<linearGradient id="graphGradient" x1="0" x2="0" y1="0" y2="1">
138+
<stop offset="0%" stopColor="rgba(166, 206, 227, 0.5)" />
139+
<stop offset="100%" stopColor="rgba(166, 206, 227, 0)" />
140+
</linearGradient>
141+
</defs>
142+
<title>Graph</title>
143+
<path ref={svgBackgroundRef} fill="url(#graphGradient)" stroke="none" />
144+
<path ref={svgLineRef} fill="none" stroke="#a6cee3" />
145+
</svg>
146+
147+
<Tooltip id="map" className={styles.tooltipContainer} classNameArrow={styles.reset} disableStyleInjection>
148+
{/* <div data-theme="dark" className={styles.tooltip}>
149+
<h2>{props.title}</h2>
150+
<h3>
151+
<span>{formatDate(new Date(point.x), props.range.getTooltipRange())}</span>{" "}
152+
{formatMetricVal(value, props.metric)}
153+
</h3>
154+
</div> */}
155+
</Tooltip>
156+
</div>
157+
);
158+
159+
// return (
160+
// <ResponsiveLine
161+
// data={[{ data, id: "data", color: "hsl(0, 70%, 50%)" }]}
162+
// margin={{ top: 10, right: 40, bottom: 30, left: 40 }}
163+
// xScale={{ type: "time" }}
164+
// yScale={{
165+
// type: "linear",
166+
// nice: true,
167+
// min: 0,
168+
// max: Math.max(Math.ceil(max * 1.1), 5),
169+
// }}
170+
// enableGridX={false}
171+
// gridYValues={yCount}
172+
// enableArea={true}
173+
// enablePoints={false}
174+
// curve="monotoneX"
175+
// axisTop={null}
176+
// axisRight={null}
177+
// axisBottom={{
178+
// legend: "",
179+
// format: (value: Date) => formatDate(value, axisRange),
180+
// tickValues: xCount,
181+
// }}
182+
// axisLeft={{
183+
// legend: "",
184+
// tickValues: yCount,
185+
// }}
186+
// pointSize={10}
187+
// pointColor={{ theme: "background" }}
188+
// pointBorderWidth={2}
189+
// pointBorderColor={{ from: "serieColor" }}
190+
// pointLabel="data.yFormatted"
191+
// pointLabelYOffset={-12}
192+
// enableSlices="x"
193+
// sliceTooltip={tooltip}
194+
// defs={[
195+
// {
196+
// colors: [
197+
// { color: "inherit", offset: 0 },
198+
// { color: "inherit", offset: 100, opacity: 0 },
199+
// ],
200+
// id: "gradientA",
201+
// type: "linearGradient",
202+
// },
203+
// ]}
204+
// fill={[
205+
// {
206+
// id: "gradientA",
207+
// match: "*",
208+
// },
209+
// ]}
210+
// colors={{
211+
// scheme: "paired",
212+
// }}
213+
// useMesh={true}
214+
// theme={{
215+
// crosshair: { line: { stroke: "var(--pico-color)", strokeWidth: 2 } },
216+
// axis: {
217+
// domain: {
218+
// line: { strokeWidth: 0 },
219+
// },
220+
// legend: {
221+
// text: { color: "var(--pico-color)" },
222+
// },
223+
224+
// // axis label color
225+
// ticks: {
226+
// line: { strokeWidth: 0 },
227+
// text: { fill: "var(--pico-color)" },
228+
// },
229+
// },
230+
// grid: {
231+
// line: {
232+
// stroke: "var(--pico-secondary-background)",
233+
// strokeWidth: 0.3,
234+
// },
235+
// },
236+
// }}
237+
// />
238+
// );
239+
};
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { differenceInSeconds, endOfDay, endOfHour, endOfMonth, endOfYear } from "date-fns";
2+
import _graph from "./graph.module.css";
3+
4+
import { lazy } from "react";
5+
import type { Metric } from "../../api";
6+
import type { DateRange } from "../../api/ranges.ts";
7+
8+
export const LineGraph2 = lazy(() =>
9+
import("./graph.tsx").then(({ LineGraph2: LineGraph }) => ({ default: LineGraph })),
10+
);
11+
12+
export type DataPoint = {
13+
x: Date;
14+
y: number;
15+
};
16+
17+
export const toDataPoints = (data: number[], range: DateRange, metric: Metric): DataPoint[] => {
18+
const step = differenceInSeconds(range.value.end, range.value.start) / data.length;
19+
return data
20+
.map((value, i) => ({
21+
x: new Date(range.value.start.getTime() + i * step * 1000 + 1000),
22+
y: value,
23+
}))
24+
.filter((p) => {
25+
if (range.getGraphRange() === "hour") {
26+
// filter out points after this hour
27+
return p.x < endOfHour(new Date());
28+
}
29+
if (range.getGraphRange() === "day") {
30+
// filter out points after today
31+
return p.x < endOfDay(new Date());
32+
}
33+
if (range.getGraphRange() === "month") {
34+
// filter out points after this month
35+
return p.x < endOfMonth(new Date());
36+
}
37+
if (range.getGraphRange() === "year") {
38+
// filter out points after this year
39+
return p.x < endOfYear(new Date());
40+
}
41+
return true;
42+
});
43+
};

0 commit comments

Comments
 (0)