Skip to content

Commit

Permalink
feat: center(ish) review heatmap (closes #14)
Browse files Browse the repository at this point in the history
  • Loading branch information
Lemmmy committed Nov 25, 2023
1 parent 00f6a3f commit 7d44df6
Show file tree
Hide file tree
Showing 3 changed files with 105 additions and 52 deletions.
29 changes: 20 additions & 9 deletions src/pages/dashboard/heatmap/ReviewHeatmapCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// This file is part of KanjiSchool under AGPL-3.0.
// Full details: https://github.com/Lemmmy/KanjiSchool/blob/master/LICENSE

import { useState } from "react";
import { ReactNode, useCallback, useState } from "react";
import { Button, Tooltip } from "antd";
import { WarningOutlined } from "@ant-design/icons";
import useBreakpoint from "antd/es/grid/hooks/useBreakpoint";
Expand All @@ -21,6 +21,8 @@ export default function ReviewHeatmapCard(): JSX.Element {
const [showAll, setShowAll] = useState(false);
const [hoverDay, setHoverDay] = useState<HeatmapDay>();

const toggleShow = useCallback(() => setShowAll(v => !v), []);

return <SimpleCard
title={<CardTitle />}
className={classNames(dashboardCardClass, "!h-auto")}
Expand All @@ -29,15 +31,11 @@ export default function ReviewHeatmapCard(): JSX.Element {
flush

// Show all button in top right of card
extra={!showAll && <Button
className="border-0 my-px mx-0 h-[54px]"
type="link"
onClick={() => setShowAll(true)}
>
Show all
</Button>}
extra={showAll
? <ExtraButton onClick={toggleShow}>Hide all</ExtraButton>
: <ExtraButton onClick={toggleShow}>Show all</ExtraButton>}
>
<div className="flex justify-stretch max-h-[200px] overflow-auto p-md">
<div className="flex justify-stretch max-h-[230px] overflow-auto p-md">
<Heatmap currentYearOnly={!showAll} setHoverDay={setHoverDay} />
</div>

Expand Down Expand Up @@ -76,3 +74,16 @@ function CardFooter({ hoverDay }: FooterProps): JSX.Element | null {
<ReviewHeatmapLegend />
</div>;
}

interface ExtraButtonProps {
onClick: () => void;
children: ReactNode;
}

const ExtraButton = ({ onClick, children }: ExtraButtonProps) => <Button
className="border-0 my-px mx-0 h-[54px]"
type="link"
onClick={onClick}
>
{children}
</Button>;
43 changes: 27 additions & 16 deletions src/pages/dashboard/heatmap/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,9 @@
import { ApiReview, StoredAssignment } from "@api";
import { db } from "@db";

import { ScaleQuantize, InternMap } from "d3";
import { timeYear, timeDay } from "d3-time";
import { rollup, sort, union, map, group, index, extent } from "d3-array";
import { scaleQuantize } from "d3-scale";
import { timeYear, timeDay, timeMonth } from "d3-time";
import { rollup, sort, union, map, group, index, extent, InternMap } from "d3-array";
import { scaleQuantize, ScaleQuantize } from "d3-scale";

import { COLORS, COLORS_FUTURE, COLORS_FUTURE_LIGHT, COLORS_LIGHT } from "./renderHeatmap";
import { ThemeName } from "@global/theme";
Expand Down Expand Up @@ -45,16 +44,23 @@ export async function generateHeatmapData(
theme: ThemeName = "dark"
): Promise<HeatmapDatum[]> {
const now = new Date();
const today = +timeDay.floor(now);
const yearEnd = timeYear.ceil(now);
const todayDate = timeDay.floor(now);
const today = +todayDate;

// If we're only showing the current year, show the period of [8 months ago, 4 months from now]. Otherwise, show the
// full year from start to finish (so we only need reviews up until the end of this year).
const yearStart = currentYearOnly
? timeMonth.offset(todayDate, -8)
: timeYear.floor(now);
const yearEnd = currentYearOnly
? timeMonth.offset(todayDate, 4) // TODO: Get this data from SpacedRepetitionSystems instead of hardcoding it
: timeYear.ceil(now);
debug("year start: %o year end: %o", yearStart, yearEnd);

let lessons: StoredAssignment[];
let reviews: ApiReview[];
if (currentYearOnly) {
// Get all the data from the current year (in the user's timezone)
const yearStart = timeYear.floor(now);
debug("year start: %o", yearStart);

// Get all the data from the current year (in the user's timezone).
// Dates are stored in ISO-8601 in the database and toISOString() will
// always return a UTC string, so we can do a simple string comparison like
// this
Expand Down Expand Up @@ -93,8 +99,10 @@ export async function generateHeatmapData(

// Zip all the rollups together into HeatmapDay[]
const dayKeys = sort(union(
rolledReviews.keys(), rolledLessons.keys(), rolledFutures.keys()
));
rolledReviews.keys(),
rolledLessons.keys(),
rolledFutures.keys())
);
const days = map<Date, HeatmapDay>(dayKeys, date => {
const reviewsN = rolledReviews.get(date) ?? 0;
const lessonsN = rolledLessons.get(date) ?? 0;
Expand All @@ -112,7 +120,10 @@ export async function generateHeatmapData(
});

// Group again into years
const years = group(days, d => d.date.getFullYear());
const years = currentYearOnly
? new InternMap([[todayDate.getFullYear(), days]])
: group(days, d => d.date.getFullYear());

const yearsArray = Array.from(years, ([year, days]) => {
// Turn the HeatmapDay[] back into an InternMap
const dayMap = index(days, d => d.date);
Expand All @@ -130,8 +141,8 @@ export async function generateHeatmapData(

return {
year,
yearStart: new Date(year, 0, 1),
yearEnd: new Date(year + 1, 0, 1),
yearStart: currentYearOnly ? yearStart : new Date(year, 0, 1),
yearEnd: currentYearOnly ? yearEnd : new Date(year + 1, 0, 1),

min: min ?? 0,
max: max ?? 1,
Expand All @@ -141,7 +152,7 @@ export async function generateHeatmapData(
dayMap
};
});
yearsArray.reverse(); // Sort by year descending

yearsArray.reverse(); // Sort by year descending
return yearsArray.filter(y => y.year !== 1970); // lol
}
85 changes: 58 additions & 27 deletions src/pages/dashboard/heatmap/renderHeatmap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import { HeatmapDatum, HeatmapDay } from "./data";

import { Selection } from "d3-selection";
import { timeDays, timeWeek, timeYear } from "d3-time";
import { timeDays, timeMonth, timeMonths, timeWeek } from "d3-time";
import { range, index } from "d3-array";

import { ThemeName } from "@global/theme";
Expand Down Expand Up @@ -42,10 +42,16 @@ export const COLORS_LIGHT = ["#a0cdf2", "#88c4f5", "#70bbf8", "#58b2fc",
export const COLORS_FUTURE = ["#35482b", "#456a2f", "#548d34", "#64af38", "#73d13d"];
export const COLORS_FUTURE_LIGHT = ["#c4ebae", "#b4e897", "#9ee079", "#84d455", "#73d13d"];

interface MonthDatum {
d: Date;
yearStart: Date;
}

interface DayDatum {
d: Date;
day?: HeatmapDay;
year?: HeatmapDatum;
yearStart: Date;
}

export function renderHeatmap(
Expand All @@ -62,6 +68,7 @@ export function renderHeatmap(

ctx.selectAll("g").remove();
const group = ctx.append("g");
let chartWidth = FULL_WIDTH;

// Group for each year
const year = group.selectAll("g")
Expand All @@ -70,26 +77,44 @@ export function renderHeatmap(
.attr("transform", (_, i) => `translate(48, ${YEAR_FULL_HEIGHT * i + CELL_SIZE * 1.5})`);

// Year label
const yearLabelClass = "fill-desc light:fill-black/75 [text-anchor:middle] text-sm font-bold " +
"[writing-mode:vertical-lr] font-ja";
year.append("text")
.classed(
"fill-desc light:fill-black/75 [text-anchor:middle] text-sm font-bold [writing-mode:vertical-lr] font-ja",
true
)
.classed(yearLabelClass, true)
.attr("transform", `translate(-32, ${(YEAR_HEIGHT - 6) / 2}) rotate(180)`)
.text(d => d.year);

// Extra stuff if we're showing the current year only (now - 8 months -> now + 4 months) and it spans two years
if (data.length === 1) {
const yearStart = data[0].yearStart;
const yearEnd = data[0].yearEnd;

if (yearEnd.getFullYear() !== yearStart.getFullYear()) {
chartWidth += 32; // Add space for the extra year label

// Extra year label on the right
year.append("text")
.classed(yearLabelClass, true)
.attr("transform", `translate(${FULL_WIDTH - 36}, ${(YEAR_HEIGHT - 6) / 2}) rotate(180)`)
.text(d => d.year + 1);
}
}

// Month label
year.append("g")
.classed("fill-desc light:fill-black/75", true)
.selectAll("text")
.data(y => range(12).map(i => new Date(y.year, i, 1)))
.data<MonthDatum>(({ yearStart, yearEnd }) =>
timeMonths(timeMonth.floor(yearStart), timeMonth.ceil(yearEnd))
.filter(d => d >= yearStart && d <= yearEnd)
.map(d => ({ d, yearStart })))
.join("text")
.classed("[text-anchor:start] text-[9px] font-ja", true)
// TODO: Round weeks up like github?
.attr("x", d => timeWeek.count(timeYear(d), d) * (CELL_SIZE + CELL_SPACING))
.attr("x", ({ d, yearStart }) => timeWeek.count(yearStart, d) * (CELL_SIZE + CELL_SPACING))
.attr("y", 0)
.attr("dy", "-0.5em")
.text(d => formatMonth(d, jp));
.text(({ d }) => formatMonth(d, jp));

// Day label
year.append("g")
Expand All @@ -108,17 +133,17 @@ export function renderHeatmap(
.classed("days", true)
.selectAll("rect")
// Use all days in the year as data, merge in the real data after
.data<DayDatum>(y => timeDays(y.yearStart, y.yearEnd).map(d => {
const year = yearsData.get(d.getFullYear());
.data<DayDatum>(({ year: y, yearStart, yearEnd }) => timeDays(yearStart, yearEnd).map(d => {
const year = yearsData.get(y);
const day = year?.dayMap.get(d);
return { d, year, day };
return { d, year, day, yearStart };
}))
.join("rect")
.classed("cursor-pointer hover:brightness-150 light:hover:brightness-90", true)
.attr("width", CELL_SIZE).attr("height", CELL_SIZE)
.attr("rx", CELL_ROUNDING).attr("ry", CELL_ROUNDING)
.attr("x", ({ d }) =>
timeWeek.count(timeYear(d), d) * (CELL_SIZE + CELL_SPACING))
.attr("x", ({ d, yearStart }) =>
timeWeek.count(yearStart, d) * (CELL_SIZE + CELL_SPACING))
.attr("y", ({ d }) =>
d.getDay() * (CELL_SIZE + CELL_SPACING))
.attr("fill", ({ year, day }) => {
Expand All @@ -135,8 +160,12 @@ export function renderHeatmap(
year.append("g")
.classed("stroke-[#565656] light:stroke-[#8c8c8c] fill-none [translate:-1px_-1px]", true)
.selectAll("path")
// Don't draw one for December
.data(y => range(11).map(i => new Date(y.year, i, 1)))
// Don't draw one for the last month
.data<MonthDatum>(({ yearStart, yearEnd }) =>
timeMonths(timeMonth.floor(yearStart), timeMonth.ceil(yearEnd))
.filter(d => d >= yearStart && d <= yearEnd)
.map(d => ({ d, yearStart }))
.slice(0, -1))
.enter()
.append("path")
.attr("shape-rendering", "crispEdges")
Expand All @@ -145,9 +174,9 @@ export function renderHeatmap(

// Enforce svg size
ctx
.attr("width", FULL_WIDTH)
.attr("width", chartWidth)
.attr("height", YEAR_FULL_HEIGHT * data.length)
.style("min-width", FULL_WIDTH + "px")
.style("min-width", chartWidth + "px")
.style("min-height", (YEAR_FULL_HEIGHT * data.length) + "px");

ctx.exit().remove();
Expand All @@ -161,18 +190,20 @@ export function renderHeatmap(
}
}

function pathMonth(t0: Date) {
const t1 = new Date(t0.getFullYear(), t0.getMonth() + 1, 0),
d1 = t1.getDay(), w1 = timeWeek.count(timeYear(t1), t1);
const s = CELL_SIZE + CELL_SPACING;
function pathMonth({ d: t0, yearStart }: MonthDatum) {
const t1 = new Date(t0.getFullYear(), t0.getMonth() + 1, 0); // Last day of month
const d1 = t1.getDay(); // Last day of month's day of week
const w1 = timeWeek.count(yearStart, t1); // Last day of month's week of year (according to chart's yearStart)
const s = CELL_SIZE + CELL_SPACING; // Size of each cell including spacing

if (d1 === 6) {
return "M" + (w1 + 1) * s + "," + 7 * s
+ "V" + 0;
// Last day of month is Saturday, so we can just draw a line down the right side
return "M" + (w1 + 1) * s + "," + 7 * s // Start at bottom right
+ "V" + 0; // Draw a line up to the top
} else {
return "M" + w1 * s + "," + 7 * s
+ "V" + (d1 + 1) * s
+ "H" + (w1 + 1) * s
+ "V" + 0;
return "M" + w1 * s + "," + 7 * s // Start at bottom left
+ "V" + (d1 + 1) * s // Draw a line up to the last day of the month
+ "H" + (w1 + 1) * s // Draw a line across to the right side
+ "V" + 0; // Draw a line up to the top
}
}

0 comments on commit 7d44df6

Please sign in to comment.