Replace straight line segments with cubic bezier curves using Catmull-Rom to bezier conversion (tension 0.5) for a smoother stream graph visualization. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
199 lines
6.6 KiB
TypeScript
199 lines
6.6 KiB
TypeScript
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<string, string>();
|
|
const weeklyMap = new Map<string, Map<string, number>>();
|
|
|
|
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<string, number>();
|
|
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 <p style={{ fontSize: '0.8rem' }}>loading language graph...</p>;
|
|
if (langQ.isError || languages.length === 0) return null;
|
|
|
|
return (
|
|
<div className="contribution-graph mb-4">
|
|
<p style={{ fontSize: '0.8rem', opacity: 0.6 }}>languages by commit activity</p>
|
|
<svg viewBox={`0 0 100 ${HEIGHT}`} width="100%" preserveAspectRatio="none" className="d-block" style={{ height: `${HEIGHT}px` }}>
|
|
{paths.map((d, i) => (
|
|
<path
|
|
key={legendItems[i].language}
|
|
d={d}
|
|
fill={legendItems[i].color}
|
|
opacity={0.85}
|
|
>
|
|
<title>{legendItems[i].language}</title>
|
|
</path>
|
|
))}
|
|
</svg>
|
|
<div className="d-flex flex-wrap gap-2 mt-1" style={{ fontSize: '0.75rem' }}>
|
|
{legendItems.map(({ language, color }) => (
|
|
<span key={language} className="d-flex align-items-center gap-1">
|
|
<span
|
|
style={{
|
|
width: 10,
|
|
height: 10,
|
|
borderRadius: '50%',
|
|
backgroundColor: color,
|
|
display: 'inline-block',
|
|
}}
|
|
/>
|
|
{language}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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(' ');
|
|
}
|