Stats: heatmap uses the theme accent with a continuous color ramp

Cells blend in OKLab from a neutral empty color to the theme's accent on a
square-root scale instead of seven fixed blue steps; the legend shows the
gradient.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-09 07:57:59 +01:00
1 parent ed9942829a
commit e693bb196f
3 files changed
+28 -22

No files matched your search

+21 -8
View File
@@ -6,7 +6,23 @@ import { useEffect, useRef, useState, type ReactNode } from "react";
import { useT } from "../i18n";
export const SERIES = Array.from({ length: 8 }, (_, i) => `var(--series-${i + 1})`);
const SEQ = Array.from({ length: 7 }, (_, i) => `var(--seq-${i})`);
/**
* Sequential heat color from the theme: empty cells use the neutral --heat-empty,
* any value > 0 blends continuously (in OKLab, so lightness changes evenly) from a
* faint tint to the full theme accent. A square-root scale keeps rare small values
* visible next to a few large peaks.
*/
const HEAT_MIN = 22;
function heatShare(v: number, max: number): number {
if (v <= 0 || max <= 0) return 0;
return HEAT_MIN + (100 - HEAT_MIN) * Math.sqrt(Math.min(v / max, 1));
}
function heatColor(share: number): string {
return share <= 0 ? "var(--heat-empty)" : `color-mix(in oklab, var(--heat-full) ${share.toFixed(1)}%, var(--heat-empty))`;
}
const HEAT_GRADIENT = `linear-gradient(in oklab to right, ${[0, 0.1, 0.25, 0.45, 0.7, 1]
.map((f) => `${heatColor(heatShare(f * f, 1))} ${f * 100}%`)
.join(", ")})`;
function useWidth<T extends HTMLElement>() {
const ref = useRef<T>(null);
@@ -173,7 +189,7 @@ export function StackedBars({
);
}
/** Day × hour heatmap with a 7-step sequential ramp. cells[row*24+hour]; rows top to bottom. */
/** Day × hour heatmap with a continuous sequential ramp in the theme accent. cells[row*24+hour]; rows top to bottom. */
export function Heatmap({
cells,
rows,
@@ -194,7 +210,6 @@ export function Heatmap({
const cell = Math.min(cellW, 26);
const max = Math.max(...cells, 0);
const order = rows.map((_, i) => i);
const bin = (v: number) => (v <= 0 || max === 0 ? 0 : Math.min(6, 1 + Math.floor((v / max) * 5.999)));
return (
<div className="chart" ref={ref} onMouseLeave={() => setTip(undefined)}>
@@ -224,7 +239,7 @@ export function Heatmap({
width={cellW - 2}
height={cell - 2}
rx={3}
fill={SEQ[bin(v)]}
style={{ fill: heatColor(heatShare(v, max)) }}
onMouseMove={(e) => {
const box = (e.currentTarget.ownerSVGElement as SVGSVGElement).getBoundingClientRect();
setTip({
@@ -246,12 +261,10 @@ export function Heatmap({
)}
</svg>
<div className="legend" style={{ marginTop: 6 }}>
<i style={{ background: SEQ[0], width: 18 }} />
<i style={{ background: heatColor(0), width: 18 }} />
<span>{t("stats.heatEmpty")}</span>
<span style={{ marginLeft: 12 }}>{format(0)}</span>
{SEQ.slice(1).map((c, i) => (
<i key={i} style={{ background: c, width: 18 }} />
))}
<i className="heat-scale" style={{ background: HEAT_GRADIENT }} />
<span>{format(max)}</span>
</div>
{tip && (