Compare commits
2 Commits
17edc05ec4
...
cf1788ebcd
| Author | SHA1 | Date | |
|---|---|---|---|
| cf1788ebcd | |||
| e67e01fd0a |
90
src/components/Donut.tsx
Normal file
90
src/components/Donut.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
src/components/DonutTooltip.tsx
Normal file
7
src/components/DonutTooltip.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {Tooltip} from "react-tooltip";
|
||||||
|
|
||||||
|
export default function DonutTooltip() {
|
||||||
|
return <Tooltip id="donut-tooltip"/>
|
||||||
|
}
|
||||||
@ -34,7 +34,7 @@ export default function OverviewPage({
|
|||||||
<main className="m-6">
|
<main className="m-6">
|
||||||
<h1 className="text-3xl font-semibold text-slate-900 dark:text-white my-2">{config.title}</h1>
|
<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) => (
|
{config.subjects.map((subject) => (
|
||||||
<SubjectOverviewCard
|
<SubjectOverviewCard
|
||||||
key={subject.projectId}
|
key={subject.projectId}
|
||||||
|
|||||||
@ -1,24 +1,20 @@
|
|||||||
"use client";
|
import {Card, Title} from "@tremor/react";
|
||||||
|
|
||||||
import {Card, DonutChart, Title} from "@tremor/react";
|
|
||||||
import * as R from "ramda";
|
import * as R from "ramda";
|
||||||
import {Data} from "@/data/fetchData";
|
import {Data} from "@/data/fetchData";
|
||||||
|
import {Donut, DonutChartData} from "@/components/Donut";
|
||||||
|
|
||||||
function useBreakdownData(data: Data): {
|
function useBreakdownData(data: Data): DonutChartData {
|
||||||
name: string,
|
|
||||||
value: number,
|
|
||||||
colour: string
|
|
||||||
}[] {
|
|
||||||
const sorted = R.sortBy(R.prop('projectId'), data.timeEntries);
|
const sorted = R.sortBy(R.prop('projectId'), data.timeEntries);
|
||||||
const grouped = R.groupWith(R.eqBy(R.prop('projectId')), sorted);
|
const grouped = R.groupWith(R.eqBy(R.prop('projectId')), sorted);
|
||||||
|
|
||||||
return grouped
|
return grouped
|
||||||
.map((entries) => {
|
.map((entries) => {
|
||||||
const project = data.projects.find(p => p.projectId === entries[0].projectId)!;
|
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 ({
|
return ({
|
||||||
name: project.name,
|
label: project.name,
|
||||||
value: (entries).map((entry) => entry.duration).reduce((a, b) => a + b, 0),
|
value: (totalDuration / (60 * 60)),
|
||||||
colour: project.color,
|
colour: project.color,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
@ -30,20 +26,15 @@ export function SubjectComparisonCard({
|
|||||||
data: Data,
|
data: Data,
|
||||||
}) {
|
}) {
|
||||||
const breakdownData = useBreakdownData(data);
|
const breakdownData = useBreakdownData(data);
|
||||||
const colours = breakdownData.map((entry) => entry.colour);
|
|
||||||
|
|
||||||
const valueFormatter = (number: number) => `${(number / (60 * 60)).toFixed(2)} hours`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="col">
|
<Card className="col">
|
||||||
<Title>Relative Breakdown</Title>
|
<Title>Relative Breakdown</Title>
|
||||||
<DonutChart
|
<Donut
|
||||||
className="mt-6"
|
|
||||||
data={breakdownData ?? []}
|
data={breakdownData ?? []}
|
||||||
category="value"
|
formatter={v => v.toFixed(2)}
|
||||||
index="name"
|
centerLabel={"hours"}
|
||||||
valueFormatter={valueFormatter}
|
hoverSuffix={"h"}
|
||||||
colors={colours}
|
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|||||||
19
src/components/donut-styles.css
Normal file
19
src/components/donut-styles.css
Normal 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);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user