feat(ui): color contribution graph circles by dominant language
Replace fixed green palette with per-period dominant language colors. Each circle's hue reflects the language with the most commits for that day (last-year graph) or month (all-time graph), with opacity scaled by volume quartile. Language data comes from the existing language daily counts endpoint. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@ import { useMemo } from 'react';
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import { fetchDailyCounts, fetchProjects, fetchSources } from '../api/client';
|
import { fetchDailyCounts, fetchLanguageDailyCounts, fetchProjects, fetchSources } from '../api/client';
|
||||||
|
|
||||||
const CELL_SIZE = 12;
|
const CELL_SIZE = 12;
|
||||||
const GAP = 3;
|
const GAP = 3;
|
||||||
@@ -14,13 +14,8 @@ const TOP_LABEL_HEIGHT = 16;
|
|||||||
const DAY_LABELS = ['', 'mon', '', 'wed', '', 'fri', ''];
|
const DAY_LABELS = ['', 'mon', '', 'wed', '', 'fri', ''];
|
||||||
const MONTH_LABELS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
|
const MONTH_LABELS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
|
||||||
|
|
||||||
const COLORS = [
|
const EMPTY_COLOR = 'rgba(255,255,255,0.05)';
|
||||||
'rgba(255,255,255,0.05)',
|
const FALLBACK_COLOR = '#39d353';
|
||||||
'#0e4429',
|
|
||||||
'#006d32',
|
|
||||||
'#26a641',
|
|
||||||
'#39d353',
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Daily contribution graph — last 1 year, one circle per day. */
|
/** Daily contribution graph — last 1 year, one circle per day. */
|
||||||
export function ContributionGraph() {
|
export function ContributionGraph() {
|
||||||
@@ -37,6 +32,12 @@ export function ContributionGraph() {
|
|||||||
staleTime: 5 * 60_000,
|
staleTime: 5 * 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const langQ = useQuery({
|
||||||
|
queryKey: ['language-daily', fromStr, toStr],
|
||||||
|
queryFn: () => fetchLanguageDailyCounts(fromStr, toStr),
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
const projectsQ = useQuery({
|
const projectsQ = useQuery({
|
||||||
queryKey: ['projects'],
|
queryKey: ['projects'],
|
||||||
queryFn: fetchProjects,
|
queryFn: fetchProjects,
|
||||||
@@ -56,6 +57,11 @@ export function ContributionGraph() {
|
|||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// Build map of date → dominant language color
|
||||||
|
const dayColorMap = useMemo(() => {
|
||||||
|
return buildDominantColorMap(langQ.data ?? []);
|
||||||
|
}, [langQ.data]);
|
||||||
|
|
||||||
const { weeks, monthMarkers, thresholds, totalCount } = useMemo(() => {
|
const { weeks, monthMarkers, thresholds, totalCount } = useMemo(() => {
|
||||||
const counts = dailyQ.data ?? [];
|
const counts = dailyQ.data ?? [];
|
||||||
const countMap = new Map(counts.map((d) => [d.date, d.count]));
|
const countMap = new Map(counts.map((d) => [d.date, d.count]));
|
||||||
@@ -142,7 +148,8 @@ export function ContributionGraph() {
|
|||||||
cx={LEFT_LABEL_WIDTH + col * (CELL_SIZE + GAP) + RADIUS}
|
cx={LEFT_LABEL_WIDTH + col * (CELL_SIZE + GAP) + RADIUS}
|
||||||
cy={TOP_LABEL_HEIGHT + row * (CELL_SIZE + GAP) + RADIUS}
|
cy={TOP_LABEL_HEIGHT + row * (CELL_SIZE + GAP) + RADIUS}
|
||||||
r={RADIUS - 1}
|
r={RADIUS - 1}
|
||||||
fill={colorFor(count, thresholds)}
|
fill={count === 0 ? EMPTY_COLOR : (dayColorMap.get(date) ?? FALLBACK_COLOR)}
|
||||||
|
opacity={count === 0 ? 1 : opacityFor(count, thresholds)}
|
||||||
className="graph-cell"
|
className="graph-cell"
|
||||||
onClick={() => navigate(`/activity/${date}`)}
|
onClick={() => navigate(`/activity/${date}`)}
|
||||||
>
|
>
|
||||||
@@ -192,8 +199,42 @@ export function AllTimeGraph() {
|
|||||||
staleTime: 10 * 60_000,
|
staleTime: 10 * 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const langQ = useQuery({
|
||||||
|
queryKey: ['language-daily-alltime', fromStr, toStr],
|
||||||
|
queryFn: () => fetchLanguageDailyCounts(fromStr, toStr),
|
||||||
|
enabled: !!earliest,
|
||||||
|
staleTime: 10 * 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// Aggregate daily language data to month level: pick the language with most commits
|
||||||
|
const monthColorMap = useMemo(() => {
|
||||||
|
const entries = langQ.data ?? [];
|
||||||
|
if (entries.length === 0) return new Map<string, string>();
|
||||||
|
const map = new Map<string, Map<string, { commits: number; color: string }>>();
|
||||||
|
for (const e of entries) {
|
||||||
|
const key = e.date.slice(0, 7); // YYYY-MM
|
||||||
|
if (!map.has(key)) map.set(key, new Map());
|
||||||
|
const langMap = map.get(key)!;
|
||||||
|
const cur = langMap.get(e.language);
|
||||||
|
if (cur) {
|
||||||
|
cur.commits += e.commits;
|
||||||
|
} else {
|
||||||
|
langMap.set(e.language, { commits: e.commits, color: e.color ?? FALLBACK_COLOR });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const result = new Map<string, string>();
|
||||||
|
for (const [key, langMap] of map) {
|
||||||
|
let best = { commits: 0, color: FALLBACK_COLOR };
|
||||||
|
for (const v of langMap.values()) {
|
||||||
|
if (v.commits > best.commits) best = v;
|
||||||
|
}
|
||||||
|
result.set(key, best.color);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [langQ.data]);
|
||||||
|
|
||||||
const { years, monthGrid, thresholds, totalCount } = useMemo(() => {
|
const { years, monthGrid, thresholds, totalCount } = useMemo(() => {
|
||||||
const counts = dailyQ.data ?? [];
|
const counts = dailyQ.data ?? [];
|
||||||
if (counts.length === 0) return { years: [], monthGrid: [], thresholds: [1, 2, 3], totalCount: 0 };
|
if (counts.length === 0) return { years: [], monthGrid: [], thresholds: [1, 2, 3], totalCount: 0 };
|
||||||
@@ -206,15 +247,16 @@ export function AllTimeGraph() {
|
|||||||
for (let yr = startYear; yr <= endYear; yr++) years.push(yr);
|
for (let yr = startYear; yr <= endYear; yr++) years.push(yr);
|
||||||
|
|
||||||
// Build a 12 x years grid of monthly totals
|
// Build a 12 x years grid of monthly totals
|
||||||
const monthGrid: { year: number; month: number; count: number; monthStart: string; monthEnd: string }[][] = [];
|
const monthGrid: { year: number; month: number; count: number; monthStart: string; monthEnd: string; monthKey: string }[][] = [];
|
||||||
for (let m = 0; m < 12; m++) {
|
for (let m = 0; m < 12; m++) {
|
||||||
const row: typeof monthGrid[0] = [];
|
const row: typeof monthGrid[0] = [];
|
||||||
for (const yr of years) {
|
for (const yr of years) {
|
||||||
const monthStart = new Date(yr, m, 1);
|
const monthStart = new Date(yr, m, 1);
|
||||||
const monthEnd = new Date(yr, m + 1, 0); // last day of month
|
const monthEnd = new Date(yr, m + 1, 0); // last day of month
|
||||||
|
const monthKey = `${yr}-${String(m + 1).padStart(2, '0')}`;
|
||||||
// Don't include months entirely outside our data range
|
// Don't include months entirely outside our data range
|
||||||
if (monthStart > to || monthEnd < from) {
|
if (monthStart > to || monthEnd < from) {
|
||||||
row.push({ year: yr, month: m, count: 0, monthStart: fmt(monthStart), monthEnd: fmt(monthEnd) });
|
row.push({ year: yr, month: m, count: 0, monthStart: fmt(monthStart), monthEnd: fmt(monthEnd), monthKey });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let total = 0;
|
let total = 0;
|
||||||
@@ -223,7 +265,7 @@ export function AllTimeGraph() {
|
|||||||
total += countMap.get(fmt(cursor)) ?? 0;
|
total += countMap.get(fmt(cursor)) ?? 0;
|
||||||
cursor.setDate(cursor.getDate() + 1);
|
cursor.setDate(cursor.getDate() + 1);
|
||||||
}
|
}
|
||||||
row.push({ year: yr, month: m, count: total, monthStart: fmt(monthStart), monthEnd: fmt(monthEnd) });
|
row.push({ year: yr, month: m, count: total, monthStart: fmt(monthStart), monthEnd: fmt(monthEnd), monthKey });
|
||||||
}
|
}
|
||||||
monthGrid.push(row);
|
monthGrid.push(row);
|
||||||
}
|
}
|
||||||
@@ -281,13 +323,14 @@ export function AllTimeGraph() {
|
|||||||
))}
|
))}
|
||||||
{/* Monthly contribution circles */}
|
{/* Monthly contribution circles */}
|
||||||
{monthGrid.map((row, rowIdx) =>
|
{monthGrid.map((row, rowIdx) =>
|
||||||
row.map(({ year, count, monthStart, monthEnd }, colIdx) => (
|
row.map(({ year, count, monthStart, monthEnd, monthKey }, colIdx) => (
|
||||||
<circle
|
<circle
|
||||||
key={`${year}-${rowIdx}`}
|
key={`${year}-${rowIdx}`}
|
||||||
cx={monthLabelWidth + colIdx * (CELL_SIZE + GAP) + RADIUS}
|
cx={monthLabelWidth + colIdx * (CELL_SIZE + GAP) + RADIUS}
|
||||||
cy={topLabelHeight + rowIdx * (CELL_SIZE + GAP) + RADIUS}
|
cy={topLabelHeight + rowIdx * (CELL_SIZE + GAP) + RADIUS}
|
||||||
r={RADIUS - 1}
|
r={RADIUS - 1}
|
||||||
fill={colorFor(count, thresholds)}
|
fill={count === 0 ? EMPTY_COLOR : (monthColorMap.get(monthKey) ?? FALLBACK_COLOR)}
|
||||||
|
opacity={count === 0 ? 1 : opacityFor(count, thresholds)}
|
||||||
className="graph-cell"
|
className="graph-cell"
|
||||||
onClick={() => navigate(`/activity/${monthStart}..${monthEnd}`)}
|
onClick={() => navigate(`/activity/${monthStart}..${monthEnd}`)}
|
||||||
>
|
>
|
||||||
@@ -305,12 +348,28 @@ function fmt(d: Date): string {
|
|||||||
return d.toISOString().slice(0, 10);
|
return d.toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
function colorFor(count: number, thresholds: number[]): string {
|
/** Build a map of date → dominant (highest commit count) language color. */
|
||||||
if (count === 0) return COLORS[0];
|
function buildDominantColorMap(entries: { date: string; language: string; color: string | null; commits: number }[]): Map<string, string> {
|
||||||
if (count <= thresholds[0]) return COLORS[1];
|
const map = new Map<string, { commits: number; color: string }>();
|
||||||
if (count <= thresholds[1]) return COLORS[2];
|
for (const e of entries) {
|
||||||
if (count <= thresholds[2]) return COLORS[3];
|
const cur = map.get(e.date);
|
||||||
return COLORS[4];
|
if (!cur || e.commits > cur.commits) {
|
||||||
|
map.set(e.date, { commits: e.commits, color: e.color ?? FALLBACK_COLOR });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const result = new Map<string, string>();
|
||||||
|
for (const [date, { color }] of map) {
|
||||||
|
result.set(date, color);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map count to opacity (0.3 – 1.0) based on quartile thresholds. */
|
||||||
|
function opacityFor(count: number, thresholds: number[]): number {
|
||||||
|
if (count <= thresholds[0]) return 0.35;
|
||||||
|
if (count <= thresholds[1]) return 0.55;
|
||||||
|
if (count <= thresholds[2]) return 0.75;
|
||||||
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeThresholds(sorted: number[]): number[] {
|
function computeThresholds(sorted: number[]): number[] {
|
||||||
|
|||||||
Reference in New Issue
Block a user