workout-session-heatmap.tsx 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216
  1. import React, { useRef, useEffect, useState } from "react";
  2. import dayjs from "dayjs";
  3. interface Props {
  4. weekNames?: string[];
  5. monthNames?: string[];
  6. panelColors?: string[];
  7. values: { [date: string]: number };
  8. until: string;
  9. dateFormat?: string;
  10. }
  11. const DEFAULT_WEEK_NAMES = ["L", "M", "M", "J", "V", "S", "D"]; // TODO i18n
  12. const DEFAULT_MONTH_NAMES = ["Jan", "Fév", "Mar", "Avr", "Mai", "Juin", "Juil", "Août", "Sep", "Oct", "Nov", "Déc"]; // TODO i18n
  13. const DEFAULT_PANEL_COLORS = [
  14. "var(--color-base-300)", // 0: empty
  15. "var(--color-success)", // 1: low activity
  16. "var(--color-success-content)", // 2: medium activity
  17. "var(--color-success-content)", // 3: high activity
  18. "var(--color-success-content)", // 4: max activity
  19. ];
  20. const DEFAULT_DATE_FORMAT = "YYYY-MM-DD";
  21. const PANEL_SIZE = 18;
  22. const PANEL_MARGIN = 2;
  23. const WEEK_LABEL_WIDTH = 18;
  24. const MONTH_LABEL_HEIGHT = 18;
  25. const MIN_COLUMNS = 10;
  26. const MAX_COLUMNS = 53;
  27. export const WorkoutSessionHeatmap: React.FC<Props> = ({
  28. weekNames = DEFAULT_WEEK_NAMES,
  29. monthNames = DEFAULT_MONTH_NAMES,
  30. panelColors = DEFAULT_PANEL_COLORS,
  31. values,
  32. until,
  33. dateFormat = DEFAULT_DATE_FORMAT,
  34. }) => {
  35. const containerRef = useRef<HTMLDivElement>(null);
  36. const [columns, setColumns] = useState(MAX_COLUMNS);
  37. const [hovered, setHovered] = useState<null | {
  38. i: number;
  39. j: number;
  40. tooltip: React.ReactNode;
  41. mouseX: number;
  42. mouseY: number;
  43. }>(null);
  44. // responsive: adapt the number of columns to the width
  45. useEffect(() => {
  46. function updateColumns() {
  47. if (!containerRef.current) return;
  48. const width = containerRef.current.offsetWidth;
  49. const available = Math.floor((width - WEEK_LABEL_WIDTH) / (PANEL_SIZE + PANEL_MARGIN));
  50. setColumns(Math.max(MIN_COLUMNS, Math.min(MAX_COLUMNS, available)));
  51. }
  52. updateColumns();
  53. const observer = new window.ResizeObserver(updateColumns);
  54. if (containerRef.current) observer.observe(containerRef.current);
  55. return () => observer.disconnect();
  56. }, []);
  57. // matrix of contributions
  58. function makeCalendarData(history: { [k: string]: number }, lastDay: string, columns: number) {
  59. const d = dayjs(lastDay, dateFormat);
  60. const lastWeekend = d.endOf("week");
  61. const endDate = d.endOf("day");
  62. const result: ({ value: number; month: number } | null)[][] = [];
  63. for (let i = 0; i < columns; i++) {
  64. result[i] = [];
  65. for (let j = 0; j < 7; j++) {
  66. const date = lastWeekend.subtract((columns - i - 1) * 7 + (6 - j), "day");
  67. if (date <= endDate) {
  68. result[i][j] = {
  69. value: history[date.format(dateFormat)] || 0,
  70. month: date.month(),
  71. };
  72. } else {
  73. result[i][j] = null;
  74. }
  75. }
  76. }
  77. return result;
  78. }
  79. const contributions = makeCalendarData(values, until, columns);
  80. const innerDom: React.ReactElement[] = [];
  81. for (let i = 0; i < columns; i++) {
  82. for (let j = 0; j < 7; j++) {
  83. const contribution = contributions[i][j];
  84. if (contribution === null) continue;
  85. const x = WEEK_LABEL_WIDTH + (PANEL_SIZE + PANEL_MARGIN) * i;
  86. const y = MONTH_LABEL_HEIGHT + (PANEL_SIZE + PANEL_MARGIN) * j;
  87. const numOfColors = panelColors.length;
  88. const color = contribution.value >= numOfColors ? panelColors[numOfColors - 1] : panelColors[contribution.value];
  89. // TODO i18n
  90. const d = dayjs(until, dateFormat)
  91. .endOf("week")
  92. .subtract((columns - i - 1) * 7 + (6 - j), "day");
  93. const dateStr = d.format(dateFormat);
  94. const tooltip =
  95. contribution.value > 0 ? (
  96. <div className="text-xs text-slate-50">
  97. {dateStr} : <br />
  98. {contribution.value} workout{contribution.value > 1 ? "s" : ""}
  99. </div>
  100. ) : (
  101. <div className="text-xs text-slate-50">
  102. {dateStr} : <br /> No workout
  103. </div>
  104. );
  105. innerDom.push(
  106. <rect
  107. fill={color}
  108. height={PANEL_SIZE}
  109. key={`panel_${i}_${j}`}
  110. onMouseEnter={(e) => setHovered({ i, j, tooltip, mouseX: e.clientX, mouseY: e.clientY })}
  111. onMouseLeave={() => setHovered(null)}
  112. onMouseMove={(e) => setHovered((prev) => prev && { ...prev, mouseX: e.clientX, mouseY: e.clientY })}
  113. rx={3}
  114. style={{
  115. cursor: "pointer",
  116. stroke: hovered && hovered.i === i && hovered.j === j ? "#059669" : "transparent",
  117. strokeWidth: hovered && hovered.i === i && hovered.j === j ? 2 : 0,
  118. opacity: hovered && hovered.i === i && hovered.j === j ? 0.85 : 1,
  119. transition: "stroke 0.1s, opacity 0.1s",
  120. }}
  121. width={PANEL_SIZE}
  122. x={x}
  123. y={y}
  124. />,
  125. );
  126. }
  127. }
  128. for (let i = 0; i < weekNames.length; i++) {
  129. const x = WEEK_LABEL_WIDTH / 2;
  130. const y = MONTH_LABEL_HEIGHT + (PANEL_SIZE + PANEL_MARGIN) * i + PANEL_SIZE / 2;
  131. innerDom.push(
  132. <text
  133. alignmentBaseline="central"
  134. fill="var(--color-base-content)"
  135. fontSize={10}
  136. key={`week_label_${i}`}
  137. textAnchor="middle"
  138. x={x}
  139. y={y}
  140. >
  141. {weekNames[i]}
  142. </text>,
  143. );
  144. }
  145. let prevMonth = -1;
  146. for (let i = 0; i < columns; i++) {
  147. const c = contributions[i][0];
  148. if (c === null) continue;
  149. if (columns > 1 && i === 0 && c.month !== contributions[i + 1][0]?.month) {
  150. continue;
  151. }
  152. if (c.month !== prevMonth) {
  153. const x = WEEK_LABEL_WIDTH + (PANEL_SIZE + PANEL_MARGIN) * i + PANEL_SIZE / 2;
  154. const y = MONTH_LABEL_HEIGHT / 1.5;
  155. innerDom.push(
  156. <text
  157. alignmentBaseline="central"
  158. fill="var(--color-base-content)"
  159. fontSize={12}
  160. key={`month_label_${i}`}
  161. textAnchor="middle"
  162. x={x}
  163. y={y}
  164. >
  165. {monthNames[c.month]}
  166. </text>,
  167. );
  168. }
  169. prevMonth = c.month;
  170. }
  171. const tooltipNode = hovered ? (
  172. <div
  173. style={{
  174. position: "fixed",
  175. left: hovered.mouseX - 100,
  176. top: hovered.mouseY - 8,
  177. pointerEvents: "none",
  178. zIndex: 9999,
  179. background: "rgba(33,33,33,0.97)",
  180. color: "#fff",
  181. padding: "6px 12px",
  182. borderRadius: 6,
  183. fontSize: 13,
  184. boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
  185. whiteSpace: "nowrap",
  186. maxWidth: 220,
  187. border: "1px solid var(--color-base-300)",
  188. }}
  189. >
  190. {hovered.tooltip}
  191. </div>
  192. ) : null;
  193. return (
  194. <div ref={containerRef} style={{ width: "100%", position: "relative" }}>
  195. <svg
  196. height={MONTH_LABEL_HEIGHT + (PANEL_SIZE + PANEL_MARGIN) * 7}
  197. style={{ fontFamily: "Helvetica, Arial, sans-serif", width: "100%", display: "block" }}
  198. >
  199. {innerDom}
  200. </svg>
  201. {tooltipNode}
  202. </div>
  203. );
  204. };