123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216 |
- import React, { useRef, useEffect, useState } from "react";
- import dayjs from "dayjs";
- interface Props {
- weekNames?: string[];
- monthNames?: string[];
- panelColors?: string[];
- values: { [date: string]: number };
- until: string;
- dateFormat?: string;
- }
- const DEFAULT_WEEK_NAMES = ["L", "M", "M", "J", "V", "S", "D"]; // TODO i18n
- const DEFAULT_MONTH_NAMES = ["Jan", "Fév", "Mar", "Avr", "Mai", "Juin", "Juil", "Août", "Sep", "Oct", "Nov", "Déc"]; // TODO i18n
- const DEFAULT_PANEL_COLORS = [
- "var(--color-base-300)", // 0: empty
- "var(--color-success)", // 1: low activity
- "var(--color-success-content)", // 2: medium activity
- "var(--color-success-content)", // 3: high activity
- "var(--color-success-content)", // 4: max activity
- ];
- const DEFAULT_DATE_FORMAT = "YYYY-MM-DD";
- const PANEL_SIZE = 18;
- const PANEL_MARGIN = 2;
- const WEEK_LABEL_WIDTH = 18;
- const MONTH_LABEL_HEIGHT = 18;
- const MIN_COLUMNS = 10;
- const MAX_COLUMNS = 53;
- export const WorkoutSessionHeatmap: React.FC<Props> = ({
- weekNames = DEFAULT_WEEK_NAMES,
- monthNames = DEFAULT_MONTH_NAMES,
- panelColors = DEFAULT_PANEL_COLORS,
- values,
- until,
- dateFormat = DEFAULT_DATE_FORMAT,
- }) => {
- const containerRef = useRef<HTMLDivElement>(null);
- const [columns, setColumns] = useState(MAX_COLUMNS);
- const [hovered, setHovered] = useState<null | {
- i: number;
- j: number;
- tooltip: React.ReactNode;
- mouseX: number;
- mouseY: number;
- }>(null);
- // responsive: adapt the number of columns to the width
- useEffect(() => {
- function updateColumns() {
- if (!containerRef.current) return;
- const width = containerRef.current.offsetWidth;
- const available = Math.floor((width - WEEK_LABEL_WIDTH) / (PANEL_SIZE + PANEL_MARGIN));
- setColumns(Math.max(MIN_COLUMNS, Math.min(MAX_COLUMNS, available)));
- }
- updateColumns();
- const observer = new window.ResizeObserver(updateColumns);
- if (containerRef.current) observer.observe(containerRef.current);
- return () => observer.disconnect();
- }, []);
- // matrix of contributions
- function makeCalendarData(history: { [k: string]: number }, lastDay: string, columns: number) {
- const d = dayjs(lastDay, dateFormat);
- const lastWeekend = d.endOf("week");
- const endDate = d.endOf("day");
- const result: ({ value: number; month: number } | null)[][] = [];
- for (let i = 0; i < columns; i++) {
- result[i] = [];
- for (let j = 0; j < 7; j++) {
- const date = lastWeekend.subtract((columns - i - 1) * 7 + (6 - j), "day");
- if (date <= endDate) {
- result[i][j] = {
- value: history[date.format(dateFormat)] || 0,
- month: date.month(),
- };
- } else {
- result[i][j] = null;
- }
- }
- }
- return result;
- }
- const contributions = makeCalendarData(values, until, columns);
- const innerDom: React.ReactElement[] = [];
- for (let i = 0; i < columns; i++) {
- for (let j = 0; j < 7; j++) {
- const contribution = contributions[i][j];
- if (contribution === null) continue;
- const x = WEEK_LABEL_WIDTH + (PANEL_SIZE + PANEL_MARGIN) * i;
- const y = MONTH_LABEL_HEIGHT + (PANEL_SIZE + PANEL_MARGIN) * j;
- const numOfColors = panelColors.length;
- const color = contribution.value >= numOfColors ? panelColors[numOfColors - 1] : panelColors[contribution.value];
- // TODO i18n
- const d = dayjs(until, dateFormat)
- .endOf("week")
- .subtract((columns - i - 1) * 7 + (6 - j), "day");
- const dateStr = d.format(dateFormat);
- const tooltip =
- contribution.value > 0 ? (
- <div className="text-xs text-slate-50">
- {dateStr} : <br />
- {contribution.value} workout{contribution.value > 1 ? "s" : ""}
- </div>
- ) : (
- <div className="text-xs text-slate-50">
- {dateStr} : <br /> No workout
- </div>
- );
- innerDom.push(
- <rect
- fill={color}
- height={PANEL_SIZE}
- key={`panel_${i}_${j}`}
- onMouseEnter={(e) => setHovered({ i, j, tooltip, mouseX: e.clientX, mouseY: e.clientY })}
- onMouseLeave={() => setHovered(null)}
- onMouseMove={(e) => setHovered((prev) => prev && { ...prev, mouseX: e.clientX, mouseY: e.clientY })}
- rx={3}
- style={{
- cursor: "pointer",
- stroke: hovered && hovered.i === i && hovered.j === j ? "#059669" : "transparent",
- strokeWidth: hovered && hovered.i === i && hovered.j === j ? 2 : 0,
- opacity: hovered && hovered.i === i && hovered.j === j ? 0.85 : 1,
- transition: "stroke 0.1s, opacity 0.1s",
- }}
- width={PANEL_SIZE}
- x={x}
- y={y}
- />,
- );
- }
- }
- for (let i = 0; i < weekNames.length; i++) {
- const x = WEEK_LABEL_WIDTH / 2;
- const y = MONTH_LABEL_HEIGHT + (PANEL_SIZE + PANEL_MARGIN) * i + PANEL_SIZE / 2;
- innerDom.push(
- <text
- alignmentBaseline="central"
- fill="var(--color-base-content)"
- fontSize={10}
- key={`week_label_${i}`}
- textAnchor="middle"
- x={x}
- y={y}
- >
- {weekNames[i]}
- </text>,
- );
- }
- let prevMonth = -1;
- for (let i = 0; i < columns; i++) {
- const c = contributions[i][0];
- if (c === null) continue;
- if (columns > 1 && i === 0 && c.month !== contributions[i + 1][0]?.month) {
- continue;
- }
- if (c.month !== prevMonth) {
- const x = WEEK_LABEL_WIDTH + (PANEL_SIZE + PANEL_MARGIN) * i + PANEL_SIZE / 2;
- const y = MONTH_LABEL_HEIGHT / 1.5;
- innerDom.push(
- <text
- alignmentBaseline="central"
- fill="var(--color-base-content)"
- fontSize={12}
- key={`month_label_${i}`}
- textAnchor="middle"
- x={x}
- y={y}
- >
- {monthNames[c.month]}
- </text>,
- );
- }
- prevMonth = c.month;
- }
- const tooltipNode = hovered ? (
- <div
- style={{
- position: "fixed",
- left: hovered.mouseX - 100,
- top: hovered.mouseY - 8,
- pointerEvents: "none",
- zIndex: 9999,
- background: "rgba(33,33,33,0.97)",
- color: "#fff",
- padding: "6px 12px",
- borderRadius: 6,
- fontSize: 13,
- boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
- whiteSpace: "nowrap",
- maxWidth: 220,
- border: "1px solid var(--color-base-300)",
- }}
- >
- {hovered.tooltip}
- </div>
- ) : null;
- return (
- <div ref={containerRef} style={{ width: "100%", position: "relative" }}>
- <svg
- height={MONTH_LABEL_HEIGHT + (PANEL_SIZE + PANEL_MARGIN) * 7}
- style={{ fontFamily: "Helvetica, Arial, sans-serif", width: "100%", display: "block" }}
- >
- {innerDom}
- </svg>
- {tooltipNode}
- </div>
- );
- };
|