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/__init__.py +7 -0
- gitinsight/__main__.py +92 -0
- gitinsight/analysis.py +541 -0
- gitinsight/charts.py +849 -0
- gitinsight/dashboard.py +622 -0
- gitinsight/git_reader.py +225 -0
- gitinsight/report.py +26 -0
- gitinsight_cli-0.1.0.dist-info/METADATA +152 -0
- gitinsight_cli-0.1.0.dist-info/RECORD +11 -0
- gitinsight_cli-0.1.0.dist-info/WHEEL +4 -0
- gitinsight_cli-0.1.0.dist-info/entry_points.txt +2 -0
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
|