Skip to content

Commit e07ba8e

Browse files
feat: package charts (#196)
* feat: add line graph * feat: yearly line graph * wip * feat: add months grouping * chore: self review * chore: bump version * chore: self review --------- Co-authored-by: Tom Schönmann <[email protected]>
1 parent 528cc32 commit e07ba8e

File tree

9 files changed

+831
-1313
lines changed

9 files changed

+831
-1313
lines changed
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
[
2+
{
3+
"downloads": [
4+
{ "day": "2024-10-02", "downloads": 35 },
5+
{ "day": "2024-10-03", "downloads": 5 },
6+
{ "day": "2024-10-04", "downloads": 2 },
7+
{ "day": "2024-10-05", "downloads": 34 },
8+
{ "day": "2024-10-06", "downloads": 1 },
9+
{ "day": "2024-10-07", "downloads": 2 },
10+
{ "day": "2024-10-08", "downloads": 36 },
11+
{ "day": "2024-10-09", "downloads": 3 },
12+
{ "day": "2024-10-11", "downloads": 39 },
13+
{ "day": "2024-10-12", "downloads": 4 },
14+
{ "day": "2024-10-13", "downloads": 1 },
15+
{ "day": "2024-10-14", "downloads": 38 },
16+
{ "day": "2024-10-15", "downloads": 3 },
17+
{ "day": "2024-10-16", "downloads": 3 },
18+
{ "day": "2024-10-17", "downloads": 33 },
19+
{ "day": "2024-10-18", "downloads": 3 },
20+
{ "day": "2024-10-19", "downloads": 4 },
21+
{ "day": "2024-10-20", "downloads": 38 },
22+
{ "day": "2024-10-21", "downloads": 2 },
23+
{ "day": "2024-10-22", "downloads": 6 },
24+
{ "day": "2024-10-23", "downloads": 38 },
25+
{ "day": "2024-10-24", "downloads": 7 },
26+
{ "day": "2024-10-25", "downloads": 2 },
27+
{ "day": "2024-10-26", "downloads": 1 },
28+
{ "day": "2024-10-27", "downloads": 36 },
29+
{ "day": "2024-10-28", "downloads": 6 },
30+
{ "day": "2024-10-29", "downloads": 2 },
31+
{ "day": "2024-10-30", "downloads": 39 },
32+
{ "day": "2024-10-31", "downloads": 5 }
33+
],
34+
"start": "2024-10-02",
35+
"end": "2024-10-31",
36+
"package": "xadmix"
37+
}
38+
]

web/app/mocks/fixtures/packge.xadmix.daily-downloads-year.json

Lines changed: 352 additions & 0 deletions
Large diffs are not rendered by default.

web/app/mocks/handlers.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { http, HttpResponse } from "msw";
22
import { ENV } from "../data/env";
33
import xadmix from "./fixtures/package.xadmix.json";
4+
import xadmixDailiesMonth from "./fixtures/package.xadmix.daily-downloads-month.json";
5+
import xadmixDailiesYear from "./fixtures/packge.xadmix.daily-downloads-year.json";
46
import GaussSuppression from "./fixtures/package.gauss-surpression.json";
57
import allAuthors from "./fixtures/author.all.json";
68
import allPackages from "./fixtures/package.all.json";
7-
89
export const handlers = [
910
http.get(
1011
`${ENV.VITE_SELECT_PKG_URL.replace("{{id}}", "GaussSuppression")}`,
@@ -15,6 +16,15 @@ export const handlers = [
1516
http.get(ENV.VITE_SELECT_PKG_URL.replace("{{id}}", "xadmix"), () => {
1617
return HttpResponse.json(xadmix);
1718
}),
19+
http.get(
20+
"https://cranlogs.r-pkg.org/downloads/daily/last-month/xadmix",
21+
() => {
22+
return HttpResponse.json(xadmixDailiesMonth);
23+
},
24+
),
25+
http.get("https://cranlogs.r-pkg.org/downloads/daily/*/xadmix", () => {
26+
return HttpResponse.json(xadmixDailiesYear);
27+
}),
1828
// Authors map.
1929
http.get(ENV.VITE_AP_PKGS_URL, () => {
2030
return HttpResponse.json(allAuthors);

web/app/modules/charts.line.tsx

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
import { useState, useEffect, useRef } from "react";
2+
import { format } from "date-fns";
3+
import clsx from "clsx";
4+
5+
type DataPoint = {
6+
date: string;
7+
value: number;
8+
};
9+
10+
type LineGraphProps = {
11+
data: DataPoint[];
12+
height?: number;
13+
padding?: number;
14+
};
15+
16+
export function LineGraph({
17+
data,
18+
height = 400,
19+
padding = 16,
20+
}: LineGraphProps) {
21+
const [hoveredPoint, setHoveredPoint] = useState<DataPoint | null>(null);
22+
const [hoveredMonth, setHoveredMonth] = useState<string | null>(null);
23+
const containerRef = useRef<HTMLDivElement | null>(null);
24+
const [width, setWidth] = useState(800);
25+
26+
useEffect(() => {
27+
const handleResize = () => {
28+
if (containerRef.current) {
29+
setWidth(containerRef.current.offsetWidth);
30+
}
31+
};
32+
handleResize();
33+
window.addEventListener("resize", handleResize);
34+
return () => window.removeEventListener("resize", handleResize);
35+
}, []);
36+
37+
// Find min and max values for scaling
38+
const minValue = Math.min(...data.map((d) => d.value));
39+
const maxValue = Math.max(...data.map((d) => d.value));
40+
41+
// Calculate x and y scaling functions
42+
const xScale = (index: number) =>
43+
padding + ((width - 2 * padding) / (data.length - 1)) * index;
44+
const yScale = (value: number) =>
45+
height -
46+
padding -
47+
((value - minValue) / (maxValue - minValue)) * (height - 2 * padding);
48+
49+
// Generate path for smooth line using cubic Bezier curves
50+
const linePath = data.reduce((path, d, i, arr) => {
51+
const x = xScale(i);
52+
const y = yScale(d.value);
53+
if (i === 0) {
54+
return `M ${x},${y}`;
55+
}
56+
const prevX = xScale(i - 1);
57+
const prevY = yScale(arr[i - 1].value);
58+
const controlX1 = prevX + (x - prevX) / 3;
59+
const controlY1 = prevY;
60+
const controlX2 = x - (x - prevX) / 3;
61+
const controlY2 = y;
62+
return `${path} C ${controlX1},${controlY1} ${controlX2},${controlY2} ${x},${y}`;
63+
}, "");
64+
65+
const nrFormatter = new Intl.NumberFormat("en-US");
66+
67+
// Calculate total downloads per month
68+
const downloadsPerMonth = data.reduce(
69+
(acc, d) => {
70+
const month = format(new Date(d.date), "MMM yyyy");
71+
acc[month] = (acc[month] || 0) + d.value;
72+
return acc;
73+
},
74+
{} as Record<string, number>,
75+
);
76+
77+
const months = Object.keys(downloadsPerMonth);
78+
const minDownloads = Math.min(...Object.values(downloadsPerMonth));
79+
const maxDownloads = Math.max(...Object.values(downloadsPerMonth));
80+
81+
return (
82+
<div ref={containerRef} className="text-gray-normal relative w-full">
83+
<svg width={width} height={height} className="bg-transparent">
84+
{/* Smooth Line Path */}
85+
<path
86+
d={linePath}
87+
fill="none"
88+
stroke="currentColor"
89+
strokeWidth={2}
90+
strokeLinecap="round"
91+
strokeLinejoin="round"
92+
/>
93+
94+
{/* Vertical hover areas */}
95+
{data.map((d, i) => (
96+
<rect
97+
key={i}
98+
x={xScale(i) - (width - 2 * padding) / (2 * (data.length - 1))}
99+
y={0}
100+
width={(width - 2 * padding) / (data.length - 1)}
101+
height={height}
102+
fill="transparent"
103+
onMouseEnter={() => setHoveredPoint(d)}
104+
onMouseLeave={() => setHoveredPoint(null)}
105+
aria-label={`Date: ${format(new Date(d.date), "MMM dd, yyyy")}, Value: ${d.value}`}
106+
/>
107+
))}
108+
109+
{/* Points */}
110+
{data.map((d, i) => (
111+
<circle
112+
key={i}
113+
cx={xScale(i)}
114+
cy={yScale(d.value)}
115+
r={1}
116+
fill="currentColor"
117+
className="cursor-pointer"
118+
aria-hidden="true"
119+
/>
120+
))}
121+
</svg>
122+
{/* Hover tooltip */}
123+
{hoveredPoint && (
124+
<div
125+
className="text-gray-normal absolute cursor-auto rounded-md p-2 text-center text-sm shadow backdrop-blur-sm"
126+
style={{
127+
left: `${xScale(data.findIndex((d) => d === hoveredPoint))}px`,
128+
top: `${yScale(hoveredPoint.value) - 50}px`,
129+
transform: "translate(-50%, -100%)",
130+
}}
131+
>
132+
<div className="font-mono font-semibold">
133+
{nrFormatter.format(hoveredPoint.value)}{" "}
134+
{hoveredPoint.value === 1 ? "download" : "downloads"}
135+
</div>
136+
<div>{format(new Date(hoveredPoint.date), "MMM dd, yyyy")}</div>
137+
</div>
138+
)}
139+
140+
{/* Monthly Downloads Distribution */}
141+
<div className="mt-8 flex w-full">
142+
{months.map((month, i) => {
143+
// 'month' is e.g. 'Jan 2022', we only want the
144+
// short month name to be 'Jan '22'.
145+
const shortMonth = month.slice(0, 3);
146+
const shortYear = month.slice(-2);
147+
const isFirst = i === 0;
148+
const isLast = i === months.length - 1;
149+
150+
return (
151+
<div
152+
key={month}
153+
className={`text-gray-normal relative flex flex-col items-center justify-start gap-2 text-center text-xs first:rounded-s-md last:rounded-e-md`}
154+
style={{ flex: downloadsPerMonth[month] }}
155+
onMouseEnter={() => setHoveredMonth(month)}
156+
onMouseLeave={() => setHoveredMonth(null)}
157+
>
158+
<div
159+
className={clsx(
160+
"h-4 w-full",
161+
getColor(
162+
downloadsPerMonth[month],
163+
minDownloads,
164+
maxDownloads,
165+
),
166+
{
167+
"rounded-s-full": isFirst,
168+
"rounded-e-full": isLast,
169+
},
170+
)}
171+
/>
172+
<span className="text-gray-dim hidden lg:inline-block">
173+
{shortMonth}
174+
<br />
175+
&apos;{shortYear}
176+
</span>
177+
178+
{hoveredMonth === month && (
179+
<div className="text-gray-normal absolute bottom-full mb-2 w-max rounded-md p-2 text-center text-xs shadow-lg backdrop-blur-md">
180+
<div className="font-mono font-semibold">
181+
{nrFormatter.format(downloadsPerMonth[month])}{" "}
182+
{downloadsPerMonth[month] === 1 ? "download" : "downloads"}
183+
</div>
184+
<div>{month}</div>
185+
</div>
186+
)}
187+
</div>
188+
);
189+
})}
190+
</div>
191+
</div>
192+
);
193+
}
194+
195+
const colorClasses = [
196+
"bg-gray-12",
197+
"bg-iris-1",
198+
"bg-iris-2",
199+
"bg-iris-3",
200+
"bg-iris-4",
201+
"bg-iris-5",
202+
"bg-iris-6",
203+
"bg-iris-7",
204+
"bg-iris-8",
205+
"bg-iris-9",
206+
"bg-iris-10",
207+
"bg-iris-11",
208+
"bg-iris-12",
209+
];
210+
211+
// Generate color based on min-max scaling
212+
function getColor(downloads: number, min: number, max: number) {
213+
if (max === min || !downloads) {
214+
return "bg-gray-ui";
215+
}
216+
217+
const intensity = Math.min(Math.max((downloads - min) / (max - min), 0), 1);
218+
const level = Math.ceil(intensity * 7) + 5;
219+
220+
return colorClasses[level] || "bg-gray-ui";
221+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { Fragment } from "react/jsx-runtime";
2+
3+
type DataPoint = {
4+
label: string;
5+
value: number;
6+
};
7+
8+
type Props = {
9+
total: number;
10+
data: DataPoint[];
11+
};
12+
13+
export function StackedBarsChart(props: Props) {
14+
const { total, data } = props;
15+
16+
return (
17+
<div className="grid grid-cols-[auto,1fr] gap-4">
18+
{data.map(({ label, value }, i) => (
19+
<Fragment key={i}>
20+
<div className="text-sm">{label}</div>
21+
<div className="flex">
22+
<div
23+
className="rounded-md bg-iris-12"
24+
style={{ flex: value / total }}
25+
/>
26+
</div>
27+
</Fragment>
28+
))}
29+
</div>
30+
);
31+
}

0 commit comments

Comments
 (0)