Skip to content
251 changes: 131 additions & 120 deletions frontend/routes/package/(_islands)/DownloadChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,99 +13,96 @@ interface Props {

export type AggregationPeriod = "daily" | "weekly" | "monthly";

export function DownloadChart(props: Props) {
const chartDivRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<ApexCharts>(null);
const [graphRendered, setGraphRendered] = useState(false);

const getChartOptions = (
isDarkMode: boolean,
aggregationPeriod: AggregationPeriod = "weekly",
) => ({
chart: {
type: "area",
stacked: true,
animations: {
enabled: false,
},
height: "100%",
width: "100%",
zoom: {
allowMouseWheelZoom: false,
},
background: "transparent",
foreColor: isDarkMode ? "#a8b2bd" : "#515d6c", // jsr-gray-300 for dark mode, jsr-gray-600 for light
const getChartOptions = (
isDarkMode: boolean,
): ApexCharts.ApexOptions => ({
chart: {
type: "area",
stacked: true,
animations: {
enabled: false,
},
legend: {
horizontalAlign: "center",
position: "top",
showForSingleSeries: true,
labels: {
colors: isDarkMode ? "#a8b2bd" : "#515d6c", // jsr-gray-300 for dark mode, jsr-gray-600 for light
},
height: "100%",
width: "100%",
zoom: {
allowMouseWheelZoom: false,
},
background: "transparent",
foreColor: isDarkMode ? "#a8b2bd" : "#515d6c", // jsr-gray-300 for dark mode, jsr-gray-600 for light
},
legend: {
horizontalAlign: "center",
position: "top",
showForSingleSeries: true,
labels: {
colors: isDarkMode ? "#a8b2bd" : "#515d6c", // jsr-gray-300 for dark mode, jsr-gray-600 for light
},
},
tooltip: {
theme: isDarkMode ? "dark" : "light",
},
dataLabels: {
enabled: false,
},
stroke: {
curve: "straight",
width: 1.7,
},
xaxis: {
type: "datetime",
tooltip: {
items: {
padding: 0,
enabled: false,
},
labels: {
style: {
colors: isDarkMode ? "#ced3da" : "#515d6c", // jsr-gray-200 for dark mode, jsr-gray-600 for light
},
theme: isDarkMode ? "dark" : "light",
},
dataLabels: {
enabled: false,
axisBorder: {
color: isDarkMode ? "#47515c" : "#ced3da", // jsr-gray-700 for dark mode, jsr-gray-200 for light
},
stroke: {
curve: "straight",
width: 1.7,
axisTicks: {
color: isDarkMode ? "#47515c" : "#ced3da", // jsr-gray-700 for dark mode, jsr-gray-200 for light
},
series: getSeries(props.downloads, aggregationPeriod),
xaxis: {
type: "datetime",
tooltip: {
enabled: false,
},
labels: {
style: {
colors: isDarkMode ? "#ced3da" : "#515d6c", // jsr-gray-200 for dark mode, jsr-gray-600 for light
},
},
axisBorder: {
color: isDarkMode ? "#47515c" : "#ced3da", // jsr-gray-700 for dark mode, jsr-gray-200 for light
},
axisTicks: {
color: isDarkMode ? "#47515c" : "#ced3da", // jsr-gray-700 for dark mode, jsr-gray-200 for light
},
yaxis: {
labels: {
style: {
colors: isDarkMode ? "#a8b2bd" : "#515d6c", // jsr-gray-300 for dark mode, jsr-gray-600 for light
},
},
yaxis: {
labels: {
style: {
colors: isDarkMode ? "#a8b2bd" : "#515d6c", // jsr-gray-300 for dark mode, jsr-gray-600 for light
},
grid: {
borderColor: isDarkMode ? "#47515c" : "#e5e8eb", // jsr-gray-700 for dark mode, jsr-gray-100 for light
strokeDashArray: 3,
},
responsive: [
{
breakpoint: 768,
options: {
legend: {
horizontalAlign: "left",
},
},
},
grid: {
borderColor: isDarkMode ? "#47515c" : "#e5e8eb", // jsr-gray-700 for dark mode, jsr-gray-100 for light
strokeDashArray: 3,
},
responsive: [
{
breakpoint: 768,
options: {
legend: {
horizontalAlign: "left",
},
},
},
],
});
],
});

export function DownloadChart(props: Props) {
const chartDivRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<ApexCharts>(null);
const [graphRendered, setGraphRendered] = useState(false);

useEffect(() => {
(async () => {
const { default: ApexCharts } = await import("apexcharts");
const isDarkMode = document.documentElement.classList.contains("dark");

const initialOptions = getChartOptions(isDarkMode);
initialOptions.series = getSeries(props.downloads, "weekly");
chartRef.current = new ApexCharts(
chartDivRef.current!,
getChartOptions(isDarkMode),
initialOptions,
);

chartRef.current.render();
Expand Down Expand Up @@ -137,37 +134,52 @@ export function DownloadChart(props: Props) {
return (
<div class="relative">
{graphRendered && (
<div className="absolute flex items-center gap-2 pt-1 text-sm pl-5 z-20">
<label htmlFor="aggregationPeriod" className="text-secondary">
Aggregation Period:
</label>
<select
id="aggregationPeriod"
onChange={(e) => {
const isDarkMode = document.documentElement.classList.contains(
"dark",
);
const newAggregationPeriod = e.currentTarget
.value as AggregationPeriod;

// Update chart with new options including the new aggregation period
chartRef.current?.updateOptions(
getChartOptions(isDarkMode, newAggregationPeriod),
);
<div className="absolute flex md:flex-col md:-top-4 gap-2 pt-1 text-sm pl-5 z-20">
<div className="flex items-center gap-2">
<label htmlFor="aggregationPeriod" className="text-secondary">
Aggregation Period:
</label>
<select
id="aggregationPeriod"
onChange={(e) => {
chartRef.current?.updateSeries(
getSeries(
props.downloads,
e.currentTarget.value as AggregationPeriod,
),
);
}}
className="input-container input select w-20"
>
<option value="daily">Daily</option>
<option value="weekly" selected>
Weekly
</option>
<option value="monthly">Monthly</option>
</select>
</div>
<div className="flex items-center gap-2">
<label htmlFor="displayAs" className="text-secondary">
Display As
</label>
<select
id="displayAs"
onChange={(e) => {
const newDisplay = e.currentTarget.value === "stacked";

chartRef.current?.updateSeries(
getSeries(
props.downloads,
e.currentTarget.value as AggregationPeriod,
),
);
}}
className="input-container input select w-20"
>
<option value="daily">Daily</option>
<option value="weekly" selected>Weekly</option>
<option value="monthly">Monthly</option>
</select>
// Update chart with new options including the new stacked display
chartRef.current?.updateOptions(
{ chart: { stacked: newDisplay } },
);
}}
className="input-container input select w-24"
>
<option value="stacked" selected>Stacked</option>
<option value="unstacked">
Unstacked
</option>
</select>
</div>
</div>
)}
<div className="h-[300px] md:pt-0 pt-10 text-secondary">
Expand Down Expand Up @@ -197,19 +209,17 @@ function adjustTimePeriod(
switch (aggregation) {
case "weekly":
// start of week (Sunday) in UTC
out = new Date(Date.UTC(
date.getUTCFullYear(),
date.getUTCMonth(),
date.getUTCDate() - date.getUTCDay(),
));
out = new Date(
Date.UTC(
date.getUTCFullYear(),
date.getUTCMonth(),
date.getUTCDate() - date.getUTCDay(),
),
);
break;
case "monthly":
// first day of month in UTC
out = new Date(Date.UTC(
date.getUTCFullYear(),
date.getUTCMonth(),
1,
));
out = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1));
break;
default: // daily
out = date;
Expand All @@ -227,8 +237,8 @@ export function collectX(
xValues.add(adjustTimePeriod(point.timeBucket, aggregationPeriod));
});

return Array.from(xValues).sort((a, b) =>
new Date(a).getTime() - new Date(b).getTime()
return Array.from(xValues).sort(
(a, b) => new Date(a).getTime() - new Date(b).getTime(),
);
}

Expand All @@ -252,17 +262,18 @@ export function normalize(
}
});

return Object.entries(normalized).map((
[key, value],
) => [new Date(key).getTime(), value]);
return Object.entries(normalized).map(([key, value]) => [
new Date(key).getTime(),
value,
]);
}

function getSeries(
recentVersions: PackageDownloadsRecentVersion[],
aggregationPeriod: AggregationPeriod,
) {
const dataPointsWithDownloads = recentVersions.filter((dataPoints) =>
dataPoints.downloads.length > 0
const dataPointsWithDownloads = recentVersions.filter(
(dataPoints) => dataPoints.downloads.length > 0,
);

const dataPointsToDisplay = dataPointsWithDownloads.slice(0, 5);
Expand Down