gitinsight-cli 0.1.0__py3-none-any.whl

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.
gitinsight/charts.py ADDED
@@ -0,0 +1,849 @@
1
+ """
2
+ charts.py — pyecharts 图表构建器。
3
+
4
+ 包含所有图表的构建函数:
5
+ - 日历热力图
6
+ - 人员变动趋势折线图
7
+ - 开发者活跃状态旭日图
8
+ - 开发者生命周期散点图
9
+ - 提交排行榜 (横向条形图)
10
+ - 卷王榜 (夜间提交排行)
11
+ - 最长维护榜 (分段条形图)
12
+ - 代码活动趋势图 (双Y轴)
13
+ - 文件修改热度旭日图
14
+ - 代码稳定性分析图
15
+ - 开发者个人分析面板 (信息卡 + 时间线 + 热力图 + 雷达图)
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import datetime
20
+ import json
21
+ from typing import Any, Dict, List
22
+
23
+ import pandas as pd
24
+ from pyecharts import options as opts
25
+ from pyecharts.charts import (
26
+ Bar,
27
+ Calendar,
28
+ Line,
29
+ Page,
30
+ Pie,
31
+ Radar,
32
+ Scatter,
33
+ Sunburst,
34
+ Tab,
35
+ # Timeline is intentionally not used — see build_calendar_heatmap comment
36
+ Grid,
37
+ )
38
+ from pyecharts.globals import ThemeType
39
+ from pyecharts.commons.utils import JsCode
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Helpers
44
+ # ---------------------------------------------------------------------------
45
+
46
+ def _to_int_list(values) -> List[int]:
47
+ """Convert numpy/pandas integers to python int."""
48
+ return [int(v) for v in values]
49
+
50
+
51
+ def _to_float_list(values, decimals: int = 1) -> List[float]:
52
+ return [round(float(v), decimals) for v in values]
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # 1. Calendar Heatmap
57
+ # ---------------------------------------------------------------------------
58
+
59
+ def build_calendar_heatmap(daily_commits: pd.Series) -> Calendar:
60
+ """提交热力图,只显示最近一年(从最后一次提交向前推一年)。"""
61
+ if daily_commits.empty:
62
+ cal = Calendar(init_opts=opts.InitOpts(width="100%", height="220px"))
63
+ cal.set_global_opts(title_opts=opts.TitleOpts(title="提交活动日历热力图"))
64
+ return cal
65
+
66
+ # 确定时间范围:最后一次提交日期 ~ 向前推一年
67
+ max_date = daily_commits.index.max()
68
+ min_date = max_date - datetime.timedelta(days=365)
69
+
70
+ # 转换为字符串, ECharts range 支持 ['YYYY-MM-DD', 'YYYY-MM-DD']
71
+ range_date = [str(min_date), str(max_date)]
72
+
73
+ # 筛选数据
74
+ data = [
75
+ [str(d), int(c)]
76
+ for d, c in daily_commits.items()
77
+ if d >= min_date
78
+ ]
79
+
80
+ max_val = int(daily_commits.max())
81
+
82
+ cal = Calendar(init_opts=opts.InitOpts(width="100%", height="240px"))
83
+ cal.add(
84
+ series_name="提交数",
85
+ yaxis_data=data,
86
+ calendar_opts=opts.CalendarOpts(
87
+ pos_top="50",
88
+ pos_left="30",
89
+ pos_right="30",
90
+ range_=range_date,
91
+ daylabel_opts=opts.CalendarDayLabelOpts(name_map="cn"),
92
+ monthlabel_opts=opts.CalendarMonthLabelOpts(name_map="cn"),
93
+ yearlabel_opts=opts.CalendarYearLabelOpts(is_show=False),
94
+ ),
95
+ )
96
+ cal.set_global_opts(
97
+ title_opts=opts.TitleOpts(title="提交活动日历热力图 (近一年)", pos_left="center"),
98
+ visualmap_opts=opts.VisualMapOpts(
99
+ max_=max_val, min_=0, orient="horizontal",
100
+ pos_bottom="10", pos_left="center",
101
+ range_color=["#ebedf0", "#9be9a8", "#40c463", "#30a14e", "#216e39"],
102
+ ),
103
+ legend_opts=opts.LegendOpts(is_show=False),
104
+ )
105
+ return cal
106
+
107
+
108
+ # ---------------------------------------------------------------------------
109
+ # 2. Personnel Trend Chart
110
+ # ---------------------------------------------------------------------------
111
+
112
+ def build_personnel_trend_chart(monthly_trends: pd.DataFrame) -> Line:
113
+ """人员变动趋势图(三条折线)。"""
114
+ if monthly_trends.empty:
115
+ return Line(init_opts=opts.InitOpts(width="100%", height="400px"))
116
+
117
+ dates = [d.strftime("%Y-%m") for d in monthly_trends.index]
118
+
119
+ line = Line(init_opts=opts.InitOpts(width="100%", height="400px"))
120
+ line.add_xaxis(dates)
121
+
122
+ line.add_yaxis(
123
+ "累计开发者",
124
+ _to_int_list(monthly_trends["cumulative_authors"]),
125
+ is_smooth=True,
126
+ is_symbol_show=False,
127
+ linestyle_opts=opts.LineStyleOpts(width=3, color="#5470c6"),
128
+ itemstyle_opts=opts.ItemStyleOpts(color="#5470c6"),
129
+ )
130
+ line.add_yaxis(
131
+ "月活跃开发者",
132
+ _to_int_list(monthly_trends["active_authors"]),
133
+ is_smooth=True,
134
+ linestyle_opts=opts.LineStyleOpts(width=2, color="#91cc75"),
135
+ itemstyle_opts=opts.ItemStyleOpts(color="#91cc75"),
136
+ )
137
+ line.add_yaxis(
138
+ "新增开发者",
139
+ _to_int_list(monthly_trends["new_authors"]),
140
+ is_smooth=True,
141
+ linestyle_opts=opts.LineStyleOpts(width=2, color="#fac858"),
142
+ itemstyle_opts=opts.ItemStyleOpts(color="#fac858"),
143
+ )
144
+
145
+ line.set_global_opts(
146
+ title_opts=opts.TitleOpts(title="人员变动趋势", pos_left="center"),
147
+ tooltip_opts=opts.TooltipOpts(trigger="axis"),
148
+ legend_opts=opts.LegendOpts(pos_bottom="0"),
149
+ datazoom_opts=[opts.DataZoomOpts(range_start=0, range_end=100)],
150
+ yaxis_opts=opts.AxisOpts(name="人数"),
151
+ )
152
+ return line
153
+
154
+
155
+ # ---------------------------------------------------------------------------
156
+ # 3. Activity Sunburst (3-layer ring)
157
+ # ---------------------------------------------------------------------------
158
+
159
+ def build_activity_sunburst(author_stats: pd.DataFrame) -> Sunburst:
160
+ """开发者活跃状态环形图(三层)。"""
161
+ if author_stats.empty:
162
+ return Sunburst(init_opts=opts.InitOpts(width="100%", height="480px"))
163
+
164
+ data = []
165
+ for is_active, group1 in author_stats.groupby("is_active"):
166
+ status_name = "活跃" if is_active else "不活跃"
167
+ l2_children = []
168
+ for phase, group2 in group1.groupby("phase"):
169
+ l3_children = []
170
+ for contrib, group3 in group2.groupby("contribution_level"):
171
+ l3_children.append({"name": contrib, "value": len(group3)})
172
+ l2_children.append({"name": phase, "children": l3_children})
173
+ data.append({"name": status_name, "children": l2_children})
174
+
175
+ sunburst = Sunburst(init_opts=opts.InitOpts(width="100%", height="480px"))
176
+ sunburst.add(
177
+ "",
178
+ data_pair=data,
179
+ radius=[0, "90%"],
180
+ highlight_policy="ancestor",
181
+ sort_="desc",
182
+ levels=[
183
+ {},
184
+ {
185
+ "r0": "0%", "r1": "25%",
186
+ "itemStyle": {"borderWidth": 2},
187
+ "label": {"fontSize": 12},
188
+ },
189
+ {
190
+ "r0": "25%", "r1": "55%",
191
+ "itemStyle": {"borderWidth": 2},
192
+ "label": {"fontSize": 11},
193
+ },
194
+ {
195
+ "r0": "55%", "r1": "90%",
196
+ "itemStyle": {"borderWidth": 2},
197
+ "label": {"fontSize": 10},
198
+ },
199
+ ],
200
+ )
201
+ sunburst.set_global_opts(
202
+ title_opts=opts.TitleOpts(title="开发者活跃状态分布", pos_left="center"),
203
+ )
204
+ return sunburst
205
+
206
+
207
+ # ---------------------------------------------------------------------------
208
+ # 4. Lifecycle Trend (Half-year)
209
+ # ---------------------------------------------------------------------------
210
+
211
+ def build_lifecycle_scatter(
212
+ author_halfyear_trends: pd.DataFrame,
213
+ author_halfyear_ranges: pd.DataFrame,
214
+ ) -> Line:
215
+ """开发者生命周期趋势图(半年维度提交数)。"""
216
+ if author_halfyear_trends.empty:
217
+ return Line(init_opts=opts.InitOpts(width="100%", height="460px"))
218
+
219
+ if isinstance(author_halfyear_trends.index, pd.MultiIndex):
220
+ labels = [idx[1] for idx in author_halfyear_trends.index]
221
+ half_year_starts = [idx[0] for idx in author_halfyear_trends.index]
222
+ else:
223
+ labels = [str(idx) for idx in author_halfyear_trends.index]
224
+ half_year_starts = [idx for idx in author_halfyear_trends.index]
225
+
226
+ line = Line(init_opts=opts.InitOpts(width="100%", height="500px"))
227
+ line.add_xaxis(labels)
228
+
229
+ for author in author_halfyear_trends.columns:
230
+ series = author_halfyear_trends[author].fillna(0)
231
+ start = None
232
+ end = None
233
+ if not author_halfyear_ranges.empty and author in author_halfyear_ranges.index:
234
+ start = author_halfyear_ranges.loc[author, "first_half_start"]
235
+ end = author_halfyear_ranges.loc[author, "last_half_start"]
236
+
237
+ values: list[int | None] = []
238
+ for dt, val in zip(half_year_starts, series):
239
+ if start is not None and dt < start:
240
+ values.append(None)
241
+ continue
242
+ if end is not None and dt > end:
243
+ values.append(None)
244
+ continue
245
+ values.append(int(val))
246
+
247
+ if all(v is None for v in values):
248
+ continue
249
+ if sum(v for v in values if v is not None) == 0:
250
+ continue
251
+ line.add_yaxis(
252
+ str(author),
253
+ values,
254
+ is_smooth=False,
255
+ is_symbol_show=False,
256
+ linestyle_opts=opts.LineStyleOpts(width=2),
257
+ )
258
+
259
+ line.set_global_opts(
260
+ title_opts=opts.TitleOpts(title="开发者提交趋势 (半年)", pos_left="center"),
261
+ tooltip_opts=opts.TooltipOpts(trigger="axis"),
262
+ legend_opts=opts.LegendOpts(type_="scroll", pos_top="36", pos_left="center"),
263
+ datazoom_opts=[
264
+ opts.DataZoomOpts(
265
+ range_start=0,
266
+ range_end=100,
267
+ pos_bottom="14",
268
+ height=16,
269
+ is_show_detail=False,
270
+ is_show_data_shadow=False,
271
+ )
272
+ ],
273
+ xaxis_opts=opts.AxisOpts(name="时间 (半年)", axislabel_opts=opts.LabelOpts(rotate=30)),
274
+ yaxis_opts=opts.AxisOpts(name="提交数"),
275
+ )
276
+ return line
277
+
278
+
279
+ # ---------------------------------------------------------------------------
280
+ # 5. Commit Rank (Horizontal Bar)
281
+ # ---------------------------------------------------------------------------
282
+
283
+ def build_commit_rank_bar(author_stats: pd.DataFrame) -> Bar:
284
+ """提交排行榜 Top10 — 横向条形图。"""
285
+ if author_stats.empty:
286
+ return Bar(init_opts=opts.InitOpts(width="100%", height="400px"))
287
+
288
+ top = author_stats.sort_values("total_commits", ascending=True).tail(10)
289
+ names = [str(n) for n in top.index]
290
+ values = _to_int_list(top["total_commits"])
291
+ colors = ["#2ecc71" if a else "#95a5a6" for a in top["is_active"]]
292
+
293
+ bar = Bar(init_opts=opts.InitOpts(width="100%", height="400px"))
294
+ bar.add_xaxis(names)
295
+ bar.add_yaxis(
296
+ "提交次数", values,
297
+ label_opts=opts.LabelOpts(position="right"),
298
+ itemstyle_opts=opts.ItemStyleOpts(
299
+ color=JsCode(
300
+ "function(p){var colors="
301
+ + str(colors)
302
+ + ";return colors[p.dataIndex];}"
303
+ )
304
+ ),
305
+ )
306
+ bar.reversal_axis()
307
+ bar.set_global_opts(
308
+ title_opts=opts.TitleOpts(title="提交榜 Top 10", pos_left="center"),
309
+ tooltip_opts=opts.TooltipOpts(trigger="axis", axis_pointer_type="shadow"),
310
+ xaxis_opts=opts.AxisOpts(name="提交次数"),
311
+ yaxis_opts=opts.AxisOpts(
312
+ axislabel_opts=opts.LabelOpts(font_size=11),
313
+ ),
314
+ legend_opts=opts.LegendOpts(pos_bottom="0"),
315
+ )
316
+ return bar
317
+
318
+
319
+ # ---------------------------------------------------------------------------
320
+ # 6. Night Commit Rank (卷王榜)
321
+ # ---------------------------------------------------------------------------
322
+
323
+ def build_night_commit_rank(author_stats: pd.DataFrame) -> Bar:
324
+ """卷王榜 Top10 — 夜间提交排行。"""
325
+ if author_stats.empty:
326
+ return Bar(init_opts=opts.InitOpts(width="100%", height="400px"))
327
+
328
+ top = author_stats.sort_values("night_commits", ascending=True).tail(10)
329
+ names = [str(n) for n in top.index]
330
+ night_vals = _to_int_list(top["night_commits"])
331
+ ratio_vals = _to_float_list(top["night_ratio"] * 100)
332
+ total_vals = _to_int_list(top["total_commits"])
333
+
334
+ bar = Bar(init_opts=opts.InitOpts(width="100%", height="400px"))
335
+ bar.add_xaxis(names)
336
+ bar.add_yaxis(
337
+ "夜间提交数", night_vals,
338
+ label_opts=opts.LabelOpts(position="right"),
339
+ itemstyle_opts=opts.ItemStyleOpts(
340
+ color=JsCode(
341
+ "new echarts.graphic.LinearGradient(0,0,1,0,"
342
+ "[{offset:0,color:'#1a1a4e'},{offset:1,color:'#6c3fa0'}])"
343
+ )
344
+ ),
345
+ )
346
+ bar.reversal_axis()
347
+
348
+ # 夜间占比折线 (使用 extend_axis + overlap)
349
+ bar.extend_axis(
350
+ xaxis=opts.AxisOpts(
351
+ name="占比 %", min_=0, max_=100,
352
+ position="top",
353
+ axislabel_opts=opts.LabelOpts(formatter="{value}%"),
354
+ )
355
+ )
356
+
357
+ line = Line()
358
+ line.add_xaxis(names)
359
+ line.add_yaxis(
360
+ "夜间提交占比",
361
+ ratio_vals,
362
+ xaxis_index=1,
363
+ label_opts=opts.LabelOpts(
364
+ is_show=True,
365
+ formatter=JsCode("function(p){return p.value+'%';}")
366
+ ),
367
+ linestyle_opts=opts.LineStyleOpts(width=2, color="#e040fb"),
368
+ itemstyle_opts=opts.ItemStyleOpts(color="#e040fb"),
369
+ )
370
+ bar.overlap(line)
371
+
372
+ bar.set_global_opts(
373
+ title_opts=opts.TitleOpts(title="卷王榜 Top 10 (20:00-06:00)", pos_left="center"),
374
+ tooltip_opts=opts.TooltipOpts(
375
+ trigger="axis", axis_pointer_type="shadow",
376
+ formatter=JsCode(
377
+ "function(ps){"
378
+ "var r='<b>'+ps[0].name+'</b>';"
379
+ "for(var i=0;i<ps.length;i++){"
380
+ "r+='<br/>'+ps[i].seriesName+': '+ps[i].value;"
381
+ "}return r;}"
382
+ ),
383
+ ),
384
+ xaxis_opts=opts.AxisOpts(name="夜间提交次数"),
385
+ legend_opts=opts.LegendOpts(pos_bottom="0"),
386
+ )
387
+ return bar
388
+
389
+
390
+ # ---------------------------------------------------------------------------
391
+ # 7. Maintenance Rank (分段横向条形图)
392
+ # ---------------------------------------------------------------------------
393
+
394
+ def build_maintenance_rank(author_stats: pd.DataFrame) -> Bar:
395
+ """最长维护榜 Top10。"""
396
+ if author_stats.empty:
397
+ return Bar(init_opts=opts.InitOpts(width="100%", height="400px"))
398
+
399
+ top = author_stats.sort_values("maintenance_days", ascending=True).tail(10)
400
+ names = [str(n) for n in top.index]
401
+
402
+ ref_date = pd.Timestamp.now()
403
+ one_year_ago = ref_date - pd.Timedelta(days=365)
404
+
405
+ recent_days = []
406
+ older_days = []
407
+ for _, row in top.iterrows():
408
+ total = row["maintenance_days"]
409
+ start = row["first_commit"]
410
+ end = row["last_commit"]
411
+ if end < one_year_ago:
412
+ recent_days.append(0)
413
+ older_days.append(int(total))
414
+ else:
415
+ recent_start = max(start, one_year_ago)
416
+ recent = max(0, (end - recent_start).days)
417
+ recent_days.append(int(recent))
418
+ older_days.append(int(total - recent))
419
+
420
+ bar = Bar(init_opts=opts.InitOpts(width="100%", height="400px"))
421
+ bar.add_xaxis(names)
422
+ bar.add_yaxis(
423
+ "往期维护(天)", older_days, stack="stack",
424
+ itemstyle_opts=opts.ItemStyleOpts(color="#b0bec5"),
425
+ label_opts=opts.LabelOpts(is_show=False),
426
+ )
427
+ bar.add_yaxis(
428
+ "近年维护(天)", recent_days, stack="stack",
429
+ itemstyle_opts=opts.ItemStyleOpts(color="#1565c0"),
430
+ label_opts=opts.LabelOpts(
431
+ position="right",
432
+ formatter=JsCode(
433
+ "function(p){"
434
+ "var t=p.value;"
435
+ "for(var i=0;i<p.encode.x.length;i++){t+=0;}"
436
+ "return p.value>0?p.value+'天':'';}"
437
+ ),
438
+ ),
439
+ )
440
+ bar.reversal_axis()
441
+ bar.set_global_opts(
442
+ title_opts=opts.TitleOpts(title="常青树榜 Top 10", pos_left="center"),
443
+ tooltip_opts=opts.TooltipOpts(
444
+ trigger="axis", axis_pointer_type="shadow",
445
+ ),
446
+ xaxis_opts=opts.AxisOpts(name="维护天数"),
447
+ legend_opts=opts.LegendOpts(pos_bottom="0"),
448
+ )
449
+ return bar
450
+
451
+
452
+ # ---------------------------------------------------------------------------
453
+ # 8. Code Activity Trend (双Y轴)
454
+ # ---------------------------------------------------------------------------
455
+
456
+ def build_code_activity_chart(code_activity: pd.DataFrame) -> Bar:
457
+ """代码活动趋势图: 提交折线 + 增删行数柱状图。"""
458
+ if code_activity.empty:
459
+ return Bar(init_opts=opts.InitOpts(width="100%", height="400px"))
460
+
461
+ dates = [d.strftime("%Y-%m") for d in code_activity.index]
462
+
463
+ bar = Bar(init_opts=opts.InitOpts(width="100%", height="400px"))
464
+ bar.add_xaxis(dates)
465
+
466
+ bar.add_yaxis(
467
+ "新增行数",
468
+ _to_int_list(code_activity["insertions"]),
469
+ stack="lines",
470
+ itemstyle_opts=opts.ItemStyleOpts(color="#66bb6a"),
471
+ label_opts=opts.LabelOpts(is_show=False),
472
+ )
473
+ bar.add_yaxis(
474
+ "删除行数",
475
+ _to_int_list(code_activity["deletions"]),
476
+ stack="lines_del",
477
+ itemstyle_opts=opts.ItemStyleOpts(color="#ef5350"),
478
+ label_opts=opts.LabelOpts(is_show=False),
479
+ )
480
+
481
+ # 提交折线 on secondary Y axis
482
+ bar.extend_axis(
483
+ yaxis=opts.AxisOpts(name="提交次数", position="right")
484
+ )
485
+ line = Line()
486
+ line.add_xaxis(dates)
487
+ line.add_yaxis(
488
+ "提交数",
489
+ _to_int_list(code_activity["commits"]),
490
+ yaxis_index=1,
491
+ is_smooth=True,
492
+ linestyle_opts=opts.LineStyleOpts(width=2, color="#42a5f5"),
493
+ itemstyle_opts=opts.ItemStyleOpts(color="#42a5f5"),
494
+ label_opts=opts.LabelOpts(is_show=False),
495
+ )
496
+ bar.overlap(line)
497
+
498
+ bar.set_global_opts(
499
+ title_opts=opts.TitleOpts(title="代码活动趋势", pos_left="center"),
500
+ tooltip_opts=opts.TooltipOpts(trigger="axis", axis_pointer_type="cross"),
501
+ datazoom_opts=[opts.DataZoomOpts(range_start=0, range_end=100)],
502
+ legend_opts=opts.LegendOpts(pos_bottom="0"),
503
+ yaxis_opts=opts.AxisOpts(name="代码行数"),
504
+ )
505
+ return bar
506
+
507
+
508
+ # ---------------------------------------------------------------------------
509
+ # 9. File Heatmap Sunburst
510
+ # ---------------------------------------------------------------------------
511
+
512
+ def build_file_heatmap_sunburst(file_heatmap: list[dict]) -> Sunburst:
513
+ """文件修改热度旭日图。"""
514
+ if not file_heatmap:
515
+ sb = Sunburst(init_opts=opts.InitOpts(width="100%", height="500px"))
516
+ sb.set_global_opts(title_opts=opts.TitleOpts(title="文件修改热度"))
517
+ return sb
518
+
519
+ sunburst = Sunburst(init_opts=opts.InitOpts(width="100%", height="500px"))
520
+ sunburst.add(
521
+ "",
522
+ data_pair=file_heatmap,
523
+ radius=[0, "90%"],
524
+ highlight_policy="ancestor",
525
+ sort_="desc",
526
+ label_opts=opts.LabelOpts(is_show=False),
527
+ levels=[
528
+ {},
529
+ {"r0": "0%", "r1": "20%", "itemStyle": {"borderWidth": 2}},
530
+ {"r0": "20%", "r1": "45%", "itemStyle": {"borderWidth": 1}},
531
+ {"r0": "45%", "r1": "70%", "itemStyle": {"borderWidth": 1}},
532
+ {"r0": "70%", "r1": "92%", "itemStyle": {"borderWidth": 1}},
533
+ ],
534
+ )
535
+ sunburst.set_global_opts(
536
+ title_opts=opts.TitleOpts(title="文件修改热度", pos_left="center"),
537
+ tooltip_opts=opts.TooltipOpts(
538
+ formatter=JsCode(
539
+ "function(p){return '<b>'+p.name+'</b><br/>修改次数: '+(p.value||'');}",
540
+ ),
541
+ ),
542
+ )
543
+ return sunburst
544
+
545
+
546
+ # ---------------------------------------------------------------------------
547
+ # 10. Code Stability Analysis
548
+ # ---------------------------------------------------------------------------
549
+
550
+ def build_code_stability_chart(code_stability: pd.DataFrame) -> Bar:
551
+ """代码稳定性分析 — 季度新增/删除行数趋势。"""
552
+ if code_stability.empty:
553
+ return Bar(init_opts=opts.InitOpts(width="100%", height="380px"))
554
+
555
+ quarters = []
556
+ for d in code_stability.index:
557
+ q = (d.month - 1) // 3 + 1
558
+ quarters.append(f"{d.year}-Q{q}")
559
+
560
+ phase_colors = {"功能开发期": "#66bb6a", "重构期": "#ef5350", "稳定期": "#42a5f5"}
561
+
562
+ bar = Bar(init_opts=opts.InitOpts(width="100%", height="380px"))
563
+ bar.add_xaxis(quarters)
564
+ bar.add_yaxis(
565
+ "新增行数",
566
+ _to_int_list(code_stability["insertions"]),
567
+ itemstyle_opts=opts.ItemStyleOpts(color="#66bb6a"),
568
+ label_opts=opts.LabelOpts(is_show=False),
569
+ )
570
+ bar.add_yaxis(
571
+ "删除行数",
572
+ _to_int_list(code_stability["deletions"]),
573
+ itemstyle_opts=opts.ItemStyleOpts(color="#ef5350"),
574
+ label_opts=opts.LabelOpts(is_show=False),
575
+ )
576
+
577
+ bar.set_global_opts(
578
+ title_opts=opts.TitleOpts(title="代码稳定性分析", pos_left="center"),
579
+ tooltip_opts=opts.TooltipOpts(trigger="axis"),
580
+ datazoom_opts=[opts.DataZoomOpts(range_start=0, range_end=100)],
581
+ legend_opts=opts.LegendOpts(pos_bottom="0"),
582
+ yaxis_opts=opts.AxisOpts(name="代码行数"),
583
+ )
584
+ return bar
585
+
586
+
587
+ # ---------------------------------------------------------------------------
588
+ # 11. Developer Detail Panel (个人分析)
589
+ # ---------------------------------------------------------------------------
590
+
591
+ def build_developer_24h_html_table(df_author: pd.DataFrame) -> str:
592
+ """24小时提交分布表 (HTML)。"""
593
+ hour_counts = [0] * 24
594
+ for _, row in df_author.iterrows():
595
+ h = int(row["hour"])
596
+ hour_counts[h] += 1
597
+
598
+ # 构建 HTML 表格
599
+ html = """
600
+ <div style="width:100%; overflow-x:auto;">
601
+ <table style="width:100%; border-collapse: collapse; text-align: center; font-size: 13px;">
602
+ <thead>
603
+ <tr style="background-color: #f1f5f9; color: #64748b;">
604
+ <th style="padding: 8px; border: 1px solid #e2e8f0;">时段</th>
605
+ <th style="padding: 8px; border: 1px solid #e2e8f0;">提交数</th>
606
+ <th style="padding: 8px; border: 1px solid #e2e8f0;">占比</th>
607
+ <th style="padding: 8px; border: 1px solid #e2e8f0;">时段</th>
608
+ <th style="padding: 8px; border: 1px solid #e2e8f0;">提交数</th>
609
+ <th style="padding: 8px; border: 1px solid #e2e8f0;">占比</th>
610
+ </tr>
611
+ </thead>
612
+ <tbody>
613
+ """
614
+
615
+ total = sum(hour_counts)
616
+ if total == 0:
617
+ total = 1 # avoid div by zero
618
+
619
+ # 双栏显示: 左边 0-11, 右边 12-23
620
+ for i in range(12):
621
+ h1 = i
622
+ c1 = hour_counts[h1]
623
+ p1 = c1 / total * 100
624
+
625
+ h2 = i + 12
626
+ c2 = hour_counts[h2]
627
+ p2 = c2 / total * 100
628
+
629
+ # 热度颜色背景 (简单的透明度)
630
+ bg1 = f"rgba(59, 130, 246, {min(c1/total*5, 0.5):.2f})" if c1 > 0 else "transparent"
631
+ bg2 = f"rgba(59, 130, 246, {min(c2/total*5, 0.5):.2f})" if c2 > 0 else "transparent"
632
+
633
+ html += f"""
634
+ <tr>
635
+ <td style="padding: 6px; border: 1px solid #e2e8f0;">{h1:02d}:00</td>
636
+ <td style="padding: 6px; border: 1px solid #e2e8f0; background-color: {bg1};">{c1}</td>
637
+ <td style="padding: 6px; border: 1px solid #e2e8f0; color: #94a3b8;">{p1:.1f}%</td>
638
+ <td style="padding: 6px; border: 1px solid #e2e8f0;">{h2:02d}:00</td>
639
+ <td style="padding: 6px; border: 1px solid #e2e8f0; background-color: {bg2};">{c2}</td>
640
+ <td style="padding: 6px; border: 1px solid #e2e8f0; color: #94a3b8;">{p2:.1f}%</td>
641
+ </tr>
642
+ """
643
+
644
+ html += """
645
+ </tbody>
646
+ </table>
647
+ </div>
648
+ """
649
+ return html
650
+
651
+
652
+ def build_developer_detail_charts(
653
+ prepared_df: pd.DataFrame,
654
+ author_name: str,
655
+ author_stats: pd.DataFrame,
656
+ ) -> Dict[str, Any]:
657
+ """
658
+ 为指定开发者构建个人面板所需的所有数据和图表。
659
+
660
+ Returns dict with keys: info, hour_table_html
661
+ """
662
+ df_author = prepared_df[prepared_df["author"] == author_name].copy()
663
+
664
+ if df_author.empty:
665
+ return {}
666
+
667
+ # 个人信息
668
+ stats_row = author_stats.loc[author_name] if author_name in author_stats.index else None
669
+ info = {}
670
+ if stats_row is not None:
671
+ info = {
672
+ "name": author_name,
673
+ "email": stats_row.get("email", ""),
674
+ "first_commit": str(stats_row["first_commit"].date()) if pd.notna(stats_row["first_commit"]) else "",
675
+ "last_commit": str(stats_row["last_commit"].date()) if pd.notna(stats_row["last_commit"]) else "",
676
+ "total_commits": int(stats_row["total_commits"]),
677
+ "total_insertions": int(stats_row.get("total_insertions", 0)),
678
+ "total_deletions": int(stats_row.get("total_deletions", 0)),
679
+ "maintenance_days": int(stats_row["maintenance_days"]),
680
+ "is_active": bool(stats_row["is_active"]),
681
+ "night_commits": int(stats_row["night_commits"]),
682
+ "night_ratio": round(float(stats_row["night_ratio"]) * 100, 1),
683
+ }
684
+
685
+ # 24小时表格
686
+ hour_table_html = build_developer_24h_html_table(df_author)
687
+
688
+ return {
689
+ "info": info,
690
+ "hour_table_html": hour_table_html,
691
+ # "calendar" removed
692
+ }
693
+
694
+
695
+ # ---------------------------------------------------------------------------
696
+ # 12. Lifecycle Gantt (Stacked Bar)
697
+ # ---------------------------------------------------------------------------
698
+
699
+ def build_lifecycle_gantt(author_stats: pd.DataFrame) -> Bar:
700
+ """开发者生命周期甘特图 (按首次提交时间排序)。"""
701
+ if author_stats.empty:
702
+ return Bar(init_opts=opts.InitOpts(width="100%", height="500px"))
703
+
704
+ df = author_stats.dropna(subset=["first_commit", "last_commit"]).copy()
705
+ if df.empty:
706
+ return Bar(init_opts=opts.InitOpts(width="100%", height="500px"))
707
+
708
+ # 倒序:让最早开始的人排在最上面 (reversal_axis 后 index 0 在底部)
709
+ df = df.sort_values("first_commit", ascending=False).copy()
710
+
711
+ names = [str(n) for n in df.index]
712
+
713
+ start_times: list[float] = []
714
+ durations: list[float] = []
715
+ end_times: list[float] = []
716
+
717
+ one_day_ms = 24 * 3600 * 1000
718
+
719
+ for _, row in df.iterrows():
720
+ start = row["first_commit"]
721
+ end = row["last_commit"]
722
+
723
+ start_ts = start.timestamp() * 1000
724
+ end_ts = end.timestamp() * 1000
725
+
726
+ duration = max(end_ts - start_ts, one_day_ms)
727
+
728
+ start_times.append(start_ts)
729
+ durations.append(duration)
730
+ end_times.append(start_ts + duration)
731
+
732
+ start_js = json.dumps(start_times)
733
+ end_js = json.dumps(end_times)
734
+
735
+ min_ts = min(start_times)
736
+ max_ts = max(end_times)
737
+ pad_ms = one_day_ms * 7
738
+
739
+ height = max(520, min(900, 28 * len(names) + 120))
740
+
741
+ bar = Bar(init_opts=opts.InitOpts(width="100%", height=f"{height}px"))
742
+ bar.add_xaxis(names)
743
+
744
+ # 辅助透明系列:将条形“推”到起始位置
745
+ bar.add_yaxis(
746
+ "开始时间",
747
+ start_times,
748
+ stack="gantt",
749
+ itemstyle_opts=opts.ItemStyleOpts(color="rgba(0,0,0,0)"),
750
+ label_opts=opts.LabelOpts(is_show=False),
751
+ tooltip_opts=opts.TooltipOpts(is_show=False),
752
+ )
753
+
754
+ # 实际显示系列:持续时间
755
+ bar.add_yaxis(
756
+ "活跃周期",
757
+ durations,
758
+ stack="gantt",
759
+ itemstyle_opts=opts.ItemStyleOpts(color="#73c0de"),
760
+ label_opts=opts.LabelOpts(
761
+ position="right",
762
+ formatter=JsCode(
763
+ "function(p){var days=Math.max(1, Math.round(p.value/86400000));return days+' 天';}"
764
+ ),
765
+ ),
766
+ )
767
+
768
+ bar.reversal_axis()
769
+
770
+ bar.set_global_opts(
771
+ title_opts=opts.TitleOpts(title="开发者生命周期甘特图", pos_left="center"),
772
+ tooltip_opts=opts.TooltipOpts(
773
+ trigger="item",
774
+ formatter=JsCode(
775
+ """
776
+ function(params) {
777
+ if (params.seriesName.indexOf('开始时间') > -1) return '';
778
+ var idx = params.dataIndex;
779
+ var starts = """ + start_js + """;
780
+ var ends = """ + end_js + """;
781
+ var start = new Date(starts[idx]);
782
+ var end = new Date(ends[idx]);
783
+ var days = Math.max(1, Math.round((ends[idx] - starts[idx]) / (24 * 3600 * 1000)));
784
+ function fmt(d) {
785
+ var m = (d.getMonth() + 1).toString().padStart(2, '0');
786
+ var day = d.getDate().toString().padStart(2, '0');
787
+ return d.getFullYear() + '-' + m + '-' + day;
788
+ }
789
+ return '<b>' + params.name + '</b><br/>' +
790
+ '开始: ' + fmt(start) + '<br/>' +
791
+ '结束: ' + fmt(end) + '<br/>' +
792
+ '活跃天数: ' + days + ' 天';
793
+ }
794
+ """
795
+ )
796
+ ),
797
+ xaxis_opts=opts.AxisOpts(
798
+ type_="value",
799
+ name="时间",
800
+ position="top",
801
+ min_=min_ts - pad_ms,
802
+ max_=max_ts + pad_ms,
803
+ axislabel_opts=opts.LabelOpts(
804
+ formatter=JsCode(
805
+ """
806
+ function (value) {
807
+ var d = new Date(value);
808
+ var m = (d.getMonth() + 1).toString().padStart(2, '0');
809
+ return d.getFullYear() + '-' + m;
810
+ }
811
+ """
812
+ )
813
+ ),
814
+ splitline_opts=opts.SplitLineOpts(is_show=True),
815
+ ),
816
+ yaxis_opts=opts.AxisOpts(
817
+ axislabel_opts=opts.LabelOpts(interval=0)
818
+ ),
819
+ legend_opts=opts.LegendOpts(is_show=False),
820
+ datazoom_opts=[
821
+ opts.DataZoomOpts(
822
+ type_="slider",
823
+ orient="horizontal",
824
+ is_show=True,
825
+ pos_bottom="10",
826
+ filter_mode="weakFilter"
827
+ ),
828
+ opts.DataZoomOpts(
829
+ type_="inside",
830
+ orient="horizontal",
831
+ filter_mode="weakFilter"
832
+ ),
833
+ # Vertical zoom for scrolling through many authors
834
+ opts.DataZoomOpts(
835
+ type_="slider",
836
+ orient="vertical",
837
+ is_show=True,
838
+ pos_right="10",
839
+ filter_mode="empty"
840
+ ),
841
+ opts.DataZoomOpts(
842
+ type_="inside",
843
+ orient="vertical",
844
+ filter_mode="empty"
845
+ )
846
+ ]
847
+ )
848
+
849
+ return bar