feat(heatmap): enhance heatmap functionality with improved activity date formatting and dynamic hover effects

This commit is contained in:
null 2026-05-22 18:56:32 -05:00
parent d8a2619313
commit f0a5c430f1
1 changed files with 104 additions and 19 deletions

View File

@ -16,7 +16,7 @@ interface ForgejoHeatmapProps {
// ── Line chart layout ────────────────────────────────────────────────────── // ── Line chart layout ──────────────────────────────────────────────────────
const LVW = 580; const LVW = 580;
const LVH = 160; const LVH = 188;
const LP_L = 34; // Y-axis labels const LP_L = 34; // Y-axis labels
const LP_R = 8; const LP_R = 8;
const LP_T = 10; const LP_T = 10;
@ -31,6 +31,12 @@ const HVH = LVH;
// ── Types & data ─────────────────────────────────────────────────────────── // ── Types & data ───────────────────────────────────────────────────────────
type RangeKey = "7d" | "30d" | "3m" | "6m" | "1y"; type RangeKey = "7d" | "30d" | "3m" | "6m" | "1y";
type ActivityDatum = {
date: string;
count: number;
label: string;
};
const RANGE_DAYS: Record<RangeKey, number> = { const RANGE_DAYS: Record<RangeKey, number> = {
"7d": 7, "30d": 30, "3m": 91, "6m": 183, "1y": 365, "7d": 7, "30d": 30, "3m": 91, "6m": 183, "1y": 365,
}; };
@ -81,6 +87,10 @@ function fmtRelative(iso: string): string {
const h = Math.floor(m/60); if (h < 24) return `${h}h ago`; const h = Math.floor(m/60); if (h < 24) return `${h}h ago`;
return `${Math.floor(h/24)}d ago`; return `${Math.floor(h/24)}d ago`;
} }
function fmtActivityDate(date: string): string {
const d = dateFromIsoDate(date);
return `${MONTHS[d.getMonth()]} ${d.getDate()}`;
}
// Catmull-Rom → cubic bezier smooth path // Catmull-Rom → cubic bezier smooth path
function smoothPath(pts: {x:number;y:number}[]): string { function smoothPath(pts: {x:number;y:number}[]): string {
@ -94,12 +104,11 @@ function smoothPath(pts: {x:number;y:number}[]): string {
return d; return d;
} }
function toLevel(count: number): number { function heatFill(count: number, maxCount: number): string {
if (count === 0) return 0; if (count <= 0 || maxCount <= 0) return HMAP_FILL[0];
if (count <= 2) return 1; const intensity = Math.max(0.18, Math.min(1, count / maxCount));
if (count <= 5) return 2; const alpha = 0.18 + intensity * 0.82;
if (count <= 9) return 3; return `rgba(16,185,129,${alpha.toFixed(2)})`;
return 4;
} }
// ── Line chart sub-component ─────────────────────────────────────────────── // ── Line chart sub-component ───────────────────────────────────────────────
@ -109,7 +118,8 @@ function LineChart({ days, range, onRangeChange, lastPush }: {
onRangeChange: (r: RangeKey) => void; onRangeChange: (r: RangeKey) => void;
lastPush: ForgejoLastPush | null; lastPush: ForgejoLastPush | null;
}) { }) {
const { points, xLabels, yLabels, linePath, areaPath } = useMemo(() => { const [hoveredPoint, setHoveredPoint] = useState<ActivityDatum | null>(null);
const { points, xLabels, yLabels, linePath, areaPath, totalEvents } = useMemo(() => {
const lookup = new Map(days.map(d => [d.date, d.count])); const lookup = new Map(days.map(d => [d.date, d.count]));
const numDays = RANGE_DAYS[range]; const numDays = RANGE_DAYS[range];
const today = new Date(); today.setHours(0,0,0,0); const today = new Date(); today.setHours(0,0,0,0);
@ -123,16 +133,24 @@ function LineChart({ days, range, onRangeChange, lastPush }: {
const series = useWeekly const series = useWeekly
? Array.from({length: Math.ceil(daily.length/7)}, (_, i) => { ? Array.from({length: Math.ceil(daily.length/7)}, (_, i) => {
const chunk = daily.slice(i*7, i*7+7); const chunk = daily.slice(i*7, i*7+7);
return { date: chunk[0].date, count: chunk.reduce((s,d) => s+d.count, 0) }; return {
date: chunk[0].date,
count: chunk.reduce((s,d) => s+d.count, 0),
label: `Week of ${fmtActivityDate(chunk[0].date)}`,
};
}) })
: daily; : daily.map(d => ({
...d,
label: fmtActivityDate(d.date),
}));
const localMax = Math.max(...series.map(p => p.count), 1); const localMax = Math.max(...series.map(p => p.count), 1);
const pts = series.map((p, i) => ({ const pts = series.map((p, i) => ({
x: LP_L + (i / Math.max(series.length-1,1)) * LCW, x: LP_L + (i / Math.max(series.length-1,1)) * LCW,
y: LP_T + LCH - (p.count / localMax) * LCH, y: LP_T + LCH - (p.count / localMax) * LCH,
date: p.date, count: p.count, date: p.date, count: p.count, label: p.label,
})); }));
const totalEvents = daily.reduce((s, d) => s + d.count, 0);
// X labels // X labels
const step = Math.max(1, Math.floor(series.length / 5)); const step = Math.max(1, Math.floor(series.length / 5));
@ -162,11 +180,16 @@ function LineChart({ days, range, onRangeChange, lastPush }: {
const areaPath = pts.length < 2 ? "" : const areaPath = pts.length < 2 ? "" :
linePath + ` L${last.x.toFixed(1)},${baseline} L${first.x.toFixed(1)},${baseline} Z`; linePath + ` L${last.x.toFixed(1)},${baseline} L${first.x.toFixed(1)},${baseline} Z`;
return { points: pts, xLabels, yLabels, linePath, areaPath }; return { points: pts, xLabels, yLabels, linePath, areaPath, totalEvents };
}, [days, range]); }, [days, range]);
const baseline = LP_T + LCH; const baseline = LP_T + LCH;
const lastPt = points[points.length-1]; const lastPt = points[points.length-1];
const activePoint = hoveredPoint
? points.find((p) => p.date === hoveredPoint.date) ?? null
: null;
const displayedCount = hoveredPoint?.count ?? totalEvents;
const displayedLabel = hoveredPoint?.label ?? RANGE_SUMMARY[range];
return ( return (
<div> <div>
@ -175,6 +198,13 @@ function LineChart({ days, range, onRangeChange, lastPush }: {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Activity className="h-4 w-4" style={{ color: VIOLET }} /> <Activity className="h-4 w-4" style={{ color: VIOLET }} />
<span className="text-sm font-semibold text-strong">Git Activity</span> <span className="text-sm font-semibold text-strong">Git Activity</span>
<span className="ml-1 rounded px-1.5 py-0.5 text-[11px] font-semibold tabular-nums"
style={{background:"rgba(139,92,246,0.14)", color:VIOLET}}>
{displayedCount.toLocaleString()}
</span>
<span className="hidden text-xs sm:inline" style={{color:W40}}>
{hoveredPoint ? `on ${displayedLabel}` : `in ${displayedLabel}`}
</span>
</div> </div>
<div className="flex gap-1"> <div className="flex gap-1">
{RANGE_LABELS.map(r => ( {RANGE_LABELS.map(r => (
@ -210,6 +240,13 @@ function LineChart({ days, range, onRangeChange, lastPush }: {
<line x1={LP_L} y1={baseline} x2={LP_L+LCW} y2={baseline} style={{stroke:W12,strokeWidth:1}}/> <line x1={LP_L} y1={baseline} x2={LP_L+LCW} y2={baseline} style={{stroke:W12,strokeWidth:1}}/>
<path d={areaPath} style={{fill:"url(#la-fill)"}} clipPath="url(#la-clip)"/> <path d={areaPath} style={{fill:"url(#la-fill)"}} clipPath="url(#la-clip)"/>
<path d={linePath} style={{fill:"none",stroke:VIOLET,strokeWidth:2,strokeLinejoin:"round",strokeLinecap:"round"}} clipPath="url(#la-clip)"/> <path d={linePath} style={{fill:"none",stroke:VIOLET,strokeWidth:2,strokeLinejoin:"round",strokeLinecap:"round"}} clipPath="url(#la-clip)"/>
{activePoint && (
<>
<line x1={activePoint.x} y1={LP_T} x2={activePoint.x} y2={baseline} strokeDasharray="3 4" style={{stroke:"rgba(139,92,246,0.55)",strokeWidth:1}}/>
<circle cx={activePoint.x} cy={activePoint.y} r={7} style={{fill:"rgba(139,92,246,0.18)"}}/>
<circle cx={activePoint.x} cy={activePoint.y} r={4} style={{fill:VIOLET}}/>
</>
)}
{lastPt && ( {lastPt && (
<> <>
<circle cx={lastPt.x} cy={lastPt.y} r={5.5} style={{fill:"rgba(10,10,30,0.9)"}}/> <circle cx={lastPt.x} cy={lastPt.y} r={5.5} style={{fill:"rgba(10,10,30,0.9)"}}/>
@ -220,7 +257,19 @@ function LineChart({ days, range, onRangeChange, lastPush }: {
<text key={i} x={x} y={LVH-6} fontSize={10} textAnchor="middle" style={{fill:W70}}>{label}</text> <text key={i} x={x} y={LVH-6} fontSize={10} textAnchor="middle" style={{fill:W70}}>{label}</text>
))} ))}
{points.map((p,i) => ( {points.map((p,i) => (
<rect key={i} x={p.x - LCW/Math.max(points.length,1)/2} y={LP_T} width={LCW/Math.max(points.length,1)} height={LCH} style={{fill:"transparent"}}> <rect
key={i}
x={p.x - LCW/Math.max(points.length,1)/2}
y={LP_T}
width={LCW/Math.max(points.length,1)}
height={LCH}
tabIndex={0}
onMouseEnter={() => setHoveredPoint({ date: p.date, count: p.count, label: p.label })}
onFocus={() => setHoveredPoint({ date: p.date, count: p.count, label: p.label })}
onMouseLeave={() => setHoveredPoint(null)}
onBlur={() => setHoveredPoint(null)}
style={{fill:"transparent", outline:"none"}}
>
<title>{p.date}{p.count > 0 ? `: ${p.count} event${p.count!==1?"s":""}` : ": no activity"}</title> <title>{p.date}{p.count > 0 ? `: ${p.count} event${p.count!==1?"s":""}` : ": no activity"}</title>
</rect> </rect>
))} ))}
@ -255,6 +304,7 @@ function HeatmapGrid({ days, range, onRangeChange }: {
range: RangeKey; range: RangeKey;
onRangeChange: (r: RangeKey) => void; onRangeChange: (r: RangeKey) => void;
}) { }) {
const [hoveredDay, setHoveredDay] = useState<ActivityDatum | null>(null);
const heatmap = 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);
@ -276,6 +326,7 @@ function HeatmapGrid({ days, range, onRangeChange }: {
}); });
const totalEvents = daily.reduce((s, d) => s + d.count, 0); const totalEvents = daily.reduce((s, d) => s + d.count, 0);
const maxCount = Math.max(...daily.map(d => d.count), 0);
if (numDays <= 30) { if (numDays <= 30) {
const gap = numDays <= 7 ? 12 : 4; const gap = numDays <= 7 ? 12 : 4;
@ -293,6 +344,7 @@ function HeatmapGrid({ days, range, onRangeChange }: {
mode: "strip" as const, mode: "strip" as const,
daily, daily,
totalEvents, totalEvents,
maxCount,
cell, cell,
gap, gap,
x, x,
@ -309,7 +361,7 @@ function HeatmapGrid({ days, range, onRangeChange }: {
Math.ceil(((calendarEnd.getTime() - calendarStart.getTime()) / 86400000 + 1) / 7), Math.ceil(((calendarEnd.getTime() - calendarStart.getTime()) / 86400000 + 1) / 7),
); );
type CalendarCell = { date:string; count:number; outsideRange:boolean }; type CalendarCell = { date:string; count:number; label:string; outsideRange:boolean };
const builtWeeks: CalendarCell[][] = []; const builtWeeks: CalendarCell[][] = [];
const monthLabelList: {weekIdx:number; label:string}[] = []; const monthLabelList: {weekIdx:number; label:string}[] = [];
let lastMonth = -1; let lastMonth = -1;
@ -327,6 +379,7 @@ function HeatmapGrid({ days, range, onRangeChange }: {
week.push({ week.push({
date: dateStr, date: dateStr,
count: lookup.get(dateStr) ?? 0, count: lookup.get(dateStr) ?? 0,
label: fmtActivityDate(dateStr),
outsideRange: cur < rangeStart || cur > today, outsideRange: cur < rangeStart || cur > today,
}); });
cur.setDate(cur.getDate()+1); cur.setDate(cur.getDate()+1);
@ -351,6 +404,7 @@ function HeatmapGrid({ days, range, onRangeChange }: {
weeks: builtWeeks, weeks: builtWeeks,
monthLabels: monthLabelList, monthLabels: monthLabelList,
totalEvents, totalEvents,
maxCount,
cell, cell,
gap, gap,
stride, stride,
@ -358,6 +412,8 @@ function HeatmapGrid({ days, range, onRangeChange }: {
y, y,
}; };
}, [days, range]); }, [days, range]);
const displayedCount = hoveredDay?.count ?? heatmap.totalEvents;
const displayedLabel = hoveredDay?.label ?? RANGE_SUMMARY[range];
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
@ -366,6 +422,13 @@ function HeatmapGrid({ days, range, onRangeChange }: {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<LayoutGrid className="h-4 w-4" style={{ color: "rgba(16,185,129,1)" }} /> <LayoutGrid className="h-4 w-4" style={{ color: "rgba(16,185,129,1)" }} />
<span className="text-sm font-semibold text-strong">Activity Heatmap</span> <span className="text-sm font-semibold text-strong">Activity Heatmap</span>
<span className="ml-1 rounded px-1.5 py-0.5 text-[11px] font-semibold tabular-nums"
style={{background:"rgba(16,185,129,0.14)", color:GREEN}}>
{displayedCount.toLocaleString()}
</span>
<span className="hidden text-xs sm:inline" style={{color:W40}}>
{hoveredDay ? `on ${displayedLabel}` : `in ${displayedLabel}`}
</span>
</div> </div>
<div className="flex gap-1"> <div className="flex gap-1">
{RANGE_LABELS.map(r => ( {RANGE_LABELS.map(r => (
@ -392,7 +455,17 @@ function HeatmapGrid({ days, range, onRangeChange }: {
width={heatmap.cell} width={heatmap.cell}
height={heatmap.cell} height={heatmap.cell}
rx={4} rx={4}
style={{fill: HMAP_FILL[toLevel(day.count)]}} tabIndex={0}
onMouseEnter={() => setHoveredDay(day)}
onFocus={() => setHoveredDay(day)}
onMouseLeave={() => setHoveredDay(null)}
onBlur={() => setHoveredDay(null)}
style={{
fill: heatFill(day.count, heatmap.maxCount),
stroke: hoveredDay?.date === day.date ? GREEN : "transparent",
strokeWidth: 2,
outline: "none",
}}
> >
<title>{day.date}{day.count>0?`: ${day.count} event${day.count!==1?"s":""}` : ": no activity"}</title> <title>{day.date}{day.count>0?`: ${day.count} event${day.count!==1?"s":""}` : ": no activity"}</title>
</rect> </rect>
@ -423,7 +496,17 @@ function HeatmapGrid({ days, range, onRangeChange }: {
<rect key={cell.date} <rect key={cell.date}
x={heatmap.x+wi*heatmap.stride} y={heatmap.y+di*heatmap.stride} x={heatmap.x+wi*heatmap.stride} y={heatmap.y+di*heatmap.stride}
width={heatmap.cell} height={heatmap.cell} rx={2} width={heatmap.cell} height={heatmap.cell} rx={2}
style={{fill: HMAP_FILL[toLevel(cell.count)]}}> tabIndex={0}
onMouseEnter={() => setHoveredDay({ date: cell.date, count: cell.count, label: cell.label })}
onFocus={() => setHoveredDay({ date: cell.date, count: cell.count, label: cell.label })}
onMouseLeave={() => setHoveredDay(null)}
onBlur={() => setHoveredDay(null)}
style={{
fill: heatFill(cell.count, heatmap.maxCount),
stroke: hoveredDay?.date === cell.date ? GREEN : "transparent",
strokeWidth: 1.5,
outline: "none",
}}>
<title>{cell.date}{cell.count>0?`: ${cell.count} event${cell.count!==1?"s":""}` : ": no activity"}</title> <title>{cell.date}{cell.count>0?`: ${cell.count} event${cell.count!==1?"s":""}` : ": no activity"}</title>
</rect> </rect>
)) ))
@ -440,10 +523,12 @@ function HeatmapGrid({ days, range, onRangeChange }: {
{/* Contributions summary */} {/* Contributions summary */}
<p className="text-center"> <p className="text-center">
<span className="text-2xl font-bold tabular-nums" style={{color:GREEN}}> <span className="text-2xl font-bold tabular-nums" style={{color:GREEN}}>
{heatmap.totalEvents.toLocaleString()} {displayedCount.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]} {hoveredDay
? `contributions on ${displayedLabel}`
: `contributions across all tracked repositories in the last ${RANGE_SUMMARY[range]}`}
</span> </span>
</p> </p>
@ -472,7 +557,7 @@ export function ForgejoHeatmap({
<div className="h-4 w-28 animate-pulse rounded" style={{background:"rgba(255,255,255,0.08)"}}/> <div className="h-4 w-28 animate-pulse rounded" style={{background:"rgba(255,255,255,0.08)"}}/>
<div className="h-4 w-20 animate-pulse rounded" style={{background:"rgba(255,255,255,0.08)"}}/> <div className="h-4 w-20 animate-pulse rounded" style={{background:"rgba(255,255,255,0.08)"}}/>
</div> </div>
<div className="animate-pulse rounded" style={{height:160,background:"rgba(255,255,255,0.05)"}}/> <div className="animate-pulse rounded" style={{height:LVH,background:"rgba(255,255,255,0.05)"}}/>
</div> </div>
))} ))}
</div> </div>