import { useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; import { fetchLanguageDailyCounts } from '../api/client'; const HEIGHT = 160; const LABEL_HEIGHT = 16; /** Language stream graph — stacked area showing language usage over time. */ export function LanguageStreamGraph() { const to = new Date(); const from = new Date(to); from.setFullYear(from.getFullYear() - 1); const fromStr = fmt(from); const toStr = fmt(to); const langQ = useQuery({ queryKey: ['language-daily', fromStr, toStr], queryFn: () => fetchLanguageDailyCounts(fromStr, toStr), staleTime: 5 * 60_000, }); const { languages, paths, legendItems } = useMemo(() => { const raw = langQ.data ?? []; if (raw.length === 0) return { weeks: [], languages: [], paths: [], legendItems: [] }; // Aggregate daily counts into weekly buckets const colorMap = new Map(); const weeklyMap = new Map>(); for (const d of raw) { if (d.color) colorMap.set(d.language, d.color); // Bucket to ISO week (Monday-based, keyed by Monday date) const dt = new Date(d.date + 'T00:00:00Z'); const day = dt.getUTCDay(); const monday = new Date(dt); monday.setUTCDate(monday.getUTCDate() - ((day + 6) % 7)); const weekKey = monday.toISOString().slice(0, 10); if (!weeklyMap.has(weekKey)) weeklyMap.set(weekKey, new Map()); const langs = weeklyMap.get(weekKey)!; langs.set(d.language, (langs.get(d.language) ?? 0) + d.commits); } const weeks = [...weeklyMap.keys()].sort(); // Rank languages by total commits to pick top N + "other" const totals = new Map(); for (const langs of weeklyMap.values()) { for (const [lang, count] of langs) { totals.set(lang, (totals.get(lang) ?? 0) + count); } } const ranked = [...totals.entries()].sort(([, a], [, b]) => b - a); const topN = 8; const topLangs = ranked.slice(0, topN).map(([l]) => l); const hasOther = ranked.length > topN; const languages = hasOther ? [...topLangs, 'Other'] : topLangs; // Build stacked data per week const stacked: number[][] = weeks.map((wk) => { const langs = weeklyMap.get(wk)!; const values = topLangs.map((l) => langs.get(l) ?? 0); if (hasOther) { let other = 0; for (const [l, c] of langs) { if (!topLangs.includes(l)) other += c; } values.push(other); } return values; }); // Compute stream layout (centered baseline) const maxTotal = Math.max(...stacked.map((row) => row.reduce((a, b) => a + b, 0)), 1); const chartHeight = HEIGHT - LABEL_HEIGHT; // For each week, compute y0 (centered) then stack upward const layerCount = languages.length; const y0s: number[][] = []; const y1s: number[][] = []; for (let w = 0; w < weeks.length; w++) { const total = stacked[w].reduce((a, b) => a + b, 0); const scaledTotal = (total / maxTotal) * chartHeight; let baseline = (chartHeight - scaledTotal) / 2 + LABEL_HEIGHT; const wy0: number[] = []; const wy1: number[] = []; for (let l = 0; l < layerCount; l++) { const h = (stacked[w][l] / maxTotal) * chartHeight; wy0.push(baseline); baseline += h; wy1.push(baseline); } y0s.push(wy0); y1s.push(wy1); } // Build SVG paths for each language layer using smooth curves const xFor = (w: number) => weeks.length > 1 ? (w / (weeks.length - 1)) * 100 : 50; const paths = languages.map((_, l) => { if (weeks.length === 0) return ''; const topPts = weeks.map((_, w) => [xFor(w), y0s[w][l]] as [number, number]); const bottomPts = weeks .map((_, w) => [xFor(w), y1s[w][l]] as [number, number]) .reverse(); return `M${topPts[0][0]},${topPts[0][1]} ${smoothLine(topPts)} L${bottomPts[0][0]},${bottomPts[0][1]} ${smoothLine(bottomPts)} Z`; }); // Default colors for "Other" and fallback const FALLBACK_COLORS = [ '#e34c26', '#563d7c', '#3178c6', '#dea584', '#f1e05a', '#89e051', '#00ADD8', '#438eff', ]; const legendItems = languages.map((lang, i) => ({ language: lang, color: lang === 'Other' ? 'rgba(255,255,255,0.2)' : colorMap.get(lang) ?? FALLBACK_COLORS[i % FALLBACK_COLORS.length], total: ranked[i]?.[1] ?? 0, })); return { weeks, languages, paths, legendItems }; }, [langQ.data]); if (langQ.isLoading) return

loading language graph...

; if (langQ.isError || languages.length === 0) return null; return (

languages by commit activity

{paths.map((d, i) => ( {legendItems[i].language} ))}
{legendItems.map(({ language, color }) => ( {language} ))}
); } function fmt(d: Date): string { return d.toISOString().slice(0, 10); } /** Convert a series of points into smooth cubic bezier curve commands. * Uses Catmull-Rom to Bezier conversion with tension 0.5. */ function smoothLine(pts: [number, number][]): string { if (pts.length < 2) return ''; if (pts.length === 2) return `L${pts[1][0]},${pts[1][1]}`; const commands: string[] = []; for (let i = 1; i < pts.length; i++) { const p0 = pts[Math.max(i - 2, 0)]; const p1 = pts[i - 1]; const p2 = pts[i]; const p3 = pts[Math.min(i + 1, pts.length - 1)]; const t = 0.5; const cp1x = p1[0] + (p2[0] - p0[0]) * t / 3; const cp1y = p1[1] + (p2[1] - p0[1]) * t / 3; const cp2x = p2[0] - (p3[0] - p1[0]) * t / 3; const cp2y = p2[1] - (p3[1] - p1[1]) * t / 3; commands.push(`C${cp1x},${cp1y} ${cp2x},${cp2y} ${p2[0]},${p2[1]}`); } return commands.join(' '); }