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