commit-report 1.0.1 → 1.0.2

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.
@@ -0,0 +1,378 @@
1
+ // ============================================================
2
+ // 提交时间分布 (24h 柱状图)
3
+ // ============================================================
4
+ function renderHourlyChart() {
5
+ const container = document.getElementById('hourly-chart');
6
+ container.innerHTML = '';
7
+
8
+ const data = stats.hourlyDistribution.map((count, hour) => ({ hour, count }));
9
+ const margin = { top: 10, right: 10, bottom: 30, left: 40 };
10
+ const width = container.clientWidth - margin.left - margin.right;
11
+ const height = 200 - margin.top - margin.bottom;
12
+
13
+ const svg = d3.select(container)
14
+ .append('svg')
15
+ .attr('width', width + margin.left + margin.right)
16
+ .attr('height', height + margin.top + margin.bottom)
17
+ .append('g')
18
+ .attr('transform', `translate(${margin.left},${margin.top})`);
19
+
20
+ const x = d3.scaleBand().domain(data.map(d => d.hour)).range([0, width]).padding(0.3);
21
+ const y = d3.scaleLinear().domain([0, d3.max(data, d => d.count) || 1]).nice().range([height, 0]);
22
+
23
+ const textColor = isDark() ? '#8b949e' : '#64748b';
24
+
25
+ svg.append('g')
26
+ .attr('transform', `translate(0,${height})`)
27
+ .call(d3.axisBottom(x).tickFormat(d => d + ':00').tickValues(data.filter((_, i) => i % 3 === 0).map(d => d.hour)))
28
+ .selectAll('text').attr('fill', textColor);
29
+
30
+ svg.append('g')
31
+ .call(d3.axisLeft(y).ticks(5))
32
+ .selectAll('text').attr('fill', textColor);
33
+
34
+ svg.selectAll('.domain, .tick line').attr('stroke', isDark() ? '#334155' : '#e2e8f0');
35
+
36
+ svg.selectAll('.bar')
37
+ .data(data)
38
+ .join('rect')
39
+ .attr('x', d => x(d.hour))
40
+ .attr('y', d => y(d.count))
41
+ .attr('width', x.bandwidth())
42
+ .attr('height', d => height - y(d.count))
43
+ .attr('fill', '#0ea5e9')
44
+ .attr('rx', 2)
45
+ .on('mouseover', (event, d) => {
46
+ const hourData = stats.hourlyByAuthor?.[d.hour];
47
+ let tooltip = `${d.hour}:00 - ${d.hour + 1}:00: ${d.count} 次提交`;
48
+ if (hourData && Object.keys(hourData.authors).length > 0) {
49
+ const authorList = Object.entries(hourData.authors)
50
+ .sort((a, b) => b[1] - a[1])
51
+ .slice(0, 5)
52
+ .map(([name, count]) => `${name}: ${count}`)
53
+ .join('\n');
54
+ tooltip += '\n' + authorList;
55
+ }
56
+ showTooltip(event, tooltip);
57
+ })
58
+ .on('mouseout', hideTooltip);
59
+ }
60
+
61
+ // ============================================================
62
+ // 文件类型占比 (甜甜圈图)
63
+ // ============================================================
64
+ function renderFileTypeChart() {
65
+ const container = document.getElementById('filetype-chart');
66
+ container.innerHTML = '';
67
+
68
+ const rawData = stats.fileTypes.slice(0, 8);
69
+ if (rawData.length === 0) {
70
+ container.innerHTML = '<p class="text-slate-400 text-sm">暂无数据</p>';
71
+ return;
72
+ }
73
+
74
+ const data = rawData.map(d => ({
75
+ name: d.extension,
76
+ value: d.added + d.deleted,
77
+ }));
78
+
79
+ const size = 220;
80
+ const radius = size / 2 - 10;
81
+
82
+ const colors = ['#0ea5e9', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6', '#f97316'];
83
+
84
+ // 外层容器:饼图 + 图例水平排列
85
+ const wrapper = d3.select(container)
86
+ .append('div')
87
+ .attr('class', 'flex items-center gap-6');
88
+
89
+ const svg = wrapper
90
+ .append('svg')
91
+ .attr('width', size)
92
+ .attr('height', size)
93
+ .append('g')
94
+ .attr('transform', `translate(${size/2},${size/2})`);
95
+
96
+ const pie = d3.pie().value(d => d.value).sort(null);
97
+ const arc = d3.arc().innerRadius(radius * 0.55).outerRadius(radius);
98
+
99
+ svg.selectAll('path')
100
+ .data(pie(data))
101
+ .join('path')
102
+ .attr('d', arc)
103
+ .attr('fill', (_, i) => colors[i % colors.length])
104
+ .attr('stroke', isDark() ? '#1e293b' : '#fff')
105
+ .attr('stroke-width', 2)
106
+ .on('mouseover', (event, d) => {
107
+ const pct = ((d.data.value / d3.sum(data, x => x.value)) * 100).toFixed(1);
108
+ showTooltip(event, `${d.data.name}: ${formatNumber(d.data.value)} 行 (${pct}%)`);
109
+ })
110
+ .on('mouseout', hideTooltip);
111
+
112
+ // 图例:垂直排列在右侧
113
+ const legend = wrapper
114
+ .append('div')
115
+ .attr('class', 'flex flex-col gap-2');
116
+
117
+ data.forEach((d, i) => {
118
+ const item = legend.append('div').attr('class', 'flex items-center gap-2 text-xs');
119
+ item.append('div')
120
+ .style('width', '10px')
121
+ .style('height', '10px')
122
+ .style('border-radius', '2px')
123
+ .style('flex-shrink', '0')
124
+ .style('background', colors[i % colors.length]);
125
+ item.append('span')
126
+ .attr('class', 'text-slate-600 dark:text-slate-400')
127
+ .text(d.name);
128
+ });
129
+ }
130
+
131
+ // ============================================================
132
+ // 作者贡献排行 (横向柱状图)
133
+ // ============================================================
134
+ function renderAuthorChart() {
135
+ const container = document.getElementById('author-chart');
136
+ container.innerHTML = '';
137
+
138
+ if (!stats.authors || stats.authors.length === 0) {
139
+ container.innerHTML = '<p class="text-slate-400 text-sm">暂无数据</p>';
140
+ return;
141
+ }
142
+
143
+ // 创建排序切换按钮(如果有 AI 数据)
144
+ if (stats.authorAIStats && stats.authorAIStats.length > 0) {
145
+ const header = container.parentElement.querySelector('h2');
146
+ if (header && !header.querySelector('.sort-toggle')) {
147
+ const toggleBtn = document.createElement('button');
148
+ toggleBtn.className = 'sort-toggle ml-3 px-3 py-1 text-xs rounded-lg bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400 hover:bg-purple-200 dark:hover:bg-purple-900/50 transition-colors';
149
+ toggleBtn.textContent = '按 AI 使用排序';
150
+ toggleBtn.onclick = () => {
151
+ const isAISort = toggleBtn.textContent.includes('AI');
152
+ toggleBtn.textContent = isAISort ? '按提交数排序' : '按 AI 使用排序';
153
+ renderAuthorChartWithSort(!isAISort);
154
+ };
155
+ header.appendChild(toggleBtn);
156
+ }
157
+ }
158
+
159
+ renderAuthorChartWithSort(false);
160
+ }
161
+
162
+ function renderAuthorChartWithSort(sortByAI) {
163
+ const container = document.getElementById('author-chart');
164
+ const existingToggle = container.parentElement.querySelector('.sort-toggle');
165
+ container.innerHTML = '';
166
+
167
+ // 合并作者数据和 AI 数据
168
+ const authorAIMap = new Map();
169
+ if (stats.authorAIStats) {
170
+ stats.authorAIStats.forEach(ai => {
171
+ authorAIMap.set(ai.email.toLowerCase(), ai);
172
+ });
173
+ }
174
+
175
+ let data = stats.authors.map(author => {
176
+ const aiData = authorAIMap.get(author.email.toLowerCase());
177
+ return {
178
+ ...author,
179
+ aiLines: aiData ? aiData.aiLines : 0,
180
+ aiPercentage: aiData ? aiData.aiPercentage : 0,
181
+ };
182
+ });
183
+
184
+ // 排序
185
+ if (sortByAI) {
186
+ data = data.sort((a, b) => b.aiPercentage - a.aiPercentage);
187
+ } else {
188
+ data = data.sort((a, b) => b.commits - a.commits);
189
+ }
190
+
191
+ data = data.slice(0, 10);
192
+
193
+ // 使用表格布局而不是 D3 图表
194
+ const hasAI = stats.authorAIStats && stats.authorAIStats.length > 0;
195
+
196
+ let html = '<div class="overflow-x-auto"><table class="w-full text-sm">';
197
+ html += '<thead><tr class="text-left border-b border-slate-200 dark:border-slate-700">';
198
+ html += '<th class="pb-3 text-slate-500 dark:text-slate-400 font-medium">作者</th>';
199
+ html += '<th class="pb-3 text-slate-500 dark:text-slate-400 font-medium text-right">提交数</th>';
200
+ html += '<th class="pb-3 text-slate-500 dark:text-slate-400 font-medium text-right">新增行</th>';
201
+ if (hasAI) {
202
+ html += '<th class="pb-3 text-slate-500 dark:text-slate-400 font-medium text-right">AI 行数</th>';
203
+ html += '<th class="pb-3 text-slate-500 dark:text-slate-400 font-medium text-right">AI 占比</th>';
204
+ }
205
+ html += '</tr></thead><tbody>';
206
+
207
+ data.forEach((author, i) => {
208
+ const aiColor = author.aiPercentage > 70 ? 'text-orange-500' :
209
+ author.aiPercentage > 50 ? 'text-yellow-500' :
210
+ 'text-slate-600 dark:text-slate-400';
211
+
212
+ html += '<tr class="border-b border-slate-100 dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-700/30">';
213
+ html += `<td class="py-3 font-medium">${escapeHtml(author.name)}</td>`;
214
+ html += `<td class="py-3 text-right">${formatNumber(author.commits)}</td>`;
215
+ html += `<td class="py-3 text-right text-emerald-600 dark:text-emerald-400">+${formatNumber(author.linesAdded)}</td>`;
216
+
217
+ if (hasAI) {
218
+ html += `<td class="py-3 text-right ${aiColor}">${formatNumber(author.aiLines)}</td>`;
219
+ html += `<td class="py-3 text-right ${aiColor} font-semibold">${author.aiPercentage.toFixed(1)}%</td>`;
220
+ }
221
+
222
+ html += '</tr>';
223
+ });
224
+
225
+ html += '</tbody></table></div>';
226
+ container.innerHTML = html;
227
+ }
228
+
229
+ // ============================================================
230
+ // 活跃目录 TOP 10 (横向柱状图)
231
+ // ============================================================
232
+ function renderDirectoryChart() {
233
+ const container = document.getElementById('directory-chart');
234
+ container.innerHTML = '';
235
+
236
+ if (!stats.directories || stats.directories.length === 0) {
237
+ container.innerHTML = '<p class="text-slate-400 text-sm">暂无数据</p>';
238
+ return;
239
+ }
240
+
241
+ // 合并目录数据和 AI 数据
242
+ const dirAIMap = new Map();
243
+ if (stats.directoryAIStats) {
244
+ stats.directoryAIStats.forEach(ai => {
245
+ dirAIMap.set(ai.path, ai);
246
+ });
247
+ }
248
+
249
+ let data = stats.directories.slice(0, 10).map(dir => {
250
+ const aiData = dirAIMap.get(dir.path);
251
+ return {
252
+ ...dir,
253
+ aiPercentage: aiData ? aiData.aiPercentage : 0,
254
+ isHighRisk: aiData ? aiData.isHighRisk : false,
255
+ };
256
+ });
257
+
258
+ const hasAI = stats.directoryAIStats && stats.directoryAIStats.length > 0;
259
+
260
+ // 使用表格布局
261
+ let html = '<div class="overflow-x-auto"><table class="w-full text-sm">';
262
+ html += '<thead><tr class="text-left border-b border-slate-200 dark:border-slate-700">';
263
+ html += '<th class="pb-3 text-slate-500 dark:text-slate-400 font-medium">目录</th>';
264
+ html += '<th class="pb-3 text-slate-500 dark:text-slate-400 font-medium text-right">提交数</th>';
265
+ html += '<th class="pb-3 text-slate-500 dark:text-slate-400 font-medium text-right">变更行数</th>';
266
+ if (hasAI) {
267
+ html += '<th class="pb-3 text-slate-500 dark:text-slate-400 font-medium text-right">AI 占比</th>';
268
+ }
269
+ html += '</tr></thead><tbody>';
270
+
271
+ data.forEach(dir => {
272
+ const riskBadge = dir.isHighRisk ? '<span class="ml-2 px-2 py-0.5 text-xs rounded bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400">高风险</span>' : '';
273
+
274
+ html += '<tr class="border-b border-slate-100 dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-700/30">';
275
+ html += `<td class="py-3 font-medium">${escapeHtml(dir.path)}${riskBadge}</td>`;
276
+ html += `<td class="py-3 text-right">${formatNumber(dir.commits)}</td>`;
277
+ html += `<td class="py-3 text-right text-violet-600 dark:text-violet-400">${formatNumber(dir.linesChanged)}</td>`;
278
+
279
+ if (hasAI) {
280
+ const aiColor = dir.aiPercentage > 70 ? 'text-red-500' :
281
+ dir.aiPercentage > 50 ? 'text-orange-500' :
282
+ dir.aiPercentage > 30 ? 'text-yellow-500' :
283
+ 'text-slate-600 dark:text-slate-400';
284
+
285
+ html += `<td class="py-3 text-right">`;
286
+ html += `<div class="flex items-center justify-end gap-2">`;
287
+ html += `<div class="w-20 bg-slate-200 dark:bg-slate-700 rounded-full h-2">`;
288
+ html += `<div class="bg-gradient-to-r from-purple-500 to-orange-500 h-2 rounded-full" style="width: ${Math.min(dir.aiPercentage, 100)}%"></div>`;
289
+ html += `</div>`;
290
+ html += `<span class="${aiColor} font-semibold">${dir.aiPercentage.toFixed(1)}%</span>`;
291
+ html += `</div>`;
292
+ html += `</td>`;
293
+ }
294
+
295
+ html += '</tr>';
296
+ });
297
+
298
+ html += '</tbody></table></div>';
299
+ container.innerHTML = html;
300
+ }
301
+
302
+ // ============================================================
303
+ // 更多统计
304
+ // ============================================================
305
+ function renderExtraStats() {
306
+ if (stats.busiestDay && stats.busiestDay.date) {
307
+ document.getElementById('stat-busiest-day').textContent =
308
+ stats.busiestDay.date + ' (' + stats.busiestDay.count + ' 次提交)';
309
+ }
310
+ if (stats.firstCommitDate) {
311
+ document.getElementById('stat-first-commit').textContent =
312
+ new Date(stats.firstCommitDate).toLocaleDateString('zh-CN');
313
+ }
314
+ if (stats.lastCommitDate) {
315
+ document.getElementById('stat-last-commit').textContent =
316
+ new Date(stats.lastCommitDate).toLocaleDateString('zh-CN');
317
+ }
318
+ }
319
+
320
+ // ============================================================
321
+ // 周趋势图 (折线图)
322
+ // ============================================================
323
+ function renderWeeklyTrendChart() {
324
+ const container = document.getElementById('weekly-trend-chart');
325
+ container.innerHTML = '';
326
+
327
+ if (!stats.trends || !stats.trends.weeklyTrend || stats.trends.weeklyTrend.length === 0) {
328
+ container.innerHTML = '<p class="text-slate-400 text-sm">暂无数据</p>';
329
+ return;
330
+ }
331
+
332
+ const data = stats.trends.weeklyTrend;
333
+ const margin = { top: 20, right: 30, bottom: 40, left: 50 };
334
+ const width = container.clientWidth - margin.left - margin.right;
335
+ const height = 200 - margin.top - margin.bottom;
336
+
337
+ const svg = d3.select(container)
338
+ .append('svg')
339
+ .attr('width', width + margin.left + margin.right)
340
+ .attr('height', height + margin.top + margin.bottom)
341
+ .append('g')
342
+ .attr('transform', `translate(${margin.left},${margin.top})`);
343
+
344
+ const x = d3.scalePoint().domain(data.map(d => d.week)).range([0, width]).padding(0.5);
345
+ const y = d3.scaleLinear().domain([0, d3.max(data, d => d.commits) || 1]).nice().range([height, 0]);
346
+
347
+ const textColor = isDark() ? '#8b949e' : '#64748b';
348
+
349
+ svg.append('g')
350
+ .attr('transform', `translate(0,${height})`)
351
+ .call(d3.axisBottom(x).tickValues(data.filter((_, i) => i % Math.ceil(data.length / 6) === 0).map(d => d.week)))
352
+ .selectAll('text').attr('fill', textColor).attr('font-size', '10px');
353
+
354
+ svg.append('g')
355
+ .call(d3.axisLeft(y).ticks(5))
356
+ .selectAll('text').attr('fill', textColor);
357
+
358
+ svg.selectAll('.domain, .tick line').attr('stroke', isDark() ? '#334155' : '#e2e8f0');
359
+
360
+ const line = d3.line().x(d => x(d.week)).y(d => y(d.commits)).curve(d3.curveMonotoneX);
361
+
362
+ svg.append('path')
363
+ .datum(data)
364
+ .attr('fill', 'none')
365
+ .attr('stroke', '#0ea5e9')
366
+ .attr('stroke-width', 2)
367
+ .attr('d', line);
368
+
369
+ svg.selectAll('.dot')
370
+ .data(data)
371
+ .join('circle')
372
+ .attr('cx', d => x(d.week))
373
+ .attr('cy', d => y(d.commits))
374
+ .attr('r', 4)
375
+ .attr('fill', '#0ea5e9')
376
+ .on('mouseover', (event, d) => showTooltip(event, `${d.week}: ${d.commits} 次提交, +${formatNumber(d.linesAdded)} / -${formatNumber(d.linesDeleted)}`))
377
+ .on('mouseout', hideTooltip);
378
+ }
@@ -0,0 +1,309 @@
1
+ // ============================================================
2
+ // AI 使用趋势图 (双轴折线图)
3
+ // ============================================================
4
+ function renderAITrendChart() {
5
+ const section = document.getElementById('ai-trend-section');
6
+ if (!stats.aiTrends || stats.aiTrends.length === 0) {
7
+ section.style.display = 'none';
8
+ return;
9
+ }
10
+
11
+ section.style.display = 'block';
12
+
13
+ const container = document.getElementById('ai-trend-chart');
14
+ container.innerHTML = '';
15
+
16
+ const data = stats.aiTrends;
17
+ const margin = { top: 20, right: 60, bottom: 40, left: 60 };
18
+ const width = container.clientWidth - margin.left - margin.right;
19
+ const height = 250 - margin.top - margin.bottom;
20
+
21
+ const svg = d3.select(container)
22
+ .append('svg')
23
+ .attr('width', width + margin.left + margin.right)
24
+ .attr('height', height + margin.top + margin.bottom)
25
+ .append('g')
26
+ .attr('transform', `translate(${margin.left},${margin.top})`);
27
+
28
+ const x = d3.scalePoint().domain(data.map(d => d.week)).range([0, width]).padding(0.5);
29
+ const yLines = d3.scaleLinear().domain([0, d3.max(data, d => d.aiLines) || 1]).nice().range([height, 0]);
30
+ const yPercent = d3.scaleLinear().domain([0, 100]).range([height, 0]);
31
+
32
+ const textColor = isDark() ? '#8b949e' : '#64748b';
33
+
34
+ // X 轴
35
+ svg.append('g')
36
+ .attr('transform', `translate(0,${height})`)
37
+ .call(d3.axisBottom(x).tickValues(data.filter((_, i) => i % Math.ceil(data.length / 8) === 0).map(d => d.week)))
38
+ .selectAll('text').attr('fill', textColor).attr('font-size', '10px');
39
+
40
+ // 左 Y 轴 (AI 行数)
41
+ svg.append('g')
42
+ .call(d3.axisLeft(yLines).ticks(5))
43
+ .selectAll('text').attr('fill', textColor);
44
+
45
+ svg.append('text')
46
+ .attr('transform', 'rotate(-90)')
47
+ .attr('y', -45)
48
+ .attr('x', -height / 2)
49
+ .attr('text-anchor', 'middle')
50
+ .attr('fill', '#8b5cf6')
51
+ .attr('font-size', '11px')
52
+ .text('AI 行数');
53
+
54
+ // 右 Y 轴 (AI 占比)
55
+ svg.append('g')
56
+ .attr('transform', `translate(${width},0)`)
57
+ .call(d3.axisRight(yPercent).ticks(5).tickFormat(d => d + '%'))
58
+ .selectAll('text').attr('fill', textColor);
59
+
60
+ svg.append('text')
61
+ .attr('transform', 'rotate(-90)')
62
+ .attr('y', width + 50)
63
+ .attr('x', -height / 2)
64
+ .attr('text-anchor', 'middle')
65
+ .attr('fill', '#f59e0b')
66
+ .attr('font-size', '11px')
67
+ .text('AI 占比');
68
+
69
+ svg.selectAll('.domain, .tick line').attr('stroke', isDark() ? '#334155' : '#e2e8f0');
70
+
71
+ // AI 行数折线 (紫色)
72
+ const lineAILines = d3.line().x(d => x(d.week)).y(d => yLines(d.aiLines)).curve(d3.curveMonotoneX);
73
+
74
+ svg.append('path')
75
+ .datum(data)
76
+ .attr('fill', 'none')
77
+ .attr('stroke', '#8b5cf6')
78
+ .attr('stroke-width', 2)
79
+ .attr('d', lineAILines);
80
+
81
+ svg.selectAll('.dot-lines')
82
+ .data(data)
83
+ .join('circle')
84
+ .attr('cx', d => x(d.week))
85
+ .attr('cy', d => yLines(d.aiLines))
86
+ .attr('r', 3)
87
+ .attr('fill', '#8b5cf6')
88
+ .on('mouseover', (event, d) => showTooltip(event, `${d.week}\nAI 行数: ${formatNumber(d.aiLines)}\n总行数: ${formatNumber(d.totalLines)}\nAI 占比: ${d.aiPercentage.toFixed(1)}%`))
89
+ .on('mouseout', hideTooltip);
90
+
91
+ // AI 占比折线 (橙色)
92
+ const linePercent = d3.line().x(d => x(d.week)).y(d => yPercent(d.aiPercentage)).curve(d3.curveMonotoneX);
93
+
94
+ svg.append('path')
95
+ .datum(data)
96
+ .attr('fill', 'none')
97
+ .attr('stroke', '#f59e0b')
98
+ .attr('stroke-width', 2)
99
+ .attr('stroke-dasharray', '5,5')
100
+ .attr('d', linePercent);
101
+
102
+ svg.selectAll('.dot-percent')
103
+ .data(data)
104
+ .join('circle')
105
+ .attr('cx', d => x(d.week))
106
+ .attr('cy', d => yPercent(d.aiPercentage))
107
+ .attr('r', 3)
108
+ .attr('fill', '#f59e0b')
109
+ .on('mouseover', (event, d) => showTooltip(event, `${d.week}\nAI 占比: ${d.aiPercentage.toFixed(1)}%`))
110
+ .on('mouseout', hideTooltip);
111
+ }
112
+
113
+ // ============================================================
114
+ // 累计代码量曲线 (面积图)
115
+ // ============================================================
116
+ function renderCumulativeChart() {
117
+ const container = document.getElementById('cumulative-chart');
118
+ container.innerHTML = '';
119
+
120
+ if (!stats.trends || !stats.trends.cumulativeLines || stats.trends.cumulativeLines.length === 0) {
121
+ container.innerHTML = '<p class="text-slate-400 text-sm">暂无数据</p>';
122
+ return;
123
+ }
124
+
125
+ const data = stats.trends.cumulativeLines;
126
+ const margin = { top: 20, right: 30, bottom: 40, left: 60 };
127
+ const width = container.clientWidth - margin.left - margin.right;
128
+ const height = 200 - margin.top - margin.bottom;
129
+
130
+ const svg = d3.select(container)
131
+ .append('svg')
132
+ .attr('width', width + margin.left + margin.right)
133
+ .attr('height', height + margin.top + margin.bottom)
134
+ .append('g')
135
+ .attr('transform', `translate(${margin.left},${margin.top})`);
136
+
137
+ const x = d3.scalePoint().domain(data.map(d => d.date)).range([0, width]).padding(0.5);
138
+ const yMin = d3.min(data, d => d.netLines) || 0;
139
+ const yMax = d3.max(data, d => d.netLines) || 1;
140
+ const y = d3.scaleLinear().domain([Math.min(0, yMin), yMax]).nice().range([height, 0]);
141
+
142
+ const textColor = isDark() ? '#8b949e' : '#64748b';
143
+
144
+ svg.append('g')
145
+ .attr('transform', `translate(0,${height})`)
146
+ .call(d3.axisBottom(x).tickValues(data.filter((_, i) => i % Math.ceil(data.length / 6) === 0).map(d => d.date)))
147
+ .selectAll('text').attr('fill', textColor).attr('font-size', '10px');
148
+
149
+ svg.append('g')
150
+ .call(d3.axisLeft(y).ticks(5).tickFormat(d => d >= 1000 ? (d/1000).toFixed(0) + 'k' : d))
151
+ .selectAll('text').attr('fill', textColor);
152
+
153
+ svg.selectAll('.domain, .tick line').attr('stroke', isDark() ? '#334155' : '#e2e8f0');
154
+
155
+ const area = d3.area()
156
+ .x(d => x(d.date))
157
+ .y0(y(0))
158
+ .y1(d => y(d.netLines))
159
+ .curve(d3.curveMonotoneX);
160
+
161
+ svg.append('path')
162
+ .datum(data)
163
+ .attr('fill', isDark() ? 'rgba(34, 197, 94, 0.3)' : 'rgba(34, 197, 94, 0.2)')
164
+ .attr('stroke', '#22c55e')
165
+ .attr('stroke-width', 2)
166
+ .attr('d', area);
167
+ }
168
+
169
+ // ============================================================
170
+ // 周几分布 (柱状图)
171
+ // ============================================================
172
+ function renderWeekdayChart() {
173
+ const container = document.getElementById('weekday-chart');
174
+ container.innerHTML = '';
175
+
176
+ if (!stats.timePatterns || !stats.timePatterns.weekdayDistribution) {
177
+ container.innerHTML = '<p class="text-slate-400 text-sm">暂无数据</p>';
178
+ return;
179
+ }
180
+
181
+ const weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
182
+ const data = stats.timePatterns.weekdayDistribution.map((count, i) => ({ day: weekdays[i], count }));
183
+
184
+ const margin = { top: 10, right: 10, bottom: 30, left: 40 };
185
+ const width = container.clientWidth - margin.left - margin.right;
186
+ const height = 200 - margin.top - margin.bottom;
187
+
188
+ const svg = d3.select(container)
189
+ .append('svg')
190
+ .attr('width', width + margin.left + margin.right)
191
+ .attr('height', height + margin.top + margin.bottom)
192
+ .append('g')
193
+ .attr('transform', `translate(${margin.left},${margin.top})`);
194
+
195
+ const x = d3.scaleBand().domain(data.map(d => d.day)).range([0, width]).padding(0.3);
196
+ const y = d3.scaleLinear().domain([0, d3.max(data, d => d.count) || 1]).nice().range([height, 0]);
197
+
198
+ const textColor = isDark() ? '#8b949e' : '#64748b';
199
+
200
+ svg.append('g')
201
+ .attr('transform', `translate(0,${height})`)
202
+ .call(d3.axisBottom(x))
203
+ .selectAll('text').attr('fill', textColor);
204
+
205
+ svg.append('g')
206
+ .call(d3.axisLeft(y).ticks(5))
207
+ .selectAll('text').attr('fill', textColor);
208
+
209
+ svg.selectAll('.domain, .tick line').attr('stroke', isDark() ? '#334155' : '#e2e8f0');
210
+
211
+ svg.selectAll('.bar')
212
+ .data(data)
213
+ .join('rect')
214
+ .attr('x', d => x(d.day))
215
+ .attr('y', d => y(d.count))
216
+ .attr('width', x.bandwidth())
217
+ .attr('height', d => height - y(d.count))
218
+ .attr('fill', (_, i) => i >= 5 ? '#f59e0b' : '#8b5cf6')
219
+ .attr('rx', 2)
220
+ .on('mouseover', (event, d, i) => {
221
+ const dayIndex = data.findIndex(item => item.day === d.day);
222
+ const dayData = stats.timePatterns?.weekdayByAuthor?.[dayIndex];
223
+ let tooltip = `${d.day}: ${d.count} 次提交`;
224
+ if (dayData && Object.keys(dayData.authors).length > 0) {
225
+ const authorList = Object.entries(dayData.authors)
226
+ .sort((a, b) => b[1] - a[1])
227
+ .slice(0, 5)
228
+ .map(([name, count]) => `${name}: ${count}`)
229
+ .join('\n');
230
+ tooltip += '\n' + authorList;
231
+ }
232
+ showTooltip(event, tooltip);
233
+ })
234
+ .on('mouseout', hideTooltip);
235
+ }
236
+
237
+ // ============================================================
238
+ // 提交类型分布 (甜甜圈图)
239
+ // ============================================================
240
+ function renderCommitTypeChart() {
241
+ const container = document.getElementById('commit-type-chart');
242
+ container.innerHTML = '';
243
+
244
+ if (!stats.messageStats || !stats.messageStats.typeDistribution) {
245
+ container.innerHTML = '<p class="text-slate-400 text-sm">暂无数据</p>';
246
+ return;
247
+ }
248
+
249
+ const rawData = Object.entries(stats.messageStats.typeDistribution);
250
+ if (rawData.length === 0) {
251
+ container.innerHTML = '<p class="text-slate-400 text-sm">暂无数据</p>';
252
+ return;
253
+ }
254
+
255
+ const data = rawData.map(([name, value]) => ({ name, value })).sort((a, b) => b.value - a.value).slice(0, 8);
256
+
257
+ const size = 220;
258
+ const radius = size / 2 - 10;
259
+
260
+ const typeColors = {
261
+ feat: '#22c55e', fix: '#ef4444', docs: '#3b82f6', style: '#ec4899',
262
+ refactor: '#8b5cf6', test: '#f59e0b', chore: '#6b7280', perf: '#14b8a6',
263
+ ci: '#06b6d4', build: '#84cc16', revert: '#f43f5e', other: '#94a3b8'
264
+ };
265
+
266
+ const wrapper = d3.select(container)
267
+ .append('div')
268
+ .attr('class', 'flex items-center gap-6');
269
+
270
+ const svg = wrapper
271
+ .append('svg')
272
+ .attr('width', size)
273
+ .attr('height', size)
274
+ .append('g')
275
+ .attr('transform', `translate(${size/2},${size/2})`);
276
+
277
+ const pie = d3.pie().value(d => d.value).sort(null);
278
+ const arc = d3.arc().innerRadius(radius * 0.55).outerRadius(radius);
279
+
280
+ svg.selectAll('path')
281
+ .data(pie(data))
282
+ .join('path')
283
+ .attr('d', arc)
284
+ .attr('fill', d => typeColors[d.data.name] || '#94a3b8')
285
+ .attr('stroke', isDark() ? '#1e293b' : '#fff')
286
+ .attr('stroke-width', 2)
287
+ .on('mouseover', (event, d) => {
288
+ const pct = ((d.data.value / d3.sum(data, x => x.value)) * 100).toFixed(1);
289
+ showTooltip(event, `${d.data.name}: ${d.data.value} 次 (${pct}%)`);
290
+ })
291
+ .on('mouseout', hideTooltip);
292
+
293
+ const legend = wrapper
294
+ .append('div')
295
+ .attr('class', 'flex flex-col gap-2');
296
+
297
+ data.forEach(d => {
298
+ const item = legend.append('div').attr('class', 'flex items-center gap-2 text-xs');
299
+ item.append('div')
300
+ .style('width', '10px')
301
+ .style('height', '10px')
302
+ .style('border-radius', '2px')
303
+ .style('flex-shrink', '0')
304
+ .style('background', typeColors[d.name] || '#94a3b8');
305
+ item.append('span')
306
+ .attr('class', 'text-slate-600 dark:text-slate-400')
307
+ .text(d.name);
308
+ });
309
+ }