feat(heatmap): refactor heatmap rendering logic and improve date handling
This commit is contained in:
parent
502c44d560
commit
d8a2619313
|
|
@ -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]}
|
||||
|
|
|
|||
Loading…
Reference in New Issue