@urmzd/github-insights 2.1.0 → 2.3.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/.sr-hooks-hash +1 -0
- package/.githooks/commit-msg +3 -0
- package/.githooks/pre-commit +3 -0
- package/AGENTS.md +32 -19
- package/CHANGELOG.md +62 -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-growth.svg +55 -0
- package/assets/insights/metrics-heatmap.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-growth.svg +55 -0
- package/examples/classic/metrics-heatmap.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-growth.svg +55 -0
- package/examples/ecosystem/metrics-heatmap.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-growth.svg +55 -0
- package/examples/minimal/metrics-heatmap.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-growth.svg +55 -0
- package/examples/modern/metrics-heatmap.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-heatmap.tsx +43 -0
- 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/growth-arc.tsx +119 -0
- 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 +34 -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,119 @@
|
|
|
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 { GrowthArcPoint, RenderResult } from "../types.js";
|
|
5
|
+
|
|
6
|
+
export function renderGrowthArc(
|
|
7
|
+
points: GrowthArcPoint[],
|
|
8
|
+
y: number,
|
|
9
|
+
): RenderResult {
|
|
10
|
+
if (points.length < 2) return { svg: "", height: 0 };
|
|
11
|
+
|
|
12
|
+
const { padX } = LAYOUT;
|
|
13
|
+
const chartWidth = 760;
|
|
14
|
+
const chartHeight = 120;
|
|
15
|
+
const labelHeight = 20;
|
|
16
|
+
const totalHeight = chartHeight + labelHeight;
|
|
17
|
+
|
|
18
|
+
const maxComplexity = Math.max(...points.map((p) => p.avgComplexity));
|
|
19
|
+
const minComplexity = Math.min(...points.map((p) => p.avgComplexity));
|
|
20
|
+
const range = maxComplexity - minComplexity || 1;
|
|
21
|
+
|
|
22
|
+
const stepX = chartWidth / Math.max(points.length - 1, 1);
|
|
23
|
+
|
|
24
|
+
// Compute point positions
|
|
25
|
+
const coords = points.map((p, i) => ({
|
|
26
|
+
x: padX + i * stepX,
|
|
27
|
+
y:
|
|
28
|
+
y +
|
|
29
|
+
chartHeight -
|
|
30
|
+
((p.avgComplexity - minComplexity) / range) * (chartHeight - 20) -
|
|
31
|
+
10,
|
|
32
|
+
...p,
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
// Build smooth path
|
|
36
|
+
let pathD = `M ${coords[0].x},${coords[0].y}`;
|
|
37
|
+
for (let i = 1; i < coords.length; i++) {
|
|
38
|
+
const prev = coords[i - 1];
|
|
39
|
+
const curr = coords[i];
|
|
40
|
+
const cpx = (prev.x + curr.x) / 2;
|
|
41
|
+
pathD += ` C ${cpx},${prev.y} ${cpx},${curr.y} ${curr.x},${curr.y}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Build filled area path
|
|
45
|
+
let areaD = pathD;
|
|
46
|
+
areaD += ` L ${coords[coords.length - 1].x},${y + chartHeight}`;
|
|
47
|
+
areaD += ` L ${coords[0].x},${y + chartHeight} Z`;
|
|
48
|
+
|
|
49
|
+
const svg = (
|
|
50
|
+
<>
|
|
51
|
+
{/* Filled area under curve */}
|
|
52
|
+
<path d={areaD} fill="#58a6ff" fill-opacity="0.15" className="fade-1" />
|
|
53
|
+
|
|
54
|
+
{/* Line */}
|
|
55
|
+
<path
|
|
56
|
+
d={pathD}
|
|
57
|
+
fill="none"
|
|
58
|
+
stroke="#58a6ff"
|
|
59
|
+
stroke-width="2.5"
|
|
60
|
+
stroke-linecap="round"
|
|
61
|
+
stroke-linejoin="round"
|
|
62
|
+
className="fade-1"
|
|
63
|
+
/>
|
|
64
|
+
|
|
65
|
+
{/* Data points */}
|
|
66
|
+
{coords.map((p, i) => (
|
|
67
|
+
<circle
|
|
68
|
+
cx={p.x}
|
|
69
|
+
cy={p.y}
|
|
70
|
+
r="4"
|
|
71
|
+
fill="#58a6ff"
|
|
72
|
+
className={`fade-${Math.min(i + 1, 6)}`}
|
|
73
|
+
/>
|
|
74
|
+
))}
|
|
75
|
+
|
|
76
|
+
{/* Point labels (complexity value) */}
|
|
77
|
+
{coords.map((p) => (
|
|
78
|
+
<text
|
|
79
|
+
x={p.x}
|
|
80
|
+
y={p.y - 10}
|
|
81
|
+
className="t t-value"
|
|
82
|
+
text-anchor="middle"
|
|
83
|
+
font-size="10"
|
|
84
|
+
>
|
|
85
|
+
{p.avgComplexity.toFixed(0)}
|
|
86
|
+
</text>
|
|
87
|
+
))}
|
|
88
|
+
|
|
89
|
+
{/* X-axis labels (relative time) */}
|
|
90
|
+
{coords.map((p) => (
|
|
91
|
+
<text
|
|
92
|
+
x={p.x}
|
|
93
|
+
y={y + chartHeight + 14}
|
|
94
|
+
className="t t-value"
|
|
95
|
+
text-anchor="middle"
|
|
96
|
+
>
|
|
97
|
+
{escapeXml(p.label)}
|
|
98
|
+
</text>
|
|
99
|
+
))}
|
|
100
|
+
|
|
101
|
+
{/* Repo count annotations */}
|
|
102
|
+
{coords.map((p) => (
|
|
103
|
+
<text
|
|
104
|
+
x={p.x}
|
|
105
|
+
y={y + chartHeight + 24}
|
|
106
|
+
className="t t-muted"
|
|
107
|
+
text-anchor="middle"
|
|
108
|
+
font-size="9"
|
|
109
|
+
>
|
|
110
|
+
{`${p.repoCount} repo${p.repoCount !== 1 ? "s" : ""}`}
|
|
111
|
+
</text>
|
|
112
|
+
))}
|
|
113
|
+
</>
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
return { svg, height: totalHeight + 10 };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
void Fragment;
|
|
@@ -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>
|