[OCaml] Mobile-friendly clone of cgit.
feat SVG commit frequency chart on repo summary page
Introduce lib/charts.ml for non-interactive SVG chart generation. The repo summary page now displays a bar chart of daily commit frequency over the past 30 days, replacing the previous commit list. The chart auto-scales vertically, shows gridlines, and labels every 7th day on the x-axis. Add Resolvers.Commit.since to fetch all commits after a timestamp cutoff, stopping traversal once commits are too old.
Changed files
lib/charts.ml
@@ -0,0 +1,131 @@
1
Added:
(* -*- mode: tuareg; -*- *)
2
Added:
3
Added:
(** Non-interactive SVG chart generation for embedding in HTML pages. *)
4
Added:
5
Added:
open Dream_html
6
Added:
7
Added:
let rect = std_tag "rect"
8
Added:
let text_ = std_tag "text"
9
Added:
let g = std_tag "g"
10
Added:
let line = std_tag "line"
11
Added:
let x = string_attr "x"
12
Added:
let y = string_attr "y"
13
Added:
let width_ = string_attr "width"
14
Added:
let height_ = string_attr "height"
15
Added:
let rx = string_attr "rx"
16
Added:
let x1 = string_attr "x1"
17
Added:
let y1 = string_attr "y1"
18
Added:
let x2 = string_attr "x2"
19
Added:
let y2 = string_attr "y2"
20
Added:
let text_anchor = string_attr "text-anchor"
21
Added:
let font_size = string_attr "font-size"
22
Added:
let int_s = string_of_int
23
Added:
let float_s f = Printf.sprintf "%.1f" f
24
Added:
25
Added:
(** Render a bar chart of daily commit frequency.
26
Added:
27
Added:
[values] is a list of [(label, count)] pairs representing consecutive days.
28
Added:
The chart auto-scales vertically to the maximum count. *)
29
Added:
let commit_frequency ~chart_width ~chart_height values =
30
Added:
let n = List.length values in
31
Added:
if n = 0 then HTML.null []
32
Added:
else
33
Added:
let max_count =
34
Added:
List.fold_left (fun acc (_, count) -> max acc count) 0 values
35
Added:
in
36
Added:
let max_count = max max_count 1 in
37
Added:
let padding_top = 20 in
38
Added:
let padding_bottom = 30 in
39
Added:
let padding_left = 30 in
40
Added:
let padding_right = 10 in
41
Added:
let plot_width = chart_width - padding_left - padding_right in
42
Added:
let plot_height = chart_height - padding_top - padding_bottom in
43
Added:
let bar_width = float_of_int plot_width /. float_of_int n in
44
Added:
let gap = bar_width *. 0.2 in
45
Added:
let bar_actual = bar_width -. gap in
46
Added:
(* Y-axis gridlines *)
47
Added:
let grid_lines =
48
Added:
let steps = min 4 max_count in
49
Added:
List.init (steps + 1) (fun i ->
50
Added:
let value = max_count * i / steps in
51
Added:
let y_pos =
52
Added:
padding_top + plot_height - (plot_height * value / max_count)
53
Added:
in
54
Added:
[
55
Added:
line
56
Added:
[
57
Added:
x1 "%s" (int_s padding_left);
58
Added:
y1 "%s" (int_s y_pos);
59
Added:
x2 "%s" (int_s (chart_width - padding_right));
60
Added:
y2 "%s" (int_s y_pos);
61
Added:
SVG.stroke "#333";
62
Added:
SVG.stroke_width "1";
63
Added:
]
64
Added:
[];
65
Added:
text_
66
Added:
[
67
Added:
x "%s" (int_s (padding_left - 5));
68
Added:
y "%s" (int_s (y_pos + 4));
69
Added:
text_anchor "end";
70
Added:
font_size "10";
71
Added:
SVG.fill "#888";
72
Added:
]
73
Added:
[ txt "%d" value ];
74
Added:
])
75
Added:
|> List.concat
76
Added:
in
77
Added:
(* Bars *)
78
Added:
let bars =
79
Added:
List.mapi
80
Added:
(fun i (_, count) ->
81
Added:
let bar_height =
82
Added:
if max_count = 0 then 0 else plot_height * count / max_count
83
Added:
in
84
Added:
let bx =
85
Added:
float_of_int padding_left
86
Added:
+. (float_of_int i *. bar_width)
87
Added:
+. (gap /. 2.0)
88
Added:
in
89
Added:
let by = padding_top + plot_height - bar_height in
90
Added:
rect
91
Added:
[
92
Added:
x "%s" (float_s bx);
93
Added:
y "%s" (int_s by);
94
Added:
width_ "%s" (float_s bar_actual);
95
Added:
height_ "%s" (int_s bar_height);
96
Added:
rx "2";
97
Added:
SVG.fill "rgb(194, 79, 30)";
98
Added:
]
99
Added:
[])
100
Added:
values
101
Added:
in
102
Added:
(* X-axis labels — show every 7th day *)
103
Added:
let labels =
104
Added:
List.mapi
105
Added:
(fun i (label, _) ->
106
Added:
if i mod 7 = 0 || i = n - 1 then
107
Added:
let lx =
108
Added:
float_of_int padding_left
109
Added:
+. (float_of_int i *. bar_width)
110
Added:
+. (bar_width /. 2.0)
111
Added:
in
112
Added:
text_
113
Added:
[
114
Added:
x "%s" (float_s lx);
115
Added:
y "%s" (int_s (chart_height - 5));
116
Added:
text_anchor "middle";
117
Added:
font_size "10";
118
Added:
SVG.fill "#888";
119
Added:
]
120
Added:
[ txt "%s" label ]
121
Added:
else HTML.null [])
122
Added:
values
123
Added:
in
124
Added:
SVG.(
125
Added:
svg
126
Added:
[
127
Added:
viewbox ~min_x:0 ~min_y:0 ~width:chart_width ~height:chart_height;
128
Added:
xmlns;
129
Added:
HTML.class_ "chart-commit-frequency";
130
Added:
]
131
Added:
[ g [] grid_lines; g [] bars; g [] labels ])
lib/handlers.ml
@@ -123,12 +123,38 @@
123
123
with_repository config name (fun repository context ->
124
124
handler repository context id)
125
125
126
Removed:
let summary config repository context =
127
Removed:
let* commits =
128
Removed:
Resolvers.Commit.recent repository config.Config.commits_max_displayed
126
Added:
let summary _config repository context =
127
Added:
let now = Unix.time () in
128
Added:
let days = 30 in
129
Added:
let seconds_per_day = 86400.0 in
130
Added:
let cutoff =
131
Added:
Int64.of_float (now -. (float_of_int days *. seconds_per_day))
129
132
in
133
Added:
let* commits = Resolvers.Commit.since repository cutoff in
130
134
let* readme = Resolvers.Repo.readme repository in
131
Removed:
Views.Repo.summary context ?readme commits
135
Added:
(* Group commits by day — build a count for each of the past 30 days *)
136
Added:
let day_of_timestamp ts =
137
Added:
let t = Int64.to_float ts in
138
Added:
let tm = Unix.localtime t in
139
Added:
(tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday)
140
Added:
in
141
Added:
let frequency =
142
Added:
List.init days (fun i ->
143
Added:
let t = now -. (float_of_int (days - 1 - i) *. seconds_per_day) in
144
Added:
let tm = Unix.localtime t in
145
Added:
let key = (tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday) in
146
Added:
let label = Printf.sprintf "%02d/%02d" (tm.tm_mon + 1) tm.tm_mday in
147
Added:
let count =
148
Added:
List.length
149
Added:
(List.filter
150
Added:
(fun (c : Resolvers.Commit.t) ->
151
Added:
let ts, _ = c.author.Git.User.date in
152
Added:
day_of_timestamp ts = key)
153
Added:
commits)
154
Added:
in
155
Added:
(label, count))
156
Added:
in
157
Added:
Views.Repo.summary context ?readme frequency
132
158
133
159
let commit_matches ?filter_type ?author ?committer
134
160
(commit : Resolvers.Commit.t) =
lib/resolvers.ml
@@ -349,6 +349,46 @@
349
349
350
350
let recent repository count =
351
351
recent_matching repository count (Fun.const true)
352
Added:
353
Added:
let since repository cutoff =
354
Added:
let predicate (commit : t) =
355
Added:
let ts, _ = commit.author.Git.User.date in
356
Added:
Int64.compare ts cutoff >= 0
357
Added:
in
358
Added:
(* Walk up to 1000 commits; stop collecting once we pass the cutoff *)
359
Added:
let* head_hash = resolve_head_hash repository in
360
Added:
let module S = Set.Make (String) in
361
Added:
let rec walk collected seen queue =
362
Added:
match queue with
363
Added:
| [] -> Lwt_result.return (List.rev collected)
364
Added:
| (_, h) :: rest ->
365
Added:
if S.mem h seen then walk collected seen rest
366
Added:
else
367
Added:
let seen = S.add h seen in
368
Added:
let* commit = of_id repository h in
369
Added:
let ts, _ = commit.author.Git.User.date in
370
Added:
if Int64.compare ts cutoff < 0 then
371
Added:
(* This commit is too old; don't enqueue its parents *)
372
Added:
walk collected seen rest
373
Added:
else
374
Added:
let new_queue =
375
Added:
List.filter_map
376
Added:
(fun p ->
377
Added:
if S.mem p seen then None
378
Added:
else Some (commit.author.Git.User.date, p))
379
Added:
commit.parents
380
Added:
in
381
Added:
let queue =
382
Added:
List.merge
383
Added:
(fun ((a_ts, _), _) ((b_ts, _), _) -> Int64.compare b_ts a_ts)
384
Added:
rest new_queue
385
Added:
in
386
Added:
let collected =
387
Added:
if predicate commit then commit :: collected else collected
388
Added:
in
389
Added:
walk collected seen queue
390
Added:
in
391
Added:
walk [] S.empty [ ((Int64.max_int, None), Store.Hash.to_hex head_hash) ]
352
392
end
353
393
354
394
module Reference = struct
lib/resolvers.mli
@@ -67,6 +67,7 @@
67
67
repository -> int -> (t -> bool) -> (t list, error) Lwt_result.t
68
68
69
69
val recent : repository -> int -> (t list, error) Lwt_result.t
70
Added:
val since : repository -> int64 -> (t list, error) Lwt_result.t
70
71
end
71
72
72
73
(** {1 References} *)
lib/static/styles.css
@@ -440,6 +440,18 @@
440
440
display: none;
441
441
}
442
442
443
Added:
/* Chart container */
444
Added:
.chart-container {
445
Added:
width: 100%;
446
Added:
overflow-x: auto;
447
Added:
}
448
Added:
449
Added:
.chart-commit-frequency {
450
Added:
width: 100%;
451
Added:
height: auto;
452
Added:
display: block;
453
Added:
}
454
Added:
443
455
@media (min-width: 960px) {
444
456
.summary-layout {
445
457
display: grid;
lib/views/repo.ml
@@ -485,17 +485,19 @@
485
485
];
486
486
])
487
487
488
Removed:
let summary context ?readme commits =
489
Removed:
let commits_section =
488
Added:
let summary context ?readme frequency =
489
Added:
let chart_section =
490
490
HTML.
491
491
[
492
Removed:
h3 [] [ txt "Latest commits" ];
493
Removed:
ul [ id "commit-list" ] (List.map (li_of_commit context.repo) commits);
494
Removed:
p
495
Removed:
[ class_ "summary-more" ]
492
Added:
h3 [] [ txt "Commit activity (past 30 days)" ];
493
Added:
div
494
Added:
[ class_ "chart-container" ]
496
495
[
497
Removed:
Routes.link_to (Commits context.repo) (txt "Show previous commits");
496
Added:
Charts.commit_frequency ~chart_width:600 ~chart_height:200 frequency;
498
497
];
498
Added:
p
499
Added:
[ class_ "summary-more" ]
500
Added:
[ Routes.link_to (Commits context.repo) (txt "View all commits") ];
499
501
]
500
502
in
501
503
let readme_section =
@@ -531,7 +533,7 @@
531
533
div
532
534
[ class_ "summary-layout" ]
533
535
[
534
Removed:
div [ class_ "summary-commits" ] commits_section;
536
Added:
div [ class_ "summary-commits" ] chart_section;
535
537
div [ class_ "summary-readme" ] [ readme_section ];
536
538
];
537
539
]