feat(ui): smooth language stream graph with Catmull-Rom splines

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>
This commit is contained in:
2026-05-06 07:02:50 +03:00
parent 14643273c0
commit 6f30a61184

View File

@@ -99,22 +99,17 @@ export function LanguageStreamGraph() {
y1s.push(wy1); y1s.push(wy1);
} }
// Build SVG paths for each language layer // 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) => { const paths = languages.map((_, l) => {
if (weeks.length === 0) return ''; if (weeks.length === 0) return '';
// Top edge left-to-right const topPts = weeks.map((_, w) => [xFor(w), y0s[w][l]] as [number, number]);
const top = weeks.map((_, w) => { const bottomPts = weeks
const x = weeks.length > 1 ? (w / (weeks.length - 1)) * 100 : 50; .map((_, w) => [xFor(w), y1s[w][l]] as [number, number])
return `${x},${y0s[w][l]}`;
});
// Bottom edge right-to-left
const bottom = weeks
.map((_, w) => {
const x = weeks.length > 1 ? (w / (weeks.length - 1)) * 100 : 50;
return `${x},${y1s[w][l]}`;
})
.reverse(); .reverse();
return `M${top.join(' L')} L${bottom.join(' L')} Z`; 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 // Default colors for "Other" and fallback
@@ -176,3 +171,28 @@ export function LanguageStreamGraph() {
function fmt(d: Date): string { function fmt(d: Date): string {
return d.toISOString().slice(0, 10); 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(' ');
}