@urmzd/github-insights 2.1.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.githooks/commit-msg +4 -0
- package/.githooks/pre-commit +4 -0
- package/AGENTS.md +32 -19
- package/CHANGELOG.md +45 -0
- package/CONTRIBUTING.md +18 -19
- package/README.md +21 -24
- package/action.yml +1 -1
- package/assets/insights/index.svg +45 -4
- package/assets/insights/metrics-constellation.svg +55 -0
- package/assets/insights/metrics-impact.svg +55 -0
- package/assets/insights/metrics-rhythm.svg +55 -0
- package/assets/insights/metrics-velocity.svg +55 -0
- package/examples/classic/README.md +36 -2
- package/examples/classic/index.svg +45 -4
- package/examples/classic/metrics-constellation.svg +55 -0
- package/examples/classic/metrics-impact.svg +55 -0
- package/examples/classic/metrics-rhythm.svg +55 -0
- package/examples/classic/metrics-velocity.svg +55 -0
- package/examples/ecosystem/README.md +39 -28
- package/examples/ecosystem/index.svg +45 -4
- package/examples/ecosystem/metrics-constellation.svg +55 -0
- package/examples/ecosystem/metrics-impact.svg +55 -0
- package/examples/ecosystem/metrics-rhythm.svg +55 -0
- package/examples/ecosystem/metrics-velocity.svg +55 -0
- package/examples/minimal/README.md +36 -2
- package/examples/minimal/index.svg +45 -4
- package/examples/minimal/metrics-constellation.svg +55 -0
- package/examples/minimal/metrics-impact.svg +55 -0
- package/examples/minimal/metrics-rhythm.svg +55 -0
- package/examples/minimal/metrics-velocity.svg +55 -0
- package/examples/modern/README.md +62 -50
- package/examples/modern/index.svg +45 -4
- package/examples/modern/metrics-constellation.svg +55 -0
- package/examples/modern/metrics-impact.svg +55 -0
- package/examples/modern/metrics-rhythm.svg +55 -0
- package/examples/modern/metrics-velocity.svg +55 -0
- package/llms.txt +4 -4
- package/package.json +1 -1
- package/skills/github-insights/SKILL.md +35 -81
- package/sr.yaml +9 -0
- package/src/api.ts +2 -140
- package/src/components/contribution-rhythm.tsx +152 -0
- package/src/components/full-svg.test.tsx +4 -1
- package/src/components/full-svg.tsx +14 -7
- package/src/components/impact-trail.tsx +90 -0
- package/src/components/language-velocity.tsx +181 -0
- package/src/components/project-constellation.tsx +97 -0
- package/src/components/section.test.tsx +5 -3
- package/src/components/section.tsx +5 -13
- package/src/components/style-defs.tsx +44 -3
- package/src/index.ts +28 -47
- package/src/metrics.test.ts +50 -57
- package/src/metrics.ts +277 -95
- package/src/readme.test.ts +2 -4
- package/src/templates.test.ts +19 -16
- package/src/templates.ts +30 -16
- package/src/theme.ts +11 -1
- package/src/types.ts +28 -7
- package/assets/insights/metrics-calendar.svg +0 -14
- package/assets/insights/metrics-complexity.svg +0 -14
- package/assets/insights/metrics-contributions.svg +0 -14
- package/assets/insights/metrics-expertise.svg +0 -14
- package/assets/insights/metrics-languages.svg +0 -14
- package/assets/insights/metrics-pulse.svg +0 -14
- package/examples/classic/metrics-calendar.svg +0 -14
- package/examples/classic/metrics-complexity.svg +0 -14
- package/examples/classic/metrics-contributions.svg +0 -14
- package/examples/classic/metrics-expertise.svg +0 -14
- package/examples/classic/metrics-languages.svg +0 -14
- package/examples/classic/metrics-pulse.svg +0 -14
- package/examples/ecosystem/metrics-calendar.svg +0 -14
- package/examples/ecosystem/metrics-complexity.svg +0 -14
- package/examples/ecosystem/metrics-contributions.svg +0 -14
- package/examples/ecosystem/metrics-expertise.svg +0 -14
- package/examples/ecosystem/metrics-languages.svg +0 -14
- package/examples/ecosystem/metrics-pulse.svg +0 -14
- package/examples/minimal/metrics-calendar.svg +0 -14
- package/examples/minimal/metrics-complexity.svg +0 -14
- package/examples/minimal/metrics-contributions.svg +0 -14
- package/examples/minimal/metrics-expertise.svg +0 -14
- package/examples/minimal/metrics-languages.svg +0 -14
- package/examples/minimal/metrics-pulse.svg +0 -14
- package/examples/modern/metrics-calendar.svg +0 -14
- package/examples/modern/metrics-complexity.svg +0 -14
- package/examples/modern/metrics-contributions.svg +0 -14
- package/examples/modern/metrics-expertise.svg +0 -14
- package/examples/modern/metrics-languages.svg +0 -14
- package/examples/modern/metrics-pulse.svg +0 -14
- package/src/components/bar-chart.test.tsx +0 -38
- package/src/components/bar-chart.tsx +0 -54
- package/src/components/contribution-calendar.test.tsx +0 -44
- package/src/components/contribution-calendar.tsx +0 -94
- package/src/components/contribution-cards.test.tsx +0 -36
- package/src/components/contribution-cards.tsx +0 -58
- package/src/components/donut-chart.test.tsx +0 -36
- package/src/components/donut-chart.tsx +0 -102
- package/src/components/project-cards.test.tsx +0 -46
- package/src/components/project-cards.tsx +0 -66
- package/src/components/stat-cards.test.tsx +0 -32
- package/src/components/stat-cards.tsx +0 -57
- package/src/components/tech-highlights.test.tsx +0 -63
- package/src/components/tech-highlights.tsx +0 -109
- package/teasr.toml +0 -14
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { Fragment, h } from "../jsx-factory.js";
|
|
2
|
+
import { escapeXml } from "../svg-utils.js";
|
|
3
|
+
import { BAR_COLORS, LAYOUT, THEME } from "../theme.js";
|
|
4
|
+
import type { ContributionRhythm, RenderResult } from "../types.js";
|
|
5
|
+
|
|
6
|
+
const DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
7
|
+
|
|
8
|
+
export function renderContributionRhythm(
|
|
9
|
+
rhythm: ContributionRhythm,
|
|
10
|
+
y: number,
|
|
11
|
+
): RenderResult {
|
|
12
|
+
const { padX } = LAYOUT;
|
|
13
|
+
|
|
14
|
+
// Radar chart dimensions
|
|
15
|
+
const radarCx = padX + 120;
|
|
16
|
+
const radarCy = y + 120;
|
|
17
|
+
const radarR = 90;
|
|
18
|
+
const maxVal = Math.max(...rhythm.dayTotals, 1);
|
|
19
|
+
|
|
20
|
+
// Guide circles
|
|
21
|
+
const guides = [0.25, 0.5, 0.75, 1.0];
|
|
22
|
+
const guidesSvg = guides.map((pct) => (
|
|
23
|
+
<circle
|
|
24
|
+
cx={radarCx}
|
|
25
|
+
cy={radarCy}
|
|
26
|
+
r={radarR * pct}
|
|
27
|
+
fill="none"
|
|
28
|
+
stroke={THEME.border}
|
|
29
|
+
stroke-width="1"
|
|
30
|
+
stroke-opacity="0.4"
|
|
31
|
+
/>
|
|
32
|
+
));
|
|
33
|
+
|
|
34
|
+
// Spoke lines
|
|
35
|
+
const spokesSvg = DAY_NAMES.map((_, i) => {
|
|
36
|
+
const angle = (i * 2 * Math.PI) / 7 - Math.PI / 2;
|
|
37
|
+
const x2 = radarCx + radarR * Math.cos(angle);
|
|
38
|
+
const y2 = radarCy + radarR * Math.sin(angle);
|
|
39
|
+
return (
|
|
40
|
+
<line
|
|
41
|
+
x1={radarCx}
|
|
42
|
+
y1={radarCy}
|
|
43
|
+
x2={x2}
|
|
44
|
+
y2={y2}
|
|
45
|
+
stroke={THEME.border}
|
|
46
|
+
stroke-width="1"
|
|
47
|
+
stroke-opacity="0.3"
|
|
48
|
+
/>
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Day labels
|
|
53
|
+
const labelsSvg = DAY_NAMES.map((name, i) => {
|
|
54
|
+
const angle = (i * 2 * Math.PI) / 7 - Math.PI / 2;
|
|
55
|
+
const labelR = radarR + 16;
|
|
56
|
+
const lx = radarCx + labelR * Math.cos(angle);
|
|
57
|
+
const ly = radarCy + labelR * Math.sin(angle) + 4;
|
|
58
|
+
return (
|
|
59
|
+
<text x={lx} y={ly} className="t t-value" text-anchor="middle">
|
|
60
|
+
{escapeXml(name)}
|
|
61
|
+
</text>
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Data polygon
|
|
66
|
+
const points = rhythm.dayTotals
|
|
67
|
+
.map((val, i) => {
|
|
68
|
+
const angle = (i * 2 * Math.PI) / 7 - Math.PI / 2;
|
|
69
|
+
const r = (val / maxVal) * radarR;
|
|
70
|
+
const px = radarCx + r * Math.cos(angle);
|
|
71
|
+
const py = radarCy + r * Math.sin(angle);
|
|
72
|
+
return `${px},${py}`;
|
|
73
|
+
})
|
|
74
|
+
.join(" ");
|
|
75
|
+
|
|
76
|
+
// Stats section (right side)
|
|
77
|
+
const statsX = padX + 300;
|
|
78
|
+
const statsStartY = y + 30;
|
|
79
|
+
const statColors = [
|
|
80
|
+
BAR_COLORS[0],
|
|
81
|
+
BAR_COLORS[1],
|
|
82
|
+
BAR_COLORS[2],
|
|
83
|
+
BAR_COLORS[4],
|
|
84
|
+
BAR_COLORS[5],
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
const statsSvg = rhythm.stats.map((stat, i) => {
|
|
88
|
+
const sy = statsStartY + i * 42;
|
|
89
|
+
const color = statColors[i % statColors.length];
|
|
90
|
+
return (
|
|
91
|
+
<>
|
|
92
|
+
<text x={statsX} y={sy} className="t t-stat-label">
|
|
93
|
+
{escapeXml(stat.label)}
|
|
94
|
+
</text>
|
|
95
|
+
<text x={statsX} y={sy + 22} fill={color} className="t t-stat-value">
|
|
96
|
+
{escapeXml(stat.value)}
|
|
97
|
+
</text>
|
|
98
|
+
</>
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const height = 250;
|
|
103
|
+
|
|
104
|
+
const svg = (
|
|
105
|
+
<>
|
|
106
|
+
{/* Guide circles */}
|
|
107
|
+
{guidesSvg.join("")}
|
|
108
|
+
|
|
109
|
+
{/* Spokes */}
|
|
110
|
+
{spokesSvg.join("")}
|
|
111
|
+
|
|
112
|
+
{/* Data polygon */}
|
|
113
|
+
<polygon
|
|
114
|
+
points={points}
|
|
115
|
+
fill={BAR_COLORS[0]}
|
|
116
|
+
fill-opacity="0.2"
|
|
117
|
+
stroke={BAR_COLORS[0]}
|
|
118
|
+
stroke-width="2"
|
|
119
|
+
stroke-opacity="0.8"
|
|
120
|
+
className="fade-2"
|
|
121
|
+
style={`transform-origin: ${radarCx}px ${radarCy}px`}
|
|
122
|
+
/>
|
|
123
|
+
|
|
124
|
+
{/* Data points */}
|
|
125
|
+
{rhythm.dayTotals.map((val, i) => {
|
|
126
|
+
const angle = (i * 2 * Math.PI) / 7 - Math.PI / 2;
|
|
127
|
+
const r = (val / maxVal) * radarR;
|
|
128
|
+
const px = radarCx + r * Math.cos(angle);
|
|
129
|
+
const py = radarCy + r * Math.sin(angle);
|
|
130
|
+
return (
|
|
131
|
+
<circle
|
|
132
|
+
cx={px}
|
|
133
|
+
cy={py}
|
|
134
|
+
r="3"
|
|
135
|
+
fill={BAR_COLORS[0]}
|
|
136
|
+
className={`fade-${Math.min(i + 1, 6)}`}
|
|
137
|
+
/>
|
|
138
|
+
);
|
|
139
|
+
})}
|
|
140
|
+
|
|
141
|
+
{/* Day labels */}
|
|
142
|
+
{labelsSvg.join("")}
|
|
143
|
+
|
|
144
|
+
{/* Stats */}
|
|
145
|
+
{statsSvg.join("")}
|
|
146
|
+
</>
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
return { svg, height };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
void Fragment;
|
|
@@ -35,7 +35,10 @@ describe("generateFullSvg", () => {
|
|
|
35
35
|
filename: "b.svg",
|
|
36
36
|
title: "Section B",
|
|
37
37
|
subtitle: "Subtitle B",
|
|
38
|
-
|
|
38
|
+
renderBody: (y) => ({
|
|
39
|
+
svg: `<text y="${y}">Go</text>`,
|
|
40
|
+
height: 30,
|
|
41
|
+
}),
|
|
39
42
|
},
|
|
40
43
|
];
|
|
41
44
|
const result = generateFullSvg(sections);
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { Fragment, h } from "../jsx-factory.js";
|
|
2
2
|
import { LAYOUT, THEME } from "../theme.js";
|
|
3
3
|
import type { SectionDef } from "../types.js";
|
|
4
|
-
import { renderBarChart } from "./bar-chart.js";
|
|
5
4
|
import { renderSectionHeader } from "./section.js";
|
|
6
5
|
import { StyleDefs } from "./style-defs.js";
|
|
7
6
|
|
|
@@ -15,7 +14,13 @@ export function wrapSectionSvg(bodySvg: string, height: number): string {
|
|
|
15
14
|
viewBox={`0 0 ${width} ${height}`}
|
|
16
15
|
>
|
|
17
16
|
<StyleDefs />
|
|
18
|
-
<rect
|
|
17
|
+
<rect
|
|
18
|
+
width={width}
|
|
19
|
+
height={height}
|
|
20
|
+
rx="12"
|
|
21
|
+
className="bg-fill"
|
|
22
|
+
fill={THEME.bg}
|
|
23
|
+
/>
|
|
19
24
|
{bodySvg}
|
|
20
25
|
</svg>
|
|
21
26
|
);
|
|
@@ -35,10 +40,6 @@ export function generateFullSvg(sections: SectionDef[]): string {
|
|
|
35
40
|
const body = section.renderBody(y);
|
|
36
41
|
bodySvg += body.svg;
|
|
37
42
|
y += body.height + sectionGap;
|
|
38
|
-
} else if (section.items) {
|
|
39
|
-
const bars = renderBarChart(section.items, y, section.options || {});
|
|
40
|
-
bodySvg += bars.svg;
|
|
41
|
-
y += bars.height + sectionGap;
|
|
42
43
|
}
|
|
43
44
|
}
|
|
44
45
|
|
|
@@ -52,7 +53,13 @@ export function generateFullSvg(sections: SectionDef[]): string {
|
|
|
52
53
|
viewBox={`0 0 ${width} ${totalHeight}`}
|
|
53
54
|
>
|
|
54
55
|
<StyleDefs />
|
|
55
|
-
<rect
|
|
56
|
+
<rect
|
|
57
|
+
width={width}
|
|
58
|
+
height={totalHeight}
|
|
59
|
+
rx="12"
|
|
60
|
+
className="bg-fill"
|
|
61
|
+
fill={THEME.bg}
|
|
62
|
+
/>
|
|
56
63
|
{bodySvg}
|
|
57
64
|
</svg>
|
|
58
65
|
);
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { Fragment, h } from "../jsx-factory.js";
|
|
2
|
+
import { escapeXml, truncate } from "../svg-utils.js";
|
|
3
|
+
import { BAR_COLORS, LAYOUT, THEME } from "../theme.js";
|
|
4
|
+
import type { ExternalRepo, RenderResult } from "../types.js";
|
|
5
|
+
|
|
6
|
+
export function renderImpactTrail(
|
|
7
|
+
repos: ExternalRepo[],
|
|
8
|
+
y: number,
|
|
9
|
+
): RenderResult {
|
|
10
|
+
if (repos.length === 0) return { svg: "", height: 0 };
|
|
11
|
+
|
|
12
|
+
const { padX } = LAYOUT;
|
|
13
|
+
const rowHeight = 36;
|
|
14
|
+
const nameWidth = 280;
|
|
15
|
+
const barMaxWidth = 400;
|
|
16
|
+
const gap = 6;
|
|
17
|
+
|
|
18
|
+
// Sort by stars (impact proxy)
|
|
19
|
+
const sorted = [...repos].sort((a, b) => b.stargazerCount - a.stargazerCount);
|
|
20
|
+
const maxStars = Math.max(sorted[0]?.stargazerCount || 1, 1);
|
|
21
|
+
const maxLog = Math.log2(maxStars + 1);
|
|
22
|
+
|
|
23
|
+
let svg = "";
|
|
24
|
+
|
|
25
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
26
|
+
const repo = sorted[i];
|
|
27
|
+
const ry = y + i * (rowHeight + gap);
|
|
28
|
+
const color = BAR_COLORS[i % BAR_COLORS.length];
|
|
29
|
+
const delay = Math.min(i + 1, 6);
|
|
30
|
+
|
|
31
|
+
// Bar width proportional to log(stars)
|
|
32
|
+
const logStars = Math.log2(repo.stargazerCount + 1);
|
|
33
|
+
const barWidth = Math.max(4, (logStars / maxLog) * barMaxWidth);
|
|
34
|
+
|
|
35
|
+
// Language dot
|
|
36
|
+
const langName = repo.primaryLanguage?.name || "";
|
|
37
|
+
|
|
38
|
+
svg += (
|
|
39
|
+
<>
|
|
40
|
+
{/* Repo name */}
|
|
41
|
+
<text
|
|
42
|
+
x={padX}
|
|
43
|
+
y={ry + rowHeight / 2 + 4}
|
|
44
|
+
className={`t t-card-title fade-${delay}`}
|
|
45
|
+
>
|
|
46
|
+
{escapeXml(truncate(repo.nameWithOwner, 38))}
|
|
47
|
+
</text>
|
|
48
|
+
|
|
49
|
+
{/* Impact bar */}
|
|
50
|
+
<rect
|
|
51
|
+
x={padX + nameWidth}
|
|
52
|
+
y={ry + rowHeight / 2 - 6}
|
|
53
|
+
width={barWidth}
|
|
54
|
+
height="12"
|
|
55
|
+
rx="3"
|
|
56
|
+
fill={color}
|
|
57
|
+
fill-opacity="0.7"
|
|
58
|
+
className={`fade-${delay}`}
|
|
59
|
+
/>
|
|
60
|
+
|
|
61
|
+
{/* Star count */}
|
|
62
|
+
<text
|
|
63
|
+
x={padX + nameWidth + barMaxWidth + 16}
|
|
64
|
+
y={ry + rowHeight / 2 + 4}
|
|
65
|
+
className={`t t-value fade-${delay}`}
|
|
66
|
+
>
|
|
67
|
+
{`\u2605 ${repo.stargazerCount.toLocaleString()}`}
|
|
68
|
+
</text>
|
|
69
|
+
|
|
70
|
+
{/* Language label */}
|
|
71
|
+
{langName ? (
|
|
72
|
+
<text
|
|
73
|
+
x={padX + nameWidth + barMaxWidth + 80}
|
|
74
|
+
y={ry + rowHeight / 2 + 4}
|
|
75
|
+
className={`t t-value fade-${delay}`}
|
|
76
|
+
>
|
|
77
|
+
{escapeXml(langName)}
|
|
78
|
+
</text>
|
|
79
|
+
) : (
|
|
80
|
+
""
|
|
81
|
+
)}
|
|
82
|
+
</>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const totalHeight = sorted.length * (rowHeight + gap) - gap;
|
|
87
|
+
return { svg, height: totalHeight };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
void Fragment;
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { Fragment, h } from "../jsx-factory.js";
|
|
2
|
+
import { escapeXml } from "../svg-utils.js";
|
|
3
|
+
import { LAYOUT, THEME } from "../theme.js";
|
|
4
|
+
import type { MonthlyLanguageBucket, RenderResult } from "../types.js";
|
|
5
|
+
|
|
6
|
+
export function renderLanguageVelocity(
|
|
7
|
+
velocity: MonthlyLanguageBucket[],
|
|
8
|
+
y: number,
|
|
9
|
+
): RenderResult {
|
|
10
|
+
if (velocity.length === 0) return { svg: "", height: 0 };
|
|
11
|
+
|
|
12
|
+
const { padX } = LAYOUT;
|
|
13
|
+
const chartWidth = 760;
|
|
14
|
+
const chartHeight = 140;
|
|
15
|
+
const labelHeight = 20;
|
|
16
|
+
const totalHeight = chartHeight + labelHeight;
|
|
17
|
+
|
|
18
|
+
// Collect all unique languages across all months
|
|
19
|
+
const langSet = new Map<string, string>();
|
|
20
|
+
for (const bucket of velocity) {
|
|
21
|
+
for (const lang of bucket.languages) {
|
|
22
|
+
if (!langSet.has(lang.name)) {
|
|
23
|
+
langSet.set(lang.name, lang.color);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Get top languages by total commits
|
|
29
|
+
const langTotals = new Map<string, number>();
|
|
30
|
+
for (const bucket of velocity) {
|
|
31
|
+
for (const lang of bucket.languages) {
|
|
32
|
+
langTotals.set(
|
|
33
|
+
lang.name,
|
|
34
|
+
(langTotals.get(lang.name) || 0) + lang.commits,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const topLangs = [...langTotals.entries()]
|
|
39
|
+
.sort((a, b) => b[1] - a[1])
|
|
40
|
+
.slice(0, 8)
|
|
41
|
+
.map(([name]) => name);
|
|
42
|
+
|
|
43
|
+
// Compute max total per month for scaling
|
|
44
|
+
const monthTotals = velocity.map((bucket) =>
|
|
45
|
+
bucket.languages
|
|
46
|
+
.filter((l) => topLangs.includes(l.name))
|
|
47
|
+
.reduce((sum, l) => sum + l.commits, 0),
|
|
48
|
+
);
|
|
49
|
+
const maxTotal = Math.max(...monthTotals, 1);
|
|
50
|
+
|
|
51
|
+
// Build stacked area data
|
|
52
|
+
const stepX = chartWidth / Math.max(velocity.length - 1, 1);
|
|
53
|
+
const paths: { path: string; color: string; name: string }[] = [];
|
|
54
|
+
|
|
55
|
+
// For each language, build a path from bottom of its stack to top
|
|
56
|
+
for (let li = topLangs.length - 1; li >= 0; li--) {
|
|
57
|
+
const langName = topLangs[li];
|
|
58
|
+
const color = langSet.get(langName) || "#8b949e";
|
|
59
|
+
|
|
60
|
+
// Compute cumulative values for this language
|
|
61
|
+
const upperPoints: { x: number; y: number }[] = [];
|
|
62
|
+
const lowerPoints: { x: number; y: number }[] = [];
|
|
63
|
+
|
|
64
|
+
for (let mi = 0; mi < velocity.length; mi++) {
|
|
65
|
+
const bucket = velocity[mi];
|
|
66
|
+
const x = padX + mi * stepX;
|
|
67
|
+
|
|
68
|
+
// Sum commits for all languages below this one (for stacking)
|
|
69
|
+
let below = 0;
|
|
70
|
+
let current = 0;
|
|
71
|
+
for (let k = 0; k < topLangs.length; k++) {
|
|
72
|
+
const langCommits =
|
|
73
|
+
bucket.languages.find((l) => l.name === topLangs[k])?.commits || 0;
|
|
74
|
+
if (k < li) below += langCommits;
|
|
75
|
+
if (k === li) current = langCommits;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const bottomY = y + chartHeight - (below / maxTotal) * chartHeight;
|
|
79
|
+
const topY =
|
|
80
|
+
y + chartHeight - ((below + current) / maxTotal) * chartHeight;
|
|
81
|
+
|
|
82
|
+
lowerPoints.push({ x, y: bottomY });
|
|
83
|
+
upperPoints.push({ x, y: topY });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Build smooth path using the points
|
|
87
|
+
if (upperPoints.length < 2) continue;
|
|
88
|
+
|
|
89
|
+
let d = `M ${upperPoints[0].x},${upperPoints[0].y}`;
|
|
90
|
+
|
|
91
|
+
// Upper edge (left to right) with smooth curves
|
|
92
|
+
for (let i = 1; i < upperPoints.length; i++) {
|
|
93
|
+
const prev = upperPoints[i - 1];
|
|
94
|
+
const curr = upperPoints[i];
|
|
95
|
+
const cpx = (prev.x + curr.x) / 2;
|
|
96
|
+
d += ` C ${cpx},${prev.y} ${cpx},${curr.y} ${curr.x},${curr.y}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Lower edge (right to left) with smooth curves
|
|
100
|
+
d += ` L ${lowerPoints[lowerPoints.length - 1].x},${lowerPoints[lowerPoints.length - 1].y}`;
|
|
101
|
+
for (let i = lowerPoints.length - 2; i >= 0; i--) {
|
|
102
|
+
const prev = lowerPoints[i + 1];
|
|
103
|
+
const curr = lowerPoints[i];
|
|
104
|
+
const cpx = (prev.x + curr.x) / 2;
|
|
105
|
+
d += ` C ${cpx},${prev.y} ${cpx},${curr.y} ${curr.x},${curr.y}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
d += " Z";
|
|
109
|
+
paths.push({ path: d, color, name: langName });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Month labels
|
|
113
|
+
const monthLabels = velocity
|
|
114
|
+
.filter((_, i) => i % Math.max(1, Math.floor(velocity.length / 6)) === 0)
|
|
115
|
+
.map((bucket) => {
|
|
116
|
+
const originalIndex = velocity.indexOf(bucket);
|
|
117
|
+
const x = padX + originalIndex * stepX;
|
|
118
|
+
const monthName = new Date(`${bucket.month}-01`).toLocaleDateString(
|
|
119
|
+
"en",
|
|
120
|
+
{ month: "short" },
|
|
121
|
+
);
|
|
122
|
+
return { x, label: monthName };
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const svg = (
|
|
126
|
+
<>
|
|
127
|
+
{/* Streamgraph paths */}
|
|
128
|
+
{paths.map((p, i) => (
|
|
129
|
+
<path
|
|
130
|
+
d={p.path}
|
|
131
|
+
fill={p.color}
|
|
132
|
+
fill-opacity="0.75"
|
|
133
|
+
className={`fade-${Math.min(i + 1, 6)}`}
|
|
134
|
+
/>
|
|
135
|
+
))}
|
|
136
|
+
|
|
137
|
+
{/* Language legend (inline, below chart) */}
|
|
138
|
+
{(() => {
|
|
139
|
+
let legendX = padX;
|
|
140
|
+
return topLangs.map((name) => {
|
|
141
|
+
const color = langSet.get(name) || "#8b949e";
|
|
142
|
+
const x = legendX;
|
|
143
|
+
legendX += name.length * 7 + 28;
|
|
144
|
+
return (
|
|
145
|
+
<>
|
|
146
|
+
<rect
|
|
147
|
+
x={x}
|
|
148
|
+
y={y + chartHeight + 6}
|
|
149
|
+
width="8"
|
|
150
|
+
height="8"
|
|
151
|
+
rx="2"
|
|
152
|
+
fill={color}
|
|
153
|
+
opacity="0.85"
|
|
154
|
+
/>
|
|
155
|
+
<text x={x + 12} y={y + chartHeight + 14} className="t t-value">
|
|
156
|
+
{escapeXml(name)}
|
|
157
|
+
</text>
|
|
158
|
+
</>
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
})()}
|
|
162
|
+
|
|
163
|
+
{/* Month labels */}
|
|
164
|
+
{monthLabels.map((m) => (
|
|
165
|
+
<text
|
|
166
|
+
x={m.x}
|
|
167
|
+
y={y + chartHeight + 14}
|
|
168
|
+
className="t t-value"
|
|
169
|
+
text-anchor="start"
|
|
170
|
+
opacity="0"
|
|
171
|
+
>
|
|
172
|
+
{escapeXml(m.label)}
|
|
173
|
+
</text>
|
|
174
|
+
))}
|
|
175
|
+
</>
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
return { svg, height: totalHeight };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
void Fragment;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { Fragment, h } from "../jsx-factory.js";
|
|
2
|
+
import { escapeXml, truncate } from "../svg-utils.js";
|
|
3
|
+
import { LAYOUT, THEME } from "../theme.js";
|
|
4
|
+
import type { ConstellationNode, RenderResult } from "../types.js";
|
|
5
|
+
|
|
6
|
+
export function renderProjectConstellation(
|
|
7
|
+
nodes: ConstellationNode[],
|
|
8
|
+
y: number,
|
|
9
|
+
): RenderResult {
|
|
10
|
+
if (nodes.length === 0) return { svg: "", height: 0 };
|
|
11
|
+
|
|
12
|
+
const { padX } = LAYOUT;
|
|
13
|
+
const height = 380;
|
|
14
|
+
|
|
15
|
+
// Draw connection lines first (behind nodes)
|
|
16
|
+
const drawnConnections = new Set<string>();
|
|
17
|
+
const connectionsSvg = nodes.flatMap((node, i) =>
|
|
18
|
+
node.connections
|
|
19
|
+
.filter((j) => {
|
|
20
|
+
const key = [Math.min(i, j), Math.max(i, j)].join("-");
|
|
21
|
+
if (drawnConnections.has(key)) return false;
|
|
22
|
+
drawnConnections.add(key);
|
|
23
|
+
return true;
|
|
24
|
+
})
|
|
25
|
+
.map((j) => {
|
|
26
|
+
const other = nodes[j];
|
|
27
|
+
return (
|
|
28
|
+
<line
|
|
29
|
+
x1={padX + node.x}
|
|
30
|
+
y1={y + node.y}
|
|
31
|
+
x2={padX + other.x}
|
|
32
|
+
y2={y + other.y}
|
|
33
|
+
stroke={THEME.border}
|
|
34
|
+
stroke-width="1"
|
|
35
|
+
stroke-opacity="0.15"
|
|
36
|
+
stroke-dasharray="4 4"
|
|
37
|
+
/>
|
|
38
|
+
);
|
|
39
|
+
}),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
// Draw nodes
|
|
43
|
+
const nodesSvg = nodes.map((node, i) => {
|
|
44
|
+
const cx = padX + node.x;
|
|
45
|
+
const cy = y + node.y;
|
|
46
|
+
const delay = Math.min(i + 1, 6);
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<>
|
|
50
|
+
{/* Glow effect */}
|
|
51
|
+
<circle
|
|
52
|
+
cx={cx}
|
|
53
|
+
cy={cy}
|
|
54
|
+
r={node.radius + 4}
|
|
55
|
+
fill={node.color}
|
|
56
|
+
fill-opacity="0.08"
|
|
57
|
+
className={`fade-${delay}`}
|
|
58
|
+
/>
|
|
59
|
+
{/* Main circle */}
|
|
60
|
+
<circle
|
|
61
|
+
cx={cx}
|
|
62
|
+
cy={cy}
|
|
63
|
+
r={node.radius}
|
|
64
|
+
fill={node.color}
|
|
65
|
+
fill-opacity="0.7"
|
|
66
|
+
stroke={node.color}
|
|
67
|
+
stroke-width="1.5"
|
|
68
|
+
stroke-opacity="0.9"
|
|
69
|
+
className={`fade-${delay}`}
|
|
70
|
+
/>
|
|
71
|
+
{/* Label */}
|
|
72
|
+
<text
|
|
73
|
+
x={cx}
|
|
74
|
+
y={cy + node.radius + 14}
|
|
75
|
+
className={`t t-value fade-${delay}`}
|
|
76
|
+
text-anchor="middle"
|
|
77
|
+
>
|
|
78
|
+
{escapeXml(truncate(node.name, 18))}
|
|
79
|
+
</text>
|
|
80
|
+
</>
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const svg = (
|
|
85
|
+
<>
|
|
86
|
+
{/* Connection lines */}
|
|
87
|
+
{connectionsSvg.join("")}
|
|
88
|
+
|
|
89
|
+
{/* Nodes */}
|
|
90
|
+
{nodesSvg.join("")}
|
|
91
|
+
</>
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
return { svg, height };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
void Fragment;
|
|
@@ -61,9 +61,11 @@ describe("renderSection", () => {
|
|
|
61
61
|
expect(result.height).toBeGreaterThan(0);
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
-
it("returns { svg, height } with
|
|
65
|
-
const
|
|
66
|
-
|
|
64
|
+
it("returns { svg, height } with another render function", () => {
|
|
65
|
+
const result = renderSection("Tech", "Detected", (y) => ({
|
|
66
|
+
svg: `<text y="${y}">Go</text>`,
|
|
67
|
+
height: 30,
|
|
68
|
+
}));
|
|
67
69
|
expect(result.svg).toContain("Go");
|
|
68
70
|
});
|
|
69
71
|
});
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { Fragment, h } from "../jsx-factory.js";
|
|
2
2
|
import { escapeXml } from "../svg-utils.js";
|
|
3
3
|
import { LAYOUT, THEME } from "../theme.js";
|
|
4
|
-
import type {
|
|
5
|
-
import { renderBarChart } from "./bar-chart.js";
|
|
4
|
+
import type { RenderResult } from "../types.js";
|
|
6
5
|
|
|
7
6
|
export function renderSectionHeader(
|
|
8
7
|
title: string,
|
|
@@ -53,8 +52,7 @@ export function renderDivider(y: number): RenderResult {
|
|
|
53
52
|
export function renderSection(
|
|
54
53
|
title: string,
|
|
55
54
|
subtitle: string,
|
|
56
|
-
|
|
57
|
-
options: Record<string, unknown> = {},
|
|
55
|
+
renderBody: (y: number) => RenderResult,
|
|
58
56
|
): RenderResult {
|
|
59
57
|
let y = LAYOUT.padY;
|
|
60
58
|
let svg = "";
|
|
@@ -63,15 +61,9 @@ export function renderSection(
|
|
|
63
61
|
svg += header.svg;
|
|
64
62
|
y += header.height;
|
|
65
63
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
y += body.height + LAYOUT.padY;
|
|
70
|
-
} else {
|
|
71
|
-
const bars = renderBarChart(itemsOrRenderBody, y, options);
|
|
72
|
-
svg += bars.svg;
|
|
73
|
-
y += bars.height + LAYOUT.padY;
|
|
74
|
-
}
|
|
64
|
+
const body = renderBody(y);
|
|
65
|
+
svg += body.svg;
|
|
66
|
+
y += body.height + LAYOUT.padY;
|
|
75
67
|
|
|
76
68
|
return { svg, height: y };
|
|
77
69
|
}
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { Fragment, h } from "../jsx-factory.js";
|
|
2
|
-
import { FONT, THEME } from "../theme.js";
|
|
2
|
+
import { FONT, THEME, THEME_LIGHT } from "../theme.js";
|
|
3
3
|
|
|
4
4
|
export function StyleDefs(): string {
|
|
5
5
|
return (
|
|
6
6
|
<defs>
|
|
7
7
|
<style>
|
|
8
8
|
{`
|
|
9
|
-
.t { font-family: ${FONT}; }
|
|
10
|
-
.t-h { font-size:
|
|
9
|
+
.t { font-family: ${FONT}; font-variant-numeric: tabular-lining; }
|
|
10
|
+
.t-h { font-size: 14px; fill: ${THEME.text}; letter-spacing: 2px; font-weight: 600; }
|
|
11
11
|
.t-sub { font-size: 11px; fill: ${THEME.muted}; }
|
|
12
12
|
.t-label { font-size: 12px; fill: ${THEME.secondary}; }
|
|
13
13
|
.t-value { font-size: 11px; fill: ${THEME.muted}; }
|
|
@@ -18,6 +18,47 @@ export function StyleDefs(): string {
|
|
|
18
18
|
.t-card-detail { font-size: 11px; fill: ${THEME.secondary}; }
|
|
19
19
|
.t-pill { font-size: 11px; font-weight: 600; }
|
|
20
20
|
.t-bullet { font-size: 12px; fill: ${THEME.text}; }
|
|
21
|
+
.bg-fill { fill: ${THEME.bg}; }
|
|
22
|
+
.card-fill { fill: ${THEME.cardBg}; }
|
|
23
|
+
.border-stroke { stroke: ${THEME.border}; }
|
|
24
|
+
|
|
25
|
+
@media (prefers-color-scheme: light) {
|
|
26
|
+
.bg-fill { fill: ${THEME_LIGHT.bg}; }
|
|
27
|
+
.card-fill { fill: ${THEME_LIGHT.cardBg}; }
|
|
28
|
+
.border-stroke { stroke: ${THEME_LIGHT.border}; }
|
|
29
|
+
.t-h { fill: ${THEME_LIGHT.text}; }
|
|
30
|
+
.t-sub { fill: ${THEME_LIGHT.muted}; }
|
|
31
|
+
.t-label { fill: ${THEME_LIGHT.secondary}; }
|
|
32
|
+
.t-value { fill: ${THEME_LIGHT.muted}; }
|
|
33
|
+
.t-subhdr { fill: ${THEME_LIGHT.secondary}; }
|
|
34
|
+
.t-stat-label { fill: ${THEME_LIGHT.secondary}; }
|
|
35
|
+
.t-card-title { fill: ${THEME_LIGHT.link}; }
|
|
36
|
+
.t-card-detail { fill: ${THEME_LIGHT.secondary}; }
|
|
37
|
+
.t-bullet { fill: ${THEME_LIGHT.text}; }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
@keyframes fadeIn {
|
|
41
|
+
from { opacity: 0; transform: translateY(8px); }
|
|
42
|
+
to { opacity: 1; transform: translateY(0); }
|
|
43
|
+
}
|
|
44
|
+
@keyframes scaleIn {
|
|
45
|
+
from { transform: scale(0); opacity: 0; }
|
|
46
|
+
to { transform: scale(1); opacity: 1; }
|
|
47
|
+
}
|
|
48
|
+
@keyframes drawPath {
|
|
49
|
+
from { stroke-dashoffset: var(--path-length); }
|
|
50
|
+
to { stroke-dashoffset: 0; }
|
|
51
|
+
}
|
|
52
|
+
@keyframes radarReveal {
|
|
53
|
+
from { transform: scale(0); opacity: 0; }
|
|
54
|
+
to { transform: scale(1); opacity: 0.6; }
|
|
55
|
+
}
|
|
56
|
+
.fade-1 { animation: fadeIn 0.6s ease-out 0.1s both; }
|
|
57
|
+
.fade-2 { animation: fadeIn 0.6s ease-out 0.25s both; }
|
|
58
|
+
.fade-3 { animation: fadeIn 0.6s ease-out 0.4s both; }
|
|
59
|
+
.fade-4 { animation: fadeIn 0.6s ease-out 0.55s both; }
|
|
60
|
+
.fade-5 { animation: fadeIn 0.6s ease-out 0.7s both; }
|
|
61
|
+
.fade-6 { animation: fadeIn 0.6s ease-out 0.85s both; }
|
|
21
62
|
`}
|
|
22
63
|
</style>
|
|
23
64
|
</defs>
|