feat(heatmap): enhance heatmap functionality with improved activity date formatting and dynamic hover effects
This commit is contained in:
parent
d8a2619313
commit
f0a5c430f1
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue