Add Donut component that can render server side (if that ever actually works)

This commit is contained in:
Joshua Coles 2024-02-22 14:57:30 +00:00
parent 17edc05ec4
commit e67e01fd0a
7 changed files with 175 additions and 20 deletions

29
src/app/donut/page.tsx Normal file
View File

@ -0,0 +1,29 @@
import './styles.css';
import {Donut} from "@/components/Donut";
const data = [
{
label: "One",
colour: "#ce4b99",
value: 35,
},
{
label: "Two",
colour: "#b1c94e",
value: 15,
},
{
label: "Three",
colour: "#377bbc",
value: 50,
},
{
label: "Four",
colour: "#f49f35",
value: 100,
}
]
export default function App() {
return <Donut data={data}/>
}

19
src/app/donut/styles.css Normal file
View File

@ -0,0 +1,19 @@
.chart-text {
font: 16px/1.4em "Montserrat", Arial, sans-serif;
fill: #000;
transform: translateY(0.25em);
}
.chart-number {
font-size: 0.6em;
line-height: 1;
text-anchor: middle;
transform: translateY(-0.25em);
}
.chart-label {
font-size: 0.2em;
text-transform: uppercase;
text-anchor: middle;
transform: translateY(0.7em);
}

90
src/components/Donut.tsx Normal file
View File

@ -0,0 +1,90 @@
import * as R from "ramda";
import DonutTooltip from "@/components/DonutTooltip";
import './donut-styles.css'
export type DonutChartData = {
label: string;
colour: string;
value: number;
}[];
export function Donut({data, centerLabel, formatter, hoverSuffix}: {
data: DonutChartData,
formatter: (value: number) => string,
centerLabel: string,
hoverSuffix: string,
}) {
const radius = 100 / (2 * Math.PI);
const backgroundColour = "#d2d3d4";
const preppedData = R.sortBy(R.compose(R.negate, R.prop('value')), data);
const total = R.sum(R.pluck('value', preppedData));
const normalisingFactor = 100 / total;
return (
<>
<DonutTooltip/>
<svg width="100%" height="100%" viewBox="0 0 42 42" className="donut">
{/* The hole in the middle of the donut */}
<circle
className="donut-hole"
cx="21"
cy="21"
r={radius}
fill="#fff"
/>
{/* The ring in the background of the donut, if it was not 100% full */}
<circle
className="donut-ring"
cx="21"
cy="21"
r={radius}
fill="transparent"
stroke={backgroundColour}
strokeWidth="3"
/>
{
preppedData.map((segment, index, array) => {
const {
label,
colour,
value
} = segment;
const precedingSegments = array.slice(0, index);
const offset = precedingSegments.reduce((acc, segment) => acc + segment.value, 0);
return (
<circle
data-tooltip-id="donut-tooltip"
data-tooltip-content={`${label}: ${formatter(value)}${hoverSuffix}`}
data-tooltip-place="top"
key={index}
className="donut-segment"
cx="21"
cy="21"
r={radius}
fill="transparent"
stroke={colour}
strokeWidth="3"
strokeDasharray={`${value * normalisingFactor} ${100 - value * normalisingFactor}`}
strokeDashoffset={100 - (offset * normalisingFactor) + 25}
/>
)
})
}
<g className="chart-text">
<text x="50%" y="50%" className="chart-number">
{formatter(total)}
</text>
<text x="50%" y="50%" className="chart-label">
{centerLabel}
</text>
</g>
</svg>
</>
);
}

View File

@ -0,0 +1,7 @@
'use client';
import {Tooltip} from "react-tooltip";
export default function DonutTooltip() {
return <Tooltip id="donut-tooltip"/>
}

View File

@ -34,7 +34,7 @@ export default function OverviewPage({
<main className="m-6">
<h1 className="text-3xl font-semibold text-slate-900 dark:text-white my-2">{config.title}</h1>
<div className="grid gap-5 grid-cols-1 sm:grid-cols-4">
<div className="grid gap-5 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4">
{config.subjects.map((subject) => (
<SubjectOverviewCard
key={subject.projectId}

View File

@ -1,24 +1,20 @@
"use client";
import {Card, DonutChart, Title} from "@tremor/react";
import {Card, Title} from "@tremor/react";
import * as R from "ramda";
import {Data} from "@/data/fetchData";
import {Donut, DonutChartData} from "@/components/Donut";
function useBreakdownData(data: Data): {
name: string,
value: number,
colour: string
}[] {
function useBreakdownData(data: Data): DonutChartData {
const sorted = R.sortBy(R.prop('projectId'), data.timeEntries);
const grouped = R.groupWith(R.eqBy(R.prop('projectId')), sorted);
return grouped
.map((entries) => {
const project = data.projects.find(p => p.projectId === entries[0].projectId)!;
const totalDuration = (entries).map((entry) => entry.duration).reduce((a, b) => a + b, 0);
return ({
name: project.name,
value: (entries).map((entry) => entry.duration).reduce((a, b) => a + b, 0),
label: project.name,
value: (totalDuration / (60 * 60)),
colour: project.color,
});
})
@ -30,20 +26,15 @@ export function SubjectComparisonCard({
data: Data,
}) {
const breakdownData = useBreakdownData(data);
const colours = breakdownData.map((entry) => entry.colour);
const valueFormatter = (number: number) => `${(number / (60 * 60)).toFixed(2)} hours`;
return (
<Card className="col">
<Title>Relative Breakdown</Title>
<DonutChart
className="mt-6"
<Donut
data={breakdownData ?? []}
category="value"
index="name"
valueFormatter={valueFormatter}
colors={colours}
formatter={v => v.toFixed(2)}
centerLabel={"hours"}
hoverSuffix={"h"}
/>
</Card>
)

View File

@ -0,0 +1,19 @@
.chart-text {
font: 16px/1.4em "Montserrat", Arial, sans-serif;
fill: #000;
transform: translateY(0.25em);
}
.chart-number {
font-size: 0.6em;
line-height: 1;
text-anchor: middle;
transform: translateY(-0.25em);
}
.chart-label {
font-size: 0.2em;
text-transform: uppercase;
text-anchor: middle;
transform: translateY(0.7em);
}