feat: GitHub-style contribution grid for punchcard heatmap
Replace Recharts ScatterChart with compact SVG grid of small rounded squares (11x11px, 2px gap). 7 rows (Mon-Sun) x 24 columns (hours). Color intensity = value relative to max. Transactions = blue scale, Errors = red scale. Toggle switches between modes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,4 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
ScatterChart, Scatter, XAxis, YAxis, Tooltip,
|
||||
ResponsiveContainer, Rectangle,
|
||||
} from 'recharts';
|
||||
import { rechartsTheme } from '@cameleer/design-system';
|
||||
import styles from './DashboardTab.module.css';
|
||||
|
||||
export interface PunchcardCell {
|
||||
@@ -19,82 +14,54 @@ interface PunchcardHeatmapProps {
|
||||
|
||||
type Mode = 'transactions' | 'errors';
|
||||
|
||||
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
// Remap: backend DOW 0=Sun..6=Sat → display 0=Mon..6=Sun
|
||||
function toDisplayDay(dow: number): number {
|
||||
return dow === 0 ? 6 : dow - 1;
|
||||
}
|
||||
|
||||
function transactionColor(ratio: number): string {
|
||||
if (ratio === 0) return 'var(--bg-inset)';
|
||||
const lightness = 90 - ratio * 55;
|
||||
return `hsl(220, 60%, ${Math.round(lightness)}%)`;
|
||||
// Blue scale matching --running hue
|
||||
const alpha = 0.15 + ratio * 0.75;
|
||||
return `hsla(220, 65%, 50%, ${alpha.toFixed(2)})`;
|
||||
}
|
||||
|
||||
function errorColor(ratio: number): string {
|
||||
if (ratio === 0) return 'var(--bg-inset)';
|
||||
const lightness = 90 - ratio * 55;
|
||||
return `hsl(0, 65%, ${Math.round(lightness)}%)`;
|
||||
const alpha = 0.15 + ratio * 0.75;
|
||||
return `hsla(0, 65%, 50%, ${alpha.toFixed(2)})`;
|
||||
}
|
||||
|
||||
interface HeatmapPoint {
|
||||
weekday: number;
|
||||
hour: number;
|
||||
value: number;
|
||||
fill: string;
|
||||
}
|
||||
|
||||
function HeatmapCell(props: Record<string, unknown>) {
|
||||
const { cx, cy, payload } = props as {
|
||||
cx: number; cy: number; payload: HeatmapPoint;
|
||||
};
|
||||
if (!payload) return null;
|
||||
const cellW = 32;
|
||||
const cellH = 10;
|
||||
return (
|
||||
<Rectangle
|
||||
x={cx - cellW / 2}
|
||||
y={cy - cellH / 2}
|
||||
width={cellW}
|
||||
height={cellH}
|
||||
fill={payload.fill}
|
||||
radius={2}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDay(value: number): string {
|
||||
return DAYS[value] ?? '';
|
||||
}
|
||||
|
||||
function formatHour(value: number): string {
|
||||
return String(value).padStart(2, '0');
|
||||
}
|
||||
|
||||
function buildGrid(cells: PunchcardCell[], mode: Mode): HeatmapPoint[] {
|
||||
const values = cells.map(c => mode === 'errors' ? c.failedCount : c.totalCount);
|
||||
const maxVal = Math.max(...values, 1);
|
||||
|
||||
const cellMap = new Map<string, PunchcardCell>();
|
||||
for (const c of cells) cellMap.set(`${c.weekday}-${c.hour}`, c);
|
||||
|
||||
const points: HeatmapPoint[] = [];
|
||||
for (let d = 0; d < 7; d++) {
|
||||
for (let h = 0; h < 24; h++) {
|
||||
const cell = cellMap.get(`${d}-${h}`);
|
||||
const val = cell ? (mode === 'errors' ? cell.failedCount : cell.totalCount) : 0;
|
||||
const ratio = maxVal > 0 ? val / maxVal : 0;
|
||||
points.push({
|
||||
weekday: d,
|
||||
hour: h,
|
||||
value: val,
|
||||
fill: mode === 'errors' ? errorColor(ratio) : transactionColor(ratio),
|
||||
});
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}
|
||||
const CELL = 11;
|
||||
const GAP = 2;
|
||||
const LABEL_W = 28;
|
||||
const LABEL_H = 14;
|
||||
|
||||
export function PunchcardHeatmap({ cells }: PunchcardHeatmapProps) {
|
||||
const [mode, setMode] = useState<Mode>('transactions');
|
||||
|
||||
const data = useMemo(() => buildGrid(cells, mode), [cells, mode]);
|
||||
const { grid, maxVal } = useMemo(() => {
|
||||
const cellMap = new Map<string, PunchcardCell>();
|
||||
for (const c of cells) cellMap.set(`${toDisplayDay(c.weekday)}-${c.hour}`, c);
|
||||
|
||||
let max = 0;
|
||||
const g: { day: number; hour: number; value: number }[] = [];
|
||||
for (let d = 0; d < 7; d++) {
|
||||
for (let h = 0; h < 24; h++) {
|
||||
const cell = cellMap.get(`${d}-${h}`);
|
||||
const val = cell ? (mode === 'errors' ? cell.failedCount : cell.totalCount) : 0;
|
||||
if (val > max) max = val;
|
||||
g.push({ day: d, hour: h, value: val });
|
||||
}
|
||||
}
|
||||
return { grid: g, maxVal: Math.max(max, 1) };
|
||||
}, [cells, mode]);
|
||||
|
||||
const cols = 24;
|
||||
const rows = 7;
|
||||
const svgW = LABEL_W + cols * (CELL + GAP);
|
||||
const svgH = LABEL_H + rows * (CELL + GAP);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -112,44 +79,56 @@ export function PunchcardHeatmap({ cells }: PunchcardHeatmapProps) {
|
||||
Errors
|
||||
</button>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<ScatterChart margin={{ top: 10, right: 10, bottom: 10, left: 10 }}>
|
||||
<XAxis
|
||||
type="number"
|
||||
dataKey="weekday"
|
||||
domain={[0, 6]}
|
||||
ticks={[0, 1, 2, 3, 4, 5, 6]}
|
||||
tickFormatter={formatDay}
|
||||
{...rechartsTheme.xAxis}
|
||||
/>
|
||||
<YAxis
|
||||
type="number"
|
||||
dataKey="hour"
|
||||
domain={[0, 23]}
|
||||
ticks={[0, 4, 8, 12, 16, 20]}
|
||||
tickFormatter={formatHour}
|
||||
reversed
|
||||
{...rechartsTheme.yAxis}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={rechartsTheme.tooltip.contentStyle}
|
||||
labelStyle={rechartsTheme.tooltip.labelStyle}
|
||||
itemStyle={rechartsTheme.tooltip.itemStyle}
|
||||
cursor={false}
|
||||
formatter={(_val: unknown, _name: string, entry: { payload?: HeatmapPoint }) => {
|
||||
const p = entry.payload;
|
||||
if (!p) return '';
|
||||
return [`${p.value.toLocaleString()} ${mode}`, `${DAYS[p.weekday]} ${formatHour(p.hour)}:00`];
|
||||
}}
|
||||
labelFormatter={() => ''}
|
||||
/>
|
||||
<Scatter
|
||||
data={data}
|
||||
shape={(props: unknown) => <HeatmapCell {...(props as Record<string, unknown>)} />}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</ScatterChart>
|
||||
</ResponsiveContainer>
|
||||
<svg viewBox={`0 0 ${svgW} ${svgH}`} style={{ width: '100%', height: 'auto', display: 'block' }}>
|
||||
{/* Hour labels (top, every 4 hours) */}
|
||||
{[0, 4, 8, 12, 16, 20].map(h => (
|
||||
<text
|
||||
key={h}
|
||||
x={LABEL_W + h * (CELL + GAP) + CELL / 2}
|
||||
y={10}
|
||||
textAnchor="middle"
|
||||
fill="var(--text-faint)"
|
||||
fontSize={7}
|
||||
fontFamily="var(--font-mono)"
|
||||
>
|
||||
{String(h).padStart(2, '0')}
|
||||
</text>
|
||||
))}
|
||||
|
||||
{/* Day labels (left) */}
|
||||
{DAYS.map((day, i) => (
|
||||
<text
|
||||
key={day}
|
||||
x={LABEL_W - 4}
|
||||
y={LABEL_H + i * (CELL + GAP) + CELL / 2 + 3}
|
||||
textAnchor="end"
|
||||
fill="var(--text-faint)"
|
||||
fontSize={7}
|
||||
fontFamily="var(--font-mono)"
|
||||
>
|
||||
{day}
|
||||
</text>
|
||||
))}
|
||||
|
||||
{/* Cells */}
|
||||
{grid.map(({ day, hour, value }) => {
|
||||
const ratio = value / maxVal;
|
||||
const fill = mode === 'errors' ? errorColor(ratio) : transactionColor(ratio);
|
||||
return (
|
||||
<rect
|
||||
key={`${day}-${hour}`}
|
||||
x={LABEL_W + hour * (CELL + GAP)}
|
||||
y={LABEL_H + day * (CELL + GAP)}
|
||||
width={CELL}
|
||||
height={CELL}
|
||||
rx={2}
|
||||
fill={fill}
|
||||
>
|
||||
<title>{`${DAYS[day]} ${String(hour).padStart(2, '0')}:00 — ${value.toLocaleString()} ${mode}`}</title>
|
||||
</rect>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user