feat(heatmap): refactor heatmap rendering logic and improve date handling

This commit is contained in:
null 2026-05-22 18:36:12 -05:00
parent 502c44d560
commit d8a2619313
1 changed files with 125 additions and 37 deletions

View File

@ -25,12 +25,8 @@ const LCW = LVW - LP_L - LP_R;
const LCH = LVH - LP_T - LP_B;
// ── Heatmap layout ─────────────────────────────────────────────────────────
const HCELL = 11;
const HGAP = 3;
const HSTRIDE = HCELL + HGAP;
const HLEFT = 30;
const HTOP = 18;
const HMIN_VW = 420;
const HLEFT = 32;
const HRIGHT = 14;
const HVH = LVH;
// ── Types & data ───────────────────────────────────────────────────────────
@ -69,6 +65,10 @@ const HMAP_FILL = [
function isoDate(d: Date): string {
return [d.getFullYear(), String(d.getMonth()+1).padStart(2,"0"), String(d.getDate()).padStart(2,"0")].join("-");
}
function dateFromIsoDate(value: string): Date {
const [year, month, day] = value.split("-").map(Number);
return new Date(year, month - 1, day);
}
function fmtLines(n: number): string {
if (n >= 1_000_000) return `${(n/1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n/1_000).toFixed(1)}k`;
@ -140,7 +140,7 @@ function LineChart({ days, range, onRangeChange, lastPush }: {
.filter((_, i) => i % step === 0 || i === series.length-1)
.map((p) => {
const idx = series.indexOf(p);
const d = new Date(p.date);
const d = dateFromIsoDate(p.date);
return {
x: LP_L + (idx / Math.max(series.length-1,1)) * LCW,
label: useWeekly || numDays > 7
@ -255,12 +255,51 @@ function HeatmapGrid({ days, range, onRangeChange }: {
range: RangeKey;
onRangeChange: (r: RangeKey) => void;
}) {
const { weeks, monthLabels, totalEvents, viewWidth } = useMemo(() => {
const heatmap = useMemo(() => {
const lookup = new Map(days.map(d => [d.date, d.count]));
const today = new Date(); today.setHours(0,0,0,0);
const numDays = RANGE_DAYS[range];
const rangeStart = new Date(today);
rangeStart.setDate(rangeStart.getDate() - (numDays - 1));
const daily = Array.from({ length: numDays }, (_, i) => {
const d = new Date(rangeStart);
d.setDate(d.getDate() + i);
const date = isoDate(d);
return {
date,
count: lookup.get(date) ?? 0,
label:
numDays <= 7
? ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"][d.getDay()]
: `${MONTHS[d.getMonth()]} ${d.getDate()}`,
};
});
const totalEvents = daily.reduce((s, d) => s + d.count, 0);
if (numDays <= 30) {
const gap = numDays <= 7 ? 12 : 4;
const maxCell = numDays <= 7 ? 42 : 15;
const availableWidth = LVW - HLEFT - HRIGHT;
const cell = Math.max(
8,
Math.min(maxCell, Math.floor((availableWidth - gap * (numDays - 1)) / numDays)),
);
const width = daily.length * cell + Math.max(0, daily.length - 1) * gap;
const x = HLEFT + (availableWidth - width) / 2;
const y = numDays <= 7 ? 48 : 54;
return {
mode: "strip" as const,
daily,
totalEvents,
cell,
gap,
x,
y,
};
}
const calendarStart = new Date(rangeStart);
calendarStart.setDate(calendarStart.getDate() - calendarStart.getDay());
const calendarEnd = new Date(today);
@ -270,14 +309,14 @@ function HeatmapGrid({ days, range, onRangeChange }: {
Math.ceil(((calendarEnd.getTime() - calendarStart.getTime()) / 86400000 + 1) / 7),
);
type Cell = { date:string; count:number; outsideRange:boolean };
const builtWeeks: Cell[][] = [];
type CalendarCell = { date:string; count:number; outsideRange:boolean };
const builtWeeks: CalendarCell[][] = [];
const monthLabelList: {weekIdx:number; label:string}[] = [];
let lastMonth = -1;
const cur = new Date(calendarStart);
for (let w = 0; w < weekCount; w++) {
const week: Cell[] = [];
const week: CalendarCell[] = [];
for (let d = 0; d < 7; d++) {
const dateStr = isoDate(cur);
const month = cur.getMonth();
@ -295,12 +334,29 @@ function HeatmapGrid({ days, range, onRangeChange }: {
builtWeeks.push(week);
}
// contributions count for selected range
const cutoffStr = isoDate(rangeStart);
const totalEvents = days.filter(d => d.date >= cutoffStr).reduce((s,d) => s+d.count, 0);
const viewWidth = Math.max(HMIN_VW, HLEFT + weekCount * HSTRIDE);
const gap = 3;
const availableWidth = LVW - HLEFT - HRIGHT;
const maxCell = range === "3m" ? 14 : range === "6m" ? 12 : 8;
const cell = Math.max(
6,
Math.min(maxCell, Math.floor((availableWidth - gap * (weekCount - 1)) / weekCount)),
);
const stride = cell + gap;
const width = weekCount * cell + Math.max(0, weekCount - 1) * gap;
const x = HLEFT + (availableWidth - width) / 2;
const y = 28;
return { weeks: builtWeeks, monthLabels: monthLabelList, totalEvents, viewWidth };
return {
mode: "calendar" as const,
weeks: builtWeeks,
monthLabels: monthLabelList,
totalEvents,
cell,
gap,
stride,
x,
y,
};
}, [days, range]);
return (
@ -325,34 +381,66 @@ function HeatmapGrid({ days, range, onRangeChange }: {
</div>
{/* SVG */}
<svg viewBox={`0 0 ${viewWidth} ${HVH}`} width="100%" style={{ display:"block", height:LVH }} aria-label={`Activity heatmap last ${range}`}>
{monthLabels.map(({weekIdx, label}) => (
<text key={`m-${weekIdx}`} x={HLEFT+weekIdx*HSTRIDE} y={11} fontSize={10} style={{fill:W70}}>{label}</text>
<svg viewBox={`0 0 ${LVW} ${HVH}`} width="100%" style={{ display:"block", height:LVH }} aria-label={`Activity heatmap last ${range}`}>
{heatmap.mode === "strip" ? (
<>
{heatmap.daily.map((day, i) => (
<g key={day.date}>
<rect
x={heatmap.x + i * (heatmap.cell + heatmap.gap)}
y={heatmap.y}
width={heatmap.cell}
height={heatmap.cell}
rx={4}
style={{fill: HMAP_FILL[toLevel(day.count)]}}
>
<title>{day.date}{day.count>0?`: ${day.count} event${day.count!==1?"s":""}` : ": no activity"}</title>
</rect>
{(range === "7d" || i % 5 === 0 || i === heatmap.daily.length - 1) && (
<text
x={heatmap.x + i * (heatmap.cell + heatmap.gap) + heatmap.cell / 2}
y={heatmap.y + heatmap.cell + 17}
fontSize={10}
textAnchor="middle"
style={{fill:W70}}
>
{day.label}
</text>
)}
</g>
))}
</>
) : (
<>
{heatmap.monthLabels.map(({weekIdx, label}) => (
<text key={`m-${weekIdx}`} x={heatmap.x+weekIdx*heatmap.stride} y={15} fontSize={10} style={{fill:W70}}>{label}</text>
))}
{(["Mon","Wed","Fri"] as const).map((label, i) => (
<text key={label} x={0} y={HTOP+(i*2+1)*HSTRIDE+HCELL-2} fontSize={10} style={{fill:W70}}>{label}</text>
<text key={label} x={0} y={heatmap.y+(i*2+1)*heatmap.stride+heatmap.cell-2} fontSize={10} style={{fill:W70}}>{label}</text>
))}
{weeks.map((week, wi) =>
{heatmap.weeks.map((week, wi) =>
week.map((cell, di) => cell.outsideRange ? null : (
<rect key={cell.date}
x={HLEFT+wi*HSTRIDE} y={HTOP+di*HSTRIDE}
width={HCELL} height={HCELL} rx={2}
x={heatmap.x+wi*heatmap.stride} y={heatmap.y+di*heatmap.stride}
width={heatmap.cell} height={heatmap.cell} rx={2}
style={{fill: HMAP_FILL[toLevel(cell.count)]}}>
<title>{cell.date}{cell.count>0?`: ${cell.count} event${cell.count!==1?"s":""}` : ": no activity"}</title>
</rect>
))
)}
<text x={HLEFT} y={HVH-4} fontSize={9} style={{fill:W40}}>Less</text>
</>
)}
<text x={HLEFT} y={HVH-9} fontSize={9} style={{fill:W40}}>Less</text>
{HMAP_FILL.map((fill, i) => (
<rect key={i} x={HLEFT+32+i*(HCELL+3)} y={HVH-HCELL-4} width={HCELL} height={HCELL} rx={2} style={{fill}}/>
<rect key={i} x={HLEFT+32+i*14} y={HVH-20} width={11} height={11} rx={2} style={{fill}}/>
))}
<text x={HLEFT+32+HMAP_FILL.length*(HCELL+3)+2} y={HVH-4} fontSize={9} style={{fill:W40}}>More</text>
<text x={HLEFT+32+HMAP_FILL.length*14+2} y={HVH-9} fontSize={9} style={{fill:W40}}>More</text>
</svg>
{/* Contributions summary */}
<p className="text-center">
<span className="text-2xl font-bold tabular-nums" style={{color:VIOLET}}>
{totalEvents.toLocaleString()}
<span className="text-2xl font-bold tabular-nums" style={{color:GREEN}}>
{heatmap.totalEvents.toLocaleString()}
</span>
<span className="ml-1.5 text-sm" style={{color:W70}}>
contributions across all tracked repositories in the last {RANGE_SUMMARY[range]}