126 lines
3.4 KiB
TypeScript
126 lines
3.4 KiB
TypeScript
|
|
import { useMemo } from 'react';
|
||
|
|
|
||
|
|
export interface PunchcardCell {
|
||
|
|
weekday: number;
|
||
|
|
hour: number;
|
||
|
|
totalCount: number;
|
||
|
|
failedCount: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface PunchcardHeatmapProps {
|
||
|
|
cells: PunchcardCell[];
|
||
|
|
mode: 'transactions' | 'errors';
|
||
|
|
width: number;
|
||
|
|
height: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||
|
|
const LEFT_MARGIN = 28;
|
||
|
|
const TOP_MARGIN = 18;
|
||
|
|
const BOTTOM_MARGIN = 4;
|
||
|
|
const RIGHT_MARGIN = 4;
|
||
|
|
|
||
|
|
function transactionColor(ratio: number): string {
|
||
|
|
if (ratio === 0) return 'hsl(220, 15%, 95%)';
|
||
|
|
const lightness = 90 - ratio * 55;
|
||
|
|
return `hsl(220, 60%, ${Math.round(lightness)}%)`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function errorColor(ratio: number): string {
|
||
|
|
if (ratio === 0) return 'hsl(0, 10%, 95%)';
|
||
|
|
const lightness = 90 - ratio * 55;
|
||
|
|
return `hsl(0, 65%, ${Math.round(lightness)}%)`;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function PunchcardHeatmap({ cells, mode, width, height }: PunchcardHeatmapProps) {
|
||
|
|
const grid = useMemo(() => {
|
||
|
|
const map = new Map<string, PunchcardCell>();
|
||
|
|
for (const c of cells) {
|
||
|
|
map.set(`${c.weekday}-${c.hour}`, c);
|
||
|
|
}
|
||
|
|
|
||
|
|
const values: number[] = [];
|
||
|
|
for (const c of cells) {
|
||
|
|
values.push(mode === 'errors' ? c.failedCount : c.totalCount);
|
||
|
|
}
|
||
|
|
const maxVal = Math.max(...values, 1);
|
||
|
|
|
||
|
|
const gridWidth = width - LEFT_MARGIN - RIGHT_MARGIN;
|
||
|
|
const gridHeight = height - TOP_MARGIN - BOTTOM_MARGIN;
|
||
|
|
const cellW = gridWidth / 7;
|
||
|
|
const cellH = gridHeight / 24;
|
||
|
|
|
||
|
|
const rects: { x: number; y: number; w: number; h: number; fill: string; value: number; day: string; hour: number }[] = [];
|
||
|
|
|
||
|
|
for (let d = 0; d < 7; d++) {
|
||
|
|
for (let h = 0; h < 24; h++) {
|
||
|
|
const cell = map.get(`${d}-${h}`);
|
||
|
|
const val = cell ? (mode === 'errors' ? cell.failedCount : cell.totalCount) : 0;
|
||
|
|
const ratio = maxVal > 0 ? val / maxVal : 0;
|
||
|
|
const fill = mode === 'errors' ? errorColor(ratio) : transactionColor(ratio);
|
||
|
|
|
||
|
|
rects.push({
|
||
|
|
x: LEFT_MARGIN + d * cellW,
|
||
|
|
y: TOP_MARGIN + h * cellH,
|
||
|
|
w: cellW,
|
||
|
|
h: cellH,
|
||
|
|
fill,
|
||
|
|
value: val,
|
||
|
|
day: DAYS[d],
|
||
|
|
hour: h,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return { rects, cellW, cellH };
|
||
|
|
}, [cells, mode, width, height]);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<svg width={width} height={height} style={{ display: 'block' }}>
|
||
|
|
{/* Day labels (top) */}
|
||
|
|
{DAYS.map((day, i) => (
|
||
|
|
<text
|
||
|
|
key={day}
|
||
|
|
x={LEFT_MARGIN + i * grid.cellW + grid.cellW / 2}
|
||
|
|
y={12}
|
||
|
|
textAnchor="middle"
|
||
|
|
fill="var(--text-muted)"
|
||
|
|
fontSize={9}
|
||
|
|
fontFamily="var(--font-mono)"
|
||
|
|
>
|
||
|
|
{day}
|
||
|
|
</text>
|
||
|
|
))}
|
||
|
|
|
||
|
|
{/* Hour labels (left, every 4 hours) */}
|
||
|
|
{[0, 4, 8, 12, 16, 20].map((h) => (
|
||
|
|
<text
|
||
|
|
key={h}
|
||
|
|
x={LEFT_MARGIN - 4}
|
||
|
|
y={TOP_MARGIN + h * grid.cellH + grid.cellH / 2 + 3}
|
||
|
|
textAnchor="end"
|
||
|
|
fill="var(--text-muted)"
|
||
|
|
fontSize={8}
|
||
|
|
fontFamily="var(--font-mono)"
|
||
|
|
>
|
||
|
|
{String(h).padStart(2, '0')}
|
||
|
|
</text>
|
||
|
|
))}
|
||
|
|
|
||
|
|
{/* Cells */}
|
||
|
|
{grid.rects.map((r) => (
|
||
|
|
<rect
|
||
|
|
key={`${r.day}-${r.hour}`}
|
||
|
|
x={r.x + 0.5}
|
||
|
|
y={r.y + 0.5}
|
||
|
|
width={Math.max(r.w - 1, 0)}
|
||
|
|
height={Math.max(r.h - 1, 0)}
|
||
|
|
rx={1.5}
|
||
|
|
fill={r.fill}
|
||
|
|
>
|
||
|
|
<title>{`${r.day} ${String(r.hour).padStart(2, '0')}:00 — ${r.value.toLocaleString()} ${mode}`}</title>
|
||
|
|
</rect>
|
||
|
|
))}
|
||
|
|
</svg>
|
||
|
|
);
|
||
|
|
}
|