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:
1 parent
ed9942829a
commit
e693bb196f
3 files changed
+28
-22
No files matched your search
@@ -6,7 +6,23 @@ import { useEffect, useRef, useState, type ReactNode } from "react";
|
|||||||
import { useT } from "../i18n";
|
import { useT } from "../i18n";
|
||||||
|
|
||||||
export const SERIES = Array.from({ length: 8 }, (_, i) => `var(--series-${i + 1})`);
|
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>() {
|
function useWidth<T extends HTMLElement>() {
|
||||||
const ref = useRef<T>(null);
|
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({
|
export function Heatmap({
|
||||||
cells,
|
cells,
|
||||||
rows,
|
rows,
|
||||||
@@ -194,7 +210,6 @@ export function Heatmap({
|
|||||||
const cell = Math.min(cellW, 26);
|
const cell = Math.min(cellW, 26);
|
||||||
const max = Math.max(...cells, 0);
|
const max = Math.max(...cells, 0);
|
||||||
const order = rows.map((_, i) => i);
|
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 (
|
return (
|
||||||
<div className="chart" ref={ref} onMouseLeave={() => setTip(undefined)}>
|
<div className="chart" ref={ref} onMouseLeave={() => setTip(undefined)}>
|
||||||
@@ -224,7 +239,7 @@ export function Heatmap({
|
|||||||
width={cellW - 2}
|
width={cellW - 2}
|
||||||
height={cell - 2}
|
height={cell - 2}
|
||||||
rx={3}
|
rx={3}
|
||||||
fill={SEQ[bin(v)]}
|
style={{ fill: heatColor(heatShare(v, max)) }}
|
||||||
onMouseMove={(e) => {
|
onMouseMove={(e) => {
|
||||||
const box = (e.currentTarget.ownerSVGElement as SVGSVGElement).getBoundingClientRect();
|
const box = (e.currentTarget.ownerSVGElement as SVGSVGElement).getBoundingClientRect();
|
||||||
setTip({
|
setTip({
|
||||||
@@ -246,12 +261,10 @@ export function Heatmap({
|
|||||||
)}
|
)}
|
||||||
</svg>
|
</svg>
|
||||||
<div className="legend" style={{ marginTop: 6 }}>
|
<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>{t("stats.heatEmpty")}</span>
|
||||||
<span style={{ marginLeft: 12 }}>{format(0)}</span>
|
<span style={{ marginLeft: 12 }}>{format(0)}</span>
|
||||||
{SEQ.slice(1).map((c, i) => (
|
<i className="heat-scale" style={{ background: HEAT_GRADIENT }} />
|
||||||
<i key={i} style={{ background: c, width: 18 }} />
|
|
||||||
))}
|
|
||||||
<span>{format(max)}</span>
|
<span>{format(max)}</span>
|
||||||
</div>
|
</div>
|
||||||
{tip && (
|
{tip && (
|
||||||
|
|||||||
@@ -1066,6 +1066,9 @@ table.grid input[type="number"] {
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
.legend i.heat-scale {
|
||||||
|
width: 120px;
|
||||||
|
}
|
||||||
.tooltip {
|
.tooltip {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
|||||||
+4
-14
@@ -9,13 +9,8 @@
|
|||||||
--series-6: #008300;
|
--series-6: #008300;
|
||||||
--series-7: #9085e9;
|
--series-7: #9085e9;
|
||||||
--series-8: #e66767;
|
--series-8: #e66767;
|
||||||
--seq-0: color-mix(in srgb, var(--fg1) 9%, var(--bg1));
|
--heat-empty: color-mix(in srgb, var(--fg1) 9%, var(--bg1));
|
||||||
--seq-1: #104281;
|
--heat-full: var(--accent);
|
||||||
--seq-2: #184f95;
|
|
||||||
--seq-3: #256abf;
|
|
||||||
--seq-4: #3987e5;
|
|
||||||
--seq-5: #6da7ec;
|
|
||||||
--seq-6: #9ec5f4;
|
|
||||||
}
|
}
|
||||||
:root[data-mode="light"] {
|
:root[data-mode="light"] {
|
||||||
--series-1: #2a78d6;
|
--series-1: #2a78d6;
|
||||||
@@ -26,13 +21,8 @@
|
|||||||
--series-6: #008300;
|
--series-6: #008300;
|
||||||
--series-7: #6250d6;
|
--series-7: #6250d6;
|
||||||
--series-8: #e34948;
|
--series-8: #e34948;
|
||||||
--seq-0: color-mix(in srgb, var(--fg1) 9%, var(--bg1));
|
--heat-empty: color-mix(in srgb, var(--fg1) 9%, var(--bg1));
|
||||||
--seq-1: #cde2fb;
|
--heat-full: var(--accent);
|
||||||
--seq-2: #9ec5f4;
|
|
||||||
--seq-3: #6da7ec;
|
|
||||||
--seq-4: #3987e5;
|
|
||||||
--seq-5: #256abf;
|
|
||||||
--seq-6: #184f95;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Theme-independent tokens. */
|
/* Theme-independent tokens. */
|
||||||
|
|||||||
Reference in new issue
Block a user