diff --git a/web/src/components/Charts.tsx b/web/src/components/Charts.tsx index 5c92b91..8e66ad0 100644 --- a/web/src/components/Charts.tsx +++ b/web/src/components/Charts.tsx @@ -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() { const ref = useRef(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 (
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({ )}
- + {t("stats.heatEmpty")} {format(0)} - {SEQ.slice(1).map((c, i) => ( - - ))} + {format(max)}
{tip && ( diff --git a/web/src/styles.css b/web/src/styles.css index 40c87a7..91089a3 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1066,6 +1066,9 @@ table.grid input[type="number"] { border-radius: 3px; display: inline-block; } +.legend i.heat-scale { + width: 120px; +} .tooltip { position: absolute; pointer-events: none; diff --git a/web/src/themes/base.css b/web/src/themes/base.css index 602d5b6..f4c8552 100644 --- a/web/src/themes/base.css +++ b/web/src/themes/base.css @@ -9,13 +9,8 @@ --series-6: #008300; --series-7: #9085e9; --series-8: #e66767; - --seq-0: color-mix(in srgb, var(--fg1) 9%, var(--bg1)); - --seq-1: #104281; - --seq-2: #184f95; - --seq-3: #256abf; - --seq-4: #3987e5; - --seq-5: #6da7ec; - --seq-6: #9ec5f4; + --heat-empty: color-mix(in srgb, var(--fg1) 9%, var(--bg1)); + --heat-full: var(--accent); } :root[data-mode="light"] { --series-1: #2a78d6; @@ -26,13 +21,8 @@ --series-6: #008300; --series-7: #6250d6; --series-8: #e34948; - --seq-0: color-mix(in srgb, var(--fg1) 9%, var(--bg1)); - --seq-1: #cde2fb; - --seq-2: #9ec5f4; - --seq-3: #6da7ec; - --seq-4: #3987e5; - --seq-5: #256abf; - --seq-6: #184f95; + --heat-empty: color-mix(in srgb, var(--fg1) 9%, var(--bg1)); + --heat-full: var(--accent); } /* Theme-independent tokens. */