@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.
Files changed (103) hide show
  1. package/.githooks/commit-msg +4 -0
  2. package/.githooks/pre-commit +4 -0
  3. package/AGENTS.md +32 -19
  4. package/CHANGELOG.md +45 -0
  5. package/CONTRIBUTING.md +18 -19
  6. package/README.md +21 -24
  7. package/action.yml +1 -1
  8. package/assets/insights/index.svg +45 -4
  9. package/assets/insights/metrics-constellation.svg +55 -0
  10. package/assets/insights/metrics-impact.svg +55 -0
  11. package/assets/insights/metrics-rhythm.svg +55 -0
  12. package/assets/insights/metrics-velocity.svg +55 -0
  13. package/examples/classic/README.md +36 -2
  14. package/examples/classic/index.svg +45 -4
  15. package/examples/classic/metrics-constellation.svg +55 -0
  16. package/examples/classic/metrics-impact.svg +55 -0
  17. package/examples/classic/metrics-rhythm.svg +55 -0
  18. package/examples/classic/metrics-velocity.svg +55 -0
  19. package/examples/ecosystem/README.md +39 -28
  20. package/examples/ecosystem/index.svg +45 -4
  21. package/examples/ecosystem/metrics-constellation.svg +55 -0
  22. package/examples/ecosystem/metrics-impact.svg +55 -0
  23. package/examples/ecosystem/metrics-rhythm.svg +55 -0
  24. package/examples/ecosystem/metrics-velocity.svg +55 -0
  25. package/examples/minimal/README.md +36 -2
  26. package/examples/minimal/index.svg +45 -4
  27. package/examples/minimal/metrics-constellation.svg +55 -0
  28. package/examples/minimal/metrics-impact.svg +55 -0
  29. package/examples/minimal/metrics-rhythm.svg +55 -0
  30. package/examples/minimal/metrics-velocity.svg +55 -0
  31. package/examples/modern/README.md +62 -50
  32. package/examples/modern/index.svg +45 -4
  33. package/examples/modern/metrics-constellation.svg +55 -0
  34. package/examples/modern/metrics-impact.svg +55 -0
  35. package/examples/modern/metrics-rhythm.svg +55 -0
  36. package/examples/modern/metrics-velocity.svg +55 -0
  37. package/llms.txt +4 -4
  38. package/package.json +1 -1
  39. package/skills/github-insights/SKILL.md +35 -81
  40. package/sr.yaml +9 -0
  41. package/src/api.ts +2 -140
  42. package/src/components/contribution-rhythm.tsx +152 -0
  43. package/src/components/full-svg.test.tsx +4 -1
  44. package/src/components/full-svg.tsx +14 -7
  45. package/src/components/impact-trail.tsx +90 -0
  46. package/src/components/language-velocity.tsx +181 -0
  47. package/src/components/project-constellation.tsx +97 -0
  48. package/src/components/section.test.tsx +5 -3
  49. package/src/components/section.tsx +5 -13
  50. package/src/components/style-defs.tsx +44 -3
  51. package/src/index.ts +28 -47
  52. package/src/metrics.test.ts +50 -57
  53. package/src/metrics.ts +277 -95
  54. package/src/readme.test.ts +2 -4
  55. package/src/templates.test.ts +19 -16
  56. package/src/templates.ts +30 -16
  57. package/src/theme.ts +11 -1
  58. package/src/types.ts +28 -7
  59. package/assets/insights/metrics-calendar.svg +0 -14
  60. package/assets/insights/metrics-complexity.svg +0 -14
  61. package/assets/insights/metrics-contributions.svg +0 -14
  62. package/assets/insights/metrics-expertise.svg +0 -14
  63. package/assets/insights/metrics-languages.svg +0 -14
  64. package/assets/insights/metrics-pulse.svg +0 -14
  65. package/examples/classic/metrics-calendar.svg +0 -14
  66. package/examples/classic/metrics-complexity.svg +0 -14
  67. package/examples/classic/metrics-contributions.svg +0 -14
  68. package/examples/classic/metrics-expertise.svg +0 -14
  69. package/examples/classic/metrics-languages.svg +0 -14
  70. package/examples/classic/metrics-pulse.svg +0 -14
  71. package/examples/ecosystem/metrics-calendar.svg +0 -14
  72. package/examples/ecosystem/metrics-complexity.svg +0 -14
  73. package/examples/ecosystem/metrics-contributions.svg +0 -14
  74. package/examples/ecosystem/metrics-expertise.svg +0 -14
  75. package/examples/ecosystem/metrics-languages.svg +0 -14
  76. package/examples/ecosystem/metrics-pulse.svg +0 -14
  77. package/examples/minimal/metrics-calendar.svg +0 -14
  78. package/examples/minimal/metrics-complexity.svg +0 -14
  79. package/examples/minimal/metrics-contributions.svg +0 -14
  80. package/examples/minimal/metrics-expertise.svg +0 -14
  81. package/examples/minimal/metrics-languages.svg +0 -14
  82. package/examples/minimal/metrics-pulse.svg +0 -14
  83. package/examples/modern/metrics-calendar.svg +0 -14
  84. package/examples/modern/metrics-complexity.svg +0 -14
  85. package/examples/modern/metrics-contributions.svg +0 -14
  86. package/examples/modern/metrics-expertise.svg +0 -14
  87. package/examples/modern/metrics-languages.svg +0 -14
  88. package/examples/modern/metrics-pulse.svg +0 -14
  89. package/src/components/bar-chart.test.tsx +0 -38
  90. package/src/components/bar-chart.tsx +0 -54
  91. package/src/components/contribution-calendar.test.tsx +0 -44
  92. package/src/components/contribution-calendar.tsx +0 -94
  93. package/src/components/contribution-cards.test.tsx +0 -36
  94. package/src/components/contribution-cards.tsx +0 -58
  95. package/src/components/donut-chart.test.tsx +0 -36
  96. package/src/components/donut-chart.tsx +0 -102
  97. package/src/components/project-cards.test.tsx +0 -46
  98. package/src/components/project-cards.tsx +0 -66
  99. package/src/components/stat-cards.test.tsx +0 -32
  100. package/src/components/stat-cards.tsx +0 -57
  101. package/src/components/tech-highlights.test.tsx +0 -63
  102. package/src/components/tech-highlights.tsx +0 -109
  103. 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
- items: [{ name: "Go", value: 10 }],
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 width={width} height={height} rx="12" fill={THEME.bg} />
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 width={width} height={totalHeight} rx="12" fill={THEME.bg} />
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 items array", () => {
65
- const items = [{ name: "Go", value: 10 }];
66
- const result = renderSection("Tech", "Detected", items);
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 { BarItem, RenderResult } from "../types.js";
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
- itemsOrRenderBody: ((y: number) => RenderResult) | BarItem[],
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
- if (typeof itemsOrRenderBody === "function") {
67
- const body = itemsOrRenderBody(y);
68
- svg += body.svg;
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: 13px; fill: ${THEME.text}; letter-spacing: 1.5px; font-weight: 600; }
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>