1- """Handler for the BiGGr front page."""
1+ """Handlers for the BiGGr front page and statistics page."""
22
33from biggr_models .handlers import utils
4- from biggr_models .queries .summary_queries import get_summary_counts
4+ from biggr_models .queries .summary_queries import (
5+ get_summary_counts ,
6+ get_summary_history ,
7+ )
8+
9+
10+ def build_model_chart (points , width = 720 , height = 320 , pad_left = 64 , pad = 32 ):
11+ """Turn [{"date": ..., "count": ...}, ...] into SVG geometry for one line.
12+
13+ Returns None when there are fewer than two points, so the template can show
14+ the cold-start message instead of an axis with nothing on it.
15+
16+ The y axis starts at zero: scaling to the minimum would turn a small
17+ absolute change into a dramatic climb, which is the kind of misleading
18+ chart this page exists to avoid.
19+ """
20+ if not points or len (points ) < 2 :
21+ return None
22+
23+ counts = [p ["count" ] for p in points ]
24+ dates = [p ["date" ] for p in points ]
25+
26+ plot_w = width - pad_left - pad
27+ plot_h = height - 2 * pad
28+
29+ top = _nice_ceiling (max (counts ))
30+ span = (dates [- 1 ] - dates [0 ]).total_seconds () or 1
31+
32+ def x_of (date ):
33+ return pad_left + plot_w * (date - dates [0 ]).total_seconds () / span
34+
35+ def y_of (count ):
36+ return pad + plot_h * (1 - count / top )
37+
38+ coords = [(x_of (d ), y_of (c )) for d , c in zip (dates , counts )]
39+
40+ return {
41+ "width" : width ,
42+ "height" : height ,
43+ "plot_left" : pad_left ,
44+ "plot_right" : pad_left + plot_w ,
45+ "plot_top" : pad ,
46+ "plot_bottom" : pad + plot_h ,
47+ "polyline" : " " .join (f"{ x :.1f} ,{ y :.1f} " for x , y in coords ),
48+ # A filled area under the line reads as "accumulated total" far more
49+ # clearly than a bare stroke.
50+ "area" : "{} {} {}" .format (
51+ f"{ coords [0 ][0 ]:.1f} ,{ pad + plot_h :.1f} " ,
52+ " " .join (f"{ x :.1f} ,{ y :.1f} " for x , y in coords ),
53+ f"{ coords [- 1 ][0 ]:.1f} ,{ pad + plot_h :.1f} " ,
54+ ),
55+ "dots" : [
56+ {"x" : x , "y" : y , "count" : c , "date" : d }
57+ for (x , y ), c , d in zip (coords , counts , dates )
58+ ],
59+ "y_ticks" : [
60+ {"value" : v , "y" : y_of (v )} for v in _tick_values (top )
61+ ],
62+ "x_ticks" : [
63+ {"label" : dates [0 ].strftime ("%b %Y" ), "x" : x_of (dates [0 ])},
64+ {"label" : dates [- 1 ].strftime ("%b %Y" ), "x" : x_of (dates [- 1 ])},
65+ ],
66+ "first" : {"count" : counts [0 ], "date" : dates [0 ]},
67+ "last" : {"count" : counts [- 1 ], "date" : dates [- 1 ]},
68+ "added" : counts [- 1 ] - counts [0 ],
69+ }
70+
71+
72+ def _nice_ceiling (value , divisions = 4 ):
73+ """Round up to an axis maximum that divides into readable ticks.
74+
75+ Rounds the tick *step* rather than the maximum, so the ticks land on round
76+ numbers while the top stays close to the data: 4881 gives 1250-steps and a
77+ top of 5000, not 8000, which would leave the line hugging the floor.
78+ """
79+ if value <= 0 :
80+ return divisions
81+ # Try every round step across the plausible magnitudes and keep the
82+ # smallest top that still covers the data, so the line fills the plot.
83+ candidates = []
84+ magnitude = 1
85+ while magnitude <= max (value , 1 ):
86+ for multiple in (1 , 1.25 , 2 , 2.5 , 5 ):
87+ step = magnitude * multiple
88+ top = step * divisions
89+ # Keep only steps that give whole-number tick labels.
90+ if top >= value and float (step ).is_integer ():
91+ candidates .append (int (top ))
92+ magnitude *= 10
93+ return min (candidates ) if candidates else int (value )
94+
95+
96+ def _tick_values (top , count = 4 ):
97+ """Evenly spaced y-axis values from 0 to top, inclusive."""
98+ step = top / count
99+ return [int (round (step * i )) for i in range (count + 1 )]
5100
6101
7102class HomeHandler (utils .BaseHandler ):
@@ -13,4 +108,22 @@ def get(self):
13108 counts = utils .do_safe_query (get_summary_counts )
14109 # Wrap in a dict so the result stays truthy even when no counts are
15110 # available; return_result skips the context entirely on a falsy result.
16- self .return_result ({"counts" : counts })
111+ self .return_result ({"counts" : counts })
112+
113+
114+ class StatisticsHandler (utils .BaseHandler ):
115+ """Database growth over time."""
116+
117+ template = utils .env .get_template ("statistics.html" )
118+
119+ def get (self ):
120+ history = utils .do_safe_query (get_summary_history )
121+ model_points = history .get ("models" , [])
122+ self .return_result (
123+ {
124+ "history" : history ,
125+ "model_points" : model_points ,
126+ "chart" : build_model_chart (model_points ),
127+ "breadcrumbs" : [("Home" , "/" ), ("Statistics" , "/statistics" )],
128+ }
129+ )
0 commit comments